diff --git a/src/controllers/api/accounts.ts b/src/controllers/api/accounts.ts index c43a424..feb6dfe 100644 --- a/src/controllers/api/accounts.ts +++ b/src/controllers/api/accounts.ts @@ -12,7 +12,8 @@ import { paginated, paginationSchema, parseBody } from '@/utils/web.ts'; import { createEvent } from '@/utils/web.ts'; import { renderEventAccounts } from '@/views.ts'; import { renderAccount } from '@/views/mastodon/accounts.ts'; -import { accountFromPubkey, toRelationship, toStatus } from '@/views/nostr-to-mastoapi.ts'; +import { renderStatus } from '@/views/mastodon/statuses.ts'; +import { accountFromPubkey, toRelationship } from '@/views/nostr-to-mastoapi.ts'; const usernameSchema = z .string().min(1).max(30) @@ -149,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); }; @@ -259,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); }; diff --git a/src/controllers/api/search.ts b/src/controllers/api/search.ts index 8cc17e6..15417c8 100644 --- a/src/controllers/api/search.ts +++ b/src/controllers/api/search.ts @@ -7,7 +7,7 @@ import { nostrIdSchema } from '@/schemas/nostr.ts'; import { dedupeEvents, Time } from '@/utils.ts'; import { lookupNip05Cached } from '@/utils/nip05.ts'; import { renderAccount } from '@/views/mastodon/accounts.ts'; -import { toStatus } from '@/views/nostr-to-mastoapi.ts'; +import { renderStatus } from '@/views/mastodon/statuses.ts'; /** Matches NIP-05 names with or without an @ in front. */ const ACCT_REGEX = /^@?(?:([\w.+-]+)@)?([\w.-]+)$/; @@ -50,7 +50,7 @@ const searchController: AppController = async (c) => { 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'))), ), ]); diff --git a/src/controllers/api/statuses.ts b/src/controllers/api/statuses.ts index 73fb982..0f6be9c 100644 --- a/src/controllers/api/statuses.ts +++ b/src/controllers/api/statuses.ts @@ -4,7 +4,7 @@ import { type Event, ISO6391, z } from '@/deps.ts'; import { getAncestors, getDescendants, getEvent } from '@/queries.ts'; import { createEvent, paginationSchema, parseBody } from '@/utils/web.ts'; import { renderEventAccounts } from '@/views.ts'; -import { toStatus } from '@/views/nostr-to-mastoapi.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; diff --git a/src/controllers/api/streaming.ts b/src/controllers/api/streaming.ts index 1e495b8..6a9e535 100644 --- a/src/controllers/api/streaming.ts +++ b/src/controllers/api/streaming.ts @@ -4,7 +4,7 @@ import { type DittoFilter } from '@/filter.ts'; import { getFeedPubkeys } from '@/queries.ts'; import { Sub } from '@/subs.ts'; import { bech32ToPubkey } from '@/utils.ts'; -import { toStatus } from '@/views/nostr-to-mastoapi.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); } diff --git a/src/controllers/api/timelines.ts b/src/controllers/api/timelines.ts index 56b8ddf..712f741 100644 --- a/src/controllers/api/timelines.ts +++ b/src/controllers/api/timelines.ts @@ -5,7 +5,7 @@ import { getFeedPubkeys } from '@/queries.ts'; import { booleanParamSchema } from '@/schema.ts'; import { Time } from '@/utils.ts'; import { paginated, paginationSchema } from '@/utils/web.ts'; -import { toStatus } from '@/views/nostr-to-mastoapi.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); } diff --git a/src/views/mastodon/statuses.ts b/src/views/mastodon/statuses.ts new file mode 100644 index 0000000..131dcd6 --- /dev/null +++ b/src/views/mastodon/statuses.ts @@ -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>; + +function buildInlineRecipients(mentions: Mention[]): string { + if (!mentions.length) return ''; + + const elements = mentions.reduce((acc, { url, username }) => { + const name = nip19.BECH32_REGEX.test(username) ? username.substring(0, 8) : username; + acc.push(`@${name}`); + return acc; + }, []); + + return `${elements.join(' ')} `; +} + +export { renderStatus }; diff --git a/src/views/nostr-to-mastoapi.ts b/src/views/nostr-to-mastoapi.ts index 21d5f01..bdad739 100644 --- a/src/views/nostr-to-mastoapi.ts +++ b/src/views/nostr-to-mastoapi.ts @@ -1,127 +1,8 @@ -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, getFollows } from '@/queries.ts'; -import { jsonMediaDataSchema } from '@/schemas/nostr.ts'; +import { type Event } from '@/deps.ts'; +import { getFollows } from '@/queries.ts'; import { isFollowing, 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 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}`), - }; - } -} - -async function toStatus(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}`), - }; -} - -type Mention = Awaited>; - -function buildInlineRecipients(mentions: Mention[]): string { - if (!mentions.length) return ''; - - const elements = mentions.reduce((acc, { url, username }) => { - const name = nip19.BECH32_REGEX.test(username) ? username.substring(0, 8) : username; - acc.push(`@${name}`); - return acc; - }, []); - - return `${elements.join(' ')} `; -} +import { accountFromPubkey } from '@/views/mastodon/accounts.ts'; +import { renderStatus } from '@/views/mastodon/statuses.ts'; async function toRelationship(sourcePubkey: string, targetPubkey: string) { const [source, target] = await Promise.all([ @@ -153,7 +34,7 @@ function toNotification(event: Event, viewerPubkey?: string) { } async function toNotificationMention(event: Event<1>, viewerPubkey?: string) { - const status = await toStatus(event, viewerPubkey); + const status = await renderStatus(event, viewerPubkey); if (!status) return; return { @@ -165,4 +46,4 @@ async function toNotificationMention(event: Event<1>, viewerPubkey?: string) { }; } -export { accountFromPubkey, toNotification, toRelationship, toStatus }; +export { accountFromPubkey, toNotification, toRelationship };