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

23
src/components/EmojiPicker/index.tsx

@ -1,4 +1,4 @@ @@ -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 { useNostr } from '@/providers/NostrProvider'
import { useTheme } from '@/providers/ThemeProvider'
@ -64,7 +64,7 @@ export default function EmojiPicker({ @@ -64,7 +64,7 @@ export default function EmojiPicker({
const handleClick = (e: Event) => {
const detail = (e as CustomEvent).detail as {
unicode?: string
emoji: {
emoji?: {
custom?: boolean
unicode?: string
name?: string
@ -73,11 +73,22 @@ export default function EmojiPicker({ @@ -73,11 +73,22 @@ export default function EmojiPicker({
}
}
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 {
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) {
const shortcode = em.shortcodes?.[0] ?? em.name
if (shortcode) {
@ -136,7 +147,7 @@ export default function EmojiPicker({ @@ -136,7 +147,7 @@ export default function EmojiPicker({
onEmojiClick(emoji, e.nativeEvent)
}}
>
{emoji}
{emoji === DEFAULT_LIKE_REACTION_CONTENT ? DEFAULT_LIKE_REACTION_DISPLAY_EMOJI : emoji}
</button>
))}
<button

42
src/components/Note/ReactionEmojiDisplay.tsx

@ -20,7 +20,7 @@ export default function ReactionEmojiDisplay({ @@ -20,7 +20,7 @@ export default function ReactionEmojiDisplay({
className?: string
/** Truncate long reaction text beyond this length */
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'
}) {
const sync = useMemo(
@ -58,28 +58,32 @@ export default function ReactionEmojiDisplay({ @@ -58,28 +58,32 @@ export default function ReactionEmojiDisplay({
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 (
<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
>
<Emoji
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'
}}
/>
<Emoji emoji={value} classNames={emojiClassNames} />
</span>
)
}

1
src/components/NoteCard/MainNoteCard.tsx

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

7
src/components/NoteStats/LikeButton.tsx

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

225
src/components/NoteStats/Likes.tsx

@ -1,225 +0,0 @@ @@ -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' @@ -12,7 +12,6 @@ import { Event } from 'nostr-tools'
import { useEffect, useRef, useState } from 'react'
import BookmarkButton from '../BookmarkButton'
import { LikeButtonWithStats } from './LikeButton'
import { LikesWithStats } from './Likes'
import { ReplyButtonWithStats } from './ReplyButton'
import { RepostButtonWithStats } from './RepostButton'
import SeenOnButton from './SeenOnButton'
@ -23,8 +22,8 @@ export default function NoteStats({ @@ -23,8 +22,8 @@ export default function NoteStats({
className,
classNames,
fetchIfNotExisting = false,
displayTopZapsAndLikes = false,
foregroundStats = false
foregroundStats = false,
useIconOnlyLikeTrigger = false
}: {
event: Event
className?: string
@ -32,9 +31,12 @@ export default function NoteStats({ @@ -32,9 +31,12 @@ export default function NoteStats({
buttonBar?: string
}
fetchIfNotExisting?: boolean
displayTopZapsAndLikes?: boolean
/** Jump ahead of spell-feed backlog so counts resolve on the open note / article. */
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 { pubkey } = useNostr()
@ -50,7 +52,7 @@ export default function NoteStats({ @@ -50,7 +52,7 @@ export default function NoteStats({
// Hide interaction counts if event is in quiet mode
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
/** Match {@link RssUrlThreadStatsBar}: inbox/favorites/fast-read merge — plain hints miss many #i indexers. */
const statsRelays = isRssArticleRoot ? rssUrlThreadRelays : hintRelays
@ -60,9 +62,6 @@ export default function NoteStats({ @@ -60,9 +62,6 @@ export default function NoteStats({
statsRelaysRef.current = statsRelays
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(() => {
if (!fetchIfNotExisting) return
setLoading(true)
@ -87,11 +86,6 @@ export default function NoteStats({ @@ -87,11 +86,6 @@ export default function NoteStats({
if (isSmallScreen) {
return (
<div className={cn('select-none', className)} data-note-stats onClick={(e) => e.stopPropagation()}>
{displayTopZapsAndLikes && (
<>
{showLikesPills && <LikesWithStats event={event} noteStats={noteStats} />}
</>
)}
<div
className={cn(
'flex justify-between items-center h-5 [&_svg]:size-5',
@ -108,6 +102,7 @@ export default function NoteStats({ @@ -108,6 +102,7 @@ export default function NoteStats({
hideCount={hideInteractions}
noteStats={noteStats}
isReplyToDiscussion={isReplyToDiscussion}
useIconOnlyLikeTrigger={useIconOnlyLikeTrigger}
/>
{!isRssArticleRoot && !isZapPoll && (
<ZapButtonWithStats event={event} hideCount={hideInteractions} noteStats={noteStats} />
@ -121,11 +116,6 @@ export default function NoteStats({ @@ -121,11 +116,6 @@ export default function NoteStats({
return (
<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={cn('flex items-center', loading ? 'animate-pulse' : '')}
@ -139,6 +129,7 @@ export default function NoteStats({ @@ -139,6 +129,7 @@ export default function NoteStats({
hideCount={hideInteractions}
noteStats={noteStats}
isReplyToDiscussion={isReplyToDiscussion}
useIconOnlyLikeTrigger={useIconOnlyLikeTrigger}
/>
{!isRssArticleRoot && !isZapPoll && (
<ZapButtonWithStats event={event} hideCount={hideInteractions} noteStats={noteStats} />

33
src/components/ReplyNote/index.tsx

@ -115,7 +115,12 @@ export default function ReplyNote({ @@ -115,7 +115,12 @@ export default function ReplyNote({
maxFileSizeKb={2048}
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-1 w-0">
<div className="flex gap-1 items-center">
@ -166,9 +171,19 @@ export default function ReplyNote({ @@ -166,9 +171,19 @@ export default function ReplyNote({
) : null}
{show ? (
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' ? (
<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' ? (
<span className="text-sm leading-none opacity-90" aria-hidden>
{DISCUSSION_UPVOTE_DISPLAY}
@ -180,7 +195,9 @@ export default function ReplyNote({ @@ -180,7 +195,9 @@ export default function ReplyNote({
) : (
<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>
) : event.kind === kinds.Zap ? (
<Zap className="mt-1.5" event={event} omitSenderHeading variant="compact" />
@ -208,15 +225,17 @@ export default function ReplyNote({ @@ -208,15 +225,17 @@ export default function ReplyNote({
</div>
</div>
</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
className="ml-14 pl-1 mr-4 mt-2"
event={event}
displayTopZapsAndLikes={event.kind !== kinds.Zap}
fetchIfNotExisting
foregroundStats={foregroundStats}
useIconOnlyLikeTrigger={isNip25ReactionKind(event.kind)}
/>
</>
)}

10
src/components/ReplyNoteList/index.tsx

@ -16,7 +16,6 @@ import { @@ -16,7 +16,6 @@ import {
getReplaceableCoordinateFromEvent,
getRootATag,
getRootETag,
isNip25ReactionKind,
isNip56ReportEvent,
isReplaceableEvent,
kind1QuotesThreadRoot,
@ -224,8 +223,8 @@ function moveReportsToEndPreserveOrder(events: NEvent[]): NEvent[] { @@ -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}. */
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). */
const WEB_THREAD_EXTRA_TAIL_KINDS = new Set<number>([kinds.Reaction, ExtendedKind.EXTERNAL_REACTION])
/** 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>([ExtendedKind.EXTERNAL_REACTION])
function isWebThreadTailKind(kind: number): boolean {
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 { @@ -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.
* Reactions still need to merge into {@link noteStatsService} for the root so the note header matches notifications.
* Thread REQ may still omit some kind-7 rows; merge reactions that tag the root hex so OP stats stay warm.
* Listed reactions under Antworten come from {@link ReplyNoteList} BFS + {@link replyMatchesThreadForList}.
*/
function mergeFetchedKind7ReactionsIntoRootNoteStats(all: NEvent[], rootInfo: TRootInfo) {
if (rootInfo.type === 'E') {
@ -449,7 +448,6 @@ function ReplyNoteList({ @@ -449,7 +448,6 @@ function ReplyNoteList({
events.forEach((evt) => {
if (replyIdSet.has(evt.id)) return
if (isNip25ReactionKind(evt.kind)) return
if (isPollVoteKind(evt)) return
if (isZapPollThreadZapReceipt(evt, event)) return
if (

4
src/constants.ts

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

18
src/index.css

@ -62,6 +62,24 @@ @@ -62,6 +62,24 @@
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 {
-ms-overflow-style: none; /* Internet Explorer 10+ */
scrollbar-width: none; /* Firefox */

19
src/lib/event.ts

@ -14,6 +14,7 @@ import { @@ -14,6 +14,7 @@ import {
generateBech32IdFromETag,
getFirstHexEventIdFromETags,
getImetaInfoFromImetaTag,
getNip25ReactionTargetHexFromTags,
tagNameEquals
} from './tag'
@ -175,8 +176,22 @@ export function isMentioningMutedUsers(event: Event, mutePubkeySet: Set<string>) @@ -175,8 +176,22 @@ export function isMentioningMutedUsers(event: Event, mutePubkeySet: Set<string>)
export function getParentETag(event?: Event) {
if (!event) return undefined
// NIP-25 reactions, NIP-18 reposts (6 / 16), poll responses: first hex `e` / `E` references the target note.
if (event.kind === kinds.Reaction || isNip18RepostKind(event.kind) || event.kind === ExtendedKind.POLL_RESPONSE) {
// 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) {
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)
if (!firstId) return undefined
return (

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

@ -2,4 +2,22 @@ @@ -2,4 +2,22 @@
* Single source for the quick-like emoji row used by the EmojiPicker / 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 @@ @@ -1,3 +1,4 @@
import { DEFAULT_LIKE_REACTION_CONTENT } from '@/lib/like-reaction-emojis'
import { replaceStandardEmojiShortcodesInContent } from '@/lib/emoji-content'
import { isNip25ReactionKind } from '@/lib/event'
import { getEmojiInfosFromEmojiTags } from '@/lib/tag'
@ -22,7 +23,7 @@ export function resolveReactionEmojiSync(event: Event, maxRawLength: number): TR @@ -22,7 +23,7 @@ export function resolveReactionEmojiSync(event: Event, maxRawLength: number): TR
const raw = event.content?.trim() ?? ''
if (!raw) {
return { mode: 'display', value: '❤' }
return { mode: 'display', value: DEFAULT_LIKE_REACTION_CONTENT }
}
if (raw.length > 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 @@ -50,6 +50,25 @@ export function getFirstHexEventIdFromETags(tags: string[][]): string | undefine
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[]) {
try {
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 }: @@ -546,7 +546,6 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }:
className="mt-3"
event={finalEvent}
fetchIfNotExisting
displayTopZapsAndLikes
foregroundStats
/>
</div>

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

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

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

@ -11,6 +11,7 @@ import { @@ -11,6 +11,7 @@ import {
getParentEventHexId,
getReplaceableCoordinateFromEvent,
isNip18RepostKind,
isNip25ReactionKind,
isReplaceableEvent
} from '@/lib/event'
import { eventReferencesThreadTarget, threadRootRefFromStatsRootEvent } from '@/lib/op-reference-tags'
@ -256,7 +257,15 @@ class NoteStatsService { @@ -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 = () => {
for (const id of hexIds) {

Loading…
Cancel
Save