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.
204 lines
6.0 KiB
204 lines
6.0 KiB
/** |
|
* Merge tags from any fetched Nostr event into NIP-A7 spell draft fields (best-effort). |
|
*/ |
|
|
|
import type { TSpellDraftParams } from '@/lib/draft-event' |
|
import { isValidPubkey } from '@/lib/pubkey' |
|
import { isWebsocketUrl, normalizeUrl } from '@/lib/url' |
|
import type { Event } from 'nostr-tools' |
|
import type { Filter } from 'nostr-tools' |
|
import { queryService } from '@/services/client.service' |
|
|
|
const HEX64 = /^[0-9a-f]{64}$/i |
|
|
|
/** Metadata tags on list events — not mapped to spell filters. */ |
|
const LIST_METADATA_TAGS = new Set([ |
|
'd', |
|
'title', |
|
'image', |
|
'description', |
|
'client', |
|
'alt', |
|
'expiration', |
|
'relay' // handled separately below |
|
]) |
|
|
|
/** Tags we explicitly report as unsupported for spell import. */ |
|
const KNOWN_UNSUPPORTED = new Set(['emoji', 'word', 'group']) |
|
|
|
export function dedupeAppendIds(base: string[], add: string[]): string[] { |
|
const seen = new Set( |
|
base |
|
.map((s) => s.trim().toLowerCase()) |
|
.filter(Boolean) |
|
) |
|
const out = base.map((s) => s.trim()).filter(Boolean) |
|
for (const raw of add) { |
|
const t = raw.trim() |
|
if (!t) continue |
|
const k = t.toLowerCase() |
|
if (seen.has(k)) continue |
|
seen.add(k) |
|
out.push(t) |
|
} |
|
return out |
|
} |
|
|
|
function mergeTagLetter( |
|
rows: { letter: string; values: string[] }[], |
|
letter: string, |
|
values: string[] |
|
): { letter: string; values: string[] }[] { |
|
const vset = new Set(values.map((v) => v.trim()).filter(Boolean)) |
|
if (vset.size === 0) return rows |
|
const mergedVals = [...vset] |
|
const idx = rows.findIndex((r) => r.letter === letter) |
|
if (idx < 0) return [...rows, { letter, values: mergedVals }] |
|
const prev = rows[idx]! |
|
const u = new Set([...prev.values.map((x) => x.trim()).filter(Boolean), ...mergedVals]) |
|
return rows.map((r, i) => (i === idx ? { letter, values: [...u] } : r)) |
|
} |
|
|
|
export type TListToSpellResult = { |
|
draft: TSpellDraftParams |
|
notices: string[] |
|
/** `a` coordinates to resolve to event ids in the background */ |
|
pendingATags: string[] |
|
} |
|
|
|
/** |
|
* Merge public tags from a list/set event into spell draft fields. |
|
* Does not resolve `a` tags — use {@link resolveSpellListATags} after. |
|
*/ |
|
export function applyListEventToSpellDraft( |
|
base: TSpellDraftParams, |
|
listEvent: Event |
|
): TListToSpellResult { |
|
const notices: string[] = [] |
|
const pendingATags: string[] = [] |
|
const unsupportedCounts = new Map<string, number>() |
|
|
|
let draft: TSpellDraftParams = { |
|
...base, |
|
ids: [...base.ids], |
|
authors: [...base.authors], |
|
relays: [...base.relays], |
|
topics: [...base.topics], |
|
tagFilters: base.tagFilters.map((r) => ({ letter: r.letter, values: [...r.values] })), |
|
kinds: [...base.kinds] |
|
} |
|
|
|
if ((listEvent.content ?? '').trim().length > 0) { |
|
notices.push('listImportContentSkipped') |
|
} |
|
|
|
const title = listEvent.tags.find((t) => t[0] === 'title')?.[1]?.trim() |
|
if (title && !(draft.name ?? '').trim()) { |
|
draft = { ...draft, name: title } |
|
} |
|
|
|
for (const tag of listEvent.tags) { |
|
const name = tag[0] |
|
if (!name) continue |
|
if (LIST_METADATA_TAGS.has(name) && name !== 'relay') continue |
|
|
|
if (name === 't' && tag[1]) { |
|
const v = tag[1].trim() |
|
if (v) draft.tagFilters = mergeTagLetter(draft.tagFilters, 't', [v]) |
|
continue |
|
} |
|
|
|
if (name === 'e' && tag[1] && HEX64.test(tag[1])) { |
|
draft.ids = dedupeAppendIds(draft.ids, [tag[1]]) |
|
continue |
|
} |
|
|
|
if (name === 'p' && tag[1] && isValidPubkey(tag[1])) { |
|
draft.authors = dedupeAppendIds(draft.authors, [tag[1]]) |
|
continue |
|
} |
|
|
|
if (name === 'relay' && tag[1]) { |
|
const u = normalizeUrl(tag[1]) || tag[1] |
|
if (isWebsocketUrl(u)) draft.relays = dedupeAppendIds(draft.relays, [u]) |
|
continue |
|
} |
|
|
|
if (name === 'r' && tag[1]) { |
|
const u = normalizeUrl(tag[1]) || tag[1] |
|
if (isWebsocketUrl(u)) draft.relays = dedupeAppendIds(draft.relays, [u]) |
|
continue |
|
} |
|
|
|
if (name === 'a' && tag[1]) { |
|
pendingATags.push(tag[1]) |
|
continue |
|
} |
|
|
|
if (KNOWN_UNSUPPORTED.has(name)) { |
|
unsupportedCounts.set(name, (unsupportedCounts.get(name) ?? 0) + 1) |
|
continue |
|
} |
|
|
|
if (LIST_METADATA_TAGS.has(name)) continue |
|
|
|
unsupportedCounts.set(name, (unsupportedCounts.get(name) ?? 0) + 1) |
|
} |
|
|
|
for (const [n, c] of unsupportedCounts) { |
|
if (n === 'emoji') notices.push('listImportUnsupportedEmoji') |
|
else notices.push(`listImportUnsupportedTag:${n}:${c}`) |
|
} |
|
|
|
return { draft, notices, pendingATags: [...new Set(pendingATags)] } |
|
} |
|
|
|
/** Resolve NIP-33 address strings (`kind:pubkey:d…`) to latest replaceable event ids. */ |
|
export async function resolveSpellListATags( |
|
aTags: string[], |
|
relayUrls: string[] |
|
): Promise<{ ids: string[]; notices: string[] }> { |
|
const ids: string[] = [] |
|
const notices: string[] = [] |
|
const relays = relayUrls.length ? relayUrls : [] |
|
|
|
await Promise.all( |
|
aTags.map(async (at) => { |
|
const parts = at.split(':') |
|
if (parts.length < 3) { |
|
notices.push(`listImportBadATag:${at.slice(0, 32)}`) |
|
return |
|
} |
|
const kind = parseInt(parts[0]!, 10) |
|
const author = parts[1]! |
|
const d = parts.slice(2).join(':') |
|
if (Number.isNaN(kind) || !isValidPubkey(author) || !d) { |
|
notices.push(`listImportBadATag:${at.slice(0, 32)}`) |
|
return |
|
} |
|
const filter: Filter = { kinds: [kind], authors: [author], '#d': [d], limit: 5 } |
|
try { |
|
const events = |
|
relays.length > 0 |
|
? await queryService.fetchEvents(relays, filter, { |
|
globalTimeout: 12_000, |
|
firstRelayResultGraceMs: false |
|
}) |
|
: await queryService.fetchEvents([], filter, { |
|
globalTimeout: 12_000, |
|
firstRelayResultGraceMs: false |
|
}) |
|
if (!events.length) { |
|
notices.push(`listImportATagNotFound:${at.slice(0, 48)}`) |
|
return |
|
} |
|
const latest = [...events].sort((a, b) => b.created_at - a.created_at)[0]! |
|
ids.push(latest.id) |
|
} catch { |
|
notices.push(`listImportATagFailed:${at.slice(0, 48)}`) |
|
} |
|
}) |
|
) |
|
|
|
return { ids: [...new Set(ids)], notices } |
|
}
|
|
|