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.
174 lines
6.2 KiB
174 lines
6.2 KiB
import { METADATA_BATCH_QUERY_EOSE_TIMEOUT_MS, METADATA_BATCH_QUERY_GLOBAL_TIMEOUT_MS } from '@/constants' |
|
import { normalizeHexPubkey } from '@/lib/pubkey' |
|
import { normalizeAnyRelayUrl } from '@/lib/url' |
|
import client from '@/services/client.service' |
|
import type { TPersonalListBech32Ref } from '@/lib/personal-list-mutations' |
|
import type { Event } from 'nostr-tools' |
|
|
|
/** |
|
* REQ across relays with {@link replaceableRace}, then keep the newest `created_at` row for this author+kind. |
|
* Use before appending to pin / bookmark / follow / mute / interest lists so merges don’t drop remote state. |
|
*/ |
|
export async function fetchLatestReplaceableListEvent( |
|
pubkeyHex: string, |
|
kind: number, |
|
relayUrls: string[] |
|
): Promise<Event | undefined> { |
|
const pk = normalizeHexPubkey(pubkeyHex) |
|
const allUrls = [...new Set(relayUrls.map((u) => normalizeAnyRelayUrl(u) || u).filter(Boolean))] |
|
if (!allUrls.length) return undefined |
|
|
|
// client.fetchEvents() handles both HTTP index relays and WebSocket relays internally. |
|
const rows = await client.fetchEvents(allUrls, { authors: [pk], kinds: [kind], limit: 80 }, { |
|
replaceableRace: true, |
|
eoseTimeout: METADATA_BATCH_QUERY_EOSE_TIMEOUT_MS, |
|
globalTimeout: METADATA_BATCH_QUERY_GLOBAL_TIMEOUT_MS |
|
}) |
|
|
|
if (!rows.length) return undefined |
|
return rows.reduce((best, e) => (e.created_at > best.created_at ? e : best)) |
|
} |
|
|
|
/** |
|
* Kind 10001 from browsing relays can be stale vs the copy resolved via the author’s relay set |
|
* ({@link client.fetchPinListEvent}). Merge both and keep the newest `created_at` so pin UI and merges |
|
* match the profile pin list. |
|
*/ |
|
export async function fetchNewestPinListForPubkey( |
|
pubkeyHex: string, |
|
relayUrls: string[] |
|
): Promise<Event | undefined> { |
|
const pk = normalizeHexPubkey(pubkeyHex) |
|
const [fromRelays, fromService] = await Promise.all([ |
|
relayUrls.length |
|
? fetchLatestReplaceableListEvent(pk, 10001, relayUrls) |
|
: Promise.resolve(undefined), |
|
client.fetchPinListEvent(pk).catch(() => undefined) |
|
]) |
|
if (!fromRelays) return fromService |
|
if (!fromService) return fromRelays |
|
return fromService.created_at >= fromRelays.created_at ? fromService : fromRelays |
|
} |
|
|
|
/** Whether this event is referenced by the pin list via `e` (hex id) or `a` (NIP-33 coordinate). */ |
|
export function isEventInPinList(pinList: Event, event: Event): boolean { |
|
const idLower = event.id.toLowerCase() |
|
const d = event.tags.find((t) => t[0] === 'd')?.[1] ?? '' |
|
const coord = `${event.kind}:${event.pubkey}:${d}`.toLowerCase() |
|
for (const tag of pinList.tags) { |
|
if (tag[0] === 'e' && tag[1] && tag[1].toLowerCase() === idLower) return true |
|
if (tag[0] === 'a' && tag[1] && tag[1].toLowerCase() === coord) return true |
|
} |
|
return false |
|
} |
|
|
|
function orderedUniqueEHexIds(tags: string[][]): string[] { |
|
const seen = new Set<string>() |
|
const out: string[] = [] |
|
for (const t of tags) { |
|
if (t[0] === 'e' && t[1] && /^[0-9a-f]{64}$/i.test(t[1])) { |
|
const id = t[1].toLowerCase() |
|
if (!seen.has(id)) { |
|
seen.add(id) |
|
out.push(id) |
|
} |
|
} |
|
} |
|
return out |
|
} |
|
|
|
/** |
|
* Next pin list (kind 10001) tags: preserve non-`e`/`a` tags and `a` pins, merge `e` hex ids with dedupe. |
|
* Unpin removes both the `e` id and an `a` coordinate when the list used NIP-33 pins. |
|
*/ |
|
export function buildPinListTagsAfterToggle( |
|
latest: Event | null | undefined, |
|
targetEvent: Event, |
|
shouldPin: boolean |
|
): string[][] { |
|
const tags = latest?.tags ?? [] |
|
const meta = tags.filter((t) => t[0] !== 'e' && t[0] !== 'a') |
|
const d = targetEvent.tags.find((t) => t[0] === 'd')?.[1] ?? '' |
|
const coord = `${targetEvent.kind}:${targetEvent.pubkey}:${d}`.toLowerCase() |
|
let aKeep = tags.filter((t) => t[0] === 'a' && t[1]) |
|
if (!shouldPin) { |
|
aKeep = aKeep.filter((t) => t[1]!.toLowerCase() !== coord) |
|
} |
|
let eIds = orderedUniqueEHexIds(tags) |
|
const id = targetEvent.id.toLowerCase() |
|
if (shouldPin) { |
|
if (!eIds.includes(id)) eIds = [...eIds, id] |
|
} else { |
|
eIds = eIds.filter((x) => x !== id) |
|
} |
|
return [...meta, ...aKeep, ...eIds.map((eid) => ['e', eid] as string[])] |
|
} |
|
|
|
/** |
|
* Pin list tags after removing an entry identified only by nevent/note id and/or naddr coordinate |
|
* (when the pinned event is not loaded). Returns null if nothing matched. |
|
*/ |
|
export function buildPinListTagsAfterRemovingRef( |
|
tags: string[][], |
|
ref: TPersonalListBech32Ref |
|
): string[][] | null { |
|
if (!ref.eIdLower && !ref.aCoordLower) return null |
|
const meta = tags.filter((t) => t[0] !== 'e' && t[0] !== 'a') |
|
let aKeep = tags.filter((t) => t[0] === 'a' && t[1]) |
|
const origALen = aKeep.length |
|
if (ref.aCoordLower) { |
|
aKeep = aKeep.filter((t) => t[1]!.toLowerCase() !== ref.aCoordLower) |
|
} |
|
let eIds = orderedUniqueEHexIds(tags) |
|
const origELen = eIds.length |
|
if (ref.eIdLower) { |
|
eIds = eIds.filter((x) => x !== ref.eIdLower) |
|
} |
|
if (aKeep.length === origALen && eIds.length === origELen) return null |
|
return [...meta, ...aKeep, ...eIds.map((eid) => ['e', eid] as string[])] |
|
} |
|
|
|
/** Dedupe `p` tags (case-insensitive hex), preserve other tags and first-seen `p` casing. */ |
|
function dedupePTags(tags: string[][]): string[][] { |
|
const nonP = tags.filter((t) => t[0] !== 'p') |
|
const seen = new Set<string>() |
|
const pOut: string[][] = [] |
|
for (const t of tags) { |
|
if (t[0] === 'p' && t[1]) { |
|
const k = t[1].toLowerCase() |
|
if (!seen.has(k)) { |
|
seen.add(k) |
|
pOut.push(['p', t[1]]) |
|
} |
|
} |
|
} |
|
return [...nonP, ...pOut] |
|
} |
|
|
|
/** Append `p` pubkey if missing; dedupe all `p` tags. */ |
|
export function dedupePTagsAppendPubkey(tags: string[][], pubkey: string): string[][] { |
|
const pk = pubkey.toLowerCase() |
|
const nonP = tags.filter((t) => t[0] !== 'p') |
|
const seen = new Set<string>() |
|
const pOut: string[][] = [] |
|
for (const t of tags) { |
|
if (t[0] === 'p' && t[1]) { |
|
const k = t[1].toLowerCase() |
|
if (!seen.has(k)) { |
|
seen.add(k) |
|
pOut.push(['p', t[1]]) |
|
} |
|
} |
|
} |
|
if (!seen.has(pk)) { |
|
pOut.push(['p', pubkey]) |
|
} |
|
return [...nonP, ...pOut] |
|
} |
|
|
|
/** Remove every `p` tag matching pubkey (case-insensitive); dedupe remaining `p` tags. */ |
|
export function removePubkeyFromPTags(tags: string[][], pubkey: string): string[][] { |
|
const pk = pubkey.toLowerCase() |
|
const filtered = tags.filter((t) => !(t[0] === 'p' && t[1]?.toLowerCase() === pk)) |
|
return dedupePTags(filtered) |
|
}
|
|
|