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.
771 lines
26 KiB
771 lines
26 KiB
import { CALENDAR_EVENT_KINDS, ExtendedKind } from '@/constants' |
|
import { muteSetHas } from '@/lib/mute-set' |
|
import { EMBEDDED_EVENT_REGEX, EMBEDDED_MENTION_REGEX, NOSTR_EMBEDDED_NOTE_REGEX } from '@/lib/content-patterns' |
|
import { cleanUrl } from '@/lib/url' |
|
import client from '@/services/client.service' |
|
import { TImetaInfo } from '@/types' |
|
import { LRUCache } from 'lru-cache' |
|
import { Event, getEventHash, kinds, nip19, UnsignedEvent } from 'nostr-tools' |
|
import { getPow } from 'nostr-tools/nip13' |
|
import { hexPubkeysEqual, normalizeHexPubkey } from './pubkey' |
|
import { |
|
generateBech32IdFromATag, |
|
generateBech32IdFromETag, |
|
getFirstHexEventIdFromETags, |
|
getImetaInfoFromImetaTag, |
|
tagNameEquals |
|
} from './tag' |
|
|
|
/** NIP-25: kind 7 (nostr target) or kind 17 (external / NIP-73 `k`+`i`). */ |
|
export function isNip25ReactionKind(kind: number): boolean { |
|
return kind === kinds.Reaction || kind === ExtendedKind.EXTERNAL_REACTION |
|
} |
|
|
|
/** NIP-18: kind 6 (kind-1 repost) or kind 16 (generic repost). */ |
|
export function isNip18RepostKind(kind: number): boolean { |
|
return kind === kinds.Repost || kind === ExtendedKind.GENERIC_REPOST |
|
} |
|
|
|
/** NIP-56: kind 1984 report / flag (`kinds.Report` and {@link ExtendedKind.REPORT} are the same kind). */ |
|
export function isNip56ReportEvent(event: Pick<Event, 'kind'>): boolean { |
|
return event.kind === kinds.Report || event.kind === ExtendedKind.REPORT |
|
} |
|
|
|
/** `e` / `E` tags for NIP-10-style thread links (kinds 1, 11, 1111, …). */ |
|
function listThreadLinkETags(event: Event): string[][] { |
|
return event.tags.filter(([n]) => n === 'e' || n === 'E') |
|
} |
|
|
|
/** |
|
* Parent `e` for kind 1111 / voice comment: prefer `reply` marker, else last `e` when multiple |
|
* (NIP-10 root-then-reply), else first. Avoids treating the thread root as the parent when clients omit uppercase `E`. |
|
*/ |
|
function getParentETagCommentOrDiscussion(event: Event): string[] | undefined { |
|
const isETag = (n: string) => n === 'e' || n === 'E' |
|
const byMarker = event.tags.find(([tagName, , , marker]) => isETag(tagName) && marker === 'reply') |
|
if (byMarker) return byMarker |
|
const etags = listThreadLinkETags(event) |
|
if (etags.length >= 2) return etags[etags.length - 1] |
|
return etags[0] |
|
} |
|
|
|
/** |
|
* Root `e` for kind 1111 / voice comment: prefer `root` marker, else uppercase `E` (Imwald / NIP-22), |
|
* else first `e` when multiple (NIP-10 root-before-reply), else single `e`. |
|
*/ |
|
function getRootETagCommentOrDiscussion(event: Event): string[] | undefined { |
|
const isETag = (n: string) => n === 'e' || n === 'E' |
|
const byMarker = event.tags.find(([tagName, , , marker]) => isETag(tagName) && marker === 'root') |
|
if (byMarker) return byMarker |
|
const upperE = event.tags.find(tagNameEquals('E')) |
|
if (upperE) return upperE |
|
const etags = listThreadLinkETags(event) |
|
if (etags.length >= 2) return etags[0] |
|
return etags[0] |
|
} |
|
|
|
const EVENT_EMBEDDED_NOTES_CACHE = new LRUCache<string, string[]>({ max: 10000 }) |
|
const EVENT_EMBEDDED_PUBKEYS_CACHE = new LRUCache<string, string[]>({ max: 10000 }) |
|
const EVENT_IS_REPLY_NOTE_CACHE = new LRUCache<string, boolean>({ max: 10000 }) |
|
/** Bump when isReplyNoteEvent logic changes so cached booleans are not stale. */ |
|
const IS_REPLY_NOTE_CACHE_KEY_SUFFIX = ':v3' |
|
|
|
export function isNsfwEvent(event: Event) { |
|
return event.tags.some( |
|
([tagName, tagValue]) => |
|
tagName === 'content-warning' || (tagName === 't' && tagValue.toLowerCase() === 'nsfw') |
|
) |
|
} |
|
|
|
export function isReplyNoteEvent(event: Event) { |
|
if ([ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT, 1111].includes(event.kind)) { |
|
return true |
|
} |
|
|
|
// Zap receipts are considered replies if they have an 'e' tag (zapping a note) or 'a' tag (zapping an addressable event) |
|
if (event.kind === kinds.Zap) { |
|
return event.tags.some(tag => tag[0] === 'e' || tag[0] === 'a') |
|
} |
|
|
|
if (event.kind !== kinds.ShortTextNote) return false |
|
|
|
const cacheKey = event.id + IS_REPLY_NOTE_CACHE_KEY_SUFFIX |
|
const cache = EVENT_IS_REPLY_NOTE_CACHE.get(cacheKey) |
|
if (cache !== undefined) return cache |
|
|
|
// NIP-18 `q` without `e`/`a` is a quote note (top-level for OP vs reply filters), not a thread reply. |
|
const isReply = !!getParentETag(event) || !!getParentATag(event) |
|
EVENT_IS_REPLY_NOTE_CACHE.set(cacheKey, isReply) |
|
return isReply |
|
} |
|
|
|
export function isReplaceableEvent(kind: number) { |
|
return ( |
|
kinds.isReplaceableKind(kind) || |
|
kinds.isAddressableKind(kind) || |
|
CALENDAR_EVENT_KINDS.includes(kind) |
|
) |
|
} |
|
|
|
export function isPictureEvent(event: Event) { |
|
return event.kind === ExtendedKind.PICTURE |
|
} |
|
|
|
export function isProtectedEvent(event: Event) { |
|
return event.tags.some(([tagName]) => tagName === '-') |
|
} |
|
|
|
export function isMentioningMutedUsers(event: Event, mutePubkeySet: Set<string>) { |
|
for (const [tagName, pubkey] of event.tags) { |
|
if (tagName === 'p' && muteSetHas(mutePubkeySet, pubkey)) { |
|
return true |
|
} |
|
} |
|
return false |
|
} |
|
|
|
export function getParentETag(event?: Event) { |
|
if (!event) return undefined |
|
|
|
// NIP-25 reactions, NIP-18 reposts (6 / 16), poll responses: first hex `e` / `E` references the target note. |
|
if (event.kind === kinds.Reaction || isNip18RepostKind(event.kind) || event.kind === ExtendedKind.POLL_RESPONSE) { |
|
const firstId = getFirstHexEventIdFromETags(event.tags) |
|
if (!firstId) return undefined |
|
return ( |
|
event.tags.find((t) => t[0] === 'e' && t[1] === firstId) ?? |
|
event.tags.find((t) => t[0] === 'E' && t[1] === firstId) |
|
) |
|
} |
|
|
|
if (event.kind === ExtendedKind.COMMENT || event.kind === ExtendedKind.VOICE_COMMENT) { |
|
return getParentETagCommentOrDiscussion(event) |
|
} |
|
|
|
// Kind 11: keep first `e` / `E` (thread shape differs from NIP-10 comment chains). |
|
if (event.kind === ExtendedKind.DISCUSSION) { |
|
return event.tags.find(tagNameEquals('e')) ?? event.tags.find(tagNameEquals('E')) |
|
} |
|
|
|
// Kind 9735: zapped note id is on `e` / `E` (or addressable target on `a` / `A`) |
|
if (event.kind === kinds.Zap) { |
|
const firstHex = getFirstHexEventIdFromETags(event.tags) |
|
if (firstHex) { |
|
return ( |
|
event.tags.find((t) => t[0] === 'e' && t[1] === firstHex) ?? |
|
event.tags.find((t) => t[0] === 'E' && t[1] === firstHex) |
|
) |
|
} |
|
return event.tags.find(tagNameEquals('e')) ?? event.tags.find(tagNameEquals('E')) |
|
} |
|
|
|
if (event.kind !== kinds.ShortTextNote) return undefined |
|
|
|
const isETag = (n: string) => n === 'e' || n === 'E' |
|
let tag = event.tags.find(([tagName, , , marker]) => { |
|
return isETag(tagName) && marker === 'reply' |
|
}) |
|
if (!tag) { |
|
const embeddedEventIds = getEmbeddedNoteBech32Ids(event) |
|
tag = event.tags.findLast( |
|
([tagName, tagValue, , marker]) => |
|
isETag(tagName) && |
|
!!tagValue && |
|
marker !== 'mention' && |
|
!embeddedEventIds.includes(tagValue) |
|
) |
|
} |
|
return tag |
|
} |
|
|
|
export function getParentATag(event?: Event) { |
|
if (!event) return undefined |
|
if (event.kind === kinds.Zap) { |
|
return event.tags.find(tagNameEquals('a')) ?? event.tags.find(tagNameEquals('A')) |
|
} |
|
if ( |
|
![kinds.ShortTextNote, ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT, ExtendedKind.DISCUSSION].includes(event.kind) |
|
) { |
|
return undefined |
|
} |
|
|
|
return event.tags.find(tagNameEquals('a')) ?? event.tags.find(tagNameEquals('A')) |
|
} |
|
|
|
export function getParentEventHexId(event?: Event) { |
|
const tag = getParentETag(event) |
|
return tag?.[1] |
|
} |
|
|
|
export function getParentBech32Id(event?: Event) { |
|
const eTag = getParentETag(event) |
|
if (!eTag) { |
|
const aTag = getParentATag(event) |
|
if (!aTag) return undefined |
|
|
|
return generateBech32IdFromATag(aTag) |
|
} |
|
|
|
return generateBech32IdFromETag(eTag) |
|
} |
|
|
|
export function getRootETag(event?: Event) { |
|
if (!event) return undefined |
|
|
|
if (event.kind === ExtendedKind.COMMENT || event.kind === ExtendedKind.VOICE_COMMENT) { |
|
return getRootETagCommentOrDiscussion(event) |
|
} |
|
|
|
if (event.kind === ExtendedKind.DISCUSSION) { |
|
return event.tags.find(tagNameEquals('E')) |
|
} |
|
|
|
// Kind 9735: thread root for note zaps is the zapped event id on `e` / `E` |
|
if (event.kind === kinds.Zap) { |
|
const firstHex = getFirstHexEventIdFromETags(event.tags) |
|
if (!firstHex) return undefined |
|
return ( |
|
event.tags.find((t) => t[0] === 'e' && t[1] === firstHex) ?? |
|
event.tags.find((t) => t[0] === 'E' && t[1] === firstHex) |
|
) |
|
} |
|
|
|
if (event.kind !== kinds.ShortTextNote) return undefined |
|
|
|
const isETag = (n: string) => n === 'e' || n === 'E' |
|
let tag = event.tags.find(([tagName, , , marker]) => { |
|
return isETag(tagName) && marker === 'root' |
|
}) |
|
if (!tag) { |
|
const embeddedEventIds = getEmbeddedNoteBech32Ids(event) |
|
tag = event.tags.find( |
|
([tagName, tagValue]) => |
|
isETag(tagName) && !!tagValue && !embeddedEventIds.includes(tagValue) |
|
) |
|
} |
|
return tag |
|
} |
|
|
|
export function getRootATag(event?: Event) { |
|
if (!event) return undefined |
|
if (event.kind === kinds.Zap) { |
|
return event.tags.find(tagNameEquals('a')) ?? event.tags.find(tagNameEquals('A')) |
|
} |
|
if ( |
|
![kinds.ShortTextNote, ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT, ExtendedKind.DISCUSSION].includes(event.kind) |
|
) { |
|
return undefined |
|
} |
|
|
|
return event.tags.find(tagNameEquals('A')) |
|
} |
|
|
|
export function getRootEventHexId(event?: Event) { |
|
const tag = getRootETag(event) |
|
return tag?.[1] |
|
} |
|
|
|
const RESOLVE_DECLARED_THREAD_ROOT_MAX_HOPS = 14 |
|
|
|
/** Zapped **note** id from a kind 9735 receipt (`e` / `E` hex). Kept here to avoid importing event-metadata (cycles). */ |
|
function zapReceiptTargetNoteHexFromEvent(ev: Event): string | undefined { |
|
if (ev.kind !== kinds.Zap) return undefined |
|
for (const t of ev.tags) { |
|
if ((t[0] === 'e' || t[0] === 'E') && t[1] && /^[0-9a-f]{64}$/i.test(t[1])) { |
|
return t[1].toLowerCase() |
|
} |
|
} |
|
return undefined |
|
} |
|
|
|
/** |
|
* Clients that reply from a notification often emit a single `e` tag whose **id is a reaction** (kind 7 / 17) |
|
* or **zap receipt** (kind 9735) but the marker is still `root` — they never saw the real OP. Walk |
|
* reaction / zap → target note → further NIP-10 `e` roots (session cache) until stable, for thread UI and child `root` tags. |
|
*/ |
|
export function resolveDeclaredThreadRootEventHex(startHexId: string): string { |
|
let cur = startHexId.trim().toLowerCase() |
|
if (!/^[0-9a-f]{64}$/i.test(cur)) return cur |
|
const seen = new Set<string>() |
|
for (let hop = 0; hop < RESOLVE_DECLARED_THREAD_ROOT_MAX_HOPS; hop++) { |
|
if (seen.has(cur)) return cur |
|
seen.add(cur) |
|
const ev = client.peekSessionCachedEvent(cur) |
|
if (!ev) return cur |
|
if (isNip25ReactionKind(ev.kind)) { |
|
const fromParent = getParentEventHexId(ev)?.toLowerCase() |
|
let next: string | undefined |
|
if (fromParent && /^[0-9a-f]{64}$/i.test(fromParent)) { |
|
next = fromParent |
|
} else { |
|
const first = getFirstHexEventIdFromETags(ev.tags) |
|
next = first && /^[0-9a-f]{64}$/i.test(first) ? first.toLowerCase() : undefined |
|
} |
|
if (!next || next === cur) return cur |
|
cur = next |
|
continue |
|
} |
|
if (ev.kind === kinds.Zap) { |
|
const next = zapReceiptTargetNoteHexFromEvent(ev) |
|
if (!next || next === cur) return cur |
|
cur = next |
|
continue |
|
} |
|
const r = getRootEventHexId(ev)?.toLowerCase() |
|
if (r && r !== cur && /^[0-9a-f]{64}$/i.test(r)) { |
|
cur = r |
|
continue |
|
} |
|
return cur |
|
} |
|
return cur |
|
} |
|
|
|
/** True if event references target as root, parent, or quoted (#q, #a) — used to hide redundant preview when showing quotes of current note. */ |
|
export function eventReferencesEventId( |
|
event: Event | undefined, |
|
targetHexIdOrEvent: string | Event |
|
): boolean { |
|
if (!event) return false |
|
const targetEvent = typeof targetHexIdOrEvent === 'object' ? targetHexIdOrEvent : undefined |
|
const targetHexId = |
|
typeof targetHexIdOrEvent === 'string' |
|
? targetHexIdOrEvent.toLowerCase() |
|
: targetHexIdOrEvent.id?.toLowerCase() |
|
const targetCoordinate = |
|
targetEvent && isReplaceableEvent(targetEvent.kind) |
|
? getReplaceableCoordinateFromEvent(targetEvent) |
|
: undefined |
|
|
|
const qRef = getQuotedReferenceFromQTags(event) |
|
|
|
if (targetHexId) { |
|
const rootId = getRootETag(event)?.[1]?.toLowerCase() |
|
if (rootId === targetHexId) return true |
|
const parentId = getParentETag(event)?.[1]?.toLowerCase() |
|
if (parentId === targetHexId) return true |
|
if (qRef?.hexId === targetHexId) return true |
|
const eTags = event.tags.filter((t) => t[0] === 'e' || t[0] === 'E') |
|
if (eTags.some((t) => t[1]?.toLowerCase() === targetHexId)) return true |
|
} |
|
|
|
if (targetCoordinate) { |
|
const targetCoordNorm = normalizeReplaceableCoordinateString(targetCoordinate) |
|
const aTags = event.tags.filter((t) => t[0] === 'a' || t[0] === 'A') |
|
if (aTags.some((t) => normalizeReplaceableCoordinateString(t[1] ?? '') === targetCoordNorm)) return true |
|
if ( |
|
qRef?.coordinate && |
|
normalizeReplaceableCoordinateString(qRef.coordinate) === targetCoordNorm |
|
) { |
|
return true |
|
} |
|
} |
|
|
|
return false |
|
} |
|
|
|
export function getRootBech32Id(event?: Event) { |
|
const eTag = getRootETag(event) |
|
if (!eTag) { |
|
const aTag = getRootATag(event) |
|
if (!aTag) return undefined |
|
|
|
return generateBech32IdFromATag(aTag) |
|
} |
|
|
|
return generateBech32IdFromETag(eTag) |
|
} |
|
|
|
export function getReplaceableCoordinate(kind: number, pubkey: string, d: string = '') { |
|
return `${kind}:${pubkey}:${d}` |
|
} |
|
|
|
export function getReplaceableCoordinateFromEvent(event: Event) { |
|
const d = event.tags.find(tagNameEquals('d'))?.[1] ?? '' |
|
return getReplaceableCoordinate(event.kind, event.pubkey, d) |
|
} |
|
|
|
/** |
|
* Merge key for NIP-33 addressable events when relays return different ids for the same logical |
|
* replaceable. Normalized `kind:pubkey:d`; missing/empty `d` or non-addressable kinds use `event.id`. |
|
*/ |
|
export function replaceableEventDedupeKey(event: Event): string { |
|
if (!kinds.isAddressableKind(event.kind)) return event.id |
|
const d = event.tags.find(tagNameEquals('d'))?.[1] |
|
if (d == null || d === '') return event.id |
|
return normalizeReplaceableCoordinateString(getReplaceableCoordinateFromEvent(event)) |
|
} |
|
|
|
/** Normalize `kind:pubkey:d` for comparisons (lowercase pubkey; preserve d). */ |
|
export function normalizeReplaceableCoordinateString(coord: string): string { |
|
const m = /^(\d+):([0-9a-f]{64}):(.*)$/i.exec(coord.trim()) |
|
if (!m) return coord.trim().toLowerCase() |
|
return getReplaceableCoordinate(Number(m[1]), m[2].toLowerCase(), m[3]) |
|
} |
|
|
|
function stripNostrUriScheme(s: string): string { |
|
const t = s.trim() |
|
if (t.toLowerCase().startsWith('nostr:')) return t.slice(6).trim() |
|
return t |
|
} |
|
|
|
/** |
|
* NIP-10 / NIP-18: `q` tag value is `<event-id>` or `<event-address>` (coordinate), or NIP-19 bech32. |
|
*/ |
|
export function parseQTagReferenceValue( |
|
raw: string | undefined | null |
|
): { hexId?: string; coordinate?: string } | undefined { |
|
if (raw == null) return undefined |
|
const s0 = stripNostrUriScheme(raw) |
|
if (!s0) return undefined |
|
|
|
if (/^[0-9a-f]{64}$/i.test(s0)) { |
|
return { hexId: s0.toLowerCase() } |
|
} |
|
|
|
const coordMatch = /^(\d+):([0-9a-f]{64}):(.*)$/i.exec(s0) |
|
if (coordMatch) { |
|
return { |
|
coordinate: getReplaceableCoordinate( |
|
Number(coordMatch[1]), |
|
coordMatch[2].toLowerCase(), |
|
coordMatch[3] |
|
) |
|
} |
|
} |
|
|
|
if (/^n(?:ote|event|addr)1/i.test(s0)) { |
|
try { |
|
const { type, data } = nip19.decode(s0) |
|
if (type === 'note') { |
|
const id = typeof data === 'string' ? data : (data as { id?: string }).id |
|
if (id && /^[0-9a-f]{64}$/i.test(id)) return { hexId: id.toLowerCase() } |
|
} |
|
if (type === 'nevent') { |
|
const id = (data as { id: string }).id |
|
if (id && /^[0-9a-f]{64}$/i.test(id)) return { hexId: id.toLowerCase() } |
|
} |
|
if (type === 'naddr') { |
|
const d = data as { kind: number; pubkey: string; identifier: string } |
|
return { |
|
coordinate: getReplaceableCoordinate( |
|
d.kind, |
|
d.pubkey.toLowerCase(), |
|
d.identifier ?? '' |
|
) |
|
} |
|
} |
|
} catch { |
|
/* invalid bech32 */ |
|
} |
|
} |
|
|
|
return undefined |
|
} |
|
|
|
/** Parsed first `q` / `Q` tag on the event (NIP-10). */ |
|
export function getQuotedReferenceFromQTags(event: Event): { |
|
hexId?: string |
|
coordinate?: string |
|
} | undefined { |
|
const q = event.tags.find((t) => t[0] === 'q' || t[0] === 'Q')?.[1] |
|
return parseQTagReferenceValue(q) |
|
} |
|
|
|
/** Hex id from `q` when the reference resolves to a fixed id (not coordinate-only). */ |
|
export function getQuotedEventHexIdFromQTags(event: Event): string | undefined { |
|
return getQuotedReferenceFromQTags(event)?.hexId |
|
} |
|
|
|
/** Kind 1 whose `q` points at this hex id (legacy helper). */ |
|
export function kind1QuotesEventHexId(event: Event, hexId: string): boolean { |
|
if (event.kind !== kinds.ShortTextNote) return false |
|
const ref = getQuotedReferenceFromQTags(event) |
|
return !!ref?.hexId && ref.hexId === hexId.trim().toLowerCase() |
|
} |
|
|
|
/** Kind 1 quote-of-root: match `q` hex and/or replaceable coordinate (and bech32 decoding). */ |
|
export function kind1QuotesThreadRoot( |
|
event: Event, |
|
root: { type: 'E'; id: string } | { type: 'A'; id: string; eventId: string } |
|
): boolean { |
|
if (event.kind !== kinds.ShortTextNote) return false |
|
const ref = getQuotedReferenceFromQTags(event) |
|
if (!ref || (!ref.hexId && !ref.coordinate)) return false |
|
if (root.type === 'E') { |
|
const rid = root.id.trim().toLowerCase() |
|
return !!ref.hexId && ref.hexId === rid |
|
} |
|
const eid = root.eventId.trim().toLowerCase() |
|
const coordNorm = normalizeReplaceableCoordinateString(root.id) |
|
if (ref.hexId && ref.hexId === eid) return true |
|
if (ref.coordinate && normalizeReplaceableCoordinateString(ref.coordinate) === coordNorm) return true |
|
return false |
|
} |
|
|
|
/** Whether an event matches a tombstone key from IndexedDB (e-tag id, a-tag coordinate, or k-tag kind:pubkey). */ |
|
export function isTombstoneKeyForEvent(event: Event, tombstones: Set<string>): boolean { |
|
if (tombstones.has(event.id)) return true |
|
if (isReplaceableEvent(event.kind)) { |
|
if (tombstones.has(getReplaceableCoordinateFromEvent(event))) return true |
|
if (tombstones.has(`${event.kind}:${event.pubkey}`)) return true |
|
} |
|
return false |
|
} |
|
|
|
export function filterEventsExcludingTombstones(events: Event[], tombstones: Set<string>): Event[] { |
|
if (tombstones.size === 0) return events |
|
return events.filter((e) => !isTombstoneKeyForEvent(e, tombstones)) |
|
} |
|
|
|
export function getNoteBech32Id(event: Event) { |
|
const hints = client.getEventHints(event.id).slice(0, 2) |
|
if (isReplaceableEvent(event.kind)) { |
|
const identifier = event.tags.find(tagNameEquals('d'))?.[1] ?? '' |
|
return nip19.naddrEncode({ pubkey: event.pubkey, kind: event.kind, identifier, relays: hints }) |
|
} |
|
return nip19.neventEncode({ id: event.id, author: event.pubkey, kind: event.kind, relays: hints }) |
|
} |
|
|
|
export function getUsingClient(event: Event) { |
|
const clientTag = event.tags.find(tagNameEquals('client')) |
|
if (!clientTag) return undefined |
|
|
|
// NIP-89 client tag format: ["client", "Client Name", "31990:pubkey:identifier", "relay"] |
|
// Simple format: ["client", "client_name"] |
|
// For display purposes, we use the client name (second element) |
|
return clientTag[1] |
|
} |
|
|
|
export function getImetaInfosFromEvent(event: Event) { |
|
const imeta: TImetaInfo[] = [] |
|
event.tags.forEach((tag) => { |
|
const imageInfo = getImetaInfoFromImetaTag(tag, event.pubkey) |
|
if (imageInfo) { |
|
imeta.push(imageInfo) |
|
} |
|
}) |
|
return imeta |
|
} |
|
|
|
export function getEmbeddedNoteBech32Ids(event: Event) { |
|
const cache = EVENT_EMBEDDED_NOTES_CACHE.get(event.id) |
|
if (cache) return cache |
|
|
|
const embeddedNoteBech32Ids: string[] = [] |
|
;(event.content.match(NOSTR_EMBEDDED_NOTE_REGEX) || []).forEach((note) => { |
|
try { |
|
const { type, data } = nip19.decode(note.split(':')[1]) |
|
if (type === 'nevent') { |
|
embeddedNoteBech32Ids.push(data.id) |
|
} else if (type === 'note') { |
|
embeddedNoteBech32Ids.push(data) |
|
} |
|
} catch { |
|
// ignore |
|
} |
|
}) |
|
EVENT_EMBEDDED_NOTES_CACHE.set(event.id, embeddedNoteBech32Ids) |
|
return embeddedNoteBech32Ids |
|
} |
|
|
|
/** |
|
* Collect targets to prefetch so embedded notes (and reply roots) resolve into session cache. |
|
* - `hexIds`: lowercase event ids (e tags, a-tag snapshot, nostr:note1 / nevent1 in content). |
|
* - `nip19Pointers`: bech32 strings (e.g. naddr) for per-pointer fetches — not batchable as a single `ids` filter. |
|
*/ |
|
export function collectEmbeddedEventPrefetchTargets(event: Event): { |
|
hexIds: string[] |
|
nip19Pointers: string[] |
|
} { |
|
const hexSet = new Set<string>() |
|
const nip19Set = new Set<string>() |
|
|
|
const addHex = (id: string | undefined) => { |
|
if (!id) return |
|
const t = id.trim().toLowerCase() |
|
if (/^[0-9a-f]{64}$/.test(t)) hexSet.add(t) |
|
} |
|
|
|
for (const tag of event.tags) { |
|
if (tag[0] === 'e' && tag[1]) addHex(tag[1]) |
|
if (tag[0] === 'a' && tag[3]) addHex(tag[3]) |
|
} |
|
|
|
for (const full of event.content.match(EMBEDDED_EVENT_REGEX) ?? []) { |
|
const colon = full.indexOf(':') |
|
if (colon < 0) continue |
|
const bech32 = full.slice(colon + 1) |
|
try { |
|
const { type, data } = nip19.decode(bech32) |
|
if (type === 'note') addHex(data) |
|
else if (type === 'nevent') addHex(data.id) |
|
else if (type === 'naddr') nip19Set.add(bech32) |
|
} catch { |
|
/* ignore */ |
|
} |
|
} |
|
|
|
// Discussion roots (kind 11) usually do not reference their own id in tags/content; include the |
|
// row id so feed prefetch + open-note `fetchEvent` hit session cache after the list has loaded. |
|
if (event.kind === ExtendedKind.DISCUSSION) { |
|
addHex(event.id) |
|
} |
|
|
|
return { |
|
hexIds: Array.from(hexSet), |
|
nip19Pointers: Array.from(nip19Set) |
|
} |
|
} |
|
|
|
export function getEmbeddedPubkeys(event: Event) { |
|
const cache = EVENT_EMBEDDED_PUBKEYS_CACHE.get(event.id) |
|
if (cache) return cache |
|
|
|
const embeddedPubkeySet = new Set<string>() |
|
;(event.content.match(EMBEDDED_MENTION_REGEX) || []).forEach((mention) => { |
|
try { |
|
const { type, data } = nip19.decode(mention.split(':')[1]) |
|
if (type === 'npub') { |
|
embeddedPubkeySet.add(data) |
|
} else if (type === 'nprofile') { |
|
embeddedPubkeySet.add(data.pubkey) |
|
} |
|
} catch { |
|
// ignore |
|
} |
|
}) |
|
const embeddedPubkeys = Array.from(embeddedPubkeySet) |
|
EVENT_EMBEDDED_PUBKEYS_CACHE.set(event.id, embeddedPubkeys) |
|
return embeddedPubkeys |
|
} |
|
|
|
/** |
|
* Whether `userPubkey` is mentioned on the event: any `p` tag and/or |
|
* `nostr:npub…` / `nostr:nprofile…` in content (see {@link getEmbeddedPubkeys}). |
|
* Events authored by the user are excluded (not treated as incoming mentions). |
|
*/ |
|
export function isUserInEventMentions(event: Event, userPubkey: string): boolean { |
|
const u = normalizeHexPubkey(userPubkey) |
|
if (hexPubkeysEqual(event.pubkey, u)) return false |
|
const inPtags = event.tags.some((t) => t[0] === 'p' && t[1] && hexPubkeysEqual(t[1], u)) |
|
if (inPtags) return true |
|
return getEmbeddedPubkeys(event).some((pk) => hexPubkeysEqual(pk, u)) |
|
} |
|
|
|
export function getLatestEvent(events: Event[]): Event | undefined { |
|
return events.sort((a, b) => b.created_at - a.created_at)[0] |
|
} |
|
|
|
export function getReplaceableEventIdentifier(event: Event) { |
|
return event.tags.find(tagNameEquals('d'))?.[1] ?? '' |
|
} |
|
|
|
export function createFakeEvent(event: Partial<Event>): Event { |
|
return { |
|
id: '', |
|
kind: 1, |
|
pubkey: '', |
|
content: '', |
|
created_at: 0, |
|
tags: [], |
|
sig: '', |
|
...event |
|
} |
|
} |
|
|
|
export async function minePow( |
|
unsigned: UnsignedEvent, |
|
difficulty: number |
|
): Promise<Omit<Event, 'sig'>> { |
|
let count = 0 |
|
|
|
const event = unsigned as Omit<Event, 'sig'> |
|
const tag = ['nonce', count.toString(), difficulty.toString()] |
|
|
|
event.tags.push(tag) |
|
|
|
return new Promise((resolve) => { |
|
const mine = () => { |
|
let iterations = 0 |
|
|
|
while (iterations < 1000) { |
|
const now = Math.floor(new Date().getTime() / 1000) |
|
|
|
if (now !== event.created_at) { |
|
count = 0 |
|
event.created_at = now |
|
} |
|
|
|
tag[1] = (++count).toString() |
|
event.id = getEventHash(event) |
|
|
|
if (getPow(event.id) >= difficulty) { |
|
resolve(event) |
|
return |
|
} |
|
|
|
iterations++ |
|
} |
|
|
|
setTimeout(mine, 0) |
|
} |
|
|
|
mine() |
|
}) |
|
} |
|
|
|
// Legacy compare function for sorting compatibility |
|
// If return 0, it means the two events are equal. |
|
// If return a negative number, it means `b` should be retained, and `a` should be discarded. |
|
// If return a positive number, it means `a` should be retained, and `b` should be discarded. |
|
export function compareEvents(a: Event, b: Event): number { |
|
if (a.created_at !== b.created_at) { |
|
return a.created_at - b.created_at |
|
} |
|
// In case of replaceable events with the same timestamp, the event with the lowest id (first in lexical order) should be retained, and the other discarded. |
|
if (a.id !== b.id) { |
|
return a.id < b.id ? 1 : -1 |
|
} |
|
return 0 |
|
} |
|
|
|
// Returns the event that should be retained when comparing two events |
|
export function getRetainedEvent(a: Event, b: Event): Event { |
|
if (compareEvents(a, b) > 0) { |
|
return a |
|
} |
|
return b |
|
} |
|
|
|
/** |
|
* Collapse replaceable/addressable events to one per NIP-01 coordinate (`kind:pubkey` or `kind:pubkey:d`), |
|
* keeping the newest (`created_at`, then lexicographically smallest `id` on ties). |
|
* Non-replaceable events are keyed by `id` only. |
|
*/ |
|
export function dedupeToLatestPerReplaceableCoordinate(events: Event[]): Event[] { |
|
const byKey = new Map<string, Event>() |
|
for (const e of events) { |
|
if (!isReplaceableEvent(e.kind)) { |
|
byKey.set(e.id, e) |
|
continue |
|
} |
|
const coord = getReplaceableCoordinateFromEvent(e) |
|
const existing = byKey.get(coord) |
|
if (!existing) { |
|
byKey.set(coord, e) |
|
continue |
|
} |
|
byKey.set(coord, getRetainedEvent(e, existing)) |
|
} |
|
return [...byKey.values()] |
|
} |
|
|
|
/** External article URL from `i` / `I` tags (e.g. kind 1111 comments on web content). */ |
|
export function getHttpUrlFromITags(event: Event): string | undefined { |
|
const lower = event.tags.find((t) => t[0] === 'i')?.[1]?.trim() |
|
const upper = event.tags.find((t) => t[0] === 'I')?.[1]?.trim() |
|
const raw = lower ?? upper |
|
if (!raw) return undefined |
|
if (!raw.startsWith('http://') && !raw.startsWith('https://')) return undefined |
|
return cleanUrl(raw) || raw |
|
}
|
|
|