diff --git a/e2e/tests/emoji-picker.spec.ts b/e2e/tests/emoji-picker.spec.ts new file mode 100644 index 00000000..92373a8e --- /dev/null +++ b/e2e/tests/emoji-picker.spec.ts @@ -0,0 +1,42 @@ +import { readFileSync } from "node:fs"; + +import { accessTokenOf } from "../support/admin"; +import { channelComposer, fixture, membersPanel } from "../support/app"; +import { expect, test } from "../support/fixtures"; + +test("chat and card pickers put this server's custom emoji first and keep Unicode searchable", async ({ owner, gryt, request }) => { + const page = owner.page; + const token = await accessTokenOf(page, gryt.server.host); + const name = "card_test_custom"; + const upload = await request.post(`${gryt.server.httpBase}/api/emojis`, { + headers: { Authorization: `Bearer ${token}` }, + multipart: { name, file: { name: "custom.png", mimeType: "image/png", buffer: readFileSync(fixture("gradient.png")) } }, + }); + expect(upload.status(), await upload.text()).toBe(201); + await page.getByRole("button", { name: "Insert emoji", exact: true }).click(); + const chatPicker = page.locator(".gryt-emoji-picker"); + await expect(chatPicker.getByRole("button", { name: "This server", exact: true })).toBeVisible(); + await expect(chatPicker.locator("[data-emoji-category]").first()).toHaveAttribute("aria-label", "This server"); + await chatPicker.getByRole("button", { name: `:${name}:`, exact: true }).click(); + await expect(channelComposer(page).getByRole("img", { name: `:${name}:`, exact: true })).toBeVisible(); + await expect(chatPicker).toBeHidden(); + await page.getByRole("button", { name: "Insert emoji", exact: true }).click(); + await chatPicker.getByPlaceholder("Search emojis...").fill("thumbsup"); + await chatPicker.getByTitle(":thumbsup:", { exact: true }).click(); + await expect(channelComposer(page)).toContainText("👍"); + + await membersPanel(page).locator(".member-row").filter({ hasText: owner.name }).click(); + await page.getByRole("button", { name: "Edit my card", exact: true }).click(); + // Settings opens on the card page, and the editor itself is a button away. + await page.getByRole("button", { name: "Open the card editor", exact: true }).click(); + const editor = page.getByRole("dialog", { name: "Edit my card", exact: true }); + await editor.getByRole("tab", { name: "Pattern", exact: true }).click(); + await editor.getByRole("button", { name: "An emoji", exact: true }).click(); + await editor.getByRole("button", { name: /^Choose emoji/ }).click(); + const cardPicker = page.locator(".gryt-emoji-picker"); + await expect(cardPicker.locator("[data-emoji-category]").first()).toHaveAttribute("aria-label", "This server"); + await cardPicker.getByRole("button", { name: `:${name}:`, exact: true }).click(); + await expect(editor.getByRole("button", { name: /^Choose emoji/ })).toContainText("server emoji"); + await expect.poll(() => editor.locator(".gmc-frame").evaluate((element) => + (element as HTMLElement).style.getPropertyValue("--pat-img"))).toMatch(/%3Cimage| = { - "Recently Used": "🕒", - "Custom": "⭐", - "Smileys & Emotion": "😀", - "People & Body": "👋", - "Animals & Nature": "🐾", - "Food & Drink": "🍕", - "Travel & Places": "✈️", - "Activities": "⚽", - "Objects": "💡", - "Symbols": "💜", - "Flags": "🏁", -}; - -const COLS = 8; const PICKER_WIDTH = 340; const PICKER_MAX_HEIGHT = 400; const VIEWPORT_PAD = 8; const GAP = 6; -function EmojiCell({ entry, onSelect }: { entry: EmojiEntry; onSelect: (src: string) => void }) { - const src = entry.isCustom ? `:${entry.name}:` : entry.emoji!; - return ( - - ); -} - -function CategorySection({ - label, - entries, - onSelect, -}: { - label: string; - entries: EmojiEntry[]; - onSelect: (src: string) => void; -}) { - if (entries.length === 0) return null; - return ( -
-
- {CATEGORY_ICONS[label] ?? ""} - {label} -
-
- {entries.map((entry, idx) => ( - - ))} -
-
- ); -} - -function NavButton({ icon, label, onClick }: { icon: string; label: string; onClick: () => void }) { - return ( - - ); +function pickerItem(entry: EmojiEntry): EmojiPickerItem { + const name = /^[+-]\d+$/.test(entry.name) ? entry.aliases[0] ?? entry.name : entry.name; + return { id: entry.isCustom ? `server:${entry.name}` : `unicode:${entry.emoji}`, name, + emoji: entry.emoji ?? undefined, imageUrl: entry.isCustom ? entry.url : undefined, + keywords: [entry.name, ...entry.aliases, ...entry.tags] }; } export function EmojiPickerContent({ onSelect, serverHost, autoFocusSearch = true }: EmojiPickerContentProps) { - const scrollRef = useRef(null); - const [search, setSearch] = useState(""); - const inputRef = useRef(null); - - useEffect(() => { - if (autoFocusSearch) inputRef.current?.focus({ preventScroll: true }); - }, [autoFocusSearch]); - - const recentEntries = useMemo((): EmojiEntry[] => { - const recent = getRecentReactions(16, serverHost); - const customEmojis = getCustomEmojis(); - const byCategory = getStandardEmojisByCategory(); - const allStandard: EmojiEntry[] = []; - for (const entries of byCategory.values()) allStandard.push(...entries); - - return recent - .map((src): EmojiEntry | null => { - if (src.startsWith(":") && src.endsWith(":")) { - const name = src.slice(1, -1); - return customEmojis.find((e) => e.name === name) ?? null; - } - return allStandard.find((e) => e.emoji === src) ?? null; - }) - .filter((e): e is EmojiEntry => e !== null); - }, [serverHost]); - - const [customVersion, setCustomVersion] = useState(0); - useEffect(() => onCustomEmojisChange(() => setCustomVersion((v) => v + 1)), []); - // eslint-disable-next-line react-hooks/exhaustive-deps - const customEntries = useMemo(() => getCustomEmojis(), [customVersion]); - const standardCategories = useMemo(() => getStandardEmojisByCategory(), []); - - const searchResults = useMemo(() => { - if (!search.trim()) return null; - return searchEmojis(search.trim()); - }, [search]); - - const categoryNames = useMemo(() => Array.from(standardCategories.keys()), [standardCategories]); - - const scrollToCategory = useCallback((category: string) => { - const container = scrollRef.current; - if (!container) return; - const el = container.querySelector(`[data-category="${CSS.escape(category)}"]`) as HTMLElement | null; - if (!el) return; - const containerRect = container.getBoundingClientRect(); - const elRect = el.getBoundingClientRect(); - container.scrollTo({ - top: container.scrollTop + elRect.top - containerRect.top, - behavior: "smooth", + const custom = useSyncExternalStore(onCustomEmojisChange, () => getCustomEmojis(serverHost)); + const { activeTheme } = useCustomThemes(); + const { resolvedAppearance } = useTheme(); + const theme = useMemo(() => createGrytTheme(grytThemeToOptions(activeTheme ?? grytTheme, resolvedAppearance)), [activeTheme, resolvedAppearance]); + const groups = useMemo((): EmojiPickerGroup[] => { + const standard = getStandardEmojisByCategory(); + const all = [...custom, ...Array.from(standard.values()).flat()]; + const recent = getRecentReactions(16, serverHost).flatMap((src) => { + const entry = all.find((item) => item.isCustom ? src === `:${item.name}:` : src === item.emoji); + return entry ? [pickerItem(entry)] : []; }); - }, []); - + return [ + { id: "server", label: "This server", items: custom.map(pickerItem) }, + { id: "recent", label: "Recently Used", items: recent }, + ...Array.from(standard, ([label, entries]) => ({ id: label, label, items: entries.map(pickerItem) })), + ]; + }, [custom, serverHost]); return ( - <> -
- setSearch(e.target.value)} - /* - * Base UI's MenuRoot puts useTypeahead on the popup, which - * preventDefaults every single-character key. Bubble phase only. - */ - onKeyDown={(e) => e.stopPropagation()} - style={{ - width: "100%", - padding: "6px 10px", - border: "1px solid var(--gryt-neutral-6)", - borderRadius: 8, - background: "var(--gryt-neutral-2)", - color: "var(--gryt-neutral-12)", - fontSize: 13, - outline: "none", - boxSizing: "border-box", - }} - onFocus={(e) => { e.currentTarget.style.borderColor = "var(--gryt-accent-8)"; }} - onBlur={(e) => { e.currentTarget.style.borderColor = "var(--gryt-neutral-6)"; }} - /> -
- - {!searchResults && ( -
- {recentEntries.length > 0 && ( - scrollToCategory("Recently Used")} /> - )} - {customEntries.length > 0 && ( - scrollToCategory("Custom")} /> - )} - {categoryNames.map((cat) => ( - scrollToCategory(cat)} /> - ))} -
- )} - -
- {searchResults ? ( -
- {searchResults.map((entry, idx) => ( - - ))} - {searchResults.length === 0 && ( -
- No emojis found -
- )} -
- ) : ( - <> - {recentEntries.length > 0 && ( -
- -
- )} - {customEntries.length > 0 && ( -
- -
- )} - {categoryNames.map((cat) => ( -
- -
- ))} - - )} -
- + + { if (event.target instanceof HTMLInputElement) event.stopPropagation(); }} + onSelect={(item) => onSelect(item.id.startsWith("server:") ? `:${item.id.slice(7)}:` : item.emoji ?? "")} + /> + ); } export const EmojiPicker = ({ onSelect, onClose, anchorEl, placement = "above", serverHost }: EmojiPickerProps) => { const containerRef = useRef(null); const [fixedPos, setFixedPos] = useState<{ top?: number; bottom?: number; left: number }>({ left: 0 }); - useLayoutEffect(() => { const anchor = anchorEl ?? containerRef.current?.parentElement; if (!(anchor instanceof HTMLElement)) return; - const compute = () => { const rect = anchor.getBoundingClientRect(); - if (placement === "beside") { - let top: number = rect.top; - if (top + PICKER_MAX_HEIGHT > window.innerHeight - VIEWPORT_PAD) { - top = window.innerHeight - PICKER_MAX_HEIGHT - VIEWPORT_PAD; - } + let top = rect.top; + if (top + PICKER_MAX_HEIGHT > window.innerHeight - VIEWPORT_PAD) top = window.innerHeight - PICKER_MAX_HEIGHT - VIEWPORT_PAD; if (top < VIEWPORT_PAD) top = VIEWPORT_PAD; - - const spaceRight = window.innerWidth - rect.right - VIEWPORT_PAD; - const spaceLeft = rect.left - VIEWPORT_PAD; + const right = window.innerWidth - rect.right - VIEWPORT_PAD; + const leftSpace = rect.left - VIEWPORT_PAD; let left: number; - - if (spaceRight >= PICKER_WIDTH + GAP) { - left = rect.right + GAP; - } else if (spaceLeft >= PICKER_WIDTH + GAP) { - left = rect.left - PICKER_WIDTH - GAP; - } else { + if (right >= PICKER_WIDTH + GAP) left = rect.right + GAP; + else if (leftSpace >= PICKER_WIDTH + GAP) left = rect.left - PICKER_WIDTH - GAP; + else { left = rect.left + rect.width / 2 - PICKER_WIDTH / 2; if (left < VIEWPORT_PAD) left = VIEWPORT_PAD; - if (left + PICKER_WIDTH > window.innerWidth - VIEWPORT_PAD) { - left = window.innerWidth - PICKER_WIDTH - VIEWPORT_PAD; - } + if (left + PICKER_WIDTH > window.innerWidth - VIEWPORT_PAD) left = window.innerWidth - PICKER_WIDTH - VIEWPORT_PAD; } - setFixedPos({ top, bottom: undefined, left }); } else { - const spaceAbove = rect.top; - const spaceBelow = window.innerHeight - rect.bottom; - - let top: number | undefined; - let bottom: number | undefined; - if (spaceAbove >= PICKER_MAX_HEIGHT || spaceAbove >= spaceBelow) { - bottom = window.innerHeight - rect.top + GAP; - } else { - top = rect.bottom + GAP; - } - + const above = rect.top; + const below = window.innerHeight - rect.bottom; + const useAbove = above >= PICKER_MAX_HEIGHT || above >= below; let left = rect.right - PICKER_WIDTH; if (left < VIEWPORT_PAD) left = VIEWPORT_PAD; - if (left + PICKER_WIDTH > window.innerWidth - VIEWPORT_PAD) { - left = window.innerWidth - PICKER_WIDTH - VIEWPORT_PAD; - } - - setFixedPos({ top, bottom, left }); + if (left + PICKER_WIDTH > window.innerWidth - VIEWPORT_PAD) left = window.innerWidth - PICKER_WIDTH - VIEWPORT_PAD; + setFixedPos({ top: useAbove ? undefined : rect.bottom + GAP, + bottom: useAbove ? window.innerHeight - rect.top + GAP : undefined, left }); } }; - compute(); window.addEventListener("resize", compute); window.addEventListener("scroll", compute, true); @@ -369,64 +106,32 @@ export const EmojiPicker = ({ onSelect, onClose, anchorEl, placement = "above", window.removeEventListener("scroll", compute, true); }; }, [anchorEl, placement]); - useEffect(() => { - const handleEscape = (e: KeyboardEvent) => { - if (e.key === "Escape") { - e.preventDefault(); - e.stopPropagation(); - onClose(); - } + const escape = (event: KeyboardEvent) => { + if (event.key !== "Escape") return; + event.preventDefault(); + event.stopPropagation(); + onClose(); }; - document.addEventListener("keydown", handleEscape, true); - return () => document.removeEventListener("keydown", handleEscape, true); + document.addEventListener("keydown", escape, true); + return () => document.removeEventListener("keydown", escape, true); }, [onClose]); - useEffect(() => { - const handleClickOutside = (e: MouseEvent) => { - if (containerRef.current && !containerRef.current.contains(e.target as Node)) { - onClose(); - } + const outside = (event: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(event.target as Node)) onClose(); }; - const timer = setTimeout(() => { - document.addEventListener("mousedown", handleClickOutside); - }, 0); + const timer = setTimeout(() => document.addEventListener("mousedown", outside), 0); return () => { clearTimeout(timer); - document.removeEventListener("mousedown", handleClickOutside); + document.removeEventListener("mousedown", outside); }; }, [onClose]); - - const handleSelect = useCallback( - (src: string) => { - onSelect(src); - onClose(); - }, - [onSelect, onClose], - ); - + const select = useCallback((src: string) => { onSelect(src); onClose(); }, [onSelect, onClose]); return ( -
e.stopPropagation()} - > - +
event.stopPropagation()}> +
); }; diff --git a/src/packages/socket/src/components/memberCard/cardEmojiGroups.ts b/src/packages/socket/src/components/memberCard/cardEmojiGroups.ts index aa9fdb07..86364844 100644 --- a/src/packages/socket/src/components/memberCard/cardEmojiGroups.ts +++ b/src/packages/socket/src/components/memberCard/cardEmojiGroups.ts @@ -4,8 +4,11 @@ import { useMemo, useSyncExternalStore } from "react"; import { getCustomEmojis, onCustomEmojisChange } from "../../utils/emojiData"; /** Custom emoji from the server currently open in the client. */ -export function useCardEmojiGroups(): readonly EmojiPickerGroup[] { - const custom = useSyncExternalStore(onCustomEmojisChange, getCustomEmojis); +export function useCardEmojiGroups(serverHost?: string): readonly EmojiPickerGroup[] { + const custom = useSyncExternalStore( + onCustomEmojisChange, + () => getCustomEmojis(serverHost), + ); return useMemo(() => { if (custom.length === 0) return []; return [{ diff --git a/src/packages/socket/src/utils/emojiData.ts b/src/packages/socket/src/utils/emojiData.ts index 7ede7871..9a2f789a 100644 --- a/src/packages/socket/src/utils/emojiData.ts +++ b/src/packages/socket/src/utils/emojiData.ts @@ -62,7 +62,9 @@ export function getStandardEmojisByCategory(): Map(); type EmojiChangeListener = () => void; const emojiChangeListeners = new Set(); @@ -77,7 +79,7 @@ export function getCustomEmojiUrl(serverHost: string, name: string): string { } export function setCustomEmojis(emojis: { name: string; file_id: string }[], serverHost: string): void { - customEmojisCache = emojis.map((e) => ({ + const next = emojis.map((e) => ({ name: e.name, emoji: null, isCustom: true, @@ -85,15 +87,17 @@ export function setCustomEmojis(emojis: { name: string; file_id: string }[], ser tags: [], aliases: [], })); + customEmojisByServer.set(serverHost, next); + customEmojisCache = next; emojiChangeListeners.forEach((fn) => fn()); } -export function getCustomEmojis(): EmojiEntry[] { - return customEmojisCache; +export function getCustomEmojis(serverHost?: string): EmojiEntry[] { + return serverHost ? (customEmojisByServer.get(serverHost) ?? EMPTY_CUSTOM_EMOJIS) : customEmojisCache; } -export function getAllEmojis(): EmojiEntry[] { - return [...getStandardEmojis(), ...customEmojisCache]; +export function getAllEmojis(serverHost?: string): EmojiEntry[] { + return [...getStandardEmojis(), ...getCustomEmojis(serverHost)]; } const enum MatchTier { @@ -108,10 +112,10 @@ interface ScoredEntry { tier: MatchTier; } -export function searchEmojis(query: string, limit = 0): EmojiEntry[] { +export function searchEmojis(query: string, limit = 0, serverHost?: string): EmojiEntry[] { if (!query) return []; const q = query.toLowerCase(); - const all = getAllEmojis(); + const all = getAllEmojis(serverHost); const scored: ScoredEntry[] = []; for (const entry of all) {