Merge branch 'views' into 'main'

Reorganize views

See merge request soapbox-pub/ditto!52
This commit is contained in:
Alex Gleason 2023-10-06 20:52:36 +00:00
commit a0ebd80c7e
21 changed files with 416 additions and 382 deletions

View File

@ -1,7 +1,7 @@
import { findUser } from '@/db/users.ts';
import { getAuthor } from '@/queries.ts';
import { toActor } from '@/transformers/nostr-to-activitypub.ts';
import { activityJson } from '@/utils/web.ts';
import { renderActor } from '@/views/activitypub/actor.ts';
import type { AppContext, AppController } from '@/app.ts';
@ -14,7 +14,7 @@ const actorController: AppController = async (c) => {
const event = await getAuthor(user.pubkey);
if (!event) return notFound(c);
const actor = await toActor(event, user.username);
const actor = await renderActor(event, user.username);
if (!actor) return notFound(c);
return activityJson(c, actor);

View File

@ -1,17 +1,19 @@
import { type AppController } from '@/app.ts';
import { Conf } from '@/config.ts';
import { insertUser } from '@/db/users.ts';
import { type Filter, findReplyTag, nip19, z } from '@/deps.ts';
import * as mixer from '@/mixer.ts';
import { getAuthor, getFollowedPubkeys, getFollows, syncUser } from '@/queries.ts';
import { booleanParamSchema, fileSchema } from '@/schema.ts';
import { jsonMetaContentSchema } from '@/schemas/nostr.ts';
import { accountFromPubkey, toAccount, toRelationship, toStatus } from '@/transformers/nostr-to-mastoapi.ts';
import { uploadFile } from '@/upload.ts';
import { isFollowing, lookupAccount, nostrNow, Time } from '@/utils.ts';
import { paginated, paginationSchema, parseBody } from '@/utils/web.ts';
import { createEvent } from '@/utils/web.ts';
import { renderEventAccounts } from '@/views.ts';
import { insertUser } from '@/db/users.ts';
import { uploadFile } from '@/upload.ts';
import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts';
import { renderRelationship } from '@/views/mastodon/relationships.ts';
import { renderStatus } from '@/views/mastodon/statuses.ts';
const usernameSchema = z
.string().min(1).max(30)
@ -60,7 +62,7 @@ const verifyCredentialsController: AppController = async (c) => {
const event = await getAuthor(pubkey);
if (event) {
return c.json(await toAccount(event, { withSource: true }));
return c.json(await renderAccount(event, { withSource: true }));
} else {
return c.json(await accountFromPubkey(pubkey, { withSource: true }));
}
@ -71,7 +73,7 @@ const accountController: AppController = async (c) => {
const event = await getAuthor(pubkey);
if (event) {
return c.json(await toAccount(event));
return c.json(await renderAccount(event));
}
return c.json({ error: 'Could not find user.' }, 404);
@ -86,7 +88,7 @@ const accountLookupController: AppController = async (c) => {
const event = await lookupAccount(decodeURIComponent(acct));
if (event) {
return c.json(await toAccount(event));
return c.json(await renderAccount(event));
}
return c.json({ error: 'Could not find user.' }, 404);
@ -101,7 +103,7 @@ const accountSearchController: AppController = async (c) => {
const event = await lookupAccount(decodeURIComponent(q));
if (event) {
return c.json([await toAccount(event)]);
return c.json([await renderAccount(event)]);
}
return c.json([]);
@ -115,7 +117,7 @@ const relationshipsController: AppController = async (c) => {
return c.json({ error: 'Missing `id[]` query parameters.' }, 422);
}
const result = await Promise.all(ids.data.map((id) => toRelationship(pubkey, id)));
const result = await Promise.all(ids.data.map((id) => renderRelationship(pubkey, id)));
return c.json(result);
};
@ -148,7 +150,7 @@ const accountStatusesController: AppController = async (c) => {
events = events.filter((event) => !findReplyTag(event));
}
const statuses = await Promise.all(events.map((event) => toStatus(event, c.get('pubkey'))));
const statuses = await Promise.all(events.map((event) => renderStatus(event, c.get('pubkey'))));
return paginated(c, events, statuses);
};
@ -199,7 +201,7 @@ const updateCredentialsController: AppController = async (c) => {
tags: [],
}, c);
const account = await toAccount(event);
const account = await renderAccount(event);
return c.json(account);
};
@ -220,7 +222,7 @@ const followController: AppController = async (c) => {
}, c);
}
const relationship = await toRelationship(sourcePubkey, targetPubkey);
const relationship = await renderRelationship(sourcePubkey, targetPubkey);
return c.json(relationship);
};
@ -237,7 +239,7 @@ const followingController: AppController = async (c) => {
// TODO: pagination by offset.
const accounts = await Promise.all(pubkeys.map(async (pubkey) => {
const event = await getAuthor(pubkey);
return event ? await toAccount(event) : undefined;
return event ? await renderAccount(event) : undefined;
}));
return c.json(accounts.filter(Boolean));
@ -258,7 +260,7 @@ const favouritesController: AppController = async (c) => {
const events1 = await mixer.getFilters([{ kinds: [1], ids }], { timeout: Time.seconds(1) });
const statuses = await Promise.all(events1.map((event) => toStatus(event, c.get('pubkey'))));
const statuses = await Promise.all(events1.map((event) => renderStatus(event, c.get('pubkey'))));
return paginated(c, events1, statuses);
};

View File

@ -2,7 +2,7 @@ import { AppController } from '@/app.ts';
import { z } from '@/deps.ts';
import { fileSchema } from '@/schema.ts';
import { parseBody } from '@/utils/web.ts';
import { renderAttachment } from '@/views/attachment.ts';
import { renderAttachment } from '@/views/mastodon/attachments.ts';
import { uploadFile } from '@/upload.ts';
const mediaBodySchema = z.object({

View File

@ -1,8 +1,8 @@
import { type AppController } from '@/app.ts';
import * as mixer from '@/mixer.ts';
import { paginated, paginationSchema } from '@/utils/web.ts';
import { toNotification } from '@/transformers/nostr-to-mastoapi.ts';
import { Time } from '@/utils.ts';
import { paginated, paginationSchema } from '@/utils/web.ts';
import { renderNotification } from '@/views/mastodon/notifications.ts';
const notificationsController: AppController = async (c) => {
const pubkey = c.get('pubkey')!;
@ -13,7 +13,7 @@ const notificationsController: AppController = async (c) => {
{ timeout: Time.seconds(3) },
);
const statuses = await Promise.all(events.map((event) => toNotification(event, pubkey)));
const statuses = await Promise.all(events.map((event) => renderNotification(event, pubkey)));
return paginated(c, events, statuses);
};

View File

@ -4,9 +4,10 @@ import { type Event, type Filter, nip19, z } from '@/deps.ts';
import * as mixer from '@/mixer.ts';
import { booleanParamSchema } from '@/schema.ts';
import { nostrIdSchema } from '@/schemas/nostr.ts';
import { toAccount, toStatus } from '@/transformers/nostr-to-mastoapi.ts';
import { dedupeEvents, Time } from '@/utils.ts';
import { lookupNip05Cached } from '@/utils/nip05.ts';
import { renderAccount } from '@/views/mastodon/accounts.ts';
import { renderStatus } from '@/views/mastodon/statuses.ts';
/** Matches NIP-05 names with or without an @ in front. */
const ACCT_REGEX = /^@?(?:([\w.+-]+)@)?([\w.-]+)$/;
@ -44,12 +45,12 @@ const searchController: AppController = async (c) => {
Promise.all(
results
.filter((event): event is Event<0> => event.kind === 0)
.map((event) => toAccount(event)),
.map((event) => renderAccount(event)),
),
Promise.all(
results
.filter((event): event is Event<1> => event.kind === 1)
.map((event) => toStatus(event, c.get('pubkey'))),
.map((event) => renderStatus(event, c.get('pubkey'))),
),
]);

View File

@ -1,10 +1,10 @@
import { type AppController } from '@/app.ts';
import { getUnattachedMediaByIds } from '@/db/unattached-media.ts';
import { type Event, ISO6391, z } from '@/deps.ts';
import { getAncestors, getDescendants, getEvent } from '@/queries.ts';
import { toStatus } from '@/transformers/nostr-to-mastoapi.ts';
import { createEvent, paginationSchema, parseBody } from '@/utils/web.ts';
import { renderEventAccounts } from '@/views.ts';
import { getUnattachedMediaByIds } from '@/db/unattached-media.ts';
import { renderStatus } from '@/views/mastodon/statuses.ts';
const createStatusSchema = z.object({
in_reply_to_id: z.string().regex(/[0-9a-f]{64}/).nullish(),
@ -31,7 +31,7 @@ const statusController: AppController = async (c) => {
const event = await getEvent(id, { kind: 1 });
if (event) {
return c.json(await toStatus(event, c.get('pubkey')));
return c.json(await renderStatus(event, c.get('pubkey')));
}
return c.json({ error: 'Event not found.' }, 404);
@ -83,7 +83,7 @@ const createStatusController: AppController = async (c) => {
tags,
}, c);
return c.json(await toStatus(event, c.get('pubkey')));
return c.json(await renderStatus(event, c.get('pubkey')));
};
const contextController: AppController = async (c) => {
@ -91,7 +91,7 @@ const contextController: AppController = async (c) => {
const event = await getEvent(id, { kind: 1 });
async function renderStatuses(events: Event<1>[]) {
const statuses = await Promise.all(events.map((event) => toStatus(event, c.get('pubkey'))));
const statuses = await Promise.all(events.map((event) => renderStatus(event, c.get('pubkey'))));
return statuses.filter(Boolean);
}
@ -121,7 +121,7 @@ const favouriteController: AppController = async (c) => {
],
}, c);
const status = await toStatus(target, c.get('pubkey'));
const status = await renderStatus(target, c.get('pubkey'));
if (status) {
status.favourited = true;

View File

@ -3,8 +3,8 @@ import { z } from '@/deps.ts';
import { type DittoFilter } from '@/filter.ts';
import { getFeedPubkeys } from '@/queries.ts';
import { Sub } from '@/subs.ts';
import { toStatus } from '@/transformers/nostr-to-mastoapi.ts';
import { bech32ToPubkey } from '@/utils.ts';
import { renderStatus } from '@/views/mastodon/statuses.ts';
/**
* Streaming timelines/categories.
@ -63,7 +63,7 @@ const streamingController: AppController = (c) => {
if (filter) {
for await (const event of Sub.sub(socket, '1', [filter])) {
const status = await toStatus(event, pubkey);
const status = await renderStatus(event, pubkey);
if (status) {
send('update', status);
}

View File

@ -3,9 +3,9 @@ import { type DittoFilter } from '@/filter.ts';
import * as mixer from '@/mixer.ts';
import { getFeedPubkeys } from '@/queries.ts';
import { booleanParamSchema } from '@/schema.ts';
import { toStatus } from '@/transformers/nostr-to-mastoapi.ts';
import { paginated, paginationSchema } from '@/utils/web.ts';
import { Time } from '@/utils.ts';
import { paginated, paginationSchema } from '@/utils/web.ts';
import { renderStatus } from '@/views/mastodon/statuses.ts';
import type { AppContext, AppController } from '@/app.ts';
@ -40,7 +40,7 @@ async function renderStatuses(c: AppContext, filters: DittoFilter<1>[]) {
return c.json([]);
}
const statuses = await Promise.all(events.map((event) => toStatus(event, c.get('pubkey'))));
const statuses = await Promise.all(events.map((event) => renderStatus(event, c.get('pubkey'))));
return paginated(c, events, statuses);
}

View File

@ -1,6 +1,6 @@
import { Conf } from '@/config.ts';
import { linkify, linkifyStr, mime, nip19, nip21 } from '@/deps.ts';
import { type DittoAttachment } from '@/views/attachment.ts';
import { type DittoAttachment } from '@/views/mastodon/attachments.ts';
linkify.registerCustomProtocol('nostr', true);
linkify.registerCustomProtocol('wss');

View File

@ -21,9 +21,6 @@ const jsonSchema = z.string().transform((value, ctx) => {
}
});
/** Parses a Nostr emoji tag. */
const emojiTagSchema = z.tuple([z.literal('emoji'), z.string(), z.string().url()]);
/** https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem */
const decode64Schema = z.string().transform((value, ctx) => {
try {
@ -51,13 +48,4 @@ const booleanParamSchema = z.enum(['true', 'false']).transform((value) => value
/** Schema for `File` objects. */
const fileSchema = z.custom<File>((value) => value instanceof File);
export {
booleanParamSchema,
decode64Schema,
emojiTagSchema,
fileSchema,
filteredArray,
hashtagSchema,
jsonSchema,
safeUrlSchema,
};
export { booleanParamSchema, decode64Schema, fileSchema, filteredArray, hashtagSchema, jsonSchema, safeUrlSchema };

View File

@ -111,6 +111,12 @@ const connectResponseSchema = z.object({
result: signedEventSchema,
});
/** Parses a Nostr emoji tag. */
const emojiTagSchema = z.tuple([z.literal('emoji'), z.string(), z.string().url()]);
/** NIP-30 custom emoji tag. */
type EmojiTag = z.infer<typeof emojiTagSchema>;
export {
type ClientCLOSE,
type ClientCOUNT,
@ -119,6 +125,8 @@ export {
clientMsgSchema,
type ClientREQ,
connectResponseSchema,
type EmojiTag,
emojiTagSchema,
filterSchema,
jsonMediaDataSchema,
jsonMetaContentSchema,

View File

@ -1,331 +0,0 @@
import { isCWTag } from 'https://gitlab.com/soapbox-pub/mostr/-/raw/c67064aee5ade5e01597c6d23e22e53c628ef0e2/src/nostr/tags.ts';
import { Conf } from '@/config.ts';
import * as eventsDB from '@/db/events.ts';
import { type Event, findReplyTag, lodash, nip19, sanitizeHtml, TTLCache, unfurl, type UnsignedEvent } from '@/deps.ts';
import { getMediaLinks, parseNoteContent } from '@/note.ts';
import { getAuthor, getFollowedPubkeys, getFollows } from '@/queries.ts';
import { emojiTagSchema, filteredArray } from '@/schema.ts';
import { jsonMediaDataSchema, jsonMetaContentSchema } from '@/schemas/nostr.ts';
import { isFollowing, type Nip05, nostrDate, nostrNow, parseNip05, Time } from '@/utils.ts';
import { verifyNip05Cached } from '@/utils/nip05.ts';
import { findUser } from '@/db/users.ts';
import { DittoAttachment, renderAttachment } from '@/views/attachment.ts';
const defaultAvatar = () => Conf.local('/images/avi.png');
const defaultBanner = () => Conf.local('/images/banner.png');
interface ToAccountOpts {
withSource?: boolean;
}
async function toAccount(event: UnsignedEvent<0>, opts: ToAccountOpts = {}) {
const { withSource = false } = opts;
const { pubkey } = event;
const {
name,
nip05,
picture = defaultAvatar(),
banner = defaultBanner(),
about,
} = jsonMetaContentSchema.parse(event.content);
const npub = nip19.npubEncode(pubkey);
const [user, parsed05, followersCount, followingCount, statusesCount] = await Promise.all([
findUser({ pubkey }),
parseAndVerifyNip05(nip05, pubkey),
eventsDB.countFilters([{ kinds: [3], '#p': [pubkey] }]),
getFollowedPubkeys(pubkey).then((pubkeys) => pubkeys.length),
eventsDB.countFilters([{ kinds: [1], authors: [pubkey] }]),
]);
return {
id: pubkey,
acct: parsed05?.handle || npub,
avatar: picture,
avatar_static: picture,
bot: false,
created_at: event ? nostrDate(event.created_at).toISOString() : new Date().toISOString(),
discoverable: true,
display_name: name,
emojis: toEmojis(event),
fields: [],
follow_requests_count: 0,
followers_count: followersCount,
following_count: followingCount,
fqn: parsed05?.handle || npub,
header: banner,
header_static: banner,
last_status_at: null,
locked: false,
note: lodash.escape(about),
roles: [],
source: withSource
? {
fields: [],
language: '',
note: about || '',
privacy: 'public',
sensitive: false,
follow_requests_count: 0,
}
: undefined,
statuses_count: statusesCount,
url: Conf.local(`/users/${pubkey}`),
username: parsed05?.nickname || npub.substring(0, 8),
pleroma: {
is_admin: user?.admin || false,
is_moderator: user?.admin || false,
},
};
}
function accountFromPubkey(pubkey: string, opts: ToAccountOpts = {}) {
const event: UnsignedEvent<0> = {
kind: 0,
pubkey,
content: '',
tags: [],
created_at: nostrNow(),
};
return toAccount(event, opts);
}
async function parseAndVerifyNip05(nip05: string | undefined, pubkey: string): Promise<Nip05 | undefined> {
if (nip05 && await verifyNip05Cached(nip05, pubkey)) {
return parseNip05(nip05);
}
}
async function toMention(pubkey: string) {
const profile = await getAuthor(pubkey);
const account = profile ? await toAccount(profile) : 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 toStatus(event: Event<1>, viewerPubkey?: string) {
const profile = await getAuthor(event.pubkey);
const account = profile ? await toAccount(profile) : await accountFromPubkey(event.pubkey);
const replyTag = findReplyTag(event);
const mentionedPubkeys = [
...new Set(
event.tags
.filter((tag) => tag[0] === 'p')
.map((tag) => tag[1]),
),
];
const { html, links, firstUrl } = parseNoteContent(event.content);
const [mentions, card, repliesCount, reblogsCount, favouritesCount, [repostEvent], [reactionEvent]] = await Promise
.all([
Promise.all(mentionedPubkeys.map(toMention)),
firstUrl ? unfurlCardCached(firstUrl) : null,
eventsDB.countFilters([{ kinds: [1], '#e': [event.id] }]),
eventsDB.countFilters([{ kinds: [6], '#e': [event.id] }]),
eventsDB.countFilters([{ kinds: [7], '#e': [event.id] }]),
viewerPubkey
? eventsDB.getFilters([{ kinds: [6], '#e': [event.id], authors: [viewerPubkey] }], { limit: 1 })
: [],
viewerPubkey
? eventsDB.getFilters([{ kinds: [7], '#e': [event.id], authors: [viewerPubkey] }], { limit: 1 })
: [],
]);
const content = buildInlineRecipients(mentions) + html;
const cw = event.tags.find(isCWTag);
const subject = event.tags.find((tag) => tag[0] === 'subject');
const mediaLinks = getMediaLinks(links);
const mediaTags: DittoAttachment[] = event.tags
.filter((tag) => tag[0] === 'media')
.map(([_, url, json]) => ({ url, data: jsonMediaDataSchema.parse(json) }));
const media = [...mediaLinks, ...mediaTags];
return {
id: event.id,
account,
card,
content,
created_at: nostrDate(event.created_at).toISOString(),
in_reply_to_id: replyTag ? replyTag[1] : null,
in_reply_to_account_id: null,
sensitive: !!cw,
spoiler_text: (cw ? cw[1] : subject?.[1]) || '',
visibility: 'public',
language: event.tags.find((tag) => tag[0] === 'lang')?.[1] || null,
replies_count: repliesCount,
reblogs_count: reblogsCount,
favourites_count: favouritesCount,
favourited: reactionEvent?.content === '+',
reblogged: Boolean(repostEvent),
muted: false,
bookmarked: false,
reblog: null,
application: null,
media_attachments: media.map(renderAttachment),
mentions,
tags: [],
emojis: toEmojis(event),
poll: null,
uri: Conf.local(`/posts/${event.id}`),
url: Conf.local(`/posts/${event.id}`),
};
}
type Mention = Awaited<ReturnType<typeof toMention>>;
function buildInlineRecipients(mentions: Mention[]): string {
if (!mentions.length) return '';
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>`);
return acc;
}, []);
return `<span class="recipients-inline">${elements.join(' ')} </span>`;
}
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): Promise<PreviewCard | null> {
console.log(`Unfurling ${url}...`);
try {
const result = await unfurl(url, {
fetch: (url) => fetch(url, { signal: AbortSignal.timeout(Time.seconds(1)) }),
});
return {
type: result.oEmbed?.type || 'link',
url: result.canonical_url || url,
title: result.oEmbed?.title || result.title || '',
description: result.open_graph.description || result.description || '',
author_name: result.oEmbed?.author_name || '',
author_url: result.oEmbed?.author_url || '',
provider_name: result.oEmbed?.provider_name || '',
provider_url: result.oEmbed?.provider_url || '',
// @ts-expect-error `html` does in fact exist on oEmbed.
html: sanitizeHtml(result.oEmbed?.html || '', {
allowedTags: ['iframe'],
allowedAttributes: {
iframe: ['width', 'height', 'src', 'frameborder', 'allowfullscreen'],
},
}),
width: result.oEmbed?.width || 0,
height: result.oEmbed?.height || 0,
image: result.oEmbed?.thumbnails?.[0].url || result.open_graph.images?.[0].url || null,
embed_url: '',
blurhash: null,
};
} catch (_e) {
return null;
}
}
const previewCardCache = new TTLCache<string, Promise<PreviewCard | null>>({ ttl: Time.hours(12), max: 500 });
/** Unfurl card from cache if available, otherwise fetch it. */
function unfurlCardCached(url: string): Promise<PreviewCard | null> {
const cached = previewCardCache.get(url);
if (cached !== undefined) return cached;
const card = unfurlCard(url);
previewCardCache.set(url, card);
return card;
}
function toEmojis(event: UnsignedEvent) {
const emojiTags = event.tags.filter((tag) => tag[0] === 'emoji');
return filteredArray(emojiTagSchema).parse(emojiTags)
.map((tag) => ({
shortcode: tag[1],
static_url: tag[2],
url: tag[2],
}));
}
async function toRelationship(sourcePubkey: string, targetPubkey: string) {
const [source, target] = await Promise.all([
getFollows(sourcePubkey),
getFollows(targetPubkey),
]);
return {
id: targetPubkey,
following: source ? isFollowing(source, targetPubkey) : false,
showing_reblogs: true,
notifying: false,
followed_by: target ? isFollowing(target, sourcePubkey) : false,
blocking: false,
blocked_by: false,
muting: false,
muting_notifications: false,
requested: false,
domain_blocking: false,
endorsed: false,
};
}
function toNotification(event: Event, viewerPubkey?: string) {
switch (event.kind) {
case 1:
return toNotificationMention(event as Event<1>, viewerPubkey);
}
}
async function toNotificationMention(event: Event<1>, viewerPubkey?: string) {
const status = await toStatus(event, viewerPubkey);
if (!status) return;
return {
id: event.id,
type: 'mention',
created_at: nostrDate(event.created_at).toISOString(),
account: status.account,
status: status,
};
}
export { accountFromPubkey, toAccount, toNotification, toRelationship, toStatus };

73
src/utils/unfurl.ts Normal file
View File

@ -0,0 +1,73 @@
import { TTLCache, unfurl } from '@/deps.ts';
import { Time } from '@/utils/time.ts';
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<PreviewCard | null> {
console.log(`Unfurling ${url}...`);
try {
const result = await unfurl(url, {
fetch: (url) => fetch(url, { signal }),
});
return {
type: result.oEmbed?.type || 'link',
url: result.canonical_url || url,
title: result.oEmbed?.title || result.title || '',
description: result.open_graph.description || result.description || '',
author_name: result.oEmbed?.author_name || '',
author_url: result.oEmbed?.author_url || '',
provider_name: result.oEmbed?.provider_name || '',
provider_url: result.oEmbed?.provider_url || '',
// @ts-expect-error `html` does in fact exist on oEmbed.
html: sanitizeHtml(result.oEmbed?.html || '', {
allowedTags: ['iframe'],
allowedAttributes: {
iframe: ['width', 'height', 'src', 'frameborder', 'allowfullscreen'],
},
}),
width: result.oEmbed?.width || 0,
height: result.oEmbed?.height || 0,
image: result.oEmbed?.thumbnails?.[0].url || result.open_graph.images?.[0].url || null,
embed_url: '',
blurhash: null,
};
} catch (_e) {
return null;
}
}
/** TTL cache for preview cards. */
const previewCardCache = new TTLCache<string, Promise<PreviewCard | null>>({
ttl: Time.hours(12),
max: 500,
});
/** Unfurl card from cache if available, otherwise fetch it. */
function unfurlCardCached(url: string, timeout = Time.seconds(1)): Promise<PreviewCard | null> {
const cached = previewCardCache.get(url);
if (cached !== undefined) {
return cached;
} else {
const card = unfurlCard(url, AbortSignal.timeout(timeout));
previewCardCache.set(url, card);
return card;
}
}
export { type PreviewCard, unfurlCardCached };

View File

@ -2,7 +2,7 @@ import { AppContext } from '@/app.ts';
import { type Filter } from '@/deps.ts';
import * as mixer from '@/mixer.ts';
import { getAuthor } from '@/queries.ts';
import { toAccount } from '@/transformers/nostr-to-mastoapi.ts';
import { renderAccount } from '@/views/mastodon/accounts.ts';
import { paginated } from '@/utils/web.ts';
/** Render account objects for the author of each event. */
@ -17,7 +17,7 @@ async function renderEventAccounts(c: AppContext, filters: Filter[]) {
const accounts = await Promise.all([...pubkeys].map(async (pubkey) => {
const author = await getAuthor(pubkey);
if (author) {
return toAccount(author);
return renderAccount(author);
}
}));

View File

@ -6,7 +6,7 @@ import type { Event } from '@/deps.ts';
import type { Actor } from '@/schemas/activitypub.ts';
/** Nostr metadata event to ActivityPub actor. */
async function toActor(event: Event<0>, username: string): Promise<Actor | undefined> {
async function renderActor(event: Event<0>, username: string): Promise<Actor | undefined> {
const content = jsonMetaContentSchema.parse(event.content);
return {
@ -44,4 +44,4 @@ async function toActor(event: Event<0>, username: string): Promise<Actor | undef
};
}
export { toActor };
export { renderActor };

View File

@ -0,0 +1,96 @@
import { Conf } from '@/config.ts';
import * as eventsDB from '@/db/events.ts';
import { findUser } from '@/db/users.ts';
import { lodash, nip19, type UnsignedEvent } from '@/deps.ts';
import { getFollowedPubkeys } from '@/queries.ts';
import { jsonMetaContentSchema } from '@/schemas/nostr.ts';
import { verifyNip05Cached } from '@/utils/nip05.ts';
import { Nip05, nostrDate, nostrNow, parseNip05 } from '@/utils.ts';
import { renderEmojis } from '@/views/mastodon/emojis.ts';
interface ToAccountOpts {
withSource?: boolean;
}
async function renderAccount(event: UnsignedEvent<0>, opts: ToAccountOpts = {}) {
const { withSource = false } = opts;
const { pubkey } = event;
const {
name,
nip05,
picture = Conf.local('/images/avi.png'),
banner = Conf.local('/images/banner.png'),
about,
} = jsonMetaContentSchema.parse(event.content);
const npub = nip19.npubEncode(pubkey);
const [user, parsed05, followersCount, followingCount, statusesCount] = await Promise.all([
findUser({ pubkey }),
parseAndVerifyNip05(nip05, pubkey),
eventsDB.countFilters([{ kinds: [3], '#p': [pubkey] }]),
getFollowedPubkeys(pubkey).then((pubkeys) => pubkeys.length),
eventsDB.countFilters([{ kinds: [1], authors: [pubkey] }]),
]);
return {
id: pubkey,
acct: parsed05?.handle || npub,
avatar: picture,
avatar_static: picture,
bot: false,
created_at: event ? nostrDate(event.created_at).toISOString() : new Date().toISOString(),
discoverable: true,
display_name: name,
emojis: renderEmojis(event),
fields: [],
follow_requests_count: 0,
followers_count: followersCount,
following_count: followingCount,
fqn: parsed05?.handle || npub,
header: banner,
header_static: banner,
last_status_at: null,
locked: false,
note: lodash.escape(about),
roles: [],
source: withSource
? {
fields: [],
language: '',
note: about || '',
privacy: 'public',
sensitive: false,
follow_requests_count: 0,
}
: undefined,
statuses_count: statusesCount,
url: Conf.local(`/users/${pubkey}`),
username: parsed05?.nickname || npub.substring(0, 8),
pleroma: {
is_admin: user?.admin || false,
is_moderator: user?.admin || false,
},
};
}
function accountFromPubkey(pubkey: string, opts: ToAccountOpts = {}) {
const event: UnsignedEvent<0> = {
kind: 0,
pubkey,
content: '',
tags: [],
created_at: nostrNow(),
};
return renderAccount(event, opts);
}
async function parseAndVerifyNip05(nip05: string | undefined, pubkey: string): Promise<Nip05 | undefined> {
if (nip05 && await verifyNip05Cached(nip05, pubkey)) {
return parseNip05(nip05);
}
}
export { accountFromPubkey, renderAccount };

View File

@ -0,0 +1,19 @@
import { UnsignedEvent } from '@/deps.ts';
import { EmojiTag, emojiTagSchema } from '@/schemas/nostr.ts';
import { filteredArray } from '@/schema.ts';
function renderEmoji([_, shortcode, url]: EmojiTag) {
return {
shortcode,
static_url: url,
url,
};
}
function renderEmojis({ tags }: UnsignedEvent) {
return filteredArray(emojiTagSchema)
.parse(tags)
.map(renderEmoji);
}
export { renderEmojis };

View File

@ -0,0 +1,26 @@
import { type Event } from '@/deps.ts';
import { nostrDate } from '@/utils.ts';
import { accountFromPubkey } from '@/views/mastodon/accounts.ts';
import { renderStatus } from '@/views/mastodon/statuses.ts';
function renderNotification(event: Event, viewerPubkey?: string) {
switch (event.kind) {
case 1:
return renderNotificationMention(event as Event<1>, viewerPubkey);
}
}
async function renderNotificationMention(event: Event<1>, viewerPubkey?: string) {
const status = await renderStatus(event, viewerPubkey);
if (!status) return;
return {
id: event.id,
type: 'mention',
created_at: nostrDate(event.created_at).toISOString(),
account: status.account,
status: status,
};
}
export { accountFromPubkey, renderNotification };

View File

@ -0,0 +1,26 @@
import { getFollows } from '@/queries.ts';
import { isFollowing } from '@/utils.ts';
async function renderRelationship(sourcePubkey: string, targetPubkey: string) {
const [source, target] = await Promise.all([
getFollows(sourcePubkey),
getFollows(targetPubkey),
]);
return {
id: targetPubkey,
following: source ? isFollowing(source, targetPubkey) : false,
showing_reblogs: true,
notifying: false,
followed_by: target ? isFollowing(target, sourcePubkey) : false,
blocking: false,
blocked_by: false,
muting: false,
muting_notifications: false,
requested: false,
domain_blocking: false,
endorsed: false,
};
}
export { renderRelationship };

View File

@ -0,0 +1,126 @@
import { isCWTag } from 'https://gitlab.com/soapbox-pub/mostr/-/raw/c67064aee5ade5e01597c6d23e22e53c628ef0e2/src/nostr/tags.ts';
import { Conf } from '@/config.ts';
import * as eventsDB from '@/db/events.ts';
import { type Event, findReplyTag, nip19 } from '@/deps.ts';
import { getMediaLinks, parseNoteContent } from '@/note.ts';
import { getAuthor } from '@/queries.ts';
import { jsonMediaDataSchema } from '@/schemas/nostr.ts';
import { nostrDate } from '@/utils.ts';
import { unfurlCardCached } from '@/utils/unfurl.ts';
import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts';
import { DittoAttachment, renderAttachment } from '@/views/mastodon/attachments.ts';
import { renderEmojis } from '@/views/mastodon/emojis.ts';
async function renderStatus(event: Event<1>, viewerPubkey?: string) {
const profile = await getAuthor(event.pubkey);
const account = profile ? await renderAccount(profile) : await accountFromPubkey(event.pubkey);
const replyTag = findReplyTag(event);
const mentionedPubkeys = [
...new Set(
event.tags
.filter((tag) => tag[0] === 'p')
.map((tag) => tag[1]),
),
];
const { html, links, firstUrl } = parseNoteContent(event.content);
const [mentions, card, repliesCount, reblogsCount, favouritesCount, [repostEvent], [reactionEvent]] = await Promise
.all([
Promise.all(mentionedPubkeys.map(toMention)),
firstUrl ? unfurlCardCached(firstUrl) : null,
eventsDB.countFilters([{ kinds: [1], '#e': [event.id] }]),
eventsDB.countFilters([{ kinds: [6], '#e': [event.id] }]),
eventsDB.countFilters([{ kinds: [7], '#e': [event.id] }]),
viewerPubkey
? eventsDB.getFilters([{ kinds: [6], '#e': [event.id], authors: [viewerPubkey] }], { limit: 1 })
: [],
viewerPubkey
? eventsDB.getFilters([{ kinds: [7], '#e': [event.id], authors: [viewerPubkey] }], { limit: 1 })
: [],
]);
const content = buildInlineRecipients(mentions) + html;
const cw = event.tags.find(isCWTag);
const subject = event.tags.find((tag) => tag[0] === 'subject');
const mediaLinks = getMediaLinks(links);
const mediaTags: DittoAttachment[] = event.tags
.filter((tag) => tag[0] === 'media')
.map(([_, url, json]) => ({ url, data: jsonMediaDataSchema.parse(json) }));
const media = [...mediaLinks, ...mediaTags];
return {
id: event.id,
account,
card,
content,
created_at: nostrDate(event.created_at).toISOString(),
in_reply_to_id: replyTag ? replyTag[1] : null,
in_reply_to_account_id: null,
sensitive: !!cw,
spoiler_text: (cw ? cw[1] : subject?.[1]) || '',
visibility: 'public',
language: event.tags.find((tag) => tag[0] === 'lang')?.[1] || null,
replies_count: repliesCount,
reblogs_count: reblogsCount,
favourites_count: favouritesCount,
favourited: reactionEvent?.content === '+',
reblogged: Boolean(repostEvent),
muted: false,
bookmarked: false,
reblog: null,
application: null,
media_attachments: media.map(renderAttachment),
mentions,
tags: [],
emojis: renderEmojis(event),
poll: null,
uri: Conf.local(`/posts/${event.id}`),
url: Conf.local(`/posts/${event.id}`),
};
}
async function toMention(pubkey: string) {
const profile = await getAuthor(pubkey);
const account = profile ? await renderAccount(profile) : 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}`),
};
}
}
type Mention = Awaited<ReturnType<typeof toMention>>;
function buildInlineRecipients(mentions: Mention[]): string {
if (!mentions.length) return '';
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>`);
return acc;
}, []);
return `<span class="recipients-inline">${elements.join(' ')} </span>`;
}
export { renderStatus };