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 { findUser } from '@/db/users.ts';
|
||||||
import { getAuthor } from '@/queries.ts';
|
import { getAuthor } from '@/queries.ts';
|
||||||
import { toActor } from '@/transformers/nostr-to-activitypub.ts';
|
|
||||||
import { activityJson } from '@/utils/web.ts';
|
import { activityJson } from '@/utils/web.ts';
|
||||||
|
import { renderActor } from '@/views/activitypub/actor.ts';
|
||||||
|
|
||||||
import type { AppContext, AppController } from '@/app.ts';
|
import type { AppContext, AppController } from '@/app.ts';
|
||||||
|
|
||||||
|
@ -14,7 +14,7 @@ const actorController: AppController = async (c) => {
|
||||||
const event = await getAuthor(user.pubkey);
|
const event = await getAuthor(user.pubkey);
|
||||||
if (!event) return notFound(c);
|
if (!event) return notFound(c);
|
||||||
|
|
||||||
const actor = await toActor(event, user.username);
|
const actor = await renderActor(event, user.username);
|
||||||
if (!actor) return notFound(c);
|
if (!actor) return notFound(c);
|
||||||
|
|
||||||
return activityJson(c, actor);
|
return activityJson(c, actor);
|
||||||
|
|
|
@ -1,17 +1,19 @@
|
||||||
import { type AppController } from '@/app.ts';
|
import { type AppController } from '@/app.ts';
|
||||||
import { Conf } from '@/config.ts';
|
import { Conf } from '@/config.ts';
|
||||||
|
import { insertUser } from '@/db/users.ts';
|
||||||
import { type Filter, findReplyTag, nip19, z } from '@/deps.ts';
|
import { type Filter, findReplyTag, nip19, z } from '@/deps.ts';
|
||||||
import * as mixer from '@/mixer.ts';
|
import * as mixer from '@/mixer.ts';
|
||||||
import { getAuthor, getFollowedPubkeys, getFollows, syncUser } from '@/queries.ts';
|
import { getAuthor, getFollowedPubkeys, getFollows, syncUser } from '@/queries.ts';
|
||||||
import { booleanParamSchema, fileSchema } from '@/schema.ts';
|
import { booleanParamSchema, fileSchema } from '@/schema.ts';
|
||||||
import { jsonMetaContentSchema } from '@/schemas/nostr.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 { isFollowing, lookupAccount, nostrNow, Time } from '@/utils.ts';
|
||||||
import { paginated, paginationSchema, parseBody } from '@/utils/web.ts';
|
import { paginated, paginationSchema, parseBody } from '@/utils/web.ts';
|
||||||
import { createEvent } from '@/utils/web.ts';
|
import { createEvent } from '@/utils/web.ts';
|
||||||
import { renderEventAccounts } from '@/views.ts';
|
import { renderEventAccounts } from '@/views.ts';
|
||||||
import { insertUser } from '@/db/users.ts';
|
import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts';
|
||||||
import { uploadFile } from '@/upload.ts';
|
import { renderRelationship } from '@/views/mastodon/relationships.ts';
|
||||||
|
import { renderStatus } from '@/views/mastodon/statuses.ts';
|
||||||
|
|
||||||
const usernameSchema = z
|
const usernameSchema = z
|
||||||
.string().min(1).max(30)
|
.string().min(1).max(30)
|
||||||
|
@ -60,7 +62,7 @@ const verifyCredentialsController: AppController = async (c) => {
|
||||||
|
|
||||||
const event = await getAuthor(pubkey);
|
const event = await getAuthor(pubkey);
|
||||||
if (event) {
|
if (event) {
|
||||||
return c.json(await toAccount(event, { withSource: true }));
|
return c.json(await renderAccount(event, { withSource: true }));
|
||||||
} else {
|
} else {
|
||||||
return c.json(await accountFromPubkey(pubkey, { withSource: true }));
|
return c.json(await accountFromPubkey(pubkey, { withSource: true }));
|
||||||
}
|
}
|
||||||
|
@ -71,7 +73,7 @@ const accountController: AppController = async (c) => {
|
||||||
|
|
||||||
const event = await getAuthor(pubkey);
|
const event = await getAuthor(pubkey);
|
||||||
if (event) {
|
if (event) {
|
||||||
return c.json(await toAccount(event));
|
return c.json(await renderAccount(event));
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.json({ error: 'Could not find user.' }, 404);
|
return c.json({ error: 'Could not find user.' }, 404);
|
||||||
|
@ -86,7 +88,7 @@ const accountLookupController: AppController = async (c) => {
|
||||||
|
|
||||||
const event = await lookupAccount(decodeURIComponent(acct));
|
const event = await lookupAccount(decodeURIComponent(acct));
|
||||||
if (event) {
|
if (event) {
|
||||||
return c.json(await toAccount(event));
|
return c.json(await renderAccount(event));
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.json({ error: 'Could not find user.' }, 404);
|
return c.json({ error: 'Could not find user.' }, 404);
|
||||||
|
@ -101,7 +103,7 @@ const accountSearchController: AppController = async (c) => {
|
||||||
|
|
||||||
const event = await lookupAccount(decodeURIComponent(q));
|
const event = await lookupAccount(decodeURIComponent(q));
|
||||||
if (event) {
|
if (event) {
|
||||||
return c.json([await toAccount(event)]);
|
return c.json([await renderAccount(event)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.json([]);
|
return c.json([]);
|
||||||
|
@ -115,7 +117,7 @@ const relationshipsController: AppController = async (c) => {
|
||||||
return c.json({ error: 'Missing `id[]` query parameters.' }, 422);
|
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);
|
return c.json(result);
|
||||||
};
|
};
|
||||||
|
@ -148,7 +150,7 @@ const accountStatusesController: AppController = async (c) => {
|
||||||
events = events.filter((event) => !findReplyTag(event));
|
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);
|
return paginated(c, events, statuses);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -199,7 +201,7 @@ const updateCredentialsController: AppController = async (c) => {
|
||||||
tags: [],
|
tags: [],
|
||||||
}, c);
|
}, c);
|
||||||
|
|
||||||
const account = await toAccount(event);
|
const account = await renderAccount(event);
|
||||||
return c.json(account);
|
return c.json(account);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -220,7 +222,7 @@ const followController: AppController = async (c) => {
|
||||||
}, c);
|
}, c);
|
||||||
}
|
}
|
||||||
|
|
||||||
const relationship = await toRelationship(sourcePubkey, targetPubkey);
|
const relationship = await renderRelationship(sourcePubkey, targetPubkey);
|
||||||
return c.json(relationship);
|
return c.json(relationship);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -237,7 +239,7 @@ const followingController: AppController = async (c) => {
|
||||||
// TODO: pagination by offset.
|
// TODO: pagination by offset.
|
||||||
const accounts = await Promise.all(pubkeys.map(async (pubkey) => {
|
const accounts = await Promise.all(pubkeys.map(async (pubkey) => {
|
||||||
const event = await getAuthor(pubkey);
|
const event = await getAuthor(pubkey);
|
||||||
return event ? await toAccount(event) : undefined;
|
return event ? await renderAccount(event) : undefined;
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return c.json(accounts.filter(Boolean));
|
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 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);
|
return paginated(c, events1, statuses);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -2,7 +2,7 @@ import { AppController } from '@/app.ts';
|
||||||
import { z } from '@/deps.ts';
|
import { z } from '@/deps.ts';
|
||||||
import { fileSchema } from '@/schema.ts';
|
import { fileSchema } from '@/schema.ts';
|
||||||
import { parseBody } from '@/utils/web.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';
|
import { uploadFile } from '@/upload.ts';
|
||||||
|
|
||||||
const mediaBodySchema = z.object({
|
const mediaBodySchema = z.object({
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
import { type AppController } from '@/app.ts';
|
import { type AppController } from '@/app.ts';
|
||||||
import * as mixer from '@/mixer.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 { Time } from '@/utils.ts';
|
||||||
|
import { paginated, paginationSchema } from '@/utils/web.ts';
|
||||||
|
import { renderNotification } from '@/views/mastodon/notifications.ts';
|
||||||
|
|
||||||
const notificationsController: AppController = async (c) => {
|
const notificationsController: AppController = async (c) => {
|
||||||
const pubkey = c.get('pubkey')!;
|
const pubkey = c.get('pubkey')!;
|
||||||
|
@ -13,7 +13,7 @@ const notificationsController: AppController = async (c) => {
|
||||||
{ timeout: Time.seconds(3) },
|
{ 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);
|
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 * as mixer from '@/mixer.ts';
|
||||||
import { booleanParamSchema } from '@/schema.ts';
|
import { booleanParamSchema } from '@/schema.ts';
|
||||||
import { nostrIdSchema } from '@/schemas/nostr.ts';
|
import { nostrIdSchema } from '@/schemas/nostr.ts';
|
||||||
import { toAccount, toStatus } from '@/transformers/nostr-to-mastoapi.ts';
|
|
||||||
import { dedupeEvents, Time } from '@/utils.ts';
|
import { dedupeEvents, Time } from '@/utils.ts';
|
||||||
import { lookupNip05Cached } from '@/utils/nip05.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. */
|
/** Matches NIP-05 names with or without an @ in front. */
|
||||||
const ACCT_REGEX = /^@?(?:([\w.+-]+)@)?([\w.-]+)$/;
|
const ACCT_REGEX = /^@?(?:([\w.+-]+)@)?([\w.-]+)$/;
|
||||||
|
@ -44,12 +45,12 @@ const searchController: AppController = async (c) => {
|
||||||
Promise.all(
|
Promise.all(
|
||||||
results
|
results
|
||||||
.filter((event): event is Event<0> => event.kind === 0)
|
.filter((event): event is Event<0> => event.kind === 0)
|
||||||
.map((event) => toAccount(event)),
|
.map((event) => renderAccount(event)),
|
||||||
),
|
),
|
||||||
Promise.all(
|
Promise.all(
|
||||||
results
|
results
|
||||||
.filter((event): event is Event<1> => event.kind === 1)
|
.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 { type AppController } from '@/app.ts';
|
||||||
|
import { getUnattachedMediaByIds } from '@/db/unattached-media.ts';
|
||||||
import { type Event, ISO6391, z } from '@/deps.ts';
|
import { type Event, ISO6391, z } from '@/deps.ts';
|
||||||
import { getAncestors, getDescendants, getEvent } from '@/queries.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 { createEvent, paginationSchema, parseBody } from '@/utils/web.ts';
|
||||||
import { renderEventAccounts } from '@/views.ts';
|
import { renderEventAccounts } from '@/views.ts';
|
||||||
import { getUnattachedMediaByIds } from '@/db/unattached-media.ts';
|
import { renderStatus } from '@/views/mastodon/statuses.ts';
|
||||||
|
|
||||||
const createStatusSchema = z.object({
|
const createStatusSchema = z.object({
|
||||||
in_reply_to_id: z.string().regex(/[0-9a-f]{64}/).nullish(),
|
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 });
|
const event = await getEvent(id, { kind: 1 });
|
||||||
if (event) {
|
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);
|
return c.json({ error: 'Event not found.' }, 404);
|
||||||
|
@ -83,7 +83,7 @@ const createStatusController: AppController = async (c) => {
|
||||||
tags,
|
tags,
|
||||||
}, c);
|
}, c);
|
||||||
|
|
||||||
return c.json(await toStatus(event, c.get('pubkey')));
|
return c.json(await renderStatus(event, c.get('pubkey')));
|
||||||
};
|
};
|
||||||
|
|
||||||
const contextController: AppController = async (c) => {
|
const contextController: AppController = async (c) => {
|
||||||
|
@ -91,7 +91,7 @@ const contextController: AppController = async (c) => {
|
||||||
const event = await getEvent(id, { kind: 1 });
|
const event = await getEvent(id, { kind: 1 });
|
||||||
|
|
||||||
async function renderStatuses(events: Event<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);
|
return statuses.filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -121,7 +121,7 @@ const favouriteController: AppController = async (c) => {
|
||||||
],
|
],
|
||||||
}, c);
|
}, c);
|
||||||
|
|
||||||
const status = await toStatus(target, c.get('pubkey'));
|
const status = await renderStatus(target, c.get('pubkey'));
|
||||||
|
|
||||||
if (status) {
|
if (status) {
|
||||||
status.favourited = true;
|
status.favourited = true;
|
||||||
|
|
|
@ -3,8 +3,8 @@ import { z } from '@/deps.ts';
|
||||||
import { type DittoFilter } from '@/filter.ts';
|
import { type DittoFilter } from '@/filter.ts';
|
||||||
import { getFeedPubkeys } from '@/queries.ts';
|
import { getFeedPubkeys } from '@/queries.ts';
|
||||||
import { Sub } from '@/subs.ts';
|
import { Sub } from '@/subs.ts';
|
||||||
import { toStatus } from '@/transformers/nostr-to-mastoapi.ts';
|
|
||||||
import { bech32ToPubkey } from '@/utils.ts';
|
import { bech32ToPubkey } from '@/utils.ts';
|
||||||
|
import { renderStatus } from '@/views/mastodon/statuses.ts';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Streaming timelines/categories.
|
* Streaming timelines/categories.
|
||||||
|
@ -63,7 +63,7 @@ const streamingController: AppController = (c) => {
|
||||||
|
|
||||||
if (filter) {
|
if (filter) {
|
||||||
for await (const event of Sub.sub(socket, '1', [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) {
|
if (status) {
|
||||||
send('update', status);
|
send('update', status);
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,9 +3,9 @@ import { type DittoFilter } from '@/filter.ts';
|
||||||
import * as mixer from '@/mixer.ts';
|
import * as mixer from '@/mixer.ts';
|
||||||
import { getFeedPubkeys } from '@/queries.ts';
|
import { getFeedPubkeys } from '@/queries.ts';
|
||||||
import { booleanParamSchema } from '@/schema.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 { 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';
|
import type { AppContext, AppController } from '@/app.ts';
|
||||||
|
|
||||||
|
@ -40,7 +40,7 @@ async function renderStatuses(c: AppContext, filters: DittoFilter<1>[]) {
|
||||||
return c.json([]);
|
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);
|
return paginated(c, events, statuses);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { Conf } from '@/config.ts';
|
import { Conf } from '@/config.ts';
|
||||||
import { linkify, linkifyStr, mime, nip19, nip21 } from '@/deps.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('nostr', true);
|
||||||
linkify.registerCustomProtocol('wss');
|
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 */
|
/** https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem */
|
||||||
const decode64Schema = z.string().transform((value, ctx) => {
|
const decode64Schema = z.string().transform((value, ctx) => {
|
||||||
try {
|
try {
|
||||||
|
@ -51,13 +48,4 @@ const booleanParamSchema = z.enum(['true', 'false']).transform((value) => value
|
||||||
/** Schema for `File` objects. */
|
/** Schema for `File` objects. */
|
||||||
const fileSchema = z.custom<File>((value) => value instanceof File);
|
const fileSchema = z.custom<File>((value) => value instanceof File);
|
||||||
|
|
||||||
export {
|
export { booleanParamSchema, decode64Schema, fileSchema, filteredArray, hashtagSchema, jsonSchema, safeUrlSchema };
|
||||||
booleanParamSchema,
|
|
||||||
decode64Schema,
|
|
||||||
emojiTagSchema,
|
|
||||||
fileSchema,
|
|
||||||
filteredArray,
|
|
||||||
hashtagSchema,
|
|
||||||
jsonSchema,
|
|
||||||
safeUrlSchema,
|
|
||||||
};
|
|
||||||
|
|
|
@ -111,6 +111,12 @@ const connectResponseSchema = z.object({
|
||||||
result: signedEventSchema,
|
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 {
|
export {
|
||||||
type ClientCLOSE,
|
type ClientCLOSE,
|
||||||
type ClientCOUNT,
|
type ClientCOUNT,
|
||||||
|
@ -119,6 +125,8 @@ export {
|
||||||
clientMsgSchema,
|
clientMsgSchema,
|
||||||
type ClientREQ,
|
type ClientREQ,
|
||||||
connectResponseSchema,
|
connectResponseSchema,
|
||||||
|
type EmojiTag,
|
||||||
|
emojiTagSchema,
|
||||||
filterSchema,
|
filterSchema,
|
||||||
jsonMediaDataSchema,
|
jsonMediaDataSchema,
|
||||||
jsonMetaContentSchema,
|
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 { type Filter } from '@/deps.ts';
|
||||||
import * as mixer from '@/mixer.ts';
|
import * as mixer from '@/mixer.ts';
|
||||||
import { getAuthor } from '@/queries.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';
|
import { paginated } from '@/utils/web.ts';
|
||||||
|
|
||||||
/** Render account objects for the author of each event. */
|
/** 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 accounts = await Promise.all([...pubkeys].map(async (pubkey) => {
|
||||||
const author = await getAuthor(pubkey);
|
const author = await getAuthor(pubkey);
|
||||||
if (author) {
|
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';
|
import type { Actor } from '@/schemas/activitypub.ts';
|
||||||
|
|
||||||
/** Nostr metadata event to ActivityPub actor. */
|
/** 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);
|
const content = jsonMetaContentSchema.parse(event.content);
|
||||||
|
|
||||||
return {
|
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