import { DittoConf } from '@ditto/conf'; import 'linkify-plugin-hashtag'; import linkifyStr from 'linkify-string'; import linkify from 'linkifyjs'; import { nip19, nip27 } from 'nostr-tools'; import { MastodonMention } from '@/entities/MastodonMention.ts'; import { html } from './html.ts'; import { getUrlMediaType, isPermittedMediaType } from './media.ts'; linkify.registerCustomProtocol('nostr', true); linkify.registerCustomProtocol('wss'); type Link = ReturnType[0]; interface ParsedNoteContent { html: string; links: Link[]; /** First non-media URL - eligible for a preview card. */ firstUrl: string | undefined; } /** Convert Nostr content to Mastodon API HTML. Also return parsed data. */ function parseNoteContent(conf: DittoConf, content: string, mentions: MastodonMention[]): ParsedNoteContent { const links = linkify.find(content).filter(({ type }) => type === 'url'); const firstUrl = links.find(isNonMediaLink)?.href; const result = linkifyStr(content, { render: { hashtag: ({ content }) => { const tag = content.replace(/^#/, ''); const href = conf.local(`/tags/${tag}`); return html``; }, url: ({ attributes, content }) => { try { const { protocol, pathname } = new URL(content); if (protocol === 'nostr:') { const match = pathname.match(new RegExp(`^${nip19.BECH32_REGEX.source}`)); if (match) { const bech32 = match[0]; const extra = pathname.slice(bech32.length); const decoded = nip19.decode(bech32); const pubkey = getDecodedPubkey(decoded); if (pubkey) { const mention = mentions.find((m) => m.id === pubkey); const npub = nip19.npubEncode(pubkey); const acct = mention?.acct ?? npub; const name = mention?.acct ?? npub.substring(0, 8); const href = mention?.url ?? conf.local(`/@${acct}`); return html`@${name}${extra}`; } else { return ''; } } else { return content; } } } catch { // fallthrough } const attr = Object.entries(attributes) .map(([name, value]) => `${name}="${value}"`) .join(' '); return `${content}`; }, }, }).replace(/\n+$/, ''); return { html: result, links, firstUrl, }; } /** Remove imeta links. */ function stripimeta(content: string, tags: string[][]): string { const imeta = tags.filter(([name]) => name === 'imeta'); if (!imeta.length) { return content; } const urls = new Set( imeta.map(([, ...values]) => values.map((v) => v.split(' ')).find(([name]) => name === 'url')?.[1]), ); const lines = content.split('\n').reverse(); for (const line of [...lines]) { if (line === '' || urls.has(line)) { lines.splice(0, 1); } else { break; } } return lines.reverse().join('\n'); } /** Returns a matrix of tags. Each item is a list of NIP-94 tags representing a file. */ function getMediaLinks(links: Pick[]): string[][][] { return links.reduce((acc, link) => { const mediaType = getUrlMediaType(link.href); if (!mediaType) return acc; if (isPermittedMediaType(mediaType, ['audio', 'image', 'video'])) { acc.push([ ['url', link.href], ['m', mediaType], ]); } return acc; }, []); } function isNonMediaLink({ href }: Link): boolean { return /^https?:\/\//.test(href) && !getUrlMediaType(href); } /** 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; } } /** Find a quote in the content. */ function findQuoteInContent(content: string): string | undefined { try { for (const { decoded } of nip27.matchAll(content)) { switch (decoded.type) { case 'note': return decoded.data; case 'nevent': return decoded.data.id; } } } catch (_) { // do nothing } } export { findQuoteInContent, getMediaLinks, parseNoteContent, stripimeta };