You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
187 lines
5.2 KiB
187 lines
5.2 KiB
import { normalizeAnyRelayUrl } from '@/lib/url' |
|
import type { TFeedSubRequest, TSubRequestFilter } from '@/types' |
|
import type { Filter } from 'nostr-tools' |
|
|
|
export type FeedExecutionMode = 'live' | 'one-shot' |
|
export type FeedSurface = |
|
| 'home' |
|
| 'favorites' |
|
| 'relay' |
|
| 'relay-set' |
|
| 'profile' |
|
| 'profile-media' |
|
| 'profile-publications' |
|
| 'spells' |
|
| 'notifications' |
|
| 'replies' |
|
| 'thread' |
|
| 'embed' |
|
| 'search' |
|
| 'hashtag' |
|
| 'calendar' |
|
| 'relay-reviews' |
|
| 'bookmarks' |
|
| 'pins' |
|
| 'interests' |
|
| 'custom' |
|
|
|
export type FeedViewPolicy = { |
|
showKinds?: readonly number[] |
|
clientSideKindFilter?: boolean |
|
includeReplies?: boolean |
|
gridLayout?: boolean |
|
} |
|
|
|
export type FeedSourcePolicy = { |
|
cache?: 'disabled' | 'stale-while-refresh' | 'fresh-required' |
|
publicReadFallback?: boolean |
|
preserveRowsOnRelayChange?: boolean |
|
} |
|
|
|
export type FeedPaginationPolicy = { |
|
enabled: boolean |
|
pageSize?: number |
|
} |
|
|
|
export type FeedDescriptor = { |
|
surface: FeedSurface |
|
id: string |
|
mode: FeedExecutionMode |
|
requests: readonly TFeedSubRequest[] |
|
view: FeedViewPolicy |
|
source: FeedSourcePolicy |
|
pagination: FeedPaginationPolicy |
|
key: string |
|
} |
|
|
|
export type FeedDescriptorInput = { |
|
surface: FeedSurface |
|
id?: string |
|
mode?: FeedExecutionMode |
|
requests: readonly TFeedSubRequest[] |
|
view?: FeedViewPolicy |
|
source?: FeedSourcePolicy |
|
pagination?: Partial<FeedPaginationPolicy> |
|
} |
|
|
|
type Jsonish = string | number | boolean | null | Jsonish[] | { [key: string]: Jsonish } |
|
|
|
function normalizeScalar(value: unknown): Jsonish { |
|
if (value == null) return null |
|
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') return value |
|
return String(value) |
|
} |
|
|
|
function stableValue(value: unknown): Jsonish { |
|
if (Array.isArray(value)) { |
|
return value.map(normalizeScalar).sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))) |
|
} |
|
if (value && typeof value === 'object') { |
|
const out: Record<string, Jsonish> = {} |
|
for (const [k, v] of Object.entries(value).sort(([a], [b]) => a.localeCompare(b))) { |
|
out[k] = stableValue(v) |
|
} |
|
return out |
|
} |
|
return normalizeScalar(value) |
|
} |
|
|
|
export function canonicalFeedFilter(filter: Omit<Filter, 'since' | 'until'> | TSubRequestFilter): Jsonish { |
|
return stableValue(filter) |
|
} |
|
|
|
export function canonicalRelayUrls(urls: readonly string[]): string[] { |
|
return Array.from( |
|
new Set( |
|
urls |
|
.map((u) => normalizeAnyRelayUrl(u) || u.trim()) |
|
.filter(Boolean) |
|
.map((u) => u.toLowerCase()) |
|
) |
|
).sort((a, b) => a.localeCompare(b)) |
|
} |
|
|
|
export function canonicalFeedRequests(requests: readonly TFeedSubRequest[]): Jsonish { |
|
return requests |
|
.map((req) => ({ |
|
urls: canonicalRelayUrls(req.urls), |
|
filter: canonicalFeedFilter(req.filter), |
|
reasonLabel: req.reasonLabel ?? null, |
|
reasonLabelIfSeenOnRelay: req.reasonLabelIfSeenOnRelay |
|
? (normalizeAnyRelayUrl(req.reasonLabelIfSeenOnRelay) || req.reasonLabelIfSeenOnRelay.trim()).toLowerCase() |
|
: null |
|
})) |
|
.sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))) |
|
} |
|
|
|
export function buildFeedDescriptorKey(input: Omit<FeedDescriptorInput, 'id'> & { id: string }): string { |
|
return JSON.stringify({ |
|
surface: input.surface, |
|
id: input.id, |
|
mode: input.mode ?? 'live', |
|
requests: canonicalFeedRequests(input.requests), |
|
view: stableValue(input.view ?? {}), |
|
source: stableValue(input.source ?? {}), |
|
pagination: stableValue({ |
|
enabled: input.pagination?.enabled ?? (input.mode ?? 'live') === 'live', |
|
pageSize: input.pagination?.pageSize ?? null |
|
}) |
|
}) |
|
} |
|
|
|
export function createFeedDescriptor(input: FeedDescriptorInput): FeedDescriptor { |
|
const id = input.id ?? input.surface |
|
const mode = input.mode ?? 'live' |
|
const pagination = { |
|
enabled: input.pagination?.enabled ?? mode === 'live', |
|
pageSize: input.pagination?.pageSize |
|
} |
|
const key = buildFeedDescriptorKey({ ...input, id, mode, pagination }) |
|
return { |
|
surface: input.surface, |
|
id, |
|
mode, |
|
requests: input.requests, |
|
view: input.view ?? {}, |
|
source: input.source ?? {}, |
|
pagination, |
|
key |
|
} |
|
} |
|
|
|
export function legacyFeedSubscriptionKey(requests: readonly TFeedSubRequest[]): string { |
|
return JSON.stringify(canonicalFeedRequests(requests)) |
|
} |
|
|
|
export function stableFeedKindKey(kinds: readonly number[] | undefined): string { |
|
if (!kinds?.length) return '' |
|
return JSON.stringify([...kinds].sort((a, b) => a - b)) |
|
} |
|
|
|
export type FeedSessionSnapshotKeyInput = { |
|
feedKey: string |
|
homeSurface?: string |
|
allowKindlessRelayExplore?: boolean |
|
showAllKinds?: boolean |
|
kindsKey?: string |
|
showKind1OPs?: boolean |
|
showKind1Replies?: boolean |
|
showKind1111?: boolean |
|
seeAllFeedEvents?: boolean |
|
} |
|
|
|
export function buildFeedSessionSnapshotKey(input: FeedSessionSnapshotKeyInput): string { |
|
return JSON.stringify({ |
|
feed: input.feedKey, |
|
...(input.homeSurface ? { homeSurface: input.homeSurface } : {}), |
|
...(input.allowKindlessRelayExplore |
|
? { relayKindless: true, showAllKinds: input.showAllKinds } |
|
: { |
|
kinds: input.kindsKey ?? '', |
|
op: input.showKind1OPs, |
|
rep: input.showKind1Replies, |
|
c1111: input.showKind1111, |
|
seeAll: input.seeAllFeedEvents |
|
}) |
|
}) |
|
}
|
|
|