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 };