,
+ params: PaginationParams,
+ c: AppContext,
+) {
const store = c.get('store');
const pubkey = await c.get('signer')?.getPublicKey()!;
const { signal } = c.req.raw;
+ const opts = { signal, limit: params.limit };
const events = await store
- .query(filters, { signal })
+ .query(filters, opts)
.then((events) => events.filter((event) => event.pubkey !== pubkey))
.then((events) => hydrateEvents({ events, store, signal }));
@@ -26,9 +89,8 @@ async function renderNotifications(c: AppContext, filters: NostrFilter[]) {
return c.json([]);
}
- const notifications = (await Promise
- .all(events.map((event) => renderNotification(event, { viewerPubkey: pubkey }))))
- .filter(Boolean);
+ const notifications = (await Promise.all(events.map((event) => renderNotification(event, { viewerPubkey: pubkey }))))
+ .filter((notification) => notification && types.has(notification.type));
if (!notifications.length) {
return c.json([]);
diff --git a/src/controllers/api/oauth.ts b/src/controllers/api/oauth.ts
index 5837a5ef..01f80bf1 100644
--- a/src/controllers/api/oauth.ts
+++ b/src/controllers/api/oauth.ts
@@ -122,6 +122,7 @@ const oauthController: AppController = (c) => {
return c.text('Missing `redirect_uri` query param.', 422);
}
+ const state = c.req.query('state');
const redirectUri = maybeDecodeUri(encodedUri);
return c.html(`
@@ -162,6 +163,7 @@ const oauthController: AppController = (c) => {
Sign in with a Nostr bunker app. Please configure the app to use this relay: ${Conf.relay}
@@ -187,6 +189,7 @@ function maybeDecodeUri(uri: string): string {
const oauthAuthorizeSchema = z.object({
bunker_uri: z.string().url().refine((v) => v.startsWith('bunker://')),
redirect_uri: z.string().url(),
+ state: z.string().optional(),
});
/** Controller the OAuth form is POSTed to. */
@@ -199,7 +202,7 @@ const oauthAuthorizeController: AppController = async (c) => {
}
// Parsed FormData values.
- const { bunker_uri, redirect_uri: redirectUri } = result.data;
+ const { bunker_uri, redirect_uri: redirectUri, state } = result.data;
const bunker = new URL(bunker_uri);
@@ -209,17 +212,26 @@ const oauthAuthorizeController: AppController = async (c) => {
relays: bunker.searchParams.getAll('relay'),
});
- const url = addCodeToRedirectUri(redirectUri, token);
+ if (redirectUri === 'urn:ietf:wg:oauth:2.0:oob') {
+ return c.text(token);
+ }
+
+ const url = addCodeToRedirectUri(redirectUri, token, state);
return c.redirect(url);
};
/** Append the given `code` as a query param to the `redirect_uri`. */
-function addCodeToRedirectUri(redirectUri: string, code: string): string {
+function addCodeToRedirectUri(redirectUri: string, code: string, state?: string): string {
const url = new URL(redirectUri);
const q = new URLSearchParams();
q.set('code', code);
+
+ if (state) {
+ q.set('state', state);
+ }
+
url.search = q.toString();
return url.toString();
diff --git a/src/controllers/api/pleroma.ts b/src/controllers/api/pleroma.ts
index 31b4fc58..31d8545f 100644
--- a/src/controllers/api/pleroma.ts
+++ b/src/controllers/api/pleroma.ts
@@ -6,7 +6,8 @@ import { Conf } from '@/config.ts';
import { configSchema, elixirTupleSchema, type PleromaConfig } from '@/schemas/pleroma-api.ts';
import { AdminSigner } from '@/signers/AdminSigner.ts';
import { Storages } from '@/storages.ts';
-import { createAdminEvent } from '@/utils/api.ts';
+import { createAdminEvent, updateAdminEvent, updateUser } from '@/utils/api.ts';
+import { lookupPubkey } from '@/utils/lookup.ts';
const frontendConfigController: AppController = async (c) => {
const store = await Storages.db();
@@ -87,4 +88,100 @@ async function getConfigs(store: NStore, signal: AbortSignal): Promise {
+ const params = pleromaAdminTagSchema.parse(await c.req.json());
+
+ for (const nickname of params.nicknames) {
+ const pubkey = await lookupPubkey(nickname);
+ if (!pubkey) continue;
+
+ await updateAdminEvent(
+ { kinds: [30382], authors: [Conf.pubkey], '#d': [pubkey], limit: 1 },
+ (prev) => {
+ const tags = prev?.tags ?? [['d', pubkey]];
+
+ for (const tag of params.tags) {
+ const existing = prev?.tags.some(([name, value]) => name === 't' && value === tag);
+ if (!existing) {
+ tags.push(['t', tag]);
+ }
+ }
+
+ return {
+ kind: 30382,
+ content: prev?.content ?? '',
+ tags,
+ };
+ },
+ c,
+ );
+ }
+
+ return new Response(null, { status: 204 });
+};
+
+const pleromaAdminUntagController: AppController = async (c) => {
+ const params = pleromaAdminTagSchema.parse(await c.req.json());
+
+ for (const nickname of params.nicknames) {
+ const pubkey = await lookupPubkey(nickname);
+ if (!pubkey) continue;
+
+ await updateAdminEvent(
+ { kinds: [30382], authors: [Conf.pubkey], '#d': [pubkey], limit: 1 },
+ (prev) => ({
+ kind: 30382,
+ content: prev?.content ?? '',
+ tags: (prev?.tags ?? [['d', pubkey]])
+ .filter(([name, value]) => !(name === 't' && params.tags.includes(value))),
+ }),
+ c,
+ );
+ }
+
+ return new Response(null, { status: 204 });
+};
+
+const pleromaAdminSuggestSchema = z.object({
+ nicknames: z.string().array(),
+});
+
+const pleromaAdminSuggestController: AppController = async (c) => {
+ const { nicknames } = pleromaAdminSuggestSchema.parse(await c.req.json());
+
+ for (const nickname of nicknames) {
+ const pubkey = await lookupPubkey(nickname);
+ if (!pubkey) continue;
+ await updateUser(pubkey, { suggested: true }, c);
+ }
+
+ return new Response(null, { status: 204 });
+};
+
+const pleromaAdminUnsuggestController: AppController = async (c) => {
+ const { nicknames } = pleromaAdminSuggestSchema.parse(await c.req.json());
+
+ for (const nickname of nicknames) {
+ const pubkey = await lookupPubkey(nickname);
+ if (!pubkey) continue;
+ await updateUser(pubkey, { suggested: false }, c);
+ }
+
+ return new Response(null, { status: 204 });
+};
+
+export {
+ configController,
+ frontendConfigController,
+ pleromaAdminDeleteStatusController,
+ pleromaAdminSuggestController,
+ pleromaAdminTagController,
+ pleromaAdminUnsuggestController,
+ pleromaAdminUntagController,
+ updateConfigController,
+};
diff --git a/src/controllers/api/reports.ts b/src/controllers/api/reports.ts
index 9cb2627a..da107ed9 100644
--- a/src/controllers/api/reports.ts
+++ b/src/controllers/api/reports.ts
@@ -1,12 +1,13 @@
-import { NSchema as n } from '@nostrify/nostrify';
+import { NostrFilter, NSchema as n } from '@nostrify/nostrify';
import { z } from 'zod';
import { type AppController } from '@/app.ts';
import { Conf } from '@/config.ts';
-import { createAdminEvent, createEvent, parseBody } from '@/utils/api.ts';
+import { createEvent, paginated, paginationSchema, 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';
+import { booleanParamSchema } from '@/schema.ts';
const reportSchema = z.object({
account_id: n.id(),
@@ -52,20 +53,55 @@ const reportController: AppController = async (c) => {
return c.json(await renderReport(event));
};
+const adminReportsSchema = z.object({
+ resolved: booleanParamSchema.optional(),
+ account_id: n.id().optional(),
+ target_account_id: n.id().optional(),
+});
+
/** https://docs.joinmastodon.org/methods/admin/reports/#get */
const adminReportsController: AppController = async (c) => {
const store = c.get('store');
const viewerPubkey = await c.get('signer')?.getPublicKey();
- const reports = await store.query([{ kinds: [1984], '#P': [Conf.pubkey] }])
- .then((events) => hydrateEvents({ store, events: events, signal: c.req.raw.signal }))
- .then((events) =>
- Promise.all(
- events.map((event) => renderAdminReport(event, { viewerPubkey })),
- )
- );
+ const params = paginationSchema.parse(c.req.query());
+ const { resolved, account_id, target_account_id } = adminReportsSchema.parse(c.req.query());
- return c.json(reports);
+ const filter: NostrFilter = {
+ kinds: [30383],
+ authors: [Conf.pubkey],
+ '#k': ['1984'],
+ ...params,
+ };
+
+ if (typeof resolved === 'boolean') {
+ filter['#n'] = [resolved ? 'closed' : 'open'];
+ }
+ if (account_id) {
+ filter['#p'] = [account_id];
+ }
+ if (target_account_id) {
+ filter['#P'] = [target_account_id];
+ }
+
+ const orig = await store.query([filter]);
+ const ids = new Set();
+
+ for (const event of orig) {
+ const d = event.tags.find(([name]) => name === 'd')?.[1];
+ if (d) {
+ ids.add(d);
+ }
+ }
+
+ const events = await store.query([{ kinds: [1984], ids: [...ids] }])
+ .then((events) => hydrateEvents({ store, events: events, signal: c.req.raw.signal }));
+
+ const reports = await Promise.all(
+ events.map((event) => renderAdminReport(event, { viewerPubkey })),
+ );
+
+ return paginated(c, orig, reports);
};
/** https://docs.joinmastodon.org/methods/admin/reports/#get-one */
@@ -82,12 +118,13 @@ const adminReportController: AppController = async (c) => {
}], { signal });
if (!event) {
- return c.json({ error: 'This action is not allowed' }, 403);
+ return c.json({ error: 'Not found' }, 404);
}
await hydrateEvents({ events: [event], store, signal });
- return c.json(await renderAdminReport(event, { viewerPubkey: pubkey }));
+ const report = await renderAdminReport(event, { viewerPubkey: pubkey });
+ return c.json(report);
};
/** https://docs.joinmastodon.org/methods/admin/reports/#resolve */
@@ -104,18 +141,43 @@ const adminReportResolveController: AppController = async (c) => {
}], { signal });
if (!event) {
- return c.json({ error: 'This action is not allowed' }, 403);
+ return c.json({ error: 'Not found' }, 404);
}
+ await updateEventInfo(eventId, { open: false, closed: true }, c);
await hydrateEvents({ events: [event], store, signal });
- await createAdminEvent({
- kind: 5,
- tags: [['e', event.id]],
- content: 'Report closed.',
- }, c);
-
- return c.json(await renderAdminReport(event, { viewerPubkey: pubkey, actionTaken: true }));
+ const report = await renderAdminReport(event, { viewerPubkey: pubkey });
+ return c.json(report);
};
-export { adminReportController, adminReportResolveController, adminReportsController, reportController };
+const adminReportReopenController: AppController = async (c) => {
+ const eventId = c.req.param('id');
+ const { signal } = c.req.raw;
+ const store = c.get('store');
+ const pubkey = await c.get('signer')?.getPublicKey();
+
+ const [event] = await store.query([{
+ kinds: [1984],
+ ids: [eventId],
+ limit: 1,
+ }], { signal });
+
+ if (!event) {
+ return c.json({ error: 'Not found' }, 404);
+ }
+
+ await updateEventInfo(eventId, { open: true, closed: false }, c);
+ await hydrateEvents({ events: [event], store, signal });
+
+ const report = await renderAdminReport(event, { viewerPubkey: pubkey });
+ return c.json(report);
+};
+
+export {
+ adminReportController,
+ adminReportReopenController,
+ adminReportResolveController,
+ adminReportsController,
+ reportController,
+};
diff --git a/src/controllers/api/statuses.ts b/src/controllers/api/statuses.ts
index b5ecf01e..7b9e82cd 100644
--- a/src/controllers/api/statuses.ts
+++ b/src/controllers/api/statuses.ts
@@ -20,6 +20,7 @@ import { lookupPubkey } from '@/utils/lookup.ts';
import { addTag, deleteTag } from '@/utils/tags.ts';
import { asyncReplaceAll } from '@/utils/text.ts';
import { DittoEvent } from '@/interfaces/DittoEvent.ts';
+import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts';
const createStatusSchema = z.object({
in_reply_to_id: n.id().nullish(),
@@ -104,6 +105,11 @@ const createStatusController: AppController = async (c) => {
tags.push(['subject', data.spoiler_text]);
}
+ if (data.language) {
+ tags.push(['L', 'ISO-639-1']);
+ tags.push(['l', data.language, 'ISO-639-1']);
+ }
+
const media = data.media_ids?.length ? await getUnattachedMediaByIds(kysely, data.media_ids) : [];
const imeta: string[][] = media.map(({ data }) => {
@@ -536,6 +542,40 @@ const zapController: AppController = async (c) => {
}
};
+const zappedByController: AppController = async (c) => {
+ const id = c.req.param('id');
+ const store = await Storages.db();
+ const amountSchema = z.coerce.number().int().nonnegative().catch(0);
+
+ const events: DittoEvent[] = (await store.query([{ kinds: [9735], '#e': [id], limit: 100 }])).map((event) => {
+ const zapRequest = event.tags.find(([name]) => name === 'description')?.[1];
+ if (!zapRequest) return;
+ try {
+ return JSON.parse(zapRequest);
+ } catch {
+ return;
+ }
+ }).filter(Boolean);
+
+ await hydrateEvents({ events, store });
+
+ const results = (await Promise.all(
+ events.map(async (event) => {
+ const amount = amountSchema.parse(event.tags.find(([name]) => name === 'amount')?.[1]);
+ const comment = event?.content ?? '';
+ const account = event?.author ? await renderAccount(event.author) : await accountFromPubkey(event.pubkey);
+
+ return {
+ comment,
+ amount,
+ account,
+ };
+ }),
+ )).filter(Boolean);
+
+ return c.json(results);
+};
+
export {
bookmarkController,
contextController,
@@ -552,4 +592,5 @@ export {
unpinController,
unreblogStatusController,
zapController,
+ zappedByController,
};
diff --git a/src/controllers/api/streaming.ts b/src/controllers/api/streaming.ts
index 427c350e..552ea3bd 100644
--- a/src/controllers/api/streaming.ts
+++ b/src/controllers/api/streaming.ts
@@ -1,4 +1,4 @@
-import { NostrFilter } from '@nostrify/nostrify';
+import { NostrEvent, NostrFilter } from '@nostrify/nostrify';
import Debug from '@soapbox/stickynotes/debug';
import { z } from 'zod';
@@ -11,6 +11,7 @@ import { hydrateEvents } from '@/storages/hydrate.ts';
import { Storages } from '@/storages.ts';
import { bech32ToPubkey } from '@/utils.ts';
import { renderReblog, renderStatus } from '@/views/mastodon/statuses.ts';
+import { renderNotification } from '@/views/mastodon/notifications.ts';
const debug = Debug('ditto:streaming');
@@ -52,6 +53,11 @@ const streamingController: AppController = async (c) => {
const { socket, response } = Deno.upgradeWebSocket(c.req.raw, { protocol: token, idleTimeout: 30 });
+ const store = await Storages.db();
+ const pubsub = await Storages.pubsub();
+
+ const policy = pubkey ? new MuteListPolicy(pubkey, await Storages.admin()) : undefined;
+
function send(name: string, payload: object) {
if (socket.readyState === WebSocket.OPEN) {
debug('send', name, JSON.stringify(payload));
@@ -63,52 +69,54 @@ const streamingController: AppController = async (c) => {
}
}
- socket.onopen = async () => {
- if (!stream) return;
-
- const filter = await topicToFilter(stream, c.req.query(), pubkey);
- if (!filter) return;
-
+ async function sub(type: string, filters: NostrFilter[], render: (event: NostrEvent) => Promise) {
try {
- const db = await Storages.db();
- const pubsub = await Storages.pubsub();
-
- for await (const msg of pubsub.req([filter], { signal: controller.signal })) {
+ for await (const msg of pubsub.req(filters, { signal: controller.signal })) {
if (msg[0] === 'EVENT') {
const event = msg[2];
- if (pubkey) {
- const policy = new MuteListPolicy(pubkey, await Storages.admin());
+ if (policy) {
const [, , ok] = await policy.call(event);
if (!ok) {
continue;
}
}
- await hydrateEvents({
- events: [event],
- store: db,
- signal: AbortSignal.timeout(1000),
- });
+ await hydrateEvents({ events: [event], store, signal: AbortSignal.timeout(1000) });
- if (event.kind === 1) {
- const status = await renderStatus(event, { viewerPubkey: pubkey });
- if (status) {
- send('update', status);
- }
- }
+ const result = await render(event);
- if (event.kind === 6) {
- const status = await renderReblog(event, { viewerPubkey: pubkey });
- if (status) {
- send('update', status);
- }
+ if (result) {
+ send(type, result);
}
}
}
} catch (e) {
debug('streaming error:', e);
}
+ }
+
+ socket.onopen = async () => {
+ if (!stream) return;
+ const topicFilter = await topicToFilter(stream, c.req.query(), pubkey);
+
+ if (topicFilter) {
+ sub('update', [topicFilter], async (event) => {
+ if (event.kind === 1) {
+ return await renderStatus(event, { viewerPubkey: pubkey });
+ }
+ if (event.kind === 6) {
+ return await renderReblog(event, { viewerPubkey: pubkey });
+ }
+ });
+ }
+
+ if (['user', 'user:notification'].includes(stream) && pubkey) {
+ sub('notification', [{ '#p': [pubkey] }], async (event) => {
+ return await renderNotification(event, { viewerPubkey: pubkey });
+ });
+ return;
+ }
};
socket.onclose = () => {
diff --git a/src/controllers/api/suggestions.ts b/src/controllers/api/suggestions.ts
index b56851a2..7e461c45 100644
--- a/src/controllers/api/suggestions.ts
+++ b/src/controllers/api/suggestions.ts
@@ -31,7 +31,7 @@ async function renderV2Suggestions(c: AppContext, params: PaginatedListParams, s
const pubkey = await signer?.getPublicKey();
const filters: NostrFilter[] = [
- { kinds: [3], authors: [Conf.pubkey], limit: 1 },
+ { kinds: [30382], authors: [Conf.pubkey], '#n': ['suggested'], limit },
{ kinds: [1985], '#L': ['pub.ditto.trends'], '#l': [`#p`], authors: [Conf.pubkey], limit: 1 },
];
@@ -42,8 +42,8 @@ async function renderV2Suggestions(c: AppContext, params: PaginatedListParams, s
const events = await store.query(filters, { signal });
- const [suggestedEvent, followsEvent, mutesEvent, trendingEvent] = [
- events.find((event) => matchFilter({ kinds: [3], authors: [Conf.pubkey] }, event)),
+ const [userEvents, followsEvent, mutesEvent, trendingEvent] = [
+ events.filter((event) => matchFilter({ kinds: [30382], authors: [Conf.pubkey], '#n': ['suggested'] }, event)),
pubkey ? events.find((event) => matchFilter({ kinds: [3], authors: [pubkey] }, event)) : undefined,
pubkey ? events.find((event) => matchFilter({ kinds: [10000], authors: [pubkey] }, event)) : undefined,
events.find((event) =>
@@ -51,8 +51,13 @@ async function renderV2Suggestions(c: AppContext, params: PaginatedListParams, s
),
];
- const [suggested, trending, follows, mutes] = [
- getTagSet(suggestedEvent?.tags ?? [], 'p'),
+ const suggested = new Set(
+ userEvents
+ .map((event) => event.tags.find(([name]) => name === 'd')?.[1])
+ .filter((pubkey): pubkey is string => !!pubkey),
+ );
+
+ const [trending, follows, mutes] = [
getTagSet(trendingEvent?.tags ?? [], 'p'),
getTagSet(followsEvent?.tags ?? [], 'p'),
getTagSet(mutesEvent?.tags ?? [], 'p'),
diff --git a/src/controllers/api/trends.ts b/src/controllers/api/trends.ts
index 349d86d5..76c84f58 100644
--- a/src/controllers/api/trends.ts
+++ b/src/controllers/api/trends.ts
@@ -1,4 +1,4 @@
-import { NostrEvent, NStore } from '@nostrify/nostrify';
+import { NostrEvent, NostrFilter, NStore } from '@nostrify/nostrify';
import { z } from 'zod';
import { AppController } from '@/app.ts';
@@ -11,7 +11,7 @@ import { renderStatus } from '@/views/mastodon/statuses.ts';
let trendingHashtagsCache = getTrendingHashtags();
-Deno.cron('update trending hashtags cache', { minute: { every: 15 } }, async () => {
+Deno.cron('update trending hashtags cache', '35 * * * *', async () => {
const trends = await getTrendingHashtags();
trendingHashtagsCache = Promise.resolve(trends);
});
@@ -50,7 +50,7 @@ async function getTrendingHashtags() {
let trendingLinksCache = getTrendingLinks();
-Deno.cron('update trending links cache', { minute: { every: 15 } }, async () => {
+Deno.cron('update trending links cache', '50 * * * *', async () => {
const trends = await getTrendingLinks();
trendingLinksCache = Promise.resolve(trends);
});
@@ -148,58 +148,56 @@ interface TrendingTag {
}
export async function getTrendingTags(store: NStore, tagName: string): Promise {
- const filter = {
+ const [label] = await store.query([{
kinds: [1985],
'#L': ['pub.ditto.trends'],
'#l': [`#${tagName}`],
authors: [Conf.pubkey],
limit: 1,
- };
+ }]);
- const [label] = await store.query([filter]);
+ if (!label) return [];
- if (!label) {
- return [];
- }
+ const date = new Date(label.created_at * 1000);
+ const lastWeek = new Date(date.getTime() - Time.days(7));
+ const dates = generateDateRange(lastWeek, date).reverse();
- const tags = label.tags.filter(([name]) => name === tagName);
+ const results: TrendingTag[] = [];
- const now = new Date();
- const lastWeek = new Date(now.getTime() - Time.days(7));
- const dates = generateDateRange(lastWeek, now).reverse();
+ for (const [name, value] of label.tags) {
+ if (name !== tagName) continue;
- return Promise.all(tags.map(async ([_, value]) => {
- const filters = dates.map((date) => ({
- ...filter,
- [`#${tagName}`]: [value],
- since: Math.floor(date.getTime() / 1000),
- until: Math.floor((date.getTime() + Time.days(1)) / 1000),
- }));
+ const history: TrendingTag['history'] = [];
- const labels = await store.query(filters);
+ for (const date of dates) {
+ const [label] = await store.query([{
+ kinds: [1985],
+ '#L': ['pub.ditto.trends'],
+ '#l': [`#${tagName}`],
+ [`#${tagName}`]: [value],
+ authors: [Conf.pubkey],
+ since: Math.floor(date.getTime() / 1000),
+ until: Math.floor((date.getTime() + Time.days(1)) / 1000),
+ limit: 1,
+ } as NostrFilter]);
- const history = dates.map((date) => {
- const label = labels.find((label) => {
- const since = Math.floor(date.getTime() / 1000);
- const until = Math.floor((date.getTime() + Time.days(1)) / 1000);
- return label.created_at >= since && label.created_at < until;
- });
+ const [, , , accounts, uses] = label?.tags.find(([n, v]) => n === tagName && v === value) ?? [];
- const [, , , accounts, uses] = label?.tags.find((tag) => tag[0] === tagName && tag[1] === value) ?? [];
-
- return {
+ history.push({
day: Math.floor(date.getTime() / 1000),
authors: Number(accounts || 0),
uses: Number(uses || 0),
- };
- });
+ });
+ }
- return {
+ results.push({
name: tagName,
value,
history,
- };
- }));
+ });
+ }
+
+ return results;
}
export { trendingLinksController, trendingStatusesController, trendingTagsController };
diff --git a/src/controllers/well-known/nostr.ts b/src/controllers/well-known/nostr.ts
index 06698887..b6b7af09 100644
--- a/src/controllers/well-known/nostr.ts
+++ b/src/controllers/well-known/nostr.ts
@@ -10,9 +10,12 @@ const nameSchema = z.string().min(1).regex(/^\w+$/);
* https://github.com/nostr-protocol/nips/blob/master/05.md
*/
const nostrController: AppController = async (c) => {
+ const store = c.get('store');
+
const result = nameSchema.safeParse(c.req.query('name'));
const name = result.success ? result.data : undefined;
- const pointer = name ? await localNip05Lookup(c.get('store'), name) : undefined;
+
+ const pointer = name ? await localNip05Lookup(store, name) : undefined;
if (!name || !pointer) {
return c.json({ names: {}, relays: {} });
diff --git a/src/cron.ts b/src/cron.ts
index aa0a8d8c..6994561e 100644
--- a/src/cron.ts
+++ b/src/cron.ts
@@ -1,84 +1,12 @@
-import { Stickynotes } from '@soapbox/stickynotes';
-
-import { Conf } from '@/config.ts';
-import { DittoDB } from '@/db/DittoDB.ts';
-import { handleEvent } from '@/pipeline.ts';
-import { AdminSigner } from '@/signers/AdminSigner.ts';
-import { getTrendingTagValues } from '@/trends/trending-tag-values.ts';
-import { Time } from '@/utils/time.ts';
-
-const console = new Stickynotes('ditto:trends');
-
-async function updateTrendingTags(
- l: string,
- tagName: string,
- kinds: number[],
- limit: number,
- extra = '',
- aliases?: string[],
-) {
- console.info(`Updating trending ${l}...`);
- const kysely = await DittoDB.getInstance();
- const signal = AbortSignal.timeout(1000);
-
- const yesterday = Math.floor((Date.now() - Time.days(1)) / 1000);
- const now = Math.floor(Date.now() / 1000);
-
- const tagNames = aliases ? [tagName, ...aliases] : [tagName];
-
- const trends = await getTrendingTagValues(kysely, tagNames, {
- kinds,
- since: yesterday,
- until: now,
- limit,
- });
-
- if (!trends.length) {
- return;
- }
-
- const signer = new AdminSigner();
-
- const label = await signer.signEvent({
- kind: 1985,
- content: '',
- tags: [
- ['L', 'pub.ditto.trends'],
- ['l', l, 'pub.ditto.trends'],
- ...trends.map(({ value, authors, uses }) => [tagName, value, extra, authors.toString(), uses.toString()]),
- ],
- created_at: Math.floor(Date.now() / 1000),
- });
-
- await handleEvent(label, signal);
- console.info(`Trending ${l} updated.`);
-}
+import { updateTrendingLinks } from '@/trends.ts';
+import { updateTrendingHashtags } from '@/trends.ts';
+import { updateTrendingEvents, updateTrendingPubkeys, updateTrendingZappedEvents } from '@/trends.ts';
/** Start cron jobs for the application. */
export function cron() {
- Deno.cron(
- 'update trending pubkeys',
- '0 * * * *',
- () => updateTrendingTags('#p', 'p', [1, 6, 7, 9735], 40, Conf.relay),
- );
- Deno.cron(
- 'update trending zapped events',
- '7 * * * *',
- () => updateTrendingTags('zapped', 'e', [9735], 40, Conf.relay, ['q']),
- );
- Deno.cron(
- 'update trending events',
- '15 * * * *',
- () => updateTrendingTags('#e', 'e', [1, 6, 7, 9735], 40, Conf.relay, ['q']),
- );
- Deno.cron(
- 'update trending hashtags',
- '30 * * * *',
- () => updateTrendingTags('#t', 't', [1], 20),
- );
- Deno.cron(
- 'update trending links',
- '45 * * * *',
- () => updateTrendingTags('#r', 'r', [1], 20),
- );
+ Deno.cron('update trending pubkeys', '0 * * * *', updateTrendingPubkeys);
+ Deno.cron('update trending zapped events', '7 * * * *', updateTrendingZappedEvents);
+ Deno.cron('update trending events', '15 * * * *', updateTrendingEvents);
+ Deno.cron('update trending hashtags', '30 * * * *', updateTrendingHashtags);
+ Deno.cron('update trending links', '45 * * * *', updateTrendingLinks);
}
diff --git a/src/db/DittoTables.ts b/src/db/DittoTables.ts
index d9e320d8..aed8c8c2 100644
--- a/src/db/DittoTables.ts
+++ b/src/db/DittoTables.ts
@@ -23,6 +23,7 @@ interface EventStatsRow {
reactions_count: number;
quotes_count: number;
reactions: string;
+ zaps_amount: number;
}
interface EventRow {
diff --git a/src/db/migrations/025_event_stats_add_zap_count.ts b/src/db/migrations/025_event_stats_add_zap_count.ts
new file mode 100644
index 00000000..91479907
--- /dev/null
+++ b/src/db/migrations/025_event_stats_add_zap_count.ts
@@ -0,0 +1,12 @@
+import { Kysely } from 'kysely';
+
+export async function up(db: Kysely): Promise {
+ await db.schema
+ .alterTable('event_stats')
+ .addColumn('zaps_amount', 'integer', (col) => col.notNull().defaultTo(0))
+ .execute();
+}
+
+export async function down(db: Kysely): Promise {
+ await db.schema.alterTable('event_stats').dropColumn('zaps_amount').execute();
+}
diff --git a/src/db/migrations/026_tags_name_index.ts b/src/db/migrations/026_tags_name_index.ts
new file mode 100644
index 00000000..a15587fb
--- /dev/null
+++ b/src/db/migrations/026_tags_name_index.ts
@@ -0,0 +1,14 @@
+import { Kysely } from 'kysely';
+
+export async function up(db: Kysely): Promise {
+ await db.schema
+ .createIndex('idx_tags_name')
+ .on('nostr_tags')
+ .column('name')
+ .ifNotExists()
+ .execute();
+}
+
+export async function down(db: Kysely): Promise {
+ await db.schema.dropIndex('idx_tags_name').ifExists().execute();
+}
diff --git a/src/db/users.ts b/src/db/users.ts
deleted file mode 100644
index bf0cab7c..00000000
--- a/src/db/users.ts
+++ /dev/null
@@ -1,75 +0,0 @@
-import { NostrFilter } from '@nostrify/nostrify';
-import Debug from '@soapbox/stickynotes/debug';
-
-import { Conf } from '@/config.ts';
-import * as pipeline from '@/pipeline.ts';
-import { AdminSigner } from '@/signers/AdminSigner.ts';
-import { Storages } from '@/storages.ts';
-
-const debug = Debug('ditto:users');
-
-interface User {
- pubkey: string;
- inserted_at: Date;
- admin: boolean;
-}
-
-function buildUserEvent(user: User) {
- const { origin, host } = Conf.url;
- const signer = new AdminSigner();
-
- return signer.signEvent({
- kind: 30361,
- tags: [
- ['d', user.pubkey],
- ['role', user.admin ? 'admin' : 'user'],
- ['origin', origin],
- // NIP-31: https://github.com/nostr-protocol/nips/blob/master/31.md
- ['alt', `User's account was updated by the admins of ${host}`],
- ],
- content: '',
- created_at: Math.floor(user.inserted_at.getTime() / 1000),
- });
-}
-
-/** Adds a user to the database. */
-async function insertUser(user: User) {
- debug('insertUser', JSON.stringify(user));
- const event = await buildUserEvent(user);
- return pipeline.handleEvent(event, AbortSignal.timeout(1000));
-}
-
-/**
- * Finds a single user based on one or more properties.
- *
- * ```ts
- * await findUser({ username: 'alex' });
- * ```
- */
-async function findUser(user: Partial, signal?: AbortSignal): Promise {
- const filter: NostrFilter = { kinds: [30361], authors: [Conf.pubkey], limit: 1 };
-
- for (const [key, value] of Object.entries(user)) {
- switch (key) {
- case 'pubkey':
- filter['#d'] = [String(value)];
- break;
- case 'admin':
- filter['#role'] = [value ? 'admin' : 'user'];
- break;
- }
- }
-
- const store = await Storages.db();
- const [event] = await store.query([filter], { signal });
-
- if (event) {
- return {
- pubkey: event.tags.find(([name]) => name === 'd')?.[1]!,
- inserted_at: new Date(event.created_at * 1000),
- admin: event.tags.find(([name]) => name === 'role')?.[1] === 'admin',
- };
- }
-}
-
-export { buildUserEvent, findUser, insertUser, type User };
diff --git a/src/entities/MastodonAccount.ts b/src/entities/MastodonAccount.ts
new file mode 100644
index 00000000..27cad244
--- /dev/null
+++ b/src/entities/MastodonAccount.ts
@@ -0,0 +1,58 @@
+/** Mastodon account entity, including supported extensions from Pleroma, etc. */
+export interface MastodonAccount {
+ id: string;
+ acct: string;
+ avatar: string;
+ avatar_static: string;
+ bot: boolean;
+ created_at: string;
+ discoverable: boolean;
+ display_name: string;
+ emojis: {
+ shortcode: string;
+ static_url: string;
+ url: string;
+ }[];
+ fields: unknown[];
+ follow_requests_count: number;
+ followers_count: number;
+ following_count: number;
+ fqn: string;
+ header: string;
+ header_static: string;
+ last_status_at: string | null;
+ locked: boolean;
+ note: string;
+ roles: unknown[];
+ source?: {
+ fields: unknown[];
+ language: string;
+ note: string;
+ privacy: string;
+ sensitive: boolean;
+ follow_requests_count: number;
+ nostr: {
+ nip05?: string;
+ };
+ };
+ statuses_count: number;
+ url: string;
+ username: string;
+ ditto: {
+ accepts_zaps: boolean;
+ };
+ pleroma: {
+ deactivated: boolean;
+ is_admin: boolean;
+ is_moderator: boolean;
+ is_suggested: boolean;
+ is_local: boolean;
+ settings_store: unknown;
+ tags: string[];
+ };
+ nostr: {
+ pubkey: string;
+ lud16?: string;
+ };
+ website?: string;
+}
diff --git a/src/entities/MastodonMention.ts b/src/entities/MastodonMention.ts
new file mode 100644
index 00000000..e6140024
--- /dev/null
+++ b/src/entities/MastodonMention.ts
@@ -0,0 +1,6 @@
+export interface MastodonMention {
+ acct: string;
+ id: string;
+ url: string;
+ username: string;
+}
diff --git a/src/entities/MastodonStatus.ts b/src/entities/MastodonStatus.ts
new file mode 100644
index 00000000..1fcbcacb
--- /dev/null
+++ b/src/entities/MastodonStatus.ts
@@ -0,0 +1,42 @@
+import { MastodonAccount } from '@/entities/MastodonAccount.ts';
+import { PreviewCard } from '@/entities/PreviewCard.ts';
+
+export interface MastodonStatus {
+ id: string;
+ account: MastodonAccount;
+ card: PreviewCard | null;
+ content: string;
+ created_at: string;
+ in_reply_to_id: string | null;
+ in_reply_to_account_id: string | null;
+ sensitive: boolean;
+ spoiler_text: string;
+ visibility: string;
+ language: string | null;
+ replies_count: number;
+ reblogs_count: number;
+ favourites_count: number;
+ zaps_amount: number;
+ favourited: boolean;
+ reblogged: boolean;
+ muted: boolean;
+ bookmarked: boolean;
+ pinned: boolean;
+ reblog: MastodonStatus | null;
+ application: unknown;
+ media_attachments: unknown[];
+ mentions: unknown[];
+ tags: unknown[];
+ emojis: unknown[];
+ poll: unknown;
+ quote?: MastodonStatus | null;
+ quote_id: string | null;
+ uri: string;
+ url: string;
+ zapped: boolean;
+ pleroma: {
+ emoji_reactions: { name: string; count: number; me: boolean }[];
+ expires_at?: string;
+ quotes_count: number;
+ };
+}
diff --git a/src/entities/PreviewCard.ts b/src/entities/PreviewCard.ts
new file mode 100644
index 00000000..2decf926
--- /dev/null
+++ b/src/entities/PreviewCard.ts
@@ -0,0 +1,16 @@
+export interface PreviewCard {
+ url: string;
+ title: string;
+ description: string;
+ type: 'link' | 'photo' | 'video' | 'rich';
+ author_name: string;
+ author_url: string;
+ provider_name: string;
+ provider_url: string;
+ html: string;
+ width: number;
+ height: number;
+ image: string | null;
+ embed_url: string;
+ blurhash: string | null;
+}
diff --git a/src/interfaces/DittoEvent.ts b/src/interfaces/DittoEvent.ts
index 6f3e1d22..2f2aef26 100644
--- a/src/interfaces/DittoEvent.ts
+++ b/src/interfaces/DittoEvent.ts
@@ -13,6 +13,7 @@ export interface EventStats {
reposts_count: number;
quotes_count: number;
reactions: Record;
+ zaps_amount: number;
}
/** Internal Event representation used by Ditto, including extra keys. */
@@ -21,7 +22,6 @@ export interface DittoEvent extends NostrEvent {
author_domain?: string;
author_stats?: AuthorStats;
event_stats?: EventStats;
- d_author?: DittoEvent;
user?: DittoEvent;
repost?: DittoEvent;
quote?: DittoEvent;
@@ -35,4 +35,6 @@ export interface DittoEvent extends NostrEvent {
* https://github.com/nostr-protocol/nips/blob/master/56.md
*/
reported_notes?: DittoEvent[];
+ /** Admin event relationship. */
+ info?: DittoEvent;
}
diff --git a/src/middleware/auth98Middleware.ts b/src/middleware/auth98Middleware.ts
index 34d69379..05b06817 100644
--- a/src/middleware/auth98Middleware.ts
+++ b/src/middleware/auth98Middleware.ts
@@ -2,8 +2,8 @@ import { NostrEvent } from '@nostrify/nostrify';
import { HTTPException } from 'hono';
import { type AppContext, type AppMiddleware } from '@/app.ts';
-import { findUser, User } from '@/db/users.ts';
import { ReadOnlySigner } from '@/signers/ReadOnlySigner.ts';
+import { Storages } from '@/storages.ts';
import { localRequest } from '@/utils/api.ts';
import {
buildAuthEventTemplate,
@@ -11,6 +11,7 @@ import {
type ParseAuthRequestOpts,
validateAuthEvent,
} from '@/utils/nip98.ts';
+import { Conf } from '@/config.ts';
/**
* NIP-98 auth.
@@ -35,7 +36,14 @@ type UserRole = 'user' | 'admin';
/** Require the user to prove their role before invoking the controller. */
function requireRole(role: UserRole, opts?: ParseAuthRequestOpts): AppMiddleware {
return withProof(async (_c, proof, next) => {
- const user = await findUser({ pubkey: proof.pubkey });
+ const store = await Storages.db();
+
+ const [user] = await store.query([{
+ kinds: [30382],
+ authors: [Conf.pubkey],
+ '#d': [proof.pubkey],
+ limit: 1,
+ }]);
if (user && matchesRole(user, role)) {
await next();
@@ -53,15 +61,8 @@ function requireProof(opts?: ParseAuthRequestOpts): AppMiddleware {
}
/** Check whether the user fulfills the role. */
-function matchesRole(user: User, role: UserRole): boolean {
- switch (role) {
- case 'user':
- return true;
- case 'admin':
- return user.admin;
- default:
- return false;
- }
+function matchesRole(user: NostrEvent, role: UserRole): boolean {
+ return user.tags.some(([tag, value]) => tag === 'n' && value === role);
}
/** HOC to obtain proof in middleware. */
diff --git a/src/middleware/uploaderMiddleware.ts b/src/middleware/uploaderMiddleware.ts
index 38e8aceb..96a47336 100644
--- a/src/middleware/uploaderMiddleware.ts
+++ b/src/middleware/uploaderMiddleware.ts
@@ -13,7 +13,20 @@ export const uploaderMiddleware: AppMiddleware = async (c, next) => {
switch (Conf.uploader) {
case 's3':
- c.set('uploader', new S3Uploader(Conf.s3));
+ c.set(
+ 'uploader',
+ new S3Uploader({
+ accessKey: Conf.s3.accessKey,
+ bucket: Conf.s3.bucket,
+ endPoint: Conf.s3.endPoint!,
+ pathStyle: Conf.s3.pathStyle,
+ port: Conf.s3.port,
+ region: Conf.s3.region!,
+ secretKey: Conf.s3.secretKey,
+ sessionToken: Conf.s3.sessionToken,
+ useSSL: Conf.s3.useSSL,
+ }),
+ );
break;
case 'ipfs':
c.set('uploader', new IPFSUploader({ baseUrl: Conf.mediaDomain, apiUrl: Conf.ipfs.apiUrl, fetch: fetchWorker }));
diff --git a/src/pipeline.ts b/src/pipeline.ts
index 3255aa7e..9f99520b 100644
--- a/src/pipeline.ts
+++ b/src/pipeline.ts
@@ -1,5 +1,4 @@
import { NKinds, NostrEvent, NSchema as n } from '@nostrify/nostrify';
-import { PipePolicy } from '@nostrify/nostrify/policies';
import Debug from '@soapbox/stickynotes/debug';
import { sql } from 'kysely';
import { LRUCache } from 'lru-cache';
@@ -8,8 +7,8 @@ import { Conf } from '@/config.ts';
import { DittoDB } from '@/db/DittoDB.ts';
import { deleteAttachedMedia } from '@/db/unattached-media.ts';
import { DittoEvent } from '@/interfaces/DittoEvent.ts';
-import { MuteListPolicy } from '@/policies/MuteListPolicy.ts';
import { RelayError } from '@/RelayError.ts';
+import { AdminSigner } from '@/signers/AdminSigner.ts';
import { hydrateEvents } from '@/storages/hydrate.ts';
import { Storages } from '@/storages.ts';
import { eventAge, parseNip05, Time } from '@/utils.ts';
@@ -35,6 +34,7 @@ async function handleEvent(event: DittoEvent, signal: AbortSignal): Promise ${event.id}`);
if (event.kind !== 24133) {
@@ -43,9 +43,19 @@ async function handleEvent(event: DittoEvent, signal: AbortSignal): Promise {
const debug = Debug('ditto:policy');
- const policy = new PipePolicy([
- new MuteListPolicy(Conf.pubkey, await Storages.admin()),
- policyWorker,
- ]);
-
try {
- const result = await policy.call(event);
+ const result = await policyWorker.call(event);
debug(JSON.stringify(result));
RelayError.assert(result);
} catch (e) {
@@ -84,6 +89,13 @@ function encounterEvent(event: NostrEvent): boolean {
return encountered;
}
+/** Check if the event already exists in the database. */
+async function existsInDB(event: DittoEvent): Promise {
+ const store = await Storages.db();
+ const events = await store.query([{ ids: [event.id], limit: 1 }]);
+ return events.length > 0;
+}
+
/** Hydrate the event with the user, if applicable. */
async function hydrateEvent(event: DittoEvent, signal: AbortSignal): Promise {
await hydrateEvents({ events: [event], store: await Storages.db(), signal });
@@ -167,4 +179,46 @@ async function streamOut(event: NostrEvent): Promise {
}
}
+async function generateSetEvents(event: NostrEvent): Promise {
+ const tagsAdmin = event.tags.some(([name, value]) => ['p', 'P'].includes(name) && value === Conf.pubkey);
+
+ if (event.kind === 1984 && tagsAdmin) {
+ const signer = new AdminSigner();
+
+ const rel = await signer.signEvent({
+ kind: 30383,
+ content: '',
+ tags: [
+ ['d', event.id],
+ ['p', event.pubkey],
+ ['k', '1984'],
+ ['n', 'open'],
+ ...[...getTagSet(event.tags, 'p')].map((pubkey) => ['P', pubkey]),
+ ...[...getTagSet(event.tags, 'e')].map((pubkey) => ['e', pubkey]),
+ ],
+ created_at: Math.floor(Date.now() / 1000),
+ });
+
+ await handleEvent(rel, AbortSignal.timeout(1000));
+ }
+
+ if (event.kind === 3036 && tagsAdmin) {
+ const signer = new AdminSigner();
+
+ const rel = await signer.signEvent({
+ kind: 30383,
+ content: '',
+ tags: [
+ ['d', event.id],
+ ['p', event.pubkey],
+ ['k', '3036'],
+ ['n', 'pending'],
+ ],
+ created_at: Math.floor(Date.now() / 1000),
+ });
+
+ await handleEvent(rel, AbortSignal.timeout(1000));
+ }
+}
+
export { handleEvent };
diff --git a/src/storages.ts b/src/storages.ts
index d57eb04a..77f16ae5 100644
--- a/src/storages.ts
+++ b/src/storages.ts
@@ -1,15 +1,16 @@
// deno-lint-ignore-file require-await
+import { NPool, NRelay1 } from '@nostrify/nostrify';
+
import { Conf } from '@/config.ts';
import { DittoDB } from '@/db/DittoDB.ts';
+import { AdminStore } from '@/storages/AdminStore.ts';
import { EventsDB } from '@/storages/EventsDB.ts';
import { SearchStore } from '@/storages/search-store.ts';
import { InternalRelay } from '@/storages/InternalRelay.ts';
-import { UserStore } from '@/storages/UserStore.ts';
-import { NPool, NRelay1 } from '@nostrify/nostrify';
export class Storages {
private static _db: Promise | undefined;
- private static _admin: Promise | undefined;
+ private static _admin: Promise | undefined;
private static _client: Promise | undefined;
private static _pubsub: Promise | undefined;
private static _search: Promise | undefined;
@@ -26,9 +27,9 @@ export class Storages {
}
/** Admin user storage. */
- public static async admin(): Promise {
+ public static async admin(): Promise {
if (!this._admin) {
- this._admin = Promise.resolve(new UserStore(Conf.pubkey, await this.db()));
+ this._admin = Promise.resolve(new AdminStore(await this.db()));
}
return this._admin;
}
@@ -62,14 +63,13 @@ export class Storages {
console.log(`pool: connecting to ${activeRelays.length} relays.`);
- const pool = new NPool({
+ return new NPool({
open(url: string) {
return new NRelay1(url);
},
reqRelays: async () => activeRelays,
eventRelays: async () => activeRelays,
});
- return pool;
})();
}
return this._client;
diff --git a/src/storages/AdminStore.ts b/src/storages/AdminStore.ts
new file mode 100644
index 00000000..014dcb76
--- /dev/null
+++ b/src/storages/AdminStore.ts
@@ -0,0 +1,41 @@
+import { NostrEvent, NostrFilter, NStore } from '@nostrify/nostrify';
+
+import { Conf } from '@/config.ts';
+import { DittoEvent } from '@/interfaces/DittoEvent.ts';
+import { getTagSet } from '@/utils/tags.ts';
+
+/** A store that prevents banned users from being displayed. */
+export class AdminStore implements NStore {
+ constructor(private store: NStore) {}
+
+ async event(event: NostrEvent, opts?: { signal?: AbortSignal }): Promise {
+ return await this.store.event(event, opts);
+ }
+
+ async query(filters: NostrFilter[], opts: { signal?: AbortSignal; limit?: number } = {}): Promise {
+ const events = await this.store.query(filters, opts);
+ const pubkeys = new Set(events.map((event) => event.pubkey));
+
+ const users = await this.store.query([{
+ kinds: [30382],
+ authors: [Conf.pubkey],
+ '#d': [...pubkeys],
+ limit: pubkeys.size,
+ }]);
+
+ return events.filter((event) => {
+ const user = users.find(
+ ({ kind, pubkey, tags }) =>
+ kind === 30382 && pubkey === Conf.pubkey && tags.find(([name]) => name === 'd')?.[1] === event.pubkey,
+ );
+
+ const n = getTagSet(user?.tags ?? [], 'n');
+
+ if (n.has('disabled') || n.has('suspended')) {
+ return false;
+ }
+
+ return true;
+ });
+ }
+}
diff --git a/src/storages/EventsDB.ts b/src/storages/EventsDB.ts
index 20a08957..8955d6d6 100644
--- a/src/storages/EventsDB.ts
+++ b/src/storages/EventsDB.ts
@@ -3,6 +3,7 @@
import { NDatabase, NIP50, NKinds, NostrEvent, NostrFilter, NSchema as n, NStore } from '@nostrify/nostrify';
import { Stickynotes } from '@soapbox/stickynotes';
import { Kysely } from 'kysely';
+import { nip27 } from 'nostr-tools';
import { Conf } from '@/config.ts';
import { DittoTables } from '@/db/DittoTables.ts';
@@ -11,7 +12,6 @@ import { RelayError } from '@/RelayError.ts';
import { purifyEvent } from '@/storages/hydrate.ts';
import { isNostrId, isURL } from '@/utils.ts';
import { abortError } from '@/utils/abort.ts';
-import { getTagSet } from '@/utils/tags.ts';
/** Function to decide whether or not to index a tag. */
type TagCondition = ({ event, count, value }: {
@@ -27,20 +27,19 @@ class EventsDB implements NStore {
/** Conditions for when to index certain tags. */
static tagConditions: Record = {
+ 'a': ({ count }) => count < 15,
'd': ({ event, count }) => count === 0 && NKinds.parameterizedReplaceable(event.kind),
'e': ({ event, count, value }) => ((event.kind === 10003) || count < 15) && isNostrId(value),
+ 'k': ({ count, value }) => count === 0 && Number.isInteger(Number(value)),
'L': ({ event, count }) => event.kind === 1985 || count === 0,
'l': ({ event, count }) => event.kind === 1985 || count === 0,
- 'media': ({ count, value }) => (count < 4) && isURL(value),
'n': ({ count, value }) => count < 50 && value.length < 50,
'P': ({ count, value }) => count === 0 && isNostrId(value),
'p': ({ event, count, value }) => (count < 15 || event.kind === 3) && isNostrId(value),
'proxy': ({ count, value }) => count === 0 && isURL(value),
'q': ({ event, count, value }) => count === 0 && event.kind === 1 && isNostrId(value),
- 'r': ({ event, count, value }) => (event.kind === 1985 ? count < 20 : count < 3) && isURL(value),
+ 'r': ({ event, count }) => (event.kind === 1985 ? count < 20 : count < 3),
't': ({ event, count, value }) => (event.kind === 1985 ? count < 20 : count < 5) && value.length < 50,
- 'name': ({ event, count }) => event.kind === 30361 && count === 0,
- 'role': ({ event, count }) => event.kind === 30361 && count === 0,
};
constructor(private kysely: Kysely) {
@@ -77,17 +76,62 @@ class EventsDB implements NStore {
/** Check if an event has been deleted by the admin. */
private async isDeletedAdmin(event: NostrEvent): Promise {
- const [deletion] = await this.query([
+ const filters: NostrFilter[] = [
{ kinds: [5], authors: [Conf.pubkey], '#e': [event.id], limit: 1 },
- ]);
- return !!deletion;
+ ];
+
+ if (NKinds.replaceable(event.kind) || NKinds.parameterizedReplaceable(event.kind)) {
+ const d = event.tags.find(([tag]) => tag === 'd')?.[1] ?? '';
+
+ filters.push({
+ kinds: [5],
+ authors: [Conf.pubkey],
+ '#a': [`${event.kind}:${event.pubkey}:${d}`],
+ since: event.created_at,
+ limit: 1,
+ });
+ }
+
+ const events = await this.query(filters);
+ return events.length > 0;
}
/** The DITTO_NSEC can delete any event from the database. NDatabase already handles user deletions. */
private async deleteEventsAdmin(event: NostrEvent): Promise {
if (event.kind === 5 && event.pubkey === Conf.pubkey) {
- const ids = getTagSet(event.tags, 'e');
- await this.remove([{ ids: [...ids] }]);
+ const ids = new Set(event.tags.filter(([name]) => name === 'e').map(([_name, value]) => value));
+ const addrs = new Set(event.tags.filter(([name]) => name === 'a').map(([_name, value]) => value));
+
+ const filters: NostrFilter[] = [];
+
+ if (ids.size) {
+ filters.push({ ids: [...ids] });
+ }
+
+ for (const addr of addrs) {
+ const [k, pubkey, d] = addr.split(':');
+ const kind = Number(k);
+
+ if (!(Number.isInteger(kind) && kind >= 0)) continue;
+ if (!isNostrId(pubkey)) continue;
+ if (d === undefined) continue;
+
+ const filter: NostrFilter = {
+ kinds: [kind],
+ authors: [pubkey],
+ until: event.created_at,
+ };
+
+ if (d) {
+ filter['#d'] = [d];
+ }
+
+ filters.push(filter);
+ }
+
+ if (filters.length) {
+ await this.remove(filters);
+ }
}
}
@@ -177,9 +221,11 @@ class EventsDB implements NStore {
case 0:
return EventsDB.buildUserSearchContent(event);
case 1:
- return event.content;
+ return nip27.replaceAll(event.content, () => '');
case 30009:
return EventsDB.buildTagsSearchContent(event.tags.filter(([t]) => t !== 'alt'));
+ case 30360:
+ return event.tags.find(([name]) => name === 'd')?.[1] || '';
default:
return '';
}
diff --git a/src/storages/hydrate.ts b/src/storages/hydrate.ts
index d80c2f46..3c264320 100644
--- a/src/storages/hydrate.ts
+++ b/src/storages/hydrate.ts
@@ -44,6 +44,10 @@ async function hydrateEvents(opts: HydrateOpts): Promise {
cache.push(event);
}
+ for (const event of await gatherInfo({ events: cache, store, signal })) {
+ cache.push(event);
+ }
+
for (const event of await gatherReportedProfiles({ events: cache, store, signal })) {
cache.push(event);
}
@@ -82,7 +86,8 @@ export function assembleEvents(
for (const event of a) {
event.author = b.find((e) => matchFilter({ kinds: [0], authors: [event.pubkey] }, e));
- event.user = b.find((e) => matchFilter({ kinds: [30361], authors: [admin], '#d': [event.pubkey] }, e));
+ event.user = b.find((e) => matchFilter({ kinds: [30382], authors: [admin], '#d': [event.pubkey] }, e));
+ event.info = b.find((e) => matchFilter({ kinds: [30383], authors: [admin], '#d': [event.id] }, e));
if (event.kind === 1) {
const id = findQuoteTag(event.tags)?.[1] || findQuoteInContent(event.content);
@@ -106,20 +111,21 @@ export function assembleEvents(
}
if (event.kind === 1984) {
- const targetAccountId = event.tags.find(([name]) => name === 'p')?.[1];
- if (targetAccountId) {
- event.reported_profile = b.find((e) => matchFilter({ kinds: [0], authors: [targetAccountId] }, e));
+ const pubkey = event.tags.find(([name]) => name === 'p')?.[1];
+ if (pubkey) {
+ event.reported_profile = b.find((e) => matchFilter({ kinds: [0], authors: [pubkey] }, e));
}
- const reportedEvents: DittoEvent[] = [];
- const status_ids = event.tags.filter(([name]) => name === 'e').map((tag) => tag[1]);
- if (status_ids.length > 0) {
- for (const id of status_ids) {
- const reportedEvent = b.find((e) => matchFilter({ kinds: [1], ids: [id] }, e));
- if (reportedEvent) reportedEvents.push(reportedEvent);
+ const reportedEvents: DittoEvent[] = [];
+ const ids = event.tags.filter(([name]) => name === 'e').map(([_name, value]) => value);
+
+ for (const id of ids) {
+ const reported = b.find((e) => matchFilter({ kinds: [1], ids: [id] }, e));
+ if (reported) {
+ reportedEvents.push(reported);
}
- event.reported_notes = reportedEvents;
}
+ event.reported_notes = reportedEvents;
}
event.author_stats = stats.authors.find((stats) => stats.pubkey === event.pubkey);
@@ -200,8 +206,32 @@ function gatherAuthors({ events, store, signal }: HydrateOpts): Promise {
const pubkeys = new Set(events.map((event) => event.pubkey));
+ if (!pubkeys.size) {
+ return Promise.resolve([]);
+ }
+
return store.query(
- [{ kinds: [30361], authors: [Conf.pubkey], '#d': [...pubkeys], limit: pubkeys.size }],
+ [{ kinds: [30382], authors: [Conf.pubkey], '#d': [...pubkeys], limit: pubkeys.size }],
+ { signal },
+ );
+}
+
+/** Collect info events from the events. */
+function gatherInfo({ events, store, signal }: HydrateOpts): Promise {
+ const ids = new Set();
+
+ for (const event of events) {
+ if (event.kind === 1984 || event.kind === 3036) {
+ ids.add(event.id);
+ }
+ }
+
+ if (!ids.size) {
+ return Promise.resolve([]);
+ }
+
+ return store.query(
+ [{ kinds: [30383], authors: [Conf.pubkey], '#d': [...ids], limit: ids.size }],
{ signal },
);
}
@@ -300,6 +330,7 @@ async function gatherEventStats(events: DittoEvent[]): Promise,
+ /** Tag name to filter by, eg `t` or `r`. */
+ tagNames: string[],
+ /** Filter of eligible events. */
+ filter: NostrFilter,
+): Promise<{ value: string; authors: number; uses: number }[]> {
+ let query = kysely
+ .selectFrom('nostr_tags')
+ .innerJoin('nostr_events', 'nostr_events.id', 'nostr_tags.event_id')
+ .select(({ fn }) => [
+ 'nostr_tags.value',
+ fn.agg('count', ['nostr_events.pubkey']).distinct().as('authors'),
+ fn.countAll().as('uses'),
+ ])
+ .where('nostr_tags.name', 'in', tagNames)
+ .groupBy('nostr_tags.value')
+ .orderBy((c) => c.fn.agg('count', ['nostr_events.pubkey']).distinct(), 'desc');
+
+ if (filter.kinds) {
+ query = query.where('nostr_events.kind', 'in', filter.kinds);
+ }
+ if (typeof filter.since === 'number') {
+ query = query.where('nostr_events.created_at', '>=', filter.since);
+ }
+ if (typeof filter.until === 'number') {
+ query = query.where('nostr_events.created_at', '<=', filter.until);
+ }
+ if (typeof filter.limit === 'number') {
+ query = query.limit(filter.limit);
+ }
+
+ const rows = await query.execute();
+
+ return rows.map((row) => ({
+ value: row.value,
+ authors: Number(row.authors),
+ uses: Number(row.uses),
+ }));
+}
+
+/** Get trending tags and publish an event with them. */
+export async function updateTrendingTags(
+ l: string,
+ tagName: string,
+ kinds: number[],
+ limit: number,
+ extra = '',
+ aliases?: string[],
+) {
+ console.info(`Updating trending ${l}...`);
+ const kysely = await DittoDB.getInstance();
+ const signal = AbortSignal.timeout(1000);
+
+ const yesterday = Math.floor((Date.now() - Time.days(1)) / 1000);
+ const now = Math.floor(Date.now() / 1000);
+
+ const tagNames = aliases ? [tagName, ...aliases] : [tagName];
+
+ const trends = await getTrendingTagValues(kysely, tagNames, {
+ kinds,
+ since: yesterday,
+ until: now,
+ limit,
+ });
+
+ if (!trends.length) {
+ console.info(`No trending ${l} found. Skipping.`);
+ return;
+ }
+
+ const signer = new AdminSigner();
+
+ const label = await signer.signEvent({
+ kind: 1985,
+ content: '',
+ tags: [
+ ['L', 'pub.ditto.trends'],
+ ['l', l, 'pub.ditto.trends'],
+ ...trends.map(({ value, authors, uses }) => [tagName, value, extra, authors.toString(), uses.toString()]),
+ ],
+ created_at: Math.floor(Date.now() / 1000),
+ });
+
+ await handleEvent(label, signal);
+ console.info(`Trending ${l} updated.`);
+}
+
+/** Update trending pubkeys. */
+export function updateTrendingPubkeys(): Promise {
+ return updateTrendingTags('#p', 'p', [1, 3, 6, 7, 9735], 40, Conf.relay);
+}
+
+/** Update trending zapped events. */
+export function updateTrendingZappedEvents(): Promise {
+ return updateTrendingTags('zapped', 'e', [9735], 40, Conf.relay, ['q']);
+}
+
+/** Update trending events. */
+export function updateTrendingEvents(): Promise {
+ return updateTrendingTags('#e', 'e', [1, 6, 7, 9735], 40, Conf.relay, ['q']);
+}
+
+/** Update trending hashtags. */
+export function updateTrendingHashtags(): Promise {
+ return updateTrendingTags('#t', 't', [1], 20);
+}
+
+/** Update trending links. */
+export function updateTrendingLinks(): Promise {
+ return updateTrendingTags('#r', 'r', [1], 20);
+}
diff --git a/src/trends/trending-tag-values.ts b/src/trends/trending-tag-values.ts
deleted file mode 100644
index 17ec53d2..00000000
--- a/src/trends/trending-tag-values.ts
+++ /dev/null
@@ -1,47 +0,0 @@
-import { NostrFilter } from '@nostrify/nostrify';
-import { Kysely } from 'kysely';
-
-import { DittoTables } from '@/db/DittoTables.ts';
-
-/** Get trending tag values for a given tag in the given time frame. */
-export async function getTrendingTagValues(
- /** Kysely instance to execute queries on. */
- kysely: Kysely,
- /** Tag name to filter by, eg `t` or `r`. */
- tagNames: string[],
- /** Filter of eligible events. */
- filter: NostrFilter,
-): Promise<{ value: string; authors: number; uses: number }[]> {
- let query = kysely
- .selectFrom('nostr_tags')
- .innerJoin('nostr_events', 'nostr_events.id', 'nostr_tags.event_id')
- .select(({ fn }) => [
- 'nostr_tags.value',
- fn.agg('count', ['nostr_events.pubkey']).distinct().as('authors'),
- fn.countAll().as('uses'),
- ])
- .where('nostr_tags.name', 'in', tagNames)
- .groupBy('nostr_tags.value')
- .orderBy((c) => c.fn.agg('count', ['nostr_events.pubkey']).distinct(), 'desc');
-
- if (filter.kinds) {
- query = query.where('nostr_events.kind', 'in', filter.kinds);
- }
- if (typeof filter.since === 'number') {
- query = query.where('nostr_events.created_at', '>=', filter.since);
- }
- if (typeof filter.until === 'number') {
- query = query.where('nostr_events.created_at', '<=', filter.until);
- }
- if (typeof filter.limit === 'number') {
- query = query.limit(filter.limit);
- }
-
- const rows = await query.execute();
-
- return rows.map((row) => ({
- value: row.value,
- authors: Number(row.authors),
- uses: Number(row.uses),
- }));
-}
diff --git a/src/utils/api.ts b/src/utils/api.ts
index 3f99fef6..84ba8e2d 100644
--- a/src/utils/api.ts
+++ b/src/utils/api.ts
@@ -108,6 +108,44 @@ async function updateAdminEvent(
return createAdminEvent(fn(prev), c);
}
+function updateUser(pubkey: string, n: Record, c: AppContext): Promise {
+ return updateNames(30382, pubkey, n, c);
+}
+
+function updateEventInfo(id: string, n: Record, c: AppContext): Promise {
+ return updateNames(30383, id, n, c);
+}
+
+async function updateNames(k: number, d: string, n: Record, c: AppContext): Promise {
+ const signer = new AdminSigner();
+ const admin = await signer.getPublicKey();
+
+ return updateAdminEvent(
+ { kinds: [k], authors: [admin], '#d': [d], limit: 1 },
+ (prev) => {
+ const prevNames = prev?.tags.reduce((acc, [name, value]) => {
+ if (name === 'n') acc[value] = true;
+ return acc;
+ }, {} as Record);
+
+ const names = { ...prevNames, ...n };
+ const nTags = Object.entries(names).filter(([, value]) => value).map(([name]) => ['n', name]);
+ const other = prev?.tags.filter(([name]) => !['d', 'n'].includes(name)) ?? [];
+
+ return {
+ kind: k,
+ content: prev?.content ?? '',
+ tags: [
+ ['d', d],
+ ...nTags,
+ ...other,
+ ],
+ };
+ },
+ c,
+ );
+}
+
/** Push the event through the pipeline, rethrowing any RelayError. */
async function publishEvent(event: NostrEvent, c: AppContext): Promise {
debug('EVENT', event);
@@ -266,7 +304,10 @@ export {
type PaginationParams,
paginationSchema,
parseBody,
+ updateAdminEvent,
updateEvent,
+ updateEventInfo,
updateListAdminEvent,
updateListEvent,
+ updateUser,
};
diff --git a/src/utils/nip05.ts b/src/utils/nip05.ts
index 840ef6df..a579da6c 100644
--- a/src/utils/nip05.ts
+++ b/src/utils/nip05.ts
@@ -45,16 +45,15 @@ const nip05Cache = new SimpleLRU(
{ max: 500, ttl: Time.hours(1) },
);
-async function localNip05Lookup(store: NStore, name: string): Promise {
- const [label] = await store.query([{
- kinds: [1985],
+async function localNip05Lookup(store: NStore, localpart: string): Promise {
+ const [grant] = await store.query([{
+ kinds: [30360],
+ '#d': [`${localpart}@${Conf.url.host}`],
authors: [Conf.pubkey],
- '#L': ['nip05'],
- '#l': [`${name}@${Conf.url.host}`],
limit: 1,
}]);
- const pubkey = label?.tags.find(([name]) => name === 'p')?.[1];
+ const pubkey = grant?.tags.find(([name]) => name === 'p')?.[1];
if (pubkey) {
return { pubkey, relays: [Conf.relay] };
diff --git a/src/utils/stats.ts b/src/utils/stats.ts
index 3cc82cfd..ccba0a5b 100644
--- a/src/utils/stats.ts
+++ b/src/utils/stats.ts
@@ -1,6 +1,7 @@
-import { NostrEvent, NStore } from '@nostrify/nostrify';
+import { NostrEvent, NSchema as n, NStore } from '@nostrify/nostrify';
import { Kysely, UpdateObject } from 'kysely';
import { SetRequired } from 'type-fest';
+import { z } from 'zod';
import { DittoTables } from '@/db/DittoTables.ts';
import { findQuoteTag, findReplyTag, getTagSet } from '@/utils/tags.ts';
@@ -27,6 +28,8 @@ export async function updateStats({ event, kysely, store, x = 1 }: UpdateStatsOp
return handleEvent6(kysely, event, x);
case 7:
return handleEvent7(kysely, event, x);
+ case 9735:
+ return handleEvent9735(kysely, event);
}
}
@@ -132,6 +135,29 @@ async function handleEvent7(kysely: Kysely, event: NostrEvent, x: n
}
}
+/** Update stats for kind 9735 event. */
+async function handleEvent9735(kysely: Kysely, event: NostrEvent): Promise {
+ // https://github.com/nostr-protocol/nips/blob/master/57.md#appendix-f-validating-zap-receipts
+ const id = event.tags.find(([name]) => name === 'e')?.[1];
+ if (!id) return;
+
+ const amountSchema = z.coerce.number().int().nonnegative().catch(0);
+ let amount = 0;
+ try {
+ const zapRequest = n.json().pipe(n.event()).parse(event.tags.find(([name]) => name === 'description')?.[1]);
+ amount = amountSchema.parse(zapRequest.tags.find(([name]) => name === 'amount')?.[1]);
+ if (amount <= 0) return;
+ } catch {
+ return;
+ }
+
+ await updateEventStats(
+ kysely,
+ id,
+ ({ zaps_amount }) => ({ zaps_amount: Math.max(0, zaps_amount + amount) }),
+ );
+}
+
/** Get the pubkeys that were added and removed from a follow event. */
export function getFollowDiff(
tags: string[][],
@@ -219,6 +245,7 @@ export async function updateEventStats(
reposts_count: 0,
reactions_count: 0,
quotes_count: 0,
+ zaps_amount: 0,
reactions: '{}',
};
diff --git a/src/utils/unfurl.ts b/src/utils/unfurl.ts
index e5ae428d..41ffcd3a 100644
--- a/src/utils/unfurl.ts
+++ b/src/utils/unfurl.ts
@@ -3,28 +3,12 @@ import Debug from '@soapbox/stickynotes/debug';
import DOMPurify from 'isomorphic-dompurify';
import { unfurl } from 'unfurl.js';
+import { PreviewCard } from '@/entities/PreviewCard.ts';
import { Time } from '@/utils/time.ts';
import { fetchWorker } from '@/workers/fetch.ts';
const debug = Debug('ditto:unfurl');
-interface PreviewCard {
- url: string;
- title: string;
- description: string;
- type: 'link' | 'photo' | 'video' | 'rich';
- author_name: string;
- author_url: string;
- provider_name: string;
- provider_url: string;
- html: string;
- width: number;
- height: number;
- image: string | null;
- embed_url: string;
- blurhash: string | null;
-}
-
async function unfurlCard(url: string, signal: AbortSignal): Promise {
debug(`Unfurling ${url}...`);
try {
diff --git a/src/views/ditto.ts b/src/views/ditto.ts
new file mode 100644
index 00000000..ebc07b77
--- /dev/null
+++ b/src/views/ditto.ts
@@ -0,0 +1,22 @@
+import { DittoEvent } from '@/interfaces/DittoEvent.ts';
+import { getTagSet } from '@/utils/tags.ts';
+import { renderAdminAccount, renderAdminAccountFromPubkey } from '@/views/mastodon/admin-accounts.ts';
+
+/** Renders an Admin::Account entity from a name request event. */
+export async function renderNameRequest(event: DittoEvent) {
+ const n = getTagSet(event.info?.tags ?? [], 'n');
+ const [username, domain] = event.tags.find(([name]) => name === 'r')?.[1]?.split('@') ?? [];
+
+ const adminAccount = event.author
+ ? await renderAdminAccount(event.author)
+ : await renderAdminAccountFromPubkey(event.pubkey);
+
+ return {
+ ...adminAccount,
+ id: event.id,
+ approved: n.has('approved'),
+ username,
+ domain,
+ invite_request: event.content,
+ };
+}
diff --git a/src/views/mastodon/accounts.ts b/src/views/mastodon/accounts.ts
index 918d03b9..5de6c389 100644
--- a/src/views/mastodon/accounts.ts
+++ b/src/views/mastodon/accounts.ts
@@ -3,9 +3,11 @@ import { escape } from 'entities';
import { nip19, UnsignedEvent } from 'nostr-tools';
import { Conf } from '@/config.ts';
+import { MastodonAccount } from '@/entities/MastodonAccount.ts';
import { type DittoEvent } from '@/interfaces/DittoEvent.ts';
import { getLnurl } from '@/utils/lnurl.ts';
import { nip05Cache } from '@/utils/nip05.ts';
+import { getTagSet } from '@/utils/tags.ts';
import { Nip05, nostrDate, nostrNow, parseNip05 } from '@/utils.ts';
import { renderEmojis } from '@/views/mastodon/emojis.ts';
@@ -16,10 +18,17 @@ interface ToAccountOpts {
async function renderAccount(
event: Omit,
opts: ToAccountOpts = {},
-) {
+): Promise {
const { withSource = false } = opts;
const { pubkey } = event;
+ const names = getTagSet(event.user?.tags ?? [], 'n');
+ if (names.has('disabled') || names.has('suspended')) {
+ const account = await accountFromPubkey(pubkey, opts);
+ account.pleroma.deactivated = true;
+ return account;
+ }
+
const {
name,
nip05,
@@ -33,7 +42,6 @@ async function renderAccount(
const npub = nip19.npubEncode(pubkey);
const parsed05 = await parseAndVerifyNip05(nip05, pubkey);
- const role = event.user?.tags.find(([name]) => name === 'role')?.[1] ?? 'user';
return {
id: pubkey,
@@ -74,13 +82,15 @@ async function renderAccount(
username: parsed05?.nickname || npub.substring(0, 8),
ditto: {
accepts_zaps: Boolean(getLnurl({ lud06, lud16 })),
- is_registered: Boolean(event.user),
},
pleroma: {
- is_admin: role === 'admin',
- is_moderator: ['admin', 'moderator'].includes(role),
+ deactivated: names.has('disabled') || names.has('suspended'),
+ is_admin: names.has('admin'),
+ is_moderator: names.has('admin') || names.has('moderator'),
+ is_suggested: names.has('suggested'),
is_local: parsed05?.domain === Conf.url.host,
settings_store: undefined as unknown,
+ tags: [...getTagSet(event.user?.tags ?? [], 't')],
},
nostr: {
pubkey,
@@ -90,7 +100,7 @@ async function renderAccount(
};
}
-function accountFromPubkey(pubkey: string, opts: ToAccountOpts = {}) {
+function accountFromPubkey(pubkey: string, opts: ToAccountOpts = {}): Promise {
const event: UnsignedEvent = {
kind: 0,
pubkey,
diff --git a/src/views/mastodon/admin-accounts.ts b/src/views/mastodon/admin-accounts.ts
index 4dc85699..34b6860a 100644
--- a/src/views/mastodon/admin-accounts.ts
+++ b/src/views/mastodon/admin-accounts.ts
@@ -1,9 +1,20 @@
import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts';
-import { type DittoEvent } from '@/interfaces/DittoEvent.ts';
+import { DittoEvent } from '@/interfaces/DittoEvent.ts';
+import { getTagSet } from '@/utils/tags.ts';
/** Expects a kind 0 fully hydrated */
async function renderAdminAccount(event: DittoEvent) {
const account = await renderAccount(event);
+ const names = getTagSet(event.user?.tags ?? [], 'n');
+
+ let role = 'user';
+
+ if (names.has('admin')) {
+ role = 'admin';
+ }
+ if (names.has('moderator')) {
+ role = 'moderator';
+ }
return {
id: account.id,
@@ -15,12 +26,13 @@ async function renderAdminAccount(event: DittoEvent) {
ips: [],
locale: '',
invite_request: null,
- role: event.tags.find(([name]) => name === 'role')?.[1],
+ role,
confirmed: true,
approved: true,
- disabled: false,
- silenced: false,
- suspended: false,
+ disabled: names.has('disabled'),
+ silenced: names.has('silenced'),
+ suspended: names.has('suspended'),
+ sensitized: names.has('sensitized'),
account,
};
}
diff --git a/src/views/mastodon/notifications.ts b/src/views/mastodon/notifications.ts
index 5b618d79..8f2a8a62 100644
--- a/src/views/mastodon/notifications.ts
+++ b/src/views/mastodon/notifications.ts
@@ -1,8 +1,10 @@
+import { NostrEvent } from '@nostrify/nostrify';
+
+import { Conf } from '@/config.ts';
import { DittoEvent } from '@/interfaces/DittoEvent.ts';
import { nostrDate } from '@/utils.ts';
import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts';
import { renderStatus } from '@/views/mastodon/statuses.ts';
-import { NostrEvent } from '@nostrify/nostrify';
interface RenderNotificationOpts {
viewerPubkey: string;
@@ -26,6 +28,10 @@ function renderNotification(event: DittoEvent, opts: RenderNotificationOpts) {
if (event.kind === 7) {
return renderReaction(event, opts);
}
+
+ if (event.kind === 30360 && event.pubkey === Conf.pubkey) {
+ return renderNameGrant(event);
+ }
}
async function renderMention(event: DittoEvent, opts: RenderNotificationOpts) {
@@ -45,7 +51,7 @@ async function renderReblog(event: DittoEvent, opts: RenderNotificationOpts) {
if (event.repost?.kind !== 1) return;
const status = await renderStatus(event.repost, opts);
if (!status) return;
- const account = event.author ? await renderAccount(event.author) : accountFromPubkey(event.pubkey);
+ const account = event.author ? await renderAccount(event.author) : await accountFromPubkey(event.pubkey);
return {
id: notificationId(event),
@@ -60,7 +66,7 @@ async function renderFavourite(event: DittoEvent, opts: RenderNotificationOpts)
if (event.reacted?.kind !== 1) return;
const status = await renderStatus(event.reacted, opts);
if (!status) return;
- const account = event.author ? await renderAccount(event.author) : accountFromPubkey(event.pubkey);
+ const account = event.author ? await renderAccount(event.author) : await accountFromPubkey(event.pubkey);
return {
id: notificationId(event),
@@ -75,7 +81,7 @@ async function renderReaction(event: DittoEvent, opts: RenderNotificationOpts) {
if (event.reacted?.kind !== 1) return;
const status = await renderStatus(event.reacted, opts);
if (!status) return;
- const account = event.author ? await renderAccount(event.author) : accountFromPubkey(event.pubkey);
+ const account = event.author ? await renderAccount(event.author) : await accountFromPubkey(event.pubkey);
return {
id: notificationId(event),
@@ -87,6 +93,21 @@ async function renderReaction(event: DittoEvent, opts: RenderNotificationOpts) {
};
}
+async function renderNameGrant(event: DittoEvent) {
+ const d = event.tags.find(([name]) => name === 'd')?.[1];
+ const account = event.author ? await renderAccount(event.author) : await accountFromPubkey(event.pubkey);
+
+ if (!d) return;
+
+ return {
+ id: notificationId(event),
+ type: 'ditto:name_grant',
+ name: d,
+ created_at: nostrDate(event.created_at).toISOString(),
+ account,
+ };
+}
+
/** This helps notifications be sorted in the correct order. */
function notificationId({ id, created_at }: NostrEvent): string {
return `${created_at}-${id}`;
diff --git a/src/views/mastodon/reports.ts b/src/views/mastodon/reports.ts
index bec08b4a..48baa42f 100644
--- a/src/views/mastodon/reports.ts
+++ b/src/views/mastodon/reports.ts
@@ -3,6 +3,7 @@ import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts';
import { nostrDate } from '@/utils.ts';
import { renderAdminAccount, renderAdminAccountFromPubkey } from '@/views/mastodon/admin-accounts.ts';
import { renderStatus } from '@/views/mastodon/statuses.ts';
+import { getTagSet } from '@/utils/tags.ts';
/** Expects a `reportEvent` of kind 1984 and a `profile` of kind 0 of the person being reported */
async function renderReport(event: DittoEvent) {
@@ -30,43 +31,42 @@ async function renderReport(event: DittoEvent) {
interface RenderAdminReportOpts {
viewerPubkey?: string;
- actionTaken?: boolean;
}
/** Admin-level information about a filed report.
* Expects an event of kind 1984 fully hydrated.
* https://docs.joinmastodon.org/entities/Admin_Report */
-async function renderAdminReport(reportEvent: DittoEvent, opts: RenderAdminReportOpts) {
- const { viewerPubkey, actionTaken = false } = opts;
+async function renderAdminReport(event: DittoEvent, opts: RenderAdminReportOpts) {
+ const { viewerPubkey } = opts;
// The category is present in both the 'e' and 'p' tag, however, it is possible to report a user without reporting a note, so it's better to get the category from the 'p' tag
- const category = reportEvent.tags.find(([name]) => name === 'p')?.[2];
+ const category = event.tags.find(([name]) => name === 'p')?.[2];
const statuses = [];
- if (reportEvent.reported_notes) {
- for (const status of reportEvent.reported_notes) {
+ if (event.reported_notes) {
+ for (const status of event.reported_notes) {
statuses.push(await renderStatus(status, { viewerPubkey }));
}
}
- const reportedPubkey = reportEvent.tags.find(([name]) => name === 'p')?.[1];
+ const reportedPubkey = event.tags.find(([name]) => name === 'p')?.[1];
if (!reportedPubkey) {
return;
}
+ const names = getTagSet(event.info?.tags ?? [], 'n');
+
return {
- id: reportEvent.id,
- action_taken: actionTaken,
+ id: event.id,
+ action_taken: names.has('closed'),
action_taken_at: null,
category,
- comment: reportEvent.content,
+ comment: event.content,
forwarded: false,
- created_at: nostrDate(reportEvent.created_at).toISOString(),
- account: reportEvent.author
- ? await renderAdminAccount(reportEvent.author)
- : await renderAdminAccountFromPubkey(reportEvent.pubkey),
- target_account: reportEvent.reported_profile
- ? await renderAdminAccount(reportEvent.reported_profile)
+ created_at: nostrDate(event.created_at).toISOString(),
+ account: event.author ? await renderAdminAccount(event.author) : await renderAdminAccountFromPubkey(event.pubkey),
+ target_account: event.reported_profile
+ ? await renderAdminAccount(event.reported_profile)
: await renderAdminAccountFromPubkey(reportedPubkey),
assigned_account: null,
action_taken_by_account: null,
diff --git a/src/views/mastodon/statuses.ts b/src/views/mastodon/statuses.ts
index 04039da9..d440b65c 100644
--- a/src/views/mastodon/statuses.ts
+++ b/src/views/mastodon/statuses.ts
@@ -2,6 +2,8 @@ import { NostrEvent } from '@nostrify/nostrify';
import { nip19 } from 'nostr-tools';
import { Conf } from '@/config.ts';
+import { MastodonMention } from '@/entities/MastodonMention.ts';
+import { MastodonStatus } from '@/entities/MastodonStatus.ts';
import { type DittoEvent } from '@/interfaces/DittoEvent.ts';
import { Storages } from '@/storages.ts';
import { nostrDate } from '@/utils.ts';
@@ -17,7 +19,7 @@ interface RenderStatusOpts {
depth?: number;
}
-async function renderStatus(event: DittoEvent, opts: RenderStatusOpts): Promise {
+async function renderStatus(event: DittoEvent, opts: RenderStatusOpts): Promise {
const { viewerPubkey, depth = 1 } = opts;
if (depth > 2 || depth < 0) return;
@@ -100,10 +102,11 @@ async function renderStatus(event: DittoEvent, opts: RenderStatusOpts): Promise<
sensitive: !!cw,
spoiler_text: (cw ? cw[1] : subject?.[1]) || '',
visibility: 'public',
- language: event.tags.find((tag) => tag[0] === 'lang')?.[1] || null,
+ language: event.tags.find((tag) => tag[0] === 'l' && tag[2] === 'ISO-639-1')?.[1] || null,
replies_count: event.event_stats?.replies_count ?? 0,
reblogs_count: event.event_stats?.reposts_count ?? 0,
favourites_count: event.event_stats?.reactions['+'] ?? 0,
+ zaps_amount: event.event_stats?.zaps_amount ?? 0,
favourited: reactionEvent?.content === '+',
reblogged: Boolean(repostEvent),
muted: false,
@@ -118,8 +121,8 @@ async function renderStatus(event: DittoEvent, opts: RenderStatusOpts): Promise<
poll: null,
quote: !event.quote ? null : await renderStatus(event.quote, { depth: depth + 1 }),
quote_id: event.quote?.id ?? null,
- uri: Conf.external(note),
- url: Conf.external(note),
+ uri: Conf.local(`/${note}`),
+ url: Conf.local(`/${note}`),
zapped: Boolean(zapEvent),
pleroma: {
emoji_reactions: reactions,
@@ -129,12 +132,14 @@ async function renderStatus(event: DittoEvent, opts: RenderStatusOpts): Promise<
};
}
-async function renderReblog(event: DittoEvent, opts: RenderStatusOpts) {
+async function renderReblog(event: DittoEvent, opts: RenderStatusOpts): Promise {
const { viewerPubkey } = opts;
if (!event.repost) return;
const status = await renderStatus(event, {}); // omit viewerPubkey intentionally
- const reblog = await renderStatus(event.repost, { viewerPubkey });
+ if (!status) return;
+
+ const reblog = await renderStatus(event.repost, { viewerPubkey }) ?? null;
return {
...status,
@@ -144,7 +149,7 @@ async function renderReblog(event: DittoEvent, opts: RenderStatusOpts) {
};
}
-async function toMention(pubkey: string, event?: NostrEvent) {
+async function toMention(pubkey: string, event?: NostrEvent): Promise {
const account = event ? await renderAccount(event) : undefined;
if (account) {
@@ -165,9 +170,7 @@ async function toMention(pubkey: string, event?: NostrEvent) {
}
}
-type Mention = Awaited>;
-
-function buildInlineRecipients(mentions: Mention[]): string {
+function buildInlineRecipients(mentions: MastodonMention[]): string {
if (!mentions.length) return '';
const elements = mentions.reduce((acc, { url, username }) => {