mirror of
https://github.com/layer-systems/website.git
synced 2026-09-13 06:07:29 +02:00
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:
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user