diff --git a/src/components/ReplyNoteList/index.tsx b/src/components/ReplyNoteList/index.tsx index 3cea481b..f0f2d207 100644 --- a/src/components/ReplyNoteList/index.tsx +++ b/src/components/ReplyNoteList/index.tsx @@ -846,8 +846,26 @@ function ReplyNoteList({ filters.push(...buildRssArticleUrlThreadInteractionFilters(rootInfo.id, LIMIT)) } + // For URL threads: stream events as they arrive from each relay so replies appear + // immediately, rather than waiting up to 10 s for all relays to EOSE. + const urlThreadRootInfo = rootInfo.type === 'I' ? rootInfo : null + const urlThreadOnevent = urlThreadRootInfo + ? (evt: NEvent) => { + if (fetchGeneration !== replyFetchGenRef.current) return + if (!isRssArticleUrlThreadInteraction(evt, urlThreadRootInfo.id)) return + if (shouldHideThreadResponseEvent(evt, mutePubkeySet, hideContentMentioningMutedUsers)) + return + addReplies([evt]) + if (!hasCache) setLoading(false) + } + : undefined + // Use fetchEvents instead of subscribeTimeline for one-time fetching - const allReplies = await queryService.fetchEvents(finalRelayUrls, filters) + const allReplies = await queryService.fetchEvents( + finalRelayUrls, + filters, + urlThreadOnevent ? { onevent: urlThreadOnevent } : undefined + ) if (fetchGeneration !== replyFetchGenRef.current) return @@ -887,6 +905,43 @@ function ReplyNoteList({ // No cache: stop loading after adding replies setLoading(false) } + + // Second pass for URL threads: fetch replies to individual comments that may omit the + // root I tag (non-NIP-22-compliant clients). NoteStats counts them via #e; without this + // pass they appear as reply counts only, with no actual content shown. + if (rootInfo.type === 'I' && regularReplies.length > 0) { + const commentKinds = [ + ExtendedKind.COMMENT, + ExtendedKind.VOICE_COMMENT, + kinds.ShortTextNote + ] + const parentIds = regularReplies + .filter((evt) => commentKinds.includes(evt.kind)) + .map((evt) => evt.id) + if (parentIds.length > 0) { + const nestedFilters: Filter[] = [ + { '#e': parentIds, kinds: commentKinds, limit: LIMIT } + ] + const nestedReplies = await queryService.fetchEvents(finalRelayUrls, nestedFilters, { + onevent: (evt: NEvent) => { + if (fetchGeneration !== replyFetchGenRef.current) return + if (shouldHideThreadResponseEvent(evt, mutePubkeySet, hideContentMentioningMutedUsers)) + return + addReplies([evt]) + } + }) + if (fetchGeneration !== replyFetchGenRef.current) return + const validNested = nestedReplies.filter( + (evt) => + !shouldHideThreadResponseEvent(evt, mutePubkeySet, hideContentMentioningMutedUsers) + ) + if (validNested.length > 0) { + discussionFeedCache.setCachedReplies(rootInfo, validNested) + const merged = discussionFeedCache.getCachedReplies(rootInfo) + addReplies(merged ?? validNested) + } + } + } } catch (error) { logger.error('[ReplyNoteList] Error fetching replies:', error) if (fetchGeneration !== replyFetchGenRef.current) return diff --git a/src/components/RssWebFeedCard/index.tsx b/src/components/RssWebFeedCard/index.tsx index 3c33c2f6..b8a993be 100644 --- a/src/components/RssWebFeedCard/index.tsx +++ b/src/components/RssWebFeedCard/index.tsx @@ -57,7 +57,7 @@ export default function RssWebFeedCard({ }} >
{hasRealRss ? ( @@ -65,7 +65,7 @@ export default function RssWebFeedCard({ ) : ( )} - {hasRealRss ? t('RSS feed item label') : t('Web URL item label')} + {canonicalUrl}
diff --git a/src/lib/event-metadata.ts b/src/lib/event-metadata.ts index a215cd64..563fbb0f 100644 --- a/src/lib/event-metadata.ts +++ b/src/lib/event-metadata.ts @@ -1,4 +1,4 @@ -import { FAST_READ_RELAY_URLS, POLL_TYPE } from '@/constants' +import { ExtendedKind, FAST_READ_RELAY_URLS, POLL_TYPE } from '@/constants' import { TEmoji, TMailboxRelay, TPollType, TRelayList, TRelaySet, TPaymentInfo, TProfile } from '@/types' import { Event, kinds } from 'nostr-tools' import { buildATag } from './draft-event' @@ -343,7 +343,43 @@ export function getRelaySetFromEvent(event: Event, blockedRelays?: string[]): TR } export function getZapInfoFromEvent(receiptEvent: Event) { - if (receiptEvent.kind !== kinds.Zap) return null + if (receiptEvent.kind !== kinds.Zap && receiptEvent.kind !== ExtendedKind.ZAP_REQUEST) return null + + // Kind 9734 — zap request: all data is directly on the event (no bolt11, no description wrapper). + if (receiptEvent.kind === ExtendedKind.ZAP_REQUEST) { + const senderPubkey = receiptEvent.pubkey + let recipientPubkey: string | undefined + let originalEventId: string | undefined + let eventId: string | undefined + let amount: number | undefined + const comment = receiptEvent.content || undefined + try { + receiptEvent.tags.forEach((tag) => { + const [tagName, tagValue] = tag + switch (tagName) { + case 'p': + recipientPubkey = tagValue + break + case 'e': + case 'E': + originalEventId = tag[1] + eventId = generateBech32IdFromETag(tag) + break + case 'a': + originalEventId = tag[1] + eventId = generateBech32IdFromATag(tag) + break + case 'amount': + if (tagValue) amount = Math.floor(parseInt(tagValue, 10) / 1000) + break + } + }) + if (!recipientPubkey || !amount) return null + return { senderPubkey, recipientPubkey, eventId, originalEventId, invoice: undefined, amount, comment, preimage: undefined } + } catch { + return null + } + } let senderPubkey: string | undefined let recipientPubkey: string | undefined diff --git a/src/lib/thread-reply-root-match.ts b/src/lib/thread-reply-root-match.ts index 53e2c30a..7499c99f 100644 --- a/src/lib/thread-reply-root-match.ts +++ b/src/lib/thread-reply-root-match.ts @@ -102,6 +102,17 @@ export function eventReplyMatchesThreadRoot(evt: Event, root: TThreadRootRef): b const hu = getHighlightSourceHttpUrl(evt) return !!hu && canonicalizeRssArticleUrl(hu) === canonicalizeRssArticleUrl(root.id) } + // Some clients omit the root I tag on nested replies. Walk one level up via the session + // cache: if the declared root or direct parent is a URL-thread comment, accept this event. + const urlMatchesRoot = (hexId: string | undefined): boolean => { + if (!hexId || !/^[0-9a-f]{64}$/i.test(hexId)) return false + const ancestor = client.peekSessionCachedEvent(hexId.toLowerCase()) + if (!ancestor) return false + const aUrl = getArticleUrlFromCommentITags(ancestor) + return !!aUrl && canonicalizeRssArticleUrl(aUrl) === canonicalizeRssArticleUrl(root.id) + } + if (urlMatchesRoot(getRootEventHexId(evt))) return true + if (urlMatchesRoot(getParentEventHexId(evt))) return true return false } if (root.type === 'A') {