Browse Source

fix text emojis in notifications

fix discussion emojis
imwald
Silberengel 1 month ago
parent
commit
28837b169e
  1. 42
      src/components/ContentPreview/index.tsx
  2. 14
      src/components/Emoji/index.tsx
  3. 11
      src/components/Note/NotificationEventCard.tsx
  4. 75
      src/components/Note/ReactionEmojiDisplay.tsx
  5. 42
      src/components/Note/index.tsx
  6. 91
      src/components/NoteStats/LikeButton.tsx
  7. 10
      src/components/NoteStats/Likes.tsx
  8. 14
      src/components/NoteStats/VoteButtons.tsx
  9. 30
      src/components/NoteStats/index.tsx
  10. 5
      src/components/Profile/ProfileHeaderInteractions.tsx
  11. 40
      src/components/ReplyNote/index.tsx
  12. 21
      src/components/ReplyNoteList/index.tsx
  13. 13
      src/components/SuggestedEmojis/DiscussionEmojis.tsx
  14. 96
      src/hooks/useNotificationReactionDisplay.ts
  15. 39
      src/hooks/useReplyUnderDiscussionRoot.ts
  16. 2
      src/i18n/locales/de.ts
  17. 2
      src/i18n/locales/en.ts
  18. 69
      src/lib/discussion-votes.ts
  19. 52
      src/lib/reaction-display.ts
  20. 3
      src/providers/ReplyProvider.tsx
  21. 10
      src/services/discussion-feed-cache.service.ts
  22. 101
      src/services/note-stats.service.ts

42
src/components/ContentPreview/index.tsx

