From 1bfd1dfbadfa76fdcbc72b9e796d54adaf8b9c35 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Sat, 28 Mar 2026 07:53:26 +0100 Subject: [PATCH] add responses to zaps and reactions in the reply thread --- package-lock.json | 4 +- package.json | 2 +- src/components/ContentPreview/index.tsx | 19 ++++++++- src/components/Note/ReactionEmojiDisplay.tsx | 19 ++++++--- src/components/Note/Zap.tsx | 34 +++++++-------- src/components/ParentNotePreview/index.tsx | 28 +++++++----- src/components/ReplyNote/index.tsx | 30 ++++++++----- src/components/ReplyNoteList/index.tsx | 3 +- src/constants.ts | 2 - src/lib/thread-reply-root-match.ts | 45 +++++++++++++++++++- src/providers/ReplyProvider.tsx | 12 +++++- src/services/client-events.service.ts | 4 +- src/services/note-stats.service.ts | 27 +++++------- 13 files changed, 155 insertions(+), 74 deletions(-) diff --git a/package-lock.json b/package-lock.json index eb0b11d8..6e7c1d4c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "jumble-imwald", - "version": "21.0.0", + "version": "21.0.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "jumble-imwald", - "version": "21.0.0", + "version": "21.0.2", "license": "MIT", "dependencies": { "@asciidoctor/core": "^3.0.4", diff --git a/package.json b/package.json index 07551acd..2f3cead7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "jumble-imwald", - "version": "21.0.0", + "version": "21.0.2", "description": "A user-friendly Nostr client focused on relay feed browsing and relay discovery, forked from Jumble", "private": true, "type": "module", diff --git a/src/components/ContentPreview/index.tsx b/src/components/ContentPreview/index.tsx index ab1751fd..6c840516 100644 --- a/src/components/ContentPreview/index.tsx +++ b/src/components/ContentPreview/index.tsx @@ -31,6 +31,7 @@ import ApplicationHandlerRecommendation from '../ApplicationHandlerRecommendatio import FollowPackPreview from './FollowPackPreview' import ReactionEmojiDisplay from '../Note/ReactionEmojiDisplay' import NoteKindLabel from '../Note/NoteKindLabel' +import Zap from '../Note/Zap' import GitRepublicEventCard from '../Note/GitRepublicEventCard' /** Inert event so hooks can run before `event` is defined. */ @@ -62,10 +63,13 @@ function splitPreviewLayoutClasses(className?: string) { export default function ContentPreview({ event, - className + className, + /** Inline parent lines (e.g. reply thread): zap receipts match compact thread styling. */ + previewDensity }: { event?: Event className?: string + previewDensity?: 'default' | 'compact' }) { const { t } = useTranslation() const reactionDisplay = useNotificationReactionDisplay(event ?? CONTENT_PREVIEW_HOOK_PLACEHOLDER) @@ -168,7 +172,18 @@ export default function ContentPreview({ return withKindRow() } - if (event.kind === ExtendedKind.ZAP_REQUEST || event.kind === ExtendedKind.ZAP_RECEIPT) { + if (event.kind === ExtendedKind.ZAP_REQUEST) { + return withKindRow() + } + + if (event.kind === ExtendedKind.ZAP_RECEIPT || event.kind === kinds.Zap) { + if (previewDensity === 'compact') { + return ( +
+ +
+ ) + } return withKindRow() } diff --git a/src/components/Note/ReactionEmojiDisplay.tsx b/src/components/Note/ReactionEmojiDisplay.tsx index 5e956a74..ced0b93f 100644 --- a/src/components/Note/ReactionEmojiDisplay.tsx +++ b/src/components/Note/ReactionEmojiDisplay.tsx @@ -21,8 +21,8 @@ export default function ReactionEmojiDisplay({ className?: string /** Truncate long reaction text beyond this length */ maxRawLength?: number - /** Compact row (notification list at-a-glance) */ - variant?: 'default' | 'compact' + /** Compact row (notification list); `thread` matches reply-list density */ + variant?: 'default' | 'compact' | 'thread' }) { const sync = useMemo( () => resolveReactionEmojiSync(event, maxRawLength), @@ -69,10 +69,17 @@ export default function ReactionEmojiDisplay({ emoji={value} classNames={{ img: - variant === 'compact' - ? 'size-4 max-h-[1em] w-auto rounded-sm' - : 'size-7 max-h-[1.5em] w-auto rounded-sm', - text: variant === 'compact' ? 'text-base leading-none' : 'text-2xl leading-none' + variant === 'thread' + ? 'size-3.5 max-h-[1em] w-auto rounded-sm opacity-90' + : variant === 'compact' + ? 'size-4 max-h-[1em] w-auto rounded-sm' + : 'size-7 max-h-[1.5em] w-auto rounded-sm', + text: + variant === 'thread' + ? 'text-sm leading-none' + : variant === 'compact' + ? 'text-base leading-none' + : 'text-2xl leading-none' }} /> diff --git a/src/components/Note/Zap.tsx b/src/components/Note/Zap.tsx index 981e35a4..a8d6a273 100644 --- a/src/components/Note/Zap.tsx +++ b/src/components/Note/Zap.tsx @@ -46,8 +46,10 @@ export default function Zap({ return (
@@ -89,27 +91,25 @@ export default function Zap({ if (variant === 'compact') { return ( -
-
- - {formatAmount(amount)} - {t('sats')} +
+
+ + {formatAmount(amount)} + {t('sats')} {recipientPubkey && recipientPubkey !== senderPubkey && ( - - {t('zapped')}{' '} - + + {t('zapped')}{' '} + )} {(isEventZap || isProfileZap) && (
{comment ? ( -

+

{comment}

) : null} diff --git a/src/components/ParentNotePreview/index.tsx b/src/components/ParentNotePreview/index.tsx index 7ddc54e9..b1b9359d 100644 --- a/src/components/ParentNotePreview/index.tsx +++ b/src/components/ParentNotePreview/index.tsx @@ -13,11 +13,14 @@ import logger from '@/lib/logger' export default function ParentNotePreview({ eventId, className, - onClick + onClick, + /** Inline hint without pill background (e.g. reply thread rows). */ + appearance = 'default' }: { eventId: string className?: string onClick?: React.MouseEventHandler | undefined + appearance?: 'default' | 'subtle' }) { const { t } = useTranslation() const { event, isFetching } = useFetchEvent(eventId) @@ -66,18 +69,17 @@ export default function ParentNotePreview({ const finalEvent = event || fallbackEvent const finalIsFetching = isFetching || isFetchingFallback + const shellClass = + appearance === 'subtle' + ? 'flex gap-1.5 items-center text-xs w-full max-w-full text-muted-foreground' + : 'flex gap-1 items-center text-sm rounded-full px-2 bg-muted w-fit max-w-full text-muted-foreground' + if (finalIsFetching) { return ( -
+
{t('reply to')}
-
+
@@ -99,7 +101,7 @@ export default function ParentNotePreview({
{t('reply to')}
{finalEvent && }
- +
) diff --git a/src/components/ReplyNote/index.tsx b/src/components/ReplyNote/index.tsx index ac3d961a..2b83e4fb 100644 --- a/src/components/ReplyNote/index.tsx +++ b/src/components/ReplyNote/index.tsx @@ -15,6 +15,7 @@ import { getZapInfoFromEvent } from '@/lib/event-metadata' import { isMentioningMutedUsers, isNip25ReactionKind } from '@/lib/event' import { getWebExternalReactionTargetUrl } from '@/lib/rss-article' import { toNote } from '@/lib/link' +import { cn } from '@/lib/utils' import { useContentPolicy } from '@/providers/ContentPolicyProvider' import { useMuteList } from '@/contexts/mute-list-context' import { useScreenSize } from '@/providers/ScreenSizeProvider' @@ -126,14 +127,23 @@ export default function ReplyNote({
- + {webReactionParentUrl ? ( -
+
) : parentEventId ? ( { e.stopPropagation() @@ -143,24 +153,24 @@ export default function ReplyNote({ ) : null} {show ? ( isNip25ReactionKind(event.kind) ? ( -
+
{reactionDisplay.status === 'pending' ? ( - + ) : reactionDisplay.status === 'vote_up' ? ( - + {DISCUSSION_UPVOTE_DISPLAY} ) : reactionDisplay.status === 'vote_down' ? ( - + {DISCUSSION_DOWNVOTE_DISPLAY} ) : ( - + )} - {t(notificationReactionSummaryKey(reactionDisplay))} + {t(notificationReactionSummaryKey(reactionDisplay))}
) : event.kind === kinds.Zap ? ( - + ) : ( !isNip25ReactionKind(evt.kind)) .map((evt) => evt.id) .filter((id) => !processedEventIds.has(id)) diff --git a/src/constants.ts b/src/constants.ts index 30d11b76..181bc85c 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -270,8 +270,6 @@ export const SEARCHABLE_RELAY_URLS = [ 'wss://relay.wikifreedia.xyz', 'wss://nostr.einundzwanzig.space', 'wss://nostrelites.org', - 'wss://relay.nsec.app', - 'wss://bucket.coracle.social', 'wss://spatia-arcana.com', 'wss://nostr-pub.wellorder.net', 'wss://pyramid.fiatjaf.com/', diff --git a/src/lib/thread-reply-root-match.ts b/src/lib/thread-reply-root-match.ts index c9a32064..cfa5b877 100644 --- a/src/lib/thread-reply-root-match.ts +++ b/src/lib/thread-reply-root-match.ts @@ -3,16 +3,55 @@ import { getQuotedEventHexIdFromQTags, getRootATag, getRootEventHexId, + isNip25ReactionKind, kind1QuotesThreadRoot } from '@/lib/event' +import { getZapInfoFromEvent } from '@/lib/event-metadata' +import { getFirstHexEventIdFromETags } from '@/lib/tag' import { canonicalizeRssArticleUrl, getArticleUrlFromCommentITags, getHighlightSourceHttpUrl } from '@/lib/rss-article' +import client from '@/services/client.service' import type { Event } from 'nostr-tools' import { kinds } from 'nostr-tools' +/** Reply whose direct parent is a zap receipt for this thread root (hex id). */ +function replyParentIsZapToRootHex(reply: Event, rootHexLower: string): boolean { + const parentHex = getParentEventHexId(reply) + if (!parentHex || !/^[0-9a-f]{64}$/i.test(parentHex)) return false + const pl = parentHex.toLowerCase() + if (pl === rootHexLower) return false + const parentEv = client.peekSessionCachedEvent(pl) + if (!parentEv || parentEv.kind !== kinds.Zap) return false + const zapped = getZapInfoFromEvent(parentEv)?.originalEventId + return ( + !!zapped && + /^[0-9a-f]{64}$/i.test(zapped) && + zapped.toLowerCase() === rootHexLower + ) +} + +function reactionTargetNoteHex(reaction: Event): string | undefined { + const fromParent = getParentEventHexId(reaction) + if (fromParent && /^[0-9a-f]{64}$/i.test(fromParent)) return fromParent.toLowerCase() + const first = getFirstHexEventIdFromETags(reaction.tags) + if (first && /^[0-9a-f]{64}$/i.test(first)) return first.toLowerCase() + return undefined +} + +/** Reply whose direct parent is a NIP-25 / kind-17 reaction to this thread root note. */ +function replyParentIsReactionToRootHex(reply: Event, rootHexLower: string): boolean { + const parentHex = getParentEventHexId(reply) + if (!parentHex || !/^[0-9a-f]{64}$/i.test(parentHex)) return false + const pl = parentHex.toLowerCase() + if (pl === rootHexLower) return false + const parentEv = client.peekSessionCachedEvent(pl) + if (!parentEv || !isNip25ReactionKind(parentEv.kind)) return false + return reactionTargetNoteHex(parentEv) === rootHexLower +} + /** Matches `ReplyNoteList` / discussion thread root shapes. */ export type TThreadRootRef = | { type: 'E'; id: string; pubkey: string } @@ -37,7 +76,11 @@ export function eventReplyMatchesThreadRoot(evt: Event, root: TThreadRootRef): b if (rootHex && (rootHex === root.eventId || rootHex === root.id)) return true return kind1QuotesThreadRoot(evt, root) } - if (getRootEventHexId(evt) === root.id) return true + const rid = root.id.trim().toLowerCase() + const evtRootHex = getRootEventHexId(evt)?.toLowerCase() + if (evtRootHex === rid) return true + if (replyParentIsZapToRootHex(evt, rid)) return true + if (replyParentIsReactionToRootHex(evt, rid)) return true return kind1QuotesThreadRoot(evt, root) } diff --git a/src/providers/ReplyProvider.tsx b/src/providers/ReplyProvider.tsx index c0892a8d..972a920d 100644 --- a/src/providers/ReplyProvider.tsx +++ b/src/providers/ReplyProvider.tsx @@ -11,6 +11,7 @@ import { getRootETag, isNip25ReactionKind } from '@/lib/event' +import { getFirstHexEventIdFromETags } from '@/lib/tag' import client from '@/services/client.service' import { Event, kinds } from 'nostr-tools' import { createContext, useCallback, useContext, useState } from 'react' @@ -40,7 +41,16 @@ export function ReplyProvider({ children }: { children: React.ReactNode }) { const newReplyEventMap = new Map() replies.forEach((reply) => { if (newReplyIdSet.has(reply.id)) return - if (isNip25ReactionKind(reply.kind)) return + if (isNip25ReactionKind(reply.kind)) { + newReplyIdSet.add(reply.id) + client.addEventToCache(reply) + const targetHex = getFirstHexEventIdFromETags(reply.tags) + if (targetHex && /^[0-9a-f]{64}$/i.test(targetHex)) { + const key = targetHex.toLowerCase() + newReplyEventMap.set(key, [...(newReplyEventMap.get(key) || []), reply]) + } + return + } newReplyIdSet.add(reply.id) client.addEventToCache(reply) diff --git a/src/services/client-events.service.ts b/src/services/client-events.service.ts index 00fac3fd..eb6310d0 100644 --- a/src/services/client-events.service.ts +++ b/src/services/client-events.service.ts @@ -512,8 +512,8 @@ export class EventService { if (shouldDropEventOnIngest(ev)) continue const threadishKind1Quote = (root.type === 'E' || root.type === 'A') && kind1QuotesThreadRoot(ev, root) - if (!isReplyNoteEvent(ev) && !threadishKind1Quote) continue - if (isNip25ReactionKind(ev.kind)) continue + if (!isReplyNoteEvent(ev) && !threadishKind1Quote && !isNip25ReactionKind(ev.kind)) + continue if (seen.has(ev.id)) continue if (!linkRefs(ev).some((id) => threadKeys.has(id))) continue out.push(ev) diff --git a/src/services/note-stats.service.ts b/src/services/note-stats.service.ts index 9bc07a02..c2284c99 100644 --- a/src/services/note-stats.service.ts +++ b/src/services/note-stats.service.ts @@ -5,7 +5,12 @@ import { SEARCHABLE_RELAY_URLS } from '@/constants' import { replaceStandardEmojiShortcodesInContent } from '@/lib/emoji-content' -import { getReplaceableCoordinateFromEvent, isNip18RepostKind, isReplaceableEvent } from '@/lib/event' +import { + getParentEventHexId, + getReplaceableCoordinateFromEvent, + isNip18RepostKind, + isReplaceableEvent +} from '@/lib/event' import { getZapInfoFromEvent } from '@/lib/event-metadata' import logger from '@/lib/logger' import { @@ -667,23 +672,11 @@ class NoteStatsService { } } } else if (evt.kind === kinds.ShortTextNote) { - const parentETag = evt.tags.find(([tagName, , , marker]) => { - return tagName === 'e' && (marker === 'reply' || marker === 'root') - }) - if (parentETag) { - originalEventId = parentETag[1] - } else { - const lastETag = evt.tags.findLast( - ([tagName, tagValue, , marker]) => - tagName === 'e' && - !!tagValue && - marker !== 'mention' - ) - if (lastETag) { - originalEventId = lastETag[1] - } + // Prefer NIP-10 reply parent (matches getParentETag), not the first of reply|root in tag order. + const parentHex = getParentEventHexId(evt) + if (parentHex && /^[0-9a-f]{64}$/i.test(parentHex)) { + originalEventId = parentHex.toLowerCase() } - if (!originalEventId) { const aTag = evt.tags.find(tagNameEquals('a')) if (aTag) {