Browse Source

emoji rework

imwald
Silberengel 1 month ago
parent
commit
3b3f207b19
  1. 13
      src/components/Emoji/index.tsx
  2. 23
      src/components/EmojiPicker/index.tsx
  3. 42
      src/components/Note/ReactionEmojiDisplay.tsx
  4. 1
      src/components/NoteCard/MainNoteCard.tsx
  5. 7
      src/components/NoteStats/LikeButton.tsx
  6. 225
      src/components/NoteStats/Likes.tsx
  7. 27
      src/components/NoteStats/index.tsx
  8. 33
      src/components/ReplyNote/index.tsx
  9. 10
      src/components/ReplyNoteList/index.tsx
  10. 4
      src/constants.ts
  11. 18
      src/index.css
  12. 19
      src/lib/event.ts
  13. 20
      src/lib/like-reaction-emojis.ts
  14. 3
      src/lib/reaction-display.ts
  15. 19
      src/lib/tag.ts
  16. 1
      src/pages/secondary/NotePage/index.tsx
  17. 2
      src/pages/secondary/RssArticlePage/index.tsx
  18. 11
      src/services/note-stats.service.ts

13
src/components/Emoji/index.tsx

@ -1,6 +1,7 @@
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { DEFAULT_LIKE_REACTION_CONTENT, DEFAULT_LIKE_REACTION_DISPLAY_EMOJI } from '@/lib/like-reaction-emojis'
import { TEmoji } from '@/types' import { TEmoji } from '@/types'
import { Heart, ThumbsDown } from 'lucide-react' import { ThumbsDown } from 'lucide-react'
import { HTMLAttributes, useState } from 'react' import { HTMLAttributes, useState } from 'react'
/** ~4/3 of legacy `size-5` for custom images when no `classNames.img` override. */ /** ~4/3 of legacy `size-5` for custom images when no `classNames.img` override. */
@ -24,8 +25,12 @@ export default function Emoji({
const [hasError, setHasError] = useState(false) const [hasError, setHasError] = useState(false)
if (typeof emoji === 'string') { if (typeof emoji === 'string') {
if (emoji === '+') { if (emoji === DEFAULT_LIKE_REACTION_CONTENT) {
return <Heart className={cn(EMOJI_IMG_DEFAULT_CLASS, 'text-red-400 fill-red-400', classNames?.img)} /> return (
<span className={cn('whitespace-nowrap', classNames?.text, classNames?.img)}>
{DEFAULT_LIKE_REACTION_DISPLAY_EMOJI}
</span>
)
} }
if (emoji === '-') { if (emoji === '-') {
return ( return (
@ -36,7 +41,7 @@ export default function Emoji({
/> />
) )
} }
return <span className={cn('whitespace-nowrap', classNames?.text)}>{emoji}</span> return <span className={cn('whitespace-nowrap', classNames?.text, classNames?.img)}>{emoji}</span>
} }
if (hasError) { if (hasError) {

23
src/components/EmojiPicker/index.tsx

@ -1,4 +1,4 @@
import { DEFAULT_SUGGESTED_EMOJIS } from '@/lib/like-reaction-emojis' import { DEFAULT_LIKE_REACTION_CONTENT, DEFAULT_LIKE_REACTION_DISPLAY_EMOJI, DEFAULT_SUGGESTED_EMOJIS } from '@/lib/like-reaction-emojis'
import { recordEmojiUsed } from '@/lib/recently-used-emojis' import { recordEmojiUsed } from '@/lib/recently-used-emojis'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { useTheme } from '@/providers/ThemeProvider' import { useTheme } from '@/providers/ThemeProvider'
@ -64,7 +64,7 @@ export default function EmojiPicker({
const handleClick = (e: Event) => { const handleClick = (e: Event) => {
const detail = (e as CustomEvent).detail as { const detail = (e as CustomEvent).detail as {
unicode?: string unicode?: string
emoji: { emoji?: {
custom?: boolean custom?: boolean
unicode?: string unicode?: string
name?: string name?: string
@ -73,11 +73,22 @@ export default function EmojiPicker({
} }
} }
let result: string | TEmoji | undefined let result: string | TEmoji | undefined
if (detail.unicode) { /**
result = detail.unicode * emoji-picker-element only puts `unicode` on the event detail when `skinTonedUnicode` is truthy
* (see getDetailForClickEvent in picker.js). Native picks often expose the sequence on `detail.emoji.unicode`
* instead, so we must fall back otherwise `insertEmoji` receives undefined and most emojis dont work.
*/
const top = typeof detail.unicode === 'string' && detail.unicode.length > 0 ? detail.unicode : undefined
const nested =
typeof detail.emoji?.unicode === 'string' && detail.emoji.unicode.length > 0
? detail.emoji.unicode
: undefined
const nativeUnicode = top ?? nested
if (nativeUnicode) {
result = nativeUnicode
} else { } else {
const em = detail.emoji const em = detail.emoji
// emoji-picker-element: native emojis have `unicode`; custom entries have `url` (+ name / shortcodes). // Custom entries: `url` (+ shortcodes / name); avoid treating native `unicode` as custom.
if (em?.url && !em.unicode) { if (em?.url && !em.unicode) {
const shortcode = em.shortcodes?.[0] ?? em.name const shortcode = em.shortcodes?.[0] ?? em.name
if (shortcode) { if (shortcode) {
@ -136,7 +147,7 @@ export default function EmojiPicker({
onEmojiClick(emoji, e.nativeEvent) onEmojiClick(emoji, e.nativeEvent)
}} }}
> >
{emoji} {emoji === DEFAULT_LIKE_REACTION_CONTENT ? DEFAULT_LIKE_REACTION_DISPLAY_EMOJI : emoji}
</button> </button>
))} ))}
<button <button

42
src/components/Note/ReactionEmojiDisplay.tsx

@ -20,7 +20,7 @@ export default function ReactionEmojiDisplay({
className?: string className?: string
/** Truncate long reaction text beyond this length */ /** Truncate long reaction text beyond this length */
maxRawLength?: number maxRawLength?: number
/** Compact row (notification list); `thread` matches reply-list density */ /** `compact`: content previews; `thread`: reply-list reaction rows (large glyph). */
variant?: 'default' | 'compact' | 'thread' variant?: 'default' | 'compact' | 'thread'
}) { }) {
const sync = useMemo( const sync = useMemo(
@ -58,28 +58,32 @@ export default function ReactionEmojiDisplay({
return null return null
} }
/** Unicode / shortcode strings must not get `img` max-height classes — {@link Emoji} merges both onto one span and clips glyphs. */
const emojiClassNames =
variant === 'thread'
? typeof value === 'object'
? {
img: 'size-[calc(1.85rem*4/3)] max-h-[2.25rem] w-auto rounded-md opacity-95 inline-block align-middle'
}
: { text: 'text-2xl sm:text-3xl leading-normal tracking-tight' }
: {
img:
variant === 'compact'
? 'size-[calc(1rem*4/3)] max-h-[1em] w-auto rounded-sm'
: 'size-[calc(1.75rem*4/3)] max-h-[1.5em] w-auto rounded-sm',
text: variant === 'compact' ? 'text-base leading-none' : 'text-2xl leading-none'
}
return ( return (
<span <span
className={cn('inline-flex shrink-0 items-center justify-center leading-none select-none', className)} className={cn(
'inline-flex shrink-0 items-center justify-center select-none',
variant === 'thread' ? 'overflow-visible leading-normal py-0.5' : 'leading-none',
className
)}
aria-hidden aria-hidden
> >
<Emoji <Emoji emoji={value} classNames={emojiClassNames} />
emoji={value}
classNames={{
img:
variant === 'thread'
? 'size-[calc(0.875rem*4/3)] max-h-[1em] w-auto rounded-sm opacity-90'
: variant === 'compact'
? 'size-[calc(1rem*4/3)] max-h-[1em] w-auto rounded-sm'
: 'size-[calc(1.75rem*4/3)] max-h-[1.5em] w-auto rounded-sm',
text:
variant === 'thread'
? 'text-sm leading-none'
: variant === 'compact'
? 'text-base leading-none'
: 'text-2xl leading-none'
}}
/>
</span> </span>
) )
} }

1
src/components/NoteCard/MainNoteCard.tsx

@ -129,7 +129,6 @@ export default function MainNoteCard({
className={embedded ? 'mt-2 px-2 sm:px-3' : `mt-3 ${notePadX}`} className={embedded ? 'mt-2 px-2 sm:px-3' : `mt-3 ${notePadX}`}
event={event} event={event}
fetchIfNotExisting={fetchNoteStatsIfMissing} fetchIfNotExisting={fetchNoteStatsIfMissing}
displayTopZapsAndLikes={isZapFeedCard}
/> />
) : null} ) : null}
{!embedded && bottomNoteLabel ? ( {!embedded && bottomNoteLabel ? (

7
src/components/NoteStats/LikeButton.tsx

@ -49,13 +49,16 @@ type LikeButtonProps = {
hideCount?: boolean hideCount?: boolean
noteStats?: Partial<TNoteStats> noteStats?: Partial<TNoteStats>
isReplyToDiscussion?: boolean isReplyToDiscussion?: boolean
/** When true, never show the user's last reaction emoji in the trigger (icon + count only). */
useIconOnlyLikeTrigger?: boolean
} }
export function LikeButtonWithStats({ export function LikeButtonWithStats({
event, event,
hideCount = false, hideCount = false,
noteStats, noteStats,
isReplyToDiscussion: isReplyToDiscussionProp isReplyToDiscussion: isReplyToDiscussionProp,
useIconOnlyLikeTrigger = false
}: LikeButtonProps) { }: LikeButtonProps) {
const { t } = useTranslation() const { t } = useTranslation()
const { isSmallScreen } = useScreenSize() const { isSmallScreen } = useScreenSize()
@ -247,7 +250,7 @@ export function LikeButtonWithStats({
> >
{liking ? ( {liking ? (
<Skeleton className="size-4 shrink-0 rounded-full" aria-hidden /> <Skeleton className="size-4 shrink-0 rounded-full" aria-hidden />
) : myLastEmoji ? ( ) : myLastEmoji && !useIconOnlyLikeTrigger ? (
<> <>
<Emoji emoji={inQuietMode ? '+' : myLastEmoji} classNames={{ img: EMOJI_IMG_INLINE_CLASS }} /> <Emoji emoji={inQuietMode ? '+' : myLastEmoji} classNames={{ img: EMOJI_IMG_INLINE_CLASS }} />
{showLikeCount && ( {showLikeCount && (

225
src/components/NoteStats/Likes.tsx

@ -1,225 +0,0 @@
import storage from '@/services/local-storage.service'
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card'
import { Skeleton } from '@/components/ui/skeleton'
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'
import { useNoteStatsById } from '@/hooks/useNoteStatsById'
import { shouldHideInteractions } from '@/lib/event-filtering'
import { createReactionDraftEvent } from '@/lib/draft-event'
import { cn } from '@/lib/utils'
import { useNostr } from '@/providers/NostrProvider'
import { useUserTrust } from '@/contexts/user-trust-context'
import noteStatsService from '@/services/note-stats.service'
import type { TNoteStats } from '@/services/note-stats.service'
import { TEmoji } from '@/types'
import { Event } from 'nostr-tools'
import { useMemo, useRef, useState } from 'react'
import Emoji, { EMOJI_IMG_INLINE_CLASS } from '../Emoji'
import Username from '../Username'
import logger from '@/lib/logger'
type LikesProps = {
event: Event
noteStats?: Partial<TNoteStats>
}
export function LikesWithStats({ event, noteStats }: LikesProps) {
const inQuietMode = shouldHideInteractions(event)
const { pubkey, checkLogin, publish } = useNostr()
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
const [liking, setLiking] = useState<string | null>(null)
const longPressTimerRef = useRef<NodeJS.Timeout | null>(null)
const [isLongPressing, setIsLongPressing] = useState<string | null>(null)
const [isCompleted, setIsCompleted] = useState<string | null>(null)
const likes = useMemo(() => {
let _likes = noteStats?.likes
if (!_likes) return []
if (hideUntrustedInteractions) {
_likes = _likes.filter((item) => isUserTrusted(item.pubkey))
}
const stats = new Map<string, { key: string; emoji: TEmoji | string; pubkeys: Set<string> }>()
_likes.forEach((item) => {
// In quiet mode, normalize all emojis to "+" to prevent trolling with funny emojis
if (inQuietMode) {
const key = '+'
if (!stats.has(key)) {
stats.set(key, { key, pubkeys: new Set(), emoji: '+' })
}
stats.get(key)?.pubkeys.add(item.pubkey)
} else {
const key = typeof item.emoji === 'string' ? item.emoji : item.emoji.url
if (!stats.has(key)) {
stats.set(key, { key, pubkeys: new Set(), emoji: item.emoji })
}
stats.get(key)?.pubkeys.add(item.pubkey)
}
})
return Array.from(stats.values())
.filter((g) => g.pubkeys.size > 0)
.sort((a, b) => b.pubkeys.size - a.pubkeys.size)
}, [noteStats, event, inQuietMode, hideUntrustedInteractions, isUserTrusted])
if (!likes.length) return null
const like = async (key: string, emoji: TEmoji | string) => {
checkLogin(async () => {
if (liking || !pubkey) return
setLiking(key)
const timer = setTimeout(() => setLiking((prev) => (prev === key ? null : prev)), 5000)
try {
const reaction = createReactionDraftEvent(event, emoji)
const evt = await publish(reaction, { addClientTag: storage.getAddClientTag() })
noteStatsService.updateNoteStatsByEvents([evt], undefined, {
interactionTargetNoteId: event.id
})
} catch (error) {
logger.error('Like failed', { error, eventId: event.id })
} finally {
setLiking(null)
clearTimeout(timer)
}
})
}
const handleMouseDown = (key: string) => {
if (pubkey && likes.find((l) => l.key === key)?.pubkeys.has(pubkey)) {
return
}
setIsLongPressing(key)
longPressTimerRef.current = setTimeout(() => {
setIsCompleted(key)
setIsLongPressing(null)
}, 800)
}
const handleMouseUp = () => {
if (longPressTimerRef.current) {
clearTimeout(longPressTimerRef.current)
longPressTimerRef.current = null
}
if (isCompleted) {
const completedKey = isCompleted
const row = likes.find((l) => l.key === completedKey)
if (row) {
like(completedKey, row.emoji)
}
}
setIsLongPressing(null)
setIsCompleted(null)
}
const handleMouseLeave = () => {
if (longPressTimerRef.current) {
clearTimeout(longPressTimerRef.current)
longPressTimerRef.current = null
}
setIsLongPressing(null)
setIsCompleted(null)
}
const handleTouchMove = (e: React.TouchEvent) => {
const touch = e.touches[0]
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
const isInside =
touch.clientX >= rect.left &&
touch.clientX <= rect.right &&
touch.clientY >= rect.top &&
touch.clientY <= rect.bottom
if (!isInside) {
handleMouseLeave()
}
}
return (
<ScrollArea className="pb-2 mb-1">
<div className="flex gap-1">
{likes.map(({ key, emoji, pubkeys }) => {
const contributorIds = Array.from(pubkeys).sort()
return (
<HoverCard key={key} openDelay={250} closeDelay={50}>
<HoverCardTrigger asChild>
<div
className={cn(
'flex h-7 w-fit gap-2 px-2 rounded-full items-center border shrink-0 select-none relative overflow-hidden transition-all duration-200',
pubkey && pubkeys.has(pubkey)
? 'border-primary bg-primary/20 text-foreground cursor-not-allowed'
: 'bg-muted/80 text-muted-foreground cursor-pointer hover:bg-primary/40 hover:border-primary hover:text-foreground',
(isLongPressing === key || isCompleted === key) && 'border-primary bg-primary/20'
)}
onClick={(e) => e.stopPropagation()}
onMouseDown={() => handleMouseDown(key)}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseLeave}
onTouchStart={() => handleMouseDown(key)}
onTouchMove={handleTouchMove}
onTouchEnd={handleMouseUp}
onTouchCancel={handleMouseLeave}
>
{(isLongPressing === key || isCompleted === key) && (
<div className="absolute inset-0 rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-primary/40 via-primary/60 to-primary/80"
style={{
width: isCompleted === key ? '100%' : '0%',
animation:
isLongPressing === key ? 'progressFill 1000ms ease-out forwards' : 'none'
}}
/>
</div>
)}
<div className="relative z-10 flex items-center gap-2">
{liking === key ? (
<Skeleton className="size-4 shrink-0 rounded-full" aria-hidden />
) : (
<div
style={{
animation: isCompleted === key ? 'shake 0.5s ease-in-out infinite' : undefined
}}
>
<Emoji emoji={emoji} classNames={{ img: EMOJI_IMG_INLINE_CLASS }} />
</div>
)}
<div className="text-sm">{pubkeys.size}</div>
</div>
</div>
</HoverCardTrigger>
<HoverCardContent
className="w-72 max-h-64 p-0 overflow-hidden"
side="top"
align="center"
onClick={(e) => e.stopPropagation()}
>
<ScrollArea className="max-h-60">
<div className="flex flex-col gap-1.5 p-3 pr-4">
{contributorIds.map((userId) => (
<Username
key={userId}
userId={userId}
className="text-sm truncate text-foreground"
skeletonClassName="h-4"
/>
))}
</div>
</ScrollArea>
</HoverCardContent>
</HoverCard>
)
})}
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
)
}
export default function Likes({ event }: LikesProps) {
const noteStats = useNoteStatsById(event.id)
return <LikesWithStats event={event} noteStats={noteStats} />
}

27
src/components/NoteStats/index.tsx

@ -12,7 +12,6 @@ import { Event } from 'nostr-tools'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import BookmarkButton from '../BookmarkButton' import BookmarkButton from '../BookmarkButton'
import { LikeButtonWithStats } from './LikeButton' import { LikeButtonWithStats } from './LikeButton'
import { LikesWithStats } from './Likes'
import { ReplyButtonWithStats } from './ReplyButton' import { ReplyButtonWithStats } from './ReplyButton'
import { RepostButtonWithStats } from './RepostButton' import { RepostButtonWithStats } from './RepostButton'
import SeenOnButton from './SeenOnButton' import SeenOnButton from './SeenOnButton'
@ -23,8 +22,8 @@ export default function NoteStats({
className, className,
classNames, classNames,
fetchIfNotExisting = false, fetchIfNotExisting = false,
displayTopZapsAndLikes = false, foregroundStats = false,
foregroundStats = false useIconOnlyLikeTrigger = false
}: { }: {
event: Event event: Event
className?: string className?: string
@ -32,9 +31,12 @@ export default function NoteStats({
buttonBar?: string buttonBar?: string
} }
fetchIfNotExisting?: boolean fetchIfNotExisting?: boolean
displayTopZapsAndLikes?: boolean
/** Jump ahead of spell-feed backlog so counts resolve on the open note / article. */ /** Jump ahead of spell-feed backlog so counts resolve on the open note / article. */
foregroundStats?: boolean foregroundStats?: boolean
/**
* Thread rows for kind-7 reactions: like control shows icon + total only (body already shows the reaction glyph).
*/
useIconOnlyLikeTrigger?: boolean
}) { }) {
const { isSmallScreen } = useScreenSize() const { isSmallScreen } = useScreenSize()
const { pubkey } = useNostr() const { pubkey } = useNostr()
@ -50,7 +52,7 @@ export default function NoteStats({
// 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: no boost/quote/zap; still show reaction breakdown (NIP-25 + kind-17 web). */ /** Synthetic RSS article root: no boost/quote/zap bar entries that normal notes have. */
const isRssArticleRoot = event.kind === ExtendedKind.RSS_THREAD_ROOT const isRssArticleRoot = event.kind === ExtendedKind.RSS_THREAD_ROOT
/** Match {@link RssUrlThreadStatsBar}: inbox/favorites/fast-read merge — plain hints miss many #i indexers. */ /** Match {@link RssUrlThreadStatsBar}: inbox/favorites/fast-read merge — plain hints miss many #i indexers. */
const statsRelays = isRssArticleRoot ? rssUrlThreadRelays : hintRelays const statsRelays = isRssArticleRoot ? rssUrlThreadRelays : hintRelays
@ -60,9 +62,6 @@ export default function NoteStats({
statsRelaysRef.current = statsRelays statsRelaysRef.current = statsRelays
const isZapPoll = event.kind === ExtendedKind.ZAP_POLL const isZapPoll = event.kind === ExtendedKind.ZAP_POLL
/** Emoji reaction pills (aggregated likes). Shown for RSS/Web URL threads so the side panel matches feed rows. */
const showLikesPills = !isDiscussion && !isReplyToDiscussion
useEffect(() => { useEffect(() => {
if (!fetchIfNotExisting) return if (!fetchIfNotExisting) return
setLoading(true) setLoading(true)
@ -87,11 +86,6 @@ export default function NoteStats({
if (isSmallScreen) { if (isSmallScreen) {
return ( return (
<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 && (
<>
{showLikesPills && <LikesWithStats event={event} noteStats={noteStats} />}
</>
)}
<div <div
className={cn( className={cn(
'flex justify-between items-center h-5 [&_svg]:size-5', 'flex justify-between items-center h-5 [&_svg]:size-5',
@ -108,6 +102,7 @@ export default function NoteStats({
hideCount={hideInteractions} hideCount={hideInteractions}
noteStats={noteStats} noteStats={noteStats}
isReplyToDiscussion={isReplyToDiscussion} isReplyToDiscussion={isReplyToDiscussion}
useIconOnlyLikeTrigger={useIconOnlyLikeTrigger}
/> />
{!isRssArticleRoot && !isZapPoll && ( {!isRssArticleRoot && !isZapPoll && (
<ZapButtonWithStats event={event} hideCount={hideInteractions} noteStats={noteStats} /> <ZapButtonWithStats event={event} hideCount={hideInteractions} noteStats={noteStats} />
@ -121,11 +116,6 @@ export default function NoteStats({
return ( return (
<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 && (
<>
{showLikesPills && <LikesWithStats event={event} noteStats={noteStats} />}
</>
)}
<div className="flex justify-between h-5 [&_svg]:size-4"> <div className="flex justify-between h-5 [&_svg]:size-4">
<div <div
className={cn('flex items-center', loading ? 'animate-pulse' : '')} className={cn('flex items-center', loading ? 'animate-pulse' : '')}
@ -139,6 +129,7 @@ export default function NoteStats({
hideCount={hideInteractions} hideCount={hideInteractions}
noteStats={noteStats} noteStats={noteStats}
isReplyToDiscussion={isReplyToDiscussion} isReplyToDiscussion={isReplyToDiscussion}
useIconOnlyLikeTrigger={useIconOnlyLikeTrigger}
/> />
{!isRssArticleRoot && !isZapPoll && ( {!isRssArticleRoot && !isZapPoll && (
<ZapButtonWithStats event={event} hideCount={hideInteractions} noteStats={noteStats} /> <ZapButtonWithStats event={event} hideCount={hideInteractions} noteStats={noteStats} />

33
src/components/ReplyNote/index.tsx

@ -115,7 +115,12 @@ export default function ReplyNote({
maxFileSizeKb={2048} maxFileSizeKb={2048}
deferRemoteAvatar={false} deferRemoteAvatar={false}
/> />
<div className="w-full overflow-hidden"> <div
className={cn(
'w-full min-w-0',
isNip25ReactionKind(event.kind) ? 'overflow-visible' : 'overflow-x-hidden'
)}
>
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<div className="flex-1 w-0"> <div className="flex-1 w-0">
<div className="flex gap-1 items-center"> <div className="flex gap-1 items-center">
@ -166,9 +171,19 @@ export default function ReplyNote({
) : null} ) : null}
{show ? ( {show ? (
isNip25ReactionKind(event.kind) ? ( isNip25ReactionKind(event.kind) ? (
<div className="mt-1.5 flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-sm text-muted-foreground"> <div
className={cn(
'mt-2 flex min-h-0 min-w-0 flex-wrap items-end gap-x-2 gap-y-1 overflow-visible pb-1.5',
reactionDisplay.status === 'default'
? 'text-foreground'
: 'text-muted-foreground text-sm'
)}
>
{reactionDisplay.status === 'pending' ? ( {reactionDisplay.status === 'pending' ? (
<Skeleton className="size-3.5 shrink-0 rounded-sm" aria-hidden /> <Skeleton
className="h-10 w-10 shrink-0 rounded-lg sm:h-11 sm:w-11"
aria-hidden
/>
) : reactionDisplay.status === 'vote_up' ? ( ) : reactionDisplay.status === 'vote_up' ? (
<span className="text-sm leading-none opacity-90" aria-hidden> <span className="text-sm leading-none opacity-90" aria-hidden>
{DISCUSSION_UPVOTE_DISPLAY} {DISCUSSION_UPVOTE_DISPLAY}
@ -180,7 +195,9 @@ export default function ReplyNote({
) : ( ) : (
<ReactionEmojiDisplay event={event} variant="thread" maxRawLength={64} /> <ReactionEmojiDisplay event={event} variant="thread" maxRawLength={64} />
)} )}
<span className="text-foreground/85">{t(notificationReactionSummaryKey(reactionDisplay))}</span> {reactionDisplay.status !== 'default' && (
<span className="text-sm text-foreground/85">{t(notificationReactionSummaryKey(reactionDisplay))}</span>
)}
</div> </div>
) : event.kind === kinds.Zap ? ( ) : event.kind === kinds.Zap ? (
<Zap className="mt-1.5" event={event} omitSenderHeading variant="compact" /> <Zap className="mt-1.5" event={event} omitSenderHeading variant="compact" />
@ -208,15 +225,17 @@ export default function ReplyNote({
</div> </div>
</div> </div>
</Collapsible> </Collapsible>
{show && !isNip25ReactionKind(event.kind) && ( {show && (
<> <>
<NoteBoostBadges event={event} className="ml-14 pl-1 mr-4 mt-2" /> {!isNip25ReactionKind(event.kind) && (
<NoteBoostBadges event={event} className="ml-14 pl-1 mr-4 mt-2" />
)}
<NoteStats <NoteStats
className="ml-14 pl-1 mr-4 mt-2" className="ml-14 pl-1 mr-4 mt-2"
event={event} event={event}
displayTopZapsAndLikes={event.kind !== kinds.Zap}
fetchIfNotExisting fetchIfNotExisting
foregroundStats={foregroundStats} foregroundStats={foregroundStats}
useIconOnlyLikeTrigger={isNip25ReactionKind(event.kind)}
/> />
</> </>
)} )}

10
src/components/ReplyNoteList/index.tsx

@ -16,7 +16,6 @@ import {
getReplaceableCoordinateFromEvent, getReplaceableCoordinateFromEvent,
getRootATag, getRootATag,
getRootETag, getRootETag,
isNip25ReactionKind,
isNip56ReportEvent, isNip56ReportEvent,
isReplaceableEvent, isReplaceableEvent,
kind1QuotesThreadRoot, kind1QuotesThreadRoot,
@ -224,8 +223,8 @@ function moveReportsToEndPreserveOrder(events: NEvent[]): NEvent[] {
/** Shown after thread replies for E/A roots (quote stream + kind 1 #q-only); matches {@link THREAD_BACKLINK_STREAM_KINDS}. */ /** Shown after thread replies for E/A roots (quote stream + kind 1 #q-only); matches {@link THREAD_BACKLINK_STREAM_KINDS}. */
const EA_THREAD_TAIL_REFERENCE_KINDS = new Set<number>(THREAD_BACKLINK_STREAM_KINDS) const EA_THREAD_TAIL_REFERENCE_KINDS = new Set<number>(THREAD_BACKLINK_STREAM_KINDS)
/** Web (NIP-22) thread: tail = reference-style rows + URL-scoped reactions (same block order as E/A). */ /** Web (NIP-22) thread: tail = reference-style rows + URL-only external reactions (kind-7 stays in the chronological middle with other replies). */
const WEB_THREAD_EXTRA_TAIL_KINDS = new Set<number>([kinds.Reaction, ExtendedKind.EXTERNAL_REACTION]) const WEB_THREAD_EXTRA_TAIL_KINDS = new Set<number>([ExtendedKind.EXTERNAL_REACTION])
function isWebThreadTailKind(kind: number): boolean { function isWebThreadTailKind(kind: number): boolean {
return EA_THREAD_TAIL_REFERENCE_KINDS.has(kind) || WEB_THREAD_EXTRA_TAIL_KINDS.has(kind) return EA_THREAD_TAIL_REFERENCE_KINDS.has(kind) || WEB_THREAD_EXTRA_TAIL_KINDS.has(kind)
@ -262,8 +261,8 @@ function noteReactionEtagEqualsHex(ev: NEvent, hexLower: string): boolean {
} }
/** /**
* Thread REQ historically omitted kind 7; {@link replyMatchesThreadForList} also drops reactions from the reply list. * Thread REQ may still omit some kind-7 rows; merge reactions that tag the root hex so OP stats stay warm.
* Reactions still need to merge into {@link noteStatsService} for the root so the note header matches notifications. * Listed reactions under Antworten come from {@link ReplyNoteList} BFS + {@link replyMatchesThreadForList}.
*/ */
function mergeFetchedKind7ReactionsIntoRootNoteStats(all: NEvent[], rootInfo: TRootInfo) { function mergeFetchedKind7ReactionsIntoRootNoteStats(all: NEvent[], rootInfo: TRootInfo) {
if (rootInfo.type === 'E') { if (rootInfo.type === 'E') {
@ -449,7 +448,6 @@ function ReplyNoteList({
events.forEach((evt) => { events.forEach((evt) => {
if (replyIdSet.has(evt.id)) return if (replyIdSet.has(evt.id)) return
if (isNip25ReactionKind(evt.kind)) return
if (isPollVoteKind(evt)) return if (isPollVoteKind(evt)) return
if (isZapPollThreadZapReceipt(evt, event)) return if (isZapPollThreadZapReceipt(evt, event)) return
if ( if (

4
src/constants.ts

@ -416,6 +416,7 @@ export const FAST_READ_RELAY_URLS = [
'wss://thecitadel.nostr1.com', 'wss://thecitadel.nostr1.com',
'wss://aggr.nostr.land', 'wss://aggr.nostr.land',
'wss://primus.nostr1.com', 'wss://primus.nostr1.com',
'wss://wheat.happytavern.co'
] ]
// Optimized relay list for write operations (no aggregator since it's read-only) // Optimized relay list for write operations (no aggregator since it's read-only)
@ -446,7 +447,8 @@ export const SEARCHABLE_RELAY_URLS = [
'wss://nostr.einundzwanzig.space', 'wss://nostr.einundzwanzig.space',
'wss://nostr-pub.wellorder.net', 'wss://nostr-pub.wellorder.net',
'wss://pyramid.fiatjaf.com/', 'wss://pyramid.fiatjaf.com/',
'wss://nostrelites.org' 'wss://nostrelites.org',
'wss://wheat.happytavern.co'
] ]
export const PROFILE_RELAY_URLS = [ export const PROFILE_RELAY_URLS = [

18
src/index.css

@ -62,6 +62,24 @@
pointer-events: none; pointer-events: none;
} }
/*
* TipTap / ProseMirror composer: without a color-emoji font in the stack (common on Linux + Firefox),
* Unicode emoji pick a text-font glyph (thin/outline). Preview and published notes often look correct
* because markdown/prose or system fonts resolve emoji differently. Append emoji-capable families last.
*/
.tiptap .ProseMirror {
font-family:
ui-sans-serif,
system-ui,
sans-serif,
'Apple Color Emoji',
'Segoe UI Emoji',
'Segoe UI Symbol',
'Noto Color Emoji',
'Noto Emoji',
sans-serif;
}
.scrollbar-hide { .scrollbar-hide {
-ms-overflow-style: none; /* Internet Explorer 10+ */ -ms-overflow-style: none; /* Internet Explorer 10+ */
scrollbar-width: none; /* Firefox */ scrollbar-width: none; /* Firefox */

19
src/lib/event.ts

@ -14,6 +14,7 @@ import {
generateBech32IdFromETag, generateBech32IdFromETag,
getFirstHexEventIdFromETags, getFirstHexEventIdFromETags,
getImetaInfoFromImetaTag, getImetaInfoFromImetaTag,
getNip25ReactionTargetHexFromTags,
tagNameEquals tagNameEquals
} from './tag' } from './tag'
@ -175,8 +176,22 @@ export function isMentioningMutedUsers(event: Event, mutePubkeySet: Set<string>)
export function getParentETag(event?: Event) { export function getParentETag(event?: Event) {
if (!event) return undefined if (!event) return undefined
// NIP-25 reactions, NIP-18 reposts (6 / 16), poll responses: first hex `e` / `E` references the target note. // NIP-25 reactions: reacted-to id is often the `reply`-marked `e`, not the first `e` (root is commonly first).
if (event.kind === kinds.Reaction || isNip18RepostKind(event.kind) || event.kind === ExtendedKind.POLL_RESPONSE) { if (event.kind === kinds.Reaction) {
const targetHex = getNip25ReactionTargetHexFromTags(event.tags)
if (!targetHex) return undefined
return (
event.tags.find(
(t) => t[0] === 'e' && typeof t[1] === 'string' && t[1].toLowerCase() === targetHex
) ??
event.tags.find(
(t) => t[0] === 'E' && typeof t[1] === 'string' && t[1].toLowerCase() === targetHex
)
)
}
// NIP-18 reposts (6 / 16), poll responses: first hex `e` / `E` references the target note.
if (isNip18RepostKind(event.kind) || event.kind === ExtendedKind.POLL_RESPONSE) {
const firstId = getFirstHexEventIdFromETags(event.tags) const firstId = getFirstHexEventIdFromETags(event.tags)
if (!firstId) return undefined if (!firstId) return undefined
return ( return (

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

@ -2,4 +2,22 @@
* Single source for the quick-like emoji row used by the EmojiPicker / LikeButton. * Single source for the quick-like emoji row used by the EmojiPicker / LikeButton.
* EmojiPicker re-exports this list as EMOJI_PICKER_REACTIONS for LikeButton. * EmojiPicker re-exports this list as EMOJI_PICKER_REACTIONS for LikeButton.
*/ */
export const DEFAULT_SUGGESTED_EMOJIS = ['❤', '👍', '🔥', '😂', '😢', '🫂', '🚀'] as const
/** NIP-25 default positive reaction is the character `+`, not a Unicode heart. */
export const DEFAULT_LIKE_REACTION_CONTENT = '+' as const
/**
* Visual glyph for {@link DEFAULT_LIKE_REACTION_CONTENT} in UI (heart suit, emoji presentation).
* Published reaction content stays `+`.
*/
export const DEFAULT_LIKE_REACTION_DISPLAY_EMOJI = '\u2665\uFE0F'
export const DEFAULT_SUGGESTED_EMOJIS = [
DEFAULT_LIKE_REACTION_CONTENT,
'👍',
'🔥',
'😂',
'😢',
'🫂',
'🚀'
] as const

3
src/lib/reaction-display.ts

@ -1,3 +1,4 @@
import { DEFAULT_LIKE_REACTION_CONTENT } from '@/lib/like-reaction-emojis'
import { replaceStandardEmojiShortcodesInContent } from '@/lib/emoji-content' import { replaceStandardEmojiShortcodesInContent } from '@/lib/emoji-content'
import { isNip25ReactionKind } from '@/lib/event' import { isNip25ReactionKind } from '@/lib/event'
import { getEmojiInfosFromEmojiTags } from '@/lib/tag' import { getEmojiInfosFromEmojiTags } from '@/lib/tag'
@ -22,7 +23,7 @@ export function resolveReactionEmojiSync(event: Event, maxRawLength: number): TR
const raw = event.content?.trim() ?? '' const raw = event.content?.trim() ?? ''
if (!raw) { if (!raw) {
return { mode: 'display', value: '❤' } return { mode: 'display', value: DEFAULT_LIKE_REACTION_CONTENT }
} }
if (raw.length > maxRawLength) { if (raw.length > maxRawLength) {
return { mode: 'display', value: `${raw.slice(0, maxRawLength)}` } return { mode: 'display', value: `${raw.slice(0, maxRawLength)}` }

19
src/lib/tag.ts

@ -50,6 +50,25 @@ export function getFirstHexEventIdFromETags(tags: string[][]): string | undefine
return undefined return undefined
} }
/**
* NIP-25 kind-7 target note id: prefer `e`/`E` with marker `reply` (reacted-to note in a thread).
* If several `e` tags have no markers, use the last hex id (common order: root, then reply).
*/
export function getNip25ReactionTargetHexFromTags(tags: string[][]): string | undefined {
const eRows: { id: string; marker?: string }[] = []
for (const t of tags) {
if (t[0] !== 'e' && t[0] !== 'E') continue
const id = t[1]
if (!id || !NOTE_HEX_ID_RE.test(id)) continue
const marker = typeof t[3] === 'string' ? t[3].toLowerCase() : undefined
if (marker === 'reply') return id.toLowerCase()
eRows.push({ id: id.toLowerCase(), marker })
}
if (eRows.length === 1) return eRows[0].id
if (eRows.length > 1) return eRows[eRows.length - 1].id
return undefined
}
export function generateBech32IdFromETag(tag: string[]) { export function generateBech32IdFromETag(tag: string[]) {
try { try {
const [, id, relay, markerOrPubkey, pubkey] = tag const [, id, relay, markerOrPubkey, pubkey] = tag

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

@ -546,7 +546,6 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }:
className="mt-3" className="mt-3"
event={finalEvent} event={finalEvent}
fetchIfNotExisting fetchIfNotExisting
displayTopZapsAndLikes
foregroundStats foregroundStats
/> />
</div> </div>

2
src/pages/secondary/RssArticlePage/index.tsx

@ -303,7 +303,6 @@ const RssArticlePage = forwardRef(
className="mt-2" className="mt-2"
event={syntheticRoot} event={syntheticRoot}
fetchIfNotExisting fetchIfNotExisting
displayTopZapsAndLikes
foregroundStats foregroundStats
/> />
</div> </div>
@ -399,7 +398,6 @@ const RssArticlePage = forwardRef(
className="mt-3" className="mt-3"
event={syntheticRoot} event={syntheticRoot}
fetchIfNotExisting fetchIfNotExisting
displayTopZapsAndLikes
foregroundStats foregroundStats
/> />
</div> </div>

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

@ -11,6 +11,7 @@ import {
getParentEventHexId, getParentEventHexId,
getReplaceableCoordinateFromEvent, getReplaceableCoordinateFromEvent,
isNip18RepostKind, isNip18RepostKind,
isNip25ReactionKind,
isReplaceableEvent isReplaceableEvent
} from '@/lib/event' } from '@/lib/event'
import { eventReferencesThreadTarget, threadRootRefFromStatsRootEvent } from '@/lib/op-reference-tags' import { eventReferencesThreadTarget, threadRootRefFromStatsRootEvent } from '@/lib/op-reference-tags'
@ -256,7 +257,15 @@ class NoteStatsService {
} }
} }
const hexIds = [...new Set(hexReplies.map((r) => this.statsKey(r.id)))] const hexIdsSet = new Set(hexReplies.map((r) => this.statsKey(r.id)))
for (const r of hexReplies) {
if (!isNip25ReactionKind(r.kind)) continue
const parentHex = getParentEventHexId(r)
if (parentHex && this.hexNoteStatsIdRe.test(parentHex)) {
hexIdsSet.add(this.statsKey(parentHex))
}
}
const hexIds = [...hexIdsSet]
const markHexTargetsLoaded = () => { const markHexTargetsLoaded = () => {
for (const id of hexIds) { for (const id of hexIds) {

Loading…
Cancel
Save