You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
307 lines
8.3 KiB
307 lines
8.3 KiB
import type { NDKTag } from '@nostr-dev-kit/ndk' |
|
import { nip19 } from 'nostr-tools' |
|
import { last } from 'ramda' |
|
|
|
export const TOPIC = 'topic' |
|
export const LINKCOLLECTION = 'link[]' |
|
export const HTML = 'html' |
|
export const INVOICE = 'invoice' |
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any |
|
const first = (list: any) => (list ? list[0] : undefined) |
|
|
|
export const fromNostrURI = (s: string) => s.replace(/^[\w+]+:\/?\/?/, '') |
|
|
|
export const urlIsMedia = (url: string): boolean => |
|
(!url.match(/\.(apk|docx|xlsx|csv|dmg)/) && |
|
last(url.split('://'))?.includes('/')) || |
|
false |
|
|
|
export const isImage = (url: string) => |
|
url.match(/^.*\.(jpg|jpeg|png|webp|gif|avif|svg)/gi) |
|
export const isVideo = (url: string) => |
|
url.match(/^.*\.(mov|mkv|mp4|avi|m4v|webm)/gi) |
|
export const isAudio = (url: string) => url.match(/^.*\.(ogg|mp3|wav)/gi) |
|
|
|
export const NEWLINE = 'newline' |
|
type PartTypeNewLine = 'newline' |
|
export type ParsedNewLine = { |
|
type: PartTypeNewLine |
|
value: string |
|
} |
|
|
|
export const LINK = 'link' |
|
type PartTypeLink = 'link' |
|
export type ParsedLink = { |
|
type: PartTypeLink |
|
url: string |
|
is_media: boolean |
|
imeta: Imeta | undefined |
|
} |
|
type Imeta = { |
|
url: string |
|
m: string | undefined |
|
alt: string | undefined |
|
size: string | undefined |
|
dim: string | undefined |
|
x: string | undefined |
|
fallback: string[] |
|
blurhash: string | undefined |
|
} |
|
|
|
export const NOSTR_NPUB = 'nostr:npub' |
|
type PartTypeNpub = 'nostr:npub' |
|
export type ParsedNpub = { |
|
type: PartTypeNpub |
|
hex: string |
|
} |
|
export const NOSTR_NPROFILE = 'nostr:nprofile' |
|
type PartTypeNprofile = 'nostr:nprofile' |
|
export type ParsedNprofile = { |
|
type: PartTypeNprofile |
|
hex: string |
|
relays: string[] |
|
} |
|
|
|
export const NOSTR_NOTE = 'nostr:note' |
|
type PartTypeNote = 'nostr:note' |
|
export type ParsedNote = { |
|
type: PartTypeNote |
|
id: string |
|
relays: string[] |
|
} |
|
|
|
export const NOSTR_NEVENT = 'nostr:nevent' |
|
type PartTypeNevent = 'nostr:nevent' |
|
export type ParsedNevent = { |
|
type: PartTypeNevent |
|
id: string |
|
relays: string[] |
|
} |
|
|
|
|
|
export const NOSTR_NADDR = 'nostr:naddr' |
|
type PartTypeNaddr = 'nostr:naddr' |
|
export type ParsedNaddr = { |
|
type: PartTypeNaddr |
|
identifier: string |
|
pubkey: string |
|
kind: number |
|
relays: string[] |
|
} |
|
|
|
|
|
export type ParsedNostrLink = ParsedNpub | ParsedNprofile | ParsedNevent | ParsedNote | ParsedNaddr |
|
|
|
export const TEXT = 'text' |
|
type PartTypeText = 'text' |
|
export type ParsedText = { |
|
type: PartTypeText |
|
value: string |
|
} |
|
|
|
export type ParsedPart = |
|
| ParsedNewLine |
|
| ParsedText |
|
| ParsedNostrLink |
|
| ParsedLink |
|
|
|
export const isParsedNewLine = (part: ParsedPart): part is ParsedNewLine => |
|
part.type == NEWLINE |
|
|
|
export const isParsedLink = (part: ParsedPart): part is ParsedLink => |
|
part.type == LINK |
|
|
|
export const isParsedNostrLink = (part: ParsedPart): part is ParsedNostrLink => |
|
part.type == NOSTR_NPUB || part.type == NOSTR_NPROFILE || part.type == NOSTR_NEVENT || part.type == NOSTR_NOTE || part.type == NOSTR_NADDR |
|
|
|
export const isParsedNpub = (part: ParsedPart): part is ParsedNpub => |
|
part.type == NOSTR_NPUB |
|
|
|
export const isParsedNprofile = (part: ParsedPart): part is ParsedNprofile => |
|
part.type == NOSTR_NPROFILE |
|
|
|
export const isParsedNevent = (part: ParsedPart): part is ParsedNevent => |
|
part.type == NOSTR_NEVENT |
|
|
|
export const isParsedNote = (part: ParsedPart): part is ParsedNote => |
|
part.type == NOSTR_NOTE |
|
|
|
export const isParsedNaddr = (part: ParsedPart): part is ParsedNaddr => |
|
part.type == NOSTR_NADDR |
|
|
|
export const isParsedText = (part: ParsedPart): part is ParsedText => |
|
part.type == TEXT |
|
|
|
export const parseContent = (content: string, tags: NDKTag[]): ParsedPart[] => { |
|
const result: ParsedPart[] = [] |
|
let text = content.trim() |
|
let buffer = '' |
|
|
|
const getIMeta = (url: string): undefined | Imeta => { |
|
const imeta_tag_for_url = tags.find( |
|
(tag) => tag[0] === 'imeta' && tag.some((e) => e.includes(url)) |
|
) |
|
if (!imeta_tag_for_url) return undefined |
|
const pairs = imeta_tag_for_url.map((s) => [ |
|
s.split(' ')[0], |
|
s.substring(s.indexOf(' ') + 1), |
|
]) |
|
return { |
|
url, |
|
m: pairs.find((p) => p[0] === 'm')?.[1], |
|
alt: pairs.find((p) => p[0] === 'alt')?.[1], |
|
x: pairs.find((p) => p[0] === 'x')?.[1], |
|
size: pairs.find((p) => p[0] === 'size')?.[1], |
|
dim: pairs.find((p) => p[0] === 'dim')?.[1], |
|
blurhash: pairs.find((p) => p[0] === 'blurhash')?.[1], |
|
fallback: pairs.filter((p) => p[0] === 'fallback')?.map((p) => p[1]), |
|
} |
|
} |
|
|
|
const parseNewline = (): undefined | [string, ParsedNewLine] => { |
|
const newline: string = first(text.match(/^\n+/)) |
|
|
|
if (newline) { |
|
return [newline, { type: NEWLINE, value: newline }] |
|
} |
|
} |
|
|
|
const parseUrl = (): undefined | [string, ParsedLink] => { |
|
const raw: string = 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) { |
|
return |
|
} |
|
|
|
const prev = 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 [ |
|
raw, |
|
{ type: LINK, url, is_media: urlIsMedia(url), imeta: getIMeta(url) }, |
|
] |
|
} |
|
|
|
const parseNostrLinks = (): undefined | [string, ParsedNostrLink] => { |
|
const bech32: string = first( |
|
text.match( |
|
/^(web\+)?(nostr:)?\/?\/?n(event|ote|profile|pub|addr)1[\d\w]+/i |
|
) |
|
) |
|
if (bech32) { |
|
try { |
|
const entity = fromNostrURI(bech32) |
|
const decoded = nip19.decode(entity) |
|
if (decoded.type === 'npub') { |
|
return [bech32, { type: NOSTR_NPUB, hex: decoded.data }] |
|
} |
|
if (decoded.type === 'nprofile') { |
|
return [bech32, { type: NOSTR_NPUB, hex: decoded.data.pubkey }] |
|
} |
|
if (decoded.type === 'note') { |
|
return [bech32, { type: NOSTR_NOTE, id: decoded.data, relays: [] }] |
|
} |
|
if (decoded.type === 'nevent') { |
|
return [bech32, { type: NOSTR_NEVENT, id: decoded.data.id, relays: decoded.data.relays || [] }] |
|
} |
|
if (decoded.type === 'naddr') { |
|
return [bech32, { ...decoded.data, type: NOSTR_NADDR, relays: decoded.data.relays || [] }] |
|
} |
|
} catch {} |
|
} |
|
} |
|
|
|
while (text) { |
|
// The order that this runs matters |
|
const part = parseNewline() || parseUrl() || parseNostrLinks() |
|
|
|
if (part) { |
|
if (buffer) { |
|
result.push({ type: TEXT, value: buffer }) |
|
buffer = '' |
|
} |
|
|
|
const [raw, parsed] = part |
|
|
|
result.push(parsed) |
|
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 isCoverLetter = (s: string): boolean => { |
|
return s.indexOf('PATCH 0/') > 0 |
|
} |
|
|
|
/** this doesn't work for all patch formats and options */ |
|
export const extractPatchMessage = (s: string): string | undefined => { |
|
try { |
|
if (isCoverLetter(s)) { |
|
return s.substring(s.indexOf('] ') + 2) |
|
} |
|
const t = s.split('\nSubject: [')[1].split('] ')[1] |
|
if (t.split('\n\n---\n ').length > 1) return t.split('\n\n---\n ')[0] |
|
return t.split('\n\ndiff --git ')[0].split('\n\n ').slice(0, -1).join('') |
|
} catch { |
|
return undefined |
|
} |
|
} |
|
|
|
/** this doesn't work for all patch formats and options */ |
|
export const extractPatchTitle = (s: string): string | undefined => { |
|
const msg = extractPatchMessage(s) |
|
if (!msg) return undefined |
|
return s.split('\n')[0] |
|
} |
|
|
|
/** patch message without first line */ |
|
export const extractPatchDescription = (s: string): string | undefined => { |
|
const msg = extractPatchMessage(s) |
|
if (!msg) return '' |
|
const i = msg.indexOf('\n') |
|
if (i === -1) return '' |
|
return msg.substring(i).trim() |
|
} |
|
|
|
export const extractIssueTitle = (s: string): string => { |
|
return s.split('\n')[0] || '' |
|
} |
|
|
|
export const extractIssueDescription = (s: string): string => { |
|
const split = s.split('\n') |
|
if (split.length === 0) return '' |
|
return s.substring(split[0].length) || '' |
|
}
|
|
|