Browse Source

add responses to zaps and reactions in the reply thread

imwald
Silberengel 1 month ago
parent
commit
1bfd1dfbad
  1. 4
      package-lock.json
  2. 2
      package.json
  3. 19
      src/components/ContentPreview/index.tsx
  4. 19
      src/components/Note/ReactionEmojiDisplay.tsx
  5. 34
      src/components/Note/Zap.tsx
  6. 28
      src/components/ParentNotePreview/index.tsx
  7. 30
      src/components/ReplyNote/index.tsx
  8. 3
      src/components/ReplyNoteList/index.tsx
  9. 2
      src/constants.ts
  10. 45
      src/lib/thread-reply-root-match.ts
  11. 12
      src/providers/ReplyProvider.tsx
  12. 4
      src/services/client-events.service.ts
  13. 27
      src/services/note-stats.service.ts

4
package-lock.json generated

@ -1,12 +1,12 @@ @@ -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",

2
package.json

@ -1,6 +1,6 @@ @@ -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",

19
src/components/ContentPreview/index.tsx

@ -31,6 +31,7 @@ import ApplicationHandlerRecommendation from '../ApplicationHandlerRecommendatio @@ -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) { @@ -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({ @@ -168,7 +172,18 @@ export default function ContentPreview({
return withKindRow(<LiveEventPreview event={event} />)
}
if (event.kind === ExtendedKind.ZAP_REQUEST || event.kind === ExtendedKind.ZAP_RECEIPT) {
if (event.kind === ExtendedKind.ZAP_REQUEST) {
return withKindRow(<ZapPreview event={event} />)
}
if (event.kind === ExtendedKind.ZAP_RECEIPT || event.kind === kinds.Zap) {
if (previewDensity === 'compact') {
return (
<div className={cn('min-w-0', previewOuter)}>
<Zap event={event} variant="compact" omitSenderHeading className={previewBody} />
</div>
)
}
return withKindRow(<ZapPreview event={event} />)
}

19
src/components/Note/ReactionEmojiDisplay.tsx

@ -21,8 +21,8 @@ export default function ReactionEmojiDisplay({ @@ -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({ @@ -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'
}}
/>
</span>

34
src/components/Note/Zap.tsx

@ -46,8 +46,10 @@ export default function Zap({ @@ -46,8 +46,10 @@ export default function Zap({
return (
<div
className={cn(
'text-sm text-muted-foreground rounded-lg border border-border bg-muted/20',
variant === 'compact' ? 'px-3 py-2' : 'p-4',
'text-sm text-muted-foreground',
variant === 'compact'
? 'py-0.5'
: 'rounded-lg border border-border bg-muted/20 p-4',
className
)}
>
@ -89,27 +91,25 @@ export default function Zap({ @@ -89,27 +91,25 @@ export default function Zap({
if (variant === 'compact') {
return (
<div
className={cn(
'rounded-md border-l-2 border-primary/50 bg-primary/[0.06] pl-3 pr-2 py-2 text-sm text-foreground dark:bg-primary/[0.08]',
className
)}
>
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-1">
<ZapIcon className="size-4 shrink-0 text-primary" strokeWidth={2} aria-hidden />
<span className="font-semibold tabular-nums text-foreground">{formatAmount(amount)}</span>
<span className="text-muted-foreground">{t('sats')}</span>
<div className={cn('text-sm text-muted-foreground', className)}>
<div className="flex flex-wrap items-center gap-x-1.5 gap-y-0.5">
<ZapIcon className="size-3.5 shrink-0 opacity-70" strokeWidth={2} aria-hidden />
<span className="tabular-nums font-medium text-foreground/90">{formatAmount(amount)}</span>
<span>{t('sats')}</span>
{recipientPubkey && recipientPubkey !== senderPubkey && (
<span className="text-muted-foreground text-xs">
<span className="text-foreground/80">{t('zapped')}</span>{' '}
<Username userId={recipientPubkey} className="inline font-medium text-foreground" />
<span className="text-xs">
<span>{t('zapped')}</span>{' '}
<Username
userId={recipientPubkey}
className="inline font-medium text-foreground/85 hover:text-foreground"
/>
</span>
)}
{(isEventZap || isProfileZap) && (
<button
type="button"
onClick={openZapTarget}
className="text-xs font-medium text-primary hover:underline"
className="text-xs text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
>
{isEventZap
? t('Zapped note')
@ -120,7 +120,7 @@ export default function Zap({ @@ -120,7 +120,7 @@ export default function Zap({
)}
</div>
{comment ? (
<p className="mt-2 text-sm leading-snug text-foreground/90 whitespace-pre-wrap break-words">
<p className="mt-1.5 pl-5 text-sm leading-snug text-muted-foreground whitespace-pre-wrap break-words">
{comment}
</p>
) : null}

28
src/components/ParentNotePreview/index.tsx

@ -13,11 +13,14 @@ import logger from '@/lib/logger' @@ -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<HTMLDivElement> | undefined
appearance?: 'default' | 'subtle'
}) {
const { t } = useTranslation()
const { event, isFetching } = useFetchEvent(eventId)
@ -66,18 +69,17 @@ export default function ParentNotePreview({ @@ -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 (
<div
data-parent-note-preview
className={cn(
'flex gap-1 items-center text-sm rounded-full px-2 bg-muted w-44 max-w-full text-muted-foreground',
className
)}
>
<div data-parent-note-preview className={cn(shellClass, appearance === 'subtle' && 'w-full', className)}>
<div className="shrink-0">{t('reply to')}</div>
<Skeleton className="w-4 h-4 rounded-full" />
<div className="py-1 flex-1">
<div className={cn('flex-1 min-w-0', appearance === 'subtle' ? 'py-0' : 'py-1')}>
<Skeleton className="h-3" />
</div>
</div>
@ -99,7 +101,7 @@ export default function ParentNotePreview({ @@ -99,7 +101,7 @@ export default function ParentNotePreview({
<div
data-parent-note-preview
className={cn(
'flex gap-1 items-center text-sm rounded-full px-2 bg-muted w-fit max-w-full text-muted-foreground',
shellClass,
(finalEvent || (!finalEvent && !finalIsFetching)) && 'hover:text-foreground cursor-pointer',
className
)}
@ -108,7 +110,11 @@ export default function ParentNotePreview({ @@ -108,7 +110,11 @@ export default function ParentNotePreview({
<div className="shrink-0">{t('reply to')}</div>
{finalEvent && <UserAvatar className="shrink-0" userId={finalEvent.pubkey} size="tiny" />}
<div className="truncate flex-1 min-w-0">
<ContentPreview className="pointer-events-none" event={finalEvent} />
<ContentPreview
className="pointer-events-none"
event={finalEvent}
previewDensity={appearance === 'subtle' ? 'compact' : 'default'}
/>
</div>
</div>
)

30
src/components/ReplyNote/index.tsx

@ -15,6 +15,7 @@ import { getZapInfoFromEvent } from '@/lib/event-metadata' @@ -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({ @@ -126,14 +127,23 @@ export default function ReplyNote({
<NoteOptions event={event} className="shrink-0 [&_svg]:size-5" />
</div>
</div>
<NoteKindLabel kind={event.kind} event={event} size="small" className="mt-0.5" />
<NoteKindLabel
kind={event.kind}
event={event}
size="small"
className={cn(
'mt-0.5',
(isNip25ReactionKind(event.kind) || event.kind === kinds.Zap) && 'opacity-60'
)}
/>
{webReactionParentUrl ? (
<div className="mt-2 not-prose max-w-full" data-parent-note-preview>
<div className="mt-1.5 not-prose max-w-full" data-parent-note-preview>
<WebPreview url={webReactionParentUrl} className="w-full" />
</div>
) : parentEventId ? (
<ParentNotePreview
className="mt-2"
appearance="subtle"
className="mt-1.5"
eventId={parentEventId}
onClick={(e) => {
e.stopPropagation()
@ -143,24 +153,24 @@ export default function ReplyNote({ @@ -143,24 +153,24 @@ export default function ReplyNote({
) : null}
{show ? (
isNip25ReactionKind(event.kind) ? (
<div className="mt-2 flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<div className="mt-1.5 flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-sm text-muted-foreground">
{reactionDisplay.status === 'pending' ? (
<Skeleton className="size-4 shrink-0 rounded-sm" aria-hidden />
<Skeleton className="size-3.5 shrink-0 rounded-sm" aria-hidden />
) : reactionDisplay.status === 'vote_up' ? (
<span className="text-base leading-none" aria-hidden>
<span className="text-sm leading-none opacity-90" aria-hidden>
{DISCUSSION_UPVOTE_DISPLAY}
</span>
) : reactionDisplay.status === 'vote_down' ? (
<span className="text-base leading-none" aria-hidden>
<span className="text-sm leading-none opacity-90" aria-hidden>
{DISCUSSION_DOWNVOTE_DISPLAY}
</span>
) : (
<ReactionEmojiDisplay event={event} variant="compact" maxRawLength={64} />
<ReactionEmojiDisplay event={event} variant="thread" maxRawLength={64} />
)}
<span>{t(notificationReactionSummaryKey(reactionDisplay))}</span>
<span className="text-foreground/85">{t(notificationReactionSummaryKey(reactionDisplay))}</span>
</div>
) : event.kind === kinds.Zap ? (
<Zap className="mt-2" event={event} omitSenderHeading variant="compact" />
<Zap className="mt-1.5" event={event} omitSenderHeading variant="compact" />
) : (
<MarkdownArticle
className="mt-2"

3
src/components/ReplyNoteList/index.tsx

@ -382,9 +382,8 @@ function ReplyNoteList({ @@ -382,9 +382,8 @@ function ReplyNoteList({
replyEvents.push(evt)
})
// Prevent infinite loops by tracking processed event IDs
// Include reactions (and every other kind) so BFS can find notes keyed under reaction / zap ids.
const newParentEventKeys = events
.filter((evt) => !isNip25ReactionKind(evt.kind))
.map((evt) => evt.id)
.filter((id) => !processedEventIds.has(id))

2
src/constants.ts

@ -270,8 +270,6 @@ export const SEARCHABLE_RELAY_URLS = [ @@ -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/',

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

@ -3,16 +3,55 @@ import { @@ -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 @@ -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)
}

12
src/providers/ReplyProvider.tsx

@ -11,6 +11,7 @@ import { @@ -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 }) { @@ -40,7 +41,16 @@ export function ReplyProvider({ children }: { children: React.ReactNode }) {
const newReplyEventMap = new Map<string, Event[]>()
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)

4
src/services/client-events.service.ts

@ -512,8 +512,8 @@ export class EventService { @@ -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)

27
src/services/note-stats.service.ts

@ -5,7 +5,12 @@ import { @@ -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 { @@ -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) {

Loading…
Cancel
Save