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.
 
 
 

338 lines
9.6 KiB

import { EMBEDDED_MENTION_REGEX, ExtendedKind } from '@/constants'
import client from '@/services/client.service'
import { TImetaInfo } from '@/types'
import { LRUCache } from 'lru-cache'
import { Event, kinds, nip19, UnsignedEvent } from 'nostr-tools'
import { fastEventHash, getPow } from 'nostr-tools/nip13'
import {
generateBech32IdFromATag,
generateBech32IdFromETag,
getImetaInfoFromImetaTag,
tagNameEquals
} from './tag'
const EVENT_EMBEDDED_NOTES_CACHE = new LRUCache<string, string[]>({ max: 10000 })
const EVENT_EMBEDDED_PUBKEYS_CACHE = new LRUCache<string, string[]>({ max: 10000 })
const EVENT_IS_REPLY_NOTE_CACHE = new LRUCache<string, boolean>({ max: 10000 })
export function isNsfwEvent(event: Event) {
return event.tags.some(
([tagName, tagValue]) =>
tagName === 'content-warning' || (tagName === 't' && tagValue.toLowerCase() === 'nsfw')
)
}
export function isReplyNoteEvent(event: Event) {
if ([ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT, 1111].includes(event.kind)) {
return true
}
// Zap receipts are considered replies if they have an 'e' tag (zapping a note) or 'a' tag (zapping an addressable event)
if (event.kind === kinds.Zap) {
return event.tags.some(tag => tag[0] === 'e' || tag[0] === 'a')
}
if (event.kind !== kinds.ShortTextNote) return false
const cache = EVENT_IS_REPLY_NOTE_CACHE.get(event.id)
if (cache !== undefined) return cache
const isReply = !!getParentETag(event) || !!getParentATag(event)
EVENT_IS_REPLY_NOTE_CACHE.set(event.id, isReply)
return isReply
}
export function isReplaceableEvent(kind: number) {
return kinds.isReplaceableKind(kind) || kinds.isAddressableKind(kind)
}
export function isPictureEvent(event: Event) {
return event.kind === ExtendedKind.PICTURE
}
export function isProtectedEvent(event: Event) {
return event.tags.some(([tagName]) => tagName === '-')
}
export function isMentioningMutedUsers(event: Event, mutePubkeySet: Set<string>) {
for (const [tagName, pubkey] of event.tags) {
if (tagName === 'p' && mutePubkeySet.has(pubkey)) {
return true
}
}
return false
}
export function getParentETag(event?: Event) {
if (!event) return undefined
if (event.kind === ExtendedKind.COMMENT || event.kind === ExtendedKind.VOICE_COMMENT) {
return event.tags.find(tagNameEquals('e')) ?? event.tags.find(tagNameEquals('E'))
}
// Handle DISCUSSION events (kind 11) - they use e tag for parent reference
if (event.kind === ExtendedKind.DISCUSSION) {
return event.tags.find(tagNameEquals('e')) ?? event.tags.find(tagNameEquals('E'))
}
if (event.kind !== kinds.ShortTextNote) return undefined
let tag = event.tags.find(([tagName, , , marker]) => {
return tagName === 'e' && marker === 'reply'
})
if (!tag) {
const embeddedEventIds = getEmbeddedNoteBech32Ids(event)
tag = event.tags.findLast(
([tagName, tagValue, , marker]) =>
tagName === 'e' &&
!!tagValue &&
marker !== 'mention' &&
!embeddedEventIds.includes(tagValue)
)
}
return tag
}
export function getParentATag(event?: Event) {
if (
!event ||
![kinds.ShortTextNote, ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT, ExtendedKind.DISCUSSION].includes(event.kind)
) {
return undefined
}
return event.tags.find(tagNameEquals('a')) ?? event.tags.find(tagNameEquals('A'))
}
export function getParentEventHexId(event?: Event) {
const tag = getParentETag(event)
return tag?.[1]
}
export function getParentBech32Id(event?: Event) {
const eTag = getParentETag(event)
if (!eTag) {
const aTag = getParentATag(event)
if (!aTag) return undefined
return generateBech32IdFromATag(aTag)
}
return generateBech32IdFromETag(eTag)
}
export function getRootETag(event?: Event) {
if (!event) return undefined
if (event.kind === ExtendedKind.COMMENT || event.kind === ExtendedKind.VOICE_COMMENT) {
return event.tags.find(tagNameEquals('E'))
}
// Handle DISCUSSION events (kind 11) - they use E tag for root reference
if (event.kind === ExtendedKind.DISCUSSION) {
return event.tags.find(tagNameEquals('E'))
}
if (event.kind !== kinds.ShortTextNote) return undefined
let tag = event.tags.find(([tagName, , , marker]) => {
return tagName === 'e' && marker === 'root'
})
if (!tag) {
const embeddedEventIds = getEmbeddedNoteBech32Ids(event)
tag = event.tags.find(
([tagName, tagValue]) => tagName === 'e' && !!tagValue && !embeddedEventIds.includes(tagValue)
)
}
return tag
}
export function getRootATag(event?: Event) {
if (
!event ||
![kinds.ShortTextNote, ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT, ExtendedKind.DISCUSSION].includes(event.kind)
) {
return undefined
}
return event.tags.find(tagNameEquals('A'))
}
export function getRootEventHexId(event?: Event) {
const tag = getRootETag(event)
return tag?.[1]
}
export function getRootBech32Id(event?: Event) {
const eTag = getRootETag(event)
if (!eTag) {
const aTag = getRootATag(event)
if (!aTag) return undefined
return generateBech32IdFromATag(aTag)
}
return generateBech32IdFromETag(eTag)
}
export function getReplaceableCoordinate(kind: number, pubkey: string, d: string = '') {
return `${kind}:${pubkey}:${d}`
}
export function getReplaceableCoordinateFromEvent(event: Event) {
const d = event.tags.find(tagNameEquals('d'))?.[1]
return getReplaceableCoordinate(event.kind, event.pubkey, d)
}
export function getNoteBech32Id(event: Event) {
const hints = client.getEventHints(event.id).slice(0, 2)
if (isReplaceableEvent(event.kind)) {
const identifier = event.tags.find(tagNameEquals('d'))?.[1] ?? ''
return nip19.naddrEncode({ pubkey: event.pubkey, kind: event.kind, identifier, relays: hints })
}
return nip19.neventEncode({ id: event.id, author: event.pubkey, kind: event.kind, relays: hints })
}
export function getUsingClient(event: Event) {
return event.tags.find(tagNameEquals('client'))?.[1]
}
export function getImetaInfosFromEvent(event: Event) {
const imeta: TImetaInfo[] = []
event.tags.forEach((tag) => {
const imageInfo = getImetaInfoFromImetaTag(tag, event.pubkey)
if (imageInfo) {
imeta.push(imageInfo)
}
})
return imeta
}
export function getEmbeddedNoteBech32Ids(event: Event) {
const cache = EVENT_EMBEDDED_NOTES_CACHE.get(event.id)
if (cache) return cache
const embeddedNoteBech32Ids: string[] = []
const embeddedNoteRegex = /nostr:(note1[a-z0-9]{58}|nevent1[a-z0-9]+)/g
;(event.content.match(embeddedNoteRegex) || []).forEach((note) => {
try {
const { type, data } = nip19.decode(note.split(':')[1])
if (type === 'nevent') {
embeddedNoteBech32Ids.push(data.id)
} else if (type === 'note') {
embeddedNoteBech32Ids.push(data)
}
} catch {
// ignore
}
})
EVENT_EMBEDDED_NOTES_CACHE.set(event.id, embeddedNoteBech32Ids)
return embeddedNoteBech32Ids
}
export function getEmbeddedPubkeys(event: Event) {
const cache = EVENT_EMBEDDED_PUBKEYS_CACHE.get(event.id)
if (cache) return cache
const embeddedPubkeySet = new Set<string>()
;(event.content.match(EMBEDDED_MENTION_REGEX) || []).forEach((mention) => {
try {
const { type, data } = nip19.decode(mention.split(':')[1])
if (type === 'npub') {
embeddedPubkeySet.add(data)
} else if (type === 'nprofile') {
embeddedPubkeySet.add(data.pubkey)
}
} catch {
// ignore
}
})
const embeddedPubkeys = Array.from(embeddedPubkeySet)
EVENT_EMBEDDED_PUBKEYS_CACHE.set(event.id, embeddedPubkeys)
return embeddedPubkeys
}
export function getLatestEvent(events: Event[]): Event | undefined {
return events.sort((a, b) => b.created_at - a.created_at)[0]
}
export function getReplaceableEventIdentifier(event: Event) {
return event.tags.find(tagNameEquals('d'))?.[1] ?? ''
}
export function createFakeEvent(event: Partial<Event>): Event {
return {
id: '',
kind: 1,
pubkey: '',
content: '',
created_at: 0,
tags: [],
sig: '',
...event
}
}
export async function minePow(
unsigned: UnsignedEvent,
difficulty: number
): Promise<Omit<Event, 'sig'>> {
let count = 0
const event = unsigned as Omit<Event, 'sig'>
const tag = ['nonce', count.toString(), difficulty.toString()]
event.tags.push(tag)
return new Promise((resolve) => {
const mine = () => {
let iterations = 0
while (iterations < 1000) {
const now = Math.floor(new Date().getTime() / 1000)
if (now !== event.created_at) {
count = 0
event.created_at = now
}
tag[1] = (++count).toString()
event.id = fastEventHash(event)
if (getPow(event.id) >= difficulty) {
resolve(event)
return
}
iterations++
}
setTimeout(mine, 0)
}
mine()
})
}
// Legacy compare function for sorting compatibility
// If return 0, it means the two events are equal.
// If return a negative number, it means `b` should be retained, and `a` should be discarded.
// If return a positive number, it means `a` should be retained, and `b` should be discarded.
export function compareEvents(a: Event, b: Event): number {
if (a.created_at !== b.created_at) {
return a.created_at - b.created_at
}
// In case of replaceable events with the same timestamp, the event with the lowest id (first in lexical order) should be retained, and the other discarded.
if (a.id !== b.id) {
return a.id < b.id ? 1 : -1
}
return 0
}
// Returns the event that should be retained when comparing two events
export function getRetainedEvent(a: Event, b: Event): Event {
if (compareEvents(a, b) > 0) {
return a
}
return b
}