UNPKG

@nostr-dev-kit/ndk

Version:

NDK - Nostr Development Kit. Includes AI Guardrails to catch common mistakes during development.

243 lines (199 loc) 9.17 kB
import type { NDKEvent, NDKEventId, NDKTag } from "../events"; import { NDKKind } from "../events/kinds"; export function eventsBySameAuthor(op: NDKEvent, events: NDKEvent[]) { const eventsByAuthor = new Map<NDKEventId, NDKEvent>(); eventsByAuthor.set(op.id, op); events.forEach((event) => { if (event.pubkey === op.pubkey) { eventsByAuthor.set(event.id, event); } }); return eventsByAuthor; } const hasMarkers = (event: NDKEvent, tagType: string): boolean => { return event.getMatchingTags(tagType).some((tag) => tag[3] && tag[3] !== ""); }; /** * Checks if an event is a reply to an original post or to a thread. * @param op The original event * @param event The event to check * @param threadIds An optional map of all events in the thread * @param tagType The tag type to search for (default: "e" for non-replaceable events and "a" for replaceable events) * @returns True if the event is a reply, false otherwise */ export function eventIsReply( op: NDKEvent, event: NDKEvent, threadIds: Set<NDKEventId> = new Set<NDKEventId>(), tagType?: string, ): boolean { tagType ??= op.tagType(); // Get all tags that we should evaluate const tags = event.getMatchingTags(tagType); // Make sure we always have the original event in the threadIds threadIds.add(op.tagId()); // We never want to consider an event in the thread as a reply if (threadIds.has(event.tagId())) return false; const heedExplicitReplyMarker = (): boolean | undefined => { // We never want to consider an event that is not tagging the original event // or if it's tagging something else as an explicit reply let eventIsTagged: "root" | boolean = false; for (const tag of tags) { // If we find an explicit reply marker, we can return if we find the ID of an event in the thread if (tag[3] === "reply") return threadIds.has(tag[1]); // If we find the original event tagged without a marker, we can flag it // if it's marked as something other than "reply" we don't consider this // a reply unless it's a root marker and no other event has a reply marker const markerIsEmpty = tag[3] === "" || tag[3] === undefined; const markerIsRoot = tag[3] === "root"; if (tag[1] === op.tagId() && (markerIsEmpty || markerIsRoot)) { eventIsTagged = markerIsRoot ? "root" : true; } } // If the event is not tagged, it's not a reply if (!eventIsTagged) return false; // If the event was marked as root and nothing else has a reply marker, mark it as a reply if (eventIsTagged === "root") return true; }; const explicitReplyMarker = heedExplicitReplyMarker(); if (explicitReplyMarker !== undefined) return explicitReplyMarker; // check if the event has valid markers, if it does and we don't have an explicit reply, this was // probably a reply to a reply or a mention if (hasMarkers(event, tagType)) return false; // if we don't have markers, check if there are tags for other events that the main event // does not have const expectedTags = op.getMatchingTags("e").map((tag) => tag[1]); expectedTags.push(op.id); // return true if there are no unexpected e tags return event.getMatchingTags("e").every((tag) => expectedTags.includes(tag[1])); } /** * Filters the returned events so that the result is the events that are * part of a thread. * * Threads are defined as a sequence of events that are related to each other * and authored by the same user. * @param op The original event * @param events All candidate events (e.g. events tagging the OP) * @returns The events that are part of the thread sorted by creation time */ export function eventThreads(op: NDKEvent, events: NDKEvent[]) { // Get all events that are tagged by the original author const eventsByAuthor = eventsBySameAuthor(op, events); // Get all events that are part of the thread const threadEvents = events.filter((event) => eventIsPartOfThread(op, event, eventsByAuthor)); // Sort the events by their created_at // TODO This is a hack, we should first filter according to tagging, not by created_at return threadEvents.sort((a, b) => a.created_at! - b.created_at!); } /** * Returns the reply ID of an event. * @param event The event to get the reply ID from * @returns The reply ID or undefined if the event is not a reply */ export function getEventReplyId(event: NDKEvent): NDKEventId | undefined { const replyTag = getReplyTag(event); if (replyTag) return replyTag[1]; const rootTag = getRootTag(event); if (rootTag) return rootTag[1]; } export function isEventOriginalPost(event: NDKEvent): boolean { return getEventReplyId(event) === undefined; } export function eventThreadIds(op: NDKEvent, events: NDKEvent[]): Map<NDKEventId, NDKEvent> { const threadIds = new Map<string, NDKEvent>(); const threadEvents = eventThreads(op, events); threadEvents.forEach((event) => threadIds.set(event.id, event)); return threadIds; } export function eventReplies(op: NDKEvent, events: NDKEvent[], threadEventIds: Set<NDKEventId>) { // Get all events that are replies to the original post or to a thread threadEventIds ??= new Set(eventThreadIds(op, events).keys()); return events.filter((event) => eventIsReply(op, event, threadEventIds)); } /** * Checks if an event is part of a thread. * @param op The original event * @param event The event to check * @param eventsByAuthor A map of all candidate events by the original author * @returns True if the event is part of the thread, false otherwise */ export function eventIsPartOfThread(op: NDKEvent, event: NDKEvent, eventsByAuthor: Map<NDKEventId, NDKEvent>): boolean { // must be same author if (op.pubkey !== event.pubkey) return false; // Check if all tagged events are by the original author const taggedEventIds = event.getMatchingTags("e").map((tag) => tag[1]); const allTaggedEventsAreByOriginalAuthor = taggedEventIds.every((id) => eventsByAuthor.has(id)); return allTaggedEventsAreByOriginalAuthor; } /** * Checks if an event has ETag markers. */ export function eventHasETagMarkers(event: NDKEvent): boolean { for (const tag of event.tags) { if (tag[0] === "e" && (tag[3] ?? "").length > 0) return true; } return false; } /** * Returns the root event ID of an event. * @param event The event to get the root event ID from * @param searchTag The tags to search for the root event ID @default "a" or "e" * @returns The root event ID or undefined if the event does not have a root event ID */ export function getRootEventId(event: NDKEvent, searchTag?: string): NDKEventId | null | undefined { searchTag ??= event.tagType(); const rootEventTag = getRootTag(event, searchTag); if (rootEventTag) return rootEventTag[1]; const replyTag = getReplyTag(event, searchTag); return replyTag?.[1]; } /** * Returns the root tag of an event. * @param event The event to get the root tag from * @param searchTags The tags to search for the root tag (default: ["a", "e"]) * @returns The root tag or undefined if the event does not have a root tag */ export function getRootTag(event: NDKEvent, searchTag?: string): NDKTag | undefined { searchTag ??= event.tagType(); const rootEventTag = event.tags.find(isTagRootTag); if (!rootEventTag) { // If we don't have an explicit root marker, this event has no other e-tag markers // and we have a single e-tag, return that value if (eventHasETagMarkers(event)) return; const matchingTags = event.getMatchingTags(searchTag); if (matchingTags.length < 3) return matchingTags[0]; } return rootEventTag; } const nip22RootTags = new Set(["A", "E", "I"]); const nip22ReplyTags = new Set(["a", "e", "i"]); export function getReplyTag(event: NDKEvent, searchTag?: string): NDKTag | undefined { if (event.kind === NDKKind.GenericReply) { let replyTag: NDKTag | undefined; for (const tag of event.tags) { // we look for an "e", "a", or "i" tag and immediately return it if we find it; // if we don't find an "e", "a", or "i" tag, we return the "E", "A", or "I" tag if (nip22RootTags.has(tag[0])) replyTag = tag; else if (nip22ReplyTags.has(tag[0])) { replyTag = tag; break; } } return replyTag; } searchTag ??= event.tagType(); let hasMarkers = false; let replyTag: NDKTag | undefined; for (const tag of event.tags) { if (tag[0] !== searchTag) continue; if ((tag[3] ?? "").length > 0) hasMarkers = true; if (hasMarkers && tag[3] === "reply") return tag; if (hasMarkers && tag[3] === "root") replyTag = tag; if (!hasMarkers) replyTag = tag; } return replyTag; } function isTagRootTag(tag: NDKTag): boolean { return tag[0] === "E" || tag[3] === "root"; }