19 changed files with 348 additions and 181 deletions
@ -1,87 +1,162 @@ |
|||||||
import { parseEmojiPickerUnified } from '@/lib/utils' |
import { DEFAULT_SUGGESTED_EMOJIS } from '@/lib/like-reaction-emojis' |
||||||
|
import { recordEmojiUsed } from '@/lib/recently-used-emojis' |
||||||
import { useNostr } from '@/providers/NostrProvider' |
import { useNostr } from '@/providers/NostrProvider' |
||||||
import { useScreenSize } from '@/providers/ScreenSizeProvider' |
|
||||||
import { useTheme } from '@/providers/ThemeProvider' |
import { useTheme } from '@/providers/ThemeProvider' |
||||||
import customEmojiService from '@/services/custom-emoji.service' |
import customEmojiService from '@/services/custom-emoji.service' |
||||||
import { TEmoji } from '@/types' |
import { TEmoji } from '@/types' |
||||||
import EmojiPickerReact, { |
import { Plus } from 'lucide-react' |
||||||
EmojiStyle, |
import { useEffect, useMemo, useRef, useState } from 'react' |
||||||
SkinTonePickerLocation, |
|
||||||
SuggestionMode, |
|
||||||
Theme |
|
||||||
} from 'emoji-picker-react' |
|
||||||
import { useEffect, useMemo, useState } from 'react' |
|
||||||
|
|
||||||
export { EMOJI_PICKER_REACTIONS } from '@/lib/like-reaction-emojis' |
export { DEFAULT_SUGGESTED_EMOJIS as EMOJI_PICKER_REACTIONS } from '@/lib/like-reaction-emojis' |
||||||
|
|
||||||
export default function EmojiPicker({ |
export default function EmojiPicker({ |
||||||
onEmojiClick, |
onEmojiClick, |
||||||
reactionsDefaultOpen, |
reactionsDefaultOpen, |
||||||
reactions |
reactions |
||||||
}: { |
}: { |
||||||
onEmojiClick: (emoji: string | TEmoji | undefined, event: MouseEvent) => void |
onEmojiClick: (emoji: string | TEmoji | undefined, event: Event) => void |
||||||
/** When true, show the compact reactions row first (tap + for full picker). */ |
|
||||||
reactionsDefaultOpen?: boolean |
reactionsDefaultOpen?: boolean |
||||||
/** Unified ids for the reactions row; for likes use {@link EMOJI_PICKER_REACTIONS}. */ |
|
||||||
reactions?: string[] |
reactions?: string[] |
||||||
}) { |
}) { |
||||||
const { themeSetting } = useTheme() |
const { themeSetting } = useTheme() |
||||||
const { isSmallScreen } = useScreenSize() |
|
||||||
const { pubkey } = useNostr() |
const { pubkey } = useNostr() |
||||||
const [viewportW, setViewportW] = useState( |
const [mode, setMode] = useState<'reactions' | 'full'>( |
||||||
() => (typeof window !== 'undefined' ? window.innerWidth : 390) |
reactionsDefaultOpen ? 'reactions' : 'full' |
||||||
) |
) |
||||||
const [viewportH, setViewportH] = useState( |
|
||||||
() => (typeof window !== 'undefined' ? window.innerHeight : 700) |
|
||||||
) |
|
||||||
useEffect(() => { |
|
||||||
const onResize = () => { |
|
||||||
setViewportW(window.innerWidth) |
|
||||||
setViewportH(window.innerHeight) |
|
||||||
} |
|
||||||
window.addEventListener('resize', onResize) |
|
||||||
return () => window.removeEventListener('resize', onResize) |
|
||||||
}, []) |
|
||||||
const [customEmojiTick, setCustomEmojiTick] = useState(0) |
const [customEmojiTick, setCustomEmojiTick] = useState(0) |
||||||
|
const containerRef = useRef<HTMLDivElement>(null) |
||||||
|
const pickerRef = useRef<(HTMLElement & { customEmoji: unknown[] }) | null>(null) |
||||||
|
|
||||||
useEffect(() => customEmojiService.subscribeIndexUpdate(() => setCustomEmojiTick((t) => t + 1)), []) |
useEffect(() => customEmojiService.subscribeIndexUpdate(() => setCustomEmojiTick((t) => t + 1)), []) |
||||||
|
|
||||||
const customEmojis = useMemo( |
const customEmojis = useMemo( |
||||||
() => customEmojiService.getAllCustomEmojisForPicker(pubkey ?? null), |
() => customEmojiService.getAllCustomEmojisForPicker(pubkey ?? null), |
||||||
[pubkey, customEmojiTick] |
[pubkey, customEmojiTick] |
||||||
) |
) |
||||||
|
|
||||||
const pickerWidth = isSmallScreen ? Math.max(260, viewportW - 24) : 350 |
const ownEmojis = useMemo( |
||||||
const pickerHeight = isSmallScreen |
() => (pubkey ? customEmojiService.getOwnCustomEmojis(pubkey) : []), |
||||||
? Math.max(280, Math.min(Math.round(viewportH * 0.52), 460)) |
[pubkey, customEmojiTick] |
||||||
: 450 |
) |
||||||
|
|
||||||
return ( |
useEffect(() => { |
||||||
<EmojiPickerReact |
if (mode !== 'full') return |
||||||
theme={ |
|
||||||
themeSetting === 'system' ? Theme.AUTO : themeSetting === 'dark' ? Theme.DARK : Theme.LIGHT |
let cancelled = false |
||||||
|
|
||||||
|
import('emoji-picker-element').then(({ Picker }) => { |
||||||
|
if (cancelled || !containerRef.current) return |
||||||
|
|
||||||
|
const picker = new Picker() as HTMLElement & { customEmoji: unknown[] } |
||||||
|
pickerRef.current = picker |
||||||
|
|
||||||
|
picker.customEmoji = customEmojis |
||||||
|
|
||||||
|
if (themeSetting === 'dark') { |
||||||
|
picker.className = 'dark' |
||||||
|
} else if (themeSetting === 'light') { |
||||||
|
picker.className = 'light' |
||||||
|
} |
||||||
|
|
||||||
|
picker.style.width = '100%' |
||||||
|
picker.style.setProperty('--num-columns', '8') |
||||||
|
|
||||||
|
const handleClick = (e: Event) => { |
||||||
|
const detail = (e as CustomEvent).detail as { |
||||||
|
unicode?: string |
||||||
|
emoji: { custom?: boolean; shortcodes?: string[]; url?: string } |
||||||
|
} |
||||||
|
let result: string | TEmoji | undefined |
||||||
|
if (detail.unicode) { |
||||||
|
result = detail.unicode |
||||||
|
} else if (detail.emoji?.custom && detail.emoji.shortcodes?.[0] && detail.emoji.url) { |
||||||
|
result = { shortcode: detail.emoji.shortcodes[0], url: detail.emoji.url } |
||||||
} |
} |
||||||
width={pickerWidth} |
if (result !== undefined) recordEmojiUsed(result) |
||||||
height={pickerHeight} |
onEmojiClick(result, e) |
||||||
autoFocusSearch={false} |
|
||||||
emojiStyle={EmojiStyle.NATIVE} |
|
||||||
skinTonePickerLocation={SkinTonePickerLocation.PREVIEW} |
|
||||||
style={ |
|
||||||
{ |
|
||||||
'--epr-bg-color': 'hsl(var(--background))', |
|
||||||
'--epr-category-label-bg-color': 'hsl(var(--background))', |
|
||||||
'--epr-text-color': 'hsl(var(--foreground))', |
|
||||||
'--epr-hover-bg-color': 'hsl(var(--muted) / 0.5)', |
|
||||||
'--epr-picker-border-color': 'transparent', |
|
||||||
'--epr-search-input-bg-color': 'hsl(var(--muted) / 0.5)' |
|
||||||
} as React.CSSProperties |
|
||||||
} |
} |
||||||
suggestedEmojisMode={SuggestionMode.FREQUENT} |
|
||||||
onEmojiClick={(data, e) => { |
picker.addEventListener('emoji-click', handleClick) |
||||||
const emoji = parseEmojiPickerUnified(data.unified) |
containerRef.current.appendChild(picker) |
||||||
onEmojiClick(emoji, e) |
}) |
||||||
|
|
||||||
|
return () => { |
||||||
|
cancelled = true |
||||||
|
if (pickerRef.current) { |
||||||
|
pickerRef.current.remove() |
||||||
|
pickerRef.current = null |
||||||
|
} |
||||||
|
} |
||||||
|
}, [mode]) |
||||||
|
|
||||||
|
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] |
||||||
|
|
||||||
|
if (mode === 'reactions') { |
||||||
|
return ( |
||||||
|
<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} |
||||||
|
</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> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className="w-full flex flex-col"> |
||||||
|
{ownEmojis.length > 0 && ( |
||||||
|
<div className="flex 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) |
||||||
}} |
}} |
||||||
customEmojis={customEmojis} |
> |
||||||
{...(reactionsDefaultOpen !== undefined ? { reactionsDefaultOpen } : {})} |
<img src={emoji.url} alt={emoji.shortcode} className="w-6 h-6 object-contain" /> |
||||||
{...(reactions !== undefined ? { reactions } : {})} |
</button> |
||||||
/> |
))} |
||||||
|
</div> |
||||||
|
)} |
||||||
|
<div ref={containerRef} /> |
||||||
|
</div> |
||||||
) |
) |
||||||
} |
} |
||||||
|
|||||||
@ -0,0 +1,6 @@ |
|||||||
|
/** |
||||||
|
* Shared highlight.js instance with a curated language subset. |
||||||
|
* Replaces the full `highlight.js` import (~969 kB) with a common subset (~350 kB). |
||||||
|
* Lazily imported via dynamic import() in article components. |
||||||
|
*/ |
||||||
|
export { default } from 'highlight.js/lib/common' |
||||||
@ -0,0 +1,28 @@ |
|||||||
|
import { TEmoji } from '@/types' |
||||||
|
|
||||||
|
const STORAGE_KEY = 'jumble-recently-used-emojis' |
||||||
|
const MAX_ENTRIES = 18 |
||||||
|
|
||||||
|
type StoredEmoji = string | { shortcode: string; url: string } |
||||||
|
|
||||||
|
export function getRecentlyUsedEmojis(): (string | TEmoji)[] { |
||||||
|
try { |
||||||
|
const raw = localStorage.getItem(STORAGE_KEY) |
||||||
|
if (!raw) return [] |
||||||
|
return JSON.parse(raw) as StoredEmoji[] |
||||||
|
} catch { |
||||||
|
return [] |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export function recordEmojiUsed(emoji: string | TEmoji): void { |
||||||
|
try { |
||||||
|
const key = typeof emoji === 'string' ? emoji : emoji.shortcode |
||||||
|
const entries = getRecentlyUsedEmojis() |
||||||
|
const filtered = entries.filter((e) => (typeof e === 'string' ? e : e.shortcode) !== key) |
||||||
|
const updated = [emoji, ...filtered].slice(0, MAX_ENTRIES) |
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(updated)) |
||||||
|
} catch { |
||||||
|
// ignore storage errors
|
||||||
|
} |
||||||
|
} |
||||||
Loading…
Reference in new issue