8 changed files with 341 additions and 25 deletions
@ -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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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