From 28837b169e7bb284124073be7fcbc9d61c4d1160 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Wed, 25 Mar 2026 08:01:50 +0100 Subject: [PATCH] fix text emojis in notifications fix discussion emojis --- src/components/ContentPreview/index.tsx | 42 ++++++- src/components/Emoji/index.tsx | 16 ++- src/components/Note/NotificationEventCard.tsx | 11 +- src/components/Note/ReactionEmojiDisplay.tsx | 75 ++++++++++++ src/components/Note/index.tsx | 50 ++++++-- src/components/NoteStats/LikeButton.tsx | 97 ++++++++------- src/components/NoteStats/Likes.tsx | 10 +- src/components/NoteStats/VoteButtons.tsx | 14 ++- src/components/NoteStats/index.tsx | 32 ++--- .../Profile/ProfileHeaderInteractions.tsx | 5 +- src/components/ReplyNote/index.tsx | 42 ++++++- src/components/ReplyNoteList/index.tsx | 27 ++-- .../SuggestedEmojis/DiscussionEmojis.tsx | 13 +- src/hooks/useNotificationReactionDisplay.ts | 96 +++++++++++++++ src/hooks/useReplyUnderDiscussionRoot.ts | 39 ++++++ src/i18n/locales/de.ts | 2 + src/i18n/locales/en.ts | 2 + src/lib/discussion-votes.ts | 69 +++++++++++ src/lib/reaction-display.ts | 52 ++++++++ src/providers/ReplyProvider.tsx | 3 +- src/services/discussion-feed-cache.service.ts | 10 +- src/services/note-stats.service.ts | 115 ++++++++++++------ 22 files changed, 653 insertions(+), 169 deletions(-) create mode 100644 src/components/Note/ReactionEmojiDisplay.tsx create mode 100644 src/hooks/useNotificationReactionDisplay.ts create mode 100644 src/hooks/useReplyUnderDiscussionRoot.ts create mode 100644 src/lib/discussion-votes.ts create mode 100644 src/lib/reaction-display.ts diff --git a/src/components/ContentPreview/index.tsx b/src/components/ContentPreview/index.tsx index babfc8ed..9b768167 100644 --- a/src/components/ContentPreview/index.tsx +++ b/src/components/ContentPreview/index.tsx @@ -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' 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({ className?: string }) { const { t } = useTranslation() + const reactionDisplay = useNotificationReactionDisplay(event ?? CONTENT_PREVIEW_HOOK_PLACEHOLDER) const muteList = useMuteListOptional() const mutePubkeySet = muteList?.mutePubkeySet ?? new Set() const contentPolicy = useContentPolicyOptional() @@ -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 ( -
- {glyph} - {t('Notification reaction summary')} +
+ {reactionDisplay.status === 'pending' ? ( + + ) : reactionDisplay.status === 'vote_up' ? ( + + {DISCUSSION_UPVOTE_DISPLAY} + + ) : reactionDisplay.status === 'vote_down' ? ( + + {DISCUSSION_DOWNVOTE_DISPLAY} + + ) : ( + + )} + {t(notificationReactionSummaryKey(reactionDisplay))}
) } diff --git a/src/components/Emoji/index.tsx b/src/components/Emoji/index.tsx index 36be7cd6..4da66706 100644 --- a/src/components/Emoji/index.tsx +++ b/src/components/Emoji/index.tsx @@ -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,11 +16,15 @@ export default function Emoji({ const [hasError, setHasError] = useState(false) if (typeof emoji === 'string') { - return emoji === '+' ? ( - - ) : ( - {emoji} - ) + if (emoji === '+') { + return + } + if (emoji === '-') { + return ( + + ) + } + return {emoji} } if (hasError) { diff --git a/src/components/Note/NotificationEventCard.tsx b/src/components/Note/NotificationEventCard.tsx index 444bc5eb..35278cde 100644 --- a/src/components/Note/NotificationEventCard.tsx +++ b/src/components/Note/NotificationEventCard.tsx @@ -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() diff --git a/src/components/Note/ReactionEmojiDisplay.tsx b/src/components/Note/ReactionEmojiDisplay.tsx new file mode 100644 index 00000000..1276ebee --- /dev/null +++ b/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(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 ( + + + + ) +} diff --git a/src/components/Note/index.tsx b/src/components/Note/index.tsx index 4e10ca80..65f14562 100644 --- a/src/components/Note/index.tsx +++ b/src/components/Note/index.tsx @@ -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' 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({ const [postEditorOpen, setPostEditorOpen] = useState(false) const [publicMessageTo, setPublicMessageTo] = useState(null) const [callInviteContent, setCallInviteContent] = useState(null) + const reactionDisplay = useNotificationReactionDisplay(event) const openHighlight = useCallback((data: HighlightData, eventContent?: string) => { setHighlightData(data) @@ -279,12 +291,34 @@ export default function Note({
{event.kind === kinds.Reaction ? (
- - {reactionDisplayEmoji(event)} - + {reactionDisplay.status === 'pending' ? ( + + ) : reactionDisplay.status === 'vote_up' ? ( + + {DISCUSSION_UPVOTE_DISPLAY} + + ) : reactionDisplay.status === 'vote_down' ? ( + + {DISCUSSION_DOWNVOTE_DISPLAY} + + ) : ( + + )}
- {t('Notification reaction summary')} + {t(notificationReactionSummaryKey(reactionDisplay))}
{ - 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 isReplyToDiscussion = useReplyUnderDiscussionRoot(event) + const showDiscussionVotes = isDiscussion || isReplyToDiscussion + 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; 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; // 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; ) - // 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 (
- {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 (
- {show && } + {show && event.kind !== kinds.Reaction && ( + + )}
) } diff --git a/src/components/ReplyNoteList/index.tsx b/src/components/ReplyNoteList/index.tsx index 847db41e..01de4682 100644 --- a/src/components/ReplyNoteList/index.tsx +++ b/src/components/ReplyNoteList/index.tsx @@ -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,17 +79,23 @@ 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) if (!stats?.likes) { 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 } @@ -98,9 +105,13 @@ function ReplyNoteList({ if (!stats?.likes) { 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({ 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({ // 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)) diff --git a/src/components/SuggestedEmojis/DiscussionEmojis.tsx b/src/components/SuggestedEmojis/DiscussionEmojis.tsx index 16f7b9a7..99dc5ccb 100644 --- a/src/components/SuggestedEmojis/DiscussionEmojis.tsx +++ b/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' -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({ }) { return (
e.stopPropagation()}> - {DISCUSSION_EMOJIS.map((emoji, index) => ( + {DISCUSSION_VOTE_EMOJIS.map((emoji, i) => (
onEmojiClick(emoji)} > - {emoji} + {GLYPHS[i]}
))}
diff --git a/src/hooks/useNotificationReactionDisplay.ts b/src/hooks/useNotificationReactionDisplay.ts new file mode 100644 index 00000000..07655367 --- /dev/null +++ b/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(() => + 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' +} diff --git a/src/hooks/useReplyUnderDiscussionRoot.ts b/src/hooks/useReplyUnderDiscussionRoot.ts new file mode 100644 index 00000000..14419dff --- /dev/null +++ b/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 +} diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index c356691d..8752074d 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -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.', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 2e748d5b..63652feb 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -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.', diff --git a/src/lib/discussion-votes.ts b/src/lib/discussion-votes.ts new file mode 100644 index 00000000..95990dfe --- /dev/null +++ b/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)) + ) +} diff --git a/src/lib/reaction-display.ts b/src/lib/reaction-display.ts new file mode 100644 index 00000000..5ffd0bb0 --- /dev/null +++ b/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 } +} diff --git a/src/providers/ReplyProvider.tsx b/src/providers/ReplyProvider.tsx index 12603cde..afc9e5b7 100644 --- a/src/providers/ReplyProvider.tsx +++ b/src/providers/ReplyProvider.tsx @@ -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 }) { const newReplyEventMap = new Map() replies.forEach((reply) => { if (newReplyIdSet.has(reply.id)) return + if (reply.kind === kinds.Reaction) return newReplyIdSet.add(reply.id) let rootId: string | undefined diff --git a/src/services/discussion-feed-cache.service.ts b/src/services/discussion-feed-cache.service.ts index e4bac97c..d60618fd 100644 --- a/src/services/discussion-feed-cache.service.ts +++ b/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' interface CachedThreadData { @@ -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 { // 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) } diff --git a/src/services/note-stats.service.ts b/src/services/note-stats.service.ts index 16539d45..b3392456 100644 --- a/src/services/note-stats.service.ts +++ b/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 { getZapInfoFromEvent } from '@/lib/event-metadata' import logger from '@/lib/logger' @@ -34,6 +40,8 @@ class NoteStatsService { // Batch processing private pendingEvents = new Set() + /** Favorite relays passed from the last fetchNoteStats call per note (used in processSingleEvent). */ + private pendingFetchFavoriteRelays = new Map() 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 { 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 @@ -55,8 +63,8 @@ class NoteStatsService { logger.debug('[NoteStats] Skipping duplicate fetch for event', eventId.substring(0, 8), 'too soon') return } - - // Add to batch processing queue + + this.pendingFetchFavoriteRelays.set(eventId, favoriteRelays ?? null) this.pendingEvents.add(eventId) this.lastProcessedTime.set(eventId, now) @@ -77,18 +85,25 @@ class NoteStatsService { private async processBatch() { 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 } - - - // Process all events in the batch - await Promise.all(eventsToProcess.map(eventId => this.processSingleEvent(eventId))) + + 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) { @@ -98,7 +113,10 @@ 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 { 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') @@ -133,22 +145,23 @@ class NoteStatsService { }) logger.debug('[NoteStats] Fetched', events.length, 'events for stats') - + this.noteStatsMap.set(event.id, { ...(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 { + private async buildNoteStatsRelayList(event: Event, favoriteRelays?: string[] | null): Promise { const blocked = new Set( E_TAG_FILTER_BLOCKED_RELAY_URLS.map((u) => (normalizeUrl(u) || u).toLowerCase()).filter(Boolean) ) @@ -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 { 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 { 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 { ) } - if (since) { - filters.forEach((filter) => { - filter.since = since - }) - } - return filters } @@ -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) } }