From 1efd4fad1233d27fefff04d04ca178b2bbcd831c Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 7 Aug 2024 12:11:13 -0500 Subject: [PATCH 01/15] search: parse bech32 ids from pasted URLs --- src/controllers/api/search.ts | 52 +++++++++++++++++++++++++---------- 1 file changed, 38 insertions(+), 14 deletions(-) diff --git a/src/controllers/api/search.ts b/src/controllers/api/search.ts index 0151f7de..6ae05f1c 100644 --- a/src/controllers/api/search.ts +++ b/src/controllers/api/search.ts @@ -5,7 +5,7 @@ import { z } from 'zod'; import { AppController } from '@/app.ts'; import { booleanParamSchema } from '@/schema.ts'; import { Storages } from '@/storages.ts'; -import { dedupeEvents } from '@/utils.ts'; +import { bech32ToPubkey, dedupeEvents } from '@/utils.ts'; import { nip05Cache } from '@/utils/nip05.ts'; import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts'; import { renderStatus } from '@/views/mastodon/statuses.ts'; @@ -60,14 +60,10 @@ const searchController: AppController = async (c) => { ), ]); - if ((result.data.type === 'accounts') && (accounts.length < 1) && (result.data.q.match(/npub1\w+/))) { - const possibleNpub = result.data.q; - try { - const npubHex = nip19.decode(possibleNpub); - accounts.push(await accountFromPubkey(String(npubHex.data))); - } catch (e) { - console.log(e); - } + // Render account from pubkey. + const pubkey = bech32ToPubkey(result.data.q); + if (pubkey && !accounts.find((account) => account.id === pubkey)) { + accounts.unshift(await accountFromPubkey(pubkey)); } return c.json({ @@ -130,9 +126,11 @@ async function getLookupFilters({ q, type, resolve }: SearchQuery, signal: Abort return filters; } - if (new RegExp(`^${nip19.BECH32_REGEX.source}$`).test(q)) { + const bech32 = extractBech32(q); + + if (bech32) { try { - const result = nip19.decode(q); + const result = nip19.decode(bech32); switch (result.type) { case 'npub': if (accounts) filters.push({ kinds: [0], authors: [result.data] }); @@ -151,10 +149,10 @@ async function getLookupFilters({ q, type, resolve }: SearchQuery, signal: Abort } break; } - } catch (_e) { + } catch { // do nothing } - } else if (/^[0-9a-f]{64}$/.test(q)) { + } else if (n.id().safeParse(q).success) { if (accounts) filters.push({ kinds: [0], authors: [q] }); if (statuses) filters.push({ kinds: [1], ids: [q] }); } else if (accounts && ACCT_REGEX.test(q)) { @@ -163,7 +161,7 @@ async function getLookupFilters({ q, type, resolve }: SearchQuery, signal: Abort if (pubkey) { filters.push({ kinds: [0], authors: [pubkey] }); } - } catch (_e) { + } catch { // do nothing } } @@ -171,4 +169,30 @@ async function getLookupFilters({ q, type, resolve }: SearchQuery, signal: Abort return filters; } +/** Extract a bech32 ID out of a search query string. */ +function extractBech32(value: string): string | undefined { + let bech32: string = value; + + try { + const uri = new URL(value); + switch (uri.protocol) { + // Extract from NIP-19 URI, eg `nostr:npub1q3sle0kvfsehgsuexttt3ugjd8xdklxfwwkh559wxckmzddywnws6cd26p`. + case 'nostr:': + bech32 = uri.pathname; + break; + // Extract from URL, eg `https://njump.me/npub1q3sle0kvfsehgsuexttt3ugjd8xdklxfwwkh559wxckmzddywnws6cd26p`. + case 'http:': + case 'https:': + bech32 = uri.pathname.slice(1); + break; + } + } catch { + // do nothing + } + + if (n.bech32().safeParse(bech32).success) { + return bech32; + } +} + export { searchController }; From cdee2604a1cd76390132fd86447d8e938d4618f7 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 7 Aug 2024 12:31:22 -0500 Subject: [PATCH 02/15] Apply same search improvements to accountSearchController --- src/controllers/api/accounts.ts | 29 ++++++++++++++++------------- src/controllers/api/search.ts | 30 ++---------------------------- src/utils.ts | 27 +++++++++++++++++++++++++++ 3 files changed, 45 insertions(+), 41 deletions(-) diff --git a/src/controllers/api/accounts.ts b/src/controllers/api/accounts.ts index 20073eec..87bef328 100644 --- a/src/controllers/api/accounts.ts +++ b/src/controllers/api/accounts.ts @@ -8,7 +8,7 @@ import { getAuthor, getFollowedPubkeys } from '@/queries.ts'; import { booleanParamSchema, fileSchema } from '@/schema.ts'; import { Storages } from '@/storages.ts'; import { uploadFile } from '@/utils/upload.ts'; -import { nostrNow } from '@/utils.ts'; +import { dedupeEvents, extractBech32, nostrNow } from '@/utils.ts'; import { createEvent, paginated, parseBody, updateListEvent } from '@/utils/api.ts'; import { lookupAccount } from '@/utils/lookup.ts'; import { renderAccounts, renderEventAccounts, renderStatuses } from '@/views.ts'; @@ -125,30 +125,33 @@ const accountSearchController: AppController = async (c) => { const query = decodeURIComponent(q); const store = await Storages.search(); + const bech32 = extractBech32(query); const [event, events] = await Promise.all([ - lookupAccount(query), + lookupAccount(bech32 ?? query), store.query([{ kinds: [0], search: query, limit }], { signal }), ]); + if (event) { + events.unshift(event); + } + const results = await hydrateEvents({ - events: event ? [event, ...events] : events, + events: dedupeEvents(events), store, signal, }); - if ((results.length < 1) && query.match(/npub1\w+/)) { - const possibleNpub = query; - try { - const npubHex = nip19.decode(possibleNpub); - return c.json([await accountFromPubkey(String(npubHex.data))]); - } catch (e) { - console.log(e); - return c.json([]); - } + const accounts = await Promise.all( + results.map((event) => renderAccount(event)), + ); + + // Render account from pubkey. + const pubkey = bech32ToPubkey(result.data.q); + if (pubkey && !accounts.find((account) => account.id === pubkey)) { + accounts.unshift(await accountFromPubkey(pubkey)); } - const accounts = await Promise.all(results.map((event) => renderAccount(event))); return c.json(accounts); }; diff --git a/src/controllers/api/search.ts b/src/controllers/api/search.ts index 6ae05f1c..3ca2aa3d 100644 --- a/src/controllers/api/search.ts +++ b/src/controllers/api/search.ts @@ -5,7 +5,7 @@ import { z } from 'zod'; import { AppController } from '@/app.ts'; import { booleanParamSchema } from '@/schema.ts'; import { Storages } from '@/storages.ts'; -import { bech32ToPubkey, dedupeEvents } from '@/utils.ts'; +import { bech32ToPubkey, dedupeEvents, extractBech32 } from '@/utils.ts'; import { nip05Cache } from '@/utils/nip05.ts'; import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts'; import { renderStatus } from '@/views/mastodon/statuses.ts'; @@ -39,7 +39,7 @@ const searchController: AppController = async (c) => { ]); if (event) { - events.push(event); + events.unshift(event); } const results = dedupeEvents(events); @@ -169,30 +169,4 @@ async function getLookupFilters({ q, type, resolve }: SearchQuery, signal: Abort return filters; } -/** Extract a bech32 ID out of a search query string. */ -function extractBech32(value: string): string | undefined { - let bech32: string = value; - - try { - const uri = new URL(value); - switch (uri.protocol) { - // Extract from NIP-19 URI, eg `nostr:npub1q3sle0kvfsehgsuexttt3ugjd8xdklxfwwkh559wxckmzddywnws6cd26p`. - case 'nostr:': - bech32 = uri.pathname; - break; - // Extract from URL, eg `https://njump.me/npub1q3sle0kvfsehgsuexttt3ugjd8xdklxfwwkh559wxckmzddywnws6cd26p`. - case 'http:': - case 'https:': - bech32 = uri.pathname.slice(1); - break; - } - } catch { - // do nothing - } - - if (n.bech32().safeParse(bech32).success) { - return bech32; - } -} - export { searchController }; diff --git a/src/utils.ts b/src/utils.ts index e9213ed1..5abc2360 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -24,6 +24,32 @@ function bech32ToPubkey(bech32: string): string | undefined { } } +/** Extract a bech32 ID out of a search query string. */ +function extractBech32(value: string): string | undefined { + let bech32: string = value; + + try { + const uri = new URL(value); + switch (uri.protocol) { + // Extract from NIP-19 URI, eg `nostr:npub1q3sle0kvfsehgsuexttt3ugjd8xdklxfwwkh559wxckmzddywnws6cd26p`. + case 'nostr:': + bech32 = uri.pathname; + break; + // Extract from URL, eg `https://njump.me/npub1q3sle0kvfsehgsuexttt3ugjd8xdklxfwwkh559wxckmzddywnws6cd26p`. + case 'http:': + case 'https:': + bech32 = uri.pathname.slice(1); + break; + } + } catch { + // do nothing + } + + if (n.bech32().safeParse(bech32).success) { + return bech32; + } +} + interface Nip05 { /** Localpart of the nip05, eg `alex` in `alex@alexgleason.me`. */ local: string | undefined; @@ -97,6 +123,7 @@ export { bech32ToPubkey, dedupeEvents, eventAge, + extractBech32, findTag, isNostrId, isURL, From 385127761d55734413e80f5cbedb8ab70466b481 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 7 Aug 2024 14:41:16 -0500 Subject: [PATCH 03/15] Strictly follow Mastodon API's way of only returning one result of a lookup succeeds --- src/controllers/api/accounts.ts | 38 +++++++++----------------- src/controllers/api/search.ts | 47 +++++++++++++++++---------------- src/utils.ts | 8 +----- 3 files changed, 38 insertions(+), 55 deletions(-) diff --git a/src/controllers/api/accounts.ts b/src/controllers/api/accounts.ts index 87bef328..c4fd6721 100644 --- a/src/controllers/api/accounts.ts +++ b/src/controllers/api/accounts.ts @@ -8,7 +8,7 @@ import { getAuthor, getFollowedPubkeys } from '@/queries.ts'; import { booleanParamSchema, fileSchema } from '@/schema.ts'; import { Storages } from '@/storages.ts'; import { uploadFile } from '@/utils/upload.ts'; -import { dedupeEvents, extractBech32, nostrNow } from '@/utils.ts'; +import { extractBech32, nostrNow } from '@/utils.ts'; import { createEvent, paginated, parseBody, updateListEvent } from '@/utils/api.ts'; import { lookupAccount } from '@/utils/lookup.ts'; import { renderAccounts, renderEventAccounts, renderStatuses } from '@/views.ts'; @@ -110,48 +110,36 @@ const accountSearchQuerySchema = z.object({ q: z.string().transform(decodeURIComponent), resolve: booleanParamSchema.optional().transform(Boolean), following: z.boolean().default(false), - limit: z.coerce.number().catch(20).transform((value) => Math.min(Math.max(value, 0), 40)), }); const accountSearchController: AppController = async (c) => { - const result = accountSearchQuerySchema.safeParse(c.req.query()); const { signal } = c.req.raw; + const { limit } = c.get('pagination'); + + const result = accountSearchQuerySchema.safeParse(c.req.query()); if (!result.success) { return c.json({ error: 'Bad request', schema: result.error }, 422); } - const { q, limit } = result.data; - - const query = decodeURIComponent(q); + const query = decodeURIComponent(result.data.q); const store = await Storages.search(); + const bech32 = extractBech32(query); + const event = await lookupAccount(bech32 ?? query); - const [event, events] = await Promise.all([ - lookupAccount(bech32 ?? query), - store.query([{ kinds: [0], search: query, limit }], { signal }), - ]); - - if (event) { - events.unshift(event); + if (!event && bech32) { + const pubkey = bech32ToPubkey(bech32); + return c.json(pubkey ? [await accountFromPubkey(pubkey)] : []); } - const results = await hydrateEvents({ - events: dedupeEvents(events), - store, - signal, - }); + const events = await store.query([{ kinds: [0], search: query, limit }], { signal }) + .then((events) => hydrateEvents({ events, store, signal })); const accounts = await Promise.all( - results.map((event) => renderAccount(event)), + events.map((event) => renderAccount(event)), ); - // Render account from pubkey. - const pubkey = bech32ToPubkey(result.data.q); - if (pubkey && !accounts.find((account) => account.id === pubkey)) { - accounts.unshift(await accountFromPubkey(pubkey)); - } - return c.json(accounts); }; diff --git a/src/controllers/api/search.ts b/src/controllers/api/search.ts index 3ca2aa3d..075843f3 100644 --- a/src/controllers/api/search.ts +++ b/src/controllers/api/search.ts @@ -5,11 +5,11 @@ import { z } from 'zod'; import { AppController } from '@/app.ts'; import { booleanParamSchema } from '@/schema.ts'; import { Storages } from '@/storages.ts'; -import { bech32ToPubkey, dedupeEvents, extractBech32 } from '@/utils.ts'; +import { hydrateEvents } from '@/storages/hydrate.ts'; +import { bech32ToPubkey, extractBech32 } from '@/utils.ts'; import { nip05Cache } from '@/utils/nip05.ts'; import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts'; import { renderStatus } from '@/views/mastodon/statuses.ts'; -import { hydrateEvents } from '@/storages/hydrate.ts'; /** Matches NIP-05 names with or without an @ in front. */ const ACCT_REGEX = /^@?(?:([\w.+-]+)@)?([\w.-]+)$/; @@ -33,39 +33,44 @@ const searchController: AppController = async (c) => { return c.json({ error: 'Bad request', schema: result.error }, 422); } - const [event, events] = await Promise.all([ - lookupEvent(result.data, signal), - searchEvents(result.data, signal), - ]); + const event = await lookupEvent(result.data, signal); + const bech32 = extractBech32(result.data.q); - if (event) { - events.unshift(event); + // Render account from pubkey. + if (!event && bech32) { + const pubkey = bech32ToPubkey(bech32); + return c.json({ + accounts: pubkey ? [await accountFromPubkey(pubkey)] : [], + statuses: [], + hashtags: [], + }); + } + + let events: NostrEvent[] = []; + + if (event) { + events = [event]; + } else { + events = await searchEvents(result.data, signal); } - const results = dedupeEvents(events); const viewerPubkey = await c.get('signer')?.getPublicKey(); const [accounts, statuses] = await Promise.all([ Promise.all( - results + events .filter((event) => event.kind === 0) .map((event) => renderAccount(event)) .filter(Boolean), ), Promise.all( - results + events .filter((event) => event.kind === 1) .map((event) => renderStatus(event, { viewerPubkey })) .filter(Boolean), ), ]); - // Render account from pubkey. - const pubkey = bech32ToPubkey(result.data.q); - if (pubkey && !accounts.find((account) => account.id === pubkey)) { - accounts.unshift(await accountFromPubkey(pubkey)); - } - return c.json({ accounts, statuses, @@ -139,14 +144,10 @@ async function getLookupFilters({ q, type, resolve }: SearchQuery, signal: Abort if (accounts) filters.push({ kinds: [0], authors: [result.data.pubkey] }); break; case 'note': - if (statuses) { - filters.push({ kinds: [1], ids: [result.data] }); - } + if (statuses) filters.push({ kinds: [1], ids: [result.data] }); break; case 'nevent': - if (statuses) { - filters.push({ kinds: [1], ids: [result.data.id] }); - } + if (statuses) filters.push({ kinds: [1], ids: [result.data.id] }); break; } } catch { diff --git a/src/utils.ts b/src/utils.ts index 5abc2360..eddf2623 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -19,7 +19,7 @@ function bech32ToPubkey(bech32: string): string | undefined { case 'npub': return decoded.data; } - } catch (_) { + } catch { // } } @@ -104,11 +104,6 @@ async function sha256(message: string): Promise { return hashHex; } -/** Deduplicate events by ID. */ -function dedupeEvents(events: NostrEvent[]): NostrEvent[] { - return [...new Map(events.map((event) => [event.id, event])).values()]; -} - /** Test whether the value is a Nostr ID. */ function isNostrId(value: unknown): boolean { return n.id().safeParse(value).success; @@ -121,7 +116,6 @@ function isURL(value: unknown): boolean { export { bech32ToPubkey, - dedupeEvents, eventAge, extractBech32, findTag, From 8f704e4ea2ccaf8de692c3a8fa90fcf0c99948b1 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 7 Aug 2024 14:52:13 -0500 Subject: [PATCH 04/15] Fix account URL lookup with @ in the URL --- src/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils.ts b/src/utils.ts index eddf2623..3dae4e1c 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -38,7 +38,7 @@ function extractBech32(value: string): string | undefined { // Extract from URL, eg `https://njump.me/npub1q3sle0kvfsehgsuexttt3ugjd8xdklxfwwkh559wxckmzddywnws6cd26p`. case 'http:': case 'https:': - bech32 = uri.pathname.slice(1); + bech32 = uri.pathname.replace(/^\/@?/, ''); break; } } catch { From dbd40357af3053662bc9ae57f393dc6e06482427 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 7 Aug 2024 15:23:33 -0500 Subject: [PATCH 05/15] Mimic Mastodon's uri/url fields exactly on Accounts and Statuses --- src/entities/MastodonAccount.ts | 1 + src/views/mastodon/accounts.ts | 6 ++++-- src/views/mastodon/statuses.ts | 4 ++-- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/entities/MastodonAccount.ts b/src/entities/MastodonAccount.ts index a7fef5de..89d08d72 100644 --- a/src/entities/MastodonAccount.ts +++ b/src/entities/MastodonAccount.ts @@ -36,6 +36,7 @@ export interface MastodonAccount { }; }; statuses_count: number; + uri: string; url: string; username: string; ditto: { diff --git a/src/views/mastodon/accounts.ts b/src/views/mastodon/accounts.ts index 5abb1aca..4e72d35c 100644 --- a/src/views/mastodon/accounts.ts +++ b/src/views/mastodon/accounts.ts @@ -42,10 +42,11 @@ async function renderAccount( const npub = nip19.npubEncode(pubkey); const parsed05 = await parseAndVerifyNip05(nip05, pubkey); + const acct = parsed05?.handle || npub; return { id: pubkey, - acct: parsed05?.handle || npub, + acct, avatar: picture, avatar_static: picture, bot: false, @@ -78,7 +79,8 @@ async function renderAccount( } : undefined, statuses_count: event.author_stats?.notes_count ?? 0, - url: Conf.local(`/users/${pubkey}`), + uri: Conf.local(`/users/${acct}`), + url: Conf.local(`/@${acct}`), username: parsed05?.nickname || npub.substring(0, 8), ditto: { accepts_zaps: Boolean(getLnurl({ lud06, lud16 })), diff --git a/src/views/mastodon/statuses.ts b/src/views/mastodon/statuses.ts index 2fa8f313..27bffdfb 100644 --- a/src/views/mastodon/statuses.ts +++ b/src/views/mastodon/statuses.ts @@ -121,8 +121,8 @@ async function renderStatus(event: DittoEvent, opts: RenderStatusOpts): Promise< poll: null, quote: !event.quote ? null : await renderStatus(event.quote, { depth: depth + 1 }), quote_id: event.quote?.id ?? null, - uri: Conf.local(`/${note}`), - url: Conf.local(`/${note}`), + uri: Conf.local(`/users/${account.acct}/statuses/${event.id}`), + url: Conf.local(`/@${account.acct}/${event.id}`), zapped: Boolean(zapEvent), ditto: { external_url: Conf.external(note), From ff900341d51a1c70e935f78dff3271e27d345907 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 7 Aug 2024 15:53:59 -0500 Subject: [PATCH 06/15] Match every possible goddamn URL format in search --- deno.json | 1 + deno.lock | 6 ++++++ src/utils.ts | 24 ++++++++++++++++++++++-- 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/deno.json b/deno.json index 0c41a378..ed5eca51 100644 --- a/deno.json +++ b/deno.json @@ -65,6 +65,7 @@ "nostr-relaypool": "npm:nostr-relaypool2@0.6.34", "nostr-tools": "npm:nostr-tools@2.5.1", "nostr-wasm": "npm:nostr-wasm@^0.1.0", + "path-to-regexp": "npm:path-to-regexp@^7.1.0", "postgres": "https://raw.githubusercontent.com/xyzshantaram/postgres.js/8a9bbce88b3f6425ecaacd99a80372338b157a53/deno/mod.js", "prom-client": "npm:prom-client@^15.1.2", "question-deno": "https://raw.githubusercontent.com/ocpu/question-deno/10022b8e52555335aa510adb08b0a300df3cf904/mod.ts", diff --git a/deno.lock b/deno.lock index 101362a4..880f8d69 100644 --- a/deno.lock +++ b/deno.lock @@ -73,6 +73,7 @@ "npm:nostr-tools@^2.5.0": "npm:nostr-tools@2.5.1", "npm:nostr-tools@^2.7.0": "npm:nostr-tools@2.7.0", "npm:nostr-wasm@^0.1.0": "npm:nostr-wasm@0.1.0", + "npm:path-to-regexp@^7.1.0": "npm:path-to-regexp@7.1.0", "npm:postgres@3.4.4": "npm:postgres@3.4.4", "npm:prom-client@^15.1.2": "npm:prom-client@15.1.2", "npm:tldts@^6.0.14": "npm:tldts@6.1.18", @@ -947,6 +948,10 @@ "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", "dependencies": {} }, + "path-to-regexp@7.1.0": { + "integrity": "sha512-ZToe+MbUF4lBqk6dV8GKot4DKfzrxXsplOddH8zN3YK+qw9/McvP7+4ICjZvOne0jQhN4eJwHsX6tT0Ns19fvw==", + "dependencies": {} + }, "picomatch@2.3.1": { "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", "dependencies": {} @@ -1856,6 +1861,7 @@ "npm:nostr-relaypool2@0.6.34", "npm:nostr-tools@2.5.1", "npm:nostr-wasm@^0.1.0", + "npm:path-to-regexp@^7.1.0", "npm:prom-client@^15.1.2", "npm:tldts@^6.0.14", "npm:tseep@^1.2.1", diff --git a/src/utils.ts b/src/utils.ts index 3dae4e1c..6613ac5d 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,5 +1,6 @@ import { NostrEvent, NSchema as n } from '@nostrify/nostrify'; import { nip19 } from 'nostr-tools'; +import { match } from 'path-to-regexp'; import { z } from 'zod'; /** Get the current time in Nostr format. */ @@ -37,9 +38,28 @@ function extractBech32(value: string): string | undefined { break; // Extract from URL, eg `https://njump.me/npub1q3sle0kvfsehgsuexttt3ugjd8xdklxfwwkh559wxckmzddywnws6cd26p`. case 'http:': - case 'https:': - bech32 = uri.pathname.replace(/^\/@?/, ''); + case 'https:': { + const accountUriMatch = match<{ acct: string }>('/users/:acct')(uri.pathname); + const accountUrlMatch = match<{ acct: string }>('/@:acct')(uri.pathname); + const statusUriMatch = match<{ acct: string; id: string }>('/users/:acct/statuses/:id')(uri.pathname); + const statusUrlMatch = match<{ acct: string; id: string }>('/@:acct/:id')(uri.pathname); + const soapboxMatch = match<{ acct: string; id: string }>('/@:acct/posts/:id')(uri.pathname); + const nostrMatch = match<{ bech32: string }>('/:bech32')(uri.pathname); + if (accountUriMatch) { + bech32 = accountUriMatch.params.acct; + } else if (accountUrlMatch) { + bech32 = accountUrlMatch.params.acct; + } else if (statusUriMatch) { + bech32 = nip19.noteEncode(statusUriMatch.params.id); + } else if (statusUrlMatch) { + bech32 = nip19.noteEncode(statusUrlMatch.params.id); + } else if (soapboxMatch) { + bech32 = nip19.noteEncode(soapboxMatch.params.id); + } else if (nostrMatch) { + bech32 = nostrMatch.params.bech32; + } break; + } } } catch { // do nothing From d3780037dfded190e74181c334738a3904f9d00e Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 7 Aug 2024 16:05:04 -0500 Subject: [PATCH 07/15] search: escape @ signs in matchers --- src/utils.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/utils.ts b/src/utils.ts index 6613ac5d..d40b2eb6 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -40,10 +40,10 @@ function extractBech32(value: string): string | undefined { case 'http:': case 'https:': { const accountUriMatch = match<{ acct: string }>('/users/:acct')(uri.pathname); - const accountUrlMatch = match<{ acct: string }>('/@:acct')(uri.pathname); + const accountUrlMatch = match<{ acct: string }>('/\\@:acct')(uri.pathname); const statusUriMatch = match<{ acct: string; id: string }>('/users/:acct/statuses/:id')(uri.pathname); - const statusUrlMatch = match<{ acct: string; id: string }>('/@:acct/:id')(uri.pathname); - const soapboxMatch = match<{ acct: string; id: string }>('/@:acct/posts/:id')(uri.pathname); + const statusUrlMatch = match<{ acct: string; id: string }>('/\\@:acct/:id')(uri.pathname); + const soapboxMatch = match<{ acct: string; id: string }>('/\\@:acct/posts/:id')(uri.pathname); const nostrMatch = match<{ bech32: string }>('/:bech32')(uri.pathname); if (accountUriMatch) { bech32 = accountUriMatch.params.acct; From bc603188fa6ce59adc3cc677541c0efcfe20e4ba Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 7 Aug 2024 16:22:10 -0500 Subject: [PATCH 08/15] extractBech32 -> extractIdentifier, support extracting nip05 names --- src/controllers/api/accounts.ts | 12 +++---- src/controllers/api/search.ts | 19 +++++------ src/utils.ts | 60 +-------------------------------- src/utils/lookup.ts | 52 ++++++++++++++++++++++++++++ 4 files changed, 67 insertions(+), 76 deletions(-) diff --git a/src/controllers/api/accounts.ts b/src/controllers/api/accounts.ts index c4fd6721..d55e0c19 100644 --- a/src/controllers/api/accounts.ts +++ b/src/controllers/api/accounts.ts @@ -8,9 +8,9 @@ import { getAuthor, getFollowedPubkeys } from '@/queries.ts'; import { booleanParamSchema, fileSchema } from '@/schema.ts'; import { Storages } from '@/storages.ts'; import { uploadFile } from '@/utils/upload.ts'; -import { extractBech32, nostrNow } from '@/utils.ts'; +import { nostrNow } from '@/utils.ts'; import { createEvent, paginated, parseBody, updateListEvent } from '@/utils/api.ts'; -import { lookupAccount } from '@/utils/lookup.ts'; +import { extractIdentifier, lookupAccount } from '@/utils/lookup.ts'; import { renderAccounts, renderEventAccounts, renderStatuses } from '@/views.ts'; import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts'; import { renderRelationship } from '@/views/mastodon/relationships.ts'; @@ -125,11 +125,11 @@ const accountSearchController: AppController = async (c) => { const query = decodeURIComponent(result.data.q); const store = await Storages.search(); - const bech32 = extractBech32(query); - const event = await lookupAccount(bech32 ?? query); + const lookup = extractIdentifier(query); + const event = await lookupAccount(lookup ?? query); - if (!event && bech32) { - const pubkey = bech32ToPubkey(bech32); + if (!event && lookup) { + const pubkey = bech32ToPubkey(lookup); return c.json(pubkey ? [await accountFromPubkey(pubkey)] : []); } diff --git a/src/controllers/api/search.ts b/src/controllers/api/search.ts index 075843f3..ef7f84c7 100644 --- a/src/controllers/api/search.ts +++ b/src/controllers/api/search.ts @@ -6,14 +6,12 @@ import { AppController } from '@/app.ts'; import { booleanParamSchema } from '@/schema.ts'; import { Storages } from '@/storages.ts'; import { hydrateEvents } from '@/storages/hydrate.ts'; -import { bech32ToPubkey, extractBech32 } from '@/utils.ts'; +import { bech32ToPubkey } from '@/utils.ts'; +import { ACCT_REGEX, extractIdentifier } from '@/utils/lookup.ts'; import { nip05Cache } from '@/utils/nip05.ts'; import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts'; import { renderStatus } from '@/views/mastodon/statuses.ts'; -/** Matches NIP-05 names with or without an @ in front. */ -const ACCT_REGEX = /^@?(?:([\w.+-]+)@)?([\w.-]+)$/; - const searchQuerySchema = z.object({ q: z.string().transform(decodeURIComponent), type: z.enum(['accounts', 'statuses', 'hashtags']).optional(), @@ -34,11 +32,11 @@ const searchController: AppController = async (c) => { } const event = await lookupEvent(result.data, signal); - const bech32 = extractBech32(result.data.q); + const lookup = extractIdentifier(result.data.q); // Render account from pubkey. - if (!event && bech32) { - const pubkey = bech32ToPubkey(bech32); + if (!event && lookup) { + const pubkey = bech32ToPubkey(lookup); return c.json({ accounts: pubkey ? [await accountFromPubkey(pubkey)] : [], statuses: [], @@ -131,11 +129,10 @@ async function getLookupFilters({ q, type, resolve }: SearchQuery, signal: Abort return filters; } - const bech32 = extractBech32(q); - - if (bech32) { + const lookup = extractIdentifier(q); + if (lookup) { try { - const result = nip19.decode(bech32); + const result = nip19.decode(lookup); switch (result.type) { case 'npub': if (accounts) filters.push({ kinds: [0], authors: [result.data] }); diff --git a/src/utils.ts b/src/utils.ts index d40b2eb6..e361109d 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,6 +1,5 @@ import { NostrEvent, NSchema as n } from '@nostrify/nostrify'; import { nip19 } from 'nostr-tools'; -import { match } from 'path-to-regexp'; import { z } from 'zod'; /** Get the current time in Nostr format. */ @@ -25,51 +24,6 @@ function bech32ToPubkey(bech32: string): string | undefined { } } -/** Extract a bech32 ID out of a search query string. */ -function extractBech32(value: string): string | undefined { - let bech32: string = value; - - try { - const uri = new URL(value); - switch (uri.protocol) { - // Extract from NIP-19 URI, eg `nostr:npub1q3sle0kvfsehgsuexttt3ugjd8xdklxfwwkh559wxckmzddywnws6cd26p`. - case 'nostr:': - bech32 = uri.pathname; - break; - // Extract from URL, eg `https://njump.me/npub1q3sle0kvfsehgsuexttt3ugjd8xdklxfwwkh559wxckmzddywnws6cd26p`. - case 'http:': - case 'https:': { - const accountUriMatch = match<{ acct: string }>('/users/:acct')(uri.pathname); - const accountUrlMatch = match<{ acct: string }>('/\\@:acct')(uri.pathname); - const statusUriMatch = match<{ acct: string; id: string }>('/users/:acct/statuses/:id')(uri.pathname); - const statusUrlMatch = match<{ acct: string; id: string }>('/\\@:acct/:id')(uri.pathname); - const soapboxMatch = match<{ acct: string; id: string }>('/\\@:acct/posts/:id')(uri.pathname); - const nostrMatch = match<{ bech32: string }>('/:bech32')(uri.pathname); - if (accountUriMatch) { - bech32 = accountUriMatch.params.acct; - } else if (accountUrlMatch) { - bech32 = accountUrlMatch.params.acct; - } else if (statusUriMatch) { - bech32 = nip19.noteEncode(statusUriMatch.params.id); - } else if (statusUrlMatch) { - bech32 = nip19.noteEncode(statusUrlMatch.params.id); - } else if (soapboxMatch) { - bech32 = nip19.noteEncode(soapboxMatch.params.id); - } else if (nostrMatch) { - bech32 = nostrMatch.params.bech32; - } - break; - } - } - } catch { - // do nothing - } - - if (n.bech32().safeParse(bech32).success) { - return bech32; - } -} - interface Nip05 { /** Localpart of the nip05, eg `alex` in `alex@alexgleason.me`. */ local: string | undefined; @@ -134,18 +88,6 @@ function isURL(value: unknown): boolean { return z.string().url().safeParse(value).success; } -export { - bech32ToPubkey, - eventAge, - extractBech32, - findTag, - isNostrId, - isURL, - type Nip05, - nostrDate, - nostrNow, - parseNip05, - sha256, -}; +export { bech32ToPubkey, eventAge, findTag, isNostrId, isURL, type Nip05, nostrDate, nostrNow, parseNip05, sha256 }; export { Time } from '@/utils/time.ts'; diff --git a/src/utils/lookup.ts b/src/utils/lookup.ts index 90b30c2b..afcd384d 100644 --- a/src/utils/lookup.ts +++ b/src/utils/lookup.ts @@ -1,10 +1,15 @@ import { NIP05, NostrEvent, NSchema as n } from '@nostrify/nostrify'; +import { nip19 } from 'nostr-tools'; +import { match } from 'path-to-regexp'; import { getAuthor } from '@/queries.ts'; import { bech32ToPubkey } from '@/utils.ts'; import { nip05Cache } from '@/utils/nip05.ts'; import { Stickynotes } from '@soapbox/stickynotes'; +/** Matches NIP-05 names with or without an @ in front. */ +export const ACCT_REGEX = /^@?(?:([\w.+-]+)@)?([\w.-]+)$/; + /** Resolve a bech32 or NIP-05 identifier to an account. */ export async function lookupAccount( value: string, @@ -35,3 +40,50 @@ export async function lookupPubkey(value: string, signal?: AbortSignal): Promise } } } + +/** Extract an acct or bech32 identifier out of a URL or of itself. */ +export function extractIdentifier(value: string): string | undefined { + try { + const uri = new URL(value); + switch (uri.protocol) { + // Extract from NIP-19 URI, eg `nostr:npub1q3sle0kvfsehgsuexttt3ugjd8xdklxfwwkh559wxckmzddywnws6cd26p`. + case 'nostr:': + value = uri.pathname; + break; + // Extract from URL, eg `https://njump.me/npub1q3sle0kvfsehgsuexttt3ugjd8xdklxfwwkh559wxckmzddywnws6cd26p`. + case 'http:': + case 'https:': { + const accountUriMatch = match<{ acct: string }>('/users/:acct')(uri.pathname); + const accountUrlMatch = match<{ acct: string }>('/\\@:acct')(uri.pathname); + const statusUriMatch = match<{ acct: string; id: string }>('/users/:acct/statuses/:id')(uri.pathname); + const statusUrlMatch = match<{ acct: string; id: string }>('/\\@:acct/:id')(uri.pathname); + const soapboxMatch = match<{ acct: string; id: string }>('/\\@:acct/posts/:id')(uri.pathname); + const nostrMatch = match<{ bech32: string }>('/:bech32')(uri.pathname); + if (accountUriMatch) { + value = accountUriMatch.params.acct; + } else if (accountUrlMatch) { + value = accountUrlMatch.params.acct; + } else if (statusUriMatch) { + value = nip19.noteEncode(statusUriMatch.params.id); + } else if (statusUrlMatch) { + value = nip19.noteEncode(statusUrlMatch.params.id); + } else if (soapboxMatch) { + value = nip19.noteEncode(soapboxMatch.params.id); + } else if (nostrMatch) { + value = nostrMatch.params.bech32; + } + break; + } + } + } catch { + // do nothing + } + + if (n.bech32().safeParse(value).success) { + return value; + } + + if (ACCT_REGEX.test(value)) { + return value; + } +} From 8f5ec50a25594d17b44ccb9182384051db6e4763 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 7 Aug 2024 16:41:46 -0500 Subject: [PATCH 09/15] search: fix nip05 lookups --- src/controllers/api/search.ts | 77 ++++++++++++++++++----------------- src/utils/lookup.ts | 9 ++-- 2 files changed, 45 insertions(+), 41 deletions(-) diff --git a/src/controllers/api/search.ts b/src/controllers/api/search.ts index ef7f84c7..23eba60f 100644 --- a/src/controllers/api/search.ts +++ b/src/controllers/api/search.ts @@ -6,8 +6,7 @@ import { AppController } from '@/app.ts'; import { booleanParamSchema } from '@/schema.ts'; import { Storages } from '@/storages.ts'; import { hydrateEvents } from '@/storages/hydrate.ts'; -import { bech32ToPubkey } from '@/utils.ts'; -import { ACCT_REGEX, extractIdentifier } from '@/utils/lookup.ts'; +import { extractIdentifier, lookupPubkey } from '@/utils/lookup.ts'; import { nip05Cache } from '@/utils/nip05.ts'; import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts'; import { renderStatus } from '@/views/mastodon/statuses.ts'; @@ -36,7 +35,7 @@ const searchController: AppController = async (c) => { // Render account from pubkey. if (!event && lookup) { - const pubkey = bech32ToPubkey(lookup); + const pubkey = await lookupPubkey(lookup); return c.json({ accounts: pubkey ? [await accountFromPubkey(pubkey)] : [], statuses: [], @@ -120,51 +119,55 @@ async function lookupEvent(query: SearchQuery, signal: AbortSignal): Promise { - const filters: NostrFilter[] = []; - const accounts = !type || type === 'accounts'; const statuses = !type || type === 'statuses'; if (!resolve || type === 'hashtags') { + return []; + } + + if (n.id().safeParse(q).success) { + const filters: NostrFilter[] = []; + if (accounts) filters.push({ kinds: [0], authors: [q] }); + if (statuses) filters.push({ kinds: [1], ids: [q] }); return filters; } const lookup = extractIdentifier(q); - if (lookup) { - try { - const result = nip19.decode(lookup); - switch (result.type) { - case 'npub': - if (accounts) filters.push({ kinds: [0], authors: [result.data] }); - break; - case 'nprofile': - if (accounts) filters.push({ kinds: [0], authors: [result.data.pubkey] }); - break; - case 'note': - if (statuses) filters.push({ kinds: [1], ids: [result.data] }); - break; - case 'nevent': - if (statuses) filters.push({ kinds: [1], ids: [result.data.id] }); - break; - } - } catch { - // do nothing - } - } else if (n.id().safeParse(q).success) { - if (accounts) filters.push({ kinds: [0], authors: [q] }); - if (statuses) filters.push({ kinds: [1], ids: [q] }); - } else if (accounts && ACCT_REGEX.test(q)) { - try { - const { pubkey } = await nip05Cache.fetch(q, { signal }); - if (pubkey) { - filters.push({ kinds: [0], authors: [pubkey] }); - } - } catch { - // do nothing + if (!lookup) return []; + + try { + const result = nip19.decode(lookup); + const filters: NostrFilter[] = []; + switch (result.type) { + case 'npub': + if (accounts) filters.push({ kinds: [0], authors: [result.data] }); + break; + case 'nprofile': + if (accounts) filters.push({ kinds: [0], authors: [result.data.pubkey] }); + break; + case 'note': + if (statuses) filters.push({ kinds: [1], ids: [result.data] }); + break; + case 'nevent': + if (statuses) filters.push({ kinds: [1], ids: [result.data.id] }); + break; } + return filters; + } catch { + // do nothing } - return filters; + try { + const { pubkey } = await nip05Cache.fetch(lookup, { signal }); + if (pubkey) { + return [{ kinds: [0], authors: [pubkey] }]; + } + } catch { + // do nothing + } + + return []; } export { searchController }; diff --git a/src/utils/lookup.ts b/src/utils/lookup.ts index afcd384d..a824949a 100644 --- a/src/utils/lookup.ts +++ b/src/utils/lookup.ts @@ -7,9 +7,6 @@ import { bech32ToPubkey } from '@/utils.ts'; import { nip05Cache } from '@/utils/nip05.ts'; import { Stickynotes } from '@soapbox/stickynotes'; -/** Matches NIP-05 names with or without an @ in front. */ -export const ACCT_REGEX = /^@?(?:([\w.+-]+)@)?([\w.-]+)$/; - /** Resolve a bech32 or NIP-05 identifier to an account. */ export async function lookupAccount( value: string, @@ -43,6 +40,8 @@ export async function lookupPubkey(value: string, signal?: AbortSignal): Promise /** Extract an acct or bech32 identifier out of a URL or of itself. */ export function extractIdentifier(value: string): string | undefined { + value = value.trim(); + try { const uri = new URL(value); switch (uri.protocol) { @@ -79,11 +78,13 @@ export function extractIdentifier(value: string): string | undefined { // do nothing } + value = value.replace(/^@/, ''); + if (n.bech32().safeParse(value).success) { return value; } - if (ACCT_REGEX.test(value)) { + if (NIP05.regex().test(value)) { return value; } } From fd90c199f5ef3a13bf63954e7270b06b49d2174c Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 7 Aug 2024 16:44:32 -0500 Subject: [PATCH 10/15] search: fix lookupPubkey for account search endpoint --- src/controllers/api/accounts.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/controllers/api/accounts.ts b/src/controllers/api/accounts.ts index d55e0c19..221813ac 100644 --- a/src/controllers/api/accounts.ts +++ b/src/controllers/api/accounts.ts @@ -10,7 +10,7 @@ import { Storages } from '@/storages.ts'; import { uploadFile } from '@/utils/upload.ts'; import { nostrNow } from '@/utils.ts'; import { createEvent, paginated, parseBody, updateListEvent } from '@/utils/api.ts'; -import { extractIdentifier, lookupAccount } from '@/utils/lookup.ts'; +import { extractIdentifier, lookupAccount, lookupPubkey } from '@/utils/lookup.ts'; import { renderAccounts, renderEventAccounts, renderStatuses } from '@/views.ts'; import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts'; import { renderRelationship } from '@/views/mastodon/relationships.ts'; @@ -129,7 +129,7 @@ const accountSearchController: AppController = async (c) => { const event = await lookupAccount(lookup ?? query); if (!event && lookup) { - const pubkey = bech32ToPubkey(lookup); + const pubkey = await lookupPubkey(lookup); return c.json(pubkey ? [await accountFromPubkey(pubkey)] : []); } From a24c119c7b503964d00cc3c29d302afb1398beec Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 7 Aug 2024 16:56:15 -0500 Subject: [PATCH 11/15] accountSearchController: actually use the looked up event, whoops --- src/controllers/api/accounts.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/controllers/api/accounts.ts b/src/controllers/api/accounts.ts index 221813ac..7d9e7641 100644 --- a/src/controllers/api/accounts.ts +++ b/src/controllers/api/accounts.ts @@ -133,11 +133,13 @@ const accountSearchController: AppController = async (c) => { return c.json(pubkey ? [await accountFromPubkey(pubkey)] : []); } - const events = await store.query([{ kinds: [0], search: query, limit }], { signal }) - .then((events) => hydrateEvents({ events, store, signal })); + const events = event ? [event] : await store.query([{ kinds: [0], search: query, limit }], { signal }); - const accounts = await Promise.all( - events.map((event) => renderAccount(event)), + const accounts = await hydrateEvents({ events, store, signal }).then( + (events) => + Promise.all( + events.map((event) => renderAccount(event)), + ), ); return c.json(accounts); From 529e61be6d286e6d64ad24ee9d9cd78b98b0ee68 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 7 Aug 2024 17:27:22 -0500 Subject: [PATCH 12/15] Return properly formatted mentions in Status API --- src/utils/note.ts | 66 +++++++++++++++++----------------- src/views/mastodon/statuses.ts | 38 ++++++++------------ 2 files changed, 48 insertions(+), 56 deletions(-) diff --git a/src/utils/note.ts b/src/utils/note.ts index 6e0d8d41..00be4b1a 100644 --- a/src/utils/note.ts +++ b/src/utils/note.ts @@ -4,40 +4,12 @@ import linkify from 'linkifyjs'; import { nip19, nip21, nip27 } from 'nostr-tools'; import { Conf } from '@/config.ts'; +import { MastodonMention } from '@/entities/MastodonMention.ts'; import { getUrlMediaType, isPermittedMediaType } from '@/utils/media.ts'; linkify.registerCustomProtocol('nostr', true); linkify.registerCustomProtocol('wss'); -const linkifyOpts: linkify.Opts = { - render: { - hashtag: ({ content }) => { - const tag = content.replace(/^#/, ''); - const href = Conf.local(`/tags/${tag}`); - return `#${tag}`; - }, - url: ({ attributes, content }) => { - try { - const { decoded } = nip21.parse(content); - const pubkey = getDecodedPubkey(decoded); - if (pubkey) { - const name = pubkey.substring(0, 8); - const href = Conf.local(`/users/${pubkey}`); - return `@${name}`; - } else { - return ''; - } - } catch { - const attr = Object.entries(attributes) - .map(([name, value]) => `${name}="${value}"`) - .join(' '); - - return `${content}`; - } - }, - }, -}; - type Link = ReturnType[0]; interface ParsedNoteContent { @@ -48,12 +20,42 @@ interface ParsedNoteContent { } /** Convert Nostr content to Mastodon API HTML. Also return parsed data. */ -function parseNoteContent(content: string): ParsedNoteContent { - // Parsing twice is ineffecient, but I don't know how to do only once. - const html = linkifyStr(content, linkifyOpts).replace(/\n+$/, ''); +function parseNoteContent(content: string, mentions: MastodonMention[]): ParsedNoteContent { const links = linkify.find(content).filter(isLinkURL); const firstUrl = links.find(isNonMediaLink)?.href; + const html = linkifyStr(content, { + render: { + hashtag: ({ content }) => { + const tag = content.replace(/^#/, ''); + const href = Conf.local(`/tags/${tag}`); + return `#${tag}`; + }, + url: ({ attributes, content }) => { + try { + const { decoded } = nip21.parse(content); + const pubkey = getDecodedPubkey(decoded); + if (pubkey) { + const mention = mentions.find((m) => m.id === pubkey); + const npub = nip19.npubEncode(pubkey); + const acct = mention?.acct ?? npub; + const name = mention?.acct ?? npub.substring(0, 8); + const href = mention?.url ?? Conf.local(`/@${acct}`); + return `@${name}`; + } else { + return ''; + } + } catch { + const attr = Object.entries(attributes) + .map(([name, value]) => `${name}="${value}"`) + .join(' '); + + return `${content}`; + } + }, + }, + }).replace(/\n+$/, ''); + return { html, links, diff --git a/src/views/mastodon/statuses.ts b/src/views/mastodon/statuses.ts index 27bffdfb..7583ea95 100644 --- a/src/views/mastodon/statuses.ts +++ b/src/views/mastodon/statuses.ts @@ -46,13 +46,14 @@ async function renderStatus(event: DittoEvent, opts: RenderStatusOpts): Promise< [{ kinds: [0], authors: mentionedPubkeys, limit: mentionedPubkeys.length }], ); - const { html, links, firstUrl } = parseNoteContent(stripimeta(event.content, event.tags)); + const mentions = await Promise.all( + mentionedPubkeys.map((pubkey) => renderMention(pubkey, mentionedProfiles.find((event) => event.pubkey === pubkey))), + ); - const [mentions, card, relatedEvents] = await Promise + const { html, links, firstUrl } = parseNoteContent(stripimeta(event.content, event.tags), mentions); + + const [card, relatedEvents] = await Promise .all([ - Promise.all( - mentionedPubkeys.map((pubkey) => toMention(pubkey, mentionedProfiles.find((event) => event.pubkey === pubkey))), - ), firstUrl ? unfurlCardCached(firstUrl) : null, viewerPubkey ? await store.query([ @@ -152,25 +153,14 @@ async function renderReblog(event: DittoEvent, opts: RenderStatusOpts): Promise< }; } -async function toMention(pubkey: string, event?: NostrEvent): Promise { - const account = event ? await renderAccount(event) : undefined; - - if (account) { - return { - id: account.id, - acct: account.acct, - username: account.username, - url: account.url, - }; - } else { - const npub = nip19.npubEncode(pubkey); - return { - id: pubkey, - acct: npub, - username: npub.substring(0, 8), - url: Conf.local(`/users/${pubkey}`), - }; - } +async function renderMention(pubkey: string, event?: NostrEvent): Promise { + const account = event ? await renderAccount(event) : await accountFromPubkey(pubkey); + return { + id: account.id, + acct: account.acct, + username: account.username, + url: account.url, + }; } function buildInlineRecipients(mentions: MastodonMention[]): string { From 2f5b4557b77ada4af59c2e730933437d0ebf27d5 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 7 Aug 2024 17:34:36 -0500 Subject: [PATCH 13/15] compatMentions: remove post author and explicit text mentions --- src/views/mastodon/statuses.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/views/mastodon/statuses.ts b/src/views/mastodon/statuses.ts index 7583ea95..86a79cac 100644 --- a/src/views/mastodon/statuses.ts +++ b/src/views/mastodon/statuses.ts @@ -72,7 +72,11 @@ async function renderStatus(event: DittoEvent, opts: RenderStatusOpts): Promise< const bookmarkEvent = relatedEvents.find((event) => event.kind === 10003); const zapEvent = relatedEvents.find((event) => event.kind === 9734); - const content = buildInlineRecipients(mentions) + html; + const compatMentions = buildInlineRecipients(mentions.filter((m) => { + if (m.id === account.id) return false; + if (html.includes(m.url)) return false; + return true; + })); const cw = event.tags.find(([name]) => name === 'content-warning'); const subject = event.tags.find(([name]) => name === 'subject'); @@ -96,7 +100,7 @@ async function renderStatus(event: DittoEvent, opts: RenderStatusOpts): Promise< id: event.id, account, card, - content, + content: compatMentions + html, created_at: nostrDate(event.created_at).toISOString(), in_reply_to_id: replyId ?? null, in_reply_to_account_id: null, From ab17be219c24f53e06078f7d6ad8e4a627381473 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 7 Aug 2024 17:37:44 -0500 Subject: [PATCH 14/15] Fix parseNoteContent test --- src/utils/note.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/note.test.ts b/src/utils/note.test.ts index b351dbfd..0c9c6bf8 100644 --- a/src/utils/note.test.ts +++ b/src/utils/note.test.ts @@ -4,7 +4,7 @@ import { eventFixture } from '@/test.ts'; import { getMediaLinks, parseNoteContent, stripimeta } from '@/utils/note.ts'; Deno.test('parseNoteContent', () => { - const { html, links, firstUrl } = parseNoteContent('Hello, world!'); + const { html, links, firstUrl } = parseNoteContent('Hello, world!', []); assertEquals(html, 'Hello, world!'); assertEquals(links, []); assertEquals(firstUrl, undefined); From 2e38a7b9ec303bcaf4d0a9227468dc5087c3db60 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 7 Aug 2024 17:54:45 -0500 Subject: [PATCH 15/15] mentionsCompat: wrap each mention in an h-card span --- src/views/mastodon/statuses.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/views/mastodon/statuses.ts b/src/views/mastodon/statuses.ts index 86a79cac..f5d8d5bb 100644 --- a/src/views/mastodon/statuses.ts +++ b/src/views/mastodon/statuses.ts @@ -172,7 +172,7 @@ function buildInlineRecipients(mentions: MastodonMention[]): string { const elements = mentions.reduce((acc, { url, username }) => { const name = nip19.BECH32_REGEX.test(username) ? username.substring(0, 8) : username; - acc.push(`@${name}`); + acc.push(`@${name}`); return acc; }, []);