22 changed files with 653 additions and 169 deletions
@ -0,0 +1,75 @@
@@ -0,0 +1,75 @@
|
||||
import Emoji from '@/components/Emoji' |
||||
import { resolveReactionEmojiSync } from '@/lib/reaction-display' |
||||
import { getEmojiInfosFromEmojiTags } from '@/lib/tag' |
||||
import { cn } from '@/lib/utils' |
||||
import { replaceableEventService } from '@/services/client.service' |
||||
import { TEmoji } from '@/types' |
||||
import { Event, kinds } from 'nostr-tools' |
||||
import { useEffect, useMemo, useState } from 'react' |
||||
|
||||
/** |
||||
* Renders a reaction glyph (Unicode, standard :shortcode:, or NIP-30 custom image from reactor profile). |
||||
*/ |
||||
export default function ReactionEmojiDisplay({ |
||||
event, |
||||
className, |
||||
maxRawLength = 64, |
||||
variant = 'default' |
||||
}: { |
||||
event: Event |
||||
className?: string |
||||
/** Truncate long reaction text beyond this length */ |
||||
maxRawLength?: number |
||||
/** Compact row (notification list at-a-glance) */ |
||||
variant?: 'default' | 'compact' |
||||
}) { |
||||
const sync = useMemo( |
||||
() => resolveReactionEmojiSync(event, maxRawLength), |
||||
[event, maxRawLength] |
||||
) |
||||
|
||||
const initial: TEmoji | string = |
||||
sync.mode === 'display' ? sync.value : sync.placeholder |
||||
|
||||
const [value, setValue] = useState<TEmoji | string>(initial) |
||||
|
||||
useEffect(() => { |
||||
setValue(initial) |
||||
}, [initial, event.id]) |
||||
|
||||
useEffect(() => { |
||||
if (sync.mode !== 'profile' || event.kind !== kinds.Reaction) return |
||||
let cancelled = false |
||||
replaceableEventService.fetchReplaceableEvent(event.pubkey, kinds.Metadata).then((pe) => { |
||||
if (cancelled || !pe) return |
||||
const infos = getEmojiInfosFromEmojiTags(pe.tags) |
||||
const hit = infos.find((i) => i.shortcode === sync.shortcode) |
||||
if (hit) setValue(hit) |
||||
}) |
||||
return () => { |
||||
cancelled = true |
||||
} |
||||
}, [event.pubkey, event.kind, sync]) |
||||
|
||||
if (event.kind !== kinds.Reaction || (sync.mode === 'display' && sync.value === '')) { |
||||
return null |
||||
} |
||||
|
||||
return ( |
||||
<span |
||||
className={cn('inline-flex shrink-0 items-center justify-center leading-none select-none', className)} |
||||
aria-hidden |
||||
> |
||||
<Emoji |
||||
emoji={value} |
||||
classNames={{ |
||||
img: |
||||
variant === 'compact' |
||||
? 'size-4 max-h-[1em] w-auto rounded-sm' |
||||
: 'size-7 max-h-[1.5em] w-auto rounded-sm', |
||||
text: variant === 'compact' ? 'text-base leading-none' : 'text-2xl leading-none' |
||||
}} |
||||
/> |
||||
</span> |
||||
) |
||||
} |
||||
@ -0,0 +1,39 @@
@@ -0,0 +1,39 @@
|
||||
import { ExtendedKind } from '@/constants' |
||||
import { getRootEventHexId } from '@/lib/event' |
||||
import { eventService } from '@/services/client.service' |
||||
import { Event } from 'nostr-tools' |
||||
import { useEffect, useState } from 'react' |
||||
|
||||
/** |
||||
* True when `event` is kind 1111 (COMMENT) whose thread root is a kind 11 discussion. |
||||
*/ |
||||
export function useReplyUnderDiscussionRoot(event: Event): boolean { |
||||
const [isReply, setIsReply] = useState(false) |
||||
|
||||
useEffect(() => { |
||||
if (event.kind !== ExtendedKind.COMMENT) { |
||||
setIsReply(false) |
||||
return |
||||
} |
||||
const rootEventId = getRootEventHexId(event) |
||||
if (!rootEventId) { |
||||
setIsReply(false) |
||||
return |
||||
} |
||||
let cancelled = false |
||||
eventService |
||||
.fetchEvent(rootEventId) |
||||
.then((rootEvent) => { |
||||
if (cancelled) return |
||||
setIsReply(!!(rootEvent && rootEvent.kind === ExtendedKind.DISCUSSION)) |
||||
}) |
||||
.catch(() => { |
||||
if (!cancelled) setIsReply(false) |
||||
}) |
||||
return () => { |
||||
cancelled = true |
||||
} |
||||
}, [event.id, event.kind]) |
||||
|
||||
return isReply |
||||
} |
||||
Loading…
Reference in new issue