@ -1,5 +1,14 @@ @@ -1,5 +1,14 @@
import { Skeleton } from '@/components/ui/skeleton'
import { ExtendedKind } from '@/constants'
import {
notificationReactionSummaryKey,
useNotificationReactionDisplay
} from '@/hooks/useNotificationReactionDisplay'
import { isMentioningMutedUsers } from '@/lib/event'
import {
DISCUSSION_DOWNVOTE_DISPLAY,
DISCUSSION_UPVOTE_DISPLAY
} from '@/lib/discussion-votes'
import { cn } from '@/lib/utils'
import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider'
import { useMuteListOptional } from '@/contexts/mute-list-context'
@ -20,6 +29,18 @@ import DiscussionNote from '../DiscussionNote' @@ -20,6 +29,18 @@ import DiscussionNote from '../DiscussionNote'
import ApplicationHandlerInfo from '../ApplicationHandlerInfo'
import ApplicationHandlerRecommendation from '../ApplicationHandlerRecommendation'
import FollowPackPreview from './FollowPackPreview'
import ReactionEmojiDisplay from '../Note/ReactionEmojiDisplay'
/** Inert event so hooks can run before `event` is defined. */
const CONTENT_PREVIEW_HOOK_PLACEHOLDER = {
kind: kinds.ShortTextNote,
id: '',
pubkey: '',
content: '',
tags: [],
created_at: 0,
sig: ''
} as Event
export default function ContentPreview({
event,
@ -29,6 +50,7 @@ export default function ContentPreview({ @@ -29,6 +50,7 @@ export default function ContentPreview({
className?: string
}) {
const { t } = useTranslation()
const reactionDisplay = useNotificationReactionDisplay(event ?? CONTENT_PREVIEW_HOOK_PLACEHOLDER)
const muteList = useMuteListOptional()
const mutePubkeySet = muteList?.mutePubkeySet ?? new Set<string>()
const contentPolicy = useContentPolicyOptional()
@ -129,12 +151,22 @@ export default function ContentPreview({ @@ -129,12 +151,22 @@ export default function ContentPreview({
}
if (event.kind === kinds.Reaction) {
const raw = event.content?.trim() ?? ''
const glyph = !raw ? '❤' : raw.length > 24 ? `${raw.slice(0, 24)}` : raw
return (
<div className={cn('pointer-events-none text-sm text-muted-foreground', className)}>
<span className="mr-1.5">{glyph}</span>
{t('Notification reaction summary')}
<div className={cn('pointer-events-none flex items-center gap-1.5 text-sm text-muted-foreground', className)}>
{reactionDisplay.status === 'pending' ? (
<Skeleton className="size-4 shrink-0 rounded-sm" aria-hidden />
) : reactionDisplay.status === 'vote_up' ? (
<span className="text-base leading-none" aria-hidden>
{DISCUSSION_UPVOTE_DISPLAY}
</span>
) : reactionDisplay.status === 'vote_down' ? (
<span className="text-base leading-none" aria-hidden>
{DISCUSSION_DOWNVOTE_DISPLAY}
</span>
) : (
<ReactionEmojiDisplay event={event} maxRawLength={24} variant="compact" />
)}
{t(notificationReactionSummaryKey(reactionDisplay))}
</div>
)
}

14
src/components/Emoji/index.tsx

@ -1,6 +1,6 @@ @@ -1,6 +1,6 @@
import { cn } from '@/lib/utils'
import { TEmoji } from '@/types'
import { Heart } from 'lucide-react'
import { Heart, ThumbsDown } from 'lucide-react'
import { HTMLAttributes, useState } from 'react'
export default function Emoji({
@ -16,12 +16,16 @@ export default function Emoji({ @@ -16,12 +16,16 @@ export default function Emoji({
const [hasError, setHasError] = useState(false)
if (typeof emoji === 'string') {
return emoji === '+' ? (
<Heart className={cn('size-5 text-red-400 fill-red-400', classNames?.img)} />
) : (
<span className={cn('whitespace-nowrap', classNames?.text)}>{emoji}</span>
if (emoji === '+') {
return <Heart className={cn('size-5 text-red-400 fill-red-400', classNames?.img)} />
}
if (emoji === '-') {
return (
<ThumbsDown className={cn('size-5 text-muted-foreground', classNames?.img)} strokeWidth={2} aria-hidden />
)
}
return <span className={cn('whitespace-nowrap', classNames?.text)}>{emoji}</span>
}
if (hasError) {
return (

11
src/components/Note/NotificationEventCard.tsx

@ -3,18 +3,9 @@ import { cn } from '@/lib/utils' @@ -3,18 +3,9 @@ import { cn } from '@/lib/utils'
import { Event, kinds } from 'nostr-tools'
import { useTranslation } from 'react-i18next'
/** Reaction `content` as display emoji/text (NIP-25); empty content → heart. */
export function reactionDisplayEmoji(event: Event): string {
if (event.kind !== kinds.Reaction) return ''
const raw = event.content?.trim() ?? ''
if (!raw) return '❤'
if (raw.length > 64) return `${raw.slice(0, 64)}`
return raw
}
/**
* Compact card for interaction events in notification-style feeds (boosts, poll votes).
* Reactions use a one-line header in {@link Note} (emoji + user + blurb) instead of this card.
* Reactions use ReactionEmojiDisplay in Note (emoji + user + blurb) instead of this card.
*/
export default function NotificationEventCard({ event, className }: { event: Event; className?: string }) {
const { t } = useTranslation()

75
src/components/Note/ReactionEmojiDisplay.tsx

@ -0,0 +1,75 @@ @@ -0,0 +1,75 @@
import Emoji from '@/components/Emoji'
import { resolveReactionEmojiSync } from '@/lib/reaction-display'
import { getEmojiInfosFromEmojiTags } from '@/lib/tag'
import { cn } from '@/lib/utils'
import { replaceableEventService } from '@/services/client.service'
import { TEmoji } from '@/types'
import { Event, kinds } from 'nostr-tools'
import { useEffect, useMemo, useState } from 'react'
/**
* Renders a reaction glyph (Unicode, standard :shortcode:, or NIP-30 custom image from reactor profile).
*/
export default function ReactionEmojiDisplay({
event,
className,
maxRawLength = 64,
variant = 'default'
}: {
event: Event
className?: string
/** Truncate long reaction text beyond this length */
maxRawLength?: number
/** Compact row (notification list at-a-glance) */
variant?: 'default' | 'compact'
}) {
const sync = useMemo(
() => resolveReactionEmojiSync(event, maxRawLength),
[event, maxRawLength]
)
const initial: TEmoji | string =
sync.mode === 'display' ? sync.value : sync.placeholder
const [value, setValue] = useState<TEmoji | string>(initial)
useEffect(() => {
setValue(initial)
}, [initial, event.id])
useEffect(() => {
if (sync.mode !== 'profile' || event.kind !== kinds.Reaction) return
let cancelled = false
replaceableEventService.fetchReplaceableEvent(event.pubkey, kinds.Metadata).then((pe) => {
if (cancelled || !pe) return
const infos = getEmojiInfosFromEmojiTags(pe.tags)
const hit = infos.find((i) => i.shortcode === sync.shortcode)
if (hit) setValue(hit)
})
return () => {
cancelled = true
}
}, [event.pubkey, event.kind, sync])
if (event.kind !== kinds.Reaction || (sync.mode === 'display' && sync.value === '')) {
return null
}
return (
<span
className={cn('inline-flex shrink-0 items-center justify-center leading-none select-none', className)}
aria-hidden
>
<Emoji
emoji={value}
classNames={{
img:
variant === 'compact'
? 'size-4 max-h-[1em] w-auto rounded-sm'
: 'size-7 max-h-[1.5em] w-auto rounded-sm',
text: variant === 'compact' ? 'text-base leading-none' : 'text-2xl leading-none'
}}
/>
</span>
)
}

42
src/components/Note/index.tsx

@ -3,6 +3,15 @@ import { ExtendedKind } from '@/constants' @@ -3,6 +3,15 @@ import { ExtendedKind } from '@/constants'
import { isRenderableNoteKind } from '@/lib/note-renderable-kinds'
import { getHttpUrlFromITags, getParentBech32Id, isNsfwEvent } from '@/lib/event'
import { toNote } from '@/lib/link'
import { cn } from '@/lib/utils'
import {
DISCUSSION_DOWNVOTE_DISPLAY,
DISCUSSION_UPVOTE_DISPLAY
} from '@/lib/discussion-votes'
import {
notificationReactionSummaryKey,
useNotificationReactionDisplay
} from '@/hooks/useNotificationReactionDisplay'
import logger from '@/lib/logger'
import client from '@/services/client.service'
import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider'
@ -41,8 +50,10 @@ import MutedNote from './MutedNote' @@ -41,8 +50,10 @@ import MutedNote from './MutedNote'
import NsfwNote from './NsfwNote'
import PictureNote from './PictureNote'
import Poll from './Poll'
import NotificationEventCard, { reactionDisplayEmoji } from './NotificationEventCard'
import NotificationEventCard from './NotificationEventCard'
import ReactionEmojiDisplay from './ReactionEmojiDisplay'
import UnknownNote from './UnknownNote'
import { Skeleton } from '@/components/ui/skeleton'
import VideoNote from './VideoNote'
import RelayReview from './RelayReview'
import Zap from './Zap'
@ -89,6 +100,7 @@ export default function Note({ @@ -89,6 +100,7 @@ export default function Note({
const [postEditorOpen, setPostEditorOpen] = useState(false)
const [publicMessageTo, setPublicMessageTo] = useState<string | null>(null)
const [callInviteContent, setCallInviteContent] = useState<string | null>(null)
const reactionDisplay = useNotificationReactionDisplay(event)
const openHighlight = useCallback((data: HighlightData, eventContent?: string) => {
setHighlightData(data)
@ -279,12 +291,34 @@ export default function Note({ @@ -279,12 +291,34 @@ export default function Note({
<div className="flex min-w-0 flex-1 items-center gap-2">
{event.kind === kinds.Reaction ? (
<div className="flex min-w-0 flex-1 flex-nowrap items-center gap-2">
{reactionDisplay.status === 'pending' ? (
<Skeleton
className={cn('shrink-0 rounded-sm', size === 'small' ? 'size-7' : 'size-8')}
aria-hidden
/>
) : reactionDisplay.status === 'vote_up' ? (
<span
className={cn(
'inline-flex shrink-0 select-none leading-none',
size === 'small' ? 'text-xl' : 'text-2xl'
)}
aria-hidden
>
{DISCUSSION_UPVOTE_DISPLAY}
</span>
) : reactionDisplay.status === 'vote_down' ? (
<span
className="shrink-0 text-2xl leading-none select-none"
className={cn(
'inline-flex shrink-0 select-none leading-none',
size === 'small' ? 'text-xl' : 'text-2xl'
)}
aria-hidden
>
{reactionDisplayEmoji(event)}
{DISCUSSION_DOWNVOTE_DISPLAY}
</span>
) : (
<ReactionEmojiDisplay event={event} />
)}
<UserAvatar userId={event.pubkey} size={size === 'small' ? 'medium' : 'normal'} />
<div className="flex min-w-0 flex-1 flex-nowrap items-center gap-2 overflow-hidden">
<Username
@ -294,7 +328,7 @@ export default function Note({ @@ -294,7 +328,7 @@ export default function Note({
/>
<ClientTag event={event} />
<span className="min-w-0 flex-1 truncate text-sm text-muted-foreground">
{t('Notification reaction summary')}
{t(notificationReactionSummaryKey(reactionDisplay))}
</span>
</div>
<FormattedTimestamp

91
src/components/NoteStats/LikeButton.tsx

@ -7,9 +7,18 @@ import { @@ -7,9 +7,18 @@ import {
import { Skeleton } from '@/components/ui/skeleton'
import { ExtendedKind } from '@/constants'
import { useNoteStatsById } from '@/hooks/useNoteStatsById'
import { useReplyUnderDiscussionRoot } from '@/hooks/useReplyUnderDiscussionRoot'
import { shouldHideInteractions } from '@/lib/event-filtering'
import { createDeletionRequestDraftEvent, createReactionDraftEvent } from '@/lib/draft-event'
import { getRootEventHexId } from '@/lib/event'
import {
DISCUSSION_DOWNVOTE_DISPLAY,
DISCUSSION_UPVOTE_DISPLAY,
DISCUSSION_VOTE_EMOJIS,
discussionVoteMatches,
isDiscussionDownvoteEmoji,
isDiscussionUpvoteEmoji,
isDiscussionVoteEmoji
} from '@/lib/discussion-votes'
import { useNostr } from '@/providers/NostrProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { useUserTrust } from '@/contexts/user-trust-context'
@ -27,8 +36,6 @@ import SuggestedEmojis from '../SuggestedEmojis' @@ -27,8 +36,6 @@ import SuggestedEmojis from '../SuggestedEmojis'
import { formatCount } from './utils'
import { showPublishingFeedback, showSimplePublishSuccess } from '@/lib/publishing-feedback'
const DISCUSSION_EMOJIS = ['⬆', '⬇']
export default function LikeButton({ event, hideCount = false }: { event: Event; hideCount?: boolean }) {
const { t } = useTranslation()
const { isSmallScreen } = useScreenSize()
@ -40,43 +47,30 @@ export default function LikeButton({ event, hideCount = false }: { event: Event; @@ -40,43 +47,30 @@ export default function LikeButton({ event, hideCount = false }: { event: Event;
const noteStats = useNoteStatsById(event.id)
const isDiscussion = event.kind === ExtendedKind.DISCUSSION
const inQuietMode = shouldHideInteractions(event)
const isReplyToDiscussion = useReplyUnderDiscussionRoot(event)
const showDiscussionVotes = isDiscussion || isReplyToDiscussion
// Check if this is a reply to a discussion event
const [isReplyToDiscussion, setIsReplyToDiscussion] = useState(false)
useMemo(() => {
if (isDiscussion) return // Already a discussion event
const rootEventId = getRootEventHexId(event)
if (rootEventId) {
// Fetch the root event to check if it's a discussion
eventService.fetchEvent(rootEventId).then(rootEvent => {
if (rootEvent && rootEvent.kind === ExtendedKind.DISCUSSION) {
setIsReplyToDiscussion(true)
}
}).catch(() => {
// If we can't fetch the root event, assume it's not a discussion reply
setIsReplyToDiscussion(false)
})
}
}, [event.id, isDiscussion])
const { myLastEmoji, likeCount, upVoteCount, downVoteCount } = useMemo(() => {
const stats = noteStats || {}
const myLike = stats.likes?.find((like) => like.pubkey === pubkey)
const likes = hideUntrustedInteractions
? stats.likes?.filter((like) => isUserTrusted(like.pubkey))
: stats.likes
// Calculate separate up/down vote counts for discussions
const myLike = likes?.find((like) => {
if (like.pubkey !== pubkey) return false
if (showDiscussionVotes) return isDiscussionVoteEmoji(like.emoji)
return true
})
let upVoteCount = 0
let downVoteCount = 0
if (isDiscussion || isReplyToDiscussion) {
upVoteCount = likes?.filter(like => like.emoji === '⬆').length || 0
downVoteCount = likes?.filter(like => like.emoji === '⬇').length || 0
if (showDiscussionVotes) {
upVoteCount = likes?.filter((like) => isDiscussionUpvoteEmoji(like.emoji)).length || 0
downVoteCount = likes?.filter((like) => isDiscussionDownvoteEmoji(like.emoji)).length || 0
}
return { myLastEmoji: myLike?.emoji, likeCount: likes?.length, upVoteCount, downVoteCount }
}, [noteStats, pubkey, hideUntrustedInteractions, isDiscussion, isReplyToDiscussion])
}, [noteStats, pubkey, hideUntrustedInteractions, showDiscussionVotes])
const like = async (emoji: string | TEmoji) => {
checkLogin(async () => {
@ -90,12 +84,16 @@ export default function LikeButton({ event, hideCount = false }: { event: Event; @@ -90,12 +84,16 @@ export default function LikeButton({ event, hideCount = false }: { event: Event;
await noteStatsService.fetchNoteStats(event, pubkey)
}
// Check if user is clicking the same emoji they already reacted with
const emojiString = typeof emoji === 'string' ? emoji : emoji.shortcode
// Normalize myLastEmoji for comparison
const myLastEmojiString = typeof myLastEmoji === 'string' ? myLastEmoji : typeof myLastEmoji === 'object' ? myLastEmoji.shortcode : undefined
const isTogglingOff = myLastEmojiString === emojiString
const myLastEmojiString =
typeof myLastEmoji === 'string'
? myLastEmoji
: typeof myLastEmoji === 'object'
? myLastEmoji.shortcode
: undefined
const isTogglingOff = showDiscussionVotes
? discussionVoteMatches(myLastEmoji, emoji)
: myLastEmojiString === emojiString
logger.debug('Like toggle check', {
myLastEmoji,
@ -109,6 +107,7 @@ export default function LikeButton({ event, hideCount = false }: { event: Event; @@ -109,6 +107,7 @@ export default function LikeButton({ event, hideCount = false }: { event: Event;
// User wants to toggle off - find their previous reaction and delete it
const myReaction = noteStats?.likes?.find((like) => {
if (like.pubkey !== pubkey) return false
if (showDiscussionVotes) return discussionVoteMatches(like.emoji, emoji)
const likeEmojiString = typeof like.emoji === 'string' ? like.emoji : like.emoji.shortcode
return likeEmojiString === emojiString
})
@ -205,32 +204,38 @@ export default function LikeButton({ event, hideCount = false }: { event: Event; @@ -205,32 +204,38 @@ export default function LikeButton({ event, hideCount = false }: { event: Event;
</button>
)
// For discussions, show the two arrow emojis directly as buttons
if (isDiscussion || isReplyToDiscussion) {
// Discussions (kind 11) and kind 1111 under a discussion: only +/- vote reactions
if (showDiscussionVotes) {
return (
<div className="flex items-center gap-1">
{DISCUSSION_EMOJIS.map((emoji, index) => {
const isSelected = myLastEmoji === emoji
{DISCUSSION_VOTE_EMOJIS.map((emoji, index) => {
const isSelected =
index === 0 ? isDiscussionUpvoteEmoji(myLastEmoji) : isDiscussionDownvoteEmoji(myLastEmoji)
const count = index === 0 ? upVoteCount : downVoteCount
const arrow = index === 0 ? DISCUSSION_UPVOTE_DISPLAY : DISCUSSION_DOWNVOTE_DISPLAY
return (
<button
key={index}
key={emoji}
className={`flex items-center enabled:hover:text-primary gap-1 px-2 h-full text-muted-foreground rounded ${
isSelected ? 'text-primary bg-muted' : ''
}`}
title={emoji === '' ? t('Upvote') : t('Downvote')}
title={emoji === '+' ? t('Upvote') : t('Downvote')}
disabled={liking}
onClick={() => {
like(emoji)
}}
>
{liking && index === 0 ? (
{liking ? (
<Skeleton className="size-4 shrink-0 rounded-full" aria-hidden />
) : (
<>
<span className="text-base">{emoji}</span>
{!hideCount && !!count && (
<div className="text-sm">{formatCount(count)}</div>
<span className="text-base leading-none" aria-hidden>
{arrow}
</span>
{!hideCount && noteStats?.updatedAt != null && (
<div className="text-sm tabular-nums">
{count >= 100 ? '99+' : count}
</div>
)}
</>
)}

10
src/components/NoteStats/Likes.tsx

@ -1,7 +1,6 @@ @@ -1,7 +1,6 @@
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card'
import { Skeleton } from '@/components/ui/skeleton'
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'
import { ExtendedKind } from '@/constants'
import { useNoteStatsById } from '@/hooks/useNoteStatsById'
import { shouldHideInteractions } from '@/lib/event-filtering'
import { createReactionDraftEvent } from '@/lib/draft-event'
@ -30,9 +29,6 @@ export default function Likes({ event }: { event: Event }) { @@ -30,9 +29,6 @@ export default function Likes({ event }: { event: Event }) {
let _likes = noteStats?.likes
if (!_likes) return []
if (event.kind === ExtendedKind.DISCUSSION) {
_likes = _likes.filter((item) => item.emoji === '⬆' || item.emoji === '⬇')
}
if (hideUntrustedInteractions) {
_likes = _likes.filter((item) => isUserTrusted(item.pubkey))
}
@ -103,9 +99,9 @@ export default function Likes({ event }: { event: Event }) { @@ -103,9 +99,9 @@ export default function Likes({ event }: { event: Event }) {
if (isCompleted) {
const completedKey = isCompleted
const completedEmoji = likes.find((l) => l.key === completedKey)?.emoji
if (completedEmoji) {
like(completedKey, completedEmoji)
const row = likes.find((l) => l.key === completedKey)
if (row) {
like(completedKey, row.emoji)
}
}

14
src/components/NoteStats/VoteButtons.tsx

@ -1,4 +1,10 @@ @@ -1,4 +1,10 @@
import { Button } from '@/components/ui/button'
import {
DISCUSSION_DOWNVOTE,
DISCUSSION_UPVOTE,
isDiscussionDownvoteEmoji,
isDiscussionUpvoteEmoji
} from '@/lib/discussion-votes'
import { createReactionDraftEvent } from '@/lib/draft-event'
import { useNostr } from '@/providers/NostrProvider'
import noteStatsService from '@/services/note-stats.service'
@ -21,8 +27,8 @@ export default function VoteButtons({ event }: { event: Event }) { @@ -21,8 +27,8 @@ export default function VoteButtons({ event }: { event: Event }) {
const stats = noteStats || {}
const reactions = stats.likes || []
const upvoteReactions = reactions.filter(r => r.emoji === '⬆')
const downvoteReactions = reactions.filter(r => r.emoji === '⬇')
const upvoteReactions = reactions.filter((r) => isDiscussionUpvoteEmoji(r.emoji))
const downvoteReactions = reactions.filter((r) => isDiscussionDownvoteEmoji(r.emoji))
const score = upvoteReactions.length - downvoteReactions.length
@ -57,7 +63,7 @@ export default function VoteButtons({ event }: { event: Event }) { @@ -57,7 +63,7 @@ export default function VoteButtons({ event }: { event: Event }) {
}
// Create the vote reaction
const emoji = type === 'up' ? '⬆' : '⬇'
const emoji = type === 'up' ? DISCUSSION_UPVOTE : DISCUSSION_DOWNVOTE
// Check if user already voted this way
const existingVote = userVote === type
@ -87,7 +93,7 @@ export default function VoteButtons({ event }: { event: Event }) { @@ -87,7 +93,7 @@ export default function VoteButtons({ event }: { event: Event }) {
} else {
// If user voted the opposite way, first remove the old vote
if (userVote) {
const oldEmoji = userVote === 'up' ? '⬆' : '⬇'
const oldEmoji = userVote === 'up' ? DISCUSSION_UPVOTE : DISCUSSION_DOWNVOTE
const removeReaction = createReactionDraftEvent(event, oldEmoji)
await publish(removeReaction)
}

30
src/components/NoteStats/index.tsx

@ -4,11 +4,10 @@ import { useScreenSize } from '@/providers/ScreenSizeProvider' @@ -4,11 +4,10 @@ import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import noteStatsService from '@/services/note-stats.service'
import { ExtendedKind } from '@/constants'
import { getRootEventHexId } from '@/lib/event'
import { useReplyUnderDiscussionRoot } from '@/hooks/useReplyUnderDiscussionRoot'
import { shouldHideInteractions } from '@/lib/event-filtering'
import { eventService } from '@/services/client.service'
import { Event } from 'nostr-tools'
import { useEffect, useState, useMemo } from 'react'
import { useEffect, useState } from 'react'
import BookmarkButton from '../BookmarkButton'
import LikeButton from './LikeButton'
import Likes from './Likes'
@ -39,7 +38,7 @@ export default function NoteStats({ @@ -39,7 +38,7 @@ export default function NoteStats({
// Hide boost button for discussion events and replies to discussions
const isDiscussion = event.kind === ExtendedKind.DISCUSSION
const [isReplyToDiscussion, setIsReplyToDiscussion] = useState(false)
const isReplyToDiscussion = useReplyUnderDiscussionRoot(event)
// Hide interaction counts if event is in quiet mode
const hideInteractions = shouldHideInteractions(event)
@ -47,22 +46,8 @@ export default function NoteStats({ @@ -47,22 +46,8 @@ export default function NoteStats({
/** Synthetic RSS article root: only reply + reactions (no boost/quote/zap). */
const isRssArticleRoot = event.kind === ExtendedKind.RSS_THREAD_ROOT
useMemo(() => {
if (isDiscussion) return // Already a discussion event
const rootEventId = getRootEventHexId(event)
if (rootEventId) {
// Fetch the root event to check if it's a discussion
eventService.fetchEvent(rootEventId).then(rootEvent => {
if (rootEvent && rootEvent.kind === ExtendedKind.DISCUSSION) {
setIsReplyToDiscussion(true)
}
}).catch(() => {
// If we can't fetch the root event, assume it's not a discussion reply
setIsReplyToDiscussion(false)
})
}
}, [event.id, isDiscussion])
/** Kind 11 / kind 1111 under kind 11: LikeButton already shows ⬆/⬇ counts — skip duplicate pill row. */
const showLikesPills = !isDiscussion && !isReplyToDiscussion && !isRssArticleRoot
useEffect(() => {
if (!fetchIfNotExisting) return
@ -75,8 +60,7 @@ export default function NoteStats({ @@ -75,8 +60,7 @@ export default function NoteStats({
<div className={cn('select-none', className)} data-note-stats onClick={(e) => e.stopPropagation()}>
{displayTopZapsAndLikes && (
<>
{/* Kind 11: LikeButton already shows ⬆/⬇; Likes row would duplicate those pills */}
{!isDiscussion && !isRssArticleRoot && <Likes event={event} />}
{showLikesPills && <Likes event={event} />}
</>
)}
<div
@ -103,7 +87,7 @@ export default function NoteStats({ @@ -103,7 +87,7 @@ export default function NoteStats({
<div className={cn('select-none', className)} data-note-stats onClick={(e) => e.stopPropagation()}>
{displayTopZapsAndLikes && (
<>
{!isDiscussion && !isRssArticleRoot && <Likes event={event} />}
{showLikesPills && <Likes event={event} />}
</>
)}
<div className="flex justify-between h-5 [&_svg]:size-4">

5
src/components/Profile/ProfileHeaderInteractions.tsx

@ -9,7 +9,7 @@ import { getEmojiInfosFromEmojiTags } from '@/lib/tag' @@ -9,7 +9,7 @@ import { getEmojiInfosFromEmojiTags } from '@/lib/tag'
import type { TProfileZap } from '@/hooks/useProfileInteractions'
import type { TProfileBadge } from '@/hooks/useProfileBadges'
import type { TProfileFollowPack } from '@/hooks/useProfileFollowPacks'
import { Zap, MessageCircle, ThumbsUp, Users } from 'lucide-react'
import { Zap, MessageCircle, ThumbsDown, ThumbsUp, Users } from 'lucide-react'
import { Skeleton } from '@/components/ui/skeleton'
import { useTranslation } from 'react-i18next'
import { Event } from 'nostr-tools'
@ -53,6 +53,7 @@ function ReactionBadge({ event }: { event: Event }) { @@ -53,6 +53,7 @@ function ReactionBadge({ event }: { event: Event }) {
const emojiInfos = getEmojiInfosFromEmojiTags(event.tags)
const displayContent = event.content.trim() || (emojiInfos[0] ? emojiInfos[0].shortcode : '+')
const isPlus = displayContent === '+'
const isMinus = displayContent === '-'
return (
<button
type="button"
@ -62,6 +63,8 @@ function ReactionBadge({ event }: { event: Event }) { @@ -62,6 +63,8 @@ function ReactionBadge({ event }: { event: Event }) {
<UserAvatar userId={event.pubkey} size="tiny" className="shrink-0" />
{isPlus ? (
<ThumbsUp className="size-3 shrink-0 text-primary" aria-hidden />
) : isMinus ? (
<ThumbsDown className="size-3 shrink-0 text-muted-foreground" aria-hidden />
) : typeof displayContent === 'string' && !displayContent.startsWith(':') ? (
<span className="text-xs shrink-0">{displayContent}</span>
) : (

40
src/components/ReplyNote/index.tsx

@ -1,17 +1,26 @@ @@ -1,17 +1,26 @@
import { useSmartNoteNavigation } from '@/PageManager'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import {
notificationReactionSummaryKey,
useNotificationReactionDisplay
} from '@/hooks/useNotificationReactionDisplay'
import {
DISCUSSION_DOWNVOTE_DISPLAY,
DISCUSSION_UPVOTE_DISPLAY
} from '@/lib/discussion-votes'
import { isMentioningMutedUsers } from '@/lib/event'
import { toNote } from '@/lib/link'
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { useMuteList } from '@/contexts/mute-list-context'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { Event } from 'nostr-tools'
import { Event, kinds } from 'nostr-tools'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import ClientTag from '../ClientTag'
import Collapsible from '../Collapsible'
import MarkdownArticle from '../Note/MarkdownArticle/MarkdownArticle'
import ReactionEmojiDisplay from '../Note/ReactionEmojiDisplay'
import { FormattedTimestamp } from '../FormattedTimestamp'
import Nip05 from '../Nip05'
import NoteOptions from '../NoteOptions'
@ -39,6 +48,7 @@ export default function ReplyNote({ @@ -39,6 +48,7 @@ export default function ReplyNote({
const { mutePubkeySet } = useMuteList()
const { hideContentMentioningMutedUsers } = useContentPolicy()
const [showMuted, setShowMuted] = useState(false)
const reactionDisplay = useNotificationReactionDisplay(event)
const show = useMemo(() => {
if (showMuted) {
return true
@ -107,7 +117,26 @@ export default function ReplyNote({ @@ -107,7 +117,26 @@ export default function ReplyNote({
/>
)}
{show ? (
event.kind === kinds.Reaction ? (
<div className="mt-2 flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
{reactionDisplay.status === 'pending' ? (
<Skeleton className="size-4 shrink-0 rounded-sm" aria-hidden />
) : reactionDisplay.status === 'vote_up' ? (
<span className="text-base leading-none" aria-hidden>
{DISCUSSION_UPVOTE_DISPLAY}
</span>
) : reactionDisplay.status === 'vote_down' ? (
<span className="text-base leading-none" aria-hidden>
{DISCUSSION_DOWNVOTE_DISPLAY}
</span>
) : (
<ReactionEmojiDisplay event={event} variant="compact" maxRawLength={64} />
)}
<span>{t(notificationReactionSummaryKey(reactionDisplay))}</span>
</div>
) : (
<MarkdownArticle className="mt-2" event={event} hideMetadata={true} />
)
) : (
<Button
variant="outline"
@ -123,7 +152,14 @@ export default function ReplyNote({ @@ -123,7 +152,14 @@ export default function ReplyNote({
</div>
</div>
</Collapsible>
{show && <NoteStats className="ml-14 pl-1 mr-4 mt-2" event={event} displayTopZapsAndLikes />}
{show && event.kind !== kinds.Reaction && (
<NoteStats
className="ml-14 pl-1 mr-4 mt-2"
event={event}
displayTopZapsAndLikes
fetchIfNotExisting
/>
)}
</div>
)
}

21
src/components/ReplyNoteList/index.tsx

@ -1,4 +1,5 @@ @@ -1,4 +1,5 @@
import { E_TAG_FILTER_BLOCKED_RELAY_URLS, ExtendedKind } from '@/constants'
import { isDiscussionDownvoteEmoji, isDiscussionUpvoteEmoji } from '@/lib/discussion-votes'
import { getArticleUrlFromCommentITags } from '@/lib/rss-article'
import {
eventReferencesEventId,
@ -78,6 +79,8 @@ function ReplyNoteList({ @@ -78,6 +79,8 @@ function ReplyNoteList({
showQuotes ?? false
)
const isDiscussionRoot = event.kind === ExtendedKind.DISCUSSION
// Helper function to get vote score for a reply
const getReplyVoteScore = (reply: NEvent) => {
const stats = noteStatsService.getNoteStats(reply.id)
@ -85,8 +88,12 @@ function ReplyNoteList({ @@ -85,8 +88,12 @@ function ReplyNoteList({
return 0
}
const upvoteReactions = stats.likes.filter(r => r.emoji === '⬆')
const downvoteReactions = stats.likes.filter(r => r.emoji === '⬇')
const upvoteReactions = stats.likes.filter((r) =>
isDiscussionRoot ? isDiscussionUpvoteEmoji(r.emoji) : r.emoji === '⬆'
)
const downvoteReactions = stats.likes.filter((r) =>
isDiscussionRoot ? isDiscussionDownvoteEmoji(r.emoji) : r.emoji === '⬇'
)
const score = upvoteReactions.length - downvoteReactions.length
return score
@ -99,8 +106,12 @@ function ReplyNoteList({ @@ -99,8 +106,12 @@ function ReplyNoteList({
return 0
}
const upvoteReactions = stats.likes.filter(r => r.emoji === '⬆')
const downvoteReactions = stats.likes.filter(r => r.emoji === '⬇')
const upvoteReactions = stats.likes.filter((r) =>
isDiscussionRoot ? isDiscussionUpvoteEmoji(r.emoji) : r.emoji === '⬆'
)
const downvoteReactions = stats.likes.filter((r) =>
isDiscussionRoot ? isDiscussionDownvoteEmoji(r.emoji) : r.emoji === '⬇'
)
// Controversy = minimum of upvotes and downvotes (both need to be high)
const controversy = Math.min(upvoteReactions.length, downvoteReactions.length)
@ -141,6 +152,7 @@ function ReplyNoteList({ @@ -141,6 +152,7 @@ function ReplyNoteList({
events.forEach((evt) => {
if (replyIdSet.has(evt.id)) return
if (evt.kind === kinds.Reaction) return
if (mutePubkeySet.has(evt.pubkey)) {
return
}
@ -154,6 +166,7 @@ function ReplyNoteList({ @@ -154,6 +166,7 @@ function ReplyNoteList({
// Prevent infinite loops by tracking processed event IDs
const newParentEventKeys = events
.filter((evt) => evt.kind !== kinds.Reaction)
.map((evt) => evt.id)
.filter((id) => !processedEventIds.has(id))

13
src/components/SuggestedEmojis/DiscussionEmojis.tsx

@ -1,6 +1,11 @@ @@ -1,6 +1,11 @@
import {
DISCUSSION_DOWNVOTE_DISPLAY,
DISCUSSION_UPVOTE_DISPLAY,
DISCUSSION_VOTE_EMOJIS
} from '@/lib/discussion-votes'
import { TEmoji } from '@/types'
const DISCUSSION_EMOJIS = ['⬆', '⬇']
const GLYPHS = [DISCUSSION_UPVOTE_DISPLAY, DISCUSSION_DOWNVOTE_DISPLAY] as const
export default function DiscussionEmojis({
onEmojiClick
@ -9,13 +14,13 @@ export default function DiscussionEmojis({ @@ -9,13 +14,13 @@ export default function DiscussionEmojis({
}) {
return (
<div className="flex gap-1 p-1" style={{ width: '60px', maxWidth: '60px' }} onClick={(e) => e.stopPropagation()}>
{DISCUSSION_EMOJIS.map((emoji, index) => (
{DISCUSSION_VOTE_EMOJIS.map((emoji, i) => (
<div
key={index}
key={emoji}
className="w-6 h-6 rounded-lg clickable flex justify-center items-center text-base hover:bg-muted flex-shrink-0"
onClick={() => onEmojiClick(emoji)}
>
{emoji}
{GLYPHS[i]}
</div>
))}
</div>

96
src/hooks/useNotificationReactionDisplay.ts

@ -0,0 +1,96 @@ @@ -0,0 +1,96 @@
import { ExtendedKind } from '@/constants'
import {
isDiscussionDownvoteEmoji,
isDiscussionUpvoteEmoji
} from '@/lib/discussion-votes'
import { getRootEventHexId } from '@/lib/event'
import { getFirstHexEventIdFromETags } from '@/lib/tag'
import { eventService } from '@/services/client.service'
import { Event, kinds } from 'nostr-tools'
import { useEffect, useMemo, useState } from 'react'
export type NotificationReactionDisplay =
| { status: 'pending' }
| { status: 'vote_up' }
| { status: 'vote_down' }
| { status: 'discussion_custom' }
| { status: 'default' }
/**
* For kind 7: resolves whether the reacted-to note is a discussion (kind 11 or 1111 under 11)
* and classifies +/- / as vote display vs other reactions.
*/
export function useNotificationReactionDisplay(event: Event): NotificationReactionDisplay {
const targetId = useMemo(() => {
if (event.kind !== kinds.Reaction) return undefined
return getFirstHexEventIdFromETags(event.tags)
}, [event.kind, event.tags])
const [state, setState] = useState<NotificationReactionDisplay>(() =>
event.kind === kinds.Reaction ? { status: 'pending' } : { status: 'default' }
)
useEffect(() => {
if (event.kind !== kinds.Reaction) {
setState({ status: 'default' })
return
}
if (!targetId) {
setState({ status: 'default' })
return
}
let cancelled = false
setState({ status: 'pending' })
;(async () => {
const target = await eventService.fetchEvent(targetId)
if (cancelled) return
if (!target) {
setState({ status: 'default' })
return
}
let inDiscussion = target.kind === ExtendedKind.DISCUSSION
if (!inDiscussion && target.kind === ExtendedKind.COMMENT) {
const rootId = getRootEventHexId(target)
if (rootId) {
const root = await eventService.fetchEvent(rootId)
if (cancelled) return
inDiscussion = root?.kind === ExtendedKind.DISCUSSION
}
}
if (!inDiscussion) {
setState({ status: 'default' })
return
}
const raw = event.content?.trim() ?? ''
if (isDiscussionUpvoteEmoji(raw)) {
setState({ status: 'vote_up' })
} else if (isDiscussionDownvoteEmoji(raw)) {
setState({ status: 'vote_down' })
} else {
setState({ status: 'discussion_custom' })
}
})()
return () => {
cancelled = true
}
}, [event.id, event.kind, event.content, targetId])
return state
}
export function notificationReactionSummaryKey(
display: NotificationReactionDisplay
):
| 'Notification discussion upvote summary'
| 'Notification discussion downvote summary'
| 'Notification reaction summary' {
if (display.status === 'vote_up') return 'Notification discussion upvote summary'
if (display.status === 'vote_down') return 'Notification discussion downvote summary'
return 'Notification reaction summary'
}

39
src/hooks/useReplyUnderDiscussionRoot.ts

@ -0,0 +1,39 @@ @@ -0,0 +1,39 @@
import { ExtendedKind } from '@/constants'
import { getRootEventHexId } from '@/lib/event'
import { eventService } from '@/services/client.service'
import { Event } from 'nostr-tools'
import { useEffect, useState } from 'react'
/**
* True when `event` is kind 1111 (COMMENT) whose thread root is a kind 11 discussion.
*/
export function useReplyUnderDiscussionRoot(event: Event): boolean {
const [isReply, setIsReply] = useState(false)
useEffect(() => {
if (event.kind !== ExtendedKind.COMMENT) {
setIsReply(false)
return
}
const rootEventId = getRootEventHexId(event)
if (!rootEventId) {
setIsReply(false)
return
}
let cancelled = false
eventService
.fetchEvent(rootEventId)
.then((rootEvent) => {
if (cancelled) return
setIsReply(!!(rootEvent && rootEvent.kind === ExtendedKind.DISCUSSION))
})
.catch(() => {
if (!cancelled) setIsReply(false)
})
return () => {
cancelled = true
}
}, [event.id, event.kind])
return isReply
}

2
src/i18n/locales/de.ts

@ -395,6 +395,8 @@ export default { @@ -395,6 +395,8 @@ export default {
'Open in a': 'Öffnen in {{a}}',
'Cannot handle event of kind k': 'Ereignis des Typs {{k}} kann nicht verarbeitet werden',
'Notification reaction summary': 'hat auf diese Notiz reagiert.',
'Notification discussion upvote summary': 'hat in dieser Diskussion positiv bewertet.',
'Notification discussion downvote summary': 'hat in dieser Diskussion negativ bewertet.',
'Notification boost summary': 'Hat diese Notiz geboostet',
'Notification boost detail': 'Die Vorschau darüber ist der Originalbeitrag.',
'Notification poll vote summary': 'Hat an der Umfrage darüber teilgenommen.',

2
src/i18n/locales/en.ts

@ -388,6 +388,8 @@ export default { @@ -388,6 +388,8 @@ export default {
'Open in a': 'Open in {{a}}',
'Cannot handle event of kind k': 'Cannot handle event of kind {{k}}',
'Notification reaction summary': 'reacted to this note.',
'Notification discussion upvote summary': 'upvoted in this discussion.',
'Notification discussion downvote summary': 'downvoted in this discussion.',
'Notification boost summary': 'Boosted this note',
'Notification boost detail': 'The preview above is the original post.',
'Notification poll vote summary': 'Voted on the poll above.',

69
src/lib/discussion-votes.ts

@ -0,0 +1,69 @@ @@ -0,0 +1,69 @@
import type { TEmoji } from '@/types'
/** Canonical reaction `content` for discussion upvotes (kind 7). */
export const DISCUSSION_UPVOTE = '+'
/** Canonical reaction `content` for discussion downvotes (kind 7). */
export const DISCUSSION_DOWNVOTE = '-'
/** Shown in discussion UIs; legacy reaction `content` used the same characters. */
export const DISCUSSION_UPVOTE_DISPLAY = '⬆'
export const DISCUSSION_DOWNVOTE_DISPLAY = '⬇'
function emojiString(emoji: TEmoji | string | undefined | null): string | undefined {
if (emoji === undefined || emoji === null) return undefined
return typeof emoji === 'string' ? emoji : emoji.shortcode
}
/**
* Relays/clients may use (U+2B06) vs (U+2B06 U+FE0F); normalize before comparing.
*/
function normalizedVoteToken(s: string): string {
return s.normalize('NFC').replace(/\ufe0f/g, '').trim()
}
const UP_ARROW = '\u2b06'
const DOWN_ARROW = '\u2b07'
export function isDiscussionUpvoteEmoji(emoji: TEmoji | string | undefined | null): boolean {
const s = emojiString(emoji)
if (s === undefined) return false
const n = normalizedVoteToken(s)
return n === DISCUSSION_UPVOTE || n === UP_ARROW
}
export function isDiscussionDownvoteEmoji(emoji: TEmoji | string | undefined | null): boolean {
const s = emojiString(emoji)
if (s === undefined) return false
const n = normalizedVoteToken(s)
return n === DISCUSSION_DOWNVOTE || n === DOWN_ARROW
}
export function isDiscussionVoteEmoji(emoji: TEmoji | string | undefined | null): boolean {
return isDiscussionUpvoteEmoji(emoji) || isDiscussionDownvoteEmoji(emoji)
}
/** Group legacy arrow reactions with +/- for one pill per direction. */
export function canonicalDiscussionVoteKey(
emoji: TEmoji | string | undefined | null
): typeof DISCUSSION_UPVOTE | typeof DISCUSSION_DOWNVOTE | null {
if (isDiscussionUpvoteEmoji(emoji)) return DISCUSSION_UPVOTE
if (isDiscussionDownvoteEmoji(emoji)) return DISCUSSION_DOWNVOTE
return null
}
export const DISCUSSION_VOTE_EMOJIS = [DISCUSSION_UPVOTE, DISCUSSION_DOWNVOTE] as const
/** Same vote direction, including legacy ⬆/⬇ vs +/-. */
export function discussionVoteMatches(
stored: TEmoji | string | undefined | null,
clicked: string | TEmoji
): boolean {
if (stored === undefined || stored === null) return false
const clickStr = typeof clicked === 'string' ? clicked : clicked.shortcode
const storeStr = typeof stored === 'string' ? stored : stored.shortcode
if (storeStr === clickStr) return true
return (
(isDiscussionUpvoteEmoji(storeStr) && isDiscussionUpvoteEmoji(clickStr)) ||
(isDiscussionDownvoteEmoji(storeStr) && isDiscussionDownvoteEmoji(clickStr))
)
}

52
src/lib/reaction-display.ts

@ -0,0 +1,52 @@ @@ -0,0 +1,52 @@
import { replaceStandardEmojiShortcodesInContent } from '@/lib/emoji-content'
import { getEmojiInfosFromEmojiTags } from '@/lib/tag'
import { TEmoji } from '@/types'
import { Event, kinds } from 'nostr-tools'
/** Whole-string :shortcode: (NIP-style); matches content-patterns rules. */
const WHOLE_SHORTCODE = /^:([a-zA-Z0-9_\-][^:]{0,19}):$/
export type TReactionEmojiSync =
| { mode: 'display'; value: TEmoji | string }
| { mode: 'profile'; shortcode: string; placeholder: string }
/**
* Resolve reaction display without network: emoji tags on the reaction, standard :shortcode: Unicode,
* or defer to profile (reactor kind 0) for custom shortcodes.
*/
export function resolveReactionEmojiSync(event: Event, maxRawLength: number): TReactionEmojiSync {
if (event.kind !== kinds.Reaction) {
return { mode: 'display', value: '' }
}
const raw = event.content?.trim() ?? ''
if (!raw) {
return { mode: 'display', value: '❤' }
}
if (raw.length > maxRawLength) {
return { mode: 'display', value: `${raw.slice(0, maxRawLength)}` }
}
const fromReactionTags = getEmojiInfosFromEmojiTags(event.tags)
const customShortcodes = fromReactionTags.map((e) => e.shortcode)
const whole = raw.match(WHOLE_SHORTCODE)
if (whole) {
const shortcode = whole[1]
const hit = fromReactionTags.find((e) => e.shortcode === shortcode)
if (hit) {
return { mode: 'display', value: hit }
}
}
const normalized = replaceStandardEmojiShortcodesInContent(raw, customShortcodes)
if (normalized !== raw && !WHOLE_SHORTCODE.test(normalized.trim())) {
return { mode: 'display', value: normalized.trim() }
}
if (whole) {
return { mode: 'profile', shortcode: whole[1], placeholder: raw }
}
return { mode: 'display', value: raw }
}

3
src/providers/ReplyProvider.tsx

@ -6,7 +6,7 @@ import { @@ -6,7 +6,7 @@ import {
getRootATag,
getRootETag
} from '@/lib/event'
import { Event } from 'nostr-tools'
import { Event, kinds } from 'nostr-tools'
import { createContext, useCallback, useContext, useState } from 'react'
type TReplyContext = {
@ -34,6 +34,7 @@ export function ReplyProvider({ children }: { children: React.ReactNode }) { @@ -34,6 +34,7 @@ export function ReplyProvider({ children }: { children: React.ReactNode }) {
const newReplyEventMap = new Map<string, Event[]>()
replies.forEach((reply) => {
if (newReplyIdSet.has(reply.id)) return
if (reply.kind === kinds.Reaction) return
newReplyIdSet.add(reply.id)
let rootId: string | undefined

10
src/services/discussion-feed-cache.service.ts

@ -1,4 +1,4 @@ @@ -1,4 +1,4 @@
import { Event as NEvent } from 'nostr-tools'
import { Event as NEvent, kinds } from 'nostr-tools'
import logger from '@/lib/logger'
interface CachedThreadData {
@ -94,7 +94,7 @@ class DiscussionFeedCacheService { @@ -94,7 +94,7 @@ class DiscussionFeedCacheService {
logger.debug('[DiscussionFeedCache] Cache hit (fresh) for thread:', cacheKey, 'replies:', cachedData.replies.length)
}
return cachedData.replies
return cachedData.replies.filter((r) => r.kind !== kinds.Reaction)
}
/**
@ -134,11 +134,13 @@ class DiscussionFeedCacheService { @@ -134,11 +134,13 @@ class DiscussionFeedCacheService {
// Merge with existing cached replies - keep all unique replies
const existingReplyIds = new Set(existingData.replies.map(r => r.id))
const newReplies = replies.filter(r => !existingReplyIds.has(r.id))
mergedReplies = [...existingData.replies, ...newReplies]
mergedReplies = [...existingData.replies, ...newReplies].filter(
(r) => r.kind !== kinds.Reaction
)
logger.debug('[DiscussionFeedCache] Merged replies for thread:', cacheKey, 'existing:', existingData.replies.length, 'new:', newReplies.length, 'total:', mergedReplies.length)
} else {
// No existing cache or rootInfo mismatch, use new replies
mergedReplies = [...replies]
mergedReplies = replies.filter((r) => r.kind !== kinds.Reaction)
logger.debug('[DiscussionFeedCache] Cached new replies for thread:', cacheKey, 'replies:', replies.length)
}

101
src/services/note-stats.service.ts

@ -1,4 +1,10 @@ @@ -1,4 +1,10 @@
import { E_TAG_FILTER_BLOCKED_RELAY_URLS, ExtendedKind, SEARCHABLE_RELAY_URLS } from '@/constants'
import {
E_TAG_FILTER_BLOCKED_RELAY_URLS,
ExtendedKind,
FAST_READ_RELAY_URLS,
SEARCHABLE_RELAY_URLS
} from '@/constants'
import { replaceStandardEmojiShortcodesInContent } from '@/lib/emoji-content'
import { getReplaceableCoordinateFromEvent, isReplaceableEvent } from '@/lib/event'
import { getZapInfoFromEvent } from '@/lib/event-metadata'
import logger from '@/lib/logger'
@ -34,6 +40,8 @@ class NoteStatsService { @@ -34,6 +40,8 @@ class NoteStatsService {
// Batch processing
private pendingEvents = new Set<string>()
/** Favorite relays passed from the last fetchNoteStats call per note (used in processSingleEvent). */
private pendingFetchFavoriteRelays = new Map<string, string[] | null | undefined>()
private batchTimeout: NodeJS.Timeout | null = null
private readonly BATCH_DELAY = 1000 // 1 second batch delay
private readonly MAX_BATCH_SIZE = 10 // Process up to 10 events at once
@ -45,7 +53,7 @@ class NoteStatsService { @@ -45,7 +53,7 @@ class NoteStatsService {
return NoteStatsService.instance
}
async fetchNoteStats(event: Event, _pubkey?: string | null, _favoriteRelays?: string[]) {
async fetchNoteStats(event: Event, _pubkey?: string | null, favoriteRelays?: string[] | null) {
const eventId = event.id
// Rate limiting: Don't process the same event more than once per 10 seconds
@ -56,7 +64,7 @@ class NoteStatsService { @@ -56,7 +64,7 @@ class NoteStatsService {
return
}
// Add to batch processing queue
this.pendingFetchFavoriteRelays.set(eventId, favoriteRelays ?? null)
this.pendingEvents.add(eventId)
this.lastProcessedTime.set(eventId, now)
@ -79,16 +87,23 @@ class NoteStatsService { @@ -79,16 +87,23 @@ class NoteStatsService {
if (this.pendingEvents.size === 0) return
const eventsToProcess = Array.from(this.pendingEvents).slice(0, this.MAX_BATCH_SIZE)
this.pendingEvents.clear()
for (const id of eventsToProcess) {
this.pendingEvents.delete(id)
}
if (this.batchTimeout) {
clearTimeout(this.batchTimeout)
this.batchTimeout = null
}
await Promise.all(eventsToProcess.map((eventId) => this.processSingleEvent(eventId)))
// Process all events in the batch
await Promise.all(eventsToProcess.map(eventId => this.processSingleEvent(eventId)))
if (this.pendingEvents.size > 0) {
this.batchTimeout = setTimeout(() => {
this.batchTimeout = null
this.processBatch()
}, this.BATCH_DELAY)
}
}
private async processSingleEvent(eventId: string) {
@ -99,6 +114,9 @@ class NoteStatsService { @@ -99,6 +114,9 @@ class NoteStatsService {
this.processingCache.add(eventId)
const favoriteRelays = this.pendingFetchFavoriteRelays.get(eventId)
this.pendingFetchFavoriteRelays.delete(eventId)
try {
// Get the event from cache or fetch it
const event = await eventService.fetchEvent(eventId)
@ -107,19 +125,13 @@ class NoteStatsService { @@ -107,19 +125,13 @@ class NoteStatsService {
return
}
const oldStats = this.noteStatsMap.get(eventId)
let since: number | undefined
if (oldStats?.updatedAt) {
since = oldStats.updatedAt
}
const finalRelayUrls = await this.buildNoteStatsRelayList(event)
const finalRelayUrls = await this.buildNoteStatsRelayList(event, favoriteRelays)
const replaceableCoordinate = isReplaceableEvent(event.kind)
? getReplaceableCoordinateFromEvent(event)
: undefined
const filters: Filter[] = this.buildFilters(event, replaceableCoordinate, since)
const filters: Filter[] = this.buildFilters(event, replaceableCoordinate)
const events: Event[] = []
logger.debug('[NoteStats] Fetching stats for event', event.id.substring(0, 8), 'from', finalRelayUrls.length, 'relays')
@ -138,17 +150,18 @@ class NoteStatsService { @@ -138,17 +150,18 @@ class NoteStatsService {
...(this.noteStatsMap.get(event.id) ?? {}),
updatedAt: dayjs().unix()
})
// Always notify: when relays return 0 rows, no updateNoteStatsByEvents ran — subscribers would never re-render.
this.notifyNoteStats(event.id)
} finally {
this.processingCache.delete(eventId)
}
}
/**
* Build relay list for note stats: search relays + relay(s) event was seen on + author's inboxes, deduplicated.
* Build relay list for note stats: SEARCHABLE + FAST_READ + optional user favorites + seen relays + author NIP-65 read (slice 10).
* Excludes E_TAG_FILTER_BLOCKED_RELAY_URLS (stats use #e filters).
*/
private async buildNoteStatsRelayList(event: Event): Promise<string[]> {
private async buildNoteStatsRelayList(event: Event, favoriteRelays?: string[] | null): Promise<string[]> {
const blocked = new Set(
E_TAG_FILTER_BLOCKED_RELAY_URLS.map((u) => (normalizeUrl(u) || u).toLowerCase()).filter(Boolean)
)
@ -161,13 +174,19 @@ class NoteStatsService { @@ -161,13 +174,19 @@ class NoteStatsService {
seen.add(n)
}
// 1. Search relays
// 1. Broad search index / aggregator relays
SEARCHABLE_RELAY_URLS.forEach(add)
// 2. Relay(s) where the event was seen
// 2. Default fast read set (includes e.g. theforest — not in SEARCHABLE)
FAST_READ_RELAY_URLS.forEach(add)
// 3. User's favorite relays (spell feed / sidebar) — was previously ignored
favoriteRelays?.forEach(add)
// 4. Relay(s) where the event was seen
client.getSeenEventRelayUrls(event.id).forEach(add)
// 3. Author's inboxes (read relays from kind 10002)
// 5. Author's inboxes (read relays from kind 10002)
try {
const relayList = await Promise.race([
client.fetchRelayList(event.pubkey),
@ -181,12 +200,26 @@ class NoteStatsService { @@ -181,12 +200,26 @@ class NoteStatsService {
return Array.from(seen)
}
private buildFilters(event: Event, replaceableCoordinate?: string, since?: number): Filter[] {
/**
* Reactions must not share one `limit` with replies relays often return newest notes first and
* fill the bucket with kind 1/1111, dropping kind 7 entirely.
* Do not use `since` from last fetch `updatedAt`: reaction `created_at` is usually far in the past,
* so incremental filters would return nothing and leave stats stuck empty.
*/
private buildFilters(event: Event, replaceableCoordinate?: string): Filter[] {
const reactionLimit = 300
const interactionLimit = 80
const filters: Filter[] = [
{
'#e': [event.id],
kinds: [kinds.Reaction, kinds.Repost, kinds.ShortTextNote, ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT, kinds.Highlights],
limit: 50 // Reduced limit for better performance
kinds: [kinds.Reaction],
limit: reactionLimit
},
{
'#e': [event.id],
kinds: [kinds.Repost, kinds.ShortTextNote, ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT, kinds.Highlights],
limit: interactionLimit
},
{
'#e': [event.id],
@ -204,8 +237,13 @@ class NoteStatsService { @@ -204,8 +237,13 @@ class NoteStatsService {
filters.push(
{
'#a': [replaceableCoordinate],
kinds: [kinds.Reaction, kinds.Repost, kinds.ShortTextNote, ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT, kinds.Highlights],
limit: 50
kinds: [kinds.Reaction],
limit: reactionLimit
},
{
'#a': [replaceableCoordinate],
kinds: [kinds.Repost, kinds.ShortTextNote, ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT, kinds.Highlights],
limit: interactionLimit
},
{
'#a': [replaceableCoordinate],
@ -220,12 +258,6 @@ class NoteStatsService { @@ -220,12 +258,6 @@ class NoteStatsService {
)
}
if (since) {
filters.forEach((filter) => {
filter.since = since
})
}
return filters
}
@ -367,7 +399,12 @@ class NoteStatsService { @@ -367,7 +399,12 @@ class NoteStatsService {
if (emojiInfo) {
emoji = emojiInfo
} else {
emoji = '+'
const customCodes = emojiInfos.map((e) => e.shortcode)
const normalized = replaceStandardEmojiShortcodesInContent(emoji, customCodes)
if (normalized !== emoji) {
emoji = normalized
}
// else keep `:custom:` string; UI resolves via reactor profile (ReactionEmojiDisplay)
}
}

Loading…
Cancel
Save