Spaces:
Runtime error
Runtime error
| <script lang="ts"> | |
| import VirtualList from "@sveltejs/svelte-virtual-list"; | |
| import { getContext } from "svelte"; | |
| import { REXPRO_BASE_URL } from "$lib/constants"; | |
| import Dropdown from "$lib/components/common/Dropdown.svelte"; | |
| import Tooltip from "$lib/components/common/Tooltip.svelte"; | |
| import emojiGroups from "$lib/emoji-groups.json"; | |
| import emojiShortCodes from "$lib/emoji-shortcodes.json"; | |
| import { settings } from "$lib/stores"; | |
| import { updateUserSettings } from "$lib/apis/users"; | |
| const i18n = getContext("i18n"); | |
| export let onClose = () => {}; | |
| export let onSubmit = (name) => {}; | |
| export let side = "top"; | |
| export let align = "start"; | |
| export let user = null; | |
| export let selected = null; | |
| const MAX_RECENT = 30; | |
| let show = false; | |
| let emojis = emojiShortCodes; | |
| let search = ""; | |
| let flattenedEmojis = []; | |
| let emojiRows = []; | |
| let saveDebounceTimer: ReturnType<typeof setTimeout> | null = null; | |
| $: recentEmojiNames = ($settings?.recentEmojis ?? []) | |
| .filter((name) => emojiShortCodes[name]) | |
| .slice(0, MAX_RECENT); | |
| function saveRecentEmoji(emojiName: string) { | |
| // Remove if already present, then prepend | |
| const updated = [ | |
| emojiName, | |
| ...recentEmojiNames.filter((n) => n !== emojiName), | |
| ].slice(0, MAX_RECENT); | |
| // Update store immediately (reactive UI) | |
| settings.set({ ...$settings, recentEmojis: updated }); | |
| // Debounce backend save (avoid API spam on rapid picks) | |
| if (saveDebounceTimer) clearTimeout(saveDebounceTimer); | |
| saveDebounceTimer = setTimeout(async () => { | |
| await updateUserSettings(localStorage.token, { | |
| ui: { ...$settings, recentEmojis: updated }, | |
| }); | |
| }, 1000); | |
| } | |
| // Reactive statement to filter the emojis based on search query | |
| $: { | |
| if (search) { | |
| emojis = Object.keys(emojiShortCodes).reduce((acc, key) => { | |
| if (key.includes(search.toLowerCase())) { | |
| acc[key] = emojiShortCodes[key]; | |
| } else { | |
| if (Array.isArray(emojiShortCodes[key])) { | |
| const filtered = emojiShortCodes[key].filter((emoji) => | |
| emoji.includes(search.toLowerCase()), | |
| ); | |
| if (filtered.length) { | |
| acc[key] = filtered; | |
| } | |
| } else { | |
| if ( | |
| emojiShortCodes[key].includes(search.toLowerCase()) | |
| ) { | |
| acc[key] = emojiShortCodes[key]; | |
| } | |
| } | |
| } | |
| return acc; | |
| }, {}); | |
| } else { | |
| emojis = emojiShortCodes; | |
| } | |
| } | |
| // Flatten emoji groups and group them into rows of 8 for virtual scrolling | |
| $: { | |
| flattenedEmojis = []; | |
| // Add "Recently Used" group first (only when not searching) | |
| if (!search && recentEmojiNames.length > 0) { | |
| flattenedEmojis.push({ | |
| type: "group", | |
| label: $i18n.t("Recently Used"), | |
| }); | |
| flattenedEmojis.push( | |
| ...recentEmojiNames.map((emoji) => ({ | |
| type: "emoji", | |
| name: emoji, | |
| shortCodes: | |
| typeof emojiShortCodes[emoji] === "string" | |
| ? [emojiShortCodes[emoji]] | |
| : emojiShortCodes[emoji], | |
| })), | |
| ); | |
| } | |
| Object.keys(emojiGroups).forEach((group) => { | |
| const groupEmojis = emojiGroups[group].filter( | |
| (emoji) => emojis[emoji], | |
| ); | |
| if (groupEmojis.length > 0) { | |
| flattenedEmojis.push({ type: "group", label: group }); | |
| flattenedEmojis.push( | |
| ...groupEmojis.map((emoji) => ({ | |
| type: "emoji", | |
| name: emoji, | |
| shortCodes: | |
| typeof emojiShortCodes[emoji] === "string" | |
| ? [emojiShortCodes[emoji]] | |
| : emojiShortCodes[emoji], | |
| })), | |
| ); | |
| } | |
| }); | |
| // Group emojis into rows of 8 | |
| emojiRows = []; | |
| let currentRow = []; | |
| flattenedEmojis.forEach((item) => { | |
| if (item.type === "emoji") { | |
| currentRow.push(item); | |
| if (currentRow.length === 8) { | |
| emojiRows.push(currentRow); | |
| currentRow = []; | |
| } | |
| } else if (item.type === "group") { | |
| if (currentRow.length > 0) { | |
| emojiRows.push(currentRow); // Push the remaining row | |
| currentRow = []; | |
| } | |
| emojiRows.push([item]); // Add the group label as a separate row | |
| } | |
| }); | |
| if (currentRow.length > 0) { | |
| emojiRows.push(currentRow); // Push the final row | |
| } | |
| } | |
| const ROW_HEIGHT = 48; // Approximate height for a row with multiple emojis | |
| // Handle emoji selection | |
| function selectEmoji(emoji) { | |
| const selectedCode = emoji.shortCodes[0]; | |
| saveRecentEmoji(emoji.name); | |
| if (selected === selectedCode) { | |
| onSubmit(null); | |
| } else { | |
| onSubmit(selectedCode); | |
| } | |
| show = false; | |
| } | |
| </script> | |
| <Dropdown | |
| bind:show | |
| {align} | |
| onOpenChange={(state) => { | |
| if (state === false) { | |
| search = ""; | |
| onClose(); | |
| } | |
| }} | |
| > | |
| <slot /> | |
| <div slot="content"> | |
| <div | |
| class="max-w-full w-80 border border-gray-100 dark:border-gray-800 bg-white dark:bg-gray-850 rounded-3xl z-9999 shadow-lg dark:text-white" | |
| > | |
| <div class="mb-1 px-4 pt-2.5 pb-2"> | |
| <input | |
| type="text" | |
| class="w-full text-sm bg-transparent outline-hidden" | |
| placeholder={$i18n.t("Search all emojis")} | |
| bind:value={search} | |
| /> | |
| </div> | |
| <!-- Virtualized Emoji List --> | |
| <div | |
| class="w-full flex justify-start h-96 overflow-y-auto px-3 pb-3 text-sm" | |
| > | |
| {#if emojiRows.length === 0} | |
| <div | |
| class="text-center text-xs text-gray-500 dark:text-gray-400" | |
| > | |
| {$i18n.t("No results")} | |
| </div> | |
| {:else} | |
| <div class="w-full flex ml-0.5"> | |
| <VirtualList | |
| rowHeight={ROW_HEIGHT} | |
| items={emojiRows} | |
| height={384} | |
| let:item | |
| > | |
| <div class="w-full mb-2.5"> | |
| {#if item.length === 1 && item[0].type === "group"} | |
| <!-- Render group header --> | |
| <div | |
| class="text-xs font-medium -mb-1 text-gray-500 dark:text-gray-400" | |
| > | |
| {item[0].label} | |
| </div> | |
| {:else} | |
| <!-- Render emojis in a row --> | |
| <div | |
| class="flex items-center gap-1.5 w-full" | |
| > | |
| {#each item as emojiItem} | |
| <Tooltip | |
| content={emojiItem.shortCodes | |
| .map((code) => `:${code}:`) | |
| .join(", ")} | |
| placement="top" | |
| > | |
| <button | |
| class="p-1.5 rounded-lg cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-700 transition {selected === | |
| emojiItem.shortCodes[0] | |
| ? 'bg-gray-200 dark:bg-gray-700' | |
| : ''}" | |
| on:click={() => | |
| selectEmoji(emojiItem)} | |
| > | |
| <img | |
| src="{REXPRO_BASE_URL}/assets/emojis/{emojiItem.name.toLowerCase()}.svg" | |
| alt={emojiItem.name} | |
| class="size-5" | |
| loading="lazy" | |
| /> | |
| </button> | |
| </Tooltip> | |
| {/each} | |
| </div> | |
| {/if} | |
| </div> | |
| </VirtualList> | |
| </div> | |
| {/if} | |
| </div> | |
| </div> | |
| </div> | |
| </Dropdown> | |