Browse Source

clean up build

imwald
Silberengel 3 weeks ago
parent
commit
1f4416369d
  1. 37
      package-lock.json
  2. 4
      package.json
  3. 191
      src/components/EmojiPicker/index.tsx
  4. 47
      src/components/GifPicker/index.tsx
  5. 1
      src/components/KindFilter/index.tsx
  6. 48
      src/components/MemePicker/index.tsx
  7. 2
      src/components/Note/AsciidocArticle/AsciidocArticle.tsx
  8. 2
      src/components/Note/MarkdownArticle/MarkdownArticle.tsx
  9. 28
      src/components/SuggestedEmojis/index.tsx
  10. 6
      src/lib/highlight.ts
  11. 19
      src/lib/like-reaction-emojis.ts
  12. 28
      src/lib/recently-used-emojis.ts
  13. 5
      src/lib/utils.ts
  14. 60
      src/services/custom-emoji.service.ts
  15. 17
      src/services/gif.service.ts
  16. 12
      src/services/local-storage.service.ts
  17. 14
      src/services/meme.service.ts
  18. 4
      src/services/note-stats.service.ts
  19. 4
      vite.config.ts

37
package-lock.json generated

@ -1,12 +1,12 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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": {

4
package.json

@ -1,6 +1,6 @@ @@ -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 @@ @@ -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",

191
src/components/EmojiPicker/index.tsx

@ -1,87 +1,162 @@ @@ -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<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 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 (
<EmojiPickerReact
theme={
themeSetting === 'system' ? Theme.AUTO : themeSetting === 'dark' ? Theme.DARK : Theme.LIGHT
useEffect(() => {
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 (
<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)
}}
>
<img src={emoji.url} alt={emoji.shortcode} className="w-6 h-6 object-contain" />
</button>
))}
</div>
)}
<div ref={containerRef} />
</div>
)
}

47
src/components/GifPicker/index.tsx

@ -17,6 +17,7 @@ import { cn } from '@/lib/utils' @@ -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' @@ -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({ @@ -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<GifMetadata[]>([])
// Initialise from the module-level session cache so re-opens are instant
const [gifs, setGifsState] = useState<GifMetadata[]>(() => _sessionGifs)
const gifsRef = useRef<GifMetadata[]>(_sessionGifs)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [uploading, setUploading] = useState(false)
@ -93,15 +99,42 @@ export default function GifPicker({ @@ -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({ @@ -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

1
src/components/KindFilter/index.tsx

@ -17,6 +17,7 @@ const KIND_1 = kinds.ShortTextNote @@ -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' },

48
src/components/MemePicker/index.tsx

@ -16,6 +16,7 @@ import { ExtendedKind, GIF_RELAY_URLS } from '@/constants' @@ -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 { @@ -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({ @@ -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<MemeMetadata[]>([])
// Initialise from the module-level session cache so re-opens are instant
const [memes, setMemesState] = useState<MemeMetadata[]>(() => _sessionMemes)
const memesRef = useRef<MemeMetadata[]>(_sessionMemes)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [uploading, setUploading] = useState(false)
@ -83,16 +89,38 @@ export default function MemePicker({ @@ -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({ @@ -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({ @@ -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('')

2
src/components/Note/AsciidocArticle/AsciidocArticle.tsx

@ -1764,7 +1764,7 @@ export default function AsciidocArticle({ @@ -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

2
src/components/Note/MarkdownArticle/MarkdownArticle.tsx

@ -334,7 +334,7 @@ function CodeBlock({ id, code, language }: { id: string; code: string; language: @@ -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

28
src/components/SuggestedEmojis/index.tsx

@ -1,8 +1,7 @@ @@ -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({ @@ -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<string>()
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
}

6
src/lib/highlight.ts

@ -0,0 +1,6 @@ @@ -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'

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

@ -1,19 +1,8 @@ @@ -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

28
src/lib/recently-used-emojis.ts

@ -0,0 +1,28 @@ @@ -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
}
}

5
src/lib/utils.ts

@ -1,8 +1,11 @@ @@ -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))
}

60
src/services/custom-emoji.service.ts

@ -1,10 +1,8 @@ @@ -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 { @@ -115,20 +113,8 @@ class CustomEmojiService {
async searchEmojis(query: string = '', viewerPubkey?: string | null): Promise<string[]> {
const v = viewerPubkey?.toLowerCase() ?? ''
if (!query) {
const idSet = new Set<string>()
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 { @@ -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 { @@ -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 { @@ -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)
}
}

17
src/services/gif.service.ts

@ -217,8 +217,7 @@ function parseGifFromEvent(event: NEvent): GifMetadata | null { @@ -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( @@ -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<GifMetadata[]> {
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,

12
src/services/local-storage.service.ts

@ -315,13 +315,23 @@ class LocalStorageService { @@ -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)

14
src/services/meme.service.ts

@ -361,6 +361,20 @@ export async function fetchMemes( @@ -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<MemeMetadata[]> {
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,

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

@ -669,8 +669,8 @@ class NoteStatsService { @@ -669,8 +669,8 @@ class NoteStatsService {
return this.addZap(
senderPubkey,
originalEventId,
invoice,
originalEventId!,
invoice!,
amount,
comment,
evt.created_at,

4
vite.config.ts

@ -183,7 +183,7 @@ export default defineConfig(({ mode }) => { @@ -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 }) => { @@ -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'
}

Loading…
Cancel
Save