mirror of
https://gitlab.com/soapbox-pub/ditto.git
synced 2025-12-06 11:29:46 +00:00
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:
commit
e264d6116c
11 changed files with 205 additions and 171 deletions
|
|
@ -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
6
deno.lock
generated
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
|
|
||||||
|
|
@ -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: {
|
||||||
|
|
|
||||||
21
src/utils.ts
21
src/utils.ts
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 })),
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue