fix: round-trip mailto:/nostr: bookmarks and preserve published_at

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BYiUtZMQeA5RHggQw73wto
This commit is contained in:
2026-09-06 18:22:02 +02:00
parent 9eae936c58
commit 09c1ef4fff
2 changed files with 39 additions and 3 deletions

View File

@@ -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');
});
});

View File

@@ -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,