UNPKG

@nostr-dev-kit/ndk

Version:

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

347 lines (299 loc) 10.5 kB
import type { NDK } from "../../ndk/index.js"; import type { NDKSubscription, NDKSubscriptionOptions } from "../../subscription/index.js"; import type { NDKUser } from "../../user/index.js"; import { NDKEvent, type NostrEvent } from "../index.js"; import { NDKKind } from "./index.js"; /** * Callback type for collaborative event updates */ export type CollaborativeEventUpdateCallback = (event: NDKEvent) => void; /** * Represents a NIP-C1 collaborative event (kind 39382). * * Collaborative events allow multiple users to collaborate on a single document/event. * The pointer event contains p-tags for all authors/owners and references the target * event kind via a k-tag. * * @example Creating a collaborative document * ```typescript * const article = new NDKArticle(ndk); * article.title = "hello world"; * article.content = "an article by bob, alice and me"; * await article.publish(); * * const collabDoc = new NDKCollaborativeEvent(ndk); * collabDoc.authors.push(new NDKUser({ pubkey: 'bob' })); * collabDoc.authors.push(alice); * collabDoc.save(article); * await collabDoc.publish(); * ``` * * @example Reading a collaborative document * ```typescript * const collabDoc = await ndk.fetchEvent('naddr1-to-collaborative-document'); * collabDoc.start(); * * collabDoc.onUpdate((e: NDKEvent) => { * console.log('collaborative document updated by', e.pubkey); * }); * * const latestVersion = collabDoc.currentVersion; * collabDoc.stop(); * ``` * * @group Kind Wrapper */ export class NDKCollaborativeEvent extends NDKEvent { static kind = NDKKind.CollaborativeEvent; static kinds = [NDKKind.CollaborativeEvent]; /** * The list of authors/owners of the collaborative document. * These are reflected as p-tags in the event. */ private _authors: NDKUser[] = []; /** * The current best version of the collaborative document. * This is the event with the highest created_at from any of the authors. */ private _currentVersion: NDKEvent | undefined; /** * Active subscription for live updates */ private _subscription: NDKSubscription | undefined; /** * Callbacks for update notifications */ private _updateCallbacks: Set<CollaborativeEventUpdateCallback> = new Set(); constructor(ndk: NDK | undefined, rawEvent?: NostrEvent | NDKEvent) { super(ndk, rawEvent); this.kind ??= NDKKind.CollaborativeEvent; // Parse existing p-tags into authors if (rawEvent) { this._parseAuthorsFromTags(); } } /** * Creates an NDKCollaborativeEvent from an existing NDKEvent. * * @param event NDKEvent to create the NDKCollaborativeEvent from. * @returns NDKCollaborativeEvent */ static from(event: NDKEvent): NDKCollaborativeEvent { return new NDKCollaborativeEvent(event.ndk, event); } /** * Parse p-tags from the event into NDKUser objects */ private _parseAuthorsFromTags(): void { const ndk = this.ndk; if (!ndk) return; const pTags = this.getMatchingTags("p"); this._authors = pTags.map((tag) => ndk.getUser({ pubkey: tag[1] })); } /** * Get the list of authors/owners of the collaborative document. * Modifying this array directly will update the p-tags on publish. */ get authors(): NDKUser[] { return this._authors; } /** * Set the authors of the collaborative document. */ set authors(users: NDKUser[]) { this._authors = users; } /** * Get all author pubkeys as an array of strings. */ get authorPubkeys(): string[] { return this._authors.map((u) => u.pubkey); } /** * Get the target event kind that this collaborative event references. */ get targetKind(): number | undefined { const kTag = this.tagValue("k"); return kTag ? parseInt(kTag, 10) : undefined; } /** * Set the target event kind. */ set targetKind(kind: number | undefined) { this.removeTag("k"); if (kind !== undefined) { this.tags.push(["k", kind.toString()]); } } /** * Get the current best version of the collaborative document. * This is the event with the highest created_at from any of the authors. */ get currentVersion(): NDKEvent | undefined { return this._currentVersion; } /** * Synchronize the internal tags with the current authors list. * This should be called before publishing. */ private _syncAuthorsToTags(): void { // Remove existing p-tags this.removeTag("p"); // Add p-tags for each author for (const author of this._authors) { this.tags.push(["p", author.pubkey]); } } /** * Save an event as the collaborative document. * This sets up the d-tag to match the target event's d-tag and the k-tag * to specify the target kind. * * @param targetEvent The event to be referenced by this collaborative pointer */ save(targetEvent: NDKEvent): void { // Set the d-tag to match the target event's d-tag const targetDTag = targetEvent.dTag; if (!targetDTag) { throw new Error("Target event must have a d-tag for collaborative events"); } this.dTag = targetDTag; // Set the k-tag to the target event's kind this.targetKind = targetEvent.kind; // Store the event reference this._currentVersion = targetEvent; // The pubkey of the target event author should be included in authors // if not already present if (!this._authors.find((a) => a.pubkey === targetEvent.pubkey)) { if (this.ndk) { this._authors.push(this.ndk.getUser({ pubkey: targetEvent.pubkey })); } } } /** * Publish the collaborative event. * This will: * 1. Sync authors to p-tags * 2. Publish the pointer event (kind 39382) * 3. Check if the target event has a backlink to this pointer * 4. If not, add the backlink and republish the target event */ async publish( relaySet?: import("../../relay/sets/index.js").NDKRelaySet, timeoutMs?: number, requiredRelayCount?: number, ): Promise<Set<import("../../relay/index.js").NDKRelay>> { // Sync authors to p-tags before publishing this._syncAuthorsToTags(); // Publish the pointer event const relays = await super.publish(relaySet, timeoutMs, requiredRelayCount); // Check if we have a target event and it needs a backlink if (this._currentVersion) { await this._ensureBacklink(this._currentVersion); } return relays; } /** * Ensure the target event has an 'a' tag pointing back to this collaborative pointer. * If not, add it and republish the target event. */ private async _ensureBacklink(targetEvent: NDKEvent): Promise<void> { const pointerAddress = this.tagAddress(); // Check if the target event already has a backlink const existingATags = targetEvent.getMatchingTags("a"); const hasBacklink = existingATags.some((tag) => tag[1] === pointerAddress); if (!hasBacklink) { // Add the backlink targetEvent.tags.push(["a", pointerAddress]); // Republish the target event await targetEvent.publishReplaceable(); } } /** * Start a live subscription for updates to this collaborative document. * This subscribes to all events from the authors with the matching d-tag and kind. * * @param opts Optional subscription options */ start(opts?: NDKSubscriptionOptions): void { if (!this.ndk) { throw new Error("NDK instance is required to start subscription"); } if (this._subscription) { // Already running return; } const authors = this.authorPubkeys; const dTag = this.dTag; const targetKind = this.targetKind; if (authors.length === 0) { throw new Error("No authors defined for collaborative event"); } if (!dTag) { throw new Error("No d-tag defined for collaborative event"); } if (targetKind === undefined || Number.isNaN(targetKind)) { throw new Error("No target kind defined for collaborative event"); } // Create subscription filter const filter = { kinds: [targetKind], authors, "#d": [dTag], }; this._subscription = this.ndk.subscribe(filter, { closeOnEose: false, ...opts, }); this._subscription.on("event", (event: NDKEvent) => { this._handleIncomingEvent(event); }); } /** * Handle an incoming event from the subscription. * Updates the current best version if this event is newer. */ private _handleIncomingEvent(event: NDKEvent): void { // Check if this event is newer than our current best const isNewer = !this._currentVersion || (event.created_at ?? 0) > (this._currentVersion.created_at ?? 0); if (isNewer) { this._currentVersion = event; } // Notify all update callbacks for (const callback of this._updateCallbacks) { callback(event); } } /** * Register a callback to be called when the collaborative document is updated. * * @param callback Function to call when an update is received */ onUpdate(callback: CollaborativeEventUpdateCallback): void { this._updateCallbacks.add(callback); } /** * Remove an update callback. * * @param callback The callback to remove */ offUpdate(callback: CollaborativeEventUpdateCallback): void { this._updateCallbacks.delete(callback); } /** * Stop the live subscription and cleanup resources. */ stop(): void { if (this._subscription) { this._subscription.stop(); this._subscription = undefined; } this._updateCallbacks.clear(); } /** * Check if the subscription is currently active. */ get isRunning(): boolean { return this._subscription !== undefined; } }