Browse Source

render newly-published notes

imwald
Silberengel 1 month ago
parent
commit
7f99cc5d36
  1. 60
      src/components/PostEditor/PostContent.tsx
  2. 20
      src/components/ReplyNoteList/index.tsx
  3. 16
      src/lib/publish-relay-urls.ts
  4. 24
      src/lib/thread-reply-root-match.ts
  5. 6
      src/providers/ReplyProvider.tsx

60
src/components/PostEditor/PostContent.tsx

@ -63,7 +63,8 @@ import { @@ -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({ @@ -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<TPostTextareaHandle>(null)
const [posting, setPosting] = useState(false)
@ -875,20 +912,12 @@ export default function PostContent({ @@ -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({ @@ -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)

20
src/components/ReplyNoteList/index.tsx

@ -28,6 +28,7 @@ import { eventService, queryService } from '@/services/client.service' @@ -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({ @@ -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<NEvent>
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<NEvent>
const evt = ce.detail
if (!evt || !isReplyNoteEvent(evt)) return
if (eventReplyMatchesThreadRoot(evt, rootInfo)) {
onNewReply(evt)
}
}

16
src/lib/publish-relay-urls.ts

@ -0,0 +1,16 @@ @@ -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)
)
)
}

24
src/lib/thread-reply-root-match.ts

@ -0,0 +1,24 @@ @@ -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
}

6
src/providers/ReplyProvider.tsx

@ -69,7 +69,11 @@ export function ReplyProvider({ children }: { children: React.ReactNode }) { @@ -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)
}

Loading…
Cancel
Save