First pass linkifying status content and rendering attachments
This commit is contained in:
parent
3d5ea61250
commit
2fff0ed879
|
@ -24,3 +24,8 @@ export { findReplyTag } from 'https://gitlab.com/soapbox-pub/mostr/-/raw/c67064a
|
||||||
export { parseFormData } from 'npm:formdata-helper@^0.3.0';
|
export { parseFormData } from 'npm:formdata-helper@^0.3.0';
|
||||||
// @deno-types="npm:@types/lodash@4.14.194"
|
// @deno-types="npm:@types/lodash@4.14.194"
|
||||||
export { default as lodash } from 'https://esm.sh/lodash@4.17.21';
|
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';
|
||||||
|
|
|
@ -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 `<a class=\"mention hashtag\" href=\"${href}\" rel=\"tag\"><span>#</span>${tag}</a>`;
|
||||||
|
},
|
||||||
|
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 `<span class="h-card"><a class="u-url mention" href="${href}" rel="ugc">@<span>${name}</span></a></span>`;
|
||||||
|
} else {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return `<a href="${content}">${content}</a>`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
type Link = ReturnType<typeof linkify.find>[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<MediaLink[]>((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 };
|
|
@ -29,7 +29,7 @@ type MetaContent = z.infer<typeof metaContentSchema>;
|
||||||
* Get (and validate) data from a kind 0 event.
|
* Get (and validate) data from a kind 0 event.
|
||||||
* https://github.com/nostr-protocol/nips/blob/master/01.md
|
* https://github.com/nostr-protocol/nips/blob/master/01.md
|
||||||
*/
|
*/
|
||||||
function parseContent(event: Event<0>): MetaContent {
|
function parseMetaContent(event: Event<0>): MetaContent {
|
||||||
try {
|
try {
|
||||||
const json = JSON.parse(event.content);
|
const json = JSON.parse(event.content);
|
||||||
return metaContentSchema.parse(json);
|
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). */
|
/** Alias for `safeParse`, but instead of returning a success object it returns the value (or undefined on fail). */
|
||||||
function parseValue<T>(schema: z.ZodType<T>, value: unknown): T | undefined {
|
function parseValue<T>(schema: z.ZodType<T>, value: unknown): T | undefined {
|
||||||
const result = schema.safeParse(value);
|
const result = schema.safeParse(value);
|
||||||
|
@ -58,4 +56,4 @@ const relaySchema = z.custom<URL>((relay) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export { jsonSchema, parseRelay, relaySchema };
|
export { jsonSchema, type MetaContent, metaContentSchema, parseMetaContent, parseRelay, relaySchema };
|
||||||
|
|
|
@ -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 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 { LOCAL_DOMAIN } from './config.ts';
|
||||||
import { getAuthor } from './client.ts';
|
import { getAuthor } from './client.ts';
|
||||||
|
import { getMediaLinks, type MediaLink, parseNoteContent } from './note.ts';
|
||||||
import { type Nip05, parseNip05 } from './utils.ts';
|
import { type Nip05, parseNip05 } from './utils.ts';
|
||||||
|
|
||||||
const DEFAULT_AVATAR = 'https://gleasonator.com/images/avi.png';
|
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 { withSource = false } = opts;
|
||||||
|
|
||||||
const { pubkey } = event;
|
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 { origin } = new URL(LOCAL_DOMAIN);
|
||||||
const npub = nip19.npubEncode(pubkey);
|
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 {
|
return {
|
||||||
id: event.id,
|
id: event.id,
|
||||||
account,
|
account,
|
||||||
content: lodash.escape(event.content),
|
content: html,
|
||||||
created_at: new Date(event.created_at * 1000).toISOString(),
|
created_at: new Date(event.created_at * 1000).toISOString(),
|
||||||
in_reply_to_id: replyTag ? replyTag[1] : null,
|
in_reply_to_id: replyTag ? replyTag[1] : null,
|
||||||
in_reply_to_account_id: null,
|
in_reply_to_account_id: null,
|
||||||
|
@ -120,7 +124,7 @@ async function toStatus(event: Event<1>) {
|
||||||
bookmarked: false,
|
bookmarked: false,
|
||||||
reblog: null,
|
reblog: null,
|
||||||
application: null,
|
application: null,
|
||||||
media_attachments: [],
|
media_attachments: mediaLinks.map(renderAttachment),
|
||||||
mentions: await Promise.all(mentionedPubkeys.map(toMention)),
|
mentions: await Promise.all(mentionedPubkeys.map(toMention)),
|
||||||
tags: [],
|
tags: [],
|
||||||
emojis: [],
|
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 };
|
export { toAccount, toStatus };
|
||||||
|
|
Loading…
Reference in New Issue