feat: NIP-10
This commit is contained in:
@@ -2,11 +2,12 @@ import { Button } from '@/components/ui/button'
|
|||||||
import { useToast } from '@/hooks/use-toast'
|
import { useToast } from '@/hooks/use-toast'
|
||||||
import { createCommentDraftEvent, createShortTextNoteDraftEvent } from '@/lib/draft-event'
|
import { createCommentDraftEvent, createShortTextNoteDraftEvent } from '@/lib/draft-event'
|
||||||
import { getRootEventTag } from '@/lib/event.ts'
|
import { getRootEventTag } from '@/lib/event.ts'
|
||||||
|
import { generateEventIdFromETag } from '@/lib/tag.ts'
|
||||||
import { useNostr } from '@/providers/NostrProvider'
|
import { useNostr } from '@/providers/NostrProvider'
|
||||||
import client from '@/services/client.service'
|
import client from '@/services/client.service'
|
||||||
import postContentCache from '@/services/post-content-cache.service'
|
import postContentCache from '@/services/post-content-cache.service'
|
||||||
import { ChevronDown, ImageUp, LoaderCircle } from 'lucide-react'
|
import { ChevronDown, ImageUp, LoaderCircle } from 'lucide-react'
|
||||||
import { Event, kinds, nip19 } from 'nostr-tools'
|
import { Event, kinds } from 'nostr-tools'
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import TextareaWithMentions from '../TextareaWithMentions.tsx'
|
import TextareaWithMentions from '../TextareaWithMentions.tsx'
|
||||||
@@ -70,35 +71,29 @@ export default function NormalPostContent({
|
|||||||
try {
|
try {
|
||||||
const additionalRelayUrls: string[] = []
|
const additionalRelayUrls: string[] = []
|
||||||
if (parentEvent && !specifiedRelayUrls) {
|
if (parentEvent && !specifiedRelayUrls) {
|
||||||
const relayList = await client.fetchRelayList(parentEvent.pubkey)
|
|
||||||
additionalRelayUrls.push(...relayList.read.slice(0, 3))
|
|
||||||
const rootEventTag = getRootEventTag(parentEvent)
|
const rootEventTag = getRootEventTag(parentEvent)
|
||||||
if (rootEventTag) {
|
if (rootEventTag) {
|
||||||
const [, rootEventId, rootEventRelay, , rootAuthor] = rootEventTag
|
const [, , , , rootAuthor] = rootEventTag
|
||||||
if (rootAuthor) {
|
if (rootAuthor) {
|
||||||
if (rootAuthor !== parentEvent.pubkey) {
|
if (rootAuthor !== parentEvent.pubkey) {
|
||||||
const rootAuthorRelayList = await client.fetchRelayList(rootAuthor)
|
const rootAuthorRelayList = await client.fetchRelayList(rootAuthor)
|
||||||
additionalRelayUrls.push(...rootAuthorRelayList.read.slice(0, 3))
|
additionalRelayUrls.push(...rootAuthorRelayList.read.slice(0, 4))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
try {
|
const rootEventId = generateEventIdFromETag(rootEventTag)
|
||||||
const rootEvent = await client.fetchEvent(
|
if (rootEventId) {
|
||||||
nip19.neventEncode(
|
const rootEvent = await client.fetchEvent(rootEventId)
|
||||||
rootEventRelay
|
|
||||||
? { id: rootEventId }
|
|
||||||
: { id: rootEventId, relays: [rootEventRelay] }
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if (rootEvent && rootEvent.pubkey !== parentEvent.pubkey) {
|
if (rootEvent && rootEvent.pubkey !== parentEvent.pubkey) {
|
||||||
const rootAuthorRelayList = await client.fetchRelayList(rootEvent.pubkey)
|
const rootAuthorRelayList = await client.fetchRelayList(rootEvent.pubkey)
|
||||||
additionalRelayUrls.push(...rootAuthorRelayList.read.slice(0, 3))
|
additionalRelayUrls.push(...rootAuthorRelayList.read.slice(0, 4))
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const relayList = await client.fetchRelayList(parentEvent.pubkey)
|
||||||
|
additionalRelayUrls.push(...relayList.read.slice(0, 4))
|
||||||
|
}
|
||||||
const draftEvent =
|
const draftEvent =
|
||||||
parentEvent && parentEvent.kind !== kinds.ShortTextNote
|
parentEvent && parentEvent.kind !== kinds.ShortTextNote
|
||||||
? await createCommentDraftEvent(content, parentEvent, pictureInfos, mentions, {
|
? await createCommentDraftEvent(content, parentEvent, pictureInfos, mentions, {
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import { Separator } from '@/components/ui/separator'
|
import { Separator } from '@/components/ui/separator'
|
||||||
import { BIG_RELAY_URLS } from '@/constants'
|
import { BIG_RELAY_URLS } from '@/constants'
|
||||||
import { isProtectedEvent, isReplyNoteEvent } from '@/lib/event'
|
import {
|
||||||
import { isReplyETag, isRootETag } from '@/lib/tag'
|
getParentEventHexId,
|
||||||
|
getRootEventHexId,
|
||||||
|
getRootEventTag,
|
||||||
|
isReplyNoteEvent
|
||||||
|
} from '@/lib/event'
|
||||||
|
import { generateEventIdFromETag } from '@/lib/tag'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useNostr } from '@/providers/NostrProvider'
|
import { useNostr } from '@/providers/NostrProvider'
|
||||||
import { useNoteStats } from '@/providers/NoteStatsProvider'
|
import { useNoteStats } from '@/providers/NoteStatsProvider'
|
||||||
@@ -17,12 +22,14 @@ const LIMIT = 100
|
|||||||
export default function ReplyNoteList({ event, className }: { event: NEvent; className?: string }) {
|
export default function ReplyNoteList({ event, className }: { event: NEvent; className?: string }) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { pubkey } = useNostr()
|
const { pubkey } = useNostr()
|
||||||
|
const [rootInfo, setRootInfo] = useState<{ id: string; pubkey: string } | undefined>(undefined)
|
||||||
const [timelineKey, setTimelineKey] = useState<string | undefined>(undefined)
|
const [timelineKey, setTimelineKey] = useState<string | undefined>(undefined)
|
||||||
const [until, setUntil] = useState<number | undefined>(() => dayjs().unix())
|
const [until, setUntil] = useState<number | undefined>(() => dayjs().unix())
|
||||||
|
const [events, setEvents] = useState<NEvent[]>([])
|
||||||
const [replies, setReplies] = useState<NEvent[]>([])
|
const [replies, setReplies] = useState<NEvent[]>([])
|
||||||
const [replyMap, setReplyMap] = useState<
|
const [replyMap, setReplyMap] = useState<
|
||||||
Record<string, { event: NEvent; level: number; parent?: NEvent } | undefined>
|
Map<string, { event: NEvent; level: number; parent?: NEvent } | undefined>
|
||||||
>({})
|
>(new Map())
|
||||||
const [loading, setLoading] = useState<boolean>(false)
|
const [loading, setLoading] = useState<boolean>(false)
|
||||||
const [highlightReplyId, setHighlightReplyId] = useState<string | undefined>(undefined)
|
const [highlightReplyId, setHighlightReplyId] = useState<string | undefined>(undefined)
|
||||||
const { updateNoteReplyCount } = useNoteStats()
|
const { updateNoteReplyCount } = useNoteStats()
|
||||||
@@ -30,13 +37,35 @@ export default function ReplyNoteList({ event, className }: { event: NEvent; cla
|
|||||||
const bottomRef = useRef<HTMLDivElement | null>(null)
|
const bottomRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const fetchRootEvent = async () => {
|
||||||
|
let root = { id: event.id, pubkey: event.pubkey }
|
||||||
|
const rootEventTag = getRootEventTag(event)
|
||||||
|
if (rootEventTag) {
|
||||||
|
const [, rootEventHexId, , , rootEventPubkey] = rootEventTag
|
||||||
|
if (rootEventHexId && rootEventPubkey) {
|
||||||
|
root = { id: rootEventHexId, pubkey: rootEventPubkey }
|
||||||
|
} else {
|
||||||
|
const rootEventId = generateEventIdFromETag(rootEventTag)
|
||||||
|
if (rootEventId) {
|
||||||
|
const rootEvent = await client.fetchEvent(rootEventId)
|
||||||
|
if (rootEvent) {
|
||||||
|
root = { id: rootEvent.id, pubkey: rootEvent.pubkey }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setRootInfo(root)
|
||||||
|
}
|
||||||
|
fetchRootEvent()
|
||||||
|
}, [event])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!rootInfo) return
|
||||||
const handleEventPublished = (data: Event) => {
|
const handleEventPublished = (data: Event) => {
|
||||||
const customEvent = data as CustomEvent<NEvent>
|
const customEvent = data as CustomEvent<NEvent>
|
||||||
const evt = customEvent.detail
|
const evt = customEvent.detail
|
||||||
if (
|
const rootId = getRootEventHexId(evt)
|
||||||
isReplyNoteEvent(evt) &&
|
if (rootId === rootInfo.id) {
|
||||||
evt.tags.some(([tagName, tagValue]) => tagName === 'e' && tagValue === event.id)
|
|
||||||
) {
|
|
||||||
onNewReply(evt)
|
onNewReply(evt)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -45,32 +74,30 @@ export default function ReplyNoteList({ event, className }: { event: NEvent; cla
|
|||||||
return () => {
|
return () => {
|
||||||
client.removeEventListener('eventPublished', handleEventPublished)
|
client.removeEventListener('eventPublished', handleEventPublished)
|
||||||
}
|
}
|
||||||
}, [event])
|
}, [rootInfo])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (loading) return
|
if (loading || !rootInfo) return
|
||||||
|
|
||||||
const init = async () => {
|
const init = async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setReplies([])
|
setEvents([])
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const relayList = await client.fetchRelayList(event.pubkey)
|
const relayList = await client.fetchRelayList(rootInfo.pubkey)
|
||||||
const relayUrls = relayList.read.concat(BIG_RELAY_URLS)
|
const relayUrls = relayList.read.concat(BIG_RELAY_URLS)
|
||||||
if (isProtectedEvent(event)) {
|
const seenOn = client.getSeenEventRelayUrls(rootInfo.id)
|
||||||
const seenOn = client.getSeenEventRelayUrls(event.id)
|
|
||||||
relayUrls.unshift(...seenOn)
|
relayUrls.unshift(...seenOn)
|
||||||
}
|
|
||||||
const { closer, timelineKey } = await client.subscribeTimeline(
|
const { closer, timelineKey } = await client.subscribeTimeline(
|
||||||
relayUrls.slice(0, 4),
|
relayUrls.slice(0, 5),
|
||||||
{
|
{
|
||||||
'#e': [event.id],
|
'#e': [rootInfo.id],
|
||||||
kinds: [kinds.ShortTextNote],
|
kinds: [kinds.ShortTextNote],
|
||||||
limit: LIMIT
|
limit: LIMIT
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
onEvents: (evts, eosed) => {
|
onEvents: (evts, eosed) => {
|
||||||
setReplies(evts.filter((evt) => isReplyNoteEvent(evt)).reverse())
|
setEvents(evts.filter((evt) => isReplyNoteEvent(evt)).reverse())
|
||||||
if (eosed) {
|
if (eosed) {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
setUntil(evts.length >= LIMIT ? evts[evts.length - 1].created_at - 1 : undefined)
|
setUntil(evts.length >= LIMIT ? evts[evts.length - 1].created_at - 1 : undefined)
|
||||||
@@ -94,52 +121,49 @@ export default function ReplyNoteList({ event, className }: { event: NEvent; cla
|
|||||||
return () => {
|
return () => {
|
||||||
promise.then((closer) => closer?.())
|
promise.then((closer) => closer?.())
|
||||||
}
|
}
|
||||||
}, [event])
|
}, [rootInfo])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
updateNoteReplyCount(event.id, replies.length)
|
const replies: NEvent[] = []
|
||||||
|
const replyMap: Map<string, { event: NEvent; level: number; parent?: NEvent } | undefined> =
|
||||||
|
new Map()
|
||||||
|
const rootEventId = getRootEventHexId(event) ?? event.id
|
||||||
|
const isRootEvent = rootEventId === event.id
|
||||||
|
for (const evt of events) {
|
||||||
|
if (evt.created_at < event.created_at) continue
|
||||||
|
|
||||||
|
const parentEventId = getParentEventHexId(evt)
|
||||||
|
if (parentEventId) {
|
||||||
|
const parentReplyInfo = replyMap.get(parentEventId)
|
||||||
|
if (!parentReplyInfo && parentEventId !== event.id) continue
|
||||||
|
|
||||||
const replyMap: Record<string, { event: NEvent; level: number; parent?: NEvent } | undefined> =
|
|
||||||
{}
|
|
||||||
for (const reply of replies) {
|
|
||||||
const parentReplyTag = reply.tags.find(isReplyETag)
|
|
||||||
if (parentReplyTag) {
|
|
||||||
const parentReplyInfo = replyMap[parentReplyTag[1]]
|
|
||||||
const level = parentReplyInfo ? parentReplyInfo.level + 1 : 1
|
const level = parentReplyInfo ? parentReplyInfo.level + 1 : 1
|
||||||
replyMap[reply.id] = { event: reply, level, parent: parentReplyInfo?.event }
|
replies.push(evt)
|
||||||
|
replyMap.set(evt.id, { event: evt, level, parent: parentReplyInfo?.event })
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
const rootReplyTag = reply.tags.find(isRootETag)
|
if (!isRootEvent) continue
|
||||||
if (rootReplyTag) {
|
|
||||||
replyMap[reply.id] = { event: reply, level: 1 }
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
let level = 0
|
replies.push(evt)
|
||||||
let parent: NEvent | undefined
|
replyMap.set(evt.id, { event: evt, level: 1 })
|
||||||
for (const [tagName, tagValue] of reply.tags) {
|
|
||||||
if (tagName === 'e') {
|
|
||||||
const info = replyMap[tagValue]
|
|
||||||
if (info && info.level > level) {
|
|
||||||
level = info.level
|
|
||||||
parent = info.event
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
replyMap[reply.id] = { event: reply, level: level + 1, parent }
|
|
||||||
}
|
}
|
||||||
setReplyMap(replyMap)
|
setReplyMap(replyMap)
|
||||||
}, [replies, event.id, updateNoteReplyCount])
|
setReplies(replies)
|
||||||
|
updateNoteReplyCount(event.id, replies.length)
|
||||||
|
if (replies.length === 0) {
|
||||||
|
loadMore()
|
||||||
|
}
|
||||||
|
}, [events, event, updateNoteReplyCount])
|
||||||
|
|
||||||
const loadMore = useCallback(async () => {
|
const loadMore = useCallback(async () => {
|
||||||
if (loading || !until || !timelineKey) return
|
if (loading || !until || !timelineKey) return
|
||||||
|
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
const events = await client.loadMoreTimeline(timelineKey, until, LIMIT)
|
const events = await client.loadMoreTimeline(timelineKey, until, LIMIT)
|
||||||
const olderReplies = events.filter((evt) => isReplyNoteEvent(evt)).reverse()
|
const olderEvents = events.filter((evt) => isReplyNoteEvent(evt)).reverse()
|
||||||
if (olderReplies.length > 0) {
|
if (olderEvents.length > 0) {
|
||||||
setReplies((pre) => [...olderReplies, ...pre])
|
setEvents((pre) => [...olderEvents, ...pre])
|
||||||
}
|
}
|
||||||
setUntil(events.length ? events[events.length - 1].created_at - 1 : undefined)
|
setUntil(events.length ? events[events.length - 1].created_at - 1 : undefined)
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
@@ -147,7 +171,7 @@ export default function ReplyNoteList({ event, className }: { event: NEvent; cla
|
|||||||
|
|
||||||
const onNewReply = useCallback(
|
const onNewReply = useCallback(
|
||||||
(evt: NEvent) => {
|
(evt: NEvent) => {
|
||||||
setReplies((pre) => {
|
setEvents((pre) => {
|
||||||
if (pre.some((reply) => reply.id === evt.id)) return pre
|
if (pre.some((reply) => reply.id === evt.id)) return pre
|
||||||
return [...pre, evt]
|
return [...pre, evt]
|
||||||
})
|
})
|
||||||
@@ -192,7 +216,7 @@ export default function ReplyNoteList({ event, className }: { event: NEvent; cla
|
|||||||
{replies.length > 0 && (loading || until) && <Separator className="mt-2" />}
|
{replies.length > 0 && (loading || until) && <Separator className="mt-2" />}
|
||||||
<div className={cn('mb-2', className)}>
|
<div className={cn('mb-2', className)}>
|
||||||
{replies.map((reply) => {
|
{replies.map((reply) => {
|
||||||
const info = replyMap[reply.id]
|
const info = replyMap.get(reply.id)
|
||||||
return (
|
return (
|
||||||
<div ref={(el) => (replyRefs.current[reply.id] = el)} key={reply.id}>
|
<div ref={(el) => (replyRefs.current[reply.id] = el)} key={reply.id}>
|
||||||
<ReplyNote
|
<ReplyNote
|
||||||
|
|||||||
@@ -61,8 +61,10 @@ export async function createShortTextNoteDraftEvent(
|
|||||||
protectedEvent?: boolean
|
protectedEvent?: boolean
|
||||||
} = {}
|
} = {}
|
||||||
): Promise<TDraftEvent> {
|
): Promise<TDraftEvent> {
|
||||||
const { otherRelatedEventIds, quoteEventIds, rootEventId, parentEventId } =
|
const { quoteEventIds, rootETag, parentETag } = await extractRelatedEventIds(
|
||||||
await extractRelatedEventIds(content, options.parentEvent)
|
content,
|
||||||
|
options.parentEvent
|
||||||
|
)
|
||||||
const hashtags = extractHashtags(content)
|
const hashtags = extractHashtags(content)
|
||||||
|
|
||||||
const tags = hashtags.map((hashtag) => ['t', hashtag])
|
const tags = hashtags.map((hashtag) => ['t', hashtag])
|
||||||
@@ -77,14 +79,12 @@ export async function createShortTextNoteDraftEvent(
|
|||||||
tags.push(...quoteEventIds.map((eventId) => ['q', eventId, client.getEventHint(eventId)]))
|
tags.push(...quoteEventIds.map((eventId) => ['q', eventId, client.getEventHint(eventId)]))
|
||||||
|
|
||||||
// e tags
|
// e tags
|
||||||
if (rootEventId) {
|
if (rootETag.length) {
|
||||||
tags.push(['e', rootEventId, client.getEventHint(rootEventId), 'root'])
|
tags.push(rootETag)
|
||||||
}
|
}
|
||||||
|
|
||||||
tags.push(...otherRelatedEventIds.map((eventId) => ['e', eventId, client.getEventHint(eventId)]))
|
if (parentETag.length) {
|
||||||
|
tags.push(parentETag)
|
||||||
if (parentEventId) {
|
|
||||||
tags.push(['e', parentEventId, client.getEventHint(parentEventId), 'reply'])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// p tags
|
// p tags
|
||||||
|
|||||||
109
src/lib/event.ts
109
src/lib/event.ts
@@ -5,7 +5,13 @@ import { LRUCache } from 'lru-cache'
|
|||||||
import { Event, kinds, nip19 } from 'nostr-tools'
|
import { Event, kinds, nip19 } from 'nostr-tools'
|
||||||
import { getAmountFromInvoice, getLightningAddressFromProfile } from './lightning'
|
import { getAmountFromInvoice, getLightningAddressFromProfile } from './lightning'
|
||||||
import { formatPubkey } from './pubkey'
|
import { formatPubkey } from './pubkey'
|
||||||
import { extractImageInfoFromTag, isReplyETag, isRootETag, tagNameEquals } from './tag'
|
import {
|
||||||
|
extractImageInfoFromTag,
|
||||||
|
generateEventIdFromETag,
|
||||||
|
isReplyETag,
|
||||||
|
isRootETag,
|
||||||
|
tagNameEquals
|
||||||
|
} from './tag'
|
||||||
import { isWebsocketUrl, normalizeHttpUrl, normalizeUrl } from './url'
|
import { isWebsocketUrl, normalizeHttpUrl, normalizeUrl } from './url'
|
||||||
|
|
||||||
const EVENT_EMBEDDED_EVENT_IDS_CACHE = new LRUCache<string, string[]>({ max: 10000 })
|
const EVENT_EMBEDDED_EVENT_IDS_CACHE = new LRUCache<string, string[]>({ max: 10000 })
|
||||||
@@ -56,23 +62,28 @@ export function isSupportedKind(kind: number) {
|
|||||||
return [kinds.ShortTextNote, PICTURE_EVENT_KIND].includes(kind)
|
return [kinds.ShortTextNote, PICTURE_EVENT_KIND].includes(kind)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getParentEventId(event?: Event) {
|
export function getParentEventTag(event?: Event) {
|
||||||
if (!event) return undefined
|
if (!event) return undefined
|
||||||
let tag = event.tags.find(isReplyETag)
|
let tag = event.tags.find(isReplyETag)
|
||||||
if (!tag) {
|
if (!tag) {
|
||||||
const embeddedEventIds = extractEmbeddedEventIds(event)
|
const embeddedEventIds = extractEmbeddedEventIds(event)
|
||||||
tag = event.tags.findLast(
|
tag = event.tags.findLast(
|
||||||
([tagName, tagValue]) => tagName === 'e' && !embeddedEventIds.includes(tagValue)
|
([tagName, tagValue]) => tagName === 'e' && !!tagValue && !embeddedEventIds.includes(tagValue)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
return tag
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getParentEventHexId(event?: Event) {
|
||||||
|
const tag = getParentEventTag(event)
|
||||||
|
return tag?.[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getParentEventId(event?: Event) {
|
||||||
|
const tag = getParentEventTag(event)
|
||||||
if (!tag) return undefined
|
if (!tag) return undefined
|
||||||
|
|
||||||
try {
|
return generateEventIdFromETag(tag)
|
||||||
const [, id, relay, , author] = tag
|
|
||||||
return nip19.neventEncode({ id, relays: relay ? [relay] : undefined, author })
|
|
||||||
} catch {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getRootEventTag(event?: Event) {
|
export function getRootEventTag(event?: Event) {
|
||||||
@@ -81,22 +92,22 @@ export function getRootEventTag(event?: Event) {
|
|||||||
if (!tag) {
|
if (!tag) {
|
||||||
const embeddedEventIds = extractEmbeddedEventIds(event)
|
const embeddedEventIds = extractEmbeddedEventIds(event)
|
||||||
tag = event.tags.find(
|
tag = event.tags.find(
|
||||||
([tagName, tagValue]) => tagName === 'e' && !embeddedEventIds.includes(tagValue)
|
([tagName, tagValue]) => tagName === 'e' && !!tagValue && !embeddedEventIds.includes(tagValue)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return tag
|
return tag
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getRootEventHexId(event?: Event) {
|
||||||
|
const tag = getRootEventTag(event)
|
||||||
|
return tag?.[1]
|
||||||
|
}
|
||||||
|
|
||||||
export function getRootEventId(event?: Event) {
|
export function getRootEventId(event?: Event) {
|
||||||
const tag = getRootEventTag(event)
|
const tag = getRootEventTag(event)
|
||||||
if (!tag) return undefined
|
if (!tag) return undefined
|
||||||
|
|
||||||
try {
|
return generateEventIdFromETag(tag)
|
||||||
const [, id, relay, , author] = tag
|
|
||||||
return nip19.neventEncode({ id, relays: relay ? [relay] : undefined, author })
|
|
||||||
} catch {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isReplaceable(kind: number) {
|
export function isReplaceable(kind: number) {
|
||||||
@@ -231,21 +242,15 @@ export async function extractMentions(content: string, parentEvent?: Event) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function extractRelatedEventIds(content: string, parentEvent?: Event) {
|
export async function extractRelatedEventIds(content: string, parentEvent?: Event) {
|
||||||
const relatedEventIds: string[] = []
|
|
||||||
const quoteEventIds: string[] = []
|
const quoteEventIds: string[] = []
|
||||||
let rootEventId: string | undefined
|
let rootETag: string[] = []
|
||||||
let parentEventId: string | undefined
|
let parentETag: string[] = []
|
||||||
const matches = content.match(/nostr:(note1[a-z0-9]{58}|nevent1[a-z0-9]+)/g)
|
const matches = content.match(/nostr:(note1[a-z0-9]{58}|nevent1[a-z0-9]+)/g)
|
||||||
|
|
||||||
const addToSet = (arr: string[], item: string) => {
|
const addToSet = (arr: string[], item: string) => {
|
||||||
if (!arr.includes(item)) arr.push(item)
|
if (!arr.includes(item)) arr.push(item)
|
||||||
}
|
}
|
||||||
|
|
||||||
const removeFromSet = (arr: string[], item: string) => {
|
|
||||||
const index = arr.indexOf(item)
|
|
||||||
if (index !== -1) arr.splice(index, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const m of matches || []) {
|
for (const m of matches || []) {
|
||||||
try {
|
try {
|
||||||
const id = m.split(':')[1]
|
const id = m.split(':')[1]
|
||||||
@@ -261,32 +266,48 @@ export async function extractRelatedEventIds(content: string, parentEvent?: Even
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (parentEvent) {
|
if (parentEvent) {
|
||||||
addToSet(relatedEventIds, parentEvent.id)
|
const rootEventTag = getRootEventTag(parentEvent)
|
||||||
parentEvent.tags.forEach((tag) => {
|
if (rootEventTag) {
|
||||||
if (isRootETag(tag)) {
|
parentETag = [
|
||||||
rootEventId = tag[1]
|
'e',
|
||||||
} else if (tagNameEquals('e')(tag)) {
|
parentEvent.id,
|
||||||
addToSet(relatedEventIds, tag[1])
|
client.getEventHint(parentEvent.id),
|
||||||
}
|
'reply',
|
||||||
})
|
parentEvent.pubkey
|
||||||
if (rootEventId || isReplyNoteEvent(parentEvent)) {
|
]
|
||||||
parentEventId = parentEvent.id
|
|
||||||
|
const [, rootEventHexId, hint, , rootEventPubkey] = rootEventTag
|
||||||
|
if (rootEventPubkey) {
|
||||||
|
rootETag = [
|
||||||
|
'e',
|
||||||
|
rootEventHexId,
|
||||||
|
hint ?? client.getEventHint(rootEventHexId),
|
||||||
|
'root',
|
||||||
|
rootEventPubkey
|
||||||
|
]
|
||||||
} else {
|
} else {
|
||||||
rootEventId = parentEvent.id
|
const rootEventId = generateEventIdFromETag(rootEventTag)
|
||||||
|
const rootEvent = rootEventId ? await client.fetchEvent(rootEventId) : undefined
|
||||||
|
rootETag = rootEvent
|
||||||
|
? ['e', rootEvent.id, hint ?? client.getEventHint(rootEvent.id), 'root', rootEvent.pubkey]
|
||||||
|
: ['e', rootEventHexId, hint ?? client.getEventHint(rootEventHexId), 'root']
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// reply to root event
|
||||||
|
rootETag = [
|
||||||
|
'e',
|
||||||
|
parentEvent.id,
|
||||||
|
client.getEventHint(parentEvent.id),
|
||||||
|
'root',
|
||||||
|
parentEvent.pubkey
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (rootEventId) {
|
|
||||||
removeFromSet(relatedEventIds, rootEventId)
|
|
||||||
}
|
|
||||||
if (parentEventId) {
|
|
||||||
removeFromSet(relatedEventIds, parentEventId)
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
otherRelatedEventIds: relatedEventIds,
|
|
||||||
quoteEventIds,
|
quoteEventIds,
|
||||||
rootEventId,
|
rootETag,
|
||||||
parentEventId
|
parentETag
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { TImageInfo } from '@/types'
|
import { TImageInfo } from '@/types'
|
||||||
import { isBlurhashValid } from 'blurhash'
|
import { isBlurhashValid } from 'blurhash'
|
||||||
|
import { nip19 } from 'nostr-tools'
|
||||||
import { isValidPubkey } from './pubkey'
|
import { isValidPubkey } from './pubkey'
|
||||||
|
|
||||||
export function tagNameEquals(tagName: string) {
|
export function tagNameEquals(tagName: string) {
|
||||||
@@ -18,6 +19,15 @@ export function isMentionETag([tagName, , , marker]: string[]) {
|
|||||||
return tagName === 'e' && marker === 'mention'
|
return tagName === 'e' && marker === 'mention'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function generateEventIdFromETag(tag: string[]) {
|
||||||
|
try {
|
||||||
|
const [, id, relay, , author] = tag
|
||||||
|
return nip19.neventEncode({ id, relays: relay ? [relay] : undefined, author })
|
||||||
|
} catch {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function extractImageInfoFromTag(tag: string[]): TImageInfo | null {
|
export function extractImageInfoFromTag(tag: string[]): TImageInfo | null {
|
||||||
if (tag[0] !== 'imeta') return null
|
if (tag[0] !== 'imeta') return null
|
||||||
const urlItem = tag.find((item) => item.startsWith('url '))
|
const urlItem = tag.find((item) => item.startsWith('url '))
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import {
|
|||||||
SimplePool,
|
SimplePool,
|
||||||
VerifiedEvent
|
VerifiedEvent
|
||||||
} from 'nostr-tools'
|
} from 'nostr-tools'
|
||||||
import { SubscribeManyParams } from 'nostr-tools/abstract-pool'
|
|
||||||
import { AbstractRelay } from 'nostr-tools/abstract-relay'
|
import { AbstractRelay } from 'nostr-tools/abstract-relay'
|
||||||
import indexedDb from './indexed-db.service'
|
import indexedDb from './indexed-db.service'
|
||||||
|
|
||||||
@@ -185,35 +184,14 @@ class ClientService extends EventTarget {
|
|||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
||||||
const that = this
|
const that = this
|
||||||
const _knownIds = new Set<string>()
|
|
||||||
let events: NEvent[] = []
|
let events: NEvent[] = []
|
||||||
let startedCount = 0
|
|
||||||
let eosedCount = 0
|
|
||||||
let eosed = false
|
let eosed = false
|
||||||
const subPromises = relays.map(async (url) => {
|
const subCloser = this.subscribe(relays, since ? { ...filter, since } : filter, {
|
||||||
const relay = await this.pool.ensureRelay(url)
|
startLogin,
|
||||||
let hasAuthed = false
|
|
||||||
|
|
||||||
return startSub()
|
|
||||||
|
|
||||||
function startSub() {
|
|
||||||
startedCount++
|
|
||||||
return relay.subscribe([since ? { ...filter, since } : filter], {
|
|
||||||
receivedEvent: (relay, id) => {
|
|
||||||
that.trackEventSeenOn(id, relay)
|
|
||||||
},
|
|
||||||
alreadyHaveEvent: (id: string) => {
|
|
||||||
const have = _knownIds.has(id)
|
|
||||||
if (have) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
_knownIds.add(id)
|
|
||||||
return false
|
|
||||||
},
|
|
||||||
onevent: (evt: NEvent) => {
|
onevent: (evt: NEvent) => {
|
||||||
that.eventDataLoader.prime(evt.id, Promise.resolve(evt))
|
that.eventDataLoader.prime(evt.id, Promise.resolve(evt))
|
||||||
// not eosed yet, push to events
|
// not eosed yet, push to events
|
||||||
if (eosedCount < startedCount) {
|
if (!eosed) {
|
||||||
return events.push(evt)
|
return events.push(evt)
|
||||||
}
|
}
|
||||||
// eosed, (algo relay feeds) no need to sort and cache
|
// eosed, (algo relay feeds) no need to sort and cache
|
||||||
@@ -248,37 +226,8 @@ class ClientService extends EventTarget {
|
|||||||
// insert the event to the right position
|
// insert the event to the right position
|
||||||
timeline.refs.splice(idx, 0, [evt.id, evt.created_at])
|
timeline.refs.splice(idx, 0, [evt.id, evt.created_at])
|
||||||
},
|
},
|
||||||
onclose: (reason: string) => {
|
oneose: (_eosed) => {
|
||||||
if (!reason.startsWith('auth-required:')) return
|
eosed = _eosed
|
||||||
if (hasAuthed) return
|
|
||||||
|
|
||||||
if (that.signer) {
|
|
||||||
relay
|
|
||||||
.auth(async (authEvt: EventTemplate) => {
|
|
||||||
const evt = await that.signer!(authEvt)
|
|
||||||
if (!evt) {
|
|
||||||
throw new Error('sign event failed')
|
|
||||||
}
|
|
||||||
return evt as VerifiedEvent
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
hasAuthed = true
|
|
||||||
if (!eosed) {
|
|
||||||
startSub()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
// ignore
|
|
||||||
})
|
|
||||||
} else if (startLogin) {
|
|
||||||
startLogin()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
oneose: () => {
|
|
||||||
if (eosed) return
|
|
||||||
eosedCount++
|
|
||||||
eosed = eosedCount >= startedCount
|
|
||||||
|
|
||||||
// (algo feeds) no need to sort and cache
|
// (algo feeds) no need to sort and cache
|
||||||
if (!needSort) {
|
if (!needSort) {
|
||||||
return onEvents([...events], eosed)
|
return onEvents([...events], eosed)
|
||||||
@@ -303,8 +252,7 @@ class ClientService extends EventTarget {
|
|||||||
const newEvents = events.filter((evt) => {
|
const newEvents = events.filter((evt) => {
|
||||||
const firstRef = timeline.refs[0]
|
const firstRef = timeline.refs[0]
|
||||||
return (
|
return (
|
||||||
evt.created_at > firstRef[1] ||
|
evt.created_at > firstRef[1] || (evt.created_at === firstRef[1] && evt.id < firstRef[0])
|
||||||
(evt.created_at === firstRef[1] && evt.id < firstRef[0])
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
const newRefs = newEvents.map((evt) => [evt.id, evt.created_at] as TTimelineRef)
|
const newRefs = newEvents.map((evt) => [evt.id, evt.created_at] as TTimelineRef)
|
||||||
@@ -318,9 +266,6 @@ class ClientService extends EventTarget {
|
|||||||
timeline.refs = newRefs.concat(timeline.refs)
|
timeline.refs = newRefs.concat(timeline.refs)
|
||||||
onEvents([...newEvents.concat(cachedEvents)], true)
|
onEvents([...newEvents.concat(cachedEvents)], true)
|
||||||
}
|
}
|
||||||
},
|
|
||||||
eoseTimeout: 10000 // 10s
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -329,6 +274,107 @@ class ClientService extends EventTarget {
|
|||||||
closer: () => {
|
closer: () => {
|
||||||
onEvents = () => {}
|
onEvents = () => {}
|
||||||
onNew = () => {}
|
onNew = () => {}
|
||||||
|
subCloser.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribe(
|
||||||
|
urls: string[],
|
||||||
|
filter: Filter | Filter[],
|
||||||
|
{
|
||||||
|
onevent,
|
||||||
|
oneose,
|
||||||
|
onclose,
|
||||||
|
startLogin
|
||||||
|
}: {
|
||||||
|
onevent?: (evt: NEvent) => void
|
||||||
|
oneose?: (eosed: boolean) => void
|
||||||
|
onclose?: (reasons: string[]) => void
|
||||||
|
startLogin?: () => void
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const relays = Array.from(new Set(urls))
|
||||||
|
const filters = Array.isArray(filter) ? filter : [filter]
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
||||||
|
const that = this
|
||||||
|
const _knownIds = new Set<string>()
|
||||||
|
let startedCount = 0
|
||||||
|
let eosedCount = 0
|
||||||
|
let eosed = false
|
||||||
|
let closedCount = 0
|
||||||
|
const closeReasons: string[] = []
|
||||||
|
const subPromises = relays.map(async (url) => {
|
||||||
|
const relay = await this.pool.ensureRelay(url)
|
||||||
|
let hasAuthed = false
|
||||||
|
|
||||||
|
return startSub()
|
||||||
|
|
||||||
|
function startSub() {
|
||||||
|
startedCount++
|
||||||
|
return relay.subscribe(filters, {
|
||||||
|
receivedEvent: (relay, id) => {
|
||||||
|
that.trackEventSeenOn(id, relay)
|
||||||
|
},
|
||||||
|
alreadyHaveEvent: (id: string) => {
|
||||||
|
const have = _knownIds.has(id)
|
||||||
|
if (have) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
_knownIds.add(id)
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
onevent: (evt: NEvent) => {
|
||||||
|
onevent?.(evt)
|
||||||
|
},
|
||||||
|
oneose: () => {
|
||||||
|
if (eosed) return
|
||||||
|
eosedCount++
|
||||||
|
eosed = eosedCount >= startedCount
|
||||||
|
|
||||||
|
oneose?.(eosed)
|
||||||
|
},
|
||||||
|
onclose: (reason: string) => {
|
||||||
|
if (!reason.startsWith('auth-required:')) {
|
||||||
|
closedCount++
|
||||||
|
closeReasons.push(reason)
|
||||||
|
if (closedCount >= startedCount) {
|
||||||
|
onclose?.(closeReasons)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (hasAuthed) return
|
||||||
|
|
||||||
|
if (that.signer) {
|
||||||
|
relay
|
||||||
|
.auth(async (authEvt: EventTemplate) => {
|
||||||
|
const evt = await that.signer!(authEvt)
|
||||||
|
if (!evt) {
|
||||||
|
throw new Error('sign event failed')
|
||||||
|
}
|
||||||
|
return evt as VerifiedEvent
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
hasAuthed = true
|
||||||
|
if (!eosed) {
|
||||||
|
startSub()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// ignore
|
||||||
|
})
|
||||||
|
} else if (startLogin) {
|
||||||
|
startLogin()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
eoseTimeout: 10000 // 10s
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
close: () => {
|
||||||
subPromises.forEach((subPromise) => {
|
subPromises.forEach((subPromise) => {
|
||||||
subPromise
|
subPromise
|
||||||
.then((sub) => {
|
.then((sub) => {
|
||||||
@@ -342,12 +388,6 @@ class ClientService extends EventTarget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
subscribe(urls: string[], filter: Filter | Filter[], params: SubscribeManyParams) {
|
|
||||||
const relays = Array.from(new Set(urls))
|
|
||||||
const filters = Array.isArray(filter) ? filter : [filter]
|
|
||||||
return this.pool.subscribeMany(relays, filters, params)
|
|
||||||
}
|
|
||||||
|
|
||||||
private async query(urls: string[], filter: Filter | Filter[], onevent?: (evt: NEvent) => void) {
|
private async query(urls: string[], filter: Filter | Filter[], onevent?: (evt: NEvent) => void) {
|
||||||
const relays = Array.from(new Set(urls))
|
const relays = Array.from(new Set(urls))
|
||||||
const filters = Array.isArray(filter) ? filter : [filter]
|
const filters = Array.isArray(filter) ? filter : [filter]
|
||||||
@@ -708,6 +748,7 @@ class ClientService extends EventTarget {
|
|||||||
private async _fetchEvent(id: string): Promise<NEvent | undefined> {
|
private async _fetchEvent(id: string): Promise<NEvent | undefined> {
|
||||||
let filter: Filter | undefined
|
let filter: Filter | undefined
|
||||||
let relays: string[] = []
|
let relays: string[] = []
|
||||||
|
let author: string | undefined
|
||||||
if (/^[0-9a-f]{64}$/.test(id)) {
|
if (/^[0-9a-f]{64}$/.test(id)) {
|
||||||
filter = { ids: [id] }
|
filter = { ids: [id] }
|
||||||
} else {
|
} else {
|
||||||
@@ -719,6 +760,7 @@ class ClientService extends EventTarget {
|
|||||||
case 'nevent':
|
case 'nevent':
|
||||||
filter = { ids: [data.id] }
|
filter = { ids: [data.id] }
|
||||||
if (data.relays) relays = data.relays
|
if (data.relays) relays = data.relays
|
||||||
|
if (data.author) author = data.author
|
||||||
break
|
break
|
||||||
case 'naddr':
|
case 'naddr':
|
||||||
filter = {
|
filter = {
|
||||||
@@ -726,6 +768,7 @@ class ClientService extends EventTarget {
|
|||||||
kinds: [data.kind],
|
kinds: [data.kind],
|
||||||
limit: 1
|
limit: 1
|
||||||
}
|
}
|
||||||
|
author = data.pubkey
|
||||||
if (data.identifier) {
|
if (data.identifier) {
|
||||||
filter['#d'] = [data.identifier]
|
filter['#d'] = [data.identifier]
|
||||||
}
|
}
|
||||||
@@ -740,6 +783,10 @@ class ClientService extends EventTarget {
|
|||||||
if (filter.ids) {
|
if (filter.ids) {
|
||||||
event = await this.fetchEventById(relays, filter.ids[0])
|
event = await this.fetchEventById(relays, filter.ids[0])
|
||||||
} else {
|
} else {
|
||||||
|
if (author) {
|
||||||
|
const relayList = await this.fetchRelayList(author)
|
||||||
|
relays.push(...relayList.write.slice(0, 4))
|
||||||
|
}
|
||||||
event = await this.tryHarderToFetchEvent(relays, filter)
|
event = await this.tryHarderToFetchEvent(relays, filter)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user