Browse Source

bug-fixes

imwald
Silberengel 1 month ago
parent
commit
28cae11f62
  1. 2
      src/components/NoteBoostBadges/index.tsx
  2. 30
      src/components/NoteStats/ZapButton.tsx
  3. 183
      src/components/ReplyNoteList/ThreadLowEffortStrip.tsx
  4. 27
      src/components/ReplyNoteList/index.tsx
  5. 66
      src/lib/like-reaction-emojis.ts
  6. 30
      src/lib/thread-response-filter.test.ts
  7. 22
      src/lib/thread-response-filter.ts

2
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). * 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 }) { export default function NoteBoostBadges({ event, className }: { event: Event; className?: string }) {
const { t } = useTranslation() const { t } = useTranslation()

30
src/components/NoteStats/ZapButton.tsx

@ -144,11 +144,8 @@ export function ZapButtonWithStats({ event, hideCount = false, noteStats }: ZapB
<> <>
<button <button
className={cn( className={cn(
'flex items-center gap-1 select-none px-3 h-full', 'group flex items-center gap-1 select-none px-3 h-full',
hasZapped ? 'text-yellow-400' : 'text-muted-foreground', disable ? 'cursor-not-allowed' : 'cursor-pointer'
disable
? 'cursor-not-allowed text-muted-foreground/40'
: 'cursor-pointer enabled:hover:text-yellow-400'
)} )}
title={t('Zap')} title={t('Zap')}
disabled={disable || zapping} disabled={disable || zapping}
@ -161,9 +158,28 @@ export function ZapButtonWithStats({ event, hideCount = false, noteStats }: ZapB
{zapping ? ( {zapping ? (
<Skeleton className="size-4 shrink-0 rounded-full" aria-hidden /> <Skeleton className="size-4 shrink-0 rounded-full" aria-hidden />
) : ( ) : (
<Zap className={hasZapped ? 'fill-yellow-400' : ''} /> <Zap
className={cn(
hasZapped && 'fill-yellow-400',
disable
? 'text-muted-foreground/40'
: cn(
'text-muted-foreground group-hover:text-yellow-400',
hasZapped && 'text-yellow-400'
)
)}
/>
)}
{!hideCount && !!zapAmount && (
<div
className={cn(
'text-sm tabular-nums',
hasZapped ? 'text-yellow-400' : 'text-muted-foreground'
)}
>
{formatAmount(zapAmount)}
</div>
)} )}
{!hideCount && !!zapAmount && <div className="text-sm">{formatAmount(zapAmount)}</div>}
</button> </button>
<ZapDialog <ZapDialog
open={openZapDialog} open={openZapDialog}

183
src/components/ReplyNoteList/ThreadLowEffortStrip.tsx

@ -1,183 +0,0 @@
import { ExtendedKind } from '@/constants'
import { useNoteStatsById } from '@/hooks/useNoteStatsById'
import { useNoteStatsRelayHints } from '@/hooks/useNoteStatsRelayHints'
import { isDiscussionDownvoteEmoji, isDiscussionUpvoteEmoji } from '@/lib/discussion-votes'
import {
DEFAULT_LIKE_REACTION_DISPLAY_EMOJI,
isLowEffortCollapsedReactionEmoji,
isNegativeLowEffortReactionEmoji,
isPositiveLowEffortReactionEmoji
} from '@/lib/like-reaction-emojis'
import { shouldHideInteractions } from '@/lib/event-filtering'
import { cn } from '@/lib/utils'
import { useUserTrust } from '@/contexts/user-trust-context'
import { useNostr } from '@/providers/NostrProvider'
import noteStatsService from '@/services/note-stats.service'
import type { Event } from 'nostr-tools'
import { useEffect, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import UserAvatar from '../UserAvatar'
const MAX_AVATARS = 20
type LowEffortRow = { id: string; pubkey: string; created_at: number }
function dedupeByPubkeyNewestFirst(rows: LowEffortRow[]): LowEffortRow[] {
const byPubkey = new Map<string, LowEffortRow>()
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 (
<div className="flex flex-wrap items-center gap-0.5" role="list" aria-label={ariaLabel}>
{visible.map((item) => (
<div key={item.id} role="listitem" className="shrink-0">
<UserAvatar userId={item.pubkey} size="xSmall" className="ring-1 ring-background" />
</div>
))}
{overflow > 0 ? (
<span className="text-[10px] font-medium text-muted-foreground/80 px-0.5">+{overflow}</span>
) : null}
</div>
)
}
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 + 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 (
<div
className={cn(
'mx-2 sm:mx-4 border-t border-border/40 pt-2 pb-1 space-y-1.5 opacity-80',
className
)}
>
{boosters.length > 0 ? (
<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">{t('Boosted by:')}</span>
<CompactAvatarRow items={boosters} ariaLabel={t('Boosts')} />
</div>
) : null}
<ReactionRow
label={t('Liked by:')}
glyph={DEFAULT_LIKE_REACTION_DISPLAY_EMOJI}
items={likedBy}
ariaLabel={t('Likes')}
/>
<ReactionRow label={t('Disliked by:')} items={dislikedBy} ariaLabel={t('Dislikes')} />
</div>
)
}

27
src/components/ReplyNoteList/index.tsx

@ -19,20 +19,15 @@ import {
isNip56ReportEvent, isNip56ReportEvent,
isMentioningMutedUsers, isMentioningMutedUsers,
isNip18RepostKind, isNip18RepostKind,
isNip25ReactionKind,
isReplaceableEvent, isReplaceableEvent,
kind1QuotesThreadRoot, kind1QuotesThreadRoot,
resolveDeclaredThreadRootEventHex resolveDeclaredThreadRootEventHex
} 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 { 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 { import { shouldHideThreadResponseEvent } from '@/lib/thread-response-filter'
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'
@ -72,18 +67,11 @@ 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 ThreadLowEffortStrip from './ThreadLowEffortStrip'
import ThreadQuoteBacklink, { import ThreadQuoteBacklink, {
BacklinkAvatarStrip, BacklinkAvatarStrip,
ThreadQuoteBacklinkSkeleton ThreadQuoteBacklinkSkeleton
} from './ThreadQuoteBacklink' } 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 TRootInfo =
| { type: 'E'; id: string; pubkey: string } | { type: 'E'; id: string; pubkey: string }
| { type: 'A'; id: string; eventId: string; pubkey: string; relay?: 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}. */ /** 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<number>(THREAD_BACKLINK_STREAM_KINDS) const EA_THREAD_TAIL_REFERENCE_KINDS = new Set<number>(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<number>([ExtendedKind.EXTERNAL_REACTION])
function isWebThreadTailKind(kind: number): boolean { 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). */ /** 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. * 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) { function mergeFetchedKind7ReactionsIntoRootNoteStats(all: NEvent[], rootInfo: TRootInfo) {
if (rootInfo.type === 'E') { if (rootInfo.type === 'E') {
@ -1637,8 +1622,6 @@ 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 (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
} }
@ -1667,8 +1650,7 @@ function ReplyNoteList({
rootInfo?.type, rootInfo?.type,
repliesMap, repliesMap,
event, event,
isDiscussionRoot, isDiscussionRoot
userPubkey
] ]
) )
@ -1845,7 +1827,6 @@ function ReplyNoteList({
<ThreadQuoteBacklinkSkeleton /> <ThreadQuoteBacklinkSkeleton />
</div> </div>
)} )}
<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')}

66
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 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,
'👍', '👍',
@ -50,7 +45,6 @@ const COMMON_HEART_LIKE_GLYPHS = new Set([
const THUMBS_UP_GLYPHS = new Set(['👍', '+1']) const THUMBS_UP_GLYPHS = new Set(['👍', '+1'])
const THUMBS_DOWN_GLYPHS = new Set(['👎', '-1']) const THUMBS_DOWN_GLYPHS = new Set(['👎', '-1'])
const ARROW_UP_GLYPHS = new Set(['⬆', '⬆', '↑', '🔼']) const ARROW_UP_GLYPHS = new Set(['⬆', '⬆', '↑', '🔼'])
const ARROW_DOWN_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 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_UP_SHORTCODES = new Set(['arrow_up', 'arrowup', 'up', 'upvote', 'up_arrow'])
const ARROW_DOWN_SHORTCODES = new Set(['arrow_down', 'arrowdown', 'down', 'downvote', 'down_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']) const DISLIKE_SHORTCODES = new Set(['dislike'])
function normalizedReactionString(emoji: TEmoji | string): string | undefined { function normalizedReactionString(emoji: TEmoji | string): string | undefined {
@ -70,7 +63,6 @@ function normalizedReactionString(emoji: TEmoji | string): string | undefined {
return undefined return undefined
} }
/** Strip emoji presentation selectors so ⬆ matches ⬆. */
function normalizedGlyph(s: string): string { function normalizedGlyph(s: string): string {
return s.normalize('NFC').replace(/\ufe0f/gi, '').trim() return s.normalize('NFC').replace(/\ufe0f/gi, '').trim()
} }
@ -96,7 +88,7 @@ function matchesShortcodeSet(
return false return false
} }
export function isHeartOrPlusLikeReactionEmoji(emoji: TEmoji | string): boolean { function isHeartOrPlusLike(emoji: TEmoji | string): boolean {
if (typeof emoji === 'object' && emoji !== null && 'shortcode' in emoji) { if (typeof emoji === 'object' && emoji !== null && 'shortcode' in emoji) {
return HEART_LIKE_SHORTCODES.has(emoji.shortcode.trim().toLowerCase()) 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) 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) const normalized = normalizedReactionString(emoji)
if (normalized === undefined) return false if (normalized === undefined) return false
if (matchesShortcodeSet(emoji, normalized, THUMBS_UP_SHORTCODES)) return true if (matchesShortcodeSet(emoji, normalized, THUMBS_UP_SHORTCODES)) return true
@ -113,7 +105,7 @@ export function isThumbsUpReactionEmoji(emoji: TEmoji | string): boolean {
return false return false
} }
export function isThumbsDownReactionEmoji(emoji: TEmoji | string): boolean { function isThumbsDown(emoji: TEmoji | string): boolean {
const normalized = normalizedReactionString(emoji) const normalized = normalizedReactionString(emoji)
if (normalized === undefined) return false if (normalized === undefined) return false
if (matchesShortcodeSet(emoji, normalized, THUMBS_DOWN_SHORTCODES)) return true if (matchesShortcodeSet(emoji, normalized, THUMBS_DOWN_SHORTCODES)) return true
@ -121,7 +113,7 @@ export function isThumbsDownReactionEmoji(emoji: TEmoji | string): boolean {
return false return false
} }
export function isArrowUpReactionEmoji(emoji: TEmoji | string): boolean { function isArrowUp(emoji: TEmoji | string): boolean {
const normalized = normalizedReactionString(emoji) const normalized = normalizedReactionString(emoji)
if (normalized === undefined) return false if (normalized === undefined) return false
if (matchesShortcodeSet(emoji, normalized, ARROW_UP_SHORTCODES)) return true if (matchesShortcodeSet(emoji, normalized, ARROW_UP_SHORTCODES)) return true
@ -129,7 +121,7 @@ export function isArrowUpReactionEmoji(emoji: TEmoji | string): boolean {
return false return false
} }
export function isArrowDownReactionEmoji(emoji: TEmoji | string): boolean { function isArrowDown(emoji: TEmoji | string): boolean {
const normalized = normalizedReactionString(emoji) const normalized = normalizedReactionString(emoji)
if (normalized === undefined) return false if (normalized === undefined) return false
if (matchesShortcodeSet(emoji, normalized, ARROW_DOWN_SHORTCODES)) return true if (matchesShortcodeSet(emoji, normalized, ARROW_DOWN_SHORTCODES)) return true
@ -137,8 +129,7 @@ export function isArrowDownReactionEmoji(emoji: TEmoji | string): boolean {
return false return false
} }
/** Explicit `dislike` shortcode / content only (not 💔 or 👎). */ function isDislike(emoji: TEmoji | string): boolean {
export function isDislikeReactionEmoji(emoji: TEmoji | string): boolean {
const normalized = normalizedReactionString(emoji) const normalized = normalizedReactionString(emoji)
if (normalized === undefined) return false if (normalized === undefined) return false
if (matchesShortcodeSet(emoji, normalized, DISLIKE_SHORTCODES)) return true if (matchesShortcodeSet(emoji, normalized, DISLIKE_SHORTCODES)) return true
@ -146,39 +137,24 @@ export function isDislikeReactionEmoji(emoji: TEmoji | string): boolean {
return false 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 { export function isGenericStatsReactionEmoji(emoji: TEmoji | string): boolean {
return isPositiveLowEffortReactionEmoji(emoji) || isNegativeLowEffortReactionEmoji(emoji) return (
} isHeartOrPlusLike(emoji) ||
isThumbsUp(emoji) ||
/** @deprecated Prefer {@link isLowEffortCollapsedReactionEmoji}. */ isThumbsDown(emoji) ||
export function isDefaultPlusLikeReactionEmoji(emoji: TEmoji | string): boolean { isArrowUp(emoji) ||
return isLowEffortCollapsedReactionEmoji(emoji) isArrowDown(emoji) ||
isDislike(emoji)
)
} }
export function isLowEffortCollapsedReactionContent(content: string): boolean { export function isGenericStatsReactionContent(content: string): boolean {
return isLowEffortCollapsedReactionEmoji(content) return isGenericStatsReactionEmoji(content)
} }
/** @deprecated Prefer {@link isLowEffortCollapsedReactionContent}. */ /** @deprecated Use {@link isGenericStatsReactionContent}. */
export function isDefaultPlusLikeReactionContent(content: string): boolean { export const isLowEffortCollapsedReactionContent = isGenericStatsReactionContent
return isLowEffortCollapsedReactionContent(content)
}

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

@ -4,7 +4,7 @@ import type { Event } from 'nostr-tools'
import { ExtendedKind } from '@/constants' import { ExtendedKind } from '@/constants'
import { import {
isThreadBoosterOnlyRow, isThreadBoosterOnlyRow,
shouldHideOwnReactionThreadRow, isThreadReactionOnlyRow,
shouldHideThreadResponseEvent shouldHideThreadResponseEvent
} from './thread-response-filter' } from './thread-response-filter'
@ -50,21 +50,23 @@ 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)', () => { it('hides all NIP-25 reactions from thread rows (stats only)', () => {
const viewer = 'b'.repeat(64) const reaction = baseEvent({
const myReaction = baseEvent({
pubkey: viewer,
kind: kinds.Reaction, kind: kinds.Reaction,
content: '+', content: '🔥',
tags: [['e', 'c'.repeat(64), '', 'root']] tags: [['e', 'c'.repeat(64), '', 'root']]
}) })
expect(shouldHideOwnReactionThreadRow(myReaction, viewer)).toBe(true) expect(isThreadReactionOnlyRow(reaction)).toBe(true)
expect(shouldHideOwnReactionThreadRow(myReaction, 'a'.repeat(64))).toBe(false) expect(shouldHideThreadResponseEvent(reaction, new Set(), false)).toBe(true)
expect(
shouldHideOwnReactionThreadRow( const external = baseEvent({ kind: ExtendedKind.EXTERNAL_REACTION, content: '👍' })
baseEvent({ pubkey: 'c'.repeat(64), kind: kinds.Reaction, content: '+' }), expect(isThreadReactionOnlyRow(external)).toBe(true)
viewer expect(shouldHideThreadResponseEvent(external, new Set(), false)).toBe(true)
) })
).toBe(false)
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)
}) })
}) })

22
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 * NIP-18 boosts: kind **6** (repost kind-1) and kind **16** (generic repost). Stats on OP/replies
* booster strip only never as discussion thread rows. * only never as thread rows (see notifications for full boost events).
*/ */
export function isThreadBoosterOnlyRow(evt: Event): boolean { 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. * NIP-25 reactions: kind **7** and **17** (external). Stats on OP/replies only never thread rows.
* Counts still use {@link noteStatsService} / merged stats; this only affects thread list rendering.
*/ */
export function isThreadReactionOnlyRow(evt: Event): boolean {
return isNip25ReactionKind(evt.kind)
}
/** @deprecated Use {@link isThreadReactionOnlyRow} / {@link shouldHideThreadResponseEvent}. */
export function shouldHideOwnReactionThreadRow( export function shouldHideOwnReactionThreadRow(
item: Event, item: Event,
viewerPubkey: string | null | undefined _viewerPubkey?: string | null
): boolean { ): boolean {
const viewer = viewerPubkey?.trim().toLowerCase() return isThreadReactionOnlyRow(item)
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}. */ /** @deprecated Use {@link shouldHideOwnReactionThreadRow}. */
export const shouldHideOwnReactionInOthersThread = 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( export function shouldHideThreadResponseEvent(
evt: Event, evt: Event,
mutePubkeySet: Set<string>, mutePubkeySet: Set<string>,
hideContentMentioningMutedUsers: boolean | undefined hideContentMentioningMutedUsers: boolean | undefined
): boolean { ): boolean {
if (isThreadBoosterOnlyRow(evt)) return true if (isThreadBoosterOnlyRow(evt)) return true
if (isThreadReactionOnlyRow(evt)) return true
if (muteSetHas(mutePubkeySet, evt.pubkey)) return true if (muteSetHas(mutePubkeySet, evt.pubkey)) return true
if (hideContentMentioningMutedUsers === true && isMentioningMutedUsers(evt, mutePubkeySet)) return true if (hideContentMentioningMutedUsers === true && isMentioningMutedUsers(evt, mutePubkeySet)) return true
return false return false

Loading…
Cancel
Save