Browse Source

bug-fixes

imwald
Silberengel 1 month ago
parent
commit
ff1f7e24ef
  1. 196
      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

196
src/components/NoteList/index.tsx

@ -1494,8 +1494,6 @@ const NoteList = forwardRef( @@ -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( @@ -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<ReturnType<typeof setTimeout> | null>(null)
const pendingStatsProfilePubkeysRef = useRef<Set<string>>(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<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])
}, [visibleNoteIdsForStatsPrefetchKey, enqueueFeedProfilePubkeys])
const clientFilteredNewEvents = useMemo(
() =>
@ -1564,7 +1670,6 @@ const NoteList = forwardRef( @@ -1564,7 +1670,6 @@ const NoteList = forwardRef(
useEffect(() => {
const handle = window.setTimeout(() => {
const gen = feedProfileBatchGenRef.current
const candidates = new Set<string>()
for (const e of timelineEventsForFilter) {
collectProfilePrefetchPubkeysFromEvent(e, candidates)
@ -1577,79 +1682,16 @@ const NoteList = forwardRef( @@ -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(() => {

7
src/components/NoteStats/LikeButton.tsx

@ -93,7 +93,12 @@ export function LikeButtonWithStats({ @@ -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`. */

67
src/components/ReplyNoteList/ThreadContextRootNote.tsx

@ -1,67 +0,0 @@ @@ -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' @@ -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[] { @@ -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({ @@ -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.
*/
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({ @@ -130,15 +171,13 @@ export default function ThreadLowEffortStrip({
<CompactAvatarRow items={boosters} ariaLabel={t('Boosts')} />
</div>
) : null}
{plusLikers.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('Liked by:')}</span>
<span className="text-sm leading-none shrink-0" aria-hidden>
{DEFAULT_LIKE_REACTION_DISPLAY_EMOJI}
</span>
<CompactAvatarRow items={plusLikers} ariaLabel={t('Likes')} />
</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>
)
}

51
src/components/ReplyNoteList/index.tsx

@ -26,10 +26,13 @@ import { @@ -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' @@ -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({ @@ -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({ @@ -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({ @@ -1704,9 +1706,6 @@ function ReplyNoteList({
</div>
)}
<div>
{showThreadContextRoot && rootInfo?.type === 'E' && (
<ThreadContextRootNote rootHex={rootInfo.id} contextEvent={event} />
)}
{displayRows.map((row, ri) => {
const prevRow = ri > 0 ? displayRows[ri - 1] : undefined
if (row.type === 'reply') {
@ -1846,7 +1845,7 @@ function ReplyNoteList({ @@ -1846,7 +1845,7 @@ function ReplyNoteList({
<ThreadQuoteBacklinkSkeleton />
</div>
)}
<ThreadLowEffortStrip event={event} statsNoteId={threadStatsNoteId} className="mt-1" />
<ThreadLowEffortStrip event={event} className="mt-1" />
{!loading && !quoteLoading && (
<div className="text-sm mt-2 mb-3 text-center text-muted-foreground">
{mergedFeed.length > 0 ? t('no more replies') : t('no replies')}

5
src/i18n/locales/en.ts

@ -37,6 +37,11 @@ export default { @@ -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",

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

@ -14,6 +14,11 @@ export const DEFAULT_LIKE_REACTION_CONTENT = '+' as const @@ -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 = [ @@ -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<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
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)
}

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

@ -2,7 +2,11 @@ import { describe, expect, it } from 'vitest' @@ -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> = {}): Event {
return {
@ -45,4 +49,22 @@ describe('thread response filter', () => { @@ -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)
})
})

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

@ -1,4 +1,4 @@ @@ -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 { @@ -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,

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

@ -12,6 +12,9 @@ import { Card } from '@/components/ui/card' @@ -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 }: @@ -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 }: @@ -509,7 +532,7 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }:
{rootEventId && (
<ParentNote
key={`thread-root-${finalEvent.id}`}
isFetching={isFetchingRootEvent}
isFetching={isFetchingRootEvent && !rootEventForStrip}
event={rootEventForStrip}
eventBech32Id={rootEventId}
isConsecutive={
@ -521,14 +544,19 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }: @@ -521,14 +544,19 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }:
)}
{parentEventId &&
!eventPointersReferenceSameNote(parentEventId, rootEventId) &&
!eventPointersReferenceSameNote(parentEventId, finalEvent.id) && (
<ParentNote
key={`parent-note-${finalEvent.id}`}
isFetching={isFetchingParentEvent}
event={parentEventForStrip}
eventBech32Id={parentEventId}
/>
)}
!eventPointersReferenceSameNote(parentEventId, finalEvent.id) &&
(parentEventForStrip ? (
<div key={`parent-note-${parentEventForStrip.id}`} className="mb-3 mt-1">
{!isConsecutive(rootEventForStrip, parentEventForStrip) ? (
<Ellipsis className="ml-3.5 mb-1 text-muted-foreground/60 size-3" />
) : 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
key={`note-${finalEvent.id}`}
event={finalEvent}
@ -566,6 +594,24 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }: @@ -566,6 +594,24 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }:
NotePage.displayName = '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 }) {
const { push } = useSecondaryPage()

Loading…
Cancel
Save