import 'linkify-plugin-hashtag';
import linkifyStr from 'linkify-string';
import linkify from 'linkifyjs';
import { nip19, nip21, nip27 } from 'nostr-tools';
import { Conf } from '@/config.ts';
import { getUrlMediaType, isPermittedMediaType } from '@/utils/media.ts';
linkify.registerCustomProtocol('nostr', true);
linkify.registerCustomProtocol('wss');
const linkifyOpts: linkify.Opts = {
render: {
hashtag: ({ content }) => {
const tag = content.replace(/^#/, '');
const href = Conf.local(`/tags/${tag}`);
return `#${tag}`;
},
url: ({ attributes, content }) => {
try {
const { decoded } = nip21.parse(content);
const pubkey = getDecodedPubkey(decoded);
if (pubkey) {
const name = pubkey.substring(0, 8);
const href = Conf.local(`/users/${pubkey}`);
return `@${name}`;
} else {
return '';
}
} catch {
const attr = Object.entries(attributes)
.map(([name, value]) => `${name}="${value}"`)
.join(' ');
return `${content}`;
}
},
},
};
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(content: string): ParsedNoteContent {
// Parsing twice is ineffecient, but I don't know how to do only once.
const html = linkifyStr(content, linkifyOpts).replace(/\n+$/, '');
const links = linkify.find(content).filter(isLinkURL);
const firstUrl = links.find(isNonMediaLink)?.href;
return {
html,
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);
}
/** 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 };