Browse Source

bug-fixes

imwald
Silberengel 1 month ago
parent
commit
ff1f7e24ef
  1. 194
      src/components/NoteList/index.tsx
  2. 7
      src/components/NoteStats/LikeButton.tsx
  3. 67
      src/components/ReplyNoteList/ThreadContextRootNote.tsx
  4. 133
      src/components/ReplyNoteList/ThreadLowEffortStrip.tsx
  5. 51
      src/components/ReplyNoteList/index.tsx
  6. 5
      src/i18n/locales/en.ts
  7. 156
      src/lib/like-reaction-emojis.ts
  8. 24
      src/lib/thread-response-filter.test.ts
  9. 19
      src/lib/thread-response-filter.ts
  10. 68
      src/pages/secondary/NotePage/index.tsx

194
src/components/NoteList/index.tsx

@ -1494,8 +1494,6 @@ const NoteList = forwardRef(
[showFeedClientFilter, applyClientFeedFilter, filteredEvents] [showFeedClientFilter, applyClientFeedFilter, filteredEvents]
) )
/** Bumps when {@link noteStatsService} updates any visible row so profile batch can include boosters/likers. */
const [feedStatsProfileBump, setFeedStatsProfileBump] = useState(0)
const visibleNoteIdsForStatsPrefetchKey = useMemo( const visibleNoteIdsForStatsPrefetchKey = useMemo(
() => () =>
clientFilteredEvents clientFilteredEvents
@ -1505,80 +1503,9 @@ const NoteList = forwardRef(
[clientFilteredEvents, showCount] [clientFilteredEvents, showCount]
) )
useEffect(() => { const enqueueFeedProfilePubkeys = useCallback((need: string[]) => {
if (!visibleNoteIdsForStatsPrefetchKey) return
const ids = visibleNoteIdsForStatsPrefetchKey.split('\n').filter(Boolean)
const bump = () => setFeedStatsProfileBump((n) => n + 1)
const unsubs = ids.map((id) => noteStatsService.subscribeNoteStats(id, bump))
return () => {
unsubs.forEach((u) => u())
}
}, [visibleNoteIdsForStatsPrefetchKey])
const clientFilteredNewEvents = useMemo(
() =>
showFeedClientFilter ? applyClientFeedFilter(filteredNewEvents) : filteredNewEvents,
[showFeedClientFilter, applyClientFeedFilter, filteredNewEvents]
)
const feedClientFilterActive = useMemo(
() =>
!!(
showFeedClientFilter &&
(feedClientSearch.trim() ||
(feedClientAuthorMode === 'me' && !!pubkey) ||
(feedClientAuthorMode === 'npub' && feedClientAuthorNpubInput.trim() !== '') ||
feedClientKindInput.trim() !== '' ||
feedClientMinCreatedAt !== null)
),
[
showFeedClientFilter,
feedClientSearch,
feedClientAuthorMode,
feedClientAuthorNpubInput,
feedClientKindInput,
pubkey,
feedClientMinCreatedAt
]
)
useLayoutEffect(() => {
if (!onSpellFeedFirstPaint || spellFeedInstrumentToken === undefined) return
if (filteredEvents.length === 0) return
const first = filteredEvents[0]
if (!first) return
const fpKey = `${spellFeedInstrumentToken}|${timelineSubscriptionKey ?? ''}`
if (spellFeedFirstPaintLoggedKeyRef.current === fpKey) return
spellFeedFirstPaintLoggedKeyRef.current = fpKey
onSpellFeedFirstPaint({
eventCount: filteredEvents.length,
firstEventId: first.id
})
}, [
onSpellFeedFirstPaint,
spellFeedInstrumentToken,
timelineSubscriptionKey,
filteredEvents.length,
filteredEvents[0]?.id
])
useEffect(() => {
const handle = window.setTimeout(() => {
const gen = feedProfileBatchGenRef.current
const candidates = new Set<string>()
for (const e of timelineEventsForFilter) {
collectProfilePrefetchPubkeysFromEvent(e, candidates)
}
for (const e of newEvents) {
collectProfilePrefetchPubkeysFromEvent(e, candidates)
}
for (const e of clientFilteredEvents.slice(0, Math.min(120, Math.max(showCount + 64, 64)))) {
collectProfilePrefetchPubkeysFromNoteStats(noteStatsService.getNoteStats(e.id), candidates)
}
const need = [...candidates].filter((pk) => !feedProfileLoadedRef.current.has(pk))
if (need.length === 0) return if (need.length === 0) return
const gen = feedProfileBatchGenRef.current
need.forEach((pk) => feedProfileLoadedRef.current.add(pk)) need.forEach((pk) => feedProfileLoadedRef.current.add(pk))
setFeedProfileBatch((prev) => { setFeedProfileBatch((prev) => {
@ -1647,9 +1574,124 @@ const NoteList = forwardRef(
return { profiles: next, pending: pend, version: prev.version + 1 } return { profiles: next, pending: pend, version: prev.version + 1 }
}) })
})() })()
}, [])
const statsProfilePrefetchDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const pendingStatsProfilePubkeysRef = useRef<Set<string>>(new Set())
useEffect(() => {
if (!visibleNoteIdsForStatsPrefetchKey) return
const ids = visibleNoteIdsForStatsPrefetchKey.split('\n').filter(Boolean)
const flushStatsProfiles = () => {
statsProfilePrefetchDebounceRef.current = null
const need = [...pendingStatsProfilePubkeysRef.current].filter(
(pk) => !feedProfileLoadedRef.current.has(pk)
)
pendingStatsProfilePubkeysRef.current.clear()
enqueueFeedProfilePubkeys(need)
}
const onStatsUpdate = (noteId: string) => {
const candidates = new Set<string>()
collectProfilePrefetchPubkeysFromNoteStats(noteStatsService.getNoteStats(noteId), candidates)
for (const pk of candidates) {
if (!feedProfileLoadedRef.current.has(pk)) {
pendingStatsProfilePubkeysRef.current.add(pk)
}
}
if (pendingStatsProfilePubkeysRef.current.size === 0) return
if (statsProfilePrefetchDebounceRef.current) {
clearTimeout(statsProfilePrefetchDebounceRef.current)
}
statsProfilePrefetchDebounceRef.current = setTimeout(
flushStatsProfiles,
FEED_PROFILE_BATCH_DEBOUNCE_MS
)
}
const unsubs = ids.map((id) => noteStatsService.subscribeNoteStats(id, () => onStatsUpdate(id)))
return () => {
unsubs.forEach((u) => u())
if (statsProfilePrefetchDebounceRef.current) {
clearTimeout(statsProfilePrefetchDebounceRef.current)
statsProfilePrefetchDebounceRef.current = null
}
pendingStatsProfilePubkeysRef.current.clear()
}
}, [visibleNoteIdsForStatsPrefetchKey, enqueueFeedProfilePubkeys])
const clientFilteredNewEvents = useMemo(
() =>
showFeedClientFilter ? applyClientFeedFilter(filteredNewEvents) : filteredNewEvents,
[showFeedClientFilter, applyClientFeedFilter, filteredNewEvents]
)
const feedClientFilterActive = useMemo(
() =>
!!(
showFeedClientFilter &&
(feedClientSearch.trim() ||
(feedClientAuthorMode === 'me' && !!pubkey) ||
(feedClientAuthorMode === 'npub' && feedClientAuthorNpubInput.trim() !== '') ||
feedClientKindInput.trim() !== '' ||
feedClientMinCreatedAt !== null)
),
[
showFeedClientFilter,
feedClientSearch,
feedClientAuthorMode,
feedClientAuthorNpubInput,
feedClientKindInput,
pubkey,
feedClientMinCreatedAt
]
)
useLayoutEffect(() => {
if (!onSpellFeedFirstPaint || spellFeedInstrumentToken === undefined) return
if (filteredEvents.length === 0) return
const first = filteredEvents[0]
if (!first) return
const fpKey = `${spellFeedInstrumentToken}|${timelineSubscriptionKey ?? ''}`
if (spellFeedFirstPaintLoggedKeyRef.current === fpKey) return
spellFeedFirstPaintLoggedKeyRef.current = fpKey
onSpellFeedFirstPaint({
eventCount: filteredEvents.length,
firstEventId: first.id
})
}, [
onSpellFeedFirstPaint,
spellFeedInstrumentToken,
timelineSubscriptionKey,
filteredEvents.length,
filteredEvents[0]?.id
])
useEffect(() => {
const handle = window.setTimeout(() => {
const candidates = new Set<string>()
for (const e of timelineEventsForFilter) {
collectProfilePrefetchPubkeysFromEvent(e, candidates)
}
for (const e of newEvents) {
collectProfilePrefetchPubkeysFromEvent(e, candidates)
}
for (const e of clientFilteredEvents.slice(0, Math.min(120, Math.max(showCount + 64, 64)))) {
collectProfilePrefetchPubkeysFromNoteStats(noteStatsService.getNoteStats(e.id), candidates)
}
const need = [...candidates].filter((pk) => !feedProfileLoadedRef.current.has(pk))
enqueueFeedProfilePubkeys(need)
}, FEED_PROFILE_BATCH_DEBOUNCE_MS) }, FEED_PROFILE_BATCH_DEBOUNCE_MS)
return () => window.clearTimeout(handle) return () => window.clearTimeout(handle)
}, [timelineEventsForFilter, newEvents, clientFilteredEvents, showCount, feedStatsProfileBump]) }, [
timelineEventsForFilter,
newEvents,
clientFilteredEvents,
showCount,
enqueueFeedProfilePubkeys
])
const scrollToTop = useCallback((behavior: ScrollBehavior = 'instant') => { const scrollToTop = useCallback((behavior: ScrollBehavior = 'instant') => {
setTimeout(() => { setTimeout(() => {

7
src/components/NoteStats/LikeButton.tsx

@ -93,7 +93,12 @@ export function LikeButtonWithStats({
downVoteCount = likes?.filter((like) => isDiscussionDownvoteEmoji(like.emoji)).length || 0 downVoteCount = likes?.filter((like) => isDiscussionDownvoteEmoji(like.emoji)).length || 0
} }
return { myLastEmoji: myLike?.emoji, likeCount: likes?.length, upVoteCount, downVoteCount } return {
myLastEmoji: myLike?.emoji,
likeCount: likes?.length,
upVoteCount,
downVoteCount
}
}, [noteStats, pubkey, hideUntrustedInteractions, showDiscussionVotes]) }, [noteStats, pubkey, hideUntrustedInteractions, showDiscussionVotes])
/** Same idea as {@link ReplyButton}: merged likes (thread fetch / publish) can exist before snapshot sets `updatedAt`. */ /** Same idea as {@link ReplyButton}: merged likes (thread fetch / publish) can exist before snapshot sets `updatedAt`. */

67
src/components/ReplyNoteList/ThreadContextRootNote.tsx

@ -1,67 +0,0 @@
import { useFetchEvent } from '@/hooks'
import { useNoteStatsRelayHints } from '@/hooks/useNoteStatsRelayHints'
import { generateBech32IdFromETag } from '@/lib/tag'
import { relayHintsFromEventTags } from '@/lib/relay-list-builder'
import Note from '@/components/Note'
import { LoadingBar } from '@/components/LoadingBar'
import { useNostr } from '@/providers/NostrProvider'
import noteStatsService from '@/services/note-stats.service'
import { useEffect, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import type { Event } from 'nostr-tools'
/**
* Thread OP at the top of Antworten when the open note is a reply (not the root).
*/
export default function ThreadContextRootNote({
rootHex,
contextEvent
}: {
rootHex: string
/** Note whose tags supply relay hints for fetching the root. */
contextEvent: Event
}) {
const { t } = useTranslation()
const rootId = useMemo(() => {
const hex = rootHex.trim().toLowerCase()
if (!/^[0-9a-f]{64}$/i.test(hex)) return hex
try {
return generateBech32IdFromETag(['e', hex]) ?? hex
} catch {
return hex
}
}, [rootHex])
const fetchOpts = useMemo(() => {
const hints = relayHintsFromEventTags(contextEvent)
return hints.length ? { relayHints: hints } : undefined
}, [contextEvent])
const { event: rootEvent, isFetching } = useFetchEvent(rootId, undefined, fetchOpts)
const { pubkey } = useNostr()
const { relays: statsRelays, currentRelaysKey } = useNoteStatsRelayHints()
useEffect(() => {
if (!rootEvent) return
void noteStatsService.fetchNoteStats(rootEvent, pubkey, statsRelays, { foreground: true })
}, [rootEvent, pubkey, statsRelays, currentRelaysKey])
if (isFetching && !rootEvent) {
return (
<div className="border-b border-border/50 pb-3 mb-2">
<p className="px-4 pb-2 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
{t('Original post')}
</p>
<LoadingBar />
</div>
)
}
if (!rootEvent) return null
return (
<div className="border-b border-border/60 pb-3 mb-3">
<p className="px-4 pb-2 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
{t('Original post')}
</p>
<Note event={rootEvent} hideParentNotePreview className="opacity-95" />
</div>
)
}

133
src/components/ReplyNoteList/ThreadLowEffortStrip.tsx

@ -4,13 +4,14 @@ import { useNoteStatsRelayHints } from '@/hooks/useNoteStatsRelayHints'
import { isDiscussionDownvoteEmoji, isDiscussionUpvoteEmoji } from '@/lib/discussion-votes' import { isDiscussionDownvoteEmoji, isDiscussionUpvoteEmoji } from '@/lib/discussion-votes'
import { import {
DEFAULT_LIKE_REACTION_DISPLAY_EMOJI, DEFAULT_LIKE_REACTION_DISPLAY_EMOJI,
isDefaultPlusLikeReactionEmoji isLowEffortCollapsedReactionEmoji,
isNegativeLowEffortReactionEmoji,
isPositiveLowEffortReactionEmoji
} from '@/lib/like-reaction-emojis' } from '@/lib/like-reaction-emojis'
import { shouldHideInteractions } from '@/lib/event-filtering' import { shouldHideInteractions } from '@/lib/event-filtering'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useUserTrust } from '@/contexts/user-trust-context' import { useUserTrust } from '@/contexts/user-trust-context'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import client from '@/services/client.service'
import noteStatsService from '@/services/note-stats.service' import noteStatsService from '@/services/note-stats.service'
import type { Event } from 'nostr-tools' import type { Event } from 'nostr-tools'
import { useEffect, useMemo } from 'react' import { useEffect, useMemo } from 'react'
@ -30,6 +31,16 @@ function dedupeByPubkeyNewestFirst(rows: LowEffortRow[]): LowEffortRow[] {
return [...byPubkey.values()].sort((a, b) => b.created_at - a.created_at) return [...byPubkey.values()].sort((a, b) => b.created_at - a.created_at)
} }
function filterTrustedRows(
rows: LowEffortRow[],
hideUntrusted: boolean,
isTrustLoaded: boolean,
isUserTrusted: (pk: string) => boolean
): LowEffortRow[] {
if (!hideUntrusted || !isTrustLoaded) return rows
return rows.filter((r) => isUserTrusted(r.pubkey))
}
function CompactAvatarRow({ function CompactAvatarRow({
items, items,
ariaLabel ariaLabel
@ -55,65 +66,95 @@ function CompactAvatarRow({
) )
} }
function ReactionRow({
label,
glyph,
items,
ariaLabel
}: {
label: string
glyph?: string
items: LowEffortRow[]
ariaLabel: string
}) {
if (items.length === 0) return null
return (
<div className="flex flex-wrap items-center gap-x-1 gap-y-1">
<span className="text-muted-foreground text-sm shrink-0 mr-0.5">{label}</span>
{glyph ? (
<span className="text-sm leading-none shrink-0" aria-hidden>
{glyph}
</span>
) : null}
<CompactAvatarRow items={items} ariaLabel={ariaLabel} />
</div>
)
}
/** /**
* Subtle booster + default-like rows at the bottom of a note thread (secondary page). * Subtle booster + collapsed-reaction rows at the bottom of a note thread (secondary page).
* Feed cards keep the prominent {@link NoteBoostBadges} strip. * Feed cards keep the prominent {@link NoteBoostBadges} strip.
*/ */
export default function ThreadLowEffortStrip({ export default function ThreadLowEffortStrip({
event, event,
statsNoteId,
className className
}: { }: {
/** Open note (for quiet-mode / discussion checks). */ /** Open note (for quiet-mode / discussion checks); boost/like stats use this note’s id. */
event: Event event: Event
/** Hex id of the thread root whose boosts/likes to show (usually the OP). */
statsNoteId: string
className?: string className?: string
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { pubkey } = useNostr() const { pubkey } = useNostr()
const noteStats = useNoteStatsById(statsNoteId) const noteStats = useNoteStatsById(event.id)
const { relays: statsRelays, currentRelaysKey } = useNoteStatsRelayHints() const { relays: statsRelays, currentRelaysKey } = useNoteStatsRelayHints()
const { hideUntrustedInteractions, isUserTrusted, isTrustLoaded } = useUserTrust() const { hideUntrustedInteractions, isUserTrusted, isTrustLoaded } = useUserTrust()
const statsTargetEvent = useMemo(() => {
const cached = client.peekSessionCachedEvent(statsNoteId)
if (cached) return cached
if (event.id === statsNoteId) return event
return undefined
}, [statsNoteId, event])
useEffect(() => { useEffect(() => {
if (!statsNoteId || shouldHideInteractions(event)) return if (!event.id || shouldHideInteractions(event)) return
const target = statsTargetEvent ?? client.peekSessionCachedEvent(statsNoteId) void noteStatsService.fetchNoteStats(event, pubkey, statsRelays, { foreground: true })
if (!target) return }, [event, pubkey, statsRelays, currentRelaysKey])
void noteStatsService.fetchNoteStats(target, pubkey, statsRelays, { foreground: true })
}, [statsNoteId, statsTargetEvent, event, pubkey, statsRelays, currentRelaysKey])
const boosters = useMemo(() => { const boosters = useMemo(() => {
let rows = [...(noteStats?.reposts ?? [])] const rows = [...(noteStats?.reposts ?? [])]
if (hideUntrustedInteractions && isTrustLoaded) { return filterTrustedRows(
rows = rows.filter((r) => isUserTrusted(r.pubkey)) dedupeByPubkeyNewestFirst(rows),
} hideUntrustedInteractions,
return dedupeByPubkeyNewestFirst(rows) isTrustLoaded,
isUserTrusted
)
}, [noteStats?.reposts, hideUntrustedInteractions, isTrustLoaded, isUserTrusted]) }, [noteStats?.reposts, hideUntrustedInteractions, isTrustLoaded, isUserTrusted])
const plusLikers = useMemo(() => { const { likedBy, dislikedBy } = useMemo(() => {
if (event.kind === ExtendedKind.DISCUSSION) return [] if (event.kind === ExtendedKind.DISCUSSION) {
let rows = return { likedBy: [], dislikedBy: [] }
noteStats?.likes?.filter( }
(like) => const positive: LowEffortRow[] = []
isDefaultPlusLikeReactionEmoji(like.emoji) && const negative: LowEffortRow[] = []
!isDiscussionUpvoteEmoji(like.emoji) && for (const like of noteStats?.likes ?? []) {
!isDiscussionDownvoteEmoji(like.emoji) if (
) ?? [] !isLowEffortCollapsedReactionEmoji(like.emoji) ||
if (hideUntrustedInteractions && isTrustLoaded) { isDiscussionUpvoteEmoji(like.emoji) ||
rows = rows.filter((like) => isUserTrusted(like.pubkey)) isDiscussionDownvoteEmoji(like.emoji)
) {
continue
}
const row = { id: like.id, pubkey: like.pubkey, created_at: like.created_at }
if (isNegativeLowEffortReactionEmoji(like.emoji)) {
negative.push(row)
} else if (isPositiveLowEffortReactionEmoji(like.emoji)) {
positive.push(row)
}
}
const trust = (rows: LowEffortRow[]) =>
filterTrustedRows(rows, hideUntrustedInteractions, isTrustLoaded, isUserTrusted)
return {
likedBy: trust(dedupeByPubkeyNewestFirst(positive)),
dislikedBy: trust(dedupeByPubkeyNewestFirst(negative))
} }
return dedupeByPubkeyNewestFirst(rows)
}, [event.kind, noteStats?.likes, hideUntrustedInteractions, isTrustLoaded, isUserTrusted]) }, [event.kind, noteStats?.likes, hideUntrustedInteractions, isTrustLoaded, isUserTrusted])
if (shouldHideInteractions(event) || (boosters.length === 0 && plusLikers.length === 0)) { const hasReactions = likedBy.length > 0 || dislikedBy.length > 0
if (shouldHideInteractions(event) || (boosters.length === 0 && !hasReactions)) {
return null return null
} }
@ -130,15 +171,13 @@ export default function ThreadLowEffortStrip({
<CompactAvatarRow items={boosters} ariaLabel={t('Boosts')} /> <CompactAvatarRow items={boosters} ariaLabel={t('Boosts')} />
</div> </div>
) : null} ) : null}
{plusLikers.length > 0 ? ( <ReactionRow
<div className="flex flex-wrap items-center gap-x-1 gap-y-1"> label={t('Liked by:')}
<span className="text-muted-foreground text-sm shrink-0 mr-0.5">{t('Liked by:')}</span> glyph={DEFAULT_LIKE_REACTION_DISPLAY_EMOJI}
<span className="text-sm leading-none shrink-0" aria-hidden> items={likedBy}
{DEFAULT_LIKE_REACTION_DISPLAY_EMOJI} ariaLabel={t('Likes')}
</span> />
<CompactAvatarRow items={plusLikers} ariaLabel={t('Likes')} /> <ReactionRow label={t('Disliked by:')} items={dislikedBy} ariaLabel={t('Dislikes')} />
</div>
) : null}
</div> </div>
) )
} }

51
src/components/ReplyNoteList/index.tsx

@ -26,10 +26,13 @@ import {
} from '@/lib/event' } from '@/lib/event'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { getZapInfoFromEvent, shouldIncludeZapReceiptAtReplyThreshold } from '@/lib/event-metadata' import { getZapInfoFromEvent, shouldIncludeZapReceiptAtReplyThreshold } from '@/lib/event-metadata'
import { isDefaultPlusLikeReactionContent } from '@/lib/like-reaction-emojis' import { isLowEffortCollapsedReactionContent } from '@/lib/like-reaction-emojis'
import { muteSetHas } from '@/lib/mute-set' import { muteSetHas } from '@/lib/mute-set'
import { normalizeAnyRelayUrl } from '@/lib/url' import { normalizeAnyRelayUrl } from '@/lib/url'
import { shouldHideThreadResponseEvent } from '@/lib/thread-response-filter' import {
shouldHideOwnReactionThreadRow,
shouldHideThreadResponseEvent
} from '@/lib/thread-response-filter'
import { getCachedThreadContextEvents } from '@/lib/navigation-related-events' import { getCachedThreadContextEvents } from '@/lib/navigation-related-events'
import { toNote } from '@/lib/link' import { toNote } from '@/lib/link'
import { generateBech32IdFromETag } from '@/lib/tag' import { generateBech32IdFromETag } from '@/lib/tag'
@ -69,17 +72,16 @@ import { useTranslation } from 'react-i18next'
import { useQuoteEvents } from '@/hooks' import { useQuoteEvents } from '@/hooks'
import { LoadingBar } from '../LoadingBar' import { LoadingBar } from '../LoadingBar'
import ReplyNote, { ReplyNoteSkeleton } from '../ReplyNote' import ReplyNote, { ReplyNoteSkeleton } from '../ReplyNote'
import ThreadContextRootNote from './ThreadContextRootNote'
import ThreadLowEffortStrip from './ThreadLowEffortStrip' import ThreadLowEffortStrip from './ThreadLowEffortStrip'
import ThreadQuoteBacklink, { import ThreadQuoteBacklink, {
BacklinkAvatarStrip, BacklinkAvatarStrip,
ThreadQuoteBacklinkSkeleton ThreadQuoteBacklinkSkeleton
} from './ThreadQuoteBacklink' } from './ThreadQuoteBacklink'
/** Collapse default `+` likes into {@link ThreadLowEffortStrip}; keep discussion ⬆/⬇ vote rows. */ /** Collapse `+`/heart/👍/👎 into {@link ThreadLowEffortStrip}; keep discussion ⬆/⬇ vote rows. */
function isDefaultPlusLikeReactionEvent(evt: NEvent, isDiscussionRoot: boolean): boolean { function isLowEffortCollapsedReactionEvent(evt: NEvent, isDiscussionRoot: boolean): boolean {
if (isDiscussionRoot) return false if (isDiscussionRoot) return false
return isNip25ReactionKind(evt.kind) && isDefaultPlusLikeReactionContent(evt.content) return isNip25ReactionKind(evt.kind) && isLowEffortCollapsedReactionContent(evt.content)
} }
type TRootInfo = type TRootInfo =
@ -1635,7 +1637,8 @@ function ReplyNoteList({
(item: NEvent) => { (item: NEvent) => {
if (isPollVoteKind(item)) return false if (isPollVoteKind(item)) return false
if (isZapPollThreadZapReceipt(item, event)) return false if (isZapPollThreadZapReceipt(item, event)) return false
if (isDefaultPlusLikeReactionEvent(item, isDiscussionRoot)) return false if (isLowEffortCollapsedReactionEvent(item, isDiscussionRoot)) return false
if (shouldHideOwnReactionThreadRow(item, userPubkey)) return false
if (shouldHideThreadResponseEvent(item, mutePubkeySet, hideContentMentioningMutedUsers)) { if (shouldHideThreadResponseEvent(item, mutePubkeySet, hideContentMentioningMutedUsers)) {
return false return false
} }
@ -1664,26 +1667,25 @@ function ReplyNoteList({
rootInfo?.type, rootInfo?.type,
repliesMap, repliesMap,
event, event,
isDiscussionRoot isDiscussionRoot,
userPubkey
] ]
) )
const threadStatsNoteId = useMemo(() => { const threadRootHex =
if (rootInfo?.type === 'E') return rootInfo.id rootInfo?.type === 'E' && /^[0-9a-f]{64}$/i.test(rootInfo.id)
if (rootInfo?.type === 'A' && /^[0-9a-f]{64}$/i.test(rootInfo.eventId)) { ? rootInfo.id.toLowerCase()
return rootInfo.eventId.toLowerCase() : undefined
}
return event.id
}, [rootInfo, event.id])
const showThreadContextRoot =
rootInfo?.type === 'E' &&
/^[0-9a-f]{64}$/i.test(rootInfo.id) &&
rootInfo.id.toLowerCase() !== event.id.toLowerCase()
const visibleForRender = useMemo( const visibleForRender = useMemo(
() => visibleFeed.filter((e) => shouldShowFeedItem(e) && e.id !== event.id), () =>
[visibleFeed, shouldShowFeedItem, event.id] visibleFeed.filter((e) => {
if (!shouldShowFeedItem(e)) return false
if (e.id === event.id) return false
if (threadRootHex && e.id.toLowerCase() === threadRootHex) return false
return true
}),
[visibleFeed, shouldShowFeedItem, event.id, threadRootHex]
) )
const displayRows = useMemo( const displayRows = useMemo(
@ -1704,9 +1706,6 @@ function ReplyNoteList({
</div> </div>
)} )}
<div> <div>
{showThreadContextRoot && rootInfo?.type === 'E' && (
<ThreadContextRootNote rootHex={rootInfo.id} contextEvent={event} />
)}
{displayRows.map((row, ri) => { {displayRows.map((row, ri) => {
const prevRow = ri > 0 ? displayRows[ri - 1] : undefined const prevRow = ri > 0 ? displayRows[ri - 1] : undefined
if (row.type === 'reply') { if (row.type === 'reply') {
@ -1846,7 +1845,7 @@ function ReplyNoteList({
<ThreadQuoteBacklinkSkeleton /> <ThreadQuoteBacklinkSkeleton />
</div> </div>
)} )}
<ThreadLowEffortStrip event={event} statsNoteId={threadStatsNoteId} className="mt-1" /> <ThreadLowEffortStrip event={event} className="mt-1" />
{!loading && !quoteLoading && ( {!loading && !quoteLoading && (
<div className="text-sm mt-2 mb-3 text-center text-muted-foreground"> <div className="text-sm mt-2 mb-3 text-center text-muted-foreground">
{mergedFeed.length > 0 ? t('no more replies') : t('no replies')} {mergedFeed.length > 0 ? t('no more replies') : t('no replies')}

5
src/i18n/locales/en.ts

@ -37,6 +37,11 @@ export default {
boosted: "boosted", boosted: "boosted",
"Boosted by:": "Boosted by:", "Boosted by:": "Boosted by:",
"Liked by:": "Liked by:", "Liked by:": "Liked by:",
"Disliked by:": "Disliked by:",
"Thumbs up": "Thumbs up",
"Thumbs down": "Thumbs down",
"Arrow up": "Arrow up",
"Dislikes": "Dislikes",
"Original post": "Original post", "Original post": "Original post",
"just now": "just now", "just now": "just now",
"n minutes ago": "{{n}} minutes ago", "n minutes ago": "{{n}} minutes ago",

156
src/lib/like-reaction-emojis.ts

@ -14,6 +14,11 @@ export const DEFAULT_LIKE_REACTION_CONTENT = '+' as const
*/ */
export const DEFAULT_LIKE_REACTION_DISPLAY_EMOJI = '\u2665\uFE0F' export const DEFAULT_LIKE_REACTION_DISPLAY_EMOJI = '\u2665\uFE0F'
export const THUMBS_UP_DISPLAY_EMOJI = '\u{1F44D}' as const
export const THUMBS_DOWN_DISPLAY_EMOJI = '\u{1F44E}' as const
export const ARROW_UP_DISPLAY_EMOJI = '\u2B06\uFE0F' as const
export const ARROW_DOWN_DISPLAY_EMOJI = '\u2B07\uFE0F' as const
export const DEFAULT_SUGGESTED_EMOJIS = [ export const DEFAULT_SUGGESTED_EMOJIS = [
DEFAULT_LIKE_REACTION_CONTENT, DEFAULT_LIKE_REACTION_CONTENT,
'👍', '👍',
@ -24,13 +29,156 @@ export const DEFAULT_SUGGESTED_EMOJIS = [
'🚀' '🚀'
] as const ] as const
/** Kind-7 content (or stats row emoji) for the default quick-like (`+`). */ /** Kind-7 bodies many clients publish instead of NIP-25 `+`. */
export function isDefaultPlusLikeReactionEmoji(emoji: TEmoji | string): boolean { const COMMON_HEART_LIKE_GLYPHS = new Set([
'❤',
'❤',
'♥',
'♥',
'🩷',
'🧡',
'💛',
'💚',
'💙',
'🩵',
'💜',
'🤎',
'🖤',
'🩶',
'🤍'
])
const THUMBS_UP_GLYPHS = new Set(['👍', '+1'])
const THUMBS_DOWN_GLYPHS = new Set(['👎', '-1'])
const ARROW_UP_GLYPHS = new Set(['⬆', '⬆', '↑', '🔼'])
const ARROW_DOWN_GLYPHS = new Set(['⬇', '⬇', '↓', '🔽'])
const HEART_LIKE_SHORTCODES = new Set(['', '+', 'heart', 'love', 'plus'])
const THUMBS_UP_SHORTCODES = new Set(['thumbsup', 'thumbs_up', '+1', 'like', 'thumbup'])
const THUMBS_DOWN_SHORTCODES = new Set(['thumbsdown', 'thumbs_down', '-1', 'thumbdown'])
const ARROW_UP_SHORTCODES = new Set(['arrow_up', 'arrowup', 'up', 'upvote', 'up_arrow'])
const ARROW_DOWN_SHORTCODES = new Set(['arrow_down', 'arrowdown', 'down', 'downvote', 'down_arrow'])
/** NIP-30 shortcode only — not 💔 (sympathy/sadness, not a downvote). */
const DISLIKE_SHORTCODES = new Set(['dislike'])
function normalizedReactionString(emoji: TEmoji | string): string | undefined {
if (typeof emoji === 'object' && emoji !== null && 'shortcode' in emoji) {
return emoji.shortcode.trim().toLowerCase()
}
if (typeof emoji === 'string') return emoji.trim()
return undefined
}
/** Strip emoji presentation selectors so ⬆ matches ⬆. */
function normalizedGlyph(s: string): string {
return s.normalize('NFC').replace(/\ufe0f/gi, '').trim()
}
function matchesGlyphSet(raw: string, glyphs: Set<string>): boolean {
const c = raw.trim()
if (glyphs.has(c)) return true
const n = normalizedGlyph(c)
for (const g of glyphs) {
if (normalizedGlyph(g) === n) return true
}
return false
}
function matchesShortcodeSet(
emoji: TEmoji | string,
normalized: string,
codes: Set<string>
): boolean {
if (typeof emoji === 'object' && emoji !== null && 'shortcode' in emoji) {
return codes.has(normalized)
}
return false
}
export function isHeartOrPlusLikeReactionEmoji(emoji: TEmoji | string): boolean {
if (typeof emoji === 'object' && emoji !== null && 'shortcode' in emoji) {
return HEART_LIKE_SHORTCODES.has(emoji.shortcode.trim().toLowerCase())
}
if (typeof emoji !== 'string') return false if (typeof emoji !== 'string') return false
const c = emoji.trim() const c = emoji.trim()
return c === '' || c === DEFAULT_LIKE_REACTION_CONTENT return c === '' || c === DEFAULT_LIKE_REACTION_CONTENT || COMMON_HEART_LIKE_GLYPHS.has(c)
}
export function isThumbsUpReactionEmoji(emoji: TEmoji | string): boolean {
const normalized = normalizedReactionString(emoji)
if (normalized === undefined) return false
if (matchesShortcodeSet(emoji, normalized, THUMBS_UP_SHORTCODES)) return true
if (typeof emoji === 'string') return matchesGlyphSet(normalized, THUMBS_UP_GLYPHS)
return false
}
export function isThumbsDownReactionEmoji(emoji: TEmoji | string): boolean {
const normalized = normalizedReactionString(emoji)
if (normalized === undefined) return false
if (matchesShortcodeSet(emoji, normalized, THUMBS_DOWN_SHORTCODES)) return true
if (typeof emoji === 'string') return matchesGlyphSet(normalized, THUMBS_DOWN_GLYPHS)
return false
}
export function isArrowUpReactionEmoji(emoji: TEmoji | string): boolean {
const normalized = normalizedReactionString(emoji)
if (normalized === undefined) return false
if (matchesShortcodeSet(emoji, normalized, ARROW_UP_SHORTCODES)) return true
if (typeof emoji === 'string') return matchesGlyphSet(normalized, ARROW_UP_GLYPHS)
return false
}
export function isArrowDownReactionEmoji(emoji: TEmoji | string): boolean {
const normalized = normalizedReactionString(emoji)
if (normalized === undefined) return false
if (matchesShortcodeSet(emoji, normalized, ARROW_DOWN_SHORTCODES)) return true
if (typeof emoji === 'string') return matchesGlyphSet(normalized, ARROW_DOWN_GLYPHS)
return false
}
/** Explicit `dislike` shortcode / content only (not 💔 or 👎). */
export function isDislikeReactionEmoji(emoji: TEmoji | string): boolean {
const normalized = normalizedReactionString(emoji)
if (normalized === undefined) return false
if (matchesShortcodeSet(emoji, normalized, DISLIKE_SHORTCODES)) return true
if (typeof emoji === 'string' && normalized.toLowerCase() === 'dislike') return true
return false
}
export function isPositiveLowEffortReactionEmoji(emoji: TEmoji | string): boolean {
return (
isHeartOrPlusLikeReactionEmoji(emoji) ||
isThumbsUpReactionEmoji(emoji) ||
isArrowUpReactionEmoji(emoji)
)
}
export function isNegativeLowEffortReactionEmoji(emoji: TEmoji | string): boolean {
return (
isThumbsDownReactionEmoji(emoji) ||
isArrowDownReactionEmoji(emoji) ||
isDislikeReactionEmoji(emoji)
)
}
/**
* Reactions collapsed into {@link ThreadLowEffortStrip} and hidden as thread rows.
*/
export function isLowEffortCollapsedReactionEmoji(emoji: TEmoji | string): boolean {
return isPositiveLowEffortReactionEmoji(emoji) || isNegativeLowEffortReactionEmoji(emoji)
}
/** @deprecated Prefer {@link isLowEffortCollapsedReactionEmoji}. */
export function isDefaultPlusLikeReactionEmoji(emoji: TEmoji | string): boolean {
return isLowEffortCollapsedReactionEmoji(emoji)
}
export function isLowEffortCollapsedReactionContent(content: string): boolean {
return isLowEffortCollapsedReactionEmoji(content)
} }
/** @deprecated Prefer {@link isLowEffortCollapsedReactionContent}. */
export function isDefaultPlusLikeReactionContent(content: string): boolean { export function isDefaultPlusLikeReactionContent(content: string): boolean {
return isDefaultPlusLikeReactionEmoji(content) return isLowEffortCollapsedReactionContent(content)
} }

24
src/lib/thread-response-filter.test.ts

@ -2,7 +2,11 @@ import { describe, expect, it } from 'vitest'
import { kinds } from 'nostr-tools' import { kinds } from 'nostr-tools'
import type { Event } from 'nostr-tools' import type { Event } from 'nostr-tools'
import { ExtendedKind } from '@/constants' import { ExtendedKind } from '@/constants'
import { isThreadBoosterOnlyRow, shouldHideThreadResponseEvent } from './thread-response-filter' import {
isThreadBoosterOnlyRow,
shouldHideOwnReactionThreadRow,
shouldHideThreadResponseEvent
} from './thread-response-filter'
function baseEvent(overrides: Partial<Event> = {}): Event { function baseEvent(overrides: Partial<Event> = {}): Event {
return { return {
@ -45,4 +49,22 @@ describe('thread response filter', () => {
}) })
expect(isThreadBoosterOnlyRow(repost)).toBe(true) expect(isThreadBoosterOnlyRow(repost)).toBe(true)
}) })
it('hides viewer NIP-25 reactions in thread rows (own or others’ notes)', () => {
const viewer = 'b'.repeat(64)
const myReaction = baseEvent({
pubkey: viewer,
kind: kinds.Reaction,
content: '+',
tags: [['e', 'c'.repeat(64), '', 'root']]
})
expect(shouldHideOwnReactionThreadRow(myReaction, viewer)).toBe(true)
expect(shouldHideOwnReactionThreadRow(myReaction, 'a'.repeat(64))).toBe(false)
expect(
shouldHideOwnReactionThreadRow(
baseEvent({ pubkey: 'c'.repeat(64), kind: kinds.Reaction, content: '+' }),
viewer
)
).toBe(false)
})
}) })

19
src/lib/thread-response-filter.ts

@ -1,4 +1,4 @@
import { isMentioningMutedUsers, isNip18RepostKind } from '@/lib/event' import { isMentioningMutedUsers, isNip18RepostKind, isNip25ReactionKind } from '@/lib/event'
import { muteSetHas } from '@/lib/mute-set' import { muteSetHas } from '@/lib/mute-set'
import { normalizeUrl } from '@/lib/url' import { normalizeUrl } from '@/lib/url'
import type { Event } from 'nostr-tools' import type { Event } from 'nostr-tools'
@ -21,6 +21,23 @@ export function isThreadBoosterOnlyRow(evt: Event): boolean {
return isNip18RepostKind(evt.kind) return isNip18RepostKind(evt.kind)
} }
/**
* The signed-in user's NIP-25 reactions are already on the note stats bar omit duplicate thread rows.
* Counts still use {@link noteStatsService} / merged stats; this only affects thread list rendering.
*/
export function shouldHideOwnReactionThreadRow(
item: Event,
viewerPubkey: string | null | undefined
): boolean {
const viewer = viewerPubkey?.trim().toLowerCase()
if (!viewer || !/^[0-9a-f]{64}$/i.test(viewer)) return false
if (item.pubkey.toLowerCase() !== viewer) return false
return isNip25ReactionKind(item.kind)
}
/** @deprecated Use {@link shouldHideOwnReactionThreadRow}. */
export const shouldHideOwnReactionInOthersThread = shouldHideOwnReactionThreadRow
/** Hide thread replies / backlinks: boosts, wire-format JSON blobs, muted author, or mute mentions. */ /** Hide thread replies / backlinks: boosts, wire-format JSON blobs, muted author, or mute mentions. */
export function shouldHideThreadResponseEvent( export function shouldHideThreadResponseEvent(
evt: Event, evt: Event,

68
src/pages/secondary/NotePage/index.tsx

@ -12,6 +12,9 @@ import { Card } from '@/components/ui/card'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { useFetchEvent, useFetchProfile, useNip84HighlightTargetEvents } from '@/hooks' import { useFetchEvent, useFetchProfile, useNip84HighlightTargetEvents } from '@/hooks'
import { useNoteStatsRelayHints } from '@/hooks/useNoteStatsRelayHints'
import { useNostr } from '@/providers/NostrProvider'
import noteStatsService from '@/services/note-stats.service'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { import {
collectEmbeddedEventPrefetchTargets, collectEmbeddedEventPrefetchTargets,
@ -147,16 +150,36 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }:
() => (threadRelayHints.length ? { relayHints: threadRelayHints } : undefined), () => (threadRelayHints.length ? { relayHints: threadRelayHints } : undefined),
[threadRelayHints] [threadRelayHints]
) )
const rootInitialEvent = useMemo(() => {
if (!finalEvent) return undefined
const rootHex = getRootEventHexId(finalEvent)?.toLowerCase()
if (!rootHex || !/^[0-9a-f]{64}$/i.test(rootHex)) return undefined
const resolved = resolveDeclaredThreadRootEventHex(rootHex)
return client.peekSessionCachedEvent(resolved) ?? client.peekSessionCachedEvent(rootHex)
}, [finalEvent])
const parentInitialEvent = useMemo(() => {
if (!finalEvent) return undefined
const parentHex = getParentEventHexId(finalEvent)?.toLowerCase()
if (!parentHex || !/^[0-9a-f]{64}$/i.test(parentHex)) return undefined
return client.peekSessionCachedEvent(parentHex)
}, [finalEvent])
const { isFetching: isFetchingRootEvent, event: rootEvent, refetch: refetchRoot } = const { isFetching: isFetchingRootEvent, event: rootEvent, refetch: refetchRoot } =
useFetchEvent(rootEventId, undefined, parentRootFetchOpts) useFetchEvent(rootEventId, rootInitialEvent, parentRootFetchOpts)
const { isFetching: isFetchingParentEvent, event: parentEvent, refetch: refetchParent } = const { isFetching: isFetchingParentEvent, event: parentEvent, refetch: refetchParent } =
useFetchEvent(parentEventId, undefined, parentRootFetchOpts) useFetchEvent(parentEventId, parentInitialEvent, parentRootFetchOpts)
const selfHex = finalEvent?.id?.toLowerCase() const selfHex = finalEvent?.id?.toLowerCase()
const rootEventForStrip = const rootEventForStrip =
rootEvent && selfHex && rootEvent.id.toLowerCase() !== selfHex ? rootEvent : undefined rootEvent && selfHex && rootEvent.id.toLowerCase() !== selfHex ? rootEvent : undefined
const parentEventForStrip = const parentEventForStrip =
parentEvent && selfHex && parentEvent.id.toLowerCase() !== selfHex ? parentEvent : undefined parentEvent && selfHex && parentEvent.id.toLowerCase() !== selfHex ? parentEvent : undefined
const { pubkey } = useNostr()
const { relays: statsRelays, currentRelaysKey } = useNoteStatsRelayHints()
useEffect(() => {
if (!rootEventForStrip) return
void noteStatsService.fetchNoteStats(rootEventForStrip, pubkey, statsRelays, { foreground: true })
}, [rootEventForStrip, pubkey, statsRelays, currentRelaysKey])
// When viewing a kind-24 invite (e.g. from notifications), extract calendar event naddr from content and show full calendar card with RSVP // When viewing a kind-24 invite (e.g. from notifications), extract calendar event naddr from content and show full calendar card with RSVP
const calendarInviteNaddr = useMemo(() => { const calendarInviteNaddr = useMemo(() => {
@ -509,7 +532,7 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }:
{rootEventId && ( {rootEventId && (
<ParentNote <ParentNote
key={`thread-root-${finalEvent.id}`} key={`thread-root-${finalEvent.id}`}
isFetching={isFetchingRootEvent} isFetching={isFetchingRootEvent && !rootEventForStrip}
event={rootEventForStrip} event={rootEventForStrip}
eventBech32Id={rootEventId} eventBech32Id={rootEventId}
isConsecutive={ isConsecutive={
@ -521,14 +544,19 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }:
)} )}
{parentEventId && {parentEventId &&
!eventPointersReferenceSameNote(parentEventId, rootEventId) && !eventPointersReferenceSameNote(parentEventId, rootEventId) &&
!eventPointersReferenceSameNote(parentEventId, finalEvent.id) && ( !eventPointersReferenceSameNote(parentEventId, finalEvent.id) &&
<ParentNote (parentEventForStrip ? (
key={`parent-note-${finalEvent.id}`} <div key={`parent-note-${parentEventForStrip.id}`} className="mb-3 mt-1">
isFetching={isFetchingParentEvent} {!isConsecutive(rootEventForStrip, parentEventForStrip) ? (
event={parentEventForStrip} <Ellipsis className="ml-3.5 mb-1 text-muted-foreground/60 size-3" />
eventBech32Id={parentEventId} ) : null}
/> <Note event={parentEventForStrip} hideParentNotePreview showFull />
)} <div className="ml-5 w-px h-3 bg-border" />
</div>
) : isFetchingParentEvent ? (
<ThreadContextSkeleton key={`parent-note-skeleton-${finalEvent.id}`} />
) : null)}
{(rootEventForStrip || parentEventForStrip) && <Separator className="my-3" />}
<Note <Note
key={`note-${finalEvent.id}`} key={`note-${finalEvent.id}`}
event={finalEvent} event={finalEvent}
@ -566,6 +594,24 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }:
NotePage.displayName = 'NotePage' NotePage.displayName = 'NotePage'
export default NotePage export default NotePage
function ThreadContextSkeleton() {
return (
<div className="mb-3">
<div className="flex items-center space-x-2">
<Skeleton className="w-10 h-10 rounded-full" />
<div className="flex-1 w-0">
<Skeleton className="h-4 w-24 mb-1" />
<Skeleton className="h-3 w-16" />
</div>
</div>
<div className="pt-2 space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-4/5" />
</div>
</div>
)
}
function ExternalRoot({ value }: { value: string }) { function ExternalRoot({ value }: { value: string }) {
const { push } = useSecondaryPage() const { push } = useSecondaryPage()

Loading…
Cancel
Save