Merge remote-tracking branch 'origin/main' into feat-persist-user-preference

This commit is contained in:
Alex Gleason
2024-05-20 14:02:38 -05:00
176 changed files with 3607 additions and 2299 deletions

View File

@@ -1,5 +1,6 @@
import { NSchema as n } from '@nostrify/nostrify';
import { Conf } from '@/config.ts';
import { jsonMetaContentSchema } from '@/schemas/nostr.ts';
import { getPublicKeyPem } from '@/utils/rsa.ts';
import type { NostrEvent } from '@nostrify/nostrify';
@@ -7,7 +8,7 @@ import type { Actor } from '@/schemas/activitypub.ts';
/** Nostr metadata event to ActivityPub actor. */
async function renderActor(event: NostrEvent, username: string): Promise<Actor | undefined> {
const content = jsonMetaContentSchema.parse(event.content);
const content = n.json().pipe(n.metadata()).catch({}).parse(event.content);
return {
type: 'Person',

View File

@@ -1,7 +1,9 @@
import { NSchema as n } from '@nostrify/nostrify';
import { nip19, UnsignedEvent } from 'nostr-tools';
import { Conf } from '@/config.ts';
import { lodash, nip19, type UnsignedEvent } from '@/deps.ts';
import { lodash } from '@/deps.ts';
import { type DittoEvent } from '@/interfaces/DittoEvent.ts';
import { jsonMetaContentSchema } from '@/schemas/nostr.ts';
import { getLnurl } from '@/utils/lnurl.ts';
import { nip05Cache } from '@/utils/nip05.ts';
import { Nip05, nostrDate, nostrNow, parseNip05 } from '@/utils.ts';
@@ -26,7 +28,7 @@ async function renderAccount(
about,
lud06,
lud16,
} = jsonMetaContentSchema.parse(event.content);
} = n.json().pipe(n.metadata()).catch({}).parse(event.content);
const npub = nip19.npubEncode(pubkey);
const parsed05 = await parseAndVerifyNip05(nip05, pubkey);
@@ -77,6 +79,7 @@ async function renderAccount(
is_admin: role === 'admin',
is_moderator: ['admin', 'moderator'].includes(role),
is_local: parsed05?.domain === Conf.url.host,
settings_store: undefined as unknown,
},
nostr: {
pubkey,

View File

@@ -1,23 +1,21 @@
import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts';
import { type DittoEvent } from '@/interfaces/DittoEvent.ts';
import { nostrDate } from '@/utils.ts';
import { accountFromPubkey, renderAccount } from './accounts.ts';
/** Expects a kind 0 fully hydrated */
async function renderAdminAccount(event: DittoEvent) {
const d = event.tags.find(([name]) => name === 'd')?.[1]!;
const account = event.d_author ? await renderAccount({ ...event.d_author, user: event }) : await accountFromPubkey(d);
const account = await renderAccount(event);
return {
id: account.id,
username: event.tags.find(([name]) => name === 'name')?.[1]!,
username: account.username,
domain: account.acct.split('@')[1] || null,
created_at: nostrDate(event.created_at).toISOString(),
created_at: account.created_at,
email: '',
ip: null,
ips: [],
locale: '',
invite_request: null,
role: event.tags.find(([name]) => name === 'role')?.[1] || 'user',
role: event.tags.find(([name]) => name === 'role')?.[1],
confirmed: true,
approved: true,
disabled: false,
@@ -27,4 +25,28 @@ async function renderAdminAccount(event: DittoEvent) {
};
}
export { renderAdminAccount };
/** Expects a target pubkey */
async function renderAdminAccountFromPubkey(pubkey: string) {
const account = await accountFromPubkey(pubkey);
return {
id: account.id,
username: account.username,
domain: account.acct.split('@')[1] || null,
created_at: account.created_at,
email: '',
ip: null,
ips: [],
locale: '',
invite_request: null,
role: 'user',
confirmed: true,
approved: true,
disabled: false,
silenced: false,
suspended: false,
account,
};
}
export { renderAdminAccount, renderAdminAccountFromPubkey };

View File

@@ -1,19 +1,41 @@
import { UnattachedMedia } from '@/db/unattached-media.ts';
import { type TypeFest } from '@/deps.ts';
import { getUrlMediaType } from '@/utils/media.ts';
type DittoAttachment = TypeFest.SetOptional<UnattachedMedia, 'id' | 'pubkey' | 'uploaded_at'>;
/** Render Mastodon media attachment. */
function renderAttachment(media: { id?: string; data: string[][] }) {
const { id, data: tags } = media;
const url = tags.find(([name]) => name === 'url')?.[1];
const m = tags.find(([name]) => name === 'm')?.[1] ?? getUrlMediaType(url!);
const alt = tags.find(([name]) => name === 'alt')?.[1];
const cid = tags.find(([name]) => name === 'cid')?.[1];
const dim = tags.find(([name]) => name === 'dim')?.[1];
const blurhash = tags.find(([name]) => name === 'blurhash')?.[1];
if (!url) return;
const [width, height] = dim?.split('x').map(Number) ?? [null, null];
const meta = (typeof width === 'number' && typeof height === 'number')
? {
original: {
width,
height,
aspect: width / height,
},
}
: undefined;
function renderAttachment(media: DittoAttachment) {
const { id, data, url } = media;
return {
id: id ?? url ?? data.cid,
type: getAttachmentType(data.mime ?? ''),
id: id ?? url,
type: getAttachmentType(m ?? ''),
url,
preview_url: url,
remote_url: null,
description: data.description ?? '',
blurhash: data.blurhash || null,
cid: data.cid,
description: alt ?? '',
blurhash: blurhash || null,
meta,
cid: cid,
};
}
@@ -31,4 +53,4 @@ function getAttachmentType(mime: string): string {
}
}
export { type DittoAttachment, renderAttachment };
export { renderAttachment };

View File

@@ -1,4 +1,5 @@
import { UnsignedEvent } from '@/deps.ts';
import { UnsignedEvent } from 'nostr-tools';
import { EmojiTag, emojiTagSchema } from '@/schemas/nostr.ts';
import { filteredArray } from '@/schema.ts';

View File

@@ -1,23 +1,39 @@
import { NostrEvent } from '@nostrify/nostrify';
import { getAuthor } from '@/queries.ts';
import { DittoEvent } from '@/interfaces/DittoEvent.ts';
import { nostrDate } from '@/utils.ts';
import { accountFromPubkey } from '@/views/mastodon/accounts.ts';
import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts';
import { renderStatus } from '@/views/mastodon/statuses.ts';
import { NostrEvent } from '@nostrify/nostrify';
function renderNotification(event: NostrEvent, viewerPubkey?: string) {
switch (event.kind) {
case 1:
return renderNotificationMention(event, viewerPubkey);
interface RenderNotificationOpts {
viewerPubkey: string;
}
function renderNotification(event: DittoEvent, opts: RenderNotificationOpts) {
const mentioned = !!event.tags.find(([name, value]) => name === 'p' && value === opts.viewerPubkey);
if (event.kind === 1 && mentioned) {
return renderMention(event, opts);
}
if (event.kind === 6) {
return renderReblog(event, opts);
}
if (event.kind === 7 && event.content === '+') {
return renderFavourite(event, opts);
}
if (event.kind === 7) {
return renderReaction(event, opts);
}
}
async function renderNotificationMention(event: NostrEvent, viewerPubkey?: string) {
const author = await getAuthor(event.pubkey);
const status = await renderStatus({ ...event, author }, { viewerPubkey: viewerPubkey });
async function renderMention(event: DittoEvent, opts: RenderNotificationOpts) {
const status = await renderStatus(event, opts);
if (!status) return;
return {
id: event.id,
id: notificationId(event),
type: 'mention',
created_at: nostrDate(event.created_at).toISOString(),
account: status.account,
@@ -25,4 +41,55 @@ async function renderNotificationMention(event: NostrEvent, viewerPubkey?: strin
};
}
export { accountFromPubkey, renderNotification };
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);
return {
id: notificationId(event),
type: 'reblog',
created_at: nostrDate(event.created_at).toISOString(),
account,
status,
};
}
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);
return {
id: notificationId(event),
type: 'favourite',
created_at: nostrDate(event.created_at).toISOString(),
account,
status,
};
}
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);
return {
id: notificationId(event),
type: 'pleroma:emoji_reaction',
emoji: event.content,
created_at: nostrDate(event.created_at).toISOString(),
account,
status,
};
}
/** This helps notifications be sorted in the correct order. */
function notificationId({ id, created_at }: NostrEvent): string {
return `${created_at}-${id}`;
}
export { renderNotification };

View File

@@ -1,18 +1,18 @@
import { eventsDB } from '@/storages.ts';
import { Storages } from '@/storages.ts';
import { hasTag } from '@/tags.ts';
async function renderRelationship(sourcePubkey: string, targetPubkey: string) {
const events = await eventsDB.query([
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 },
{ kinds: [10000], authors: [targetPubkey], 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);
const target10000 = events.find((event) => event.kind === 10000 && event.pubkey === targetPubkey);
return {
id: targetPubkey,
@@ -20,9 +20,9 @@ async function renderRelationship(sourcePubkey: string, targetPubkey: string) {
showing_reblogs: true,
notifying: false,
followed_by: target3 ? hasTag(target3?.tags, ['p', sourcePubkey]) : false,
blocking: event10000 ? hasTag(event10000.tags, ['p', targetPubkey]) : false,
blocked_by: target10000 ? hasTag(target10000.tags, ['p', sourcePubkey]) : false,
muting: false,
blocking: false,
blocked_by: false,
muting: event10000 ? hasTag(event10000.tags, ['p', targetPubkey]) : false,
muting_notifications: false,
requested: false,
domain_blocking: false,

View File

@@ -0,0 +1,78 @@
import { type DittoEvent } from '@/interfaces/DittoEvent.ts';
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';
/** Expects a `reportEvent` of kind 1984 and a `profile` of kind 0 of the person being reported */
async function renderReport(event: DittoEvent) {
// 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 = event.tags.find(([name]) => name === 'p')?.[2];
const statusIds = event.tags.filter(([name]) => name === 'e').map((tag) => tag[1]) ?? [];
const reportedPubkey = event.tags.find(([name]) => name === 'p')?.[1];
if (!reportedPubkey) return;
return {
id: event.id,
action_taken: false,
action_taken_at: null,
category,
comment: event.content,
forwarded: false,
created_at: nostrDate(event.created_at).toISOString(),
status_ids: statusIds,
rules_ids: null,
target_account: event.reported_profile
? await renderAccount(event.reported_profile)
: await accountFromPubkey(reportedPubkey),
};
}
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;
// 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 statuses = [];
if (reportEvent.reported_notes) {
for (const status of reportEvent.reported_notes) {
statuses.push(await renderStatus(status, { viewerPubkey }));
}
}
const reportedPubkey = reportEvent.tags.find(([name]) => name === 'p')?.[1];
if (!reportedPubkey) {
return;
}
return {
id: reportEvent.id,
action_taken: actionTaken,
action_taken_at: null,
category,
comment: reportEvent.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)
: await renderAdminAccountFromPubkey(reportedPubkey),
assigned_account: null,
action_taken_by_account: null,
statuses,
rule: [],
};
}
export { renderAdminReport, renderReport };

View File

@@ -1,28 +1,27 @@
import { NostrEvent } from '@nostrify/nostrify';
import { isCWTag } from 'https://gitlab.com/soapbox-pub/mostr/-/raw/c67064aee5ade5e01597c6d23e22e53c628ef0e2/src/nostr/tags.ts';
import { nip19 } from 'nostr-tools';
import { Conf } from '@/config.ts';
import { nip19 } from '@/deps.ts';
import { type DittoEvent } from '@/interfaces/DittoEvent.ts';
import { getMediaLinks, parseNoteContent } from '@/note.ts';
import { getAuthor } from '@/queries.ts';
import { jsonMediaDataSchema } from '@/schemas/nostr.ts';
import { eventsDB } from '@/storages.ts';
import { Storages } from '@/storages.ts';
import { findReplyTag } from '@/tags.ts';
import { nostrDate } from '@/utils.ts';
import { getMediaLinks, parseNoteContent, stripimeta } from '@/utils/note.ts';
import { unfurlCardCached } from '@/utils/unfurl.ts';
import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts';
import { DittoAttachment, renderAttachment } from '@/views/mastodon/attachments.ts';
import { renderAttachment } from '@/views/mastodon/attachments.ts';
import { renderEmojis } from '@/views/mastodon/emojis.ts';
interface statusOpts {
interface RenderStatusOpts {
viewerPubkey?: string;
depth?: number;
}
async function renderStatus(event: DittoEvent, opts: statusOpts): Promise<any> {
async function renderStatus(event: DittoEvent, opts: RenderStatusOpts): Promise<any> {
const { viewerPubkey, depth = 1 } = opts;
if (depth > 2 || depth < 0) return null;
if (depth > 2 || depth < 0) return;
const note = nip19.noteEncode(event.id);
@@ -40,14 +39,23 @@ async function renderStatus(event: DittoEvent, opts: statusOpts): Promise<any> {
),
];
const { html, links, firstUrl } = parseNoteContent(event.content);
const db = await Storages.db();
const optimizer = await Storages.optimizer();
const mentionedProfiles = await optimizer.query(
[{ kinds: [0], authors: mentionedPubkeys, limit: mentionedPubkeys.length }],
);
const { html, links, firstUrl } = parseNoteContent(stripimeta(event.content, event.tags));
const [mentions, card, relatedEvents] = await Promise
.all([
Promise.all(mentionedPubkeys.map(toMention)),
Promise.all(
mentionedPubkeys.map((pubkey) => toMention(pubkey, mentionedProfiles.find((event) => event.pubkey === pubkey))),
),
firstUrl ? unfurlCardCached(firstUrl) : null,
viewerPubkey
? await eventsDB.query([
? await db.query([
{ kinds: [6], '#e': [event.id], authors: [viewerPubkey], limit: 1 },
{ kinds: [7], '#e': [event.id], authors: [viewerPubkey], limit: 1 },
{ kinds: [9734], '#e': [event.id], authors: [viewerPubkey], limit: 1 },
@@ -68,13 +76,11 @@ async function renderStatus(event: DittoEvent, opts: statusOpts): Promise<any> {
const cw = event.tags.find(isCWTag);
const subject = event.tags.find((tag) => tag[0] === 'subject');
const mediaLinks = getMediaLinks(links);
const imeta: string[][][] = event.tags
.filter(([name]) => name === 'imeta')
.map(([_, ...entries]) => entries.map((entry) => entry.split(' ')));
const mediaTags: DittoAttachment[] = event.tags
.filter((tag) => tag[0] === 'media')
.map(([_, url, json]) => ({ url, data: jsonMediaDataSchema.parse(json) }));
const media = [...mediaLinks, ...mediaTags];
const media = imeta.length ? imeta : getMediaLinks(links);
return {
id: event.id,
@@ -98,12 +104,12 @@ async function renderStatus(event: DittoEvent, opts: statusOpts): Promise<any> {
pinned: Boolean(pinEvent),
reblog: null,
application: null,
media_attachments: media.map(renderAttachment),
media_attachments: media.map((m) => renderAttachment({ data: m })).filter(Boolean),
mentions,
tags: [],
emojis: renderEmojis(event),
poll: null,
quote: !event.quote_repost ? null : await renderStatus(event.quote_repost, { depth: depth + 1 }),
quote: !event.quote ? null : await renderStatus(event.quote, { depth: depth + 1 }),
quote_id: event.tags.find(([name]) => name === 'q')?.[1] ?? null,
uri: Conf.external(note),
url: Conf.external(note),
@@ -111,11 +117,9 @@ async function renderStatus(event: DittoEvent, opts: statusOpts): Promise<any> {
};
}
async function renderReblog(event: DittoEvent, opts: statusOpts) {
async function renderReblog(event: DittoEvent, opts: RenderStatusOpts) {
const { viewerPubkey } = opts;
if (!event.author) return;
const repostId = event.tags.find(([name]) => name === 'e')?.[1];
if (!repostId) return;
@@ -125,15 +129,14 @@ async function renderReblog(event: DittoEvent, opts: statusOpts) {
return {
id: event.id,
account: await renderAccount(event.author),
account: event.author ? await renderAccount(event.author) : await accountFromPubkey(event.pubkey),
reblogged: true,
reblog,
};
}
async function toMention(pubkey: string) {
const author = await getAuthor(pubkey);
const account = author ? await renderAccount(author) : undefined;
async function toMention(pubkey: string, event?: NostrEvent) {
const account = event ? await renderAccount(event) : undefined;
if (account) {
return {