239 lines
5.4 KiB
TypeScript
239 lines
5.4 KiB
TypeScript
import { last, pluck, identity } from "ramda";
|
|
import { nip19 } from "nostr-tools";
|
|
|
|
export const NEWLINE = "newline";
|
|
export const TEXT = "text";
|
|
export const TOPIC = "topic";
|
|
export const LINK = "link";
|
|
export const INVOICE = "invoice";
|
|
export const NOSTR_NOTE = "nostr:note";
|
|
export const NOSTR_NEVENT = "nostr:nevent";
|
|
export const NOSTR_NPUB = "nostr:npub";
|
|
export const NOSTR_NPROFILE = "nostr:nprofile";
|
|
export const NOSTR_NADDR = "nostr:naddr";
|
|
|
|
function first(list: any) {
|
|
return list ? list[0] : undefined;
|
|
}
|
|
|
|
export const fromNostrURI = (s: string) => s.replace(/^[\w\+]+:\/?\/?/, "");
|
|
|
|
export const urlIsMedia = (url: string) =>
|
|
!url.match(/\.(apk|docx|xlsx|csv|dmg)/) &&
|
|
last(url.split("://"))?.includes("/");
|
|
|
|
export const parseContent = ({
|
|
content,
|
|
tags = [],
|
|
}: {
|
|
content: string;
|
|
tags: any[];
|
|
}): { type: string; value: string }[] => {
|
|
const result: { type: string; value: string }[] = [];
|
|
|
|
let text = content.trim();
|
|
let buffer = "";
|
|
|
|
const parseNewline = () => {
|
|
const newline = first(text.match(/^\n+/));
|
|
|
|
if (newline) {
|
|
return [NEWLINE, newline, newline];
|
|
}
|
|
};
|
|
|
|
const parseMention = () => {
|
|
// Convert legacy mentions to bech32 entities
|
|
const mentionMatch = text.match(/^#\[(\d+)\]/i);
|
|
|
|
if (mentionMatch) {
|
|
const i = parseInt(mentionMatch[1]);
|
|
|
|
if (tags[i]) {
|
|
const [tag, value, url] = tags[i];
|
|
const relays = [url].filter(identity);
|
|
|
|
let type, data, entity;
|
|
if (tag === "p") {
|
|
type = "nprofile";
|
|
data = { pubkey: value, relays };
|
|
entity = nip19.nprofileEncode(data);
|
|
} else {
|
|
type = "nevent";
|
|
data = { id: value, relays };
|
|
entity = nip19.neventEncode(data);
|
|
}
|
|
|
|
return [`nostr:${type}`, mentionMatch[0], { ...data, entity }];
|
|
}
|
|
}
|
|
};
|
|
|
|
const parseTopic = () => {
|
|
const topic = first(text.match(/^#\w+/i));
|
|
|
|
// Skip numeric topics
|
|
if (topic && !topic.match(/^#\d+$/)) {
|
|
return [TOPIC, topic, topic.slice(1)];
|
|
}
|
|
};
|
|
|
|
const parseBech32 = () => {
|
|
const bech32 = first(
|
|
text.match(
|
|
/^(web\+)?(nostr:)?\/?\/?n(event|ote|profile|pub|addr)1[\d\w]+/i,
|
|
),
|
|
);
|
|
|
|
if (bech32) {
|
|
try {
|
|
const entity = fromNostrURI(bech32);
|
|
const { type, data } = nip19.decode(entity) as {
|
|
type: string;
|
|
data: object;
|
|
};
|
|
|
|
let value = data;
|
|
if (type === "note") {
|
|
value = { id: data };
|
|
} else if (type === "npub") {
|
|
value = { pubkey: data };
|
|
}
|
|
|
|
return [`nostr:${type}`, bech32, { ...value, entity }];
|
|
} catch (e) {
|
|
console.log(e);
|
|
// pass
|
|
}
|
|
}
|
|
};
|
|
|
|
const parseInvoice = () => {
|
|
const invoice = first(text.match(/^ln(bc|url)[\d\w]{50,1000}/i));
|
|
|
|
if (invoice) {
|
|
return [INVOICE, invoice, invoice];
|
|
}
|
|
};
|
|
|
|
const parseUrl = () => {
|
|
const raw = first(
|
|
text.match(
|
|
/^([a-z\+:]{2,30}:\/\/)?[^\s]+\.[a-z]{2,6}[^\s]*[^\.!?,:\s]/gi,
|
|
),
|
|
);
|
|
|
|
// Skip url if it's just the end of a filepath
|
|
if (raw) {
|
|
const prev: any = last(result);
|
|
|
|
if (prev?.type === "text" && prev.value.endsWith("/")) {
|
|
return;
|
|
}
|
|
|
|
let url = raw;
|
|
|
|
// Skip ellipses and very short non-urls
|
|
if (url.match(/\.\./)) {
|
|
return;
|
|
}
|
|
|
|
if (!url.match("://")) {
|
|
url = "https://" + url;
|
|
}
|
|
|
|
return [LINK, raw, { url, isMedia: urlIsMedia(url) }];
|
|
}
|
|
};
|
|
|
|
while (text) {
|
|
const part =
|
|
parseNewline() ||
|
|
parseMention() ||
|
|
parseTopic() ||
|
|
parseBech32() ||
|
|
parseUrl() ||
|
|
parseInvoice();
|
|
|
|
if (part) {
|
|
if (buffer) {
|
|
result.push({ type: "text", value: buffer });
|
|
buffer = "";
|
|
}
|
|
|
|
const [type, raw, value] = part;
|
|
|
|
result.push({ type, value });
|
|
text = text.slice(raw.length);
|
|
} else {
|
|
// Instead of going character by character and re-running all the above regular expressions
|
|
// a million times, try to match the next word and add it to the buffer
|
|
const match = first(text.match(/^[\w\d]+ ?/i)) || text[0];
|
|
|
|
buffer += match;
|
|
text = text.slice(match.length);
|
|
}
|
|
}
|
|
|
|
if (buffer) {
|
|
result.push({ type: TEXT, value: buffer });
|
|
}
|
|
|
|
return result;
|
|
};
|
|
|
|
export const truncateContent = (
|
|
content: { value: string; type: string; isMedia: boolean }[],
|
|
{
|
|
showEntire,
|
|
maxLength,
|
|
showMedia = false,
|
|
}: { showEntire: boolean; maxLength: number; showMedia: boolean },
|
|
) => {
|
|
if (showEntire) {
|
|
return content;
|
|
}
|
|
|
|
let length = 0;
|
|
const result: any[] = [];
|
|
const truncateAt = maxLength * 0.6;
|
|
|
|
content.every((part, i) => {
|
|
const isText =
|
|
[TOPIC, TEXT].includes(part.type) ||
|
|
(part.type === LINK && !part.isMedia);
|
|
const isMedia =
|
|
part.type === INVOICE || part.type.startsWith("nostr:") || part.isMedia;
|
|
|
|
if (isText) {
|
|
length += part.value.length;
|
|
}
|
|
|
|
if (isMedia) {
|
|
length += showMedia ? maxLength / 3 : part.value.length;
|
|
}
|
|
|
|
result.push(part);
|
|
|
|
if (length > truncateAt && i < content.length - 1) {
|
|
if (isText || (isMedia && !showMedia)) {
|
|
result.push({ type: TEXT, value: "..." });
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
});
|
|
|
|
return result;
|
|
};
|
|
|
|
export const getLinks = (
|
|
parts: { value: string; type: string; isMedia: boolean }[],
|
|
) =>
|
|
pluck(
|
|
"value",
|
|
parts.filter((x) => x.type === LINK && x.isMedia),
|
|
);
|