From 09c1ef4fffa694dfba2b0d9cd1d8d27806db9e98 Mon Sep 17 00:00:00 2001 From: highperfocused Date: Sun, 6 Sep 2026 18:22:02 +0200 Subject: [PATCH] fix: round-trip mailto:/nostr: bookmarks and preserve published_at MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review: - bookmarkUrl() required "scheme://" to recognize an already-schemed d tag, so opaque URIs with no "//" — mailto: and nostr: — were incorrectly prefixed with "https://". Fixed by also checking a closed list of the opaque schemes this app supports, alongside the existing "://" check (kept as-is so a hierarchical scheme like gemini:// still round-trips, and so a stripped https URL containing a port, e.g. alice.blog:8080/post, still isn't misread as scheme "alice.blog"). Added regression tests for all three cases. - useCreateWebBookmark now looks up the existing bookmark for the same d tag before publishing and carries its published_at forward, instead of resetting it to now on every edit — per NIP-B0, published_at is "the first time the bookmark was published." Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01BYiUtZMQeA5RHggQw73wto --- src/hooks/useWebBookmarks.test.ts | 14 ++++++++++++++ src/hooks/useWebBookmarks.ts | 28 +++++++++++++++++++++++++--- 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/src/hooks/useWebBookmarks.test.ts b/src/hooks/useWebBookmarks.test.ts index a627250..25a936c 100644 --- a/src/hooks/useWebBookmarks.test.ts +++ b/src/hooks/useWebBookmarks.test.ts @@ -26,4 +26,18 @@ describe('bookmarkDTag / bookmarkUrl', () => { expect(bookmarkDTag('HtTpS://alice.blog/post')).toBe('alice.blog/post'); expect(bookmarkDTag('HTTPS://alice.blog/post')).toBe(bookmarkDTag('https://alice.blog/post')); }); + + it('round-trips schemes with no "//", like mailto: and nostr:, instead of prefixing them with https://', () => { + expect(bookmarkUrl(bookmarkDTag('mailto:hello@example.com'))).toBe('mailto:hello@example.com'); + expect(bookmarkUrl(bookmarkDTag('nostr:npub1abc'))).toBe('nostr:npub1abc'); + }); + + it('round-trips a hierarchical non-https scheme like gemini://', () => { + const url = 'gemini://example.com/'; + expect(bookmarkUrl(bookmarkDTag(url))).toBe(url); + }); + + it('does not mistake a port number in a stripped https URL for a scheme', () => { + expect(bookmarkUrl('alice.blog:8080/post')).toBe('https://alice.blog:8080/post'); + }); }); diff --git a/src/hooks/useWebBookmarks.ts b/src/hooks/useWebBookmarks.ts index 08a5009..9fda6fd 100644 --- a/src/hooks/useWebBookmarks.ts +++ b/src/hooks/useWebBookmarks.ts @@ -24,9 +24,17 @@ export function bookmarkDTag(url: string): string { return HTTPS_SCHEME_RE.test(url) ? url.slice('https://'.length) : url; } +/** Hierarchical URIs, e.g. `http://…` or `gemini://…` — the `//` is what rules out a false match on a stripped https URL that happens to contain a port, e.g. `alice.blog:8080/post`. */ +const HIERARCHICAL_SCHEME_RE = /^[a-z][a-z0-9+.-]*:\/\//i; +/** Non-hierarchical URIs with no `//`, e.g. `mailto:` and `nostr:` — not matched by the pattern above. */ +const OPAQUE_SCHEMES = ['mailto:', 'nostr:']; + /** Reconstructs a clickable URL from a `d` tag written by `bookmarkDTag`. */ export function bookmarkUrl(dTag: string): string { - return /^[a-z][a-z0-9+.-]*:\/\//i.test(dTag) ? dTag : `https://${dTag}`; + const lower = dTag.toLowerCase(); + const alreadyHasScheme = + HIERARCHICAL_SCHEME_RE.test(dTag) || OPAQUE_SCHEMES.some((scheme) => lower.startsWith(scheme)); + return alreadyHasScheme ? dTag : `https://${dTag}`; } export interface WebBookmarkInput { @@ -57,6 +65,7 @@ export function useMyWebBookmarks() { } export function useCreateWebBookmark() { + const { nostr } = useNostr(); const { user } = useCurrentUser(); const publish = useNostrPublish(); const queryClient = useQueryClient(); @@ -65,12 +74,25 @@ export function useCreateWebBookmark() { mutationFn: async ({ url, title, description, tags }: WebBookmarkInput) => { if (!user) throw new Error('Sign in to bookmark a page'); - const eventTags: string[][] = [['d', bookmarkDTag(url)]]; + const dTag = bookmarkDTag(url); + // Re-bookmarking an already-saved URL is an edit of the same + // addressable event, not a new bookmark — published_at per NIP-B0 is + // "the first time the bookmark was published", so it must carry over + // rather than being reset to now on every edit. + const [existing] = await nostr.query( + [{ kinds: [WEB_BOOKMARK_KIND], authors: [user.pubkey], '#d': [dTag], limit: 1 }], + { signal: AbortSignal.timeout(6000) }, + ); + const publishedAt = existing + ? (tagValue(existing, 'published_at') ?? String(existing.created_at)) + : String(Math.floor(Date.now() / 1000)); + + const eventTags: string[][] = [['d', dTag]]; if (title?.trim()) eventTags.push(['title', title.trim()]); for (const tag of tags ?? []) { if (tag.trim()) eventTags.push(['t', tag.trim().toLowerCase()]); } - eventTags.push(['published_at', String(Math.floor(Date.now() / 1000))]); + eventTags.push(['published_at', publishedAt]); return publish.mutateAsync({ kind: WEB_BOOKMARK_KIND,