// Copyright 2025 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import type { CSSProperties } from 'react'; import React, { memo, useCallback, useEffect, useMemo, useRef, useState, } from 'react'; import type { PressEvent } from 'react-aria'; 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; type StickerPackLookup = Record; function getStickerId(sticker: StickerType): string { return `${sticker.packId}-${sticker.id}`; } function getTimeStickerId(style: FunTimeStickerStyle): string { return `_timeSticker:${style}`; } function toStickerIds( stickers: ReadonlyArray ): ReadonlyArray { return stickers.map(sticker => getStickerId(sticker)); } function toGridSectionNode( section: FunStickersSection, values: ReadonlyArray ): 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(null); const packsLookup = useMemo(() => { const result: Record = {}; 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(null); const searchEmojis = useFunEmojiSearch(); const searchQuery = useMemo(() => searchInput.trim(), [searchInput]); const sections = useMemo(() => { if (searchQuery !== '') { const emojiKeys = new Set(); 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 = []; 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 handlePressSticker = useCallback( (event: PressEvent, stickerSelection: FunStickerSelection) => { onFunSelectSticker(stickerSelection); onSelectSticker(stickerSelection); if (!(event.ctrlKey || event.metaKey)) { setFocusedCellKey(null); onClose(); } }, [onFunSelectSticker, onSelectSticker, onClose] ); const handlePressTimeSticker = useCallback( (event: PressEvent, style: FunTimeStickerStyle) => { onSelectTimeSticker?.(style); if (!(event.ctrlKey || event.metaKey)) { onClose(); } }, [onSelectTimeSticker, onClose] ); return ( {!hasSearchQuery && ( {selectedStickersSection != null && ( {recentStickers.length > 0 && ( )} {installedStickerPacks.map(installedStickerPack => { return ( {installedStickerPack.cover && ( )} ); })} )} {onAddStickerPack != null && ( )} )} {layout.sections.length === 0 && ( {i18n('icu:FunPanelStickers__SearchResults__EmptyHeading')}{' '} )} {layout.sections.map(section => { return ( {getTitleForSection( i18n, section.id as FunStickersSection, packsLookup )} {section.rowGroup.rows.map(row => { return ( ); })} ); })} ); } const Row = memo(function Row(props: { rowIndex: number; stickerLookup: StickerLookup; cells: ReadonlyArray; focusedCellKey: CellKey | null; onPressSticker: ( event: PressEvent, stickerSelection: FunStickerSelection ) => void; onPressTimeSticker: (event: PressEvent, style: FunTimeStickerStyle) => void; }): JSX.Element { return ( {props.cells.map(cell => { const isTabbable = props.focusedCellKey != null ? cell.key === props.focusedCellKey : cell.rowIndex === 0 && cell.colIndex === 0; return ( ); })} ); }); const Cell = memo(function Cell(props: { value: string; cellKey: CellKey; colIndex: number; rowIndex: number; stickerLookup: StickerLookup; isTabbable: boolean; onPressSticker: ( event: PressEvent, stickerSelection: FunStickerSelection ) => void; onPressTimeSticker: (event: PressEvent, style: FunTimeStickerStyle) => void; }): JSX.Element { const { onPressSticker, onPressTimeSticker } = props; const stickerLookupItem = props.stickerLookup[props.value]; const handlePress = useCallback( (event: PressEvent) => { if (stickerLookupItem.kind === 'sticker') { onPressSticker(event, { stickerPackId: stickerLookupItem.sticker.packId, stickerId: stickerLookupItem.sticker.id, stickerUrl: stickerLookupItem.sticker.url, }); } else if (stickerLookupItem.kind === 'timeSticker') { onPressTimeSticker(event, stickerLookupItem.style); } }, [stickerLookupItem, onPressSticker, onPressTimeSticker] ); return ( {stickerLookupItem.kind === 'sticker' && ( )} {stickerLookupItem.kind === 'timeSticker' && stickerLookupItem.style === 'digital' && ( )} {stickerLookupItem.kind === 'timeSticker' && stickerLookupItem.style === 'analog' && ( )} ); }); 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 ( {stickerLookupItem.kind === 'sticker' && ( )} {stickerLookupItem.kind === 'timeSticker' && stickerLookupItem.style === 'digital' && ( )} {stickerLookupItem.kind === 'timeSticker' && stickerLookupItem.style === 'analog' && ( )} ); } 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 ( {digitalTime} ); } 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 ( ); }