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

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) || ''
}