diff --git a/js/nostr/client.js b/js/nostr/client.js index 1572ef1e..a56f7600 100644 --- a/js/nostr/client.js +++ b/js/nostr/client.js @@ -2217,6 +2217,44 @@ export class NostrClient { return resolveActiveSigner(normalizedPubkey || extensionPubkey); } + resolveEventDTag(event, fallbackEvent = null) { + const immediate = getDTagValueFromTags(event?.tags); + if (immediate) { + return immediate; + } + + const fallbackTag = getDTagValueFromTags(fallbackEvent?.tags); + if (fallbackTag) { + return fallbackTag; + } + + const candidateIds = []; + const pushCandidate = (candidate) => { + if (typeof candidate === "string" && candidate.trim()) { + const normalized = candidate.trim(); + if (!candidateIds.includes(normalized)) { + candidateIds.push(normalized); + } + } + }; + + pushCandidate(event?.id); + pushCandidate(fallbackEvent?.id); + + for (const candidateId of candidateIds) { + const rawEvent = this.rawEvents?.get?.(candidateId); + if (!rawEvent) { + continue; + } + const rawTag = getDTagValueFromTags(rawEvent.tags); + if (rawTag) { + return rawTag; + } + } + + return ""; + } + applyRootCreatedAt(video) { if (!video || typeof video !== "object") { return null; @@ -4283,8 +4321,9 @@ export class NostrClient { // Use the existing videoRootId (or fall back to the base event's ID) const oldRootId = baseEvent.videoRootId || baseEvent.id; - // Generate a new d-tag so that the edit gets its own share link - const newD = `${Date.now()}-edit-${Math.random().toString(36).slice(2)}`; + const preservedDTag = this.resolveEventDTag(baseEvent, originalEventStub); + const finalDTagValue = + preservedDTag || `${Date.now()}-edit-${Math.random().toString(36).slice(2)}`; // Build the updated content object const contentObject = { @@ -4329,13 +4368,13 @@ export class NostrClient { const event = buildVideoPostEvent({ pubkey: userPubkeyLower, created_at: Math.floor(Date.now() / 1000), - dTagValue: newD, + dTagValue: finalDTagValue, content: contentObject, additionalTags: nip71Tags, }); devLogger.log("Creating edited event with root ID:", oldRootId); - devLogger.log("Event content:", event.content); + devLogger.log("Event content:", event.content); await this.ensureActiveSignerForPubkey(userPubkeyLower); @@ -4458,9 +4497,7 @@ export class NostrClient { }; } - const safeTags = Array.isArray(baseEvent.tags) ? baseEvent.tags : []; - const dTag = safeTags.find((t) => t[0] === "d"); - const existingD = dTag ? dTag[1] : null; + const existingD = this.resolveEventDTag(baseEvent, originalEvent) || null; let oldContent = {}; try { @@ -4626,7 +4663,7 @@ export class NostrClient { (targetVideo && typeof targetVideo.videoRootId === "string" ? targetVideo.videoRootId.trim() : ""); - const targetDTag = targetVideo ? getDTagValueFromTags(targetVideo.tags) : ""; + const targetDTag = this.resolveEventDTag(targetVideo); if (targetVideo) { try { @@ -4656,7 +4693,7 @@ export class NostrClient { const candidateRoot = typeof candidate.videoRootId === "string" ? candidate.videoRootId : ""; - const candidateDTag = getDTagValueFromTags(candidate.tags); + const candidateDTag = this.resolveEventDTag(candidate); const sameRoot = inferredRoot && candidateRoot === inferredRoot; const sameD = targetDTag && candidateDTag === targetDTag; const legacyMatch = @@ -5843,7 +5880,7 @@ export class NostrClient { const targetRoot = typeof video.videoRootId === "string" ? video.videoRootId : ""; const targetPubkey = typeof video.pubkey === "string" ? video.pubkey.toLowerCase() : ""; - const targetDTag = getDTagValueFromTags(video.tags); + const targetDTag = this.resolveEventDTag(video); const collectLocalMatches = () => { const seen = new Set(); @@ -5863,7 +5900,7 @@ export class NostrClient { const candidateRoot = typeof candidate.videoRootId === "string" ? candidate.videoRootId : ""; - const candidateDTag = getDTagValueFromTags(candidate.tags); + const candidateDTag = this.resolveEventDTag(candidate, video); const sameRoot = targetRoot && candidateRoot === targetRoot; const sameD = targetDTag && candidateDTag === targetDTag; diff --git a/tests/nostr/edit-video-dtag.test.mjs b/tests/nostr/edit-video-dtag.test.mjs new file mode 100644 index 00000000..34d73f1e --- /dev/null +++ b/tests/nostr/edit-video-dtag.test.mjs @@ -0,0 +1,98 @@ +import "../test-helpers/setup-localstorage.mjs"; +import test from "node:test"; +import assert from "node:assert/strict"; +import { + NostrClient, + setActiveSigner, + clearActiveSigner, +} from "../../js/nostr.js"; + +const noop = () => {}; + +function createStubPool() { + return { + publish(urls, event) { + noop(event); + return { + on(type, handler) { + if (type === "ok") { + handler(); + return true; + } + if (type === "failed") { + return true; + } + return false; + }, + }; + }, + }; +} + +test("editVideo preserves existing d tag", async (t) => { + const client = new NostrClient(); + const pubkey = "ABC123"; + const existingD = "stable-d-tag"; + const baseEvent = { + id: "event-original", + pubkey, + version: 3, + videoRootId: "root-abc", + title: "Original Title", + url: "https://example.com/video.mp4", + magnet: "magnet:?xt=urn:btih:example", + ws: "wss://example.com", + xs: "https://example.com/file.torrent", + enableComments: true, + isPrivate: false, + isNsfw: false, + isForKids: false, + nip71: null, + thumbnail: "https://example.com/thumb.jpg", + description: "original", + mode: "live", + tags: [ + ["t", "video"], + ["d", existingD], + ], + }; + + client.getEventById = async (id) => (id === baseEvent.id ? baseEvent : null); + client.relays = ["wss://relay.one"]; + client.pool = createStubPool(); + client.ensureExtensionPermissions = async () => ({ ok: true }); + + const signedEvents = []; + const signer = { + type: "app", + pubkey, + signEvent: async (event) => { + signedEvents.push(event); + return { ...event, id: `signed-${signedEvents.length}` }; + }, + }; + + setActiveSigner(signer); + client.ensureActiveSignerForPubkey = async () => signer; + + t.after(() => { + clearActiveSigner(); + signedEvents.length = 0; + }); + + const result = await client.editVideo({ id: baseEvent.id }, { title: "Updated" }, pubkey); + + assert.ok(result, "editVideo should return the signed event"); + assert.equal(signedEvents.length, 1, "editVideo should sign exactly one event"); + const signedEvent = signedEvents[0]; + assert.ok(signedEvent, "signed event should be captured"); + const dTags = signedEvent.tags.filter((tag) => Array.isArray(tag) && tag[0] === "d"); + assert.deepEqual(dTags, [["d", existingD]], "editVideo should preserve the existing d tag"); + + const parsedContent = JSON.parse(signedEvent.content); + assert.equal( + parsedContent.videoRootId, + baseEvent.videoRootId, + "editVideo should preserve the original videoRootId", + ); +});