ai / frontend /src /lib /components /common /EmojiPicker.svelte
fdrah
updt4
9ed2e2f
Raw
History Blame Contribute Delete
6.67 kB
<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>