From cb4610db98c4117f1746c9769f78bf99f524d332 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=AA=E7=8C=AB?= Date: Tue, 18 Aug 2026 07:03:26 +0000 Subject: [PATCH 1/5] Support copying and deleting old-format bookmarks --- web/src/lib/Author.ts | 10 +- web/src/lib/WebStorage.ts | 4 + web/src/lib/author/Bookmark.test.ts | 235 ++++++++++++++++-- web/src/lib/author/Bookmark.ts | 193 +++++++------- web/src/lib/author/Delete.test.ts | 58 +++++ web/src/lib/author/Delete.ts | 26 ++ web/src/lib/i18n/locales/en.json | 28 ++- web/src/lib/i18n/locales/ja.json | 28 ++- web/src/lib/timelines/HomeTimeline.ts | 9 +- .../(app)/[slug=npub]/bookmarks/+page.svelte | 106 +++++++- .../bookmarks/BookmarkPageState.test.ts | 4 + 11 files changed, 586 insertions(+), 115 deletions(-) create mode 100644 web/src/lib/author/Delete.test.ts diff --git a/web/src/lib/Author.ts b/web/src/lib/Author.ts index ab1a99b35..a9d20d83f 100644 --- a/web/src/lib/Author.ts +++ b/web/src/lib/Author.ts @@ -30,6 +30,7 @@ import { replaceableKinds } from './Constants'; import { bookmarkEvent, legacyBookmarkEvent } from './author/Bookmark'; +import { deletedEventCoordinates } from './author/Delete'; import { legacyProfileBadgesKey, setProfileBadgesEvent } from './author/ProfileBadges'; import { profileBadgesKind } from './ProfileBadgesEvent'; import { contactsOfFolloweesReqEmit } from './author/MuteAutomatically'; @@ -121,8 +122,15 @@ export class Author { } bookmarkEvent.set(replaceableEvents.get(Kind.BookmarkList)); + const legacyBookmark = parameterizedReplaceableEvents.get( + `${Kind.Genericlists}:${legacyBookmarkIdentifier}` + ); legacyBookmarkEvent.set( - parameterizedReplaceableEvents.get(`${Kind.Genericlists}:${legacyBookmarkIdentifier}`) + get(deletedEventCoordinates).has( + `${Kind.Genericlists}:${this.pubkey}:${legacyBookmarkIdentifier}` + ) + ? undefined + : legacyBookmark ); setProfileBadgesEvent( replaceableEvents.get(profileBadgesKind), diff --git a/web/src/lib/WebStorage.ts b/web/src/lib/WebStorage.ts index f422bcb2b..d41ca4e54 100644 --- a/web/src/lib/WebStorage.ts +++ b/web/src/lib/WebStorage.ts @@ -76,6 +76,10 @@ export class WebStorage { } } + public removeParameterizedReplaceableEvent(kind: number, identifier: string): void { + this.remove(`kind:${kind}:${identifier}`); + } + public getCachedAt(): number | null { const cachedAt = this.get('cached_at'); return cachedAt === null ? null : Number(cachedAt); diff --git a/web/src/lib/author/Bookmark.test.ts b/web/src/lib/author/Bookmark.test.ts index 90e74b81a..9f9a873b9 100644 --- a/web/src/lib/author/Bookmark.test.ts +++ b/web/src/lib/author/Bookmark.test.ts @@ -1,55 +1,242 @@ -import { describe, expect, it, vi } from 'vitest'; -import { of } from 'rxjs'; +import { get } from 'svelte/store'; +import { of, throwError } from 'rxjs'; import { kinds as Kind } from 'nostr-tools'; +import type * as Nostr from 'nostr-typedef'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; -const { signEvent } = vi.hoisted(() => ({ - signEvent: vi.fn(async (event) => ({ ...event, id: 'signed', pubkey: 'pubkey', sig: 'sig' })) +const mocks = vi.hoisted(() => ({ + stored: undefined as Nostr.Event | undefined, + sendFails: false, + sent: [] as Nostr.Event[], + privateByContent: new Map(), + signCount: 0, + deleteAddressableEvent: vi.fn(), + removedLegacyCache: false })); vi.mock('$lib/timelines/MainTimeline', () => ({ - rxNostr: { send: () => of({ ok: true }) } + rxNostr: { + send: (event: Nostr.Event) => { + mocks.sent.push(event); + return mocks.sendFails ? throwError(() => new Error('rejected')) : of({ ok: true }); + } + } +})); +vi.mock('$lib/Signer', () => ({ + Signer: { + signEvent: vi.fn(async (event) => ({ + ...event, + id: `signed-${++mocks.signCount}`, + pubkey: 'pubkey', + sig: 'sig' + })) + } +})); +vi.mock('$lib/List', () => ({ + decryptListContent: vi.fn(async (_pubkey: string, content: string) => [ + mocks.privateByContent.get(content) ?? [], + content.startsWith('nip04:') + ]), + encryptListContent: vi.fn( + async (tags: string[][], legacy = false) => + `${legacy ? 'nip04' : 'nip44'}:${JSON.stringify(tags)}` + ) })); -vi.mock('$lib/Signer', () => ({ Signer: { signEvent } })); -vi.mock('$lib/RxNostrHelper', () => ({ fetchLastEvent: vi.fn(async () => undefined) })); +vi.mock('$lib/RxNostrHelper', () => ({ + fetchLastEvent: vi.fn(async () => mocks.stored) +})); +vi.mock('$lib/stores/Author', async () => { + const { writable } = await import('svelte/store'); + return { pubkey: writable('pubkey') }; +}); vi.mock('$lib/WebStorage', () => ({ WebStorage: class { getReplaceableEvent() { - return undefined; + return mocks.stored; + } + setReplaceableEvent(event: Nostr.Event) { + mocks.stored = event; + } + removeParameterizedReplaceableEvent() { + mocks.removedLegacyCache = true; } - - setReplaceableEvent() {} } })); +vi.mock('./Delete', () => ({ deleteAddressableEvent: mocks.deleteAddressableEvent })); vi.stubGlobal('localStorage', {}); -import { bookmark, bookmarkEvent, legacyBookmarkEvent, updateBookmarkTags } from './Bookmark'; -import { get } from 'svelte/store'; +import { + bookmark, + bookmarkEvent, + copyLegacyBookmarks, + deleteLegacyBookmarks, + legacyBookmarkEvent, + mergeBookmarkReferences, + updateBookmarkTags +} from './Bookmark'; + +const event = (values: Partial): Nostr.Event => + ({ + id: 'event', + pubkey: 'pubkey', + kind: Kind.BookmarkList, + created_at: 10, + content: '', + tags: [], + sig: 'sig', + ...values + }) as Nostr.Event; describe('Bookmark', () => { - it('publishes updates as the standard NIP-51 bookmark kind', async () => { - await bookmark(['e', 'event-id']); + beforeEach(() => { + mocks.stored = undefined; + mocks.sendFails = false; + mocks.sent = []; + mocks.privateByContent.clear(); + mocks.signCount = 0; + mocks.removedLegacyCache = false; + mocks.deleteAddressableEvent.mockReset(); + bookmarkEvent.set(undefined); + legacyBookmarkEvent.set(undefined); + }); - expect(signEvent).toHaveBeenCalledWith( - expect.objectContaining({ kind: Kind.BookmarkList }) + it('adds and removes e/a references without duplicating them', () => { + const added = updateBookmarkTags([], { type: 'bookmark', tag: ['a', '30023:p:d'] }); + expect(updateBookmarkTags(added, { type: 'bookmark', tag: ['a', '30023:p:d'] })).toEqual( + added + ); + expect(updateBookmarkTags(added, { type: 'unbookmark', tag: ['a', '30023:p:d'] })).toEqual( + [] ); }); - it('adds and removes bookmark tags without an addressable-event identifier', () => { - const added = updateBookmarkTags([], { type: 'bookmark', tag: ['e', 'event-id'] }); - expect(added).toEqual([['e', 'event-id']]); - expect(updateBookmarkTags(added, { type: 'unbookmark', tag: ['e', 'event-id'] })).toEqual( - [] + it('merges only bookmark references and preserves destination tags', () => { + expect( + mergeBookmarkReferences( + [ + ['e', 'existing'], + ['client', 'nostter'] + ], + [ + ['e', 'existing'], + ['a', '30023:p:d'], + ['p', 'unsupported'] + ] + ) + ).toEqual([ + ['e', 'existing'], + ['client', 'nostter'], + ['a', '30023:p:d'] + ]); + }); + + it('copies public and NIP-04 private bookmarks into NIP-44 without changing legacy state', async () => { + mocks.stored = event({ + id: 'standard', + tags: [ + ['e', 'existing'], + ['client', 'nostter'] + ], + content: 'standard-private' + }); + mocks.privateByContent.set('standard-private', [ + ['a', 'existing-private'], + ['x', 'keep-private'] + ]); + const legacy = event({ + id: 'legacy', + kind: Kind.Genericlists, + tags: [ + ['d', 'bookmark'], + ['e', 'existing'], + ['a', 'public-new'] + ], + content: 'nip04:legacy-private' + }); + mocks.privateByContent.set('nip04:legacy-private', [ + ['a', 'existing-private'], + ['e', 'private-new'], + ['p', 'unsupported'] + ]); + legacyBookmarkEvent.set(legacy); + + const copied = await copyLegacyBookmarks(); + + expect(copied.tags).toEqual([ + ['e', 'existing'], + ['client', 'nostter'], + ['a', 'public-new'] + ]); + expect(copied.content).toBe( + 'nip44:[["a","existing-private"],["x","keep-private"],["e","private-new"]]' ); + expect(get(bookmarkEvent)).toBe(copied); + expect(get(legacyBookmarkEvent)).toBe(legacy); + expect(mocks.sent).toHaveLength(1); + }); + + it('uses the newer relay event instead of a stale local bookmark snapshot', async () => { + mocks.stored = event({ id: 'relay-newer', created_at: 20, tags: [['e', 'remote']] }); + legacyBookmarkEvent.set(event({ kind: Kind.Genericlists, tags: [['e', 'legacy']] })); + + const copied = await copyLegacyBookmarks(); + + expect(copied.tags).toEqual([ + ['e', 'remote'], + ['e', 'legacy'] + ]); }); - it('keeps standard and legacy bookmark events in separate state', () => { - const standard = { id: 'standard' } as never; - const legacy = { id: 'legacy' } as never; + it('serializes normal bookmark writes with bulk copy without losing either change', async () => { + legacyBookmarkEvent.set(event({ kind: Kind.Genericlists, tags: [['e', 'legacy']] })); + + await Promise.all([bookmark(['e', 'normal']), copyLegacyBookmarks()]); + expect(mocks.stored?.tags).toEqual([ + ['e', 'normal'], + ['e', 'legacy'] + ]); + expect(mocks.sent).toHaveLength(2); + }); + + it('does not update standard or legacy local state when publish fails', async () => { + const standard = event({ id: 'standard', tags: [['e', 'existing']] }); + const legacy = event({ id: 'legacy', kind: Kind.Genericlists, tags: [['e', 'legacy']] }); + mocks.stored = standard; bookmarkEvent.set(standard); legacyBookmarkEvent.set(legacy); + mocks.sendFails = true; + await expect(copyLegacyBookmarks()).rejects.toThrow(); expect(get(bookmarkEvent)).toBe(standard); expect(get(legacyBookmarkEvent)).toBe(legacy); + expect(mocks.stored).toBe(standard); + }); + + it('hides and clears only old-format local state after deletion publish succeeds', async () => { + const standard = event({ id: 'standard' }); + const legacy = event({ id: 'legacy', kind: Kind.Genericlists }); + bookmarkEvent.set(standard); + legacyBookmarkEvent.set(legacy); + mocks.deleteAddressableEvent.mockResolvedValue(event({ kind: 5 })); + + await deleteLegacyBookmarks(); + + expect(mocks.deleteAddressableEvent).toHaveBeenCalledWith(30001, 'pubkey', 'bookmark'); + expect(mocks.removedLegacyCache).toBe(true); + expect(get(legacyBookmarkEvent)).toBeUndefined(); + expect(get(bookmarkEvent)).toBe(standard); + expect(mocks.sent).toHaveLength(0); + }); + + it('retains old-format local state when deletion publish fails', async () => { + const legacy = event({ id: 'legacy', kind: Kind.Genericlists }); + legacyBookmarkEvent.set(legacy); + mocks.deleteAddressableEvent.mockRejectedValue(new Error('rejected')); + + await expect(deleteLegacyBookmarks()).rejects.toThrow('rejected'); + + expect(mocks.removedLegacyCache).toBe(false); + expect(get(legacyBookmarkEvent)).toBe(legacy); }); }); diff --git a/web/src/lib/author/Bookmark.ts b/web/src/lib/author/Bookmark.ts index 8781e0b30..385fbdc6c 100644 --- a/web/src/lib/author/Bookmark.ts +++ b/web/src/lib/author/Bookmark.ts @@ -3,128 +3,149 @@ import { now } from 'rx-nostr'; import { filter, firstValueFrom } from 'rxjs'; import type * as Nostr from 'nostr-typedef'; import { kinds as Kind } from 'nostr-tools'; -import { rxNostr } from '$lib/timelines/MainTimeline'; -import { Queue } from '$lib/Queue'; +import { decryptListContent, encryptListContent } from '$lib/List'; import { fetchLastEvent } from '$lib/RxNostrHelper'; import { Signer } from '$lib/Signer'; +import { pubkey } from '$lib/stores/Author'; +import { rxNostr } from '$lib/timelines/MainTimeline'; import { WebStorage } from '$lib/WebStorage'; -import { pubkey } from '../stores/Author'; +import { deleteAddressableEvent } from './Delete'; type DataType = 'bookmark' | 'unbookmark'; -type Data = { - type: DataType; - tag: string[]; -}; +type Data = { type: DataType; tag: string[] }; -const queue = new Queue(); - -let processing = false; +const legacyBookmarkIdentifier = 'bookmark'; +let writeQueue = Promise.resolve(); export const bookmarkEvent: Writable = writable(); export const legacyBookmarkEvent: Writable = writable(); export function updateBookmarkTags(tags: string[][], data: Data): string[][] { - if ( - data.type === 'bookmark' && - !tags.some(([tagName, value]) => tagName === data.tag[0] && value === data.tag[1]) - ) { - return [...tags, data.tag]; - } - if ( - data.type === 'unbookmark' && - tags.some(([tagName, value]) => tagName === data.tag[0] && value === data.tag[1]) - ) { - return tags.filter( - ([tagName, value]) => !(tagName === data.tag[0] && value === data.tag[1]) - ); + if (data.type === 'bookmark' && !hasReference(tags, data.tag)) return [...tags, data.tag]; + if (data.type === 'unbookmark' && hasReference(tags, data.tag)) { + return tags.filter((tag) => !sameReference(tag, data.tag)); } return tags; } -// TODO: Private bookmarks -export const isBookmarked = (event: Nostr.Event): boolean => { - const $bookmarkEvent = get(bookmarkEvent); - if ($bookmarkEvent === undefined) { - return false; +const isBookmarkReference = ([name, value]: string[]): boolean => + (name === 'e' || name === 'a') && value !== undefined; +const sameReference = (left: string[], right: string[]): boolean => + left[0] === right[0] && left[1] === right[1]; +const hasReference = (tags: string[][], reference: string[]): boolean => + tags.some((tag) => sameReference(tag, reference)); + +export const mergeBookmarkReferences = ( + destination: string[][], + source: string[][] +): string[][] => { + const merged = destination.map((tag) => [...tag]); + for (const tag of source.filter(isBookmarkReference)) { + if (!hasReference(merged, tag)) merged.push([...tag]); } - return $bookmarkEvent.tags.some(([tagName, id]) => tagName === 'e' && id === event.id); + return merged; }; +export const isBookmarked = (event: Nostr.Event): boolean => + get(bookmarkEvent)?.tags.some(([name, id]) => name === 'e' && id === event.id) ?? false; + +function serialize(operation: () => Promise): Promise { + const result = writeQueue.then(operation, operation); + writeQueue = result.then( + () => undefined, + () => undefined + ); + return result; +} + export async function bookmark(tag: string[]): Promise { - console.log('[bookmark]', tag, queue.dump()); - await save('bookmark', tag); + await serialize(() => updateStandardBookmarks({ type: 'bookmark', tag })); } export async function unbookmark(tag: string[]): Promise { - console.log('[unbookmark]', tag, queue.dump()); - await save('unbookmark', tag); + await serialize(() => updateStandardBookmarks({ type: 'unbookmark', tag })); } -async function save(type: DataType, tag: string[]): Promise { - queue.enqueue({ - type, - tag +export async function copyLegacyBookmarks(): Promise { + const source = get(legacyBookmarkEvent); + if (source === undefined) throw new Error('Old-format bookmarks were not found.'); + return serialize(async () => { + const [privateSourceTags] = await decryptListContent(source.pubkey, source.content); + return publishStandardBookmarks((publicTags, privateTags) => ({ + publicTags: mergeBookmarkReferences(publicTags, source.tags), + privateTags: mergeBookmarkReferences(privateTags, privateSourceTags) + })); }); - - if (!processing) { - processing = true; - await publish(); - processing = false; - } } -async function publish(): Promise { - const storage = new WebStorage(localStorage); - const lastEvent = storage.getReplaceableEvent(Kind.BookmarkList); - let tags = lastEvent?.tags ?? []; - - while (queue.length > 0) { - const data = queue.dequeue(); - if (data === undefined) { - break; - } - - tags = updateBookmarkTags(tags, data); - } - - const event = await Signer.signEvent({ - kind: Kind.BookmarkList, - content: lastEvent?.content ?? '', - tags, - created_at: now() - }); - - bookmarkEvent.set(event); - - // Lazy validation for UX - if (!(await validate(lastEvent))) { - bookmarkEvent.set(lastEvent); - throw new Error('Cache is outdated.'); - } - - storage.setReplaceableEvent(event); - await firstValueFrom(rxNostr.send(event).pipe(filter(({ ok }) => ok))); +export async function deleteLegacyBookmarks(): Promise { + const $pubkey = get(pubkey); + await deleteAddressableEvent(Kind.Genericlists, $pubkey, legacyBookmarkIdentifier); + new WebStorage(localStorage).removeParameterizedReplaceableEvent( + Kind.Genericlists, + legacyBookmarkIdentifier + ); + legacyBookmarkEvent.set(undefined); +} - if (queue.length > 0) { - await publish(); - } +async function updateStandardBookmarks(data: Data): Promise { + await publishStandardBookmarks((publicTags, privateTags) => ({ + publicTags: updateBookmarkTags(publicTags, data), + privateTags + })); } -async function validate(event: Nostr.Event | undefined): Promise { +type BookmarkTags = { publicTags: string[][]; privateTags: string[][] }; + +async function publishStandardBookmarks( + update: (publicTags: string[][], privateTags: string[][]) => BookmarkTags +): Promise { + const storage = new WebStorage(localStorage); const $pubkey = get(pubkey); - const lastEvent = await fetchLastEvent({ + const cached = storage.getReplaceableEvent(Kind.BookmarkList); + const remote = await fetchLastEvent({ kinds: [Kind.BookmarkList], authors: [$pubkey], limit: 1 }); + if (cached !== undefined && remote === undefined) throw new Error('Cache is outdated.'); + const base = latestEvent(cached, remote); + const [privateTags] = await decryptListContent(base?.pubkey ?? $pubkey, base?.content ?? ''); + const updated = update(base?.tags ?? [], privateTags); - if (event === undefined) { - if (lastEvent !== undefined) { - return false; - } - } else if (lastEvent === undefined || event.created_at < lastEvent.created_at) { - return false; + // Check once more immediately before signing so an event observed during the merge is not lost. + const latestRemote = await fetchLastEvent({ + kinds: [Kind.BookmarkList], + authors: [$pubkey], + limit: 1 + }); + if ( + latestRemote !== undefined && + latestRemote.id !== remote?.id && + latestRemote.id !== base?.id + ) { + return publishStandardBookmarks(update); } - return true; + const event = await Signer.signEvent({ + kind: Kind.BookmarkList, + content: await encryptListContent(updated.privateTags), + tags: updated.publicTags, + created_at: Math.max(now(), (base?.created_at ?? 0) + 1) + }); + await firstValueFrom(rxNostr.send(event).pipe(filter(({ ok }) => ok))); + storage.setReplaceableEvent(event); + bookmarkEvent.set(event); + return event; +} + +function latestEvent( + left: Nostr.Event | undefined, + right: Nostr.Event | undefined +): Nostr.Event | undefined { + if (left === undefined) return right; + if (right === undefined) return left; + if (left.created_at !== right.created_at) + return left.created_at > right.created_at ? left : right; + return left.id > right.id ? left : right; } diff --git a/web/src/lib/author/Delete.test.ts b/web/src/lib/author/Delete.test.ts new file mode 100644 index 000000000..4e682afcc --- /dev/null +++ b/web/src/lib/author/Delete.test.ts @@ -0,0 +1,58 @@ +import { get } from 'svelte/store'; +import { of, throwError } from 'rxjs'; +import type * as Nostr from 'nostr-typedef'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + sent: [] as Nostr.Event[], + fails: false, + signEvent: vi.fn(async (event) => ({ ...event, id: 'deletion', sig: 'sig' })) +})); + +vi.mock('$lib/timelines/MainTimeline', () => ({ + rxNostr: { + send: (event: Nostr.Event) => { + mocks.sent.push(event); + return mocks.fails ? throwError(() => new Error('failed')) : of({ ok: true }); + } + } +})); +vi.mock('$lib/Signer', () => ({ Signer: { signEvent: mocks.signEvent } })); +vi.mock('$lib/stores/Author', async () => { + const { writable } = await import('svelte/store'); + return { pubkey: writable('author') }; +}); + +import { deleteAddressableEvent, deletedEventCoordinates } from './Delete'; + +describe('deleteAddressableEvent', () => { + beforeEach(() => { + mocks.sent = []; + mocks.fails = false; + mocks.signEvent.mockClear(); + deletedEventCoordinates.set(new Set()); + }); + + it('publishes only a NIP-09 address deletion request and records relay success', async () => { + await deleteAddressableEvent(30001, 'author', 'bookmark'); + + expect(mocks.signEvent).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 5, + tags: [ + ['a', '30001:author:bookmark'], + ['k', '30001'] + ] + }) + ); + expect(mocks.sent).toHaveLength(1); + expect(mocks.sent[0].kind).toBe(5); + expect(get(deletedEventCoordinates)).toContain('30001:author:bookmark'); + }); + + it('does not record deletion when no relay accepts the request', async () => { + mocks.fails = true; + await expect(deleteAddressableEvent(30001, 'author', 'bookmark')).rejects.toThrow(); + expect(get(deletedEventCoordinates)).not.toContain('30001:author:bookmark'); + }); +}); diff --git a/web/src/lib/author/Delete.ts b/web/src/lib/author/Delete.ts index 78546b614..8d2e3eb4e 100644 --- a/web/src/lib/author/Delete.ts +++ b/web/src/lib/author/Delete.ts @@ -5,9 +5,11 @@ import { pubkey as authorPubkey } from '$lib/stores/Author'; import { rxNostr } from '$lib/timelines/MainTimeline'; import { Signer } from '$lib/Signer'; import { filterTags } from '$lib/EventHelper'; +import { filter, firstValueFrom } from 'rxjs'; export const deletedEventIds = writable(new Set()); export const deletedEventIdsByPubkey = writable(new Map>()); +export const deletedEventCoordinates = writable(new Set()); export function storeDeletedEvents(event: Nostr.Event): void { const pubkey = event.pubkey; @@ -29,6 +31,30 @@ export function storeDeletedEvents(event: Nostr.Event): void { } } +export async function deleteAddressableEvent( + kind: number, + pubkey: string, + identifier: string, + reason = '' +): Promise { + const $authorPubkey = get(authorPubkey); + if (pubkey !== $authorPubkey) throw new Error("Cannot delete another author's event."); + const coordinate = `${kind}:${pubkey}:${identifier}`; + const event = await Signer.signEvent({ + kind: 5, + pubkey: $authorPubkey, + content: reason, + tags: [ + ['a', coordinate], + ['k', `${kind}`] + ], + created_at: now() + }); + await firstValueFrom(rxNostr.send(event).pipe(filter(({ ok }) => ok))); + deletedEventCoordinates.update((coordinates) => new Set(coordinates).add(coordinate)); + return event; +} + export async function deleteEvent(events: Nostr.Event[], reason = ''): Promise { if (events.length === 0) { return; diff --git a/web/src/lib/i18n/locales/en.json b/web/src/lib/i18n/locales/en.json index f63a4be84..caa2f8d82 100644 --- a/web/src/lib/i18n/locales/en.json +++ b/web/src/lib/i18n/locales/en.json @@ -16,7 +16,33 @@ }, "bookmarks": { "old_format": "Old format", - "private": "Private" + "private": "Private", + "copy": { + "action": "Copy all to Bookmarks", + "running": "Copying…", + "description": "Copies all public and private items to your current Bookmarks. The old format remains unchanged.", + "success": { + "title": "Bookmarks copied", + "description": "The old format is still available." + }, + "failed": { + "title": "Copy failed", + "description": "Your Bookmarks and old format were not changed." + } + }, + "delete": { + "action": "Delete old format", + "running": "Deleting…", + "confirm": "Send a deletion request for the old-format bookmarks? Relays may retain the event.", + "success": { + "title": "Deletion request sent", + "description": "Old-format bookmarks are hidden for this session." + }, + "failed": { + "title": "Deletion request failed", + "description": "Old-format bookmarks were not removed." + } + } }, "pages": { "profile_edit": "Edit profile", diff --git a/web/src/lib/i18n/locales/ja.json b/web/src/lib/i18n/locales/ja.json index 4c877c2f1..842f3366e 100644 --- a/web/src/lib/i18n/locales/ja.json +++ b/web/src/lib/i18n/locales/ja.json @@ -16,7 +16,33 @@ }, "bookmarks": { "old_format": "旧形式", - "private": "プライベート" + "private": "プライベート", + "copy": { + "action": "すべてブックマークへコピー", + "running": "コピー中…", + "description": "公開・プライベートの項目を現在のブックマークへコピーします。旧形式は変更されません。", + "success": { + "title": "ブックマークへコピーしました", + "description": "旧形式はそのまま残っています。" + }, + "failed": { + "title": "コピーに失敗しました", + "description": "ブックマークと旧形式は変更されていません。" + } + }, + "delete": { + "action": "旧形式を削除", + "running": "削除中…", + "confirm": "旧形式のブックマークに削除リクエストを送信しますか?リレーにイベントが残る場合があります。", + "success": { + "title": "削除リクエストを送信しました", + "description": "このセッションでは旧形式を非表示にしました。" + }, + "failed": { + "title": "削除リクエストに失敗しました", + "description": "旧形式のブックマークは削除されていません。" + } + } }, "pages": { "profile_edit": "プロフィールを編集", diff --git a/web/src/lib/timelines/HomeTimeline.ts b/web/src/lib/timelines/HomeTimeline.ts index 3805f3f03..01096a8e6 100644 --- a/web/src/lib/timelines/HomeTimeline.ts +++ b/web/src/lib/timelines/HomeTimeline.ts @@ -16,6 +16,7 @@ import { WebStorage } from '$lib/WebStorage'; import { kinds as Kind } from 'nostr-tools'; import { get } from 'svelte/store'; import { bookmarkEvent, legacyBookmarkEvent } from '$lib/author/Bookmark'; +import { deletedEventCoordinates } from '$lib/author/Delete'; import { authorActionReqEmit, updateReactionedEvents, @@ -176,7 +177,13 @@ export class HomeTimeline extends NewTimeline { addressable$ .pipe( filterByKind(Kind.Genericlists), - filter(({ event }) => findIdentifier(event.tags) === legacyBookmarkIdentifier) + filter(({ event }) => findIdentifier(event.tags) === legacyBookmarkIdentifier), + filter( + ({ event }) => + !get(deletedEventCoordinates).has( + `${event.kind}:${event.pubkey}:${legacyBookmarkIdentifier}` + ) + ) ) .subscribe(({ event }) => legacyBookmarkEvent.set(event)); addressable$ diff --git a/web/src/routes/(app)/[slug=npub]/bookmarks/+page.svelte b/web/src/routes/(app)/[slug=npub]/bookmarks/+page.svelte index 5b4fff3c2..337741cf5 100644 --- a/web/src/routes/(app)/[slug=npub]/bookmarks/+page.svelte +++ b/web/src/routes/(app)/[slug=npub]/bookmarks/+page.svelte @@ -6,7 +6,13 @@ import { _ } from 'svelte-i18n'; import { pubkey as authorPubkey, rom } from '$lib/stores/Author'; import TimelineView from '../../TimelineView.svelte'; - import { bookmarkEvent, legacyBookmarkEvent } from '$lib/author/Bookmark'; + import { + bookmarkEvent, + copyLegacyBookmarks, + deleteLegacyBookmarks, + legacyBookmarkEvent + } from '$lib/author/Bookmark'; + import { addToast } from '$lib/components/Toaster.svelte'; import { authorActionReqEmit } from '$lib/author/Action'; import { appName, reverseChronologicalItem } from '$lib/Constants'; import { filterTags } from '$lib/EventHelper'; @@ -25,6 +31,56 @@ let privateBookmarkEventItems: EventItem[] = $state([]); let privateLegacyBookmarkEventItems: EventItem[] = $state([]); + let copyingLegacyBookmarks = $state(false); + let deletingLegacyBookmarks = $state(false); + + async function copyAllLegacyBookmarks(): Promise { + if (copyingLegacyBookmarks) return; + copyingLegacyBookmarks = true; + try { + await copyLegacyBookmarks(); + addToast({ + data: { + title: $_('bookmarks.copy.success.title'), + description: $_('bookmarks.copy.success.description') + } + }); + } catch (error) { + console.error('[legacy bookmarks copy failed]', error); + addToast({ + data: { + title: $_('bookmarks.copy.failed.title'), + description: $_('bookmarks.copy.failed.description') + } + }); + } finally { + copyingLegacyBookmarks = false; + } + } + + async function deleteAllLegacyBookmarks(): Promise { + if (deletingLegacyBookmarks || !confirm($_('bookmarks.delete.confirm'))) return; + deletingLegacyBookmarks = true; + try { + await deleteLegacyBookmarks(); + addToast({ + data: { + title: $_('bookmarks.delete.success.title'), + description: $_('bookmarks.delete.success.description') + } + }); + } catch (error) { + console.error('[legacy bookmarks delete failed]', error); + addToast({ + data: { + title: $_('bookmarks.delete.failed.title'), + description: $_('bookmarks.delete.failed.description') + } + }); + } finally { + deletingLegacyBookmarks = false; + } + } function loadPublicItems(event: Nostr.Event, addItem: (item: EventItem) => void): () => void { const ids = filterTags('e', event.tags); @@ -91,6 +147,7 @@ // Private bookmarks $effect(() => { + privateBookmarkEventItems = []; if ( data.pubkey === $authorPubkey && !$rom && @@ -131,6 +188,7 @@ // Private legacy bookmarks $effect(() => { + privateLegacyBookmarkEventItems = []; if ( data.pubkey === $authorPubkey && !$rom && @@ -203,6 +261,32 @@ aria-labelledby={`bookmark-tab-${tab.id}`} hidden={selectedBookmarkList?.id !== tab.id} > + {#if tab.id === legacyBookmarkListId && data.pubkey === $authorPubkey && !$rom} +
+
+ +

{$_('bookmarks.copy.description')}

+
+ +
+ {/if}

{$_('pages.public')}

@@ -259,4 +343,24 @@ border-bottom-color: var(--accent); font-weight: bold; } + + .legacy-actions { + display: flex; + align-items: start; + justify-content: space-between; + gap: 1rem; + padding: 1rem; + border: var(--default-border); + border-radius: 0.5rem; + } + + .legacy-actions p { + margin: 0.5rem 0 0; + color: var(--subtle); + } + + .delete-legacy { + color: var(--danger, #c62828); + white-space: nowrap; + } diff --git a/web/src/routes/(app)/[slug=npub]/bookmarks/BookmarkPageState.test.ts b/web/src/routes/(app)/[slug=npub]/bookmarks/BookmarkPageState.test.ts index 4fae60baa..528300a34 100644 --- a/web/src/routes/(app)/[slug=npub]/bookmarks/BookmarkPageState.test.ts +++ b/web/src/routes/(app)/[slug=npub]/bookmarks/BookmarkPageState.test.ts @@ -52,6 +52,10 @@ describe('Bookmark page state', () => { expect(state.publicBookmarkEventItems).toEqual(['standard item']); expect(state.selectedBookmarkListId).toBe(standardBookmarkListId); + legacyEvent.set(undefined); + expect(state.bookmarkListTabs.map(({ id }) => id)).toEqual([standardBookmarkListId]); + expect(state.publicLegacyBookmarkEventItems).toEqual([]); + state.destroy(); expect(unsubscribed).toEqual(expect.arrayContaining(['standard-1', 'legacy-2'])); }); From 92d7e86c2f5ff2bd6e4c0c95e1f26828f2f683cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=AA=E7=8C=AB?= Date: Tue, 18 Aug 2026 09:34:12 +0000 Subject: [PATCH 2/5] Fix bookmark review feedback --- web/src/lib/Author.ts | 6 +-- web/src/lib/List.ts | 25 ++++++---- web/src/lib/author/Bookmark.test.ts | 65 +++++++++++++++++++++++-- web/src/lib/author/Bookmark.ts | 40 +++++++++------ web/src/lib/author/Delete.test.ts | 70 ++++++++++++++++++++++++--- web/src/lib/author/Delete.ts | 32 +++++++++++- web/src/lib/timelines/HomeTimeline.ts | 19 ++++---- 7 files changed, 204 insertions(+), 53 deletions(-) diff --git a/web/src/lib/Author.ts b/web/src/lib/Author.ts index a9d20d83f..c2c53f9bf 100644 --- a/web/src/lib/Author.ts +++ b/web/src/lib/Author.ts @@ -30,7 +30,7 @@ import { replaceableKinds } from './Constants'; import { bookmarkEvent, legacyBookmarkEvent } from './author/Bookmark'; -import { deletedEventCoordinates } from './author/Delete'; +import { isAddressableEventDeleted } from './author/Delete'; import { legacyProfileBadgesKey, setProfileBadgesEvent } from './author/ProfileBadges'; import { profileBadgesKind } from './ProfileBadgesEvent'; import { contactsOfFolloweesReqEmit } from './author/MuteAutomatically'; @@ -126,9 +126,7 @@ export class Author { `${Kind.Genericlists}:${legacyBookmarkIdentifier}` ); legacyBookmarkEvent.set( - get(deletedEventCoordinates).has( - `${Kind.Genericlists}:${this.pubkey}:${legacyBookmarkIdentifier}` - ) + legacyBookmark !== undefined && isAddressableEventDeleted(legacyBookmark) ? undefined : legacyBookmark ); diff --git a/web/src/lib/List.ts b/web/src/lib/List.ts index 76037a580..9997abe84 100644 --- a/web/src/lib/List.ts +++ b/web/src/lib/List.ts @@ -45,22 +45,29 @@ export async function decryptListContent( pubkey: string, content: string ): Promise<[tags: string[][], legacy: boolean]> { - if (content === '') { - return [[], false]; - } - try { - const legacy = isLegacyEncryption(content); - const json = await (legacy - ? Signer.decrypt(pubkey, content) - : Signer.decryptNip44(pubkey, content)); - return [JSON.parse(json), legacy]; + return await decryptListContentStrict(pubkey, content); } catch (error) { console.warn('[list parse error]', error); return [[], false]; } } +export async function decryptListContentStrict( + pubkey: string, + content: string +): Promise<[tags: string[][], legacy: boolean]> { + if (content === '') { + return [[], false]; + } + + const legacy = isLegacyEncryption(content); + const json = await (legacy + ? Signer.decrypt(pubkey, content) + : Signer.decryptNip44(pubkey, content)); + return [JSON.parse(json), legacy]; +} + export async function encryptListContent( tags: string[][], legacy: boolean = false diff --git a/web/src/lib/author/Bookmark.test.ts b/web/src/lib/author/Bookmark.test.ts index 9f9a873b9..ba65aa9d3 100644 --- a/web/src/lib/author/Bookmark.test.ts +++ b/web/src/lib/author/Bookmark.test.ts @@ -9,6 +9,7 @@ const mocks = vi.hoisted(() => ({ sendFails: false, sent: [] as Nostr.Event[], privateByContent: new Map(), + decryptFailures: new Set(), signCount: 0, deleteAddressableEvent: vi.fn(), removedLegacyCache: false @@ -33,10 +34,10 @@ vi.mock('$lib/Signer', () => ({ } })); vi.mock('$lib/List', () => ({ - decryptListContent: vi.fn(async (_pubkey: string, content: string) => [ - mocks.privateByContent.get(content) ?? [], - content.startsWith('nip04:') - ]), + decryptListContentStrict: vi.fn(async (_pubkey: string, content: string) => { + if (mocks.decryptFailures.has(content)) throw new Error('decrypt failed'); + return [mocks.privateByContent.get(content) ?? [], content.startsWith('nip04:')]; + }), encryptListContent: vi.fn( async (tags: string[][], legacy = false) => `${legacy ? 'nip04' : 'nip44'}:${JSON.stringify(tags)}` @@ -71,7 +72,9 @@ import { copyLegacyBookmarks, deleteLegacyBookmarks, legacyBookmarkEvent, + latestEvent, mergeBookmarkReferences, + unbookmark, updateBookmarkTags } from './Bookmark'; @@ -93,6 +96,7 @@ describe('Bookmark', () => { mocks.sendFails = false; mocks.sent = []; mocks.privateByContent.clear(); + mocks.decryptFailures.clear(); mocks.signCount = 0; mocks.removedLegacyCache = false; mocks.deleteAddressableEvent.mockReset(); @@ -199,6 +203,59 @@ describe('Bookmark', () => { expect(mocks.sent).toHaveLength(2); }); + it('preserves encrypted private content when adding a public bookmark', async () => { + mocks.stored = event({ content: 'opaque-encrypted-content' }); + + await bookmark(['e', 'normal']); + + expect(mocks.sent[0].content).toBe('opaque-encrypted-content'); + }); + + it('preserves encrypted private content when removing a public bookmark', async () => { + mocks.stored = event({ + content: 'opaque-encrypted-content', + tags: [['e', 'normal']] + }); + + await unbookmark(['e', 'normal']); + + expect(mocks.sent[0].content).toBe('opaque-encrypted-content'); + }); + + it.each([ + ['source', 'broken-source', 'valid-destination'], + ['destination', 'valid-source', 'broken-destination'] + ])( + 'does not publish or change local state when copy %s private content cannot be decrypted', + async (location, sourceContent, destinationContent) => { + const standard = event({ id: 'standard', content: destinationContent }); + const legacy = event({ + id: 'legacy', + kind: Kind.Genericlists, + content: sourceContent + }); + mocks.stored = standard; + bookmarkEvent.set(standard); + legacyBookmarkEvent.set(legacy); + mocks.decryptFailures.add(`broken-${location}`); + + await expect(copyLegacyBookmarks()).rejects.toThrow('decrypt failed'); + + expect(mocks.sent).toHaveLength(0); + expect(mocks.stored).toBe(standard); + expect(get(bookmarkEvent)).toBe(standard); + expect(get(legacyBookmarkEvent)).toBe(legacy); + } + ); + + it('selects the lexicographically lower id when replaceable timestamps match', () => { + const higher = event({ id: 'bbbb', created_at: 20 }); + const lower = event({ id: 'aaaa', created_at: 20 }); + + expect(latestEvent(higher, lower)).toBe(lower); + expect(latestEvent(lower, higher)).toBe(lower); + }); + it('does not update standard or legacy local state when publish fails', async () => { const standard = event({ id: 'standard', tags: [['e', 'existing']] }); const legacy = event({ id: 'legacy', kind: Kind.Genericlists, tags: [['e', 'legacy']] }); diff --git a/web/src/lib/author/Bookmark.ts b/web/src/lib/author/Bookmark.ts index 385fbdc6c..1d3162975 100644 --- a/web/src/lib/author/Bookmark.ts +++ b/web/src/lib/author/Bookmark.ts @@ -3,7 +3,7 @@ import { now } from 'rx-nostr'; import { filter, firstValueFrom } from 'rxjs'; import type * as Nostr from 'nostr-typedef'; import { kinds as Kind } from 'nostr-tools'; -import { decryptListContent, encryptListContent } from '$lib/List'; +import { decryptListContentStrict, encryptListContent } from '$lib/List'; import { fetchLastEvent } from '$lib/RxNostrHelper'; import { Signer } from '$lib/Signer'; import { pubkey } from '$lib/stores/Author'; @@ -70,11 +70,17 @@ export async function copyLegacyBookmarks(): Promise { const source = get(legacyBookmarkEvent); if (source === undefined) throw new Error('Old-format bookmarks were not found.'); return serialize(async () => { - const [privateSourceTags] = await decryptListContent(source.pubkey, source.content); - return publishStandardBookmarks((publicTags, privateTags) => ({ - publicTags: mergeBookmarkReferences(publicTags, source.tags), - privateTags: mergeBookmarkReferences(privateTags, privateSourceTags) - })); + const [privateSourceTags] = await decryptListContentStrict(source.pubkey, source.content); + return publishStandardBookmarks(async (base) => { + const [privateDestinationTags] = await decryptListContentStrict( + base?.pubkey ?? get(pubkey), + base?.content ?? '' + ); + return { + publicTags: mergeBookmarkReferences(base?.tags ?? [], source.tags), + privateTags: mergeBookmarkReferences(privateDestinationTags, privateSourceTags) + }; + }); }); } @@ -89,16 +95,18 @@ export async function deleteLegacyBookmarks(): Promise { } async function updateStandardBookmarks(data: Data): Promise { - await publishStandardBookmarks((publicTags, privateTags) => ({ - publicTags: updateBookmarkTags(publicTags, data), - privateTags + await publishStandardBookmarks((base) => ({ + publicTags: updateBookmarkTags(base?.tags ?? [], data), + content: base?.content ?? '' })); } -type BookmarkTags = { publicTags: string[][]; privateTags: string[][] }; +type BookmarkUpdate = + | { publicTags: string[][]; content: string } + | { publicTags: string[][]; privateTags: string[][] }; async function publishStandardBookmarks( - update: (publicTags: string[][], privateTags: string[][]) => BookmarkTags + update: (base: Nostr.Event | undefined) => BookmarkUpdate | Promise ): Promise { const storage = new WebStorage(localStorage); const $pubkey = get(pubkey); @@ -110,8 +118,7 @@ async function publishStandardBookmarks( }); if (cached !== undefined && remote === undefined) throw new Error('Cache is outdated.'); const base = latestEvent(cached, remote); - const [privateTags] = await decryptListContent(base?.pubkey ?? $pubkey, base?.content ?? ''); - const updated = update(base?.tags ?? [], privateTags); + const updated = await update(base); // Check once more immediately before signing so an event observed during the merge is not lost. const latestRemote = await fetchLastEvent({ @@ -129,7 +136,8 @@ async function publishStandardBookmarks( const event = await Signer.signEvent({ kind: Kind.BookmarkList, - content: await encryptListContent(updated.privateTags), + content: + 'content' in updated ? updated.content : await encryptListContent(updated.privateTags), tags: updated.publicTags, created_at: Math.max(now(), (base?.created_at ?? 0) + 1) }); @@ -139,7 +147,7 @@ async function publishStandardBookmarks( return event; } -function latestEvent( +export function latestEvent( left: Nostr.Event | undefined, right: Nostr.Event | undefined ): Nostr.Event | undefined { @@ -147,5 +155,5 @@ function latestEvent( if (right === undefined) return left; if (left.created_at !== right.created_at) return left.created_at > right.created_at ? left : right; - return left.id > right.id ? left : right; + return left.id < right.id ? left : right; } diff --git a/web/src/lib/author/Delete.test.ts b/web/src/lib/author/Delete.test.ts index 4e682afcc..7491b6aa7 100644 --- a/web/src/lib/author/Delete.test.ts +++ b/web/src/lib/author/Delete.test.ts @@ -4,11 +4,14 @@ import type * as Nostr from 'nostr-typedef'; import { beforeEach, describe, expect, it, vi } from 'vitest'; const mocks = vi.hoisted(() => ({ + author: 'a'.repeat(64), sent: [] as Nostr.Event[], fails: false, signEvent: vi.fn(async (event) => ({ ...event, id: 'deletion', sig: 'sig' })) })); +const author = mocks.author; + vi.mock('$lib/timelines/MainTimeline', () => ({ rxNostr: { send: (event: Nostr.Event) => { @@ -20,39 +23,90 @@ vi.mock('$lib/timelines/MainTimeline', () => ({ vi.mock('$lib/Signer', () => ({ Signer: { signEvent: mocks.signEvent } })); vi.mock('$lib/stores/Author', async () => { const { writable } = await import('svelte/store'); - return { pubkey: writable('author') }; + return { pubkey: writable(mocks.author) }; }); -import { deleteAddressableEvent, deletedEventCoordinates } from './Delete'; +import { + deleteAddressableEvent, + deletedEventCoordinates, + isAddressableEventDeleted, + storeDeletedEvents +} from './Delete'; + +const event = (values: Partial): Nostr.Event => + ({ + id: 'event', + pubkey: author, + kind: 5, + created_at: 10, + content: '', + tags: [], + sig: 'sig', + ...values + }) as Nostr.Event; describe('deleteAddressableEvent', () => { beforeEach(() => { mocks.sent = []; mocks.fails = false; mocks.signEvent.mockClear(); - deletedEventCoordinates.set(new Set()); + deletedEventCoordinates.set(new Map()); }); it('publishes only a NIP-09 address deletion request and records relay success', async () => { - await deleteAddressableEvent(30001, 'author', 'bookmark'); + const deletion = await deleteAddressableEvent(30001, author, 'bookmark'); expect(mocks.signEvent).toHaveBeenCalledWith( expect.objectContaining({ kind: 5, tags: [ - ['a', '30001:author:bookmark'], + ['a', `30001:${author}:bookmark`], ['k', '30001'] ] }) ); expect(mocks.sent).toHaveLength(1); expect(mocks.sent[0].kind).toBe(5); - expect(get(deletedEventCoordinates)).toContain('30001:author:bookmark'); + expect(get(deletedEventCoordinates).get(`30001:${author}:bookmark`)).toBe( + deletion.created_at + ); }); it('does not record deletion when no relay accepts the request', async () => { mocks.fails = true; - await expect(deleteAddressableEvent(30001, 'author', 'bookmark')).rejects.toThrow(); - expect(get(deletedEventCoordinates)).not.toContain('30001:author:bookmark'); + await expect(deleteAddressableEvent(30001, author, 'bookmark')).rejects.toThrow(); + expect(get(deletedEventCoordinates)).not.toContain(`30001:${author}:bookmark`); + }); + + it('hides only addressable versions at or before the deletion timestamp', () => { + storeDeletedEvents(event({ created_at: 20, tags: [['a', `30001:${author}:bookmark`]] })); + const legacy = (created_at: number) => + event({ kind: 30001, created_at, tags: [['d', 'bookmark']] }); + + expect(isAddressableEventDeleted(legacy(20))).toBe(true); + expect(isAddressableEventDeleted(legacy(21))).toBe(false); + }); + + it('records received valid deletion requests and keeps the newest timestamp', () => { + const coordinate = `30001:${author}:bookmark`; + storeDeletedEvents(event({ created_at: 30, tags: [['a', coordinate]] })); + storeDeletedEvents(event({ created_at: 20, tags: [['a', coordinate]] })); + + expect(get(deletedEventCoordinates).get(coordinate)).toBe(30); + }); + + it('ignores malformed coordinates and coordinates for another author', () => { + const otherAuthor = 'b'.repeat(64); + storeDeletedEvents( + event({ + tags: [ + ['a', 'invalid'], + ['a', `1:${author}:bookmark`], + ['a', `30001:${otherAuthor}:bookmark`] + ] + }) + ); + + expect(get(deletedEventCoordinates)).toEqual(new Map()); }); }); diff --git a/web/src/lib/author/Delete.ts b/web/src/lib/author/Delete.ts index 8d2e3eb4e..f2e28a015 100644 --- a/web/src/lib/author/Delete.ts +++ b/web/src/lib/author/Delete.ts @@ -6,12 +6,40 @@ import { rxNostr } from '$lib/timelines/MainTimeline'; import { Signer } from '$lib/Signer'; import { filterTags } from '$lib/EventHelper'; import { filter, firstValueFrom } from 'rxjs'; +import { isAddressableKind } from 'nostr-tools/kinds'; export const deletedEventIds = writable(new Set()); export const deletedEventIdsByPubkey = writable(new Map>()); -export const deletedEventCoordinates = writable(new Set()); +export const deletedEventCoordinates = writable(new Map()); + +const addressableCoordinate = /^(\d+):([0-9a-f]{64}):(.*)$/; + +function storeDeletedEventCoordinates(event: Nostr.Event): void { + const coordinates = filterTags('a', event.tags).filter((coordinate) => { + const match = addressableCoordinate.exec(coordinate); + return match !== null && isAddressableKind(Number(match[1])) && match[2] === event.pubkey; + }); + if (coordinates.length === 0) return; + + deletedEventCoordinates.update((stored) => { + const updated = new Map(stored); + for (const coordinate of coordinates) { + updated.set(coordinate, Math.max(updated.get(coordinate) ?? 0, event.created_at)); + } + return updated; + }); +} + +export function isAddressableEventDeleted(event: Nostr.Event): boolean { + const identifier = event.tags.find(([name]) => name === 'd')?.[1] ?? ''; + const deletedAt = get(deletedEventCoordinates).get( + `${event.kind}:${event.pubkey}:${identifier}` + ); + return deletedAt !== undefined && event.created_at <= deletedAt; +} export function storeDeletedEvents(event: Nostr.Event): void { + storeDeletedEventCoordinates(event); const pubkey = event.pubkey; const ids = filterTags('e', event.tags); @@ -51,7 +79,7 @@ export async function deleteAddressableEvent( created_at: now() }); await firstValueFrom(rxNostr.send(event).pipe(filter(({ ok }) => ok))); - deletedEventCoordinates.update((coordinates) => new Set(coordinates).add(coordinate)); + storeDeletedEventCoordinates(event); return event; } diff --git a/web/src/lib/timelines/HomeTimeline.ts b/web/src/lib/timelines/HomeTimeline.ts index 01096a8e6..9680ab7e1 100644 --- a/web/src/lib/timelines/HomeTimeline.ts +++ b/web/src/lib/timelines/HomeTimeline.ts @@ -16,7 +16,7 @@ import { WebStorage } from '$lib/WebStorage'; import { kinds as Kind } from 'nostr-tools'; import { get } from 'svelte/store'; import { bookmarkEvent, legacyBookmarkEvent } from '$lib/author/Bookmark'; -import { deletedEventCoordinates } from '$lib/author/Delete'; +import { isAddressableEventDeleted } from '$lib/author/Delete'; import { authorActionReqEmit, updateReactionedEvents, @@ -178,12 +178,7 @@ export class HomeTimeline extends NewTimeline { .pipe( filterByKind(Kind.Genericlists), filter(({ event }) => findIdentifier(event.tags) === legacyBookmarkIdentifier), - filter( - ({ event }) => - !get(deletedEventCoordinates).has( - `${event.kind}:${event.pubkey}:${legacyBookmarkIdentifier}` - ) - ) + filter(({ event }) => !isAddressableEventDeleted(event)) ) .subscribe(({ event }) => legacyBookmarkEvent.set(event)); addressable$ @@ -208,9 +203,13 @@ export class HomeTimeline extends NewTimeline { latestEach(({ event }) => event.pubkey) ) .subscribe(({ event }) => storeMetadata(event)); - observable$ - .pipe(filterByKind(Kind.EventDeletion)) - .subscribe(({ event }) => storeDeletedEvents(event)); + observable$.pipe(filterByKind(Kind.EventDeletion)).subscribe(({ event }) => { + storeDeletedEvents(event); + const legacy = get(legacyBookmarkEvent); + if (legacy !== undefined && isAddressableEventDeleted(legacy)) { + legacyBookmarkEvent.set(undefined); + } + }); observable$ .pipe(filterByKind(Kind.BadgeAward)) .subscribe(({ event, from }) => storeSeenOn(event.id, from)); // TODO: Migrate to tie From 69578520b6c2f109926cd5e628d5cd0e4105711f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=AA=E7=8C=AB?= Date: Wed, 19 Aug 2026 07:24:39 +0000 Subject: [PATCH 3/5] Restore legacy bookmark deletion state --- web/src/lib/Author.ts | 11 ++++- web/src/lib/author/Delete.test.ts | 66 ++++++++++++++++++++++++++- web/src/lib/author/Delete.ts | 38 ++++++++++++++- web/src/lib/timelines/HomeTimeline.ts | 8 +++- 4 files changed, 118 insertions(+), 5 deletions(-) diff --git a/web/src/lib/Author.ts b/web/src/lib/Author.ts index c2c53f9bf..a82287b62 100644 --- a/web/src/lib/Author.ts +++ b/web/src/lib/Author.ts @@ -30,7 +30,11 @@ import { replaceableKinds } from './Constants'; import { bookmarkEvent, legacyBookmarkEvent } from './author/Bookmark'; -import { isAddressableEventDeleted } from './author/Delete'; +import { + isAddressableEventDeleted, + isDeletedLegacyBookmarkEvent, + restoreLegacyBookmarkDeletionState +} from './author/Delete'; import { legacyProfileBadgesKey, setProfileBadgesEvent } from './author/ProfileBadges'; import { profileBadgesKind } from './ProfileBadgesEvent'; import { contactsOfFolloweesReqEmit } from './author/MuteAutomatically'; @@ -202,6 +206,7 @@ export class Author { parameterizedReplaceableEvents: Map; }> { const storage = new WebStorage(localStorage); + await restoreLegacyBookmarkDeletionState(pubkey, storage); const cachedAt = storage.getCachedAt(); if (cachedAt !== null) { console.log('[cached at]', new Date(cachedAt * 1000)); @@ -238,7 +243,9 @@ export class Author { storage.setReplaceableEvent(event); } for (const [, event] of [...parameterizedReplaceableEvents]) { - storage.setParameterizedReplaceableEvent(event); + if (!isDeletedLegacyBookmarkEvent(event)) { + storage.setParameterizedReplaceableEvent(event); + } } return { replaceableEvents, parameterizedReplaceableEvents }; } diff --git a/web/src/lib/author/Delete.test.ts b/web/src/lib/author/Delete.test.ts index 7491b6aa7..c5a279fa5 100644 --- a/web/src/lib/author/Delete.test.ts +++ b/web/src/lib/author/Delete.test.ts @@ -7,7 +7,9 @@ const mocks = vi.hoisted(() => ({ author: 'a'.repeat(64), sent: [] as Nostr.Event[], fails: false, - signEvent: vi.fn(async (event) => ({ ...event, id: 'deletion', sig: 'sig' })) + signEvent: vi.fn(async (event) => ({ ...event, id: 'deletion', sig: 'sig' })), + fetched: [] as Nostr.Event[], + fetchEvents: vi.fn() })); const author = mocks.author; @@ -21,6 +23,12 @@ vi.mock('$lib/timelines/MainTimeline', () => ({ } })); vi.mock('$lib/Signer', () => ({ Signer: { signEvent: mocks.signEvent } })); +vi.mock('$lib/RxNostrHelper', () => ({ + fetchEvents: (filters: unknown) => { + mocks.fetchEvents(filters); + return Promise.resolve(mocks.fetched); + } +})); vi.mock('$lib/stores/Author', async () => { const { writable } = await import('svelte/store'); return { pubkey: writable(mocks.author) }; @@ -30,6 +38,10 @@ import { deleteAddressableEvent, deletedEventCoordinates, isAddressableEventDeleted, + isDeletedLegacyBookmarkEvent, + isLegacyBookmarkEvent, + removeDeletedLegacyBookmarkCache, + restoreLegacyBookmarkDeletionState, storeDeletedEvents } from './Delete'; @@ -49,6 +61,8 @@ describe('deleteAddressableEvent', () => { beforeEach(() => { mocks.sent = []; mocks.fails = false; + mocks.fetched = []; + mocks.fetchEvents.mockReset(); mocks.signEvent.mockClear(); deletedEventCoordinates.set(new Map()); }); @@ -95,6 +109,56 @@ describe('deleteAddressableEvent', () => { expect(get(deletedEventCoordinates).get(coordinate)).toBe(30); }); + it('restores historical legacy bookmark deletion requests', async () => { + const coordinate = `30001:${author}:bookmark`; + mocks.fetched = [event({ created_at: 20, tags: [['a', coordinate]] })]; + let cached: Nostr.Event | undefined = event({ + kind: 30001, + created_at: 20, + tags: [['d', 'bookmark']] + }); + const storage = { + getParameterizedReplaceableEvent: () => cached, + removeParameterizedReplaceableEvent: () => { + cached = undefined; + } + }; + + await restoreLegacyBookmarkDeletionState(author, storage as never); + + expect(mocks.fetchEvents).toHaveBeenCalledWith([ + { kinds: [5], authors: [author], '#a': [coordinate] } + ]); + expect(get(deletedEventCoordinates).get(coordinate)).toBe(20); + expect(cached).toBeUndefined(); + }); + + it('removes a deleted legacy bookmark cache but keeps a newer replacement', () => { + const coordinate = `30001:${author}:bookmark`; + storeDeletedEvents(event({ created_at: 20, tags: [['a', coordinate]] })); + let cached: Nostr.Event | undefined = event({ + kind: 30001, + created_at: 20, + tags: [['d', 'bookmark']] + }); + const storage = { + getParameterizedReplaceableEvent: () => cached, + removeParameterizedReplaceableEvent: () => { + cached = undefined; + } + }; + + expect(isLegacyBookmarkEvent(cached)).toBe(true); + expect(isDeletedLegacyBookmarkEvent(cached)).toBe(true); + removeDeletedLegacyBookmarkCache(storage as never); + expect(cached).toBeUndefined(); + + cached = event({ kind: 30001, created_at: 21, tags: [['d', 'bookmark']] }); + expect(isDeletedLegacyBookmarkEvent(cached)).toBe(false); + removeDeletedLegacyBookmarkCache(storage as never); + expect(cached).toBeDefined(); + }); + it('ignores malformed coordinates and coordinates for another author', () => { const otherAuthor = 'b'.repeat(64); storeDeletedEvents( diff --git a/web/src/lib/author/Delete.ts b/web/src/lib/author/Delete.ts index f2e28a015..3f3ccf4f9 100644 --- a/web/src/lib/author/Delete.ts +++ b/web/src/lib/author/Delete.ts @@ -4,9 +4,12 @@ import type * as Nostr from 'nostr-typedef'; import { pubkey as authorPubkey } from '$lib/stores/Author'; import { rxNostr } from '$lib/timelines/MainTimeline'; import { Signer } from '$lib/Signer'; -import { filterTags } from '$lib/EventHelper'; +import { filterTags, findIdentifier } from '$lib/EventHelper'; import { filter, firstValueFrom } from 'rxjs'; import { isAddressableKind } from 'nostr-tools/kinds'; +import { fetchEvents } from '$lib/RxNostrHelper'; +import { legacyBookmarkIdentifier } from '$lib/Constants'; +import { WebStorage } from '$lib/WebStorage'; export const deletedEventIds = writable(new Set()); export const deletedEventIdsByPubkey = writable(new Map>()); @@ -14,6 +17,35 @@ export const deletedEventCoordinates = writable(new Map()); const addressableCoordinate = /^(\d+):([0-9a-f]{64}):(.*)$/; +export function isLegacyBookmarkEvent(event: Nostr.Event): boolean { + return event.kind === 30001 && findIdentifier(event.tags) === legacyBookmarkIdentifier; +} + +export async function restoreLegacyBookmarkDeletionState( + pubkey: string, + storage = new WebStorage(localStorage) +): Promise { + const coordinate = `30001:${pubkey}:${legacyBookmarkIdentifier}`; + const deletionRequests = await fetchEvents([ + { + kinds: [5], + authors: [pubkey], + '#a': [coordinate] + } + ]); + for (const event of deletionRequests) { + storeDeletedEvents(event); + } + removeDeletedLegacyBookmarkCache(storage); +} + +export function removeDeletedLegacyBookmarkCache(storage = new WebStorage(localStorage)): void { + const cached = storage.getParameterizedReplaceableEvent(30001, legacyBookmarkIdentifier); + if (cached !== undefined && isAddressableEventDeleted(cached)) { + storage.removeParameterizedReplaceableEvent(30001, legacyBookmarkIdentifier); + } +} + function storeDeletedEventCoordinates(event: Nostr.Event): void { const coordinates = filterTags('a', event.tags).filter((coordinate) => { const match = addressableCoordinate.exec(coordinate); @@ -38,6 +70,10 @@ export function isAddressableEventDeleted(event: Nostr.Event): boolean { return deletedAt !== undefined && event.created_at <= deletedAt; } +export function isDeletedLegacyBookmarkEvent(event: Nostr.Event): boolean { + return isLegacyBookmarkEvent(event) && isAddressableEventDeleted(event); +} + export function storeDeletedEvents(event: Nostr.Event): void { storeDeletedEventCoordinates(event); const pubkey = event.pubkey; diff --git a/web/src/lib/timelines/HomeTimeline.ts b/web/src/lib/timelines/HomeTimeline.ts index 9680ab7e1..6e480fee8 100644 --- a/web/src/lib/timelines/HomeTimeline.ts +++ b/web/src/lib/timelines/HomeTimeline.ts @@ -16,7 +16,11 @@ import { WebStorage } from '$lib/WebStorage'; import { kinds as Kind } from 'nostr-tools'; import { get } from 'svelte/store'; import { bookmarkEvent, legacyBookmarkEvent } from '$lib/author/Bookmark'; -import { isAddressableEventDeleted } from '$lib/author/Delete'; +import { + isAddressableEventDeleted, + isDeletedLegacyBookmarkEvent, + removeDeletedLegacyBookmarkCache +} from '$lib/author/Delete'; import { authorActionReqEmit, updateReactionedEvents, @@ -153,6 +157,7 @@ export class HomeTimeline extends NewTimeline { const addressable$ = author$.pipe( filterByKinds(parameterizedReplaceableKinds), latestEach(({ event }) => `${event.kind}:${findIdentifier(event.tags) ?? ''}`), + filter(({ event }) => !isDeletedLegacyBookmarkEvent(event)), filter(({ event }) => { const storage = new WebStorage(localStorage); const cache = storage.getParameterizedReplaceableEvent( @@ -205,6 +210,7 @@ export class HomeTimeline extends NewTimeline { .subscribe(({ event }) => storeMetadata(event)); observable$.pipe(filterByKind(Kind.EventDeletion)).subscribe(({ event }) => { storeDeletedEvents(event); + removeDeletedLegacyBookmarkCache(); const legacy = get(legacyBookmarkEvent); if (legacy !== undefined && isAddressableEventDeleted(legacy)) { legacyBookmarkEvent.set(undefined); From 9c062cce53f5ab0cd3cc4b38fc473454de2f118a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=AA=E7=8C=AB?= Date: Wed, 19 Aug 2026 07:43:17 +0000 Subject: [PATCH 4/5] Reuse legacy bookmark constants --- web/src/lib/author/Bookmark.ts | 2 +- web/src/lib/author/Delete.ts | 14 ++++++++++---- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/web/src/lib/author/Bookmark.ts b/web/src/lib/author/Bookmark.ts index 1d3162975..41133d4a5 100644 --- a/web/src/lib/author/Bookmark.ts +++ b/web/src/lib/author/Bookmark.ts @@ -9,12 +9,12 @@ import { Signer } from '$lib/Signer'; import { pubkey } from '$lib/stores/Author'; import { rxNostr } from '$lib/timelines/MainTimeline'; import { WebStorage } from '$lib/WebStorage'; +import { legacyBookmarkIdentifier } from '$lib/Constants'; import { deleteAddressableEvent } from './Delete'; type DataType = 'bookmark' | 'unbookmark'; type Data = { type: DataType; tag: string[] }; -const legacyBookmarkIdentifier = 'bookmark'; let writeQueue = Promise.resolve(); export const bookmarkEvent: Writable = writable(); diff --git a/web/src/lib/author/Delete.ts b/web/src/lib/author/Delete.ts index 3f3ccf4f9..a811f42ba 100644 --- a/web/src/lib/author/Delete.ts +++ b/web/src/lib/author/Delete.ts @@ -6,6 +6,7 @@ import { rxNostr } from '$lib/timelines/MainTimeline'; import { Signer } from '$lib/Signer'; import { filterTags, findIdentifier } from '$lib/EventHelper'; import { filter, firstValueFrom } from 'rxjs'; +import { kinds as Kind } from 'nostr-tools'; import { isAddressableKind } from 'nostr-tools/kinds'; import { fetchEvents } from '$lib/RxNostrHelper'; import { legacyBookmarkIdentifier } from '$lib/Constants'; @@ -18,14 +19,16 @@ export const deletedEventCoordinates = writable(new Map()); const addressableCoordinate = /^(\d+):([0-9a-f]{64}):(.*)$/; export function isLegacyBookmarkEvent(event: Nostr.Event): boolean { - return event.kind === 30001 && findIdentifier(event.tags) === legacyBookmarkIdentifier; + return ( + event.kind === Kind.Genericlists && findIdentifier(event.tags) === legacyBookmarkIdentifier + ); } export async function restoreLegacyBookmarkDeletionState( pubkey: string, storage = new WebStorage(localStorage) ): Promise { - const coordinate = `30001:${pubkey}:${legacyBookmarkIdentifier}`; + const coordinate = `${Kind.Genericlists}:${pubkey}:${legacyBookmarkIdentifier}`; const deletionRequests = await fetchEvents([ { kinds: [5], @@ -40,9 +43,12 @@ export async function restoreLegacyBookmarkDeletionState( } export function removeDeletedLegacyBookmarkCache(storage = new WebStorage(localStorage)): void { - const cached = storage.getParameterizedReplaceableEvent(30001, legacyBookmarkIdentifier); + const cached = storage.getParameterizedReplaceableEvent( + Kind.Genericlists, + legacyBookmarkIdentifier + ); if (cached !== undefined && isAddressableEventDeleted(cached)) { - storage.removeParameterizedReplaceableEvent(30001, legacyBookmarkIdentifier); + storage.removeParameterizedReplaceableEvent(Kind.Genericlists, legacyBookmarkIdentifier); } } From 923bfd9e8421161f062606a70a53ac79e37b8559 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=AA=E7=8C=AB?= Date: Wed, 19 Aug 2026 16:46:18 +0000 Subject: [PATCH 5/5] Refine old-format bookmark actions --- web/src/lib/i18n/locales/en.json | 2 +- web/src/lib/i18n/locales/ja.json | 2 +- .../(app)/[slug=npub]/bookmarks/+page.svelte | 72 ++++++++++--------- .../bookmarks/BookmarkPageState.svelte.ts | 1 + .../bookmarks/BookmarkPageState.test.ts | 3 + 5 files changed, 46 insertions(+), 34 deletions(-) diff --git a/web/src/lib/i18n/locales/en.json b/web/src/lib/i18n/locales/en.json index caa2f8d82..ca2703624 100644 --- a/web/src/lib/i18n/locales/en.json +++ b/web/src/lib/i18n/locales/en.json @@ -18,7 +18,7 @@ "old_format": "Old format", "private": "Private", "copy": { - "action": "Copy all to Bookmarks", + "action": "Copy all", "running": "Copying…", "description": "Copies all public and private items to your current Bookmarks. The old format remains unchanged.", "success": { diff --git a/web/src/lib/i18n/locales/ja.json b/web/src/lib/i18n/locales/ja.json index 842f3366e..158d0cf33 100644 --- a/web/src/lib/i18n/locales/ja.json +++ b/web/src/lib/i18n/locales/ja.json @@ -18,7 +18,7 @@ "old_format": "旧形式", "private": "プライベート", "copy": { - "action": "すべてブックマークへコピー", + "action": "すべてコピー", "running": "コピー中…", "description": "公開・プライベートの項目を現在のブックマークへコピーします。旧形式は変更されません。", "success": { diff --git a/web/src/routes/(app)/[slug=npub]/bookmarks/+page.svelte b/web/src/routes/(app)/[slug=npub]/bookmarks/+page.svelte index 337741cf5..d4a648ea0 100644 --- a/web/src/routes/(app)/[slug=npub]/bookmarks/+page.svelte +++ b/web/src/routes/(app)/[slug=npub]/bookmarks/+page.svelte @@ -262,30 +262,33 @@ hidden={selectedBookmarkList?.id !== tab.id} > {#if tab.id === legacyBookmarkListId && data.pubkey === $authorPubkey && !$rom} -
-
- -

{$_('bookmarks.copy.description')}

-
+
+

{$_('bookmarks.copy.description')}

+ -
+ + {#if pageState.hasStandardBookmarks} + + {/if} + {/if}

{$_('pages.public')}

@@ -344,23 +347,28 @@ font-weight: bold; } - .legacy-actions { - display: flex; - align-items: start; - justify-content: space-between; - gap: 1rem; - padding: 1rem; - border: var(--default-border); - border-radius: 0.5rem; + .legacy-bookmark-actions { + margin-block: 1rem 1.5rem; + } + + .legacy-bookmark-actions p { + margin: 0 0 1rem; + color: var(--accent-gray); } - .legacy-actions p { - margin: 0.5rem 0 0; - color: var(--subtle); + .legacy-bookmark-actions .primary { + min-height: 2.75rem; + padding-inline: 1.25rem; } .delete-legacy { + display: block; + min-height: 2.75rem; + margin-top: 0.75rem; + padding: 0; + border: 0; + background: transparent; color: var(--danger, #c62828); - white-space: nowrap; + text-decoration: underline; } diff --git a/web/src/routes/(app)/[slug=npub]/bookmarks/BookmarkPageState.svelte.ts b/web/src/routes/(app)/[slug=npub]/bookmarks/BookmarkPageState.svelte.ts index 3d8a2d3e9..17d0c8a41 100644 --- a/web/src/routes/(app)/[slug=npub]/bookmarks/BookmarkPageState.svelte.ts +++ b/web/src/routes/(app)/[slug=npub]/bookmarks/BookmarkPageState.svelte.ts @@ -22,6 +22,7 @@ export class BookmarkPageState { #cleanupPublicLegacyBookmarks: (() => void) | undefined; bookmarkListTabs = $derived(getBookmarkListTabs(this.#legacyBookmarkEvent !== undefined)); + hasStandardBookmarks = $derived(this.#bookmarkEvent !== undefined); constructor( bookmarkEvent: Writable, diff --git a/web/src/routes/(app)/[slug=npub]/bookmarks/BookmarkPageState.test.ts b/web/src/routes/(app)/[slug=npub]/bookmarks/BookmarkPageState.test.ts index 528300a34..608dcbf48 100644 --- a/web/src/routes/(app)/[slug=npub]/bookmarks/BookmarkPageState.test.ts +++ b/web/src/routes/(app)/[slug=npub]/bookmarks/BookmarkPageState.test.ts @@ -24,6 +24,7 @@ describe('Bookmark page state', () => { expect(state.bookmarkListTabs.map(({ id }) => id)).toEqual([standardBookmarkListId]); expect(state.selectedBookmarkListId).toBe(standardBookmarkListId); expect(state.selectionFinalized).toBe(false); + expect(state.hasStandardBookmarks).toBe(false); expect(loadPublicItems).not.toHaveBeenCalled(); legacyEvent.set(bookmarkEvent('legacy-1')); @@ -38,9 +39,11 @@ describe('Bookmark page state', () => { ); addItems.get('legacy-1')?.('legacy item'); expect(state.publicLegacyBookmarkEventItems).toEqual(['legacy item']); + expect(state.hasStandardBookmarks).toBe(false); state.selectBookmarkList(standardBookmarkListId); standardEvent.set(bookmarkEvent('standard-1')); + expect(state.hasStandardBookmarks).toBe(true); addItems.get('standard-1')?.('standard item'); expect(state.selectedBookmarkListId).toBe(standardBookmarkListId); expect(state.publicBookmarkEventItems).toEqual(['standard item']);