From 28cae11f62d150d4bf89fa36b04fdd69b9310781 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Sat, 16 May 2026 09:49:30 +0200 Subject: [PATCH] bug-fixes --- src/components/NoteBoostBadges/index.tsx | 2 +- src/components/NoteStats/ZapButton.tsx | 30 ++- .../ReplyNoteList/ThreadLowEffortStrip.tsx | 183 ------------------ src/components/ReplyNoteList/index.tsx | 27 +-- src/lib/like-reaction-emojis.ts | 66 ++----- src/lib/thread-response-filter.test.ts | 30 +-- src/lib/thread-response-filter.ts | 22 ++- 7 files changed, 77 insertions(+), 283 deletions(-) delete mode 100644 src/components/ReplyNoteList/ThreadLowEffortStrip.tsx diff --git a/src/components/NoteBoostBadges/index.tsx b/src/components/NoteBoostBadges/index.tsx index 641a57d2..7d98645c 100644 --- a/src/components/NoteBoostBadges/index.tsx +++ b/src/components/NoteBoostBadges/index.tsx @@ -11,7 +11,7 @@ const MAX_VISIBLE = 28 /** * Avatar strip of users who boosted (kind 6 / 16) β€” feed cards only (attention on the timeline). - * Thread view uses {@link ThreadLowEffortStrip} at the bottom of replies instead. + * Thread view omits boost rows; boosts appear in note stats on OP/replies only. */ export default function NoteBoostBadges({ event, className }: { event: Event; className?: string }) { const { t } = useTranslation() diff --git a/src/components/NoteStats/ZapButton.tsx b/src/components/NoteStats/ZapButton.tsx index 191118fe..e294aed3 100644 --- a/src/components/NoteStats/ZapButton.tsx +++ b/src/components/NoteStats/ZapButton.tsx @@ -144,11 +144,8 @@ export function ZapButtonWithStats({ event, hideCount = false, noteStats }: ZapB <> () - for (const row of rows) { - const prev = byPubkey.get(row.pubkey) - if (!prev || row.created_at > prev.created_at) byPubkey.set(row.pubkey, row) - } - 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({ - items, - ariaLabel -}: { - items: LowEffortRow[] - ariaLabel: string -}) { - if (items.length === 0) return null - const visible = items.slice(0, MAX_AVATARS) - const overflow = items.length - visible.length - - return ( -
- {visible.map((item) => ( -
- -
- ))} - {overflow > 0 ? ( - +{overflow} - ) : null} -
- ) -} - -function ReactionRow({ - label, - glyph, - items, - ariaLabel -}: { - label: string - glyph?: string - items: LowEffortRow[] - ariaLabel: string -}) { - if (items.length === 0) return null - return ( -
- {label} - {glyph ? ( - - {glyph} - - ) : null} - -
- ) -} - -/** - * Subtle booster + collapsed-reaction rows at the bottom of a note thread (secondary page). - * Feed cards keep the prominent {@link NoteBoostBadges} strip. - */ -export default function ThreadLowEffortStrip({ - event, - className -}: { - /** Open note (for quiet-mode / discussion checks); boost/like stats use this note’s id. */ - event: Event - className?: string -}) { - const { t } = useTranslation() - const { pubkey } = useNostr() - const noteStats = useNoteStatsById(event.id) - const { relays: statsRelays, currentRelaysKey } = useNoteStatsRelayHints() - const { hideUntrustedInteractions, isUserTrusted, isTrustLoaded } = useUserTrust() - - useEffect(() => { - if (!event.id || shouldHideInteractions(event)) return - void noteStatsService.fetchNoteStats(event, pubkey, statsRelays, { foreground: true }) - }, [event, pubkey, statsRelays, currentRelaysKey]) - - const boosters = useMemo(() => { - const rows = [...(noteStats?.reposts ?? [])] - return filterTrustedRows( - dedupeByPubkeyNewestFirst(rows), - hideUntrustedInteractions, - isTrustLoaded, - isUserTrusted - ) - }, [noteStats?.reposts, hideUntrustedInteractions, isTrustLoaded, isUserTrusted]) - - const { likedBy, dislikedBy } = useMemo(() => { - if (event.kind === ExtendedKind.DISCUSSION) { - return { likedBy: [], dislikedBy: [] } - } - const positive: LowEffortRow[] = [] - const negative: LowEffortRow[] = [] - for (const like of noteStats?.likes ?? []) { - if ( - !isLowEffortCollapsedReactionEmoji(like.emoji) || - isDiscussionUpvoteEmoji(like.emoji) || - 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)) - } - }, [event.kind, noteStats?.likes, hideUntrustedInteractions, isTrustLoaded, isUserTrusted]) - - const hasReactions = likedBy.length > 0 || dislikedBy.length > 0 - if (shouldHideInteractions(event) || (boosters.length === 0 && !hasReactions)) { - return null - } - - return ( -
- {boosters.length > 0 ? ( -
- {t('Boosted by:')} - -
- ) : null} - - -
- ) -} diff --git a/src/components/ReplyNoteList/index.tsx b/src/components/ReplyNoteList/index.tsx index 46b0f81b..7904b1c0 100644 --- a/src/components/ReplyNoteList/index.tsx +++ b/src/components/ReplyNoteList/index.tsx @@ -19,20 +19,15 @@ import { isNip56ReportEvent, isMentioningMutedUsers, isNip18RepostKind, - isNip25ReactionKind, isReplaceableEvent, kind1QuotesThreadRoot, resolveDeclaredThreadRootEventHex } from '@/lib/event' import logger from '@/lib/logger' import { getZapInfoFromEvent, shouldIncludeZapReceiptAtReplyThreshold } from '@/lib/event-metadata' -import { isLowEffortCollapsedReactionContent } from '@/lib/like-reaction-emojis' import { muteSetHas } from '@/lib/mute-set' import { normalizeAnyRelayUrl } from '@/lib/url' -import { - shouldHideOwnReactionThreadRow, - shouldHideThreadResponseEvent -} from '@/lib/thread-response-filter' +import { shouldHideThreadResponseEvent } from '@/lib/thread-response-filter' import { getCachedThreadContextEvents } from '@/lib/navigation-related-events' import { toNote } from '@/lib/link' import { generateBech32IdFromETag } from '@/lib/tag' @@ -72,18 +67,11 @@ import { useTranslation } from 'react-i18next' import { useQuoteEvents } from '@/hooks' import { LoadingBar } from '../LoadingBar' import ReplyNote, { ReplyNoteSkeleton } from '../ReplyNote' -import ThreadLowEffortStrip from './ThreadLowEffortStrip' import ThreadQuoteBacklink, { BacklinkAvatarStrip, ThreadQuoteBacklinkSkeleton } from './ThreadQuoteBacklink' -/** Collapse `+`/heart/πŸ‘/πŸ‘Ž into {@link ThreadLowEffortStrip}; keep discussion ⬆️/⬇️ vote rows. */ -function isLowEffortCollapsedReactionEvent(evt: NEvent, isDiscussionRoot: boolean): boolean { - if (isDiscussionRoot) return false - return isNip25ReactionKind(evt.kind) && isLowEffortCollapsedReactionContent(evt.content) -} - type TRootInfo = | { type: 'E'; id: string; pubkey: string } | { type: 'A'; id: string; eventId: string; pubkey: string; relay?: string } @@ -238,11 +226,8 @@ function moveReportsToEndPreserveOrder(events: NEvent[]): NEvent[] { /** Shown after thread replies for E/A roots (quote stream + kind 1 #q-only); matches {@link THREAD_BACKLINK_STREAM_KINDS}. */ const EA_THREAD_TAIL_REFERENCE_KINDS = new Set(THREAD_BACKLINK_STREAM_KINDS) -/** Web (NIP-22) thread: tail = reference-style rows + URL-only external reactions (kind-7 stays in the chronological middle with other replies). */ -const WEB_THREAD_EXTRA_TAIL_KINDS = new Set([ExtendedKind.EXTERNAL_REACTION]) - function isWebThreadTailKind(kind: number): boolean { - return EA_THREAD_TAIL_REFERENCE_KINDS.has(kind) || WEB_THREAD_EXTRA_TAIL_KINDS.has(kind) + return EA_THREAD_TAIL_REFERENCE_KINDS.has(kind) } /** Kind 1111 / 1244 that includes the thread root id on an e/E tag (common on relays; stricter root-tag walks may miss these). */ @@ -277,7 +262,7 @@ function noteReactionEtagEqualsHex(ev: NEvent, hexLower: string): boolean { /** * Thread REQ may still omit some kind-7 rows; merge reactions that tag the root hex so OP stats stay warm. - * Listed reactions under β€œAntworten” come from {@link ReplyNoteList} BFS + {@link replyMatchesThreadForList}. + * Reactions are not listed under β€œAntworten”; this merge keeps OP stats warm when the thread REQ omits kind 7. */ function mergeFetchedKind7ReactionsIntoRootNoteStats(all: NEvent[], rootInfo: TRootInfo) { if (rootInfo.type === 'E') { @@ -1637,8 +1622,6 @@ function ReplyNoteList({ (item: NEvent) => { if (isPollVoteKind(item)) return false if (isZapPollThreadZapReceipt(item, event)) return false - if (isLowEffortCollapsedReactionEvent(item, isDiscussionRoot)) return false - if (shouldHideOwnReactionThreadRow(item, userPubkey)) return false if (shouldHideThreadResponseEvent(item, mutePubkeySet, hideContentMentioningMutedUsers)) { return false } @@ -1667,8 +1650,7 @@ function ReplyNoteList({ rootInfo?.type, repliesMap, event, - isDiscussionRoot, - userPubkey + isDiscussionRoot ] ) @@ -1845,7 +1827,6 @@ function ReplyNoteList({ )} - {!loading && !quoteLoading && (
{mergedFeed.length > 0 ? t('no more replies') : t('no replies')} diff --git a/src/lib/like-reaction-emojis.ts b/src/lib/like-reaction-emojis.ts index c3966d6c..c3fb9ade 100644 --- a/src/lib/like-reaction-emojis.ts +++ b/src/lib/like-reaction-emojis.ts @@ -14,11 +14,6 @@ export const DEFAULT_LIKE_REACTION_CONTENT = '+' as const */ 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 = [ DEFAULT_LIKE_REACTION_CONTENT, 'πŸ‘', @@ -50,7 +45,6 @@ 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(['⬇', '⬇️', '↓', 'πŸ”½']) @@ -59,7 +53,6 @@ const THUMBS_UP_SHORTCODES = new Set(['thumbsup', 'thumbs_up', '+1', 'like', 'th 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 { @@ -70,7 +63,6 @@ function normalizedReactionString(emoji: TEmoji | string): string | undefined { return undefined } -/** Strip emoji presentation selectors so ⬆️ matches ⬆. */ function normalizedGlyph(s: string): string { return s.normalize('NFC').replace(/\ufe0f/gi, '').trim() } @@ -96,7 +88,7 @@ function matchesShortcodeSet( return false } -export function isHeartOrPlusLikeReactionEmoji(emoji: TEmoji | string): boolean { +function isHeartOrPlusLike(emoji: TEmoji | string): boolean { if (typeof emoji === 'object' && emoji !== null && 'shortcode' in emoji) { return HEART_LIKE_SHORTCODES.has(emoji.shortcode.trim().toLowerCase()) } @@ -105,7 +97,7 @@ export function isHeartOrPlusLikeReactionEmoji(emoji: TEmoji | string): boolean return c === '' || c === DEFAULT_LIKE_REACTION_CONTENT || COMMON_HEART_LIKE_GLYPHS.has(c) } -export function isThumbsUpReactionEmoji(emoji: TEmoji | string): boolean { +function isThumbsUp(emoji: TEmoji | string): boolean { const normalized = normalizedReactionString(emoji) if (normalized === undefined) return false if (matchesShortcodeSet(emoji, normalized, THUMBS_UP_SHORTCODES)) return true @@ -113,7 +105,7 @@ export function isThumbsUpReactionEmoji(emoji: TEmoji | string): boolean { return false } -export function isThumbsDownReactionEmoji(emoji: TEmoji | string): boolean { +function isThumbsDown(emoji: TEmoji | string): boolean { const normalized = normalizedReactionString(emoji) if (normalized === undefined) return false if (matchesShortcodeSet(emoji, normalized, THUMBS_DOWN_SHORTCODES)) return true @@ -121,7 +113,7 @@ export function isThumbsDownReactionEmoji(emoji: TEmoji | string): boolean { return false } -export function isArrowUpReactionEmoji(emoji: TEmoji | string): boolean { +function isArrowUp(emoji: TEmoji | string): boolean { const normalized = normalizedReactionString(emoji) if (normalized === undefined) return false if (matchesShortcodeSet(emoji, normalized, ARROW_UP_SHORTCODES)) return true @@ -129,7 +121,7 @@ export function isArrowUpReactionEmoji(emoji: TEmoji | string): boolean { return false } -export function isArrowDownReactionEmoji(emoji: TEmoji | string): boolean { +function isArrowDown(emoji: TEmoji | string): boolean { const normalized = normalizedReactionString(emoji) if (normalized === undefined) return false if (matchesShortcodeSet(emoji, normalized, ARROW_DOWN_SHORTCODES)) return true @@ -137,8 +129,7 @@ export function isArrowDownReactionEmoji(emoji: TEmoji | string): boolean { return false } -/** Explicit `dislike` shortcode / content only (not πŸ’” or πŸ‘Ž). */ -export function isDislikeReactionEmoji(emoji: TEmoji | string): boolean { +function isDislike(emoji: TEmoji | string): boolean { const normalized = normalizedReactionString(emoji) if (normalized === undefined) return false if (matchesShortcodeSet(emoji, normalized, DISLIKE_SHORTCODES)) return true @@ -146,39 +137,24 @@ export function isDislikeReactionEmoji(emoji: TEmoji | string): boolean { 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. + * Generic positive/negative reactions (hearts, +, thumbs, arrows, explicit dislike) β€” counted in + * note stats only; not rendered as separate 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 isGenericStatsReactionEmoji(emoji: TEmoji | string): boolean { + return ( + isHeartOrPlusLike(emoji) || + isThumbsUp(emoji) || + isThumbsDown(emoji) || + isArrowUp(emoji) || + isArrowDown(emoji) || + isDislike(emoji) + ) } -export function isLowEffortCollapsedReactionContent(content: string): boolean { - return isLowEffortCollapsedReactionEmoji(content) +export function isGenericStatsReactionContent(content: string): boolean { + return isGenericStatsReactionEmoji(content) } -/** @deprecated Prefer {@link isLowEffortCollapsedReactionContent}. */ -export function isDefaultPlusLikeReactionContent(content: string): boolean { - return isLowEffortCollapsedReactionContent(content) -} +/** @deprecated Use {@link isGenericStatsReactionContent}. */ +export const isLowEffortCollapsedReactionContent = isGenericStatsReactionContent diff --git a/src/lib/thread-response-filter.test.ts b/src/lib/thread-response-filter.test.ts index 3b1dcb74..c56dabcd 100644 --- a/src/lib/thread-response-filter.test.ts +++ b/src/lib/thread-response-filter.test.ts @@ -4,7 +4,7 @@ import type { Event } from 'nostr-tools' import { ExtendedKind } from '@/constants' import { isThreadBoosterOnlyRow, - shouldHideOwnReactionThreadRow, + isThreadReactionOnlyRow, shouldHideThreadResponseEvent } from './thread-response-filter' @@ -50,21 +50,23 @@ describe('thread response filter', () => { 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, + it('hides all NIP-25 reactions from thread rows (stats only)', () => { + const reaction = baseEvent({ kind: kinds.Reaction, - content: '+', + 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) + expect(isThreadReactionOnlyRow(reaction)).toBe(true) + expect(shouldHideThreadResponseEvent(reaction, new Set(), false)).toBe(true) + + const external = baseEvent({ kind: ExtendedKind.EXTERNAL_REACTION, content: 'πŸ‘' }) + expect(isThreadReactionOnlyRow(external)).toBe(true) + expect(shouldHideThreadResponseEvent(external, new Set(), false)).toBe(true) + }) + + it('does not hide kind-1 replies as reactions', () => { + const reply = baseEvent({ kind: kinds.ShortTextNote, content: 'thanks' }) + expect(isThreadReactionOnlyRow(reply)).toBe(false) + expect(shouldHideThreadResponseEvent(reply, new Set(), false)).toBe(false) }) }) diff --git a/src/lib/thread-response-filter.ts b/src/lib/thread-response-filter.ts index 5419f72b..13d52cae 100644 --- a/src/lib/thread-response-filter.ts +++ b/src/lib/thread-response-filter.ts @@ -14,37 +14,39 @@ export function buildNormalizedBlockedRelaySet(blockedRelays: readonly string[] } /** - * NIP-18 boosts: kind **6** (repost kind-1) and kind **16** (generic repost). Shown on the OP - * booster strip only β€” never as discussion thread rows. + * NIP-18 boosts: kind **6** (repost kind-1) and kind **16** (generic repost). Stats on OP/replies + * only β€” never as thread rows (see notifications for full boost events). */ export function isThreadBoosterOnlyRow(evt: Event): boolean { 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. + * NIP-25 reactions: kind **7** and **17** (external). Stats on OP/replies only β€” never thread rows. */ +export function isThreadReactionOnlyRow(evt: Event): boolean { + return isNip25ReactionKind(evt.kind) +} + +/** @deprecated Use {@link isThreadReactionOnlyRow} / {@link shouldHideThreadResponseEvent}. */ export function shouldHideOwnReactionThreadRow( item: Event, - viewerPubkey: string | null | undefined + _viewerPubkey?: string | null ): 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) + return isThreadReactionOnlyRow(item) } /** @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, reactions, muted author, or mute mentions. */ export function shouldHideThreadResponseEvent( evt: Event, mutePubkeySet: Set, hideContentMentioningMutedUsers: boolean | undefined ): boolean { if (isThreadBoosterOnlyRow(evt)) return true + if (isThreadReactionOnlyRow(evt)) return true if (muteSetHas(mutePubkeySet, evt.pubkey)) return true if (hideContentMentioningMutedUsers === true && isMentioningMutedUsers(evt, mutePubkeySet)) return true return false