From 1f4416369d6d2856642457f8d2e019dc3124ba84 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Wed, 8 Apr 2026 19:47:18 +0200 Subject: [PATCH] clean up build --- package-lock.json | 37 +--- package.json | 4 +- src/components/EmojiPicker/index.tsx | 191 ++++++++++++------ src/components/GifPicker/index.tsx | 47 ++++- src/components/KindFilter/index.tsx | 1 + src/components/MemePicker/index.tsx | 48 ++++- .../Note/AsciidocArticle/AsciidocArticle.tsx | 2 +- .../Note/MarkdownArticle/MarkdownArticle.tsx | 2 +- src/components/SuggestedEmojis/index.tsx | 28 +-- src/lib/highlight.ts | 6 + src/lib/like-reaction-emojis.ts | 19 +- src/lib/recently-used-emojis.ts | 28 +++ src/lib/utils.ts | 5 +- src/services/custom-emoji.service.ts | 60 +++--- src/services/gif.service.ts | 17 +- src/services/local-storage.service.ts | 12 +- src/services/meme.service.ts | 14 ++ src/services/note-stats.service.ts | 4 +- vite.config.ts | 4 +- 19 files changed, 348 insertions(+), 181 deletions(-) create mode 100644 src/lib/highlight.ts create mode 100644 src/lib/recently-used-emojis.ts diff --git a/package-lock.json b/package-lock.json index 6ec92a1d..220cc269 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "imwald", - "version": "22.2.0", + "version": "22.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "imwald", - "version": "22.2.0", + "version": "22.3.0", "license": "MIT", "dependencies": { "@asciidoctor/core": "^3.0.4", @@ -57,7 +57,7 @@ "dayjs": "^1.11.13", "embla-carousel-react": "^8.6.0", "embla-carousel-wheel-gestures": "^8.1.0", - "emoji-picker-react": "^4.12.2", + "emoji-picker-element": "^1.29.1", "flexsearch": "^0.7.43", "highlight.js": "^11.9.0", "i18next": "^24.2.0", @@ -8685,20 +8685,11 @@ "embla-carousel": "^8.0.0 || ~8.0.0-rc03" } }, - "node_modules/emoji-picker-react": { - "version": "4.18.0", - "resolved": "https://registry.npmjs.org/emoji-picker-react/-/emoji-picker-react-4.18.0.tgz", - "integrity": "sha512-vLTrLfApXAIciguGE57pXPWs9lPLBspbEpPMiUq03TIli2dHZBiB+aZ0R9/Wat0xmTfcd4AuEzQgSYxEZ8C88Q==", - "license": "MIT", - "dependencies": { - "flairup": "1.0.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "react": ">=16" - } + "node_modules/emoji-picker-element": { + "version": "1.29.1", + "resolved": "https://registry.npmjs.org/emoji-picker-element/-/emoji-picker-element-1.29.1.tgz", + "integrity": "sha512-TOiHzu9Dqib3x4MwcAi3wi3RdyT4SoeB4b15AvH1ks4SBwTl7DeebhZ0d3x6dNi4XfNU7IGRZ7NBQllj0RqwrQ==", + "license": "Apache-2.0" }, "node_modules/emoji-regex": { "version": "10.6.0", @@ -9368,12 +9359,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/flairup": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/flairup/-/flairup-1.0.0.tgz", - "integrity": "sha512-IKlE+pNvL2R+kVL1kEhUYqRxVqeFnjiIvHWDMLFXNaqyUdFXQM2wte44EfMYJNHkW16X991t2Zg8apKkhv7OBA==", - "license": "MIT" - }, "node_modules/flat-cache": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", @@ -15195,9 +15180,9 @@ } }, "node_modules/vite": { - "version": "6.4.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", - "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", + "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index b29a67cd..9d507ec1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "imwald", - "version": "22.2.0", + "version": "22.3.0", "description": "Imwald — a user-friendly Nostr client focused on relay feed browsing, publications, and relay discovery", "private": true, "type": "module", @@ -79,7 +79,7 @@ "dayjs": "^1.11.13", "embla-carousel-react": "^8.6.0", "embla-carousel-wheel-gestures": "^8.1.0", - "emoji-picker-react": "^4.12.2", + "emoji-picker-element": "^1.29.1", "flexsearch": "^0.7.43", "highlight.js": "^11.9.0", "i18next": "^24.2.0", diff --git a/src/components/EmojiPicker/index.tsx b/src/components/EmojiPicker/index.tsx index 8d9b1d7c..35ba86a4 100644 --- a/src/components/EmojiPicker/index.tsx +++ b/src/components/EmojiPicker/index.tsx @@ -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 { useScreenSize } from '@/providers/ScreenSizeProvider' import { useTheme } from '@/providers/ThemeProvider' import customEmojiService from '@/services/custom-emoji.service' import { TEmoji } from '@/types' -import EmojiPickerReact, { - EmojiStyle, - SkinTonePickerLocation, - SuggestionMode, - Theme -} from 'emoji-picker-react' -import { useEffect, useMemo, useState } from 'react' +import { Plus } from 'lucide-react' +import { useEffect, useMemo, useRef, 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({ onEmojiClick, reactionsDefaultOpen, reactions }: { - onEmojiClick: (emoji: string | TEmoji | undefined, event: MouseEvent) => void - /** When true, show the compact reactions row first (tap + for full picker). */ + onEmojiClick: (emoji: string | TEmoji | undefined, event: Event) => void reactionsDefaultOpen?: boolean - /** Unified ids for the reactions row; for likes use {@link EMOJI_PICKER_REACTIONS}. */ reactions?: string[] }) { const { themeSetting } = useTheme() - const { isSmallScreen } = useScreenSize() const { pubkey } = useNostr() - const [viewportW, setViewportW] = useState( - () => (typeof window !== 'undefined' ? window.innerWidth : 390) + const [mode, setMode] = useState<'reactions' | 'full'>( + 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 containerRef = useRef(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 pickerWidth = isSmallScreen ? Math.max(260, viewportW - 24) : 350 - const pickerHeight = isSmallScreen - ? Math.max(280, Math.min(Math.round(viewportH * 0.52), 460)) - : 450 + const ownEmojis = useMemo( + () => (pubkey ? customEmojiService.getOwnCustomEmojis(pubkey) : []), + [pubkey, customEmojiTick] + ) - return ( - { + if (mode !== 'full') return + + 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 } + } + if (result !== undefined) recordEmojiUsed(result) + onEmojiClick(result, e) } - width={pickerWidth} - height={pickerHeight} - 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 + + picker.addEventListener('emoji-click', handleClick) + containerRef.current.appendChild(picker) + }) + + return () => { + cancelled = true + if (pickerRef.current) { + pickerRef.current.remove() + pickerRef.current = null } - suggestedEmojisMode={SuggestionMode.FREQUENT} - onEmojiClick={(data, e) => { - const emoji = parseEmojiPickerUnified(data.unified) - onEmojiClick(emoji, e) - }} - customEmojis={customEmojis} - {...(reactionsDefaultOpen !== undefined ? { reactionsDefaultOpen } : {})} - {...(reactions !== undefined ? { reactions } : {})} - /> + } + }, [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 ( +
+ {reactionsList.map((emoji) => ( + + ))} + +
+ ) + } + + return ( +
+ {ownEmojis.length > 0 && ( +
+ {ownEmojis.map((emoji) => ( + + ))} +
+ )} +
+
) } diff --git a/src/components/GifPicker/index.tsx b/src/components/GifPicker/index.tsx index 8a5beb9b..82f13e71 100644 --- a/src/components/GifPicker/index.tsx +++ b/src/components/GifPicker/index.tsx @@ -17,6 +17,7 @@ import { cn } from '@/lib/utils' import { normalizeUrl } from '@/lib/url' import { fetchGifs, + getCachedGifs, searchGifs, gifShouldOfferNip94Archive, type GifMetadata @@ -25,6 +26,9 @@ import mediaUpload from '@/services/media-upload.service' import { Download, ExternalLink, X } from 'lucide-react' import { kinds } from 'nostr-tools' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' + +/** In-session cache: survives Drawer/Dropdown open↔close without a relay re-fetch. */ +let _sessionGifs: GifMetadata[] = [] import { useTranslation } from 'react-i18next' const GIFBUDDY_URL = 'https://www.gifbuddy.lol/' @@ -48,7 +52,9 @@ export default function GifPicker({ const [open, setOpen] = useState(false) const [query, setQuery] = useState('') const [searchInput, setSearchInput] = useState('') - const [gifs, setGifs] = useState([]) + // Initialise from the module-level session cache so re-opens are instant + const [gifs, setGifsState] = useState(() => _sessionGifs) + const gifsRef = useRef(_sessionGifs) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) const [uploading, setUploading] = useState(false) @@ -93,15 +99,42 @@ export default function GifPicker({ }) }, [userWriteRelays]) + /** Keep gifsRef, session cache, and React state in sync. */ + const setGifs = useCallback((newGifs: GifMetadata[], isSearch = false) => { + gifsRef.current = newGifs + if (!isSearch) _sessionGifs = newGifs + setGifsState(newGifs) + }, []) + const loadGifs = useCallback(async (q: string, forceRefresh = false) => { setError(null) - setLoading(true) + const isSearch = q.trim() !== '' + + // For a search or a forced refresh with no data: clear and show skeleton immediately. + if (isSearch) { + gifsRef.current = [] + setGifsState([]) + setLoading(true) + } else if (gifsRef.current.length === 0) { + // No data yet — try the IDB cache first so we can show something instantly. + try { + const cached = await getCachedGifs(pubkey ?? null) + if (cached.length > 0) { + setGifs(cached) + } + } catch { /* ignore */ } + // If still empty after the cache read, show the skeleton while we wait for relays. + if (gifsRef.current.length === 0) setLoading(true) + } + // If we already have data (session cache or IDB seed above): no skeleton — + // results will update silently when the relay fetch completes. + try { - const results = q.trim() + const results = isSearch ? await searchGifs(q.trim(), 50, forceRefresh, userReadRelays, pubkey ?? null) : await fetchGifs(undefined, 50, forceRefresh, userReadRelays, pubkey ?? null) - setGifs(results) - if (results.length === 0 && !q.trim()) { + setGifs(results, isSearch) + if (results.length === 0 && !isSearch) { setError( t( 'No GIFs found. Try searching or add your own. GIFs come from Nostr kind 1063 (NIP-94) events on GIF relays.' @@ -110,11 +143,11 @@ export default function GifPicker({ } } catch (e) { setError(e instanceof Error ? e.message : 'Failed to load GIFs') - setGifs([]) + if (gifsRef.current.length === 0) setGifsState([]) } finally { setLoading(false) } - }, [t, userReadRelays, pubkey]) + }, [t, userReadRelays, pubkey, setGifs]) useEffect(() => { if (!open) return diff --git a/src/components/KindFilter/index.tsx b/src/components/KindFilter/index.tsx index d3ed999d..d448d330 100644 --- a/src/components/KindFilter/index.tsx +++ b/src/components/KindFilter/index.tsx @@ -17,6 +17,7 @@ const KIND_1 = kinds.ShortTextNote const KIND_1111 = ExtendedKind.COMMENT const KIND_FILTER_OPTIONS = [ + { kindGroup: [kinds.LongFormArticle, ExtendedKind.WIKI_ARTICLE, ExtendedKind.WIKI_ARTICLE_MARKDOWN], label: 'Articles' }, { kindGroup: [kinds.Highlights], label: 'Highlights' }, { kindGroup: [ExtendedKind.POLL], label: 'Polls' }, { kindGroup: [ExtendedKind.ZAP_POLL], label: 'Zap polls' }, diff --git a/src/components/MemePicker/index.tsx b/src/components/MemePicker/index.tsx index 1e9f7632..2d899abb 100644 --- a/src/components/MemePicker/index.tsx +++ b/src/components/MemePicker/index.tsx @@ -16,6 +16,7 @@ import { ExtendedKind, GIF_RELAY_URLS } from '@/constants' import { normalizeUrl } from '@/lib/url' import { fetchMemes, + getCachedMemes, mergeMemesIntoIdbCache, memeMetadataFrom1063Event, searchMemes, @@ -24,6 +25,9 @@ import { import mediaUpload from '@/services/media-upload.service' import { ExternalLink, X } from 'lucide-react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' + +/** In-session cache: survives Drawer/Dropdown open↔close without a relay re-fetch. */ +let _sessionMemes: MemeMetadata[] = [] import { useTranslation } from 'react-i18next' import { toast } from 'sonner' @@ -68,7 +72,9 @@ export default function MemePicker({ const [open, setOpen] = useState(false) const [query, setQuery] = useState('') const [searchInput, setSearchInput] = useState('') - const [memes, setMemes] = useState([]) + // Initialise from the module-level session cache so re-opens are instant + const [memes, setMemesState] = useState(() => _sessionMemes) + const memesRef = useRef(_sessionMemes) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) const [uploading, setUploading] = useState(false) @@ -83,16 +89,38 @@ export default function MemePicker({ const userReadRelays = useMemo(() => userReadRelaysWithHttp(relayList), [relayList]) const userWriteRelays = relayList?.write ?? [] + /** Keep memesRef, session cache, and React state in sync. */ + const setMemes = useCallback((newMemes: MemeMetadata[], isSearch = false) => { + memesRef.current = newMemes + if (!isSearch) _sessionMemes = newMemes + setMemesState(newMemes) + }, []) + const loadMemes = useCallback( async (q: string, forceRefresh = false) => { setError(null) - setLoading(true) + const isSearch = q.trim() !== '' + + if (isSearch) { + memesRef.current = [] + setMemesState([]) + setLoading(true) + } else if (memesRef.current.length === 0) { + try { + const cached = await getCachedMemes(pubkey ?? null) + if (cached.length > 0) { + setMemes(cached) + } + } catch { /* ignore */ } + if (memesRef.current.length === 0) setLoading(true) + } + try { - const results = q.trim() + const results = isSearch ? await searchMemes(q.trim(), 50, forceRefresh, userReadRelays, pubkey ?? null) : await fetchMemes(undefined, 50, forceRefresh, userReadRelays, pubkey ?? null) - setMemes(results) - if (results.length === 0 && !q.trim()) { + setMemes(results, isSearch) + if (results.length === 0 && !isSearch) { setError( t( 'No meme templates found. Try searching or open Meme Amigo. The grid only lists kind 1063 (NIP-94) files tagged memeamigo (not random photos from notes).' @@ -101,12 +129,12 @@ export default function MemePicker({ } } catch (e) { setError(e instanceof Error ? e.message : 'Failed to load memes') - setMemes([]) + if (memesRef.current.length === 0) setMemesState([]) } finally { setLoading(false) } }, - [t, userReadRelays, pubkey] + [t, userReadRelays, pubkey, setMemes] ) useEffect(() => { @@ -166,10 +194,8 @@ export default function MemePicker({ const meta = memeMetadataFrom1063Event(published) if (meta) { await mergeMemesIntoIdbCache([meta]) - setMemes((prev) => { - const next = [meta, ...prev.filter((m) => m.eventId !== meta.eventId)] - return next.slice(0, 50) - }) + const next = [meta, ...memesRef.current.filter((m) => m.eventId !== meta.eventId)].slice(0, 50) + setMemes(next) } setPublishDescription('') setQuery('') diff --git a/src/components/Note/AsciidocArticle/AsciidocArticle.tsx b/src/components/Note/AsciidocArticle/AsciidocArticle.tsx index bb36ea1e..29097a7c 100644 --- a/src/components/Note/AsciidocArticle/AsciidocArticle.tsx +++ b/src/components/Note/AsciidocArticle/AsciidocArticle.tsx @@ -1764,7 +1764,7 @@ export default function AsciidocArticle({ useEffect(() => { const initHighlight = async () => { if (typeof window !== 'undefined') { - const hljs = await import('highlight.js') + const hljs = await import('@/lib/highlight') if (contentRef.current) { contentRef.current.querySelectorAll('pre code').forEach((block) => { const element = block as HTMLElement diff --git a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx index 99cc8fe7..a13596ee 100644 --- a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx +++ b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx @@ -334,7 +334,7 @@ function CodeBlock({ id, code, language }: { id: string; code: string; language: const initHighlight = async () => { if (typeof window === 'undefined') return try { - const hljs = await import('highlight.js') + const hljs = await import('@/lib/highlight') if (cancelled) return const root = codeRef.current if (!root) return diff --git a/src/components/SuggestedEmojis/index.tsx b/src/components/SuggestedEmojis/index.tsx index f184c863..ce3b6445 100644 --- a/src/components/SuggestedEmojis/index.tsx +++ b/src/components/SuggestedEmojis/index.tsx @@ -1,8 +1,7 @@ import { Button } from '@/components/ui/button' import { DEFAULT_SUGGESTED_EMOJIS } from '@/lib/like-reaction-emojis' -import { parseEmojiPickerUnified } from '@/lib/utils' +import { getRecentlyUsedEmojis } from '@/lib/recently-used-emojis' import { TEmoji } from '@/types' -import { getSuggested } from 'emoji-picker-react/src/dataUtils/suggested' import { MoreHorizontal } from 'lucide-react' import { useEffect, useState } from 'react' import Emoji from '../Emoji' @@ -19,22 +18,17 @@ export default function SuggestedEmojis({ useEffect(() => { try { - const suggested = getSuggested() + const recent = getRecentlyUsedEmojis() + if (recent.length === 0) return + const emojiSet = new Set() - const suggestEmojis = ( - suggested - .sort((a, b) => b.count - a.count) - .map((item) => parseEmojiPickerUnified(item.unified)) - .filter(Boolean) as (string | TEmoji)[] - ) - .concat(DEFAULT_SUGGESTED_EMOJIS) - .filter((emoji) => { - if (typeof emoji !== 'string') return true - if (emojiSet.has(emoji)) return false - emojiSet.add(emoji) - return true - }) - setSuggestedEmojis(suggestEmojis.slice(0, 9)) + const merged = [...recent, ...DEFAULT_SUGGESTED_EMOJIS].filter((emoji) => { + const key = typeof emoji === 'string' ? emoji : emoji.shortcode + if (emojiSet.has(key)) return false + emojiSet.add(key) + return true + }) + setSuggestedEmojis(merged.slice(0, 9)) } catch { // ignore } diff --git a/src/lib/highlight.ts b/src/lib/highlight.ts new file mode 100644 index 00000000..f2b912e9 --- /dev/null +++ b/src/lib/highlight.ts @@ -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' diff --git a/src/lib/like-reaction-emojis.ts b/src/lib/like-reaction-emojis.ts index fcb396ab..806b65db 100644 --- a/src/lib/like-reaction-emojis.ts +++ b/src/lib/like-reaction-emojis.ts @@ -1,19 +1,8 @@ /** - * Single source for the quick-like emoji row (SuggestedEmojis “+” row uses the same glyphs; - * emoji-picker-react needs hex unified ids — see {@link EMOJI_PICKER_REACTIONS}). + * Single source for the quick-like emoji row used by SuggestedEmojis and the EmojiPicker + * reactions row. Also re-exported as EMOJI_PICKER_REACTIONS for LikeButton. */ export const DEFAULT_SUGGESTED_EMOJIS = ['❤️', '👍', '🔥', '😂', '😢', '🫂', '🚀'] as const -function emojiToPickerUnified(emoji: string): string { - const parts: string[] = [] - for (const ch of emoji) { - const cp = ch.codePointAt(0) - if (cp != null) parts.push(cp.toString(16)) - } - return parts.join('-') -} - -/** Unified ids for `emoji-picker-react` reactions row — derived from {@link DEFAULT_SUGGESTED_EMOJIS}. */ -export const EMOJI_PICKER_REACTIONS: readonly string[] = DEFAULT_SUGGESTED_EMOJIS.map((e) => - emojiToPickerUnified(e) -) +/** Emoji characters for the reactions row in the like-button picker. */ +export const EMOJI_PICKER_REACTIONS: readonly string[] = DEFAULT_SUGGESTED_EMOJIS diff --git a/src/lib/recently-used-emojis.ts b/src/lib/recently-used-emojis.ts new file mode 100644 index 00000000..1bebf71c --- /dev/null +++ b/src/lib/recently-used-emojis.ts @@ -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 + } +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts index a9a99828..75891e94 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,8 +1,11 @@ import { TEmoji } from '@/types' import { clsx, type ClassValue } from 'clsx' -import { parseNativeEmoji } from 'emoji-picker-react/src/dataUtils/parseNativeEmoji' import { twMerge } from 'tailwind-merge' +function parseNativeEmoji(unified: string): string { + return String.fromCodePoint(...unified.split('-').map((h) => parseInt(h, 16))) +} + export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } diff --git a/src/services/custom-emoji.service.ts b/src/services/custom-emoji.service.ts index 5fd1270e..1cf84f5e 100644 --- a/src/services/custom-emoji.service.ts +++ b/src/services/custom-emoji.service.ts @@ -1,10 +1,8 @@ import { getEmojisAndEmojiSetsFromEvent, getEmojisFromEvent } from '@/lib/event-metadata' -import { parseEmojiPickerUnified } from '@/lib/utils' +import { recordEmojiUsed } from '@/lib/recently-used-emojis' import client from '@/services/client.service' import { TEmoji } from '@/types' import { sha256 } from '@noble/hashes/sha2' -import { SkinTones } from 'emoji-picker-react' -import { getSuggested, setSuggested } from 'emoji-picker-react/src/dataUtils/suggested' import FlexSearch from 'flexsearch' import { Event, kinds } from 'nostr-tools' @@ -115,20 +113,8 @@ class CustomEmojiService { async searchEmojis(query: string = '', viewerPubkey?: string | null): Promise { const v = viewerPubkey?.toLowerCase() ?? '' if (!query) { - const idSet = new Set() - getSuggested() - .sort((a, b) => b.count - a.count) - .map((item) => parseEmojiPickerUnified(item.unified)) - .forEach((item) => { - if (item && typeof item !== 'string') { - const id = this.getEmojiId(item) - idSet.add(id) - } - }) - for (const key of this.emojiMap.keys()) { - idSet.add(key) - } - return this.sortEmojiIdsForViewer(Array.from(idSet), v) + const ids = this.sortEmojiIdsForViewer(Array.from(this.emojiMap.keys()), v) + return ids } const results = await this.emojiIndex.searchAsync(query) const filtered = results.filter((id) => typeof id === 'string') as string[] @@ -141,14 +127,22 @@ class CustomEmojiService { return this.emojiMap.get(id) } - getAllCustomEmojisForPicker(viewerPubkey?: string | null) { + /** Returns the emojis that the viewer themselves authored, sorted by shortcode. */ + getOwnCustomEmojis(viewerPubkey: string): TEmoji[] { + const v = viewerPubkey.toLowerCase() + const own: TEmoji[] = [] + for (const [hashId, emoji] of this.emojiMap.entries()) { + if (this.emojiAuthorById.get(hashId) === v) own.push(emoji) + } + return own.sort((a, b) => a.shortcode.localeCompare(b.shortcode)) + } + + getAllCustomEmojisForPicker( + viewerPubkey?: string | null + ): Array<{ name: string; shortcodes: [string]; url: string; category: string }> { const v = viewerPubkey?.toLowerCase() ?? '' const rows = Array.from(this.emojiMap.entries()).map(([hashId, emoji]) => ({ - row: { - id: `:${emoji.shortcode}:${emoji.url}`, - imgUrl: emoji.url, - names: [emoji.shortcode] as [string] - }, + emoji, author: this.emojiAuthorById.get(hashId) ?? '' })) rows.sort((a, b) => { @@ -157,9 +151,14 @@ class CustomEmojiService { const bOwn = b.author === v ? 0 : 1 if (aOwn !== bOwn) return aOwn - bOwn } - return a.row.names[0].localeCompare(b.row.names[0]) + return a.emoji.shortcode.localeCompare(b.emoji.shortcode) }) - return rows.map((r) => r.row) + return rows.map((r) => ({ + name: r.emoji.shortcode, + shortcodes: [r.emoji.shortcode] as [string], + url: r.emoji.url, + category: 'Custom' + })) } isCustomEmojiId(shortcode: string) { @@ -188,16 +187,7 @@ class CustomEmojiService { updateSuggested(id: string) { const emoji = this.getEmojiById(id) if (!emoji) return - - setSuggested( - { - n: [emoji.shortcode.toLowerCase()], - u: `:${emoji.shortcode}:${emoji.url}`.toLowerCase(), - a: '0', - imgUrl: emoji.url - }, - SkinTones.NEUTRAL - ) + recordEmojiUsed(emoji) } } diff --git a/src/services/gif.service.ts b/src/services/gif.service.ts index bcd188b3..59f790f8 100644 --- a/src/services/gif.service.ts +++ b/src/services/gif.service.ts @@ -217,8 +217,7 @@ function parseGifFromEvent(event: NEvent): GifMetadata | null { } } -const CACHE_MAX_AGE_MS = 5 * 60 * 1000 // 5 minutes; cache lives in IndexedDB -/** Partial fetches (timeouts, relay issues) used to get cached as-is and hide the grid for 5 minutes. */ +const CACHE_MAX_AGE_MS = 60 * 60 * 1000 // 1 hour; short enough to stay fresh, long enough to survive browser restarts const MIN_GIF_CACHE_ENTRIES = 8 /** Ensured on the kind 1063 (NIP-94) relay set even if absent from merged read lists. */ @@ -334,6 +333,20 @@ export async function fetchGifs( return result } +/** + * Return whatever is currently in the IndexedDB GIF cache without fetching from relays. + * Used to seed the picker immediately on open; the caller can then trigger a background refresh. + */ +export async function getCachedGifs(userPubkey: string | null = null): Promise { + try { + const cached = await indexedDb.getGifCache() + if (!cached?.gifs?.length) return [] + return sortGifsForPicker(cached.gifs as GifMetadata[], userPubkey).slice(0, 50) + } catch { + return [] + } +} + /** Search GIFs by query (same as fetchGifs with query). */ export async function searchGifs( query: string, diff --git a/src/services/local-storage.service.ts b/src/services/local-storage.service.ts index 7f6c4eb5..6463d2dc 100644 --- a/src/services/local-storage.service.ts +++ b/src/services/local-storage.service.ts @@ -315,13 +315,23 @@ class LocalStorageService { showKinds.push(ExtendedKind.GIT_RELEASE) } } + if (showKindsVersion < 12) { + // Add WIKI_ARTICLE_MARKDOWN (30817) for users who already have long-form articles (30023) or + // wiki articles (30818) enabled — it was omitted from the earlier v4 migration. + if ( + (showKinds.includes(kinds.LongFormArticle) || showKinds.includes(ExtendedKind.WIKI_ARTICLE)) && + !showKinds.includes(ExtendedKind.WIKI_ARTICLE_MARKDOWN) + ) { + showKinds.push(ExtendedKind.WIKI_ARTICLE_MARKDOWN) + } + } // v9: boosts are optional in the same filter list as other kinds; do not auto-enable (leave absent). this.showKinds = showKinds // Only persist when we read from localStorage. If SHOW_KINDS is missing here (migrated to IDB and // keys cleared), persisting would write DEFAULT_FEED_SHOW_KINDS to IndexedDB and wipe the user's // saved filter before initAsync/applySettings runs. this.persistSetting(StorageKey.SHOW_KINDS, JSON.stringify(this.showKinds)) - this.persistSetting(StorageKey.SHOW_KINDS_VERSION, '11') + this.persistSetting(StorageKey.SHOW_KINDS_VERSION, '12') } // Feed filter: kind 1 OPs, kind 1 replies, kind 1111 (migrate from legacy showRepliesAndComments if set) diff --git a/src/services/meme.service.ts b/src/services/meme.service.ts index d7de6cce..6304d225 100644 --- a/src/services/meme.service.ts +++ b/src/services/meme.service.ts @@ -361,6 +361,20 @@ export async function fetchMemes( return result } +/** + * Return whatever is currently in the IndexedDB meme cache without fetching from relays. + * Used to seed the picker immediately on open; the caller can then trigger a background refresh. + */ +export async function getCachedMemes(userPubkey: string | null = null): Promise { + try { + const cached = await indexedDb.getMemeCache() + if (!cached?.memes?.length) return [] + return sortMemesForPicker(cached.memes as MemeMetadata[], userPubkey).slice(0, 50) + } catch { + return [] + } +} + export async function searchMemes( query: string, limit: number = 50, diff --git a/src/services/note-stats.service.ts b/src/services/note-stats.service.ts index cfd6acf6..53884de3 100644 --- a/src/services/note-stats.service.ts +++ b/src/services/note-stats.service.ts @@ -669,8 +669,8 @@ class NoteStatsService { return this.addZap( senderPubkey, - originalEventId, - invoice, + originalEventId!, + invoice!, amount, comment, evt.created_at, diff --git a/vite.config.ts b/vite.config.ts index ab4104de..a76d7b48 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -183,7 +183,7 @@ export default defineConfig(({ mode }) => { return 'vendor-dnd' } - if (norm.includes('highlight.js')) { + if (norm.includes('highlight.js') || norm.includes('/src/lib/highlight')) { return 'vendor-highlight' } @@ -191,7 +191,7 @@ export default defineConfig(({ mode }) => { return 'vendor-flexsearch' } - if (norm.includes('emoji-picker-react')) { + if (norm.includes('emoji-picker-element')) { return 'vendor-emoji' }