fix: dedupe multiple revisions of the same web bookmark

Per an earlier "previously missed" finding: useMyWebBookmarks()
returned every kind-39701 event a relay handed back, but for an
addressable event the pool can return more than one revision of the
same d tag (an edit history, or relays disagreeing on what's
current), which showed up as duplicate rows for the same URL.
Extracted dedupeLatestByDTag() (keeps the newest per d, newest-first)
and covered it with regression tests.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BYiUtZMQeA5RHggQw73wto
This commit is contained in:
2026-09-06 18:31:28 +02:00
parent 09c1ef4fff
commit 8e46454cdf
2 changed files with 50 additions and 4 deletions

View File

@@ -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([]);
});
});

View File

@@ -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<string, NostrEvent>();
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,
});