Files
cinny/src/app/components/emoji-board/EmojiBoard.tsx
T
jaredandClaude Opus 5 8d11a62e14
CI / Build & Quality Checks (push) Successful in 1m59s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 6s
CI / Trigger Desktop Build (push) Successful in 5s
CI / Playwright smoke (e2e) (push) Canceled after 4m38s
feat(composer): tap once to preview, again to send stickers/GIFs on touch (#147)
Fat-finger guard for phones. On coarse-pointer devices the first tap on a
sticker (emoji board Sticker tab) or a GIF parks it in a small bar with a
thumbnail, name, Send and Cancel; a second tap on the same item or Send
sends it; tapping a different item switches the preview; tapping empty
picker space or Cancel clears it. Mouse clicks, keyboard activation and
screen-reader activation (bare click without touch events) still send in
one step, and the emoji tab is untouched.

useRecentTouch records touches inside the picker at the document level so
it survives the Sticker tab remount, and reports false without a coarse
pointer.

Also fixes the compact composer's GIF picker opening mostly off-screen:
end-aligning the 312px popout to a button near the left edge of the
overflow row pushed it to x≈-95; it now anchors to the row itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-20 00:15:09 -04:00

706 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, stopPropagation } 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),
escapeDeactivates: stopPropagation,
}}
>
<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>
);
}