diff --git a/src/deps.ts b/src/deps.ts index 1366422..52cd691 100644 --- a/src/deps.ts +++ b/src/deps.ts @@ -24,3 +24,8 @@ export { findReplyTag } from 'https://gitlab.com/soapbox-pub/mostr/-/raw/c67064a export { parseFormData } from 'npm:formdata-helper@^0.3.0'; // @deno-types="npm:@types/lodash@4.14.194" export { default as lodash } from 'https://esm.sh/lodash@4.17.21'; +export { default as linkify } from 'npm:linkifyjs@^4.1.0'; +export { default as linkifyStr } from 'npm:linkify-string@^4.1.0'; +import 'npm:linkify-plugin-hashtag@^4.1.0'; +// @deno-types="npm:@types/mime@3.0.0" +export { default as mime } from 'npm:mime@^3.0.0'; diff --git a/src/note.ts b/src/note.ts new file mode 100644 index 0000000..eee20cb --- /dev/null +++ b/src/note.ts @@ -0,0 +1,98 @@ +import { LOCAL_DOMAIN } from '@/config.ts'; +import { linkify, linkifyStr, mime, nip19, nip21 } from '@/deps.ts'; + +linkify.registerCustomProtocol('nostr', true); +linkify.registerCustomProtocol('wss'); + +const url = (path: string) => new URL(path, LOCAL_DOMAIN).toString(); + +/** Get pubkey from decoded bech32 entity, or undefined if not applicable. */ +function getDecodedPubkey(decoded: nip19.DecodeResult): string | undefined { + switch (decoded.type) { + case 'npub': + return decoded.data; + case 'nprofile': + return decoded.data.pubkey; + } +} + +const linkifyOpts: linkify.Opts = { + render: { + hashtag: ({ content }) => { + const tag = content.replace(/^#/, ''); + const href = url(`/tags/${tag}`); + return `#${tag}`; + }, + url: ({ content }) => { + if (nip21.test(content)) { + const { decoded } = nip21.parse(content); + const pubkey = getDecodedPubkey(decoded); + if (pubkey) { + const name = pubkey.substring(0, 8); + const href = url(`/users/${pubkey}`); + return `@${name}`; + } else { + return ''; + } + } else { + return `${content}`; + } + }, + }, +}; + +type Link = ReturnType[0]; + +interface ParsedNoteContent { + html: string; + links: Link[]; +} + +/** Ensures the URL can be parsed. Why linkifyjs doesn't already guarantee this, idk... */ +function isValidLink(link: Link): boolean { + try { + new URL(link.href); + return true; + } catch (_e) { + return false; + } +} + +/** Convert Nostr content to Mastodon API HTML. Also return parsed data. */ +function parseNoteContent(content: string): ParsedNoteContent { + // Parsing twice is ineffecient, but I don't know how to do only once. + const html = linkifyStr(content, linkifyOpts); + const links = linkify.find(content).filter(isValidLink); + + return { + html, + links, + }; +} + +interface MediaLink { + url: string; + mimeType: string; +} + +function getMediaLinks(links: Link[]): MediaLink[] { + return links.reduce((acc, link) => { + const { pathname } = new URL(link.href); + const mimeType = mime.getType(pathname); + + if (!mimeType) return acc; + + const [baseType, _subType] = mimeType.split('/'); + + if (['audio', 'image', 'video'].includes(baseType)) { + acc.push({ + url: link.href, + mimeType, + }); + } + + return acc; + }, []); +} + +export { getMediaLinks, type MediaLink, parseNoteContent }; diff --git a/src/schema.ts b/src/schema.ts index e186a94..7cdd393 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -29,7 +29,7 @@ type MetaContent = z.infer; * Get (and validate) data from a kind 0 event. * https://github.com/nostr-protocol/nips/blob/master/01.md */ -function parseContent(event: Event<0>): MetaContent { +function parseMetaContent(event: Event<0>): MetaContent { try { const json = JSON.parse(event.content); return metaContentSchema.parse(json); @@ -38,8 +38,6 @@ function parseContent(event: Event<0>): MetaContent { } } -export { type MetaContent, metaContentSchema, parseContent }; - /** Alias for `safeParse`, but instead of returning a success object it returns the value (or undefined on fail). */ function parseValue(schema: z.ZodType, value: unknown): T | undefined { const result = schema.safeParse(value); @@ -58,4 +56,4 @@ const relaySchema = z.custom((relay) => { } }); -export { jsonSchema, parseRelay, relaySchema }; +export { jsonSchema, type MetaContent, metaContentSchema, parseMetaContent, parseRelay, relaySchema }; diff --git a/src/transmute.ts b/src/transmute.ts index 1ab248e..70ecb20 100644 --- a/src/transmute.ts +++ b/src/transmute.ts @@ -1,9 +1,10 @@ -import { findReplyTag, lodash, nip19 } from '@/deps.ts'; +import { findReplyTag, lodash, nip19, z } from '@/deps.ts'; import { type Event } from '@/event.ts'; -import { type MetaContent, parseContent } from '@/schema.ts'; +import { type MetaContent, parseMetaContent } from '@/schema.ts'; import { LOCAL_DOMAIN } from './config.ts'; import { getAuthor } from './client.ts'; +import { getMediaLinks, type MediaLink, parseNoteContent } from './note.ts'; import { type Nip05, parseNip05 } from './utils.ts'; const DEFAULT_AVATAR = 'https://gleasonator.com/images/avi.png'; @@ -17,7 +18,7 @@ function toAccount(event: Event<0>, opts: ToAccountOpts = {}) { const { withSource = false } = opts; const { pubkey } = event; - const { name, nip05, picture, banner, about }: MetaContent = parseContent(event); + const { name, nip05, picture, banner, about }: MetaContent = parseMetaContent(event); const { origin } = new URL(LOCAL_DOMAIN); const npub = nip19.npubEncode(pubkey); @@ -100,10 +101,13 @@ async function toStatus(event: Event<1>) { ), ]; + const { html, links } = parseNoteContent(event.content); + const mediaLinks = getMediaLinks(links); + return { id: event.id, account, - content: lodash.escape(event.content), + content: html, created_at: new Date(event.created_at * 1000).toISOString(), in_reply_to_id: replyTag ? replyTag[1] : null, in_reply_to_account_id: null, @@ -120,7 +124,7 @@ async function toStatus(event: Event<1>) { bookmarked: false, reblog: null, application: null, - media_attachments: [], + media_attachments: mediaLinks.map(renderAttachment), mentions: await Promise.all(mentionedPubkeys.map(toMention)), tags: [], emojis: [], @@ -131,4 +135,22 @@ async function toStatus(event: Event<1>) { }; } +const attachmentTypeSchema = z.enum(['image', 'video', 'gifv', 'audio', 'unknown']).catch('unknown'); + +function renderAttachment({ url, mimeType }: MediaLink) { + const [baseType, _subType] = mimeType.split('/'); + const type = attachmentTypeSchema.parse(baseType); + + return { + id: url, + type, + url, + preview_url: url, + remote_url: null, + meta: {}, + description: '', + blurhash: null, + }; +} + export { toAccount, toStatus };