mirror of
https://gitlab.com/soapbox-pub/ditto.git
synced 2025-12-06 11:29:46 +00:00
157 lines
4.6 KiB
TypeScript
157 lines
4.6 KiB
TypeScript
import 'linkify-plugin-hashtag';
|
|
import linkifyStr from 'linkify-string';
|
|
import linkify from 'linkifyjs';
|
|
import { nip19, nip27 } from 'nostr-tools';
|
|
|
|
import { Conf } from '@/config.ts';
|
|
import { MastodonMention } from '@/entities/MastodonMention.ts';
|
|
import { html } from '@/utils/html.ts';
|
|
import { getUrlMediaType, isPermittedMediaType } from '@/utils/media.ts';
|
|
|
|
linkify.registerCustomProtocol('nostr', true);
|
|
linkify.registerCustomProtocol('wss');
|
|
|
|
type Link = ReturnType<typeof linkify.find>[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(content: string, mentions: MastodonMention[]): ParsedNoteContent {
|
|
const links = linkify.find(content).filter(isLinkURL);
|
|
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`<a class="mention hashtag" href="${href}" rel="tag"><span>#</span>${tag}</a>`;
|
|
},
|
|
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`<span class="h-card"><a class="u-url mention" href="${href}" rel="ugc">@<span>${name}</span></a></span>${extra}`;
|
|
} else {
|
|
return '';
|
|
}
|
|
} else {
|
|
return content;
|
|
}
|
|
}
|
|
} catch {
|
|
// fallthrough
|
|
}
|
|
|
|
const attr = Object.entries(attributes)
|
|
.map(([name, value]) => `${name}="${value}"`)
|
|
.join(' ');
|
|
|
|
return `<a ${attr}>${content}</a>`;
|
|
},
|
|
},
|
|
}).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<Link, 'href'>[]): string[][][] {
|
|
return links.reduce<string[][][]>((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);
|
|
}
|
|
|
|
/** Ensures the Link is a URL so it can be parsed. */
|
|
function isLinkURL(link: Link): boolean {
|
|
return link.type === 'url';
|
|
}
|
|
|
|
/** 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 };
|