Merge branch 'views' into 'main'
Reorganize views See merge request soapbox-pub/ditto!52
This commit is contained in:
commit
a0ebd80c7e
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
};
|
||||
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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);
|
||||
};
|
||||
|
||||
|
|
|
@ -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'))),
|
||||
),
|
||||
]);
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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 };
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 };
|
|
@ -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 };
|
|
@ -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);
|
||||
}
|
||||
}));
|
||||
|
||||
|
|
|
@ -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 };
|
|
@ -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 };
|
|
@ -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 };
|
|
@ -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 };
|
|
@ -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 };
|
|
@ -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 };
|
Loading…
Reference in New Issue