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) {