Signal-Desktop/ts/components/fun/panels/FunPanelStickers.tsx

748 lines
22 KiB
TypeScript

// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { CSSProperties, PointerEvent } from 'react';
import React, {
memo,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import type {
StickerPackType,
StickerType,
} from '../../../state/ducks/stickers';
import type { LocalizerType } from '../../../types/I18N';
import { strictAssert } from '../../../util/assert';
import type {
FunStickersPackSection,
FunStickersSection,
FunTimeStickerStyle,
} from '../constants';
import {
FunSectionCommon,
FunStickersSectionBase,
FunTimeStickerStylesOrder,
toFunStickersPackSection,
} from '../constants';
import {
FunGridCell,
FunGridContainer,
FunGridHeader,
FunGridHeaderText,
FunGridRow,
FunGridRowGroup,
FunGridScrollerSection,
} from '../base/FunGrid';
import { FunItemButton } from '../base/FunItem';
import {
FunPanel,
FunPanelBody,
FunPanelFooter,
FunPanelHeader,
} from '../base/FunPanel';
import { FunScroller } from '../base/FunScroller';
import { FunSearch } from '../base/FunSearch';
import {
FunSubNav,
FunSubNavButton,
FunSubNavButtons,
FunSubNavIcon,
FunSubNavImage,
FunSubNavListBox,
FunSubNavListBoxItem,
FunSubNavScroller,
} from '../base/FunSubNav';
import {
type EmojiParentKey,
emojiVariantConstant,
getEmojiParentKeyByValue,
isEmojiParentValue,
} from '../data/emojis';
import { FunKeyboard } from '../keyboard/FunKeyboard';
import type { GridKeyboardState } from '../keyboard/GridKeyboardDelegate';
import { GridKeyboardDelegate } from '../keyboard/GridKeyboardDelegate';
import type {
CellKey,
CellLayoutNode,
GridSectionNode,
} from '../virtual/useFunVirtualGrid';
import { useFunVirtualGrid } from '../virtual/useFunVirtualGrid';
import { useFunContext } from '../FunProvider';
import { FunResults, FunResultsHeader } from '../base/FunResults';
import { FunStaticEmoji } from '../FunEmoji';
import {
FunLightboxPortal,
FunLightboxBackdrop,
FunLightboxDialog,
FunLightboxProvider,
useFunLightboxKey,
} from '../base/FunLightbox';
import { FunSticker } from '../FunSticker';
import { getAnalogTime } from '../../../util/getAnalogTime';
import { getDateTimeFormatter } from '../../../util/formatTimestamp';
import { useFunEmojiSearch } from '../useFunEmojiSearch';
const STICKER_GRID_COLUMNS = 4;
const STICKER_GRID_CELL_WIDTH = 80;
const STICKER_GRID_CELL_HEIGHT = 80;
const STICKER_GRID_SECTION_GAP = 20;
const STICKER_GRID_HEADER_SIZE = 28;
const STICKER_GRID_ROW_SIZE = STICKER_GRID_CELL_HEIGHT;
type StickerLookupItemSticker = { kind: 'sticker'; sticker: StickerType };
type StickerLookupItemTimeSticker = {
kind: 'timeSticker';
style: FunTimeStickerStyle;
};
type StickerLookupItem =
| StickerLookupItemSticker
| StickerLookupItemTimeSticker;
type StickerLookup = Record<string, StickerLookupItem>;
type StickerPackLookup = Record<string, StickerPackType>;
function getStickerId(sticker: StickerType): string {
return `${sticker.packId}-${sticker.id}`;
}
function getTimeStickerId(style: FunTimeStickerStyle): string {
return `_timeSticker:${style}`;
}
function toStickerIds(
stickers: ReadonlyArray<StickerType>
): ReadonlyArray<string> {
return stickers.map(sticker => getStickerId(sticker));
}
function toGridSectionNode(
section: FunStickersSection,
values: ReadonlyArray<string>
): GridSectionNode {
return {
id: section,
key: `section-${section}`,
header: {
key: `header-${section}`,
},
cells: values.map(value => {
return {
key: `cell-${section}-${value}`,
value,
};
}),
};
}
function getTitleForSection(
i18n: LocalizerType,
section: FunStickersSection,
packs: StickerPackLookup
): string {
if (section === FunSectionCommon.SearchResults) {
return i18n('icu:FunPanelStickers__SectionTitle--SearchResults');
}
if (section === FunSectionCommon.Recents) {
return i18n('icu:FunPanelStickers__SectionTitle--Recents');
}
if (section === FunStickersSectionBase.StickersSetup) {
return '';
}
if (section === FunStickersSectionBase.Featured) {
return i18n('icu:FunPanelStickers__SectionTitle--Featured');
}
// To assert the typescript type:
const stickerPackSection: FunStickersPackSection = section;
const packId = stickerPackSection.replace(/^StickerPack:/, '');
const pack = packs[packId];
strictAssert(pack != null, `Missing pack for ${packId}`);
return pack.title;
}
export type FunStickerSelection = Readonly<{
stickerPackId: string;
stickerId: number;
stickerUrl: string;
}>;
export type FunPanelStickersProps = Readonly<{
showTimeStickers: boolean;
onSelectTimeSticker?: (style: FunTimeStickerStyle) => void;
onSelectSticker: (stickerSelection: FunStickerSelection) => void;
onAddStickerPack: (() => void) | null;
onClose: () => void;
}>;
export function FunPanelStickers({
showTimeStickers,
onSelectTimeSticker,
onSelectSticker,
onAddStickerPack,
onClose,
}: FunPanelStickersProps): JSX.Element {
const fun = useFunContext();
const {
i18n,
searchInput,
onSearchInputChange,
selectedStickersSection,
onChangeSelectedStickersSection,
recentStickers,
installedStickerPacks,
onSelectSticker: onFunSelectSticker,
} = fun;
const scrollerRef = useRef<HTMLDivElement>(null);
const packsLookup = useMemo(() => {
const result: Record<string, StickerPackType> = {};
for (const pack of installedStickerPacks) {
result[pack.id] = pack;
}
return result;
}, [installedStickerPacks]);
const stickerLookup = useMemo(() => {
const result: StickerLookup = {};
for (const sticker of recentStickers) {
result[getStickerId(sticker)] = { kind: 'sticker', sticker };
}
for (const installedStickerPack of installedStickerPacks) {
for (const sticker of installedStickerPack.stickers) {
result[getStickerId(sticker)] = { kind: 'sticker', sticker };
}
}
for (const style of FunTimeStickerStylesOrder) {
result[getTimeStickerId(style)] = { kind: 'timeSticker', style };
}
return result;
}, [recentStickers, installedStickerPacks]);
const [focusedCellKey, setFocusedCellKey] = useState<CellKey | null>(null);
const searchEmojis = useFunEmojiSearch();
const searchQuery = useMemo(() => searchInput.trim(), [searchInput]);
const sections = useMemo(() => {
if (searchQuery !== '') {
const emojiKeys = new Set<EmojiParentKey>();
for (const result of searchEmojis(searchQuery)) {
emojiKeys.add(result.parentKey);
}
const allStickers = installedStickerPacks.flatMap(pack => pack.stickers);
const matchingStickers = allStickers.filter(sticker => {
if (sticker.emoji == null) {
return false;
}
if (!isEmojiParentValue(sticker.emoji)) {
return false;
}
const parentKey = getEmojiParentKeyByValue(sticker.emoji);
return emojiKeys.has(parentKey);
});
return [
toGridSectionNode(
FunSectionCommon.SearchResults,
toStickerIds(matchingStickers)
),
];
}
const result: Array<GridSectionNode> = [];
if (showTimeStickers) {
result.push(
toGridSectionNode(
FunStickersSectionBase.Featured,
FunTimeStickerStylesOrder.map(style => {
return getTimeStickerId(style);
})
)
);
}
if (recentStickers.length > 0) {
result.push(
toGridSectionNode(
FunSectionCommon.Recents,
toStickerIds(recentStickers)
)
);
}
for (const pack of installedStickerPacks) {
const section = toFunStickersPackSection(pack);
result.push(toGridSectionNode(section, toStickerIds(pack.stickers)));
}
return result;
}, [
showTimeStickers,
recentStickers,
installedStickerPacks,
searchEmojis,
searchQuery,
]);
const [virtualizer, layout] = useFunVirtualGrid({
scrollerRef,
sections,
columns: STICKER_GRID_COLUMNS,
overscan: 8,
sectionGap: STICKER_GRID_SECTION_GAP,
headerSize: STICKER_GRID_HEADER_SIZE,
rowSize: STICKER_GRID_ROW_SIZE,
focusedCellKey,
});
const keyboard = useMemo(() => {
return new GridKeyboardDelegate(virtualizer, layout);
}, [virtualizer, layout]);
const handleSelectSection = useCallback(
(section: FunStickersSection) => {
const layoutSection = layout.sections.find(s => s.id === section);
strictAssert(layoutSection != null, `Missing section to for ${section}`);
onChangeSelectedStickersSection(section);
virtualizer.scrollToOffset(layoutSection.header.item.start, {
align: 'start',
});
},
[virtualizer, layout, onChangeSelectedStickersSection]
);
const handleScrollSectionChange = useCallback(
(sectionId: string) => {
onChangeSelectedStickersSection(sectionId as FunStickersSection);
},
[onChangeSelectedStickersSection]
);
const handleKeyboardStateChange = useCallback(
(state: GridKeyboardState) => {
if (state.cell == null) {
setFocusedCellKey(null);
return;
}
setFocusedCellKey(state.cell.cellKey ?? null);
onChangeSelectedStickersSection(
state.cell?.sectionKey as FunStickersSection
);
},
[onChangeSelectedStickersSection]
);
const hasSearchQuery = useMemo(() => {
return searchInput.length > 0;
}, [searchInput]);
const handleClickSticker = useCallback(
(event: PointerEvent, stickerSelection: FunStickerSelection) => {
onFunSelectSticker(stickerSelection);
onSelectSticker(stickerSelection);
if (!(event.ctrlKey || event.metaKey)) {
setFocusedCellKey(null);
onClose();
}
},
[onFunSelectSticker, onSelectSticker, onClose]
);
const handleClickTimeSticker = useCallback(
(event: PointerEvent, style: FunTimeStickerStyle) => {
onSelectTimeSticker?.(style);
if (!(event.ctrlKey || event.metaKey)) {
onClose();
}
},
[onSelectTimeSticker, onClose]
);
return (
<FunPanel>
<FunPanelHeader>
<FunSearch
i18n={i18n}
searchInput={searchInput}
onSearchInputChange={onSearchInputChange}
placeholder={i18n('icu:FunPanelStickers__SearchPlaceholder')}
aria-label={i18n('icu:FunPanelStickers__SearchLabel')}
/>
</FunPanelHeader>
{!hasSearchQuery && (
<FunPanelFooter>
<FunSubNav>
<FunSubNavScroller>
{selectedStickersSection != null && (
<FunSubNavListBox
aria-label={i18n('icu:FunPanelSticker__SubNavLabel')}
selected={selectedStickersSection}
onSelect={handleSelectSection}
>
{recentStickers.length > 0 && (
<FunSubNavListBoxItem
id={FunSectionCommon.Recents}
label={i18n(
'icu:FunPanelStickers__SubNavCategoryLabel--Recents'
)}
>
<FunSubNavIcon iconClassName="FunSubNav__Icon--Recents" />
</FunSubNavListBoxItem>
)}
{installedStickerPacks.map(installedStickerPack => {
return (
<FunSubNavListBoxItem
key={installedStickerPack.id}
id={toFunStickersPackSection(installedStickerPack)}
label={installedStickerPack.title}
>
{installedStickerPack.cover && (
<FunSubNavImage
src={installedStickerPack.cover?.url}
/>
)}
</FunSubNavListBoxItem>
);
})}
</FunSubNavListBox>
)}
</FunSubNavScroller>
{onAddStickerPack != null && (
<FunSubNavButtons>
<FunSubNavButton onClick={onAddStickerPack}>
<FunSubNavIcon iconClassName="FunSubNav__Icon--Plus" />
</FunSubNavButton>
</FunSubNavButtons>
)}
</FunSubNav>
</FunPanelFooter>
)}
<FunPanelBody>
<FunScroller
ref={scrollerRef}
sectionGap={STICKER_GRID_SECTION_GAP}
onScrollSectionChange={handleScrollSectionChange}
>
{layout.sections.length === 0 && (
<FunResults aria-busy={false}>
<FunResultsHeader>
{i18n('icu:FunPanelStickers__SearchResults__EmptyHeading')}{' '}
<FunStaticEmoji
size={16}
role="presentation"
emoji={emojiVariantConstant('\u{1F641}')}
/>
</FunResultsHeader>
</FunResults>
)}
<FunLightboxProvider containerRef={scrollerRef}>
<StickersLightbox i18n={i18n} stickerLookup={stickerLookup} />
<FunKeyboard
scrollerRef={scrollerRef}
keyboard={keyboard}
onStateChange={handleKeyboardStateChange}
>
<FunGridContainer
totalSize={layout.totalHeight}
cellWidth={STICKER_GRID_CELL_WIDTH}
cellHeight={STICKER_GRID_CELL_HEIGHT}
columnCount={STICKER_GRID_COLUMNS}
>
{layout.sections.map(section => {
return (
<FunGridScrollerSection
key={section.key}
id={section.id}
sectionOffset={section.sectionOffset}
sectionSize={section.sectionSize}
>
<FunGridHeader
id={section.header.key}
headerOffset={section.header.headerOffset}
headerSize={section.header.headerSize}
>
<FunGridHeaderText>
{getTitleForSection(
i18n,
section.id as FunStickersSection,
packsLookup
)}
</FunGridHeaderText>
</FunGridHeader>
<FunGridRowGroup
aria-labelledby={section.header.key}
colCount={section.colCount}
rowCount={section.rowCount}
rowGroupOffset={section.rowGroup.rowGroupOffset}
rowGroupSize={section.rowGroup.rowGroupSize}
>
{section.rowGroup.rows.map(row => {
return (
<Row
key={row.key}
rowIndex={row.rowIndex}
cells={row.cells}
stickerLookup={stickerLookup}
focusedCellKey={focusedCellKey}
onClickSticker={handleClickSticker}
onClickTimeSticker={handleClickTimeSticker}
/>
);
})}
</FunGridRowGroup>
</FunGridScrollerSection>
);
})}
</FunGridContainer>
</FunKeyboard>
</FunLightboxProvider>
</FunScroller>
</FunPanelBody>
</FunPanel>
);
}
const Row = memo(function Row(props: {
rowIndex: number;
stickerLookup: StickerLookup;
cells: ReadonlyArray<CellLayoutNode>;
focusedCellKey: CellKey | null;
onClickSticker: (
event: PointerEvent,
stickerSelection: FunStickerSelection
) => void;
onClickTimeSticker: (event: PointerEvent, style: FunTimeStickerStyle) => void;
}): JSX.Element {
return (
<FunGridRow rowIndex={props.rowIndex}>
{props.cells.map(cell => {
const isTabbable =
props.focusedCellKey != null
? cell.key === props.focusedCellKey
: cell.rowIndex === 0 && cell.colIndex === 0;
return (
<Cell
key={cell.key}
value={cell.value}
cellKey={cell.key}
rowIndex={cell.rowIndex}
colIndex={cell.colIndex}
stickerLookup={props.stickerLookup}
isTabbable={isTabbable}
onClickSticker={props.onClickSticker}
onClickTimeSticker={props.onClickTimeSticker}
/>
);
})}
</FunGridRow>
);
});
const Cell = memo(function Cell(props: {
value: string;
cellKey: CellKey;
colIndex: number;
rowIndex: number;
stickerLookup: StickerLookup;
isTabbable: boolean;
onClickSticker: (
event: PointerEvent,
stickerSelection: FunStickerSelection
) => void;
onClickTimeSticker: (event: PointerEvent, style: FunTimeStickerStyle) => void;
}): JSX.Element {
const { onClickSticker, onClickTimeSticker } = props;
const stickerLookupItem = props.stickerLookup[props.value];
const handleClick = useCallback(
(event: PointerEvent) => {
if (stickerLookupItem.kind === 'sticker') {
onClickSticker(event, {
stickerPackId: stickerLookupItem.sticker.packId,
stickerId: stickerLookupItem.sticker.id,
stickerUrl: stickerLookupItem.sticker.url,
});
} else if (stickerLookupItem.kind === 'timeSticker') {
onClickTimeSticker(event, stickerLookupItem.style);
}
},
[stickerLookupItem, onClickSticker, onClickTimeSticker]
);
return (
<FunGridCell
data-key={props.cellKey}
colIndex={props.colIndex}
rowIndex={props.rowIndex}
>
<FunItemButton
excludeFromTabOrder={!props.isTabbable}
aria-label={
stickerLookupItem.kind === 'sticker'
? (stickerLookupItem.sticker.emoji ?? '')
: stickerLookupItem.style
}
onClick={handleClick}
>
{stickerLookupItem.kind === 'sticker' && (
<FunSticker
role="presentation"
src={stickerLookupItem.sticker.url}
size={68}
/>
)}
{stickerLookupItem.kind === 'timeSticker' &&
stickerLookupItem.style === 'digital' && (
<DigitalTimeSticker size={68} />
)}
{stickerLookupItem.kind === 'timeSticker' &&
stickerLookupItem.style === 'analog' && (
<AnalogTimeSticker size={68} />
)}
</FunItemButton>
</FunGridCell>
);
});
function StickersLightbox(props: {
i18n: LocalizerType;
stickerLookup: StickerLookup;
}) {
const { i18n } = props;
const key = useFunLightboxKey();
const stickerLookupItem = useMemo(() => {
if (key == null) {
return null;
}
const [, , ...stickerIdParts] = key.split('-');
const stickerId = stickerIdParts.join('-');
const found = props.stickerLookup[stickerId];
strictAssert(found, `Must have sticker for "${stickerId}"`);
return found;
}, [props.stickerLookup, key]);
if (stickerLookupItem == null) {
return null;
}
return (
<FunLightboxPortal>
<FunLightboxBackdrop>
<FunLightboxDialog
aria-label={i18n('icu:FunPanelStickers__LightboxDialog__Label')}
>
{stickerLookupItem.kind === 'sticker' && (
<FunSticker
role="img"
aria-label={stickerLookupItem.sticker.emoji ?? ''}
src={stickerLookupItem.sticker.url}
size={512}
ignoreReducedMotion
/>
)}
{stickerLookupItem.kind === 'timeSticker' &&
stickerLookupItem.style === 'digital' && (
<DigitalTimeSticker size={512} />
)}
{stickerLookupItem.kind === 'timeSticker' &&
stickerLookupItem.style === 'analog' && (
<AnalogTimeSticker size={512} />
)}
</FunLightboxDialog>
</FunLightboxBackdrop>
</FunLightboxPortal>
);
}
function getDigitalTime() {
return getDateTimeFormatter({ hour: 'numeric', minute: 'numeric' })
.formatToParts(Date.now())
.filter(x => x.type !== 'dayPeriod')
.reduce((acc, { value }) => `${acc}${value}`, '')
.trim();
}
function DigitalTimeSticker(props: { size: number }) {
const [digitalTime, setDigitalTime] = useState(() => getDigitalTime());
useEffect(() => {
const interval = setInterval(() => {
setDigitalTime(getDigitalTime());
}, 1000);
return () => {
clearInterval(interval);
};
}, []);
return (
<svg
className="FunPanelStickers__TimeStickerWrapper"
width={props.size}
height={props.size}
viewBox="0 0 512 512"
>
<foreignObject x={0} y={0} width={512} height={512}>
<span className="FunPanelStickers__DigitalTimeSticker">
{digitalTime}
</span>
</foreignObject>
</svg>
);
}
function AnalogTimeSticker(props: { size: number }) {
const [analogTime, setAnalogTime] = useState(() => {
return getAnalogTime();
});
useEffect(() => {
const interval = setInterval(() => {
setAnalogTime(prev => {
const current = getAnalogTime();
if (current.hour === prev.hour && current.minute === prev.minute) {
return prev;
}
return current;
});
}, 1000);
return () => {
clearInterval(interval);
};
}, []);
return (
<svg
className="FunPanelStickers__TimeStickerWrapper"
width={props.size}
height={props.size}
viewBox="0 0 512 512"
>
<foreignObject x={0} y={0} width={512} height={512}>
<span className="FunPanelStickers__AnalogTimeSticker">
<span
className="FunPanelStickers__AnalogTimeSticker__HourHand"
style={
{
'--fun-analog-time-sticker-hour': `${analogTime.hour}deg`,
} as CSSProperties
}
/>
<span
className="FunPanelStickers__AnalogTimeSticker__MinuteHand"
style={
{
'--fun-analog-time-sticker-minute': `${analogTime.minute}deg`,
} as CSSProperties
}
/>
</span>
</foreignObject>
</svg>
);
}