diff --git a/src/components/PostEditor/PostContent.tsx b/src/components/PostEditor/PostContent.tsx index a598b260..d982bc45 100644 --- a/src/components/PostEditor/PostContent.tsx +++ b/src/components/PostEditor/PostContent.tsx @@ -63,7 +63,8 @@ import { import { getMediaKindFromFile } from '@/lib/media-kind-detection' import { hasPrivateRelays, getPrivateRelayUrls } from '@/lib/private-relays' import mediaUpload from '@/services/media-upload.service' -import client from '@/services/client.service' +import { successfulPublishRelayUrls, type TRelayPublishStatus } from '@/lib/publish-relay-urls' +import client, { eventService } from '@/services/client.service' import discussionFeedCache from '@/services/discussion-feed-cache.service' import CreateThreadDialog from '@/pages/primary/DiscussionsPage/CreateThreadDialog' import { getReplaceableCoordinateFromEvent, isProtectedEvent as isEventProtected, isReplaceableEvent, isReplyNoteEvent } from '@/lib/event' @@ -104,6 +105,42 @@ export default function PostContent({ const { pubkey, publish, checkLogin } = useNostr() const { feedInfo } = useFeed() const { addReplies } = useReply() + + const mergePublishedReplyIntoThread = useCallback( + (reply: Event, relayStatuses?: TRelayPublishStatus[]) => { + if (!parentEvent) return + const clean = { ...reply } as Event + delete (clean as any).relayStatuses + addReplies([clean]) + const rootInfo = !isReplaceableEvent(parentEvent.kind) + ? { type: 'E' as const, id: parentEvent.id, pubkey: parentEvent.pubkey } + : { + type: 'A' as const, + id: getReplaceableCoordinateFromEvent(parentEvent), + eventId: parentEvent.id, + pubkey: parentEvent.pubkey, + relay: client.getEventHint(parentEvent.id) + } + const cached = discussionFeedCache.getCachedReplies(rootInfo) ?? [] + const next = cached.filter((r) => r.id !== clean.id).concat([clean]) + discussionFeedCache.setCachedReplies(rootInfo, next) + + const urls = successfulPublishRelayUrls(relayStatuses) + if (!clean.id || urls.length === 0) return + + const delayMs = 1600 + setTimeout(() => { + void eventService.fetchEventWithExternalRelays(clean.id, urls).then((fresh) => { + if (!fresh || fresh.id !== clean.id) return + addReplies([fresh]) + const merged = (discussionFeedCache.getCachedReplies(rootInfo) ?? []).filter((r) => r.id !== fresh.id) + discussionFeedCache.setCachedReplies(rootInfo, [...merged, fresh]) + client.addEventToCache(fresh) + }) + }, delayMs) + }, + [addReplies, parentEvent] + ) const [text, setText] = useState('') const textareaRef = useRef(null) const [posting, setPosting] = useState(false) @@ -875,20 +912,12 @@ export default function PostContent({ // Full success - clean up and close postEditorCache.clearPostCache({ defaultContent, parentEvent }) deleteDraftEventCache(draftEvent) - // Remove relayStatuses before storing the event (it's only for UI feedback) + const relayStatuses = (newEvent as any).relayStatuses as TRelayPublishStatus[] | undefined const cleanEvent = { ...newEvent } delete (cleanEvent as any).relayStatuses - // Reply: add to UI and cache immediately so it shows without tabbing away (publish already emitted via NostrProvider) if (parentEvent) { - addReplies([cleanEvent]) - const rootInfo = !isReplaceableEvent(parentEvent.kind) - ? { type: 'E' as const, id: parentEvent.id, pubkey: parentEvent.pubkey } - : { type: 'A' as const, id: getReplaceableCoordinateFromEvent(parentEvent), eventId: parentEvent.id, pubkey: parentEvent.pubkey, relay: client.getEventHint(parentEvent.id) } - const cached = discussionFeedCache.getCachedReplies(rootInfo) ?? [] - if (!cached.some((r) => r.id === cleanEvent.id)) { - discussionFeedCache.setCachedReplies(rootInfo, [...cached, cleanEvent]) - } + mergePublishedReplyIntoThread(cleanEvent, relayStatuses) } close() @@ -928,14 +957,7 @@ export default function PostContent({ if (parentEvent && partialEvent) { const clean = { ...partialEvent } delete (clean as any).relayStatuses - addReplies([clean]) - const rootInfo = !isReplaceableEvent(parentEvent.kind) - ? { type: 'E' as const, id: parentEvent.id, pubkey: parentEvent.pubkey } - : { type: 'A' as const, id: getReplaceableCoordinateFromEvent(parentEvent), eventId: parentEvent.id, pubkey: parentEvent.pubkey, relay: client.getEventHint(parentEvent.id) } - const cached = discussionFeedCache.getCachedReplies(rootInfo) ?? [] - if (!cached.some((r) => r.id === clean.id)) { - discussionFeedCache.setCachedReplies(rootInfo, [...cached, clean]) - } + mergePublishedReplyIntoThread(clean, (error as any).relayStatuses) } postEditorCache.clearPostCache({ defaultContent, parentEvent }) if (draftEvent) deleteDraftEventCache(draftEvent) diff --git a/src/components/ReplyNoteList/index.tsx b/src/components/ReplyNoteList/index.tsx index 03d39a0d..bf359144 100644 --- a/src/components/ReplyNoteList/index.tsx +++ b/src/components/ReplyNoteList/index.tsx @@ -28,6 +28,7 @@ import { eventService, queryService } from '@/services/client.service' import noteStatsService from '@/services/note-stats.service' import discussionFeedCache from '@/services/discussion-feed-cache.service' import { buildReplyReadRelayList, relayHintsFromEventTags } from '@/lib/relay-list-builder' +import { eventReplyMatchesThreadRoot } from '@/lib/thread-reply-root-match' import { Filter, Event as NEvent, kinds } from 'nostr-tools' import { useNoteStatsById } from '@/hooks/useNoteStatsById' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' @@ -272,27 +273,20 @@ function ReplyNoteList({ const onNewReply = useCallback((evt: NEvent) => { addReplies([evt]) - // Also update the discussion cache so the reply persists if (rootInfo) { const cachedReplies = discussionFeedCache.getCachedReplies(rootInfo) || [] - const existingReplyIds = new Set(cachedReplies.map(r => r.id)) - if (!existingReplyIds.has(evt.id)) { - discussionFeedCache.setCachedReplies(rootInfo, [...cachedReplies, evt]) - } + const without = cachedReplies.filter((r) => r.id !== evt.id) + discussionFeedCache.setCachedReplies(rootInfo, [...without, evt]) } }, [addReplies, rootInfo]) useEffect(() => { if (!rootInfo) return const handleEventPublished = (data: Event) => { - const customEvent = data as CustomEvent - const evt = customEvent.detail - const articleThreadUrl = rootInfo.type === 'I' ? getArticleUrlFromCommentITags(evt) : undefined - const matchesThread = - rootInfo.type === 'I' - ? articleThreadUrl === rootInfo.id - : getRootEventHexId(evt) === rootInfo.id - if (matchesThread && isReplyNoteEvent(evt)) { + const ce = data as CustomEvent + const evt = ce.detail + if (!evt || !isReplyNoteEvent(evt)) return + if (eventReplyMatchesThreadRoot(evt, rootInfo)) { onNewReply(evt) } } diff --git a/src/lib/publish-relay-urls.ts b/src/lib/publish-relay-urls.ts new file mode 100644 index 00000000..09654a0b --- /dev/null +++ b/src/lib/publish-relay-urls.ts @@ -0,0 +1,16 @@ +import { normalizeUrl } from '@/lib/url' + +export type TRelayPublishStatus = { url: string; success: boolean } + +/** Normalized relay URLs that accepted the event (for follow-up REQ). */ +export function successfulPublishRelayUrls(relayStatuses: TRelayPublishStatus[] | undefined): string[] { + if (!relayStatuses?.length) return [] + return Array.from( + new Set( + relayStatuses + .filter((s) => s.success) + .map((s) => normalizeUrl(s.url) || s.url) + .filter(Boolean) + ) + ) +} diff --git a/src/lib/thread-reply-root-match.ts b/src/lib/thread-reply-root-match.ts new file mode 100644 index 00000000..faff9fcb --- /dev/null +++ b/src/lib/thread-reply-root-match.ts @@ -0,0 +1,24 @@ +import { getRootATag, getRootEventHexId } from '@/lib/event' +import { getArticleUrlFromCommentITags } from '@/lib/rss-article' +import type { Event } from 'nostr-tools' + +/** Matches `ReplyNoteList` / discussion thread root shapes. */ +export type TThreadRootRef = + | { type: 'E'; id: string; pubkey: string } + | { type: 'A'; id: string; eventId: string; pubkey: string; relay?: string } + | { type: 'I'; id: string } + +/** Whether a newly published/fetched reply belongs to the thread rooted at `root`. */ +export function eventReplyMatchesThreadRoot(evt: Event, root: TThreadRootRef): boolean { + if (root.type === 'I') { + return getArticleUrlFromCommentITags(evt) === root.id + } + if (root.type === 'A') { + const coord = getRootATag(evt)?.[1] + if (coord === root.id) return true + const rootHex = getRootEventHexId(evt) + if (rootHex && (rootHex === root.eventId || rootHex === root.id)) return true + return false + } + return getRootEventHexId(evt) === root.id +} diff --git a/src/providers/ReplyProvider.tsx b/src/providers/ReplyProvider.tsx index 7b0f52d6..dd550467 100644 --- a/src/providers/ReplyProvider.tsx +++ b/src/providers/ReplyProvider.tsx @@ -69,7 +69,11 @@ export function ReplyProvider({ children }: { children: React.ReactNode }) { for (const [id, newReplyEvents] of newReplyEventMap.entries()) { const replies = prev.get(id) || { events: [], eventIdSet: new Set() } newReplyEvents.forEach((reply) => { - if (!replies.eventIdSet.has(reply.id)) { + const existingIdx = replies.events.findIndex((e) => e.id === reply.id) + if (existingIdx >= 0) { + replies.events[existingIdx] = reply + replies.eventIdSet.add(reply.id) + } else { replies.events.push(reply) replies.eventIdSet.add(reply.id) }