You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
232 lines
7.6 KiB
232 lines
7.6 KiB
import { EMOJI_PICKER_DATA_SOURCE } from '@/lib/emoji-picker-data-source' |
|
import { cn } from '@/lib/utils' |
|
import { preloadEmojiPickerModule } from '@/lib/emoji-picker-preload' |
|
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' |
|
import customEmojiService from '@/services/custom-emoji.service' |
|
import { TEmoji } from '@/types' |
|
import { Plus } from 'lucide-react' |
|
import { useEffect, useMemo, useRef, useState } from 'react' |
|
|
|
export { DEFAULT_SUGGESTED_EMOJIS as EMOJI_PICKER_REACTIONS } from '@/lib/like-reaction-emojis' |
|
|
|
export default function EmojiPicker({ |
|
onEmojiClick, |
|
reactionsDefaultOpen, |
|
reactions, |
|
layout = 'popover' |
|
}: { |
|
onEmojiClick: (emoji: string | TEmoji | undefined, event: Event) => void |
|
reactionsDefaultOpen?: boolean |
|
reactions?: string[] |
|
/** `drawer` fills the mobile sheet; `popover` uses a fixed height for dropdowns. */ |
|
layout?: 'drawer' | 'popover' |
|
}) { |
|
const inDrawer = layout === 'drawer' |
|
const { themeSetting } = useTheme() |
|
const { pubkey } = useNostr() |
|
const [mode, setMode] = useState<'reactions' | 'full'>( |
|
reactionsDefaultOpen ? 'reactions' : 'full' |
|
) |
|
const [customEmojiTick, setCustomEmojiTick] = useState(0) |
|
const [pickerReady, setPickerReady] = useState(false) |
|
const containerRef = useRef<HTMLDivElement>(null) |
|
const pickerRef = useRef<(HTMLElement & { customEmoji: unknown[] }) | null>(null) |
|
|
|
useEffect(() => customEmojiService.subscribeIndexUpdate(() => setCustomEmojiTick((t) => t + 1)), []) |
|
|
|
const customEmojis = useMemo( |
|
() => customEmojiService.getAllCustomEmojisForPicker(pubkey ?? null), |
|
[pubkey, customEmojiTick] |
|
) |
|
|
|
const ownEmojis = useMemo( |
|
() => (pubkey ? customEmojiService.getOwnCustomEmojis(pubkey) : []), |
|
[pubkey, customEmojiTick] |
|
) |
|
|
|
useEffect(() => { |
|
if (mode !== 'full') return |
|
|
|
let cancelled = false |
|
setPickerReady(false) |
|
|
|
preloadEmojiPickerModule().then(({ Picker }) => { |
|
if (cancelled || !containerRef.current) return |
|
|
|
const picker = new Picker({ |
|
dataSource: EMOJI_PICKER_DATA_SOURCE, |
|
customEmoji: customEmojis |
|
}) as HTMLElement & { customEmoji: unknown[] } |
|
pickerRef.current = picker |
|
|
|
if (themeSetting === 'dark') { |
|
picker.className = 'dark' |
|
} else if (themeSetting === 'light') { |
|
picker.className = 'light' |
|
} |
|
|
|
picker.style.width = '100%' |
|
picker.style.minWidth = '280px' |
|
picker.style.maxWidth = '350px' |
|
if (inDrawer) { |
|
picker.style.height = '100%' |
|
picker.style.minHeight = '0' |
|
} else { |
|
picker.style.height = 'min(350px, 50dvh)' |
|
picker.style.minHeight = '280px' |
|
} |
|
picker.style.setProperty('--num-columns', '8') |
|
|
|
const handleClick = (e: Event) => { |
|
const detail = (e as CustomEvent).detail as { |
|
unicode?: string |
|
emoji?: { |
|
custom?: boolean |
|
unicode?: string |
|
name?: string |
|
shortcodes?: string[] |
|
url?: string |
|
} |
|
} |
|
let result: string | TEmoji | undefined |
|
/** |
|
* 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 don’t 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 |
|
// 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) { |
|
result = { shortcode, url: em.url } |
|
} |
|
} else if (em?.custom && em.shortcodes?.[0] && em.url) { |
|
result = { shortcode: em.shortcodes[0], url: em.url } |
|
} |
|
} |
|
if (result !== undefined) recordEmojiUsed(result) |
|
onEmojiClick(result, e) |
|
} |
|
|
|
picker.addEventListener('emoji-click', handleClick) |
|
containerRef.current.appendChild(picker) |
|
if (!cancelled) setPickerReady(true) |
|
}) |
|
|
|
return () => { |
|
cancelled = true |
|
setPickerReady(false) |
|
if (pickerRef.current) { |
|
pickerRef.current.remove() |
|
pickerRef.current = null |
|
} |
|
} |
|
}, [mode, inDrawer]) |
|
|
|
useEffect(() => { |
|
if (pickerRef.current) { |
|
pickerRef.current.customEmoji = customEmojis |
|
} |
|
}, [customEmojis]) |
|
|
|
useEffect(() => { |
|
if (!pickerRef.current) return |
|
if (themeSetting === 'dark') { |
|
pickerRef.current.className = 'dark' |
|
} else if (themeSetting === 'light') { |
|
pickerRef.current.className = 'light' |
|
} else { |
|
pickerRef.current.className = '' |
|
} |
|
}, [themeSetting]) |
|
|
|
const reactionsList = reactions ?? [...DEFAULT_SUGGESTED_EMOJIS] |
|
|
|
const ownEmojisRow = |
|
ownEmojis.length > 0 ? ( |
|
<div className="flex shrink-0 items-center gap-0.5 px-1 py-1 border-b overflow-x-auto scrollbar-hide"> |
|
{ownEmojis.map((emoji) => ( |
|
<button |
|
key={emoji.shortcode} |
|
type="button" |
|
title={`:${emoji.shortcode}:`} |
|
className="shrink-0 w-8 h-8 rounded hover:bg-muted flex items-center justify-center" |
|
onClick={(e) => { |
|
recordEmojiUsed(emoji) |
|
onEmojiClick(emoji, e.nativeEvent) |
|
}} |
|
> |
|
<img src={emoji.url} alt={emoji.shortcode} className="w-6 h-6 object-contain" /> |
|
</button> |
|
))} |
|
</div> |
|
) : null |
|
|
|
if (mode === 'reactions') { |
|
return ( |
|
<div className="flex w-full min-w-0 flex-col"> |
|
{ownEmojisRow} |
|
<div className="flex flex-wrap items-center gap-1 p-2"> |
|
{reactionsList.map((emoji) => ( |
|
<button |
|
key={emoji} |
|
type="button" |
|
className="text-2xl p-1 rounded hover:bg-muted leading-none" |
|
onClick={(e) => { |
|
recordEmojiUsed(emoji) |
|
onEmojiClick(emoji, e.nativeEvent) |
|
}} |
|
> |
|
{emoji === DEFAULT_LIKE_REACTION_CONTENT ? DEFAULT_LIKE_REACTION_DISPLAY_EMOJI : emoji} |
|
</button> |
|
))} |
|
<button |
|
type="button" |
|
title="More emojis" |
|
className="p-1 rounded hover:bg-muted text-muted-foreground flex items-center justify-center" |
|
onClick={() => setMode('full')} |
|
> |
|
<Plus size={20} /> |
|
</button> |
|
</div> |
|
</div> |
|
) |
|
} |
|
|
|
return ( |
|
<div |
|
className={cn( |
|
'flex w-full min-w-0 flex-col', |
|
inDrawer && 'min-h-0 flex-1' |
|
)} |
|
> |
|
{ownEmojisRow} |
|
<div |
|
ref={containerRef} |
|
className={cn( |
|
'relative w-full min-w-[280px] max-w-[350px]', |
|
inDrawer ? 'min-h-0 flex-1' : 'h-[min(320px,45dvh)] min-h-[240px] shrink-0' |
|
)} |
|
> |
|
{!pickerReady ? ( |
|
<div className="absolute inset-0 flex items-center justify-center text-sm text-muted-foreground"> |
|
… |
|
</div> |
|
) : null} |
|
</div> |
|
</div> |
|
) |
|
}
|
|
|