From ff1f7e24ef27d4b04b38574aac28bda072bdc744 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Sat, 16 May 2026 09:18:30 +0200 Subject: [PATCH] bug-fixes --- src/components/NoteList/index.tsx | 196 +++++++++++------- src/components/NoteStats/LikeButton.tsx | 7 +- .../ReplyNoteList/ThreadContextRootNote.tsx | 67 ------ .../ReplyNoteList/ThreadLowEffortStrip.tsx | 133 +++++++----- src/components/ReplyNoteList/index.tsx | 51 +++-- src/i18n/locales/en.ts | 5 + src/lib/like-reaction-emojis.ts | 156 +++++++++++++- src/lib/thread-response-filter.test.ts | 24 ++- src/lib/thread-response-filter.ts | 19 +- src/pages/secondary/NotePage/index.tsx | 68 +++++- 10 files changed, 491 insertions(+), 235 deletions(-) delete mode 100644 src/components/ReplyNoteList/ThreadContextRootNote.tsx diff --git a/src/components/NoteList/index.tsx b/src/components/NoteList/index.tsx index 9c89d7f9..a7cb10c9 100644 --- a/src/components/NoteList/index.tsx +++ b/src/components/NoteList/index.tsx @@ -1494,8 +1494,6 @@ const NoteList = forwardRef( [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( () => clientFilteredEvents @@ -1505,15 +1503,123 @@ const NoteList = forwardRef( [clientFilteredEvents, showCount] ) + const enqueueFeedProfilePubkeys = useCallback((need: string[]) => { + if (need.length === 0) return + const gen = feedProfileBatchGenRef.current + need.forEach((pk) => feedProfileLoadedRef.current.add(pk)) + + setFeedProfileBatch((prev) => { + const pending = new Set(prev.pending) + let pendingChanged = false + for (const pk of need) { + if (!pending.has(pk)) { + pending.add(pk) + pendingChanged = true + } + } + if (!pendingChanged) return prev + return { ...prev, pending } + }) + + void (async () => { + if (gen !== feedProfileBatchGenRef.current) return + const contextualReadRelays = Array.from( + new Set( + subRequestsRef.current + .flatMap((r) => r.urls) + .map((u) => normalizeAnyRelayUrl(u) || normalizeUrl(u) || u.trim()) + .filter(Boolean) + ) + ) + const chunks: string[][] = [] + for (let i = 0; i < need.length; i += FEED_PROFILE_CHUNK) { + chunks.push(need.slice(i, i + FEED_PROFILE_CHUNK)) + } + const settled = await Promise.allSettled( + chunks.map((chunk) => + client.fetchProfilesForPubkeys(chunk, { contextualReadRelays }) + ) + ) + if (gen !== feedProfileBatchGenRef.current) return + + setFeedProfileBatch((prev) => { + const next = new Map(prev.profiles) + const pend = new Set(prev.pending) + settled.forEach((res, idx) => { + const chunk = chunks[idx]! + if (res.status === 'rejected') { + chunk.forEach((pk) => feedProfileLoadedRef.current.delete(pk)) + chunk.forEach((pk) => pend.delete(pk)) + return + } + const profiles = res.value + for (const p of profiles) { + const pkNorm = p.pubkey.toLowerCase() + next.set(pkNorm, { ...p, pubkey: pkNorm }) + pend.delete(pkNorm) + } + for (const pk of chunk) { + const pkNorm = pk.toLowerCase() + pend.delete(pkNorm) + if (!next.has(pkNorm)) { + next.set(pkNorm, { + pubkey: pkNorm, + npub: pubkeyToNpub(pkNorm) ?? '', + username: formatPubkey(pkNorm), + batchPlaceholder: true + }) + } + } + }) + return { profiles: next, pending: pend, version: prev.version + 1 } + }) + })() + }, []) + + const statsProfilePrefetchDebounceRef = useRef | null>(null) + const pendingStatsProfilePubkeysRef = useRef>(new Set()) + useEffect(() => { 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)) + + 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() + 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]) + }, [visibleNoteIdsForStatsPrefetchKey, enqueueFeedProfilePubkeys]) const clientFilteredNewEvents = useMemo( () => @@ -1564,7 +1670,6 @@ const NoteList = forwardRef( useEffect(() => { const handle = window.setTimeout(() => { - const gen = feedProfileBatchGenRef.current const candidates = new Set() for (const e of timelineEventsForFilter) { collectProfilePrefetchPubkeysFromEvent(e, candidates) @@ -1577,79 +1682,16 @@ const NoteList = forwardRef( } const need = [...candidates].filter((pk) => !feedProfileLoadedRef.current.has(pk)) - if (need.length === 0) return - - need.forEach((pk) => feedProfileLoadedRef.current.add(pk)) - - setFeedProfileBatch((prev) => { - const pending = new Set(prev.pending) - let pendingChanged = false - for (const pk of need) { - if (!pending.has(pk)) { - pending.add(pk) - pendingChanged = true - } - } - if (!pendingChanged) return prev - return { ...prev, pending } - }) - - void (async () => { - if (gen !== feedProfileBatchGenRef.current) return - const contextualReadRelays = Array.from( - new Set( - subRequestsRef.current - .flatMap((r) => r.urls) - .map((u) => normalizeAnyRelayUrl(u) || normalizeUrl(u) || u.trim()) - .filter(Boolean) - ) - ) - const chunks: string[][] = [] - for (let i = 0; i < need.length; i += FEED_PROFILE_CHUNK) { - chunks.push(need.slice(i, i + FEED_PROFILE_CHUNK)) - } - const settled = await Promise.allSettled( - chunks.map((chunk) => - client.fetchProfilesForPubkeys(chunk, { contextualReadRelays }) - ) - ) - if (gen !== feedProfileBatchGenRef.current) return - - setFeedProfileBatch((prev) => { - const next = new Map(prev.profiles) - const pend = new Set(prev.pending) - settled.forEach((res, idx) => { - const chunk = chunks[idx]! - if (res.status === 'rejected') { - chunk.forEach((pk) => feedProfileLoadedRef.current.delete(pk)) - chunk.forEach((pk) => pend.delete(pk)) - return - } - const profiles = res.value - for (const p of profiles) { - const pkNorm = p.pubkey.toLowerCase() - next.set(pkNorm, { ...p, pubkey: pkNorm }) - pend.delete(pkNorm) - } - for (const pk of chunk) { - const pkNorm = pk.toLowerCase() - pend.delete(pkNorm) - if (!next.has(pkNorm)) { - next.set(pkNorm, { - pubkey: pkNorm, - npub: pubkeyToNpub(pkNorm) ?? '', - username: formatPubkey(pkNorm), - batchPlaceholder: true - }) - } - } - }) - return { profiles: next, pending: pend, version: prev.version + 1 } - }) - })() + enqueueFeedProfilePubkeys(need) }, FEED_PROFILE_BATCH_DEBOUNCE_MS) return () => window.clearTimeout(handle) - }, [timelineEventsForFilter, newEvents, clientFilteredEvents, showCount, feedStatsProfileBump]) + }, [ + timelineEventsForFilter, + newEvents, + clientFilteredEvents, + showCount, + enqueueFeedProfilePubkeys + ]) const scrollToTop = useCallback((behavior: ScrollBehavior = 'instant') => { setTimeout(() => { diff --git a/src/components/NoteStats/LikeButton.tsx b/src/components/NoteStats/LikeButton.tsx index 75223d77..3813febb 100644 --- a/src/components/NoteStats/LikeButton.tsx +++ b/src/components/NoteStats/LikeButton.tsx @@ -93,7 +93,12 @@ export function LikeButtonWithStats({ 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]) /** Same idea as {@link ReplyButton}: merged likes (thread fetch / publish) can exist before snapshot sets `updatedAt`. */ diff --git a/src/components/ReplyNoteList/ThreadContextRootNote.tsx b/src/components/ReplyNoteList/ThreadContextRootNote.tsx deleted file mode 100644 index 8973962e..00000000 --- a/src/components/ReplyNoteList/ThreadContextRootNote.tsx +++ /dev/null @@ -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 ( -
-

- {t('Original post')} -

- -
- ) - } - if (!rootEvent) return null - - return ( -
-

- {t('Original post')} -

- -
- ) -} diff --git a/src/components/ReplyNoteList/ThreadLowEffortStrip.tsx b/src/components/ReplyNoteList/ThreadLowEffortStrip.tsx index a4e93a13..a6ebe1f1 100644 --- a/src/components/ReplyNoteList/ThreadLowEffortStrip.tsx +++ b/src/components/ReplyNoteList/ThreadLowEffortStrip.tsx @@ -4,13 +4,14 @@ import { useNoteStatsRelayHints } from '@/hooks/useNoteStatsRelayHints' import { isDiscussionDownvoteEmoji, isDiscussionUpvoteEmoji } from '@/lib/discussion-votes' import { DEFAULT_LIKE_REACTION_DISPLAY_EMOJI, - isDefaultPlusLikeReactionEmoji + 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 client from '@/services/client.service' import noteStatsService from '@/services/note-stats.service' import type { Event } from 'nostr-tools' 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) } +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 @@ -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 ( +
+ {label} + {glyph ? ( + + {glyph} + + ) : null} + +
+ ) +} + /** - * 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. */ export default function ThreadLowEffortStrip({ event, - statsNoteId, 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 - /** Hex id of the thread root whose boosts/likes to show (usually the OP). */ - statsNoteId: string className?: string }) { const { t } = useTranslation() const { pubkey } = useNostr() - const noteStats = useNoteStatsById(statsNoteId) + const noteStats = useNoteStatsById(event.id) const { relays: statsRelays, currentRelaysKey } = useNoteStatsRelayHints() 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(() => { - if (!statsNoteId || shouldHideInteractions(event)) return - const target = statsTargetEvent ?? client.peekSessionCachedEvent(statsNoteId) - if (!target) return - void noteStatsService.fetchNoteStats(target, pubkey, statsRelays, { foreground: true }) - }, [statsNoteId, statsTargetEvent, event, pubkey, statsRelays, currentRelaysKey]) + if (!event.id || shouldHideInteractions(event)) return + void noteStatsService.fetchNoteStats(event, pubkey, statsRelays, { foreground: true }) + }, [event, pubkey, statsRelays, currentRelaysKey]) const boosters = useMemo(() => { - let rows = [...(noteStats?.reposts ?? [])] - if (hideUntrustedInteractions && isTrustLoaded) { - rows = rows.filter((r) => isUserTrusted(r.pubkey)) - } - return dedupeByPubkeyNewestFirst(rows) + const rows = [...(noteStats?.reposts ?? [])] + return filterTrustedRows( + dedupeByPubkeyNewestFirst(rows), + hideUntrustedInteractions, + isTrustLoaded, + isUserTrusted + ) }, [noteStats?.reposts, hideUntrustedInteractions, isTrustLoaded, isUserTrusted]) - const plusLikers = useMemo(() => { - if (event.kind === ExtendedKind.DISCUSSION) return [] - let rows = - noteStats?.likes?.filter( - (like) => - isDefaultPlusLikeReactionEmoji(like.emoji) && - !isDiscussionUpvoteEmoji(like.emoji) && - !isDiscussionDownvoteEmoji(like.emoji) - ) ?? [] - if (hideUntrustedInteractions && isTrustLoaded) { - rows = rows.filter((like) => isUserTrusted(like.pubkey)) + 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)) } - return dedupeByPubkeyNewestFirst(rows) }, [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 } @@ -130,15 +171,13 @@ export default function ThreadLowEffortStrip({ ) : null} - {plusLikers.length > 0 ? ( -
- {t('Liked by:')} - - {DEFAULT_LIKE_REACTION_DISPLAY_EMOJI} - - -
- ) : null} + + ) } diff --git a/src/components/ReplyNoteList/index.tsx b/src/components/ReplyNoteList/index.tsx index 38056e3c..46b0f81b 100644 --- a/src/components/ReplyNoteList/index.tsx +++ b/src/components/ReplyNoteList/index.tsx @@ -26,10 +26,13 @@ import { } from '@/lib/event' import logger from '@/lib/logger' 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 { 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 { toNote } from '@/lib/link' import { generateBech32IdFromETag } from '@/lib/tag' @@ -69,17 +72,16 @@ import { useTranslation } from 'react-i18next' import { useQuoteEvents } from '@/hooks' import { LoadingBar } from '../LoadingBar' import ReplyNote, { ReplyNoteSkeleton } from '../ReplyNote' -import ThreadContextRootNote from './ThreadContextRootNote' import ThreadLowEffortStrip from './ThreadLowEffortStrip' import ThreadQuoteBacklink, { BacklinkAvatarStrip, ThreadQuoteBacklinkSkeleton } from './ThreadQuoteBacklink' -/** Collapse default `+` likes into {@link ThreadLowEffortStrip}; keep discussion ⬆️/⬇️ vote rows. */ -function isDefaultPlusLikeReactionEvent(evt: NEvent, isDiscussionRoot: boolean): boolean { +/** Collapse `+`/heart/👍/👎 into {@link ThreadLowEffortStrip}; keep discussion ⬆️/⬇️ vote rows. */ +function isLowEffortCollapsedReactionEvent(evt: NEvent, isDiscussionRoot: boolean): boolean { if (isDiscussionRoot) return false - return isNip25ReactionKind(evt.kind) && isDefaultPlusLikeReactionContent(evt.content) + return isNip25ReactionKind(evt.kind) && isLowEffortCollapsedReactionContent(evt.content) } type TRootInfo = @@ -1635,7 +1637,8 @@ function ReplyNoteList({ (item: NEvent) => { if (isPollVoteKind(item)) 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)) { return false } @@ -1664,26 +1667,25 @@ function ReplyNoteList({ rootInfo?.type, repliesMap, event, - isDiscussionRoot + isDiscussionRoot, + userPubkey ] ) - const threadStatsNoteId = useMemo(() => { - if (rootInfo?.type === 'E') return rootInfo.id - if (rootInfo?.type === 'A' && /^[0-9a-f]{64}$/i.test(rootInfo.eventId)) { - return rootInfo.eventId.toLowerCase() - } - 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 threadRootHex = + rootInfo?.type === 'E' && /^[0-9a-f]{64}$/i.test(rootInfo.id) + ? rootInfo.id.toLowerCase() + : undefined 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( @@ -1704,9 +1706,6 @@ function ReplyNoteList({ )}
- {showThreadContextRoot && rootInfo?.type === 'E' && ( - - )} {displayRows.map((row, ri) => { const prevRow = ri > 0 ? displayRows[ri - 1] : undefined if (row.type === 'reply') { @@ -1846,7 +1845,7 @@ function ReplyNoteList({
)} - + {!loading && !quoteLoading && (
{mergedFeed.length > 0 ? t('no more replies') : t('no replies')} diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 9573581b..7e3afd5a 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -37,6 +37,11 @@ export default { boosted: "boosted", "Boosted by:": "Boosted 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", "just now": "just now", "n minutes ago": "{{n}} minutes ago", diff --git a/src/lib/like-reaction-emojis.ts b/src/lib/like-reaction-emojis.ts index c477d2dc..c3966d6c 100644 --- a/src/lib/like-reaction-emojis.ts +++ b/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 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, '👍', @@ -24,13 +29,156 @@ export const DEFAULT_SUGGESTED_EMOJIS = [ '🚀' ] as const -/** Kind-7 content (or stats row emoji) for the default quick-like (`+`). */ -export function isDefaultPlusLikeReactionEmoji(emoji: TEmoji | string): boolean { +/** Kind-7 bodies many clients publish instead of NIP-25 `+`. */ +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): 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 +): 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 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 { - return isDefaultPlusLikeReactionEmoji(content) + return isLowEffortCollapsedReactionContent(content) } diff --git a/src/lib/thread-response-filter.test.ts b/src/lib/thread-response-filter.test.ts index cf02af53..3b1dcb74 100644 --- a/src/lib/thread-response-filter.test.ts +++ b/src/lib/thread-response-filter.test.ts @@ -2,7 +2,11 @@ import { describe, expect, it } from 'vitest' import { kinds } from 'nostr-tools' import type { Event } from 'nostr-tools' import { ExtendedKind } from '@/constants' -import { isThreadBoosterOnlyRow, shouldHideThreadResponseEvent } from './thread-response-filter' +import { + isThreadBoosterOnlyRow, + shouldHideOwnReactionThreadRow, + shouldHideThreadResponseEvent +} from './thread-response-filter' function baseEvent(overrides: Partial = {}): Event { return { @@ -45,4 +49,22 @@ 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, + 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) + }) }) diff --git a/src/lib/thread-response-filter.ts b/src/lib/thread-response-filter.ts index fd2d0f82..5419f72b 100644 --- a/src/lib/thread-response-filter.ts +++ b/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 { normalizeUrl } from '@/lib/url' import type { Event } from 'nostr-tools' @@ -21,6 +21,23 @@ 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. + */ +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. */ export function shouldHideThreadResponseEvent( evt: Event, diff --git a/src/pages/secondary/NotePage/index.tsx b/src/pages/secondary/NotePage/index.tsx index 85f92f9a..cbe7380c 100644 --- a/src/pages/secondary/NotePage/index.tsx +++ b/src/pages/secondary/NotePage/index.tsx @@ -12,6 +12,9 @@ import { Card } from '@/components/ui/card' import { Separator } from '@/components/ui/separator' import { Skeleton } from '@/components/ui/skeleton' 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 { collectEmbeddedEventPrefetchTargets, @@ -147,16 +150,36 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }: () => (threadRelayHints.length ? { relayHints: threadRelayHints } : undefined), [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 } = - useFetchEvent(rootEventId, undefined, parentRootFetchOpts) + useFetchEvent(rootEventId, rootInitialEvent, parentRootFetchOpts) const { isFetching: isFetchingParentEvent, event: parentEvent, refetch: refetchParent } = - useFetchEvent(parentEventId, undefined, parentRootFetchOpts) + useFetchEvent(parentEventId, parentInitialEvent, parentRootFetchOpts) const selfHex = finalEvent?.id?.toLowerCase() const rootEventForStrip = rootEvent && selfHex && rootEvent.id.toLowerCase() !== selfHex ? rootEvent : undefined const parentEventForStrip = 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 const calendarInviteNaddr = useMemo(() => { @@ -509,7 +532,7 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }: {rootEventId && ( - )} + !eventPointersReferenceSameNote(parentEventId, finalEvent.id) && + (parentEventForStrip ? ( +
+ {!isConsecutive(rootEventForStrip, parentEventForStrip) ? ( + + ) : null} + +
+
+ ) : isFetchingParentEvent ? ( + + ) : null)} + {(rootEventForStrip || parentEventForStrip) && } +
+ +
+ + +
+
+
+ + +
+
+ ) +} + function ExternalRoot({ value }: { value: string }) { const { push } = useSecondaryPage()