diff --git a/src/hooks/useWebBookmarks.test.ts b/src/hooks/useWebBookmarks.test.ts index 25a936c..dda9878 100644 --- a/src/hooks/useWebBookmarks.test.ts +++ b/src/hooks/useWebBookmarks.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from 'vitest'; -import { bookmarkDTag, bookmarkUrl } from './useWebBookmarks'; +import type { NostrEvent } from '@nostrify/nostrify'; +import { bookmarkDTag, bookmarkUrl, dedupeLatestByDTag } from './useWebBookmarks'; + +function bookmarkEvent(dTag: string, createdAt: number, id = `${dTag}-${createdAt}`): NostrEvent { + return { id, pubkey: 'author', created_at: createdAt, kind: 39701, tags: [['d', dTag]], content: '', sig: '' }; +} describe('bookmarkDTag / bookmarkUrl', () => { it('strips the https scheme per NIP-B0', () => { @@ -41,3 +46,30 @@ describe('bookmarkDTag / bookmarkUrl', () => { expect(bookmarkUrl('alice.blog:8080/post')).toBe('https://alice.blog:8080/post'); }); }); + +describe('dedupeLatestByDTag', () => { + it('keeps only the newest event for each d tag', () => { + const older = bookmarkEvent('alice.blog/post', 100); + const newer = bookmarkEvent('alice.blog/post', 200); + const result = dedupeLatestByDTag([older, newer]); + expect(result).toEqual([newer]); + }); + + it('is order-independent', () => { + const older = bookmarkEvent('alice.blog/post', 100); + const newer = bookmarkEvent('alice.blog/post', 200); + expect(dedupeLatestByDTag([newer, older])).toEqual([newer]); + }); + + it('keeps distinct d tags separately, sorted newest first', () => { + const a = bookmarkEvent('a.com', 100); + const b = bookmarkEvent('b.com', 300); + const c = bookmarkEvent('c.com', 200); + expect(dedupeLatestByDTag([a, b, c])).toEqual([b, c, a]); + }); + + it('drops events with no d tag', () => { + const noD: NostrEvent = { id: 'x', pubkey: 'author', created_at: 0, kind: 39701, tags: [], content: '', sig: '' }; + expect(dedupeLatestByDTag([noD])).toEqual([]); + }); +}); diff --git a/src/hooks/useWebBookmarks.ts b/src/hooks/useWebBookmarks.ts index 9fda6fd..31bd9f0 100644 --- a/src/hooks/useWebBookmarks.ts +++ b/src/hooks/useWebBookmarks.ts @@ -44,6 +44,22 @@ export interface WebBookmarkInput { tags?: string[]; } +/** + * Addressable events: relays across the pool can hand back more than one + * revision of the same `d` tag (an edit history, or just multiple relays + * disagreeing on what's current). Keeps only the newest per `d`, newest first. + */ +export function dedupeLatestByDTag(events: NostrEvent[]): NostrEvent[] { + const latest = new Map(); + for (const event of events) { + const dTag = tagValue(event, 'd'); + if (!dTag) continue; + const current = latest.get(dTag); + if (!current || event.created_at > current.created_at) latest.set(dTag, event); + } + return [...latest.values()].sort((a, b) => b.created_at - a.created_at); +} + export function useMyWebBookmarks() { const { nostr } = useNostr(); const { user } = useCurrentUser(); @@ -56,9 +72,7 @@ export function useMyWebBookmarks() { [{ kinds: [WEB_BOOKMARK_KIND], authors: [user!.pubkey], limit: 200 }], { signal: AbortSignal.any([signal, AbortSignal.timeout(6000)]) }, ); - return events - .filter((event) => Boolean(tagValue(event, 'd'))) - .sort((a, b) => b.created_at - a.created_at); + return dedupeLatestByDTag(events); }, staleTime: 60_000, });