mirror of
https://gitlab.com/soapbox-pub/ditto.git
synced 2025-12-06 11:29:46 +00:00
Merge remote-tracking branch 'origin/main' into opengraph-metadata
This commit is contained in:
commit
349f59cc70
30 changed files with 486 additions and 301 deletions
|
|
@ -30,7 +30,8 @@
|
|||
"@isaacs/ttlcache": "npm:@isaacs/ttlcache@^1.4.1",
|
||||
"@lambdalisue/async": "jsr:@lambdalisue/async@^2.1.1",
|
||||
"@noble/secp256k1": "npm:@noble/secp256k1@^2.0.0",
|
||||
"@nostrify/nostrify": "jsr:@nostrify/nostrify@^0.29.0",
|
||||
"@nostrify/db": "jsr:@nostrify/db@^0.30.0",
|
||||
"@nostrify/nostrify": "jsr:@nostrify/nostrify@^0.30.0",
|
||||
"@scure/base": "npm:@scure/base@^1.1.6",
|
||||
"@sentry/deno": "https://deno.land/x/sentry@7.112.2/index.mjs",
|
||||
"@soapbox/kysely-deno-sqlite": "jsr:@soapbox/kysely-deno-sqlite@^2.1.0",
|
||||
|
|
@ -64,7 +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@6.2.1",
|
||||
"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",
|
||||
|
|
|
|||
34
deno.lock
generated
34
deno.lock
generated
|
|
@ -11,10 +11,12 @@
|
|||
"jsr:@gleasonator/policy@0.4.1": "jsr:@gleasonator/policy@0.4.1",
|
||||
"jsr:@hono/hono@^4.4.6": "jsr:@hono/hono@4.5.3",
|
||||
"jsr:@lambdalisue/async@^2.1.1": "jsr:@lambdalisue/async@2.1.1",
|
||||
"jsr:@nostrify/db@^0.30.0": "jsr:@nostrify/db@0.30.0",
|
||||
"jsr:@nostrify/nostrify@^0.22.1": "jsr:@nostrify/nostrify@0.22.5",
|
||||
"jsr:@nostrify/nostrify@^0.22.4": "jsr:@nostrify/nostrify@0.22.4",
|
||||
"jsr:@nostrify/nostrify@^0.22.5": "jsr:@nostrify/nostrify@0.22.5",
|
||||
"jsr:@nostrify/nostrify@^0.29.0": "jsr:@nostrify/nostrify@0.29.0",
|
||||
"jsr:@nostrify/nostrify@^0.30.0": "jsr:@nostrify/nostrify@0.30.0",
|
||||
"jsr:@nostrify/types@^0.30.0": "jsr:@nostrify/types@0.30.0",
|
||||
"jsr:@soapbox/kysely-deno-sqlite@^2.1.0": "jsr:@soapbox/kysely-deno-sqlite@2.2.0",
|
||||
"jsr:@soapbox/stickynotes@^0.4.0": "jsr:@soapbox/stickynotes@0.4.0",
|
||||
"jsr:@std/assert@^0.217.0": "jsr:@std/assert@0.217.0",
|
||||
|
|
@ -71,8 +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@6.2.1": "npm:path-to-regexp@6.2.1",
|
||||
"npm:path-to-regexp@7.1.0": "npm:path-to-regexp@7.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",
|
||||
|
|
@ -137,6 +138,15 @@
|
|||
"@lambdalisue/async@2.1.1": {
|
||||
"integrity": "1fc9bc6f4ed50215cd2f7217842b18cea80f81c25744f88f8c5eb4be5a1c9ab4"
|
||||
},
|
||||
"@nostrify/db@0.30.0": {
|
||||
"integrity": "a75ba78be89d57c54c3d47e9e94c7142817c5b50daec27bf7f9a4af4629be20b",
|
||||
"dependencies": [
|
||||
"jsr:@nostrify/nostrify@^0.30.0",
|
||||
"jsr:@nostrify/types@^0.30.0",
|
||||
"npm:kysely@^0.27.3",
|
||||
"npm:nostr-tools@^2.7.0"
|
||||
]
|
||||
},
|
||||
"@nostrify/nostrify@0.22.4": {
|
||||
"integrity": "1c8a7847e5773213044b491e85fd7cafae2ad194ce59da4d957d2b27c776b42d",
|
||||
"dependencies": [
|
||||
|
|
@ -166,21 +176,24 @@
|
|||
"npm:zod@^3.23.8"
|
||||
]
|
||||
},
|
||||
"@nostrify/nostrify@0.29.0": {
|
||||
"integrity": "d0489b62441c891324cce60c14bb398013259494b5ad9d21ec6dfbf0ca7368c9",
|
||||
"@nostrify/nostrify@0.30.0": {
|
||||
"integrity": "7c29e7d8b5a0a81e238170ac1e7ad708bc72dd8f478d8d82c30598fb4eff9b9c",
|
||||
"dependencies": [
|
||||
"jsr:@nostrify/types@^0.30.0",
|
||||
"jsr:@std/crypto@^0.224.0",
|
||||
"jsr:@std/encoding@^0.224.1",
|
||||
"npm:@scure/base@^1.1.6",
|
||||
"npm:@scure/bip32@^1.4.0",
|
||||
"npm:@scure/bip39@^1.3.0",
|
||||
"npm:kysely@^0.27.3",
|
||||
"npm:lru-cache@^10.2.0",
|
||||
"npm:nostr-tools@^2.7.0",
|
||||
"npm:websocket-ts@^2.1.5",
|
||||
"npm:zod@^3.23.8"
|
||||
]
|
||||
},
|
||||
"@nostrify/types@0.30.0": {
|
||||
"integrity": "1f38fa849cff930bd709edbf94ef9ac02f46afb8b851f86c8736517b354616da"
|
||||
},
|
||||
"@soapbox/kysely-deno-sqlite@2.2.0": {
|
||||
"integrity": "668ec94600bc4b4d7bd618dd7ca65d4ef30ee61c46ffcb379b6f45203c08517a",
|
||||
"dependencies": [
|
||||
|
|
@ -935,10 +948,6 @@
|
|||
"integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==",
|
||||
"dependencies": {}
|
||||
},
|
||||
"path-to-regexp@6.2.1": {
|
||||
"integrity": "sha512-JLyh7xT1kizaEvcaXOQwOc2/Yhw6KZOvPf1S8401UyLk86CU79LN3vl7ztXGm/pZ+YjoyAJ4rxmHwbkBXJX+yw==",
|
||||
"dependencies": {}
|
||||
},
|
||||
"path-to-regexp@7.1.0": {
|
||||
"integrity": "sha512-ZToe+MbUF4lBqk6dV8GKot4DKfzrxXsplOddH8zN3YK+qw9/McvP7+4ICjZvOne0jQhN4eJwHsX6tT0Ns19fvw==",
|
||||
"dependencies": {}
|
||||
|
|
@ -1818,7 +1827,8 @@
|
|||
"jsr:@db/sqlite@^0.11.1",
|
||||
"jsr:@hono/hono@^4.4.6",
|
||||
"jsr:@lambdalisue/async@^2.1.1",
|
||||
"jsr:@nostrify/nostrify@^0.29.0",
|
||||
"jsr:@nostrify/db@^0.30.0",
|
||||
"jsr:@nostrify/nostrify@^0.30.0",
|
||||
"jsr:@soapbox/kysely-deno-sqlite@^2.1.0",
|
||||
"jsr:@soapbox/stickynotes@^0.4.0",
|
||||
"jsr:@std/assert@^0.225.1",
|
||||
|
|
@ -1851,7 +1861,7 @@
|
|||
"npm:nostr-relaypool2@0.6.34",
|
||||
"npm:nostr-tools@2.5.1",
|
||||
"npm:nostr-wasm@^0.1.0",
|
||||
"npm:path-to-regexp@6.2.1",
|
||||
"npm:path-to-regexp@^7.1.0",
|
||||
"npm:prom-client@^15.1.2",
|
||||
"npm:tldts@^6.0.14",
|
||||
"npm:tseep@^1.2.1",
|
||||
|
|
|
|||
14
src/app.ts
14
src/app.ts
|
|
@ -40,8 +40,10 @@ import {
|
|||
adminRelaysController,
|
||||
adminSetRelaysController,
|
||||
deleteZapSplitsController,
|
||||
getZapSplitsController,
|
||||
nameRequestController,
|
||||
nameRequestsController,
|
||||
statusZapSplitsController,
|
||||
updateZapSplitsController,
|
||||
} from '@/controllers/api/ditto.ts';
|
||||
import { emptyArrayController, emptyObjectController, notImplementedController } from '@/controllers/api/fallback.ts';
|
||||
|
|
@ -117,6 +119,7 @@ import { nostrController } from '@/controllers/well-known/nostr.ts';
|
|||
import { auth98Middleware, requireProof, requireRole } from '@/middleware/auth98Middleware.ts';
|
||||
import { cspMiddleware } from '@/middleware/cspMiddleware.ts';
|
||||
import { metricsMiddleware } from '@/middleware/metricsMiddleware.ts';
|
||||
import { paginationMiddleware } from '@/middleware/paginationMiddleware.ts';
|
||||
import { rateLimitMiddleware } from '@/middleware/rateLimitMiddleware.ts';
|
||||
import { requireSigner } from '@/middleware/requireSigner.ts';
|
||||
import { signerMiddleware } from '@/middleware/signerMiddleware.ts';
|
||||
|
|
@ -131,8 +134,12 @@ interface AppEnv extends HonoEnv {
|
|||
uploader?: NUploader;
|
||||
/** NIP-98 signed event proving the pubkey is owned by the user. */
|
||||
proof?: NostrEvent;
|
||||
/** Store */
|
||||
/** Storage for the user, might filter out unwanted content. */
|
||||
store: NStore;
|
||||
/** Normalized pagination params. */
|
||||
pagination: { since?: number; until?: number; limit: number };
|
||||
/** Normalized list pagination params. */
|
||||
listPagination: { offset: number; limit: number };
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -146,7 +153,7 @@ const debug = Debug('ditto:http');
|
|||
|
||||
app.use('*', rateLimitMiddleware(300, Time.minutes(5)));
|
||||
|
||||
app.use('/api/*', metricsMiddleware, logger(debug));
|
||||
app.use('/api/*', metricsMiddleware, paginationMiddleware, logger(debug));
|
||||
app.use('/.well-known/*', metricsMiddleware, logger(debug));
|
||||
app.use('/users/*', metricsMiddleware, logger(debug));
|
||||
app.use('/nodeinfo/*', metricsMiddleware, logger(debug));
|
||||
|
|
@ -264,6 +271,9 @@ app.put('/api/v1/admin/ditto/relays', requireRole('admin'), adminSetRelaysContro
|
|||
app.post('/api/v1/ditto/names', requireSigner, nameRequestController);
|
||||
app.get('/api/v1/ditto/names', requireSigner, nameRequestsController);
|
||||
|
||||
app.get('/api/v1/ditto/zap_splits', getZapSplitsController);
|
||||
app.get('/api/v1/ditto/:id{[0-9a-f]{64}}/zap_splits', statusZapSplitsController);
|
||||
|
||||
app.put('/api/v1/admin/ditto/zap_splits', requireRole('admin'), updateZapSplitsController);
|
||||
app.delete('/api/v1/admin/ditto/zap_splits', requireRole('admin'), deleteZapSplitsController);
|
||||
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@ import { booleanParamSchema, fileSchema } from '@/schema.ts';
|
|||
import { Storages } from '@/storages.ts';
|
||||
import { uploadFile } from '@/utils/upload.ts';
|
||||
import { nostrNow } from '@/utils.ts';
|
||||
import { createEvent, paginated, paginationSchema, parseBody, updateListEvent } from '@/utils/api.ts';
|
||||
import { lookupAccount } from '@/utils/lookup.ts';
|
||||
import { createEvent, paginated, parseBody, updateListEvent } from '@/utils/api.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';
|
||||
|
|
@ -110,45 +110,38 @@ 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 [event, events] = await Promise.all([
|
||||
lookupAccount(query),
|
||||
store.query([{ kinds: [0], search: query, limit }], { signal }),
|
||||
]);
|
||||
const lookup = extractIdentifier(query);
|
||||
const event = await lookupAccount(lookup ?? query);
|
||||
|
||||
const results = await hydrateEvents({
|
||||
events: event ? [event, ...events] : 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([]);
|
||||
}
|
||||
if (!event && lookup) {
|
||||
const pubkey = await lookupPubkey(lookup);
|
||||
return c.json(pubkey ? [await accountFromPubkey(pubkey)] : []);
|
||||
}
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
|
|
@ -160,7 +153,25 @@ const relationshipsController: AppController = async (c) => {
|
|||
return c.json({ error: 'Missing `id[]` query parameters.' }, 422);
|
||||
}
|
||||
|
||||
const result = await Promise.all(ids.data.map((id) => renderRelationship(pubkey, id)));
|
||||
const db = await Storages.db();
|
||||
|
||||
const [sourceEvents, targetEvents] = await Promise.all([
|
||||
db.query([{ kinds: [3, 10000], authors: [pubkey] }]),
|
||||
db.query([{ kinds: [3], authors: ids.data }]),
|
||||
]);
|
||||
|
||||
const event3 = sourceEvents.find((event) => event.kind === 3 && event.pubkey === pubkey);
|
||||
const event10000 = sourceEvents.find((event) => event.kind === 10000 && event.pubkey === pubkey);
|
||||
|
||||
const result = ids.data.map((id) =>
|
||||
renderRelationship({
|
||||
sourcePubkey: pubkey,
|
||||
targetPubkey: id,
|
||||
event3,
|
||||
target3: targetEvents.find((event) => event.kind === 3 && event.pubkey === id),
|
||||
event10000,
|
||||
})
|
||||
);
|
||||
|
||||
return c.json(result);
|
||||
};
|
||||
|
|
@ -174,7 +185,7 @@ const accountStatusesQuerySchema = z.object({
|
|||
|
||||
const accountStatusesController: AppController = async (c) => {
|
||||
const pubkey = c.req.param('pubkey');
|
||||
const { since, until } = paginationSchema.parse(c.req.query());
|
||||
const { since, until } = c.get('pagination');
|
||||
const { pinned, limit, exclude_replies, tagged } = accountStatusesQuerySchema.parse(c.req.query());
|
||||
const { signal } = c.req.raw;
|
||||
|
||||
|
|
@ -325,7 +336,7 @@ const followController: AppController = async (c) => {
|
|||
c,
|
||||
);
|
||||
|
||||
const relationship = await renderRelationship(sourcePubkey, targetPubkey);
|
||||
const relationship = await getRelationship(sourcePubkey, targetPubkey);
|
||||
relationship.following = true;
|
||||
|
||||
return c.json(relationship);
|
||||
|
|
@ -342,13 +353,13 @@ const unfollowController: AppController = async (c) => {
|
|||
c,
|
||||
);
|
||||
|
||||
const relationship = await renderRelationship(sourcePubkey, targetPubkey);
|
||||
const relationship = await getRelationship(sourcePubkey, targetPubkey);
|
||||
return c.json(relationship);
|
||||
};
|
||||
|
||||
const followersController: AppController = (c) => {
|
||||
const pubkey = c.req.param('pubkey');
|
||||
const params = paginationSchema.parse(c.req.query());
|
||||
const params = c.get('pagination');
|
||||
return renderEventAccounts(c, [{ kinds: [3], '#p': [pubkey], ...params }]);
|
||||
};
|
||||
|
||||
|
|
@ -379,7 +390,7 @@ const muteController: AppController = async (c) => {
|
|||
c,
|
||||
);
|
||||
|
||||
const relationship = await renderRelationship(sourcePubkey, targetPubkey);
|
||||
const relationship = await getRelationship(sourcePubkey, targetPubkey);
|
||||
return c.json(relationship);
|
||||
};
|
||||
|
||||
|
|
@ -394,13 +405,13 @@ const unmuteController: AppController = async (c) => {
|
|||
c,
|
||||
);
|
||||
|
||||
const relationship = await renderRelationship(sourcePubkey, targetPubkey);
|
||||
const relationship = await getRelationship(sourcePubkey, targetPubkey);
|
||||
return c.json(relationship);
|
||||
};
|
||||
|
||||
const favouritesController: AppController = async (c) => {
|
||||
const pubkey = await c.get('signer')?.getPublicKey()!;
|
||||
const params = paginationSchema.parse(c.req.query());
|
||||
const params = c.get('pagination');
|
||||
const { signal } = c.req.raw;
|
||||
|
||||
const store = await Storages.db();
|
||||
|
|
@ -447,6 +458,23 @@ const familiarFollowersController: AppController = async (c) => {
|
|||
return c.json(results);
|
||||
};
|
||||
|
||||
async function getRelationship(sourcePubkey: string, targetPubkey: string) {
|
||||
const db = await Storages.db();
|
||||
|
||||
const [sourceEvents, targetEvents] = await Promise.all([
|
||||
db.query([{ kinds: [3, 10000], authors: [sourcePubkey] }]),
|
||||
db.query([{ kinds: [3], authors: [targetPubkey] }]),
|
||||
]);
|
||||
|
||||
return renderRelationship({
|
||||
sourcePubkey,
|
||||
targetPubkey,
|
||||
event3: sourceEvents.find((event) => event.kind === 3 && event.pubkey === sourcePubkey),
|
||||
target3: targetEvents.find((event) => event.kind === 3 && event.pubkey === targetPubkey),
|
||||
event10000: sourceEvents.find((event) => event.kind === 10000 && event.pubkey === sourcePubkey),
|
||||
});
|
||||
}
|
||||
|
||||
export {
|
||||
accountController,
|
||||
accountLookupController,
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { Conf } from '@/config.ts';
|
|||
import { booleanParamSchema } from '@/schema.ts';
|
||||
import { Storages } from '@/storages.ts';
|
||||
import { hydrateEvents } from '@/storages/hydrate.ts';
|
||||
import { createAdminEvent, paginated, paginationSchema, parseBody, updateEventInfo, updateUser } from '@/utils/api.ts';
|
||||
import { createAdminEvent, paginated, parseBody, updateEventInfo, updateUser } from '@/utils/api.ts';
|
||||
import { renderNameRequest } from '@/views/ditto.ts';
|
||||
import { renderAdminAccount, renderAdminAccountFromPubkey } from '@/views/mastodon/admin-accounts.ts';
|
||||
|
||||
|
|
@ -29,7 +29,7 @@ const adminAccountQuerySchema = z.object({
|
|||
|
||||
const adminAccountsController: AppController = async (c) => {
|
||||
const store = await Storages.db();
|
||||
const params = paginationSchema.parse(c.req.query());
|
||||
const params = c.get('pagination');
|
||||
const { signal } = c.req.raw;
|
||||
const {
|
||||
local,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
import type { AppController } from '@/app.ts';
|
||||
import { parseBody } from '@/utils/api.ts';
|
||||
|
||||
/**
|
||||
* Apps are unnecessary cruft in Mastodon API, but necessary to make clients work.
|
||||
|
|
@ -14,10 +17,14 @@ const FAKE_APP = {
|
|||
vapid_key: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=',
|
||||
};
|
||||
|
||||
const createAppSchema = z.object({
|
||||
redirect_uris: z.string().url().optional(),
|
||||
});
|
||||
|
||||
const createAppController: AppController = async (c) => {
|
||||
// TODO: Handle both formData and json. 422 on parsing error.
|
||||
try {
|
||||
const { redirect_uris } = await c.req.json();
|
||||
const { redirect_uris } = createAppSchema.parse(await parseBody(c.req.raw));
|
||||
|
||||
return c.json({
|
||||
...FAKE_APP,
|
||||
|
|
|
|||
|
|
@ -1,18 +1,22 @@
|
|||
import { NostrEvent, NostrFilter, NSchema as n } from '@nostrify/nostrify';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { accountFromPubkey } from '@/views/mastodon/accounts.ts';
|
||||
import { AppController } from '@/app.ts';
|
||||
import { AdminSigner } from '@/signers/AdminSigner.ts';
|
||||
import { booleanParamSchema } from '@/schema.ts';
|
||||
import { Conf } from '@/config.ts';
|
||||
import { Storages } from '@/storages.ts';
|
||||
import { hydrateEvents } from '@/storages/hydrate.ts';
|
||||
import { createEvent, paginated, paginationSchema, parseBody } from '@/utils/api.ts';
|
||||
import { renderNameRequest } from '@/views/ditto.ts';
|
||||
import { getZapSplits } from '@/utils/zap-split.ts';
|
||||
import { updateListAdminEvent } from '@/utils/api.ts';
|
||||
import { addTag } from '@/utils/tags.ts';
|
||||
import { AdminSigner } from '@/signers/AdminSigner.ts';
|
||||
import { booleanParamSchema, percentageSchema } from '@/schema.ts';
|
||||
import { Conf } from '@/config.ts';
|
||||
import { createEvent, paginated, parseBody } from '@/utils/api.ts';
|
||||
import { deleteTag } from '@/utils/tags.ts';
|
||||
import { DittoEvent } from '@/interfaces/DittoEvent.ts';
|
||||
import { DittoZapSplits, getZapSplits } from '@/utils/zap-split.ts';
|
||||
import { getAuthor } from '@/queries.ts';
|
||||
import { hydrateEvents } from '@/storages/hydrate.ts';
|
||||
import { renderNameRequest } from '@/views/ditto.ts';
|
||||
import { renderAccount } from '@/views/mastodon/accounts.ts';
|
||||
import { Storages } from '@/storages.ts';
|
||||
import { updateListAdminEvent } from '@/utils/api.ts';
|
||||
|
||||
const markerSchema = z.enum(['read', 'write']);
|
||||
|
||||
|
|
@ -111,7 +115,7 @@ export const nameRequestsController: AppController = async (c) => {
|
|||
const signer = c.get('signer')!;
|
||||
const pubkey = await signer.getPublicKey();
|
||||
|
||||
const params = paginationSchema.parse(c.req.query());
|
||||
const params = c.get('pagination');
|
||||
const { approved, rejected } = nameRequestsSchema.parse(c.req.query());
|
||||
|
||||
const filter: NostrFilter = {
|
||||
|
|
@ -156,7 +160,7 @@ export const nameRequestsController: AppController = async (c) => {
|
|||
const zapSplitSchema = z.record(
|
||||
n.id(),
|
||||
z.object({
|
||||
amount: z.number().int().min(1).max(100),
|
||||
weight: z.number().int().min(1).max(100),
|
||||
message: z.string().max(500),
|
||||
}),
|
||||
);
|
||||
|
|
@ -170,8 +174,8 @@ export const updateZapSplitsController: AppController = async (c) => {
|
|||
return c.json({ error: result.error }, 400);
|
||||
}
|
||||
|
||||
const zap_split = await getZapSplits(store, Conf.pubkey);
|
||||
if (!zap_split) {
|
||||
const dittoZapSplit = await getZapSplits(store, Conf.pubkey);
|
||||
if (!dittoZapSplit) {
|
||||
return c.json({ error: 'Zap split not activated, restart the server.' }, 404);
|
||||
}
|
||||
|
||||
|
|
@ -186,7 +190,7 @@ export const updateZapSplitsController: AppController = async (c) => {
|
|||
{ kinds: [30078], authors: [Conf.pubkey], '#d': ['pub.ditto.zapSplits'], limit: 1 },
|
||||
(tags) =>
|
||||
pubkeys.reduce((accumulator, pubkey) => {
|
||||
return addTag(accumulator, ['p', pubkey, data[pubkey].amount.toString(), data[pubkey].message]);
|
||||
return addTag(accumulator, ['p', pubkey, data[pubkey].weight.toString(), data[pubkey].message]);
|
||||
}, tags),
|
||||
c,
|
||||
);
|
||||
|
|
@ -205,8 +209,8 @@ export const deleteZapSplitsController: AppController = async (c) => {
|
|||
return c.json({ error: result.error }, 400);
|
||||
}
|
||||
|
||||
const zap_split = await getZapSplits(store, Conf.pubkey);
|
||||
if (!zap_split) {
|
||||
const dittoZapSplit = await getZapSplits(store, Conf.pubkey);
|
||||
if (!dittoZapSplit) {
|
||||
return c.json({ error: 'Zap split not activated, restart the server.' }, 404);
|
||||
}
|
||||
|
||||
|
|
@ -223,3 +227,61 @@ export const deleteZapSplitsController: AppController = async (c) => {
|
|||
|
||||
return c.json(200);
|
||||
};
|
||||
|
||||
export const getZapSplitsController: AppController = async (c) => {
|
||||
const store = c.get('store');
|
||||
|
||||
const dittoZapSplit: DittoZapSplits | undefined = await getZapSplits(store, Conf.pubkey) ?? {};
|
||||
if (!dittoZapSplit) {
|
||||
return c.json({ error: 'Zap split not activated, restart the server.' }, 404);
|
||||
}
|
||||
|
||||
const pubkeys = Object.keys(dittoZapSplit);
|
||||
|
||||
const zapSplits = await Promise.all(pubkeys.map(async (pubkey) => {
|
||||
const author = await getAuthor(pubkey);
|
||||
|
||||
const account = author ? await renderAccount(author) : await accountFromPubkey(pubkey);
|
||||
|
||||
return {
|
||||
account,
|
||||
weight: dittoZapSplit[pubkey].weight,
|
||||
message: dittoZapSplit[pubkey].message,
|
||||
};
|
||||
}));
|
||||
|
||||
return c.json(zapSplits, 200);
|
||||
};
|
||||
|
||||
export const statusZapSplitsController: AppController = async (c) => {
|
||||
const store = c.get('store');
|
||||
const id = c.req.param('id');
|
||||
const { signal } = c.req.raw;
|
||||
|
||||
const [event] = await store.query([{ kinds: [1], ids: [id], limit: 1 }], { signal });
|
||||
if (!event) {
|
||||
return c.json({ error: 'Event not found' }, 404);
|
||||
}
|
||||
|
||||
const zapsTag = event.tags.filter(([name]) => name === 'zap');
|
||||
|
||||
const pubkeys = zapsTag.map((name) => name[1]);
|
||||
|
||||
const users = await store.query([{ authors: pubkeys, kinds: [0], limit: pubkeys.length }], { signal });
|
||||
await hydrateEvents({ events: users, store, signal });
|
||||
|
||||
const zapSplits = (await Promise.all(pubkeys.map(async (pubkey) => {
|
||||
const author = (users.find((event) => event.pubkey === pubkey) as DittoEvent | undefined)?.author;
|
||||
const account = author ? await renderAccount(author) : await accountFromPubkey(pubkey);
|
||||
|
||||
const weight = percentageSchema.catch(0).parse(zapsTag.find((name) => name[1] === pubkey)![3]) ?? 0;
|
||||
|
||||
return {
|
||||
account,
|
||||
message: '',
|
||||
weight: weight,
|
||||
};
|
||||
}))).filter((zapSplit) => zapSplit.weight > 0);
|
||||
|
||||
return c.json(zapSplits, 200);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,16 +4,12 @@ import { AppController } from '@/app.ts';
|
|||
import { Conf } from '@/config.ts';
|
||||
import { Storages } from '@/storages.ts';
|
||||
import { getInstanceMetadata } from '@/utils/instance.ts';
|
||||
import { DittoZapSplits, getZapSplits } from '@/utils/zap-split.ts';
|
||||
|
||||
const version = `3.0.0 (compatible; Ditto ${denoJson.version})`;
|
||||
|
||||
const instanceV1Controller: AppController = async (c) => {
|
||||
const { host, protocol } = Conf.url;
|
||||
const meta = await getInstanceMetadata(await Storages.db(), c.req.raw.signal);
|
||||
const store = c.get('store');
|
||||
|
||||
const zap_split: DittoZapSplits | undefined = await getZapSplits(store, Conf.pubkey) ?? {};
|
||||
|
||||
/** Protocol to use for WebSocket URLs, depending on the protocol of the `LOCAL_DOMAIN`. */
|
||||
const wsProtocol = protocol === 'http:' ? 'ws:' : 'wss:';
|
||||
|
|
@ -72,9 +68,6 @@ const instanceV1Controller: AppController = async (c) => {
|
|||
},
|
||||
},
|
||||
rules: [],
|
||||
ditto: {
|
||||
zap_split,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -3,8 +3,9 @@ import { z } from 'zod';
|
|||
|
||||
import { AppContext, AppController } from '@/app.ts';
|
||||
import { Conf } from '@/config.ts';
|
||||
import { DittoPagination } from '@/interfaces/DittoPagination.ts';
|
||||
import { hydrateEvents } from '@/storages/hydrate.ts';
|
||||
import { paginated, PaginationParams, paginationSchema } from '@/utils/api.ts';
|
||||
import { paginated } from '@/utils/api.ts';
|
||||
import { renderNotification } from '@/views/mastodon/notifications.ts';
|
||||
|
||||
/** Set of known notification types across backends. */
|
||||
|
|
@ -30,7 +31,7 @@ const notificationsSchema = z.object({
|
|||
|
||||
const notificationsController: AppController = async (c) => {
|
||||
const pubkey = await c.get('signer')?.getPublicKey()!;
|
||||
const params = paginationSchema.parse(c.req.query());
|
||||
const params = c.get('pagination');
|
||||
|
||||
const types = notificationTypes
|
||||
.intersection(new Set(c.req.queries('types[]') ?? notificationTypes))
|
||||
|
|
@ -72,7 +73,7 @@ const notificationsController: AppController = async (c) => {
|
|||
async function renderNotifications(
|
||||
filters: NostrFilter[],
|
||||
types: Set<string>,
|
||||
params: PaginationParams,
|
||||
params: DittoPagination,
|
||||
c: AppContext,
|
||||
) {
|
||||
const store = c.get('store');
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { z } from 'zod';
|
|||
|
||||
import { type AppController } from '@/app.ts';
|
||||
import { Conf } from '@/config.ts';
|
||||
import { createEvent, paginated, paginationSchema, parseBody, updateEventInfo } from '@/utils/api.ts';
|
||||
import { createEvent, paginated, parseBody, updateEventInfo } from '@/utils/api.ts';
|
||||
import { hydrateEvents } from '@/storages/hydrate.ts';
|
||||
import { renderAdminReport } from '@/views/mastodon/reports.ts';
|
||||
import { renderReport } from '@/views/mastodon/reports.ts';
|
||||
|
|
@ -64,7 +64,7 @@ const adminReportsController: AppController = async (c) => {
|
|||
const store = c.get('store');
|
||||
const viewerPubkey = await c.get('signer')?.getPublicKey();
|
||||
|
||||
const params = paginationSchema.parse(c.req.query());
|
||||
const params = c.get('pagination');
|
||||
const { resolved, account_id, target_account_id } = adminReportsSchema.parse(c.req.query());
|
||||
|
||||
const filter: NostrFilter = {
|
||||
|
|
|
|||
|
|
@ -5,14 +5,11 @@ 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 { hydrateEvents } from '@/storages/hydrate.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';
|
||||
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({
|
||||
q: z.string().transform(decodeURIComponent),
|
||||
|
|
@ -33,43 +30,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 lookup = extractIdentifier(result.data.q);
|
||||
|
||||
if (event) {
|
||||
events.push(event);
|
||||
// Render account from pubkey.
|
||||
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 [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),
|
||||
),
|
||||
]);
|
||||
|
||||
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({
|
||||
accounts,
|
||||
statuses,
|
||||
|
|
@ -121,54 +119,55 @@ async function lookupEvent(query: SearchQuery, signal: AbortSignal): Promise<Nos
|
|||
|
||||
/** Get filters to lookup the input value. */
|
||||
async function getLookupFilters({ q, type, resolve }: SearchQuery, signal: AbortSignal): Promise<NostrFilter[]> {
|
||||
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;
|
||||
}
|
||||
|
||||
if (new RegExp(`^${nip19.BECH32_REGEX.source}$`).test(q)) {
|
||||
try {
|
||||
const result = nip19.decode(q);
|
||||
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 (_e) {
|
||||
// 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
|
||||
const lookup = extractIdentifier(q);
|
||||
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 };
|
||||
|
|
|
|||
|
|
@ -19,15 +19,7 @@ import { renderEventAccounts } from '@/views.ts';
|
|||
import { renderReblog, renderStatus } from '@/views/mastodon/statuses.ts';
|
||||
import { Storages } from '@/storages.ts';
|
||||
import { hydrateEvents, purifyEvent } from '@/storages/hydrate.ts';
|
||||
import {
|
||||
createEvent,
|
||||
listPaginationSchema,
|
||||
paginated,
|
||||
paginatedList,
|
||||
paginationSchema,
|
||||
parseBody,
|
||||
updateListEvent,
|
||||
} from '@/utils/api.ts';
|
||||
import { createEvent, paginated, paginatedList, parseBody, updateListEvent } from '@/utils/api.ts';
|
||||
import { getInvoice, getLnurl } from '@/utils/lnurl.ts';
|
||||
import { getZapSplits } from '@/utils/zap-split.ts';
|
||||
|
||||
|
|
@ -179,12 +171,12 @@ const createStatusController: AppController = async (c) => {
|
|||
|
||||
const meta = n.json().pipe(n.metadata()).catch({}).parse(author?.content);
|
||||
const lnurl = getLnurl(meta);
|
||||
const zap_split = await getZapSplits(store, Conf.pubkey);
|
||||
if (lnurl && zap_split) {
|
||||
const dittoZapSplit = await getZapSplits(store, Conf.pubkey);
|
||||
if (lnurl && dittoZapSplit) {
|
||||
let totalSplit = 0;
|
||||
for (const pubkey in zap_split) {
|
||||
totalSplit += zap_split[pubkey].amount;
|
||||
tags.push(['zap', pubkey, Conf.relay, zap_split[pubkey].amount.toString()]);
|
||||
for (const pubkey in dittoZapSplit) {
|
||||
totalSplit += dittoZapSplit[pubkey].weight;
|
||||
tags.push(['zap', pubkey, Conf.relay, dittoZapSplit[pubkey].weight.toString()]);
|
||||
}
|
||||
if (totalSplit) {
|
||||
tags.push(['zap', author?.pubkey as string, Conf.relay, Math.max(0, 100 - totalSplit).toString()]);
|
||||
|
|
@ -296,7 +288,7 @@ const favouriteController: AppController = async (c) => {
|
|||
|
||||
const favouritedByController: AppController = (c) => {
|
||||
const id = c.req.param('id');
|
||||
const params = paginationSchema.parse(c.req.query());
|
||||
const params = c.get('pagination');
|
||||
|
||||
return renderEventAccounts(c, [{ kinds: [7], '#e': [id], ...params }], {
|
||||
filterFn: ({ content }) => content === '+',
|
||||
|
|
@ -364,13 +356,13 @@ const unreblogStatusController: AppController = async (c) => {
|
|||
|
||||
const rebloggedByController: AppController = (c) => {
|
||||
const id = c.req.param('id');
|
||||
const params = paginationSchema.parse(c.req.query());
|
||||
const params = c.get('pagination');
|
||||
return renderEventAccounts(c, [{ kinds: [6], '#e': [id], ...params }]);
|
||||
};
|
||||
|
||||
const quotesController: AppController = async (c) => {
|
||||
const id = c.req.param('id');
|
||||
const params = paginationSchema.parse(c.req.query());
|
||||
const params = c.get('pagination');
|
||||
const store = await Storages.db();
|
||||
|
||||
const [event] = await store.query([{ ids: [id], kinds: [1] }]);
|
||||
|
|
@ -571,7 +563,7 @@ const zapController: AppController = async (c) => {
|
|||
|
||||
const zappedByController: AppController = async (c) => {
|
||||
const id = c.req.param('id');
|
||||
const params = listPaginationSchema.parse(c.req.query());
|
||||
const params = c.get('listPagination');
|
||||
const store = await Storages.db();
|
||||
const db = await DittoDB.getInstance();
|
||||
|
||||
|
|
|
|||
|
|
@ -4,13 +4,13 @@ import { matchFilter } from 'nostr-tools';
|
|||
import { AppContext, AppController } from '@/app.ts';
|
||||
import { Conf } from '@/config.ts';
|
||||
import { hydrateEvents } from '@/storages/hydrate.ts';
|
||||
import { listPaginationSchema, paginatedList, PaginatedListParams } from '@/utils/api.ts';
|
||||
import { paginatedList } from '@/utils/api.ts';
|
||||
import { getTagSet } from '@/utils/tags.ts';
|
||||
import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts';
|
||||
|
||||
export const suggestionsV1Controller: AppController = async (c) => {
|
||||
const signal = c.req.raw.signal;
|
||||
const params = listPaginationSchema.parse(c.req.query());
|
||||
const params = c.get('listPagination');
|
||||
const suggestions = await renderV2Suggestions(c, params, signal);
|
||||
const accounts = suggestions.map(({ account }) => account);
|
||||
return paginatedList(c, params, accounts);
|
||||
|
|
@ -18,12 +18,12 @@ export const suggestionsV1Controller: AppController = async (c) => {
|
|||
|
||||
export const suggestionsV2Controller: AppController = async (c) => {
|
||||
const signal = c.req.raw.signal;
|
||||
const params = listPaginationSchema.parse(c.req.query());
|
||||
const params = c.get('listPagination');
|
||||
const suggestions = await renderV2Suggestions(c, params, signal);
|
||||
return paginatedList(c, params, suggestions);
|
||||
};
|
||||
|
||||
async function renderV2Suggestions(c: AppContext, params: PaginatedListParams, signal?: AbortSignal) {
|
||||
async function renderV2Suggestions(c: AppContext, params: { offset: number; limit: number }, signal?: AbortSignal) {
|
||||
const { offset, limit } = params;
|
||||
|
||||
const store = c.get('store');
|
||||
|
|
|
|||
|
|
@ -6,12 +6,12 @@ import { Conf } from '@/config.ts';
|
|||
import { getFeedPubkeys } from '@/queries.ts';
|
||||
import { booleanParamSchema } from '@/schema.ts';
|
||||
import { hydrateEvents } from '@/storages/hydrate.ts';
|
||||
import { paginated, paginationSchema } from '@/utils/api.ts';
|
||||
import { paginated } from '@/utils/api.ts';
|
||||
import { getTagSet } from '@/utils/tags.ts';
|
||||
import { renderReblog, renderStatus } from '@/views/mastodon/statuses.ts';
|
||||
|
||||
const homeTimelineController: AppController = async (c) => {
|
||||
const params = paginationSchema.parse(c.req.query());
|
||||
const params = c.get('pagination');
|
||||
const pubkey = await c.get('signer')?.getPublicKey()!;
|
||||
const authors = await getFeedPubkeys(pubkey);
|
||||
return renderStatuses(c, [{ authors, kinds: [1, 6], ...params }]);
|
||||
|
|
@ -23,7 +23,7 @@ const publicQuerySchema = z.object({
|
|||
});
|
||||
|
||||
const publicTimelineController: AppController = (c) => {
|
||||
const params = paginationSchema.parse(c.req.query());
|
||||
const params = c.get('pagination');
|
||||
const { local, instance } = publicQuerySchema.parse(c.req.query());
|
||||
|
||||
const filter: NostrFilter = { kinds: [1], ...params };
|
||||
|
|
@ -39,13 +39,13 @@ const publicTimelineController: AppController = (c) => {
|
|||
|
||||
const hashtagTimelineController: AppController = (c) => {
|
||||
const hashtag = c.req.param('hashtag')!.toLowerCase();
|
||||
const params = paginationSchema.parse(c.req.query());
|
||||
const params = c.get('pagination');
|
||||
return renderStatuses(c, [{ kinds: [1], '#t': [hashtag], ...params }]);
|
||||
};
|
||||
|
||||
const suggestedTimelineController: AppController = async (c) => {
|
||||
const store = c.get('store');
|
||||
const params = paginationSchema.parse(c.req.query());
|
||||
const params = c.get('pagination');
|
||||
|
||||
const [follows] = await store.query(
|
||||
[{ kinds: [3], authors: [Conf.pubkey], limit: 1 }],
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ export interface MastodonAccount {
|
|||
};
|
||||
};
|
||||
statuses_count: number;
|
||||
uri: string;
|
||||
url: string;
|
||||
username: string;
|
||||
ditto: {
|
||||
|
|
|
|||
15
src/interfaces/DittoPagination.ts
Normal file
15
src/interfaces/DittoPagination.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
/** Based on Mastodon pagination. */
|
||||
export interface DittoPagination {
|
||||
/** Lowest Nostr event `created_at` timestamp. */
|
||||
since?: number;
|
||||
/** Highest Nostr event `created_at` timestamp. */
|
||||
until?: number;
|
||||
/** @deprecated Mastodon apps are supposed to use the `Link` header. */
|
||||
max_id?: string;
|
||||
/** @deprecated Mastodon apps are supposed to use the `Link` header. */
|
||||
min_id?: string;
|
||||
/** Maximum number of results to return. Default 20, maximum 40. */
|
||||
limit?: number;
|
||||
/** Used by Ditto to offset tag values in Nostr list events. */
|
||||
offset?: number;
|
||||
}
|
||||
49
src/middleware/paginationMiddleware.ts
Normal file
49
src/middleware/paginationMiddleware.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { AppMiddleware } from '@/app.ts';
|
||||
import { paginationSchema } from '@/schemas/pagination.ts';
|
||||
import { Storages } from '@/storages.ts';
|
||||
|
||||
/** Fixes compatibility with Mastodon apps by that don't use `Link` headers. */
|
||||
export const paginationMiddleware: AppMiddleware = async (c, next) => {
|
||||
const pagination = paginationSchema.parse(c.req.query());
|
||||
|
||||
const {
|
||||
max_id: maxId,
|
||||
min_id: minId,
|
||||
since,
|
||||
until,
|
||||
} = pagination;
|
||||
|
||||
if ((maxId && !until) || (minId && !since)) {
|
||||
const ids: string[] = [];
|
||||
|
||||
if (maxId) ids.push(maxId);
|
||||
if (minId) ids.push(minId);
|
||||
|
||||
if (ids.length) {
|
||||
const store = await Storages.db();
|
||||
|
||||
const events = await store.query(
|
||||
[{ ids, limit: ids.length }],
|
||||
{ signal: c.req.raw.signal },
|
||||
);
|
||||
|
||||
for (const event of events) {
|
||||
if (!until && maxId === event.id) pagination.until = event.created_at;
|
||||
if (!since && minId === event.id) pagination.since = event.created_at;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
c.set('pagination', {
|
||||
since: pagination.since,
|
||||
until: pagination.until,
|
||||
limit: pagination.limit,
|
||||
});
|
||||
|
||||
c.set('listPagination', {
|
||||
limit: pagination.limit,
|
||||
offset: pagination.offset,
|
||||
});
|
||||
|
||||
await next();
|
||||
};
|
||||
11
src/schemas/pagination.ts
Normal file
11
src/schemas/pagination.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
/** Schema to parse pagination query params. */
|
||||
export const paginationSchema = z.object({
|
||||
max_id: z.string().optional().catch(undefined),
|
||||
min_id: z.string().optional().catch(undefined),
|
||||
since: z.coerce.number().nonnegative().optional().catch(undefined),
|
||||
until: z.coerce.number().nonnegative().optional().catch(undefined),
|
||||
limit: z.coerce.number().catch(20).transform((value) => Math.min(Math.max(value, 0), 40)),
|
||||
offset: z.coerce.number().nonnegative().catch(0),
|
||||
});
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
// deno-lint-ignore-file require-await
|
||||
|
||||
import { NDatabase } from '@nostrify/db';
|
||||
import {
|
||||
NDatabase,
|
||||
NIP50,
|
||||
NKinds,
|
||||
NostrEvent,
|
||||
|
|
|
|||
|
|
@ -2,9 +2,10 @@ import fs from 'node:fs/promises';
|
|||
import path from 'node:path';
|
||||
|
||||
import { Database as Sqlite } from '@db/sqlite';
|
||||
import { NDatabase } from '@nostrify/db';
|
||||
import { NostrEvent } from '@nostrify/nostrify';
|
||||
import { DenoSqlite3Dialect } from '@soapbox/kysely-deno-sqlite';
|
||||
import { finalizeEvent, generateSecretKey } from 'nostr-tools';
|
||||
import { NDatabase, NostrEvent } from '@nostrify/nostrify';
|
||||
import { FileMigrationProvider, Kysely, Migrator } from 'kysely';
|
||||
import { PostgresJSDialect, PostgresJSDialectConfig } from 'kysely-postgres-js';
|
||||
import postgres from 'postgres';
|
||||
|
|
|
|||
21
src/utils.ts
21
src/utils.ts
|
|
@ -19,7 +19,7 @@ function bech32ToPubkey(bech32: string): string | undefined {
|
|||
case 'npub':
|
||||
return decoded.data;
|
||||
}
|
||||
} catch (_) {
|
||||
} catch {
|
||||
//
|
||||
}
|
||||
}
|
||||
|
|
@ -78,11 +78,6 @@ async function sha256(message: string): Promise<string> {
|
|||
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;
|
||||
|
|
@ -93,18 +88,6 @@ function isURL(value: unknown): boolean {
|
|||
return z.string().url().safeParse(value).success;
|
||||
}
|
||||
|
||||
export {
|
||||
bech32ToPubkey,
|
||||
dedupeEvents,
|
||||
eventAge,
|
||||
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';
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import Debug from '@soapbox/stickynotes/debug';
|
|||
import { parseFormData } from 'formdata-helper';
|
||||
import { EventTemplate } from 'nostr-tools';
|
||||
import * as TypeFest from 'type-fest';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { type AppContext } from '@/app.ts';
|
||||
import { Conf } from '@/config.ts';
|
||||
|
|
@ -176,16 +175,6 @@ async function parseBody(req: Request): Promise<unknown> {
|
|||
}
|
||||
}
|
||||
|
||||
/** Schema to parse pagination query params. */
|
||||
const paginationSchema = z.object({
|
||||
since: z.coerce.number().nonnegative().optional().catch(undefined),
|
||||
until: z.coerce.number().nonnegative().optional().catch(undefined),
|
||||
limit: z.coerce.number().catch(20).transform((value) => Math.min(Math.max(value, 0), 40)),
|
||||
});
|
||||
|
||||
/** Mastodon API pagination query params. */
|
||||
type PaginationParams = z.infer<typeof paginationSchema>;
|
||||
|
||||
/** Build HTTP Link header for Mastodon API pagination. */
|
||||
function buildLinkHeader(url: string, events: NostrEvent[]): string | undefined {
|
||||
if (events.length <= 1) return;
|
||||
|
|
@ -219,12 +208,6 @@ function paginated(c: AppContext, events: NostrEvent[], entities: (Entity | unde
|
|||
return c.json(results, 200, headers);
|
||||
}
|
||||
|
||||
/** Query params for paginating a list. */
|
||||
const listPaginationSchema = z.object({
|
||||
offset: z.coerce.number().nonnegative().catch(0),
|
||||
limit: z.coerce.number().catch(20).transform((value) => Math.min(Math.max(value, 0), 40)),
|
||||
});
|
||||
|
||||
/** Build HTTP Link header for paginating Nostr lists. */
|
||||
function buildListLinkHeader(url: string, params: { offset: number; limit: number }): string | undefined {
|
||||
const { origin } = Conf.url;
|
||||
|
|
@ -242,15 +225,10 @@ function buildListLinkHeader(url: string, params: { offset: number; limit: numbe
|
|||
return `<${next}>; rel="next", <${prev}>; rel="prev"`;
|
||||
}
|
||||
|
||||
interface PaginatedListParams {
|
||||
offset: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
/** paginate a list of tags. */
|
||||
function paginatedList(
|
||||
c: AppContext,
|
||||
params: PaginatedListParams,
|
||||
params: { offset: number; limit: number },
|
||||
entities: unknown[],
|
||||
headers: HeaderRecord = {},
|
||||
) {
|
||||
|
|
@ -296,13 +274,9 @@ export {
|
|||
createAdminEvent,
|
||||
createEvent,
|
||||
type EventStub,
|
||||
listPaginationSchema,
|
||||
localRequest,
|
||||
paginated,
|
||||
paginatedList,
|
||||
type PaginatedListParams,
|
||||
type PaginationParams,
|
||||
paginationSchema,
|
||||
parseBody,
|
||||
updateAdminEvent,
|
||||
updateEvent,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
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';
|
||||
|
|
@ -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';
|
||||
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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 `<a class=\"mention hashtag\" href=\"${href}\" rel=\"tag\"><span>#</span>${tag}</a>`;
|
||||
},
|
||||
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 `<span class="h-card"><a class="u-url mention" href="${href}" rel="ugc">@<span>${name}</span></a></span>`;
|
||||
} else {
|
||||
return '';
|
||||
}
|
||||
} catch {
|
||||
const attr = Object.entries(attributes)
|
||||
.map(([name, value]) => `${name}="${value}"`)
|
||||
.join(' ');
|
||||
|
||||
return `<a ${attr}>${content}</a>`;
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
type Link = ReturnType<typeof linkify.find>[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 `<a class=\"mention hashtag\" href=\"${href}\" rel=\"tag\"><span>#</span>${tag}</a>`;
|
||||
},
|
||||
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 `<span class="h-card"><a class="u-url mention" href="${href}" rel="ugc">@<span>${name}</span></a></span>`;
|
||||
} else {
|
||||
return '';
|
||||
}
|
||||
} catch {
|
||||
const attr = Object.entries(attributes)
|
||||
.map(([name, value]) => `${name}="${value}"`)
|
||||
.join(' ');
|
||||
|
||||
return `<a ${attr}>${content}</a>`;
|
||||
}
|
||||
},
|
||||
},
|
||||
}).replace(/\n+$/, '');
|
||||
|
||||
return {
|
||||
html,
|
||||
links,
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ type ExtraMessage = string;
|
|||
type splitPercentages = number;
|
||||
|
||||
export type DittoZapSplits = {
|
||||
[key: Pubkey]: { amount: splitPercentages; message: ExtraMessage };
|
||||
[key: Pubkey]: { weight: splitPercentages; message: ExtraMessage };
|
||||
};
|
||||
|
||||
/** Gets zap splits from NIP-78 in DittoZapSplits format. */
|
||||
|
|
@ -30,7 +30,7 @@ export async function getZapSplits(store: NStore, pubkey: string): Promise<Ditto
|
|||
tag[0] === 'p' && n.id().safeParse(tag[1]).success &&
|
||||
percentageSchema.safeParse(tag[2]).success
|
||||
) {
|
||||
zapSplits[tag[1]] = { amount: Number(tag[2]), message: tag[3] };
|
||||
zapSplits[tag[1]] = { weight: Number(tag[2]), message: tag[3] };
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { AppContext } from '@/app.ts';
|
|||
import { Storages } from '@/storages.ts';
|
||||
import { renderAccount } from '@/views/mastodon/accounts.ts';
|
||||
import { renderStatus } from '@/views/mastodon/statuses.ts';
|
||||
import { listPaginationSchema, paginated, paginatedList, paginationSchema } from '@/utils/api.ts';
|
||||
import { paginated, paginatedList } from '@/utils/api.ts';
|
||||
import { hydrateEvents } from '@/storages/hydrate.ts';
|
||||
import { accountFromPubkey } from '@/views/mastodon/accounts.ts';
|
||||
|
||||
|
|
@ -43,7 +43,7 @@ async function renderEventAccounts(c: AppContext, filters: NostrFilter[], opts?:
|
|||
}
|
||||
|
||||
async function renderAccounts(c: AppContext, pubkeys: string[]) {
|
||||
const { offset, limit } = listPaginationSchema.parse(c.req.query());
|
||||
const { offset, limit } = c.get('listPagination');
|
||||
const authors = pubkeys.reverse().slice(offset, offset + limit);
|
||||
|
||||
const store = await Storages.db();
|
||||
|
|
@ -73,7 +73,7 @@ async function renderStatuses(c: AppContext, ids: string[], signal = AbortSignal
|
|||
}
|
||||
|
||||
const store = await Storages.db();
|
||||
const { limit } = paginationSchema.parse(c.req.query());
|
||||
const { limit } = c.get('pagination');
|
||||
|
||||
const events = await store.query([{ kinds: [1], ids, limit }], { signal })
|
||||
.then((events) => hydrateEvents({ events, store, signal }));
|
||||
|
|
|
|||
|
|
@ -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 })),
|
||||
|
|
|
|||
|
|
@ -1,19 +1,16 @@
|
|||
import { Storages } from '@/storages.ts';
|
||||
import { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { hasTag } from '@/utils/tags.ts';
|
||||
|
||||
async function renderRelationship(sourcePubkey: string, targetPubkey: string) {
|
||||
const db = await Storages.db();
|
||||
|
||||
const events = await db.query([
|
||||
{ kinds: [3], authors: [sourcePubkey], limit: 1 },
|
||||
{ kinds: [3], authors: [targetPubkey], limit: 1 },
|
||||
{ kinds: [10000], authors: [sourcePubkey], limit: 1 },
|
||||
]);
|
||||
|
||||
const event3 = events.find((event) => event.kind === 3 && event.pubkey === sourcePubkey);
|
||||
const target3 = events.find((event) => event.kind === 3 && event.pubkey === targetPubkey);
|
||||
const event10000 = events.find((event) => event.kind === 10000 && event.pubkey === sourcePubkey);
|
||||
interface RenderRelationshipOpts {
|
||||
sourcePubkey: string;
|
||||
targetPubkey: string;
|
||||
event3: NostrEvent | undefined;
|
||||
target3: NostrEvent | undefined;
|
||||
event10000: NostrEvent | undefined;
|
||||
}
|
||||
|
||||
function renderRelationship({ sourcePubkey, targetPubkey, event3, target3, event10000 }: RenderRelationshipOpts) {
|
||||
return {
|
||||
id: targetPubkey,
|
||||
following: event3 ? hasTag(event3.tags, ['p', targetPubkey]) : false,
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
@ -71,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');
|
||||
|
|
@ -95,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,
|
||||
|
|
@ -121,8 +126,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),
|
||||
|
|
@ -152,25 +157,14 @@ async function renderReblog(event: DittoEvent, opts: RenderStatusOpts): Promise<
|
|||
};
|
||||
}
|
||||
|
||||
async function toMention(pubkey: string, event?: NostrEvent): Promise<MastodonMention> {
|
||||
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<MastodonMention> {
|
||||
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 {
|
||||
|
|
@ -178,7 +172,7 @@ function buildInlineRecipients(mentions: MastodonMention[]): string {
|
|||
|
||||
const elements = mentions.reduce<string[]>((acc, { url, 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;
|
||||
}, []);
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue