8 changed files with 341 additions and 25 deletions
@ -0,0 +1,178 @@
@@ -0,0 +1,178 @@
|
||||
import { SEARCHABLE_RELAY_URLS } from '@/constants' |
||||
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' |
||||
import { useDeletedEvent } from '@/providers/DeletedEventProvider' |
||||
import { useNostr } from '@/providers/NostrProvider' |
||||
import { useReply } from '@/providers/ReplyProvider' |
||||
import { getNoteBech32Id, getParentETag, getRootETag } from '@/lib/event' |
||||
import { buildThreadContextFetchRelayUrls } from '@/lib/thread-context-relays' |
||||
import client, { eventService } from '@/services/client.service' |
||||
import { navigationEventStore } from '@/services/navigation-event-store' |
||||
import { Event } from 'nostr-tools' |
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' |
||||
|
||||
export type ThreadContextRole = 'parent' | 'root' |
||||
|
||||
export function useFetchThreadContextEvent( |
||||
eventId: string | undefined, |
||||
contextEvent: Event | undefined, |
||||
role: ThreadContextRole, |
||||
initialEvent?: Event |
||||
) { |
||||
const { pubkey: viewerPubkey } = useNostr() |
||||
const { blockedRelays } = useFavoriteRelays() |
||||
const { isEventDeleted } = useDeletedEvent() |
||||
const { addReplies } = useReply() |
||||
const [error, setError] = useState<Error | null>(null) |
||||
const [event, setEvent] = useState<Event | undefined>(initialEvent) |
||||
const [isFetching, setIsFetching] = useState(!initialEvent) |
||||
const [refetchToken, setRefetchToken] = useState(0) |
||||
const searchableAttemptedRef = useRef(false) |
||||
|
||||
const refetch = useCallback(() => { |
||||
searchableAttemptedRef.current = false |
||||
setRefetchToken((n) => n + 1) |
||||
}, []) |
||||
|
||||
const targetTag = useMemo(() => { |
||||
if (!contextEvent) return undefined |
||||
return role === 'parent' ? getParentETag(contextEvent) : getRootETag(contextEvent) |
||||
}, [contextEvent, role]) |
||||
|
||||
const blockedKey = useMemo( |
||||
() => [...blockedRelays].map((u) => u).sort().join('\0'), |
||||
[blockedRelays] |
||||
) |
||||
|
||||
useEffect(() => { |
||||
searchableAttemptedRef.current = false |
||||
}, [eventId]) |
||||
|
||||
useEffect(() => { |
||||
let cancelled = false |
||||
|
||||
if (!eventId || !contextEvent) { |
||||
setIsFetching(false) |
||||
setEvent(initialEvent) |
||||
return () => { |
||||
cancelled = true |
||||
} |
||||
} |
||||
|
||||
const skipShortcuts = refetchToken > 0 |
||||
|
||||
const initialMatches = |
||||
initialEvent && |
||||
(initialEvent.id === eventId || |
||||
(() => { |
||||
try { |
||||
return getNoteBech32Id(initialEvent) === eventId |
||||
} catch { |
||||
return false |
||||
} |
||||
})()) |
||||
if (!skipShortcuts && initialMatches && initialEvent) { |
||||
if (!isEventDeleted(initialEvent)) { |
||||
setEvent(initialEvent) |
||||
addReplies([initialEvent]) |
||||
setIsFetching(false) |
||||
} |
||||
return () => { |
||||
cancelled = true |
||||
} |
||||
} |
||||
|
||||
if (!skipShortcuts) { |
||||
const navigationEvent = navigationEventStore.peekEvent(eventId) |
||||
if (navigationEvent && !isEventDeleted(navigationEvent)) { |
||||
setEvent(navigationEvent) |
||||
addReplies([navigationEvent]) |
||||
setIsFetching(false) |
||||
return () => { |
||||
cancelled = true |
||||
} |
||||
} |
||||
} |
||||
|
||||
setEvent(undefined) |
||||
setError(null) |
||||
setIsFetching(true) |
||||
|
||||
const fetchWithFallback = async () => { |
||||
try { |
||||
const relayUrls = await buildThreadContextFetchRelayUrls( |
||||
contextEvent, |
||||
targetTag, |
||||
viewerPubkey ?? undefined, |
||||
blockedRelays |
||||
) |
||||
const opts = relayUrls.length ? { relayHints: relayUrls } : undefined |
||||
let fetchedEvent = skipShortcuts |
||||
? await eventService.fetchEventForceRetry(eventId, opts) |
||||
: await eventService.fetchEvent(eventId, opts) |
||||
|
||||
if ( |
||||
!fetchedEvent && |
||||
!searchableAttemptedRef.current && |
||||
SEARCHABLE_RELAY_URLS.length > 0 |
||||
) { |
||||
searchableAttemptedRef.current = true |
||||
fetchedEvent = await client.fetchEventWithExternalRelays( |
||||
eventId, |
||||
SEARCHABLE_RELAY_URLS |
||||
) |
||||
if (fetchedEvent) { |
||||
client.addEventToCache(fetchedEvent) |
||||
} |
||||
} |
||||
|
||||
if (cancelled) return |
||||
if (fetchedEvent && !isEventDeleted(fetchedEvent)) { |
||||
setEvent(fetchedEvent) |
||||
addReplies([fetchedEvent]) |
||||
} else { |
||||
setEvent(undefined) |
||||
} |
||||
} catch (err) { |
||||
if (!cancelled) { |
||||
setError(err as Error) |
||||
setEvent(undefined) |
||||
} |
||||
} finally { |
||||
if (!cancelled) { |
||||
setIsFetching(false) |
||||
} |
||||
} |
||||
} |
||||
|
||||
void fetchWithFallback() |
||||
|
||||
return () => { |
||||
cancelled = true |
||||
setIsFetching(false) |
||||
} |
||||
}, [ |
||||
eventId, |
||||
contextEvent, |
||||
targetTag, |
||||
initialEvent, |
||||
isEventDeleted, |
||||
addReplies, |
||||
refetchToken, |
||||
viewerPubkey, |
||||
blockedKey, |
||||
role |
||||
]) |
||||
|
||||
useEffect(() => { |
||||
if (event && isEventDeleted(event)) { |
||||
setEvent(undefined) |
||||
} |
||||
}, [isEventDeleted, event]) |
||||
|
||||
useEffect(() => { |
||||
if (!eventId || event !== undefined) return undefined |
||||
return eventService.subscribeWhenSessionHasEvent(eventId, refetch) |
||||
}, [eventId, event, refetch]) |
||||
|
||||
return { isFetching, error, event, refetch } |
||||
} |
||||
@ -0,0 +1,42 @@
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from 'vitest' |
||||
import { getParentEventHexId, getRootEventHexId } from '@/lib/event' |
||||
import { pubkeyFromThreadETag, relayHintsFromThreadETag } from '@/lib/thread-context-relays' |
||||
|
||||
/** Damus-style NIP-10 reply (user report: parent missing on note page). */ |
||||
const DAMUS_REPLY_TAGS: string[][] = [ |
||||
[ |
||||
'e', |
||||
'1dae240e0fe68c331cd9f0923f756c148fb7cf0344a7229c7831b676d6102e71', |
||||
'wss://theforest.nostr1.com/', |
||||
'root', |
||||
'b133bfc57bed61c391d4e8f953b906c7f1709c438d91c75fb6daf79449d5789d' |
||||
], |
||||
[ |
||||
'e', |
||||
'c89a8525b4ef8fd3dd149824e6d4f854de8b3120d26286942fd2aabce2d72304', |
||||
'wss://relay.mostr.pub', |
||||
'reply', |
||||
'dd664d5e4016433a8cd69f005ae1480804351789b59de5af06276de65633d319' |
||||
] |
||||
] |
||||
|
||||
describe('NIP-10 Damus reply thread tags', () => { |
||||
const reply = { kind: 1, tags: DAMUS_REPLY_TAGS } as import('nostr-tools').Event |
||||
|
||||
it('resolves parent and root hex ids', () => { |
||||
expect(getParentEventHexId(reply)).toBe( |
||||
'c89a8525b4ef8fd3dd149824e6d4f854de8b3120d26286942fd2aabce2d72304' |
||||
) |
||||
expect(getRootEventHexId(reply)).toBe( |
||||
'1dae240e0fe68c331cd9f0923f756c148fb7cf0344a7229c7831b676d6102e71' |
||||
) |
||||
}) |
||||
|
||||
it('reads relay hint and parent author from reply e tag', () => { |
||||
const parentTag = DAMUS_REPLY_TAGS[1] |
||||
expect(relayHintsFromThreadETag(parentTag)).toEqual(['wss://relay.mostr.pub/']) |
||||
expect(pubkeyFromThreadETag(parentTag)).toBe( |
||||
'dd664d5e4016433a8cd69f005ae1480804351789b59de5af06276de65633d319' |
||||
) |
||||
}) |
||||
}) |
||||
@ -0,0 +1,42 @@
@@ -0,0 +1,42 @@
|
||||
import { urlIsNonLocalForRemoteViewer } from '@/lib/relay-list-sanitize' |
||||
import { buildReplyReadRelayList, relayHintsFromEventTags } from '@/lib/relay-list-builder' |
||||
import { normalizeUrl } from '@/lib/url' |
||||
import type { Event } from 'nostr-tools' |
||||
|
||||
/** NIP-10 optional pubkey on an `e` tag (field 4 when field 3 is a marker). */ |
||||
export function pubkeyFromThreadETag(tag: string[] | undefined): string | undefined { |
||||
if (!tag) return undefined |
||||
const markerOrPubkey = tag[3] |
||||
const pubkeyField = tag[4] |
||||
const candidates = [pubkeyField, markerOrPubkey] |
||||
for (const v of candidates) { |
||||
if (typeof v === 'string' && /^[0-9a-f]{64}$/i.test(v)) { |
||||
return v.toLowerCase() |
||||
} |
||||
} |
||||
return undefined |
||||
} |
||||
|
||||
/** Relay hint from a single `e` / `E` tag (third field). */ |
||||
export function relayHintsFromThreadETag(tag: string[] | undefined): string[] { |
||||
if (!tag?.[2] || typeof tag[2] !== 'string') return [] |
||||
const n = normalizeUrl(tag[2]) || tag[2] |
||||
if (!n || !urlIsNonLocalForRemoteViewer(n)) return [] |
||||
return [n] |
||||
} |
||||
|
||||
/** |
||||
* Relays to REQ a thread parent/root: tag-specific hint first, then all `e` hints on the reply, |
||||
* plus author NIP-65 when the referenced note's author pubkey is on the tag. |
||||
*/ |
||||
export async function buildThreadContextFetchRelayUrls( |
||||
contextEvent: Event, |
||||
targetTag: string[] | undefined, |
||||
viewerPubkey: string | undefined, |
||||
blockedRelays: string[] = [] |
||||
): Promise<string[]> { |
||||
const tagHints = relayHintsFromThreadETag(targetTag) |
||||
const threadRelayHints = [...new Set([...tagHints, ...relayHintsFromEventTags(contextEvent)])] |
||||
const opAuthorPubkey = pubkeyFromThreadETag(targetTag) |
||||
return buildReplyReadRelayList(opAuthorPubkey, viewerPubkey, blockedRelays, threadRelayHints) |
||||
} |
||||
@ -0,0 +1,32 @@
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from 'vitest' |
||||
import { nip19 } from 'nostr-tools' |
||||
import { candidateKeysForNoteUrlId, navigationEventStore } from './navigation-event-store' |
||||
|
||||
describe('navigationEventStore', () => { |
||||
it('aliases hex and nevent url ids', () => { |
||||
const id = 'c89a8525b4ef8fd3dd149824e6d4f854de8b3120d26286942fd2aabce2d72304' |
||||
const nevent = nip19.neventEncode({ id }) |
||||
const event = { |
||||
id, |
||||
kind: 1, |
||||
pubkey: 'dd664d5e4016433a8cd69f005ae1480804351789b59de5af06276de65633d319', |
||||
created_at: 1, |
||||
tags: [], |
||||
content: 'hello', |
||||
sig: 'x'.repeat(128) |
||||
} |
||||
navigationEventStore.clear() |
||||
navigationEventStore.setEvent(event as import('nostr-tools').Event, nevent) |
||||
expect(navigationEventStore.peekEvent(nevent)?.id).toBe(id) |
||||
expect(navigationEventStore.peekEvent(id)?.content).toBe('hello') |
||||
navigationEventStore.clear() |
||||
}) |
||||
|
||||
it('decodes nevent keys for lookup', () => { |
||||
const id = '88687efb89dc05a72a2505f61aa4f87579e87b43aa632b911dae2cdf916b621e' |
||||
const nevent = nip19.neventEncode({ id }) |
||||
const keys = candidateKeysForNoteUrlId(nevent) |
||||
expect(keys).toContain(nevent) |
||||
expect(keys.map((k) => k.toLowerCase())).toContain(id) |
||||
}) |
||||
}) |
||||
Loading…
Reference in new issue