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. 16
      src/components/Emoji/index.tsx
  3. 11
      src/components/Note/NotificationEventCard.tsx
  4. 75
      src/components/Note/ReactionEmojiDisplay.tsx
  5. 50
      src/components/Note/index.tsx
  6. 97
      src/components/NoteStats/LikeButton.tsx
  7. 10
      src/components/NoteStats/Likes.tsx
  8. 14
      src/components/NoteStats/VoteButtons.tsx
  9. 32
      src/components/NoteStats/index.tsx
  10. 5
      src/components/Profile/ProfileHeaderInteractions.tsx
  11. 42
      src/components/ReplyNote/index.tsx
  12. 27
      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. 115
      src/services/note-stats.service.ts

42
src/components/ContentPreview/index.tsx

@ -1,5 +1,14 @@
import { Skeleton } from '@/components/ui/skeleton'
import { ExtendedKind } from '@/constants' import { ExtendedKind } from '@/constants'
import {
notificationReactionSummaryKey,
useNotificationReactionDisplay
} from '@/hooks/useNotificationReactionDisplay'
import { isMentioningMutedUsers } from '@/lib/event' import { isMentioningMutedUsers } from '@/lib/event'
import {
DISCUSSION_DOWNVOTE_DISPLAY,
DISCUSSION_UPVOTE_DISPLAY
} from '@/lib/discussion-votes'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider' import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider'
import { useMuteListOptional } from '@/contexts/mute-list-context' import { useMuteListOptional } from '@/contexts/mute-list-context'
@ -20,6 +29,18 @@ import DiscussionNote from '../DiscussionNote'
import ApplicationHandlerInfo from '../ApplicationHandlerInfo' import ApplicationHandlerInfo from '../ApplicationHandlerInfo'
import ApplicationHandlerRecommendation from '../ApplicationHandlerRecommendation' import ApplicationHandlerRecommendation from '../ApplicationHandlerRecommendation'
import FollowPackPreview from './FollowPackPreview' 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({ export default function ContentPreview({
event, event,
@ -29,6 +50,7 @@ export default function ContentPreview({
className?: string className?: string
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const reactionDisplay = useNotificationReactionDisplay(event ?? CONTENT_PREVIEW_HOOK_PLACEHOLDER)
const muteList = useMuteListOptional() const muteList = useMuteListOptional()
const mutePubkeySet = muteList?.mutePubkeySet ?? new Set<string>() const mutePubkeySet = muteList?.mutePubkeySet ?? new Set<string>()
const contentPolicy = useContentPolicyOptional() const contentPolicy = useContentPolicyOptional()
@ -129,12 +151,22 @@ export default function ContentPreview({
} }
if (event.kind === kinds.Reaction) { if (event.kind === kinds.Reaction) {
const raw = event.content?.trim() ?? ''
const glyph = !raw ? '❤' : raw.length > 24 ? `${raw.slice(0, 24)}` : raw
return ( return (
<div className={cn('pointer-events-none text-sm text-muted-foreground', className)}> <div className={cn('pointer-events-none flex items-center gap-1.5 text-sm text-muted-foreground', className)}>
<span className="mr-1.5">{glyph}</span> {reactionDisplay.status === 'pending' ? (
{t('Notification reaction summary')} <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> </div>
) )
} }

16
src/components/Emoji/index.tsx

@ -1,6 +1,6 @@
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { TEmoji } from '@/types' import { TEmoji } from '@/types'
import { Heart } from 'lucide-react' import { Heart, ThumbsDown } from 'lucide-react'
import { HTMLAttributes, useState } from 'react' import { HTMLAttributes, useState } from 'react'
export default function Emoji({ export default function Emoji({
@ -16,11 +16,15 @@ export default function Emoji({
const [hasError, setHasError] = useState(false) const [hasError, setHasError] = useState(false)
if (typeof emoji === 'string') { if (typeof emoji === 'string') {
return emoji === '+' ? ( if (emoji === '+') {
<Heart className={cn('size-5 text-red-400 fill-red-400', classNames?.img)} /> return <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 (
<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) { if (hasError) {

11
src/components/Note/NotificationEventCard.tsx

@ -3,18 +3,9 @@ import { cn } from '@/lib/utils'
import { Event, kinds } from 'nostr-tools' import { Event, kinds } from 'nostr-tools'
import { useTranslation } from 'react-i18next' 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). * 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 }) { export default function NotificationEventCard({ event, className }: { event: Event; className?: string }) {
const { t } = useTranslation() const { t } = useTranslation()

75
src/components/Note/ReactionEmojiDisplay.tsx

@ -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>
)
}

50
src/components/Note/index.tsx

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

97
src/components/NoteStats/LikeButton.tsx

@ -7,9 +7,18 @@ import {
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { ExtendedKind } from '@/constants' import { ExtendedKind } from '@/constants'
import { useNoteStatsById } from '@/hooks/useNoteStatsById' import { useNoteStatsById } from '@/hooks/useNoteStatsById'
import { useReplyUnderDiscussionRoot } from '@/hooks/useReplyUnderDiscussionRoot'
import { shouldHideInteractions } from '@/lib/event-filtering' import { shouldHideInteractions } from '@/lib/event-filtering'
import { createDeletionRequestDraftEvent, createReactionDraftEvent } from '@/lib/draft-event' 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 { useNostr } from '@/providers/NostrProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { useUserTrust } from '@/contexts/user-trust-context' import { useUserTrust } from '@/contexts/user-trust-context'
@ -27,8 +36,6 @@ import SuggestedEmojis from '../SuggestedEmojis'
import { formatCount } from './utils' import { formatCount } from './utils'
import { showPublishingFeedback, showSimplePublishSuccess } from '@/lib/publishing-feedback' import { showPublishingFeedback, showSimplePublishSuccess } from '@/lib/publishing-feedback'
const DISCUSSION_EMOJIS = ['⬆', '⬇']
export default function LikeButton({ event, hideCount = false }: { event: Event; hideCount?: boolean }) { export default function LikeButton({ event, hideCount = false }: { event: Event; hideCount?: boolean }) {
const { t } = useTranslation() const { t } = useTranslation()
const { isSmallScreen } = useScreenSize() const { isSmallScreen } = useScreenSize()
@ -40,43 +47,30 @@ export default function LikeButton({ event, hideCount = false }: { event: Event;
const noteStats = useNoteStatsById(event.id) const noteStats = useNoteStatsById(event.id)
const isDiscussion = event.kind === ExtendedKind.DISCUSSION const isDiscussion = event.kind === ExtendedKind.DISCUSSION
const inQuietMode = shouldHideInteractions(event) const inQuietMode = shouldHideInteractions(event)
const isReplyToDiscussion = useReplyUnderDiscussionRoot(event)
// Check if this is a reply to a discussion event const showDiscussionVotes = isDiscussion || isReplyToDiscussion
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 { myLastEmoji, likeCount, upVoteCount, downVoteCount } = useMemo(() => {
const stats = noteStats || {} const stats = noteStats || {}
const myLike = stats.likes?.find((like) => like.pubkey === pubkey)
const likes = hideUntrustedInteractions const likes = hideUntrustedInteractions
? stats.likes?.filter((like) => isUserTrusted(like.pubkey)) ? stats.likes?.filter((like) => isUserTrusted(like.pubkey))
: stats.likes : 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 upVoteCount = 0
let downVoteCount = 0 let downVoteCount = 0
if (isDiscussion || isReplyToDiscussion) { if (showDiscussionVotes) {
upVoteCount = likes?.filter(like => like.emoji === '⬆').length || 0 upVoteCount = likes?.filter((like) => isDiscussionUpvoteEmoji(like.emoji)).length || 0
downVoteCount = likes?.filter(like => like.emoji === '⬇').length || 0 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, isDiscussion, isReplyToDiscussion]) }, [noteStats, pubkey, hideUntrustedInteractions, showDiscussionVotes])
const like = async (emoji: string | TEmoji) => { const like = async (emoji: string | TEmoji) => {
checkLogin(async () => { checkLogin(async () => {
@ -90,12 +84,16 @@ export default function LikeButton({ event, hideCount = false }: { event: Event;
await noteStatsService.fetchNoteStats(event, pubkey) 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 const emojiString = typeof emoji === 'string' ? emoji : emoji.shortcode
const myLastEmojiString =
// Normalize myLastEmoji for comparison typeof myLastEmoji === 'string'
const myLastEmojiString = typeof myLastEmoji === 'string' ? myLastEmoji : typeof myLastEmoji === 'object' ? myLastEmoji.shortcode : undefined ? myLastEmoji
const isTogglingOff = myLastEmojiString === emojiString : typeof myLastEmoji === 'object'
? myLastEmoji.shortcode
: undefined
const isTogglingOff = showDiscussionVotes
? discussionVoteMatches(myLastEmoji, emoji)
: myLastEmojiString === emojiString
logger.debug('Like toggle check', { logger.debug('Like toggle check', {
myLastEmoji, myLastEmoji,
@ -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 // User wants to toggle off - find their previous reaction and delete it
const myReaction = noteStats?.likes?.find((like) => { const myReaction = noteStats?.likes?.find((like) => {
if (like.pubkey !== pubkey) return false if (like.pubkey !== pubkey) return false
if (showDiscussionVotes) return discussionVoteMatches(like.emoji, emoji)
const likeEmojiString = typeof like.emoji === 'string' ? like.emoji : like.emoji.shortcode const likeEmojiString = typeof like.emoji === 'string' ? like.emoji : like.emoji.shortcode
return likeEmojiString === emojiString return likeEmojiString === emojiString
}) })
@ -205,32 +204,38 @@ export default function LikeButton({ event, hideCount = false }: { event: Event;
</button> </button>
) )
// For discussions, show the two arrow emojis directly as buttons // Discussions (kind 11) and kind 1111 under a discussion: only +/- vote reactions
if (isDiscussion || isReplyToDiscussion) { if (showDiscussionVotes) {
return ( return (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{DISCUSSION_EMOJIS.map((emoji, index) => { {DISCUSSION_VOTE_EMOJIS.map((emoji, index) => {
const isSelected = myLastEmoji === emoji const isSelected =
index === 0 ? isDiscussionUpvoteEmoji(myLastEmoji) : isDiscussionDownvoteEmoji(myLastEmoji)
const count = index === 0 ? upVoteCount : downVoteCount const count = index === 0 ? upVoteCount : downVoteCount
const arrow = index === 0 ? DISCUSSION_UPVOTE_DISPLAY : DISCUSSION_DOWNVOTE_DISPLAY
return ( return (
<button <button
key={index} key={emoji}
className={`flex items-center enabled:hover:text-primary gap-1 px-2 h-full text-muted-foreground rounded ${ className={`flex items-center enabled:hover:text-primary gap-1 px-2 h-full text-muted-foreground rounded ${
isSelected ? 'text-primary bg-muted' : '' isSelected ? 'text-primary bg-muted' : ''
}`} }`}
title={emoji === '' ? t('Upvote') : t('Downvote')} title={emoji === '+' ? t('Upvote') : t('Downvote')}
disabled={liking} disabled={liking}
onClick={() => { onClick={() => {
like(emoji) like(emoji)
}} }}
> >
{liking && index === 0 ? ( {liking ? (
<Skeleton className="size-4 shrink-0 rounded-full" aria-hidden /> <Skeleton className="size-4 shrink-0 rounded-full" aria-hidden />
) : ( ) : (
<> <>
<span className="text-base">{emoji}</span> <span className="text-base leading-none" aria-hidden>
{!hideCount && !!count && ( {arrow}
<div className="text-sm">{formatCount(count)}</div> </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 @@
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card' import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area' import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'
import { ExtendedKind } from '@/constants'
import { useNoteStatsById } from '@/hooks/useNoteStatsById' import { useNoteStatsById } from '@/hooks/useNoteStatsById'
import { shouldHideInteractions } from '@/lib/event-filtering' import { shouldHideInteractions } from '@/lib/event-filtering'
import { createReactionDraftEvent } from '@/lib/draft-event' import { createReactionDraftEvent } from '@/lib/draft-event'
@ -30,9 +29,6 @@ export default function Likes({ event }: { event: Event }) {
let _likes = noteStats?.likes let _likes = noteStats?.likes
if (!_likes) return [] if (!_likes) return []
if (event.kind === ExtendedKind.DISCUSSION) {
_likes = _likes.filter((item) => item.emoji === '⬆' || item.emoji === '⬇')
}
if (hideUntrustedInteractions) { if (hideUntrustedInteractions) {
_likes = _likes.filter((item) => isUserTrusted(item.pubkey)) _likes = _likes.filter((item) => isUserTrusted(item.pubkey))
} }
@ -103,9 +99,9 @@ export default function Likes({ event }: { event: Event }) {
if (isCompleted) { if (isCompleted) {
const completedKey = isCompleted const completedKey = isCompleted
const completedEmoji = likes.find((l) => l.key === completedKey)?.emoji const row = likes.find((l) => l.key === completedKey)
if (completedEmoji) { if (row) {
like(completedKey, completedEmoji) like(completedKey, row.emoji)
} }
} }

14
src/components/NoteStats/VoteButtons.tsx

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

32
src/components/NoteStats/index.tsx

@ -4,11 +4,10 @@ import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import noteStatsService from '@/services/note-stats.service' import noteStatsService from '@/services/note-stats.service'
import { ExtendedKind } from '@/constants' import { ExtendedKind } from '@/constants'
import { getRootEventHexId } from '@/lib/event' import { useReplyUnderDiscussionRoot } from '@/hooks/useReplyUnderDiscussionRoot'
import { shouldHideInteractions } from '@/lib/event-filtering' import { shouldHideInteractions } from '@/lib/event-filtering'
import { eventService } from '@/services/client.service'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { useEffect, useState, useMemo } from 'react' import { useEffect, useState } from 'react'
import BookmarkButton from '../BookmarkButton' import BookmarkButton from '../BookmarkButton'
import LikeButton from './LikeButton' import LikeButton from './LikeButton'
import Likes from './Likes' import Likes from './Likes'
@ -39,30 +38,16 @@ export default function NoteStats({
// Hide boost button for discussion events and replies to discussions // Hide boost button for discussion events and replies to discussions
const isDiscussion = event.kind === ExtendedKind.DISCUSSION const isDiscussion = event.kind === ExtendedKind.DISCUSSION
const [isReplyToDiscussion, setIsReplyToDiscussion] = useState(false) const isReplyToDiscussion = useReplyUnderDiscussionRoot(event)
// Hide interaction counts if event is in quiet mode // Hide interaction counts if event is in quiet mode
const hideInteractions = shouldHideInteractions(event) const hideInteractions = shouldHideInteractions(event)
/** Synthetic RSS article root: only reply + reactions (no boost/quote/zap). */ /** Synthetic RSS article root: only reply + reactions (no boost/quote/zap). */
const isRssArticleRoot = event.kind === ExtendedKind.RSS_THREAD_ROOT const isRssArticleRoot = event.kind === ExtendedKind.RSS_THREAD_ROOT
useMemo(() => { /** Kind 11 / kind 1111 under kind 11: LikeButton already shows ⬆/⬇ counts — skip duplicate pill row. */
if (isDiscussion) return // Already a discussion event const showLikesPills = !isDiscussion && !isReplyToDiscussion && !isRssArticleRoot
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])
useEffect(() => { useEffect(() => {
if (!fetchIfNotExisting) return if (!fetchIfNotExisting) return
@ -75,8 +60,7 @@ export default function NoteStats({
<div className={cn('select-none', className)} data-note-stats onClick={(e) => e.stopPropagation()}> <div className={cn('select-none', className)} data-note-stats onClick={(e) => e.stopPropagation()}>
{displayTopZapsAndLikes && ( {displayTopZapsAndLikes && (
<> <>
{/* Kind 11: LikeButton already shows ⬆/⬇; Likes row would duplicate those pills */} {showLikesPills && <Likes event={event} />}
{!isDiscussion && !isRssArticleRoot && <Likes event={event} />}
</> </>
)} )}
<div <div
@ -103,7 +87,7 @@ export default function NoteStats({
<div className={cn('select-none', className)} data-note-stats onClick={(e) => e.stopPropagation()}> <div className={cn('select-none', className)} data-note-stats onClick={(e) => e.stopPropagation()}>
{displayTopZapsAndLikes && ( {displayTopZapsAndLikes && (
<> <>
{!isDiscussion && !isRssArticleRoot && <Likes event={event} />} {showLikesPills && <Likes event={event} />}
</> </>
)} )}
<div className="flex justify-between h-5 [&_svg]:size-4"> <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'
import type { TProfileZap } from '@/hooks/useProfileInteractions' import type { TProfileZap } from '@/hooks/useProfileInteractions'
import type { TProfileBadge } from '@/hooks/useProfileBadges' import type { TProfileBadge } from '@/hooks/useProfileBadges'
import type { TProfileFollowPack } from '@/hooks/useProfileFollowPacks' 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 { Skeleton } from '@/components/ui/skeleton'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
@ -53,6 +53,7 @@ function ReactionBadge({ event }: { event: Event }) {
const emojiInfos = getEmojiInfosFromEmojiTags(event.tags) const emojiInfos = getEmojiInfosFromEmojiTags(event.tags)
const displayContent = event.content.trim() || (emojiInfos[0] ? emojiInfos[0].shortcode : '+') const displayContent = event.content.trim() || (emojiInfos[0] ? emojiInfos[0].shortcode : '+')
const isPlus = displayContent === '+' const isPlus = displayContent === '+'
const isMinus = displayContent === '-'
return ( return (
<button <button
type="button" type="button"
@ -62,6 +63,8 @@ function ReactionBadge({ event }: { event: Event }) {
<UserAvatar userId={event.pubkey} size="tiny" className="shrink-0" /> <UserAvatar userId={event.pubkey} size="tiny" className="shrink-0" />
{isPlus ? ( {isPlus ? (
<ThumbsUp className="size-3 shrink-0 text-primary" aria-hidden /> <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(':') ? ( ) : typeof displayContent === 'string' && !displayContent.startsWith(':') ? (
<span className="text-xs shrink-0">{displayContent}</span> <span className="text-xs shrink-0">{displayContent}</span>
) : ( ) : (

42
src/components/ReplyNote/index.tsx

@ -1,17 +1,26 @@
import { useSmartNoteNavigation } from '@/PageManager' import { useSmartNoteNavigation } from '@/PageManager'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton' 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 { isMentioningMutedUsers } from '@/lib/event'
import { toNote } from '@/lib/link' import { toNote } from '@/lib/link'
import { useContentPolicy } from '@/providers/ContentPolicyProvider' import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { useMuteList } from '@/contexts/mute-list-context' import { useMuteList } from '@/contexts/mute-list-context'
import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { Event } from 'nostr-tools' import { Event, kinds } from 'nostr-tools'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import ClientTag from '../ClientTag' import ClientTag from '../ClientTag'
import Collapsible from '../Collapsible' import Collapsible from '../Collapsible'
import MarkdownArticle from '../Note/MarkdownArticle/MarkdownArticle' import MarkdownArticle from '../Note/MarkdownArticle/MarkdownArticle'
import ReactionEmojiDisplay from '../Note/ReactionEmojiDisplay'
import { FormattedTimestamp } from '../FormattedTimestamp' import { FormattedTimestamp } from '../FormattedTimestamp'
import Nip05 from '../Nip05' import Nip05 from '../Nip05'
import NoteOptions from '../NoteOptions' import NoteOptions from '../NoteOptions'
@ -39,6 +48,7 @@ export default function ReplyNote({
const { mutePubkeySet } = useMuteList() const { mutePubkeySet } = useMuteList()
const { hideContentMentioningMutedUsers } = useContentPolicy() const { hideContentMentioningMutedUsers } = useContentPolicy()
const [showMuted, setShowMuted] = useState(false) const [showMuted, setShowMuted] = useState(false)
const reactionDisplay = useNotificationReactionDisplay(event)
const show = useMemo(() => { const show = useMemo(() => {
if (showMuted) { if (showMuted) {
return true return true
@ -107,7 +117,26 @@ export default function ReplyNote({
/> />
)} )}
{show ? ( {show ? (
<MarkdownArticle className="mt-2" event={event} hideMetadata={true} /> 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 <Button
variant="outline" variant="outline"
@ -123,7 +152,14 @@ export default function ReplyNote({
</div> </div>
</div> </div>
</Collapsible> </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> </div>
) )
} }

27
src/components/ReplyNoteList/index.tsx

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

13
src/components/SuggestedEmojis/DiscussionEmojis.tsx

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

96
src/hooks/useNotificationReactionDisplay.ts

@ -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 @@
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 {
'Open in a': 'Öffnen in {{a}}', 'Open in a': 'Öffnen in {{a}}',
'Cannot handle event of kind k': 'Ereignis des Typs {{k}} kann nicht verarbeitet werden', 'Cannot handle event of kind k': 'Ereignis des Typs {{k}} kann nicht verarbeitet werden',
'Notification reaction summary': 'hat auf diese Notiz reagiert.', '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 summary': 'Hat diese Notiz geboostet',
'Notification boost detail': 'Die Vorschau darüber ist der Originalbeitrag.', 'Notification boost detail': 'Die Vorschau darüber ist der Originalbeitrag.',
'Notification poll vote summary': 'Hat an der Umfrage darüber teilgenommen.', 'Notification poll vote summary': 'Hat an der Umfrage darüber teilgenommen.',

2
src/i18n/locales/en.ts

@ -388,6 +388,8 @@ export default {
'Open in a': 'Open in {{a}}', 'Open in a': 'Open in {{a}}',
'Cannot handle event of kind k': 'Cannot handle event of kind {{k}}', 'Cannot handle event of kind k': 'Cannot handle event of kind {{k}}',
'Notification reaction summary': 'reacted to this note.', '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 summary': 'Boosted this note',
'Notification boost detail': 'The preview above is the original post.', 'Notification boost detail': 'The preview above is the original post.',
'Notification poll vote summary': 'Voted on the poll above.', 'Notification poll vote summary': 'Voted on the poll above.',

69
src/lib/discussion-votes.ts

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

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

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

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

@ -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 { getReplaceableCoordinateFromEvent, isReplaceableEvent } from '@/lib/event'
import { getZapInfoFromEvent } from '@/lib/event-metadata' import { getZapInfoFromEvent } from '@/lib/event-metadata'
import logger from '@/lib/logger' import logger from '@/lib/logger'
@ -34,6 +40,8 @@ class NoteStatsService {
// Batch processing // Batch processing
private pendingEvents = new Set<string>() 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 batchTimeout: NodeJS.Timeout | null = null
private readonly BATCH_DELAY = 1000 // 1 second batch delay private readonly BATCH_DELAY = 1000 // 1 second batch delay
private readonly MAX_BATCH_SIZE = 10 // Process up to 10 events at once private readonly MAX_BATCH_SIZE = 10 // Process up to 10 events at once
@ -45,7 +53,7 @@ class NoteStatsService {
return NoteStatsService.instance 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 const eventId = event.id
// Rate limiting: Don't process the same event more than once per 10 seconds // Rate limiting: Don't process the same event more than once per 10 seconds
@ -55,8 +63,8 @@ class NoteStatsService {
logger.debug('[NoteStats] Skipping duplicate fetch for event', eventId.substring(0, 8), 'too soon') logger.debug('[NoteStats] Skipping duplicate fetch for event', eventId.substring(0, 8), 'too soon')
return return
} }
// Add to batch processing queue this.pendingFetchFavoriteRelays.set(eventId, favoriteRelays ?? null)
this.pendingEvents.add(eventId) this.pendingEvents.add(eventId)
this.lastProcessedTime.set(eventId, now) this.lastProcessedTime.set(eventId, now)
@ -77,18 +85,25 @@ class NoteStatsService {
private async processBatch() { private async processBatch() {
if (this.pendingEvents.size === 0) return if (this.pendingEvents.size === 0) return
const eventsToProcess = Array.from(this.pendingEvents).slice(0, this.MAX_BATCH_SIZE) 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) { if (this.batchTimeout) {
clearTimeout(this.batchTimeout) clearTimeout(this.batchTimeout)
this.batchTimeout = null 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) { private async processSingleEvent(eventId: string) {
@ -98,7 +113,10 @@ class NoteStatsService {
} }
this.processingCache.add(eventId) this.processingCache.add(eventId)
const favoriteRelays = this.pendingFetchFavoriteRelays.get(eventId)
this.pendingFetchFavoriteRelays.delete(eventId)
try { try {
// Get the event from cache or fetch it // Get the event from cache or fetch it
const event = await eventService.fetchEvent(eventId) const event = await eventService.fetchEvent(eventId)
@ -107,19 +125,13 @@ class NoteStatsService {
return return
} }
const oldStats = this.noteStatsMap.get(eventId) const finalRelayUrls = await this.buildNoteStatsRelayList(event, favoriteRelays)
let since: number | undefined
if (oldStats?.updatedAt) {
since = oldStats.updatedAt
}
const finalRelayUrls = await this.buildNoteStatsRelayList(event)
const replaceableCoordinate = isReplaceableEvent(event.kind) const replaceableCoordinate = isReplaceableEvent(event.kind)
? getReplaceableCoordinateFromEvent(event) ? getReplaceableCoordinateFromEvent(event)
: undefined : undefined
const filters: Filter[] = this.buildFilters(event, replaceableCoordinate, since) const filters: Filter[] = this.buildFilters(event, replaceableCoordinate)
const events: Event[] = [] const events: Event[] = []
logger.debug('[NoteStats] Fetching stats for event', event.id.substring(0, 8), 'from', finalRelayUrls.length, 'relays') logger.debug('[NoteStats] Fetching stats for event', event.id.substring(0, 8), 'from', finalRelayUrls.length, 'relays')
@ -133,22 +145,23 @@ class NoteStatsService {
}) })
logger.debug('[NoteStats] Fetched', events.length, 'events for stats') logger.debug('[NoteStats] Fetched', events.length, 'events for stats')
this.noteStatsMap.set(event.id, { this.noteStatsMap.set(event.id, {
...(this.noteStatsMap.get(event.id) ?? {}), ...(this.noteStatsMap.get(event.id) ?? {}),
updatedAt: dayjs().unix() updatedAt: dayjs().unix()
}) })
// Always notify: when relays return 0 rows, no updateNoteStatsByEvents ran — subscribers would never re-render.
this.notifyNoteStats(event.id)
} finally { } finally {
this.processingCache.delete(eventId) 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). * 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( const blocked = new Set(
E_TAG_FILTER_BLOCKED_RELAY_URLS.map((u) => (normalizeUrl(u) || u).toLowerCase()).filter(Boolean) E_TAG_FILTER_BLOCKED_RELAY_URLS.map((u) => (normalizeUrl(u) || u).toLowerCase()).filter(Boolean)
) )
@ -161,13 +174,19 @@ class NoteStatsService {
seen.add(n) seen.add(n)
} }
// 1. Search relays // 1. Broad search index / aggregator relays
SEARCHABLE_RELAY_URLS.forEach(add) 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) 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 { try {
const relayList = await Promise.race([ const relayList = await Promise.race([
client.fetchRelayList(event.pubkey), client.fetchRelayList(event.pubkey),
@ -181,12 +200,26 @@ class NoteStatsService {
return Array.from(seen) 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[] = [ const filters: Filter[] = [
{ {
'#e': [event.id], '#e': [event.id],
kinds: [kinds.Reaction, kinds.Repost, kinds.ShortTextNote, ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT, kinds.Highlights], kinds: [kinds.Reaction],
limit: 50 // Reduced limit for better performance limit: reactionLimit
},
{
'#e': [event.id],
kinds: [kinds.Repost, kinds.ShortTextNote, ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT, kinds.Highlights],
limit: interactionLimit
}, },
{ {
'#e': [event.id], '#e': [event.id],
@ -204,8 +237,13 @@ class NoteStatsService {
filters.push( filters.push(
{ {
'#a': [replaceableCoordinate], '#a': [replaceableCoordinate],
kinds: [kinds.Reaction, kinds.Repost, kinds.ShortTextNote, ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT, kinds.Highlights], kinds: [kinds.Reaction],
limit: 50 limit: reactionLimit
},
{
'#a': [replaceableCoordinate],
kinds: [kinds.Repost, kinds.ShortTextNote, ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT, kinds.Highlights],
limit: interactionLimit
}, },
{ {
'#a': [replaceableCoordinate], '#a': [replaceableCoordinate],
@ -220,12 +258,6 @@ class NoteStatsService {
) )
} }
if (since) {
filters.forEach((filter) => {
filter.since = since
})
}
return filters return filters
} }
@ -367,7 +399,12 @@ class NoteStatsService {
if (emojiInfo) { if (emojiInfo) {
emoji = emojiInfo emoji = emojiInfo
} else { } 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