Files
cinny/src/app/components/emoji-board/EmojiBoard.tsx
T
Lotus CIandClaude Opus 5.5 f7bdd2855a
CI / Build & Quality Checks (pull_request) Successful in 2m46s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 13s
CI / Playwright smoke (e2e) (pull_request) Successful in 15m19s
fix(composer): Escape closes the emoji and sticker boards (#304)
The board opens with focus in its search field, and its FocusTrap used
the shared stopPropagation helper, which ignores Escape while an input
is focused, so Escape never closed it (Chromium, Firefox, WebKit).
Always close on Escape here, still stopping the key from closing the
room or modal underneath. The GIF picker already uses the default.

Fixes #304

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-17 20:06:41 -04:00

712 lines
22 KiB
TypeScript

import React, {
ChangeEventHandler,
FocusEventHandler,
MouseEventHandler,
ReactNode,
RefObject,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { Box, config, Icons, Scroll } from 'folds';
import FocusTrap from 'focus-trap-react';
import { isKeyHotkey } from 'is-hotkey';
import { Room } from 'matrix-js-sdk';
import { atom, PrimitiveAtom, useAtom, useAtomValue, useSetAtom } from 'jotai';
import { useVirtualizer } from '@tanstack/react-virtual';
import { EmojiData, IEmoji, emojiGroups, emojis, loadEmojiData } from '../../plugins/emoji';
import { useEmojiGroupLabels } from './useEmojiGroupLabels';
import { useEmojiGroupIcons } from './useEmojiGroupIcons';
import { preventScrollWithArrowKey } from '../../utils/keyboard';
import { useRelevantImagePacks } from '../../hooks/useImagePacks';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRecentEmoji } from '../../hooks/useRecentEmoji';
import { isUserId, mxcUrlToHttp } from '../../utils/matrix';
import { editableActiveElement, targetFromEvent } from '../../utils/dom';
import { useAsyncSearch, UseAsyncSearchOptions } from '../../hooks/useAsyncSearch';
import { useDebounce } from '../../hooks/useDebounce';
import { useThrottle } from '../../hooks/useThrottle';
import { addRecentEmoji } from '../../plugins/recent-emoji';
import { addRecentSticker, recentStickersAtom } from '../../state/recentStickers';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useRecentTouch } from '../../hooks/useRecentTouch';
import { TapToSendBar } from '../tap-to-send/TapToSendBar';
import { ImagePack, ImageUsage, PackImageReader } from '../../plugins/custom-emoji';
import { getEmoticonSearchStr } from '../../plugins/utils';
import {
SearchInput,
EmojiBoardTabs,
SidebarStack,
SidebarDivider,
Sidebar,
NoStickerPacks,
createPreviewDataAtom,
Preview,
PreviewData,
EmojiItem,
StickerItem,
CustomEmojiItem,
ImageGroupIcon,
GroupIcon,
getEmojiItemInfo,
EmojiGroup,
EmojiBoardLayout,
} from './components';
import { EmojiBoardTab, EmojiItemInfo, EmojiType } from './types';
import { VirtualTile } from '../virtualizer';
const RECENT_GROUP_ID = 'recent_group';
const SEARCH_GROUP_ID = 'search_group';
// Stable empty pack list for hideCustomEmojis (unicode-only) mode — a fresh []
// each render would needlessly re-run the memoized group/search builders.
const NO_IMAGE_PACKS: ImagePack[] = [];
/**
* Lazily pull in the emojibase data (see plugins/emoji `loadEmojiData`). The
* `emojis`/`emojiGroups` arrays are populated in place once the promise
* resolves; we wrap them in a fresh object on load so React re-renders and the
* board fills in. Before that, both are empty and the board shows only custom
* image packs / recents (which is fleeting — the load starts on mount).
*/
const useEmojiData = (): EmojiData => {
const [data, setData] = useState<EmojiData>(() => ({ emojis, emojiGroups }));
useEffect(() => {
let alive = true;
loadEmojiData()
// Fresh array references (not just a fresh wrapper): downstream memos
// depend on the arrays themselves, which are populated IN PLACE — same
// refs would skip recompute and leave emoji search empty until remount.
.then((loaded) => {
if (alive)
setData({ emojis: loaded.emojis.slice(), emojiGroups: loaded.emojiGroups.slice() });
})
.catch(() => undefined);
return () => {
alive = false;
};
}, []);
return data;
};
type EmojiGroupItem = {
id: string;
name: string;
items: Array<IEmoji | PackImageReader>;
};
type StickerGroupItem = {
id: string;
name: string;
items: Array<PackImageReader>;
};
const useGroups = (
tab: EmojiBoardTab,
imagePacks: ImagePack[],
hideCustomEmojis?: boolean,
): [EmojiGroupItem[], StickerGroupItem[]] => {
const mx = useMatrixClient();
const recentEmojis = useRecentEmoji(mx, 21);
const recentStickers = useAtomValue(recentStickersAtom);
const labels = useEmojiGroupLabels();
const { emojiGroups: loadedEmojiGroups } = useEmojiData();
const emojiGroupItems = useMemo(() => {
const g: EmojiGroupItem[] = [];
if (tab !== EmojiBoardTab.Emoji) return g;
g.push({
id: RECENT_GROUP_ID,
name: 'Recent',
// In unicode-only mode drop recently-used CUSTOM emojis (PackImageReader);
// keep only unicode ones (IEmoji has `unicode`).
items: hideCustomEmojis ? recentEmojis.filter((e) => 'unicode' in e) : recentEmojis,
});
imagePacks.forEach((pack) => {
let label = pack.meta.name;
if (!label) label = isUserId(pack.id) ? 'Personal Pack' : mx.getRoom(pack.id)?.name;
g.push({
id: pack.id,
name: label ?? 'Unknown',
items: pack
.getImages(ImageUsage.Emoticon)
.sort((a, b) => a.shortcode.localeCompare(b.shortcode)),
});
});
loadedEmojiGroups.forEach((group) => {
g.push({
id: group.id,
name: labels[group.id],
items: group.emojis,
});
});
return g;
}, [mx, recentEmojis, labels, imagePacks, tab, loadedEmojiGroups, hideCustomEmojis]);
const stickerGroupItems = useMemo(() => {
const g: StickerGroupItem[] = [];
if (tab !== EmojiBoardTab.Sticker) return g;
if (recentStickers.length > 0) {
g.push({
id: RECENT_GROUP_ID,
name: 'Recent',
// StickerItem only reads url/shortcode/body, so a minimal PackImageReader
// reconstructed from the stored data renders and re-sends correctly.
items: recentStickers.map((s) => new PackImageReader(s.shortcode, s.url, { body: s.body })),
});
}
imagePacks.forEach((pack) => {
let label = pack.meta.name;
if (!label) label = isUserId(pack.id) ? 'Personal Pack' : mx.getRoom(pack.id)?.name;
g.push({
id: pack.id,
name: label ?? 'Unknown',
items: pack
.getImages(ImageUsage.Sticker)
.sort((a, b) => a.shortcode.localeCompare(b.shortcode)),
});
});
return g;
}, [mx, imagePacks, tab, recentStickers]);
return [emojiGroupItems, stickerGroupItems];
};
const useItemRenderer = (tab: EmojiBoardTab) => {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const renderItem = (emoji: IEmoji | PackImageReader, index: number) => {
if ('unicode' in emoji) {
return <EmojiItem key={emoji.unicode + index} emoji={emoji} />;
}
if (tab === EmojiBoardTab.Sticker) {
return (
<StickerItem
key={emoji.shortcode + index}
mx={mx}
useAuthentication={useAuthentication}
image={emoji}
/>
);
}
return (
<CustomEmojiItem
key={emoji.shortcode + index}
mx={mx}
useAuthentication={useAuthentication}
image={emoji}
/>
);
};
return renderItem;
};
type EmojiSidebarProps = {
activeGroupAtom: PrimitiveAtom<string | undefined>;
packs: ImagePack[];
onScrollToGroup: (groupId: string) => void;
};
function EmojiSidebar({ activeGroupAtom, packs, onScrollToGroup }: EmojiSidebarProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [activeGroupId, setActiveGroupId] = useAtom(activeGroupAtom);
const usage = ImageUsage.Emoticon;
const labels = useEmojiGroupLabels();
const icons = useEmojiGroupIcons();
const { emojiGroups: loadedEmojiGroups } = useEmojiData();
const packLabels = useMemo(() => {
const map = new Map<string, string | undefined>();
packs.forEach((pack) => {
let label = pack.meta.name;
if (!label) label = isUserId(pack.id) ? 'Personal Pack' : mx.getRoom(pack.id)?.name;
map.set(pack.id, label);
});
return map;
}, [mx, packs]);
const handleScrollToGroup = (groupId: string) => {
setActiveGroupId(groupId);
onScrollToGroup(groupId);
};
return (
<Sidebar>
<SidebarStack>
<GroupIcon
active={activeGroupId === RECENT_GROUP_ID}
id={RECENT_GROUP_ID}
label="Recent"
icon={Icons.RecentClock}
onClick={handleScrollToGroup}
/>
</SidebarStack>
{packs.length > 0 && (
<SidebarStack>
<SidebarDivider />
{packs.map((pack) => {
const label = packLabels.get(pack.id);
const url =
mxcUrlToHttp(mx, pack.getAvatarUrl(usage) ?? '', useAuthentication) ?? undefined;
return (
<ImageGroupIcon
key={pack.id}
active={activeGroupId === pack.id}
id={pack.id}
label={label ?? 'Unknown Pack'}
url={url}
onClick={handleScrollToGroup}
/>
);
})}
</SidebarStack>
)}
<SidebarStack
style={{
position: 'sticky',
bottom: '-67%',
zIndex: 1,
}}
>
<SidebarDivider />
{loadedEmojiGroups.map((group) => (
<GroupIcon
key={group.id}
active={activeGroupId === group.id}
id={group.id}
label={labels[group.id]}
icon={icons[group.id]}
onClick={handleScrollToGroup}
/>
))}
</SidebarStack>
</Sidebar>
);
}
type StickerSidebarProps = {
activeGroupAtom: PrimitiveAtom<string | undefined>;
packs: ImagePack[];
onScrollToGroup: (groupId: string) => void;
};
function StickerSidebar({ activeGroupAtom, packs, onScrollToGroup }: StickerSidebarProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [activeGroupId, setActiveGroupId] = useAtom(activeGroupAtom);
const recentStickers = useAtomValue(recentStickersAtom);
const usage = ImageUsage.Sticker;
const packLabels = useMemo(() => {
const map = new Map<string, string | undefined>();
packs.forEach((pack) => {
let label = pack.meta.name;
if (!label) label = isUserId(pack.id) ? 'Personal Pack' : mx.getRoom(pack.id)?.name;
map.set(pack.id, label);
});
return map;
}, [mx, packs]);
const handleScrollToGroup = (groupId: string) => {
setActiveGroupId(groupId);
onScrollToGroup(groupId);
};
return (
<Sidebar>
{recentStickers.length > 0 && (
<SidebarStack>
<GroupIcon
active={activeGroupId === RECENT_GROUP_ID}
id={RECENT_GROUP_ID}
label="Recent"
icon={Icons.RecentClock}
onClick={handleScrollToGroup}
/>
</SidebarStack>
)}
<SidebarStack>
{packs.map((pack) => {
const label = packLabels.get(pack.id);
const url =
mxcUrlToHttp(mx, pack.getAvatarUrl(usage) ?? '', useAuthentication) ?? undefined;
return (
<ImageGroupIcon
key={pack.id}
active={activeGroupId === pack.id}
id={pack.id}
label={label ?? 'Unknown Pack'}
url={url}
onClick={handleScrollToGroup}
/>
);
})}
</SidebarStack>
</Sidebar>
);
}
type EmojiGroupHolderProps = {
contentScrollRef: RefObject<HTMLDivElement>;
previewAtom: PrimitiveAtom<PreviewData | undefined>;
children?: ReactNode;
onGroupItemClick: MouseEventHandler;
};
function EmojiGroupHolder({
contentScrollRef,
previewAtom,
onGroupItemClick,
children,
}: EmojiGroupHolderProps) {
const setPreviewData = useSetAtom(previewAtom);
const handleEmojiPreview = useCallback(
(element: HTMLButtonElement) => {
const emojiInfo = getEmojiItemInfo(element);
if (!emojiInfo) return;
setPreviewData({
key: emojiInfo.data,
shortcode: emojiInfo.shortcode,
});
},
[setPreviewData],
);
const throttleEmojiHover = useThrottle(handleEmojiPreview, {
wait: 200,
immediate: true,
});
const handleEmojiHover: MouseEventHandler = (evt) => {
const targetEl = targetFromEvent(evt.nativeEvent, 'button') as HTMLButtonElement | undefined;
if (!targetEl) return;
throttleEmojiHover(targetEl);
};
const handleEmojiFocus: FocusEventHandler = (evt) => {
const targetEl = evt.target as HTMLButtonElement;
handleEmojiPreview(targetEl);
};
return (
<Scroll ref={contentScrollRef} size="400" onKeyDown={preventScrollWithArrowKey} hideTrack>
<Box
onClick={onGroupItemClick}
onMouseMove={handleEmojiHover}
onFocus={handleEmojiFocus}
direction="Column"
>
{children}
</Box>
</Scroll>
);
}
const DefaultEmojiPreview: PreviewData = { key: '🙂', shortcode: 'slight_smile' };
const SEARCH_OPTIONS: UseAsyncSearchOptions = {
limit: 1000,
matchOptions: {
contain: true,
},
};
const VIRTUAL_OVER_SCAN = 2;
type EmojiBoardProps = {
tab?: EmojiBoardTab;
onTabChange?: (tab: EmojiBoardTab) => void;
imagePackRooms: Room[];
requestClose: () => void;
returnFocusOnDeactivate?: boolean;
onEmojiSelect?: (unicode: string, shortcode: string) => void;
onCustomEmojiSelect?: (mxc: string, shortcode: string) => void;
onStickerSelect?: (mxc: string, shortcode: string, label: string) => void;
allowTextCustomEmoji?: boolean;
addToRecentEmoji?: boolean;
// Unicode-only mode: hide custom/image-pack emojis (packs, sidebar icons,
// search, and custom entries in Recent). For targets that can only hold plain
// text — e.g. the presence status message, which can't render an mxc image —
// so users only see emojis that actually insert.
hideCustomEmojis?: boolean;
};
export function EmojiBoard({
tab = EmojiBoardTab.Emoji,
onTabChange,
imagePackRooms,
requestClose,
returnFocusOnDeactivate,
onEmojiSelect,
onCustomEmojiSelect,
onStickerSelect,
allowTextCustomEmoji,
addToRecentEmoji = true,
hideCustomEmojis,
}: EmojiBoardProps) {
const mx = useMatrixClient();
const emojiTab = tab === EmojiBoardTab.Emoji;
const usage = emojiTab ? ImageUsage.Emoticon : ImageUsage.Sticker;
const previewAtom = useMemo(
() => createPreviewDataAtom(emojiTab ? DefaultEmojiPreview : undefined),
[emojiTab],
);
const activeGroupIdAtom = useMemo(() => atom<string | undefined>(undefined), []);
const setActiveGroupId = useSetAtom(activeGroupIdAtom);
const setRecentStickers = useSetAtom(recentStickersAtom);
const relevantImagePacks = useRelevantImagePacks(usage, imagePackRooms);
// Unicode-only mode: drop all custom/image packs so the sidebar, group list,
// and search show only insertable unicode emojis.
const imagePacks = hideCustomEmojis ? NO_IMAGE_PACKS : relevantImagePacks;
const [emojiGroupItems, stickerGroupItems] = useGroups(tab, imagePacks, hideCustomEmojis);
const groups = emojiTab ? emojiGroupItems : stickerGroupItems;
const renderItem = useItemRenderer(tab);
const { emojis: loadedEmojis } = useEmojiData();
const searchList = useMemo(() => {
let list: Array<PackImageReader | IEmoji> = [];
list = list.concat(imagePacks.flatMap((pack) => pack.getImages(usage)));
if (emojiTab) list = list.concat(loadedEmojis);
return list;
}, [emojiTab, usage, imagePacks, loadedEmojis]);
const [result, search, resetSearch] = useAsyncSearch(
searchList,
getEmoticonSearchStr,
SEARCH_OPTIONS,
);
const searchedItems = result?.items.slice(0, 100);
const handleOnChange: ChangeEventHandler<HTMLInputElement> = useDebounce(
useCallback(
(evt) => {
const term = evt.target.value;
if (term) search(term);
else resetSearch();
},
[search, resetSearch],
),
{ wait: 200 },
);
const contentScrollRef = useRef<HTMLDivElement>(null) as React.RefObject<HTMLDivElement>;
const virtualBaseRef = useRef<HTMLDivElement>(null) as React.RefObject<HTMLDivElement>;
const virtualizer = useVirtualizer({
count: groups.length,
getScrollElement: () => contentScrollRef.current,
estimateSize: () => 40,
overscan: VIRTUAL_OVER_SCAN,
});
const vItems = virtualizer.getVirtualItems();
// [Gitea #147] Touch: first tap on a sticker parks it in a preview bar,
// second tap (or the bar's Send) sends. Mouse/keyboard/screen reader: one step.
const { wasTouch } = useRecentTouch(contentScrollRef);
const [pendingSticker, setPendingSticker] = useState<EmojiItemInfo | undefined>();
const stickerUseAuthentication = useMediaAuthentication();
const sendSticker = (info: EmojiItemInfo, close: boolean) => {
onStickerSelect?.(info.data, info.shortcode, info.label);
setRecentStickers((prev) =>
addRecentSticker(prev, { url: info.data, shortcode: info.shortcode, body: info.label }),
);
setPendingSticker(undefined);
if (close) requestClose();
};
const handleGroupItemClick: MouseEventHandler = (evt) => {
const targetEl = targetFromEvent(evt.nativeEvent, 'button');
const emojiInfo = targetEl && getEmojiItemInfo(targetEl);
if (!emojiInfo) {
if (pendingSticker) setPendingSticker(undefined);
return;
}
if (emojiInfo.type === EmojiType.Emoji) {
onEmojiSelect?.(emojiInfo.data, emojiInfo.shortcode);
if (!evt.altKey && !evt.shiftKey && addToRecentEmoji) {
addRecentEmoji(mx, emojiInfo.data);
}
}
if (emojiInfo.type === EmojiType.CustomEmoji) {
onCustomEmojiSelect?.(emojiInfo.data, emojiInfo.shortcode);
}
if (emojiInfo.type === EmojiType.Sticker) {
if (wasTouch() && pendingSticker?.data !== emojiInfo.data) {
setPendingSticker(emojiInfo);
return;
}
sendSticker(emojiInfo, !evt.altKey && !evt.shiftKey);
return;
}
if (!evt.altKey && !evt.shiftKey) requestClose();
};
const handleTextCustomEmojiSelect = (textEmoji: string) => {
onCustomEmojiSelect?.(textEmoji, textEmoji);
requestClose();
};
const handleScrollToGroup = (groupId: string) => {
const groupIndex = groups.findIndex((group) => group.id === groupId);
virtualizer.scrollToIndex(groupIndex, { align: 'start' });
};
// sync active sidebar tab with scroll
useEffect(() => {
const scrollElement = contentScrollRef.current;
if (scrollElement) {
const scrollTop = scrollElement.offsetTop + scrollElement.scrollTop;
const offsetTop = virtualBaseRef.current?.offsetTop ?? 0;
const inViewVItem = vItems.find((vItem) => scrollTop < offsetTop + vItem.end);
const group = inViewVItem ? groups[inViewVItem?.index] : undefined;
setActiveGroupId(group?.id);
}
}, [vItems, groups, setActiveGroupId, result?.query]);
// reset scroll position on search
useEffect(() => {
const scrollElement = contentScrollRef.current;
if (scrollElement) {
scrollElement.scrollTo({ top: 0 });
}
}, [result?.query]);
// reset scroll position on tab change
useEffect(() => {
if (groups.length > 0) {
virtualizer.scrollToIndex(0, { align: 'start' });
}
}, [tab, virtualizer, groups]);
return (
<FocusTrap
focusTrapOptions={{
returnFocusOnDeactivate,
initialFocus: false,
onDeactivate: requestClose,
clickOutsideDeactivates: true,
allowOutsideClick: true,
isKeyForward: (evt: KeyboardEvent) =>
!editableActiveElement() && isKeyHotkey(['arrowdown', 'arrowright'], evt),
isKeyBackward: (evt: KeyboardEvent) =>
!editableActiveElement() && isKeyHotkey(['arrowup', 'arrowleft'], evt),
// [Gitea #304] The board opens with focus in its search field, where
// the shared stopPropagation helper ignores Escape. Always close here,
// without letting the key also close the room/modal underneath.
escapeDeactivates: (evt: KeyboardEvent) => {
evt.stopPropagation();
return true;
},
}}
>
<EmojiBoardLayout
data-emoji-board=""
header={
<Box direction="Column" gap="200">
{onTabChange && <EmojiBoardTabs tab={tab} onTabChange={onTabChange} />}
<SearchInput
key={tab}
query={result?.query}
onChange={handleOnChange}
allowTextCustomEmoji={allowTextCustomEmoji}
onTextCustomEmojiSelect={handleTextCustomEmojiSelect}
/>
</Box>
}
sidebar={
emojiTab ? (
<EmojiSidebar
activeGroupAtom={activeGroupIdAtom}
packs={imagePacks}
onScrollToGroup={handleScrollToGroup}
/>
) : (
<StickerSidebar
activeGroupAtom={activeGroupIdAtom}
packs={imagePacks}
onScrollToGroup={handleScrollToGroup}
/>
)
}
>
<Box grow="Yes">
<EmojiGroupHolder
key={tab}
contentScrollRef={contentScrollRef}
previewAtom={previewAtom}
onGroupItemClick={handleGroupItemClick}
>
{searchedItems && (
<EmojiGroup
id={SEARCH_GROUP_ID}
label={searchedItems.length ? 'Search Results' : 'No Results found'}
>
{searchedItems.map(renderItem)}
</EmojiGroup>
)}
<div
ref={virtualBaseRef}
style={{
position: 'relative',
height: virtualizer.getTotalSize(),
}}
>
{vItems.map((vItem) => {
const group = groups[vItem.index];
return (
<VirtualTile
virtualItem={vItem}
style={{ paddingTop: config.space.S200 }}
ref={virtualizer.measureElement}
key={vItem.index}
>
<EmojiGroup key={group.id} id={group.id} label={group.name}>
{group.items.map(renderItem)}
</EmojiGroup>
</VirtualTile>
);
})}
</div>
{tab === EmojiBoardTab.Sticker && groups.length === 0 && <NoStickerPacks />}
</EmojiGroupHolder>
</Box>
{pendingSticker && tab === EmojiBoardTab.Sticker ? (
<TapToSendBar
previewUrl={
mxcUrlToHttp(mx, pendingSticker.data, stickerUseAuthentication) ?? undefined
}
label={pendingSticker.label}
onSend={() => sendSticker(pendingSticker, true)}
onCancel={() => setPendingSticker(undefined)}
/>
) : (
<Preview previewAtom={previewAtom} />
)}
</EmojiBoardLayout>
</FocusTrap>
);
}