Merge branch 'search-fixes' into 'main'

search: parse bech32 ids from pasted URLs, improve Mastodon API compat

See merge request soapbox-pub/ditto!450
This commit is contained in:
Alex Gleason 2024-08-07 23:11:20 +00:00
commit e264d6116c
11 changed files with 205 additions and 171 deletions

View file

@ -65,6 +65,7 @@
"nostr-relaypool": "npm:nostr-relaypool2@0.6.34", "nostr-relaypool": "npm:nostr-relaypool2@0.6.34",
"nostr-tools": "npm:nostr-tools@2.5.1", "nostr-tools": "npm:nostr-tools@2.5.1",
"nostr-wasm": "npm:nostr-wasm@^0.1.0", "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", "postgres": "https://raw.githubusercontent.com/xyzshantaram/postgres.js/8a9bbce88b3f6425ecaacd99a80372338b157a53/deno/mod.js",
"prom-client": "npm:prom-client@^15.1.2", "prom-client": "npm:prom-client@^15.1.2",
"question-deno": "https://raw.githubusercontent.com/ocpu/question-deno/10022b8e52555335aa510adb08b0a300df3cf904/mod.ts", "question-deno": "https://raw.githubusercontent.com/ocpu/question-deno/10022b8e52555335aa510adb08b0a300df3cf904/mod.ts",

6
deno.lock generated
View file

@ -73,6 +73,7 @@
"npm:nostr-tools@^2.5.0": "npm:nostr-tools@2.5.1", "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-tools@^2.7.0": "npm:nostr-tools@2.7.0",
"npm:nostr-wasm@^0.1.0": "npm:nostr-wasm@0.1.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:postgres@3.4.4": "npm:postgres@3.4.4",
"npm:prom-client@^15.1.2": "npm:prom-client@15.1.2", "npm:prom-client@^15.1.2": "npm:prom-client@15.1.2",
"npm:tldts@^6.0.14": "npm:tldts@6.1.18", "npm:tldts@^6.0.14": "npm:tldts@6.1.18",
@ -947,6 +948,10 @@
"integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==",
"dependencies": {} "dependencies": {}
}, },
"path-to-regexp@7.1.0": {
"integrity": "sha512-ZToe+MbUF4lBqk6dV8GKot4DKfzrxXsplOddH8zN3YK+qw9/McvP7+4ICjZvOne0jQhN4eJwHsX6tT0Ns19fvw==",
"dependencies": {}
},
"picomatch@2.3.1": { "picomatch@2.3.1": {
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dependencies": {} "dependencies": {}
@ -1856,6 +1861,7 @@
"npm:nostr-relaypool2@0.6.34", "npm:nostr-relaypool2@0.6.34",
"npm:nostr-tools@2.5.1", "npm:nostr-tools@2.5.1",
"npm:nostr-wasm@^0.1.0", "npm:nostr-wasm@^0.1.0",
"npm:path-to-regexp@^7.1.0",
"npm:prom-client@^15.1.2", "npm:prom-client@^15.1.2",
"npm:tldts@^6.0.14", "npm:tldts@^6.0.14",
"npm:tseep@^1.2.1", "npm:tseep@^1.2.1",

View file

@ -10,7 +10,7 @@ import { Storages } from '@/storages.ts';
import { uploadFile } from '@/utils/upload.ts'; import { uploadFile } from '@/utils/upload.ts';
import { nostrNow } from '@/utils.ts'; import { nostrNow } from '@/utils.ts';
import { createEvent, paginated, parseBody, updateListEvent } from '@/utils/api.ts'; import { createEvent, paginated, parseBody, updateListEvent } from '@/utils/api.ts';
import { lookupAccount } from '@/utils/lookup.ts'; import { extractIdentifier, lookupAccount, lookupPubkey } from '@/utils/lookup.ts';
import { renderAccounts, renderEventAccounts, renderStatuses } from '@/views.ts'; import { renderAccounts, renderEventAccounts, renderStatuses } from '@/views.ts';
import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts'; import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts';
import { renderRelationship } from '@/views/mastodon/relationships.ts'; import { renderRelationship } from '@/views/mastodon/relationships.ts';
@ -110,45 +110,38 @@ const accountSearchQuerySchema = z.object({
q: z.string().transform(decodeURIComponent), q: z.string().transform(decodeURIComponent),
resolve: booleanParamSchema.optional().transform(Boolean), resolve: booleanParamSchema.optional().transform(Boolean),
following: z.boolean().default(false), 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 accountSearchController: AppController = async (c) => {
const result = accountSearchQuerySchema.safeParse(c.req.query());
const { signal } = c.req.raw; const { signal } = c.req.raw;
const { limit } = c.get('pagination');
const result = accountSearchQuerySchema.safeParse(c.req.query());
if (!result.success) { if (!result.success) {
return c.json({ error: 'Bad request', schema: result.error }, 422); return c.json({ error: 'Bad request', schema: result.error }, 422);
} }
const { q, limit } = result.data; const query = decodeURIComponent(result.data.q);
const query = decodeURIComponent(q);
const store = await Storages.search(); const store = await Storages.search();
const [event, events] = await Promise.all([ const lookup = extractIdentifier(query);
lookupAccount(query), const event = await lookupAccount(lookup ?? query);
store.query([{ kinds: [0], search: query, limit }], { signal }),
]);
const results = await hydrateEvents({ if (!event && lookup) {
events: event ? [event, ...events] : events, const pubkey = await lookupPubkey(lookup);
store, return c.json(pubkey ? [await accountFromPubkey(pubkey)] : []);
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))); const events = event ? [event] : await store.query([{ kinds: [0], search: query, limit }], { signal });
const accounts = await hydrateEvents({ events, store, signal }).then(
(events) =>
Promise.all(
events.map((event) => renderAccount(event)),
),
);
return c.json(accounts); return c.json(accounts);
}; };

View file

@ -5,14 +5,11 @@ import { z } from 'zod';
import { AppController } from '@/app.ts'; import { AppController } from '@/app.ts';
import { booleanParamSchema } from '@/schema.ts'; import { booleanParamSchema } from '@/schema.ts';
import { Storages } from '@/storages.ts'; import { Storages } from '@/storages.ts';
import { dedupeEvents } from '@/utils.ts'; import { hydrateEvents } from '@/storages/hydrate.ts';
import { extractIdentifier, lookupPubkey } from '@/utils/lookup.ts';
import { nip05Cache } from '@/utils/nip05.ts'; import { nip05Cache } from '@/utils/nip05.ts';
import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts'; import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts';
import { renderStatus } from '@/views/mastodon/statuses.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.-]+)$/;
const searchQuerySchema = z.object({ const searchQuerySchema = z.object({
q: z.string().transform(decodeURIComponent), q: z.string().transform(decodeURIComponent),
@ -33,43 +30,44 @@ const searchController: AppController = async (c) => {
return c.json({ error: 'Bad request', schema: result.error }, 422); return c.json({ error: 'Bad request', schema: result.error }, 422);
} }
const [event, events] = await Promise.all([ const event = await lookupEvent(result.data, signal);
lookupEvent(result.data, signal), const lookup = extractIdentifier(result.data.q);
searchEvents(result.data, signal),
]);
if (event) { // Render account from pubkey.
events.push(event); if (!event && lookup) {
const pubkey = await lookupPubkey(lookup);
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 viewerPubkey = await c.get('signer')?.getPublicKey();
const [accounts, statuses] = await Promise.all([ const [accounts, statuses] = await Promise.all([
Promise.all( Promise.all(
results events
.filter((event) => event.kind === 0) .filter((event) => event.kind === 0)
.map((event) => renderAccount(event)) .map((event) => renderAccount(event))
.filter(Boolean), .filter(Boolean),
), ),
Promise.all( Promise.all(
results events
.filter((event) => event.kind === 1) .filter((event) => event.kind === 1)
.map((event) => renderStatus(event, { viewerPubkey })) .map((event) => renderStatus(event, { viewerPubkey }))
.filter(Boolean), .filter(Boolean),
), ),
]); ]);
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);
}
}
return c.json({ return c.json({
accounts, accounts,
statuses, statuses,
@ -121,18 +119,26 @@ async function lookupEvent(query: SearchQuery, signal: AbortSignal): Promise<Nos
/** Get filters to lookup the input value. */ /** Get filters to lookup the input value. */
async function getLookupFilters({ q, type, resolve }: SearchQuery, signal: AbortSignal): Promise<NostrFilter[]> { async function getLookupFilters({ q, type, resolve }: SearchQuery, signal: AbortSignal): Promise<NostrFilter[]> {
const filters: NostrFilter[] = [];
const accounts = !type || type === 'accounts'; const accounts = !type || type === 'accounts';
const statuses = !type || type === 'statuses'; const statuses = !type || type === 'statuses';
if (!resolve || type === 'hashtags') { 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; return filters;
} }
if (new RegExp(`^${nip19.BECH32_REGEX.source}$`).test(q)) { const lookup = extractIdentifier(q);
if (!lookup) return [];
try { try {
const result = nip19.decode(q); const result = nip19.decode(lookup);
const filters: NostrFilter[] = [];
switch (result.type) { switch (result.type) {
case 'npub': case 'npub':
if (accounts) filters.push({ kinds: [0], authors: [result.data] }); if (accounts) filters.push({ kinds: [0], authors: [result.data] });
@ -141,34 +147,27 @@ async function getLookupFilters({ q, type, resolve }: SearchQuery, signal: Abort
if (accounts) filters.push({ kinds: [0], authors: [result.data.pubkey] }); if (accounts) filters.push({ kinds: [0], authors: [result.data.pubkey] });
break; break;
case 'note': case 'note':
if (statuses) { if (statuses) filters.push({ kinds: [1], ids: [result.data] });
filters.push({ kinds: [1], ids: [result.data] });
}
break; break;
case 'nevent': case 'nevent':
if (statuses) { if (statuses) filters.push({ kinds: [1], ids: [result.data.id] });
filters.push({ kinds: [1], ids: [result.data.id] });
}
break; break;
} }
} catch (_e) { return filters;
} catch {
// do nothing // do nothing
} }
} else if (/^[0-9a-f]{64}$/.test(q)) {
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 (_e) {
// 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 }; export { searchController };

View file

@ -36,6 +36,7 @@ export interface MastodonAccount {
}; };
}; };
statuses_count: number; statuses_count: number;
uri: string;
url: string; url: string;
username: string; username: string;
ditto: { ditto: {

View file

@ -19,7 +19,7 @@ function bech32ToPubkey(bech32: string): string | undefined {
case 'npub': case 'npub':
return decoded.data; return decoded.data;
} }
} catch (_) { } catch {
// //
} }
} }
@ -78,11 +78,6 @@ async function sha256(message: string): Promise<string> {
return hashHex; 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. */ /** Test whether the value is a Nostr ID. */
function isNostrId(value: unknown): boolean { function isNostrId(value: unknown): boolean {
return n.id().safeParse(value).success; return n.id().safeParse(value).success;
@ -93,18 +88,6 @@ function isURL(value: unknown): boolean {
return z.string().url().safeParse(value).success; return z.string().url().safeParse(value).success;
} }
export { export { bech32ToPubkey, eventAge, findTag, isNostrId, isURL, type Nip05, nostrDate, nostrNow, parseNip05, sha256 };
bech32ToPubkey,
dedupeEvents,
eventAge,
findTag,
isNostrId,
isURL,
type Nip05,
nostrDate,
nostrNow,
parseNip05,
sha256,
};
export { Time } from '@/utils/time.ts'; export { Time } from '@/utils/time.ts';

View file

@ -1,4 +1,6 @@
import { NIP05, NostrEvent, NSchema as n } from '@nostrify/nostrify'; 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 { getAuthor } from '@/queries.ts';
import { bech32ToPubkey } from '@/utils.ts'; import { bech32ToPubkey } from '@/utils.ts';
@ -35,3 +37,54 @@ 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) {
// 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
}
value = value.replace(/^@/, '');
if (n.bech32().safeParse(value).success) {
return value;
}
if (NIP05.regex().test(value)) {
return value;
}
}

View file

@ -4,7 +4,7 @@ import { eventFixture } from '@/test.ts';
import { getMediaLinks, parseNoteContent, stripimeta } from '@/utils/note.ts'; import { getMediaLinks, parseNoteContent, stripimeta } from '@/utils/note.ts';
Deno.test('parseNoteContent', () => { Deno.test('parseNoteContent', () => {
const { html, links, firstUrl } = parseNoteContent('Hello, world!'); const { html, links, firstUrl } = parseNoteContent('Hello, world!', []);
assertEquals(html, 'Hello, world!'); assertEquals(html, 'Hello, world!');
assertEquals(links, []); assertEquals(links, []);
assertEquals(firstUrl, undefined); assertEquals(firstUrl, undefined);

View file

@ -4,12 +4,27 @@ import linkify from 'linkifyjs';
import { nip19, nip21, nip27 } from 'nostr-tools'; import { nip19, nip21, nip27 } from 'nostr-tools';
import { Conf } from '@/config.ts'; import { Conf } from '@/config.ts';
import { MastodonMention } from '@/entities/MastodonMention.ts';
import { getUrlMediaType, isPermittedMediaType } from '@/utils/media.ts'; import { getUrlMediaType, isPermittedMediaType } from '@/utils/media.ts';
linkify.registerCustomProtocol('nostr', true); linkify.registerCustomProtocol('nostr', true);
linkify.registerCustomProtocol('wss'); linkify.registerCustomProtocol('wss');
const linkifyOpts: linkify.Opts = { type Link = ReturnType<typeof linkify.find>[0];
interface ParsedNoteContent {
html: string;
links: Link[];
/** First non-media URL - eligible for a preview card. */
firstUrl: string | undefined;
}
/** Convert Nostr content to Mastodon API HTML. Also return parsed data. */
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: { render: {
hashtag: ({ content }) => { hashtag: ({ content }) => {
const tag = content.replace(/^#/, ''); const tag = content.replace(/^#/, '');
@ -21,8 +36,11 @@ const linkifyOpts: linkify.Opts = {
const { decoded } = nip21.parse(content); const { decoded } = nip21.parse(content);
const pubkey = getDecodedPubkey(decoded); const pubkey = getDecodedPubkey(decoded);
if (pubkey) { if (pubkey) {
const name = pubkey.substring(0, 8); const mention = mentions.find((m) => m.id === pubkey);
const href = Conf.local(`/users/${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 `<span class="h-card"><a class="u-url mention" href="${href}" rel="ugc">@<span>${name}</span></a></span>`; return `<span class="h-card"><a class="u-url mention" href="${href}" rel="ugc">@<span>${name}</span></a></span>`;
} else { } else {
return ''; return '';
@ -36,23 +54,7 @@ const linkifyOpts: linkify.Opts = {
} }
}, },
}, },
}; }).replace(/\n+$/, '');
type Link = ReturnType<typeof linkify.find>[0];
interface ParsedNoteContent {
html: string;
links: Link[];
/** First non-media URL - eligible for a preview card. */
firstUrl: string | undefined;
}
/** 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+$/, '');
const links = linkify.find(content).filter(isLinkURL);
const firstUrl = links.find(isNonMediaLink)?.href;
return { return {
html, html,

View file

@ -42,10 +42,11 @@ async function renderAccount(
const npub = nip19.npubEncode(pubkey); const npub = nip19.npubEncode(pubkey);
const parsed05 = await parseAndVerifyNip05(nip05, pubkey); const parsed05 = await parseAndVerifyNip05(nip05, pubkey);
const acct = parsed05?.handle || npub;
return { return {
id: pubkey, id: pubkey,
acct: parsed05?.handle || npub, acct,
avatar: picture, avatar: picture,
avatar_static: picture, avatar_static: picture,
bot: false, bot: false,
@ -78,7 +79,8 @@ async function renderAccount(
} }
: undefined, : undefined,
statuses_count: event.author_stats?.notes_count ?? 0, 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), username: parsed05?.nickname || npub.substring(0, 8),
ditto: { ditto: {
accepts_zaps: Boolean(getLnurl({ lud06, lud16 })), accepts_zaps: Boolean(getLnurl({ lud06, lud16 })),

View file

@ -46,13 +46,14 @@ async function renderStatus(event: DittoEvent, opts: RenderStatusOpts): Promise<
[{ kinds: [0], authors: mentionedPubkeys, limit: mentionedPubkeys.length }], [{ 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([ .all([
Promise.all(
mentionedPubkeys.map((pubkey) => toMention(pubkey, mentionedProfiles.find((event) => event.pubkey === pubkey))),
),
firstUrl ? unfurlCardCached(firstUrl) : null, firstUrl ? unfurlCardCached(firstUrl) : null,
viewerPubkey viewerPubkey
? await store.query([ ? await store.query([
@ -71,7 +72,11 @@ async function renderStatus(event: DittoEvent, opts: RenderStatusOpts): Promise<
const bookmarkEvent = relatedEvents.find((event) => event.kind === 10003); const bookmarkEvent = relatedEvents.find((event) => event.kind === 10003);
const zapEvent = relatedEvents.find((event) => event.kind === 9734); 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 cw = event.tags.find(([name]) => name === 'content-warning');
const subject = event.tags.find(([name]) => name === 'subject'); const subject = event.tags.find(([name]) => name === 'subject');
@ -95,7 +100,7 @@ async function renderStatus(event: DittoEvent, opts: RenderStatusOpts): Promise<
id: event.id, id: event.id,
account, account,
card, card,
content, content: compatMentions + html,
created_at: nostrDate(event.created_at).toISOString(), created_at: nostrDate(event.created_at).toISOString(),
in_reply_to_id: replyId ?? null, in_reply_to_id: replyId ?? null,
in_reply_to_account_id: null, in_reply_to_account_id: null,
@ -121,8 +126,8 @@ async function renderStatus(event: DittoEvent, opts: RenderStatusOpts): Promise<
poll: null, poll: null,
quote: !event.quote ? null : await renderStatus(event.quote, { depth: depth + 1 }), quote: !event.quote ? null : await renderStatus(event.quote, { depth: depth + 1 }),
quote_id: event.quote?.id ?? null, quote_id: event.quote?.id ?? null,
uri: Conf.local(`/${note}`), uri: Conf.local(`/users/${account.acct}/statuses/${event.id}`),
url: Conf.local(`/${note}`), url: Conf.local(`/@${account.acct}/${event.id}`),
zapped: Boolean(zapEvent), zapped: Boolean(zapEvent),
ditto: { ditto: {
external_url: Conf.external(note), external_url: Conf.external(note),
@ -152,25 +157,14 @@ async function renderReblog(event: DittoEvent, opts: RenderStatusOpts): Promise<
}; };
} }
async function toMention(pubkey: string, event?: NostrEvent): Promise<MastodonMention> { async function renderMention(pubkey: string, event?: NostrEvent): Promise<MastodonMention> {
const account = event ? await renderAccount(event) : undefined; const account = event ? await renderAccount(event) : await accountFromPubkey(pubkey);
if (account) {
return { return {
id: account.id, id: account.id,
acct: account.acct, acct: account.acct,
username: account.username, username: account.username,
url: account.url, url: account.url,
}; };
} else {
const npub = nip19.npubEncode(pubkey);
return {
id: pubkey,
acct: npub,
username: npub.substring(0, 8),
url: Conf.local(`/users/${pubkey}`),
};
}
} }
function buildInlineRecipients(mentions: MastodonMention[]): string { function buildInlineRecipients(mentions: MastodonMention[]): string {
@ -178,7 +172,7 @@ function buildInlineRecipients(mentions: MastodonMention[]): string {
const elements = mentions.reduce<string[]>((acc, { url, username }) => { const elements = mentions.reduce<string[]>((acc, { url, username }) => {
const name = nip19.BECH32_REGEX.test(username) ? username.substring(0, 8) : username; const name = nip19.BECH32_REGEX.test(username) ? username.substring(0, 8) : username;
acc.push(`<a href="${url}" class="u-url mention" rel="ugc">@<span>${name}</span></a>`); acc.push(`<span class="h-card"><a class="u-url mention" href="${url}" rel="ugc">@<span>${name}</span></a></span>`);
return acc; return acc;
}, []); }, []);