Fun picker improvements

This commit is contained in:
Jamie Kyle
2025-03-26 12:35:32 -07:00
committed by GitHub
parent 427f91f903
commit b0653d06fe
142 changed files with 3581 additions and 1280 deletions

View File

@@ -4,8 +4,8 @@ import { useVirtualizer } from '@tanstack/react-virtual';
import { chunk } from 'lodash';
import React, { StrictMode, useCallback, useEffect, useRef } from 'react';
import { type ComponentMeta } from '../../storybook/types';
import type { FunEmojiProps } from './FunEmoji';
import { FunEmoji } from './FunEmoji';
import type { FunStaticEmojiProps } from './FunEmoji';
import { FunStaticEmoji } from './FunEmoji';
import {
_allEmojiVariantKeys,
getEmojiParentByKey,
@@ -26,7 +26,7 @@ export default {
const COLUMNS = 8;
type AllProps = Pick<FunEmojiProps, 'size'>;
type AllProps = Pick<FunStaticEmojiProps, 'size'>;
export function All(props: AllProps): JSX.Element {
const scrollerRef = useRef<HTMLDivElement>(null);
@@ -102,7 +102,7 @@ export function All(props: AllProps): JSX.Element {
key={emojiVariantKey}
style={{ display: 'flex', outline: '1px solid' }}
>
<FunEmoji
<FunStaticEmoji
role="img"
aria-label={parent.englishShortNameDefault}
size={props.size}

View File

@@ -2,35 +2,170 @@
// SPDX-License-Identifier: AGPL-3.0-only
import classNames from 'classnames';
import type { CSSProperties } from 'react';
import React from 'react';
import React, { useMemo } from 'react';
import type { EmojiVariantData } from './data/emojis';
import type { FunImageAriaProps } from './types';
export type FunEmojiSize = 16 | 32;
export const FUN_STATIC_EMOJI_CLASS = 'FunStaticEmoji';
export const FUN_INLINE_EMOJI_CLASS = 'FunInlineEmoji';
export type FunEmojiProps = Readonly<{
role: 'img' | 'presentation';
'aria-label': string;
size: FunEmojiSize;
emoji: EmojiVariantData;
}>;
function getEmojiJumboUrl(emoji: EmojiVariantData): string {
return `emoji://jumbo?emoji=${encodeURIComponent(emoji.value)}`;
}
const sizeToClassName: Record<FunEmojiSize, string> = {
16: 'FunEmoji--Size16',
32: 'FunEmoji--Size32',
};
export type FunStaticEmojiSize =
| 16
| 18
| 20
| 24
| 28
| 32
| 36
| 40
| 48
| 56
| 64
| 66;
export function FunEmoji(props: FunEmojiProps): JSX.Element {
export enum FunJumboEmojiSize {
Small = 32,
Medium = 36,
Large = 40,
ExtraLarge = 48,
Max = 56,
}
const funStaticEmojiSizeClasses = {
16: 'FunStaticEmoji--Size16',
18: 'FunStaticEmoji--Size18',
20: 'FunStaticEmoji--Size20',
24: 'FunStaticEmoji--Size24',
28: 'FunStaticEmoji--Size28',
32: 'FunStaticEmoji--Size32',
36: 'FunStaticEmoji--Size36',
40: 'FunStaticEmoji--Size40',
48: 'FunStaticEmoji--Size48',
56: 'FunStaticEmoji--Size56',
64: 'FunStaticEmoji--Size64',
66: 'FunStaticEmoji--Size66',
} satisfies Record<FunStaticEmojiSize, string>;
export type FunStaticEmojiProps = FunImageAriaProps &
Readonly<{
size: FunStaticEmojiSize;
emoji: EmojiVariantData;
}>;
export function FunStaticEmoji(props: FunStaticEmojiProps): JSX.Element {
const emojiJumboUrl = useMemo(() => {
return getEmojiJumboUrl(props.emoji);
}, [props.emoji]);
return (
<div
role={props.role}
aria-label={props['aria-label']}
className={classNames('FunEmoji', sizeToClassName[props.size])}
data-emoji-key={props.emoji.key}
data-emoji-value={props.emoji.value}
className={classNames(
FUN_STATIC_EMOJI_CLASS,
funStaticEmojiSizeClasses[props.size]
)}
style={
{
'--fun-emoji-sheet-x': props.emoji.sheetX,
'--fun-emoji-sheet-y': props.emoji.sheetY,
'--fun-emoji-jumbo-image': `url(${emojiJumboUrl})`,
} as CSSProperties
}
/>
);
}
export type StaticEmojiBlotProps = FunStaticEmojiProps;
const TRANSPARENT_PIXEL =
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'/%3E";
/**
* This is for Quill. It should stay in sync with <FunStaticEmoji> as much as possible.
*
* The biggest difference between them is that the emoji blot uses an `<img>`
* tag with a single transparent pixel in order to render the selection cursor
* correctly in the browser when using `contenteditable`
*
* We need to use the `<img>` bec ause
*/
export function createStaticEmojiBlot(
node: HTMLImageElement,
props: StaticEmojiBlotProps
): void {
// eslint-disable-next-line no-param-reassign
node.src = TRANSPARENT_PIXEL;
// eslint-disable-next-line no-param-reassign
node.role = props.role;
node.classList.add(FUN_STATIC_EMOJI_CLASS);
node.classList.add(funStaticEmojiSizeClasses[props.size]);
node.classList.add('FunStaticEmoji--Blot');
if (props['aria-label'] != null) {
node.setAttribute('aria-label', props['aria-label']);
}
node.style.setProperty('--fun-emoji-sheet-x', `${props.emoji.sheetX}`);
node.style.setProperty('--fun-emoji-sheet-y', `${props.emoji.sheetY}`);
node.style.setProperty(
'--fun-emoji-jumbo-image',
`url(${getEmojiJumboUrl(props.emoji)})`
);
}
export type FunInlineEmojiProps = FunImageAriaProps &
Readonly<{
size?: number | null;
emoji: EmojiVariantData;
}>;
export function FunInlineEmoji(props: FunInlineEmojiProps): JSX.Element {
const emojiJumboUrl = useMemo(() => {
return getEmojiJumboUrl(props.emoji);
}, [props.emoji]);
return (
<svg
role="none"
className={FUN_INLINE_EMOJI_CLASS}
width={64}
height={64}
viewBox="0 0 64 64"
data-emoji-key={props.emoji.key}
data-emoji-value={props.emoji.value}
style={
{
'--fun-inline-emoji-size':
props.size != null ? `${props.size}px` : null,
} as CSSProperties
}
>
{/*
<foreignObject> is used to embed HTML+CSS within SVG, the HTML+CSS gets
rendered at a normal size then scaled by the SVG. This allows us to make
use of CSS features that are not supported by SVG while still using SVG's
ability to scale relative to the parent's font-size.
*/}
<foreignObject x={0} y={0} width={64} height={64}>
<span aria-hidden className="FunEmojiSelectionText">
{props.emoji.value}
</span>
<span
role={props.role}
aria-label={props['aria-label']}
className="FunInlineEmoji__Image"
style={
{
'--fun-emoji-sheet-x': props.emoji.sheetX,
'--fun-emoji-sheet-y': props.emoji.sheetY,
'--fun-emoji-jumbo-image': `url(${emojiJumboUrl})`,
} as CSSProperties
}
/>
</foreignObject>
</svg>
);
}

View File

@@ -25,12 +25,16 @@ function Template(props: TemplateProps): JSX.Element {
recentStickers={recentStickers}
recentGifs={[]}
// Emojis
defaultEmojiSkinTone={EmojiSkinTone.None}
onChangeDefaultEmojiSkinTone={() => null}
emojiSkinToneDefault={EmojiSkinTone.None}
onEmojiSkinToneDefaultChange={() => null}
// Stickers
installedStickerPacks={packs}
showStickerPickerHint={false}
onClearStickerPickerHint={() => null}
// Gifs
fetchGifsSearch={() => Promise.reject()}
fetchGifsFeatured={() => Promise.reject()}
fetchGif={() => Promise.reject()}
>
<FunEmojiPicker {...props}>
<Button>Open EmojiPicker</Button>

View File

@@ -1,12 +1,13 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { ReactNode } from 'react';
import React, { memo, useCallback, useState } from 'react';
import React, { memo, useCallback, useEffect, useState } from 'react';
import type { Placement } from 'react-aria';
import { DialogTrigger } from 'react-aria-components';
import { FunPopover } from './base/FunPopover';
import type { FunEmojiSelection } from './panels/FunPanelEmojis';
import { FunPanelEmojis } from './panels/FunPanelEmojis';
import { useFunContext } from './FunProvider';
export type FunEmojiPickerProps = Readonly<{
placement?: Placement;
@@ -20,6 +21,8 @@ export const FunEmojiPicker = memo(function FunEmojiPicker(
props: FunEmojiPickerProps
): JSX.Element {
const { onOpenChange } = props;
const fun = useFunContext();
const { onClose } = fun;
const [isOpen, setIsOpen] = useState(props.defaultOpen ?? false);
const handleOpenChange = useCallback(
@@ -34,6 +37,12 @@ export const FunEmojiPicker = memo(function FunEmojiPicker(
setIsOpen(false);
}, []);
useEffect(() => {
if (!isOpen) {
onClose();
}
}, [isOpen, onClose]);
return (
<DialogTrigger isOpen={isOpen} onOpenChange={handleOpenChange}>
{props.children}

View File

@@ -0,0 +1,111 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useEffect, useState } from 'react';
import type { Meta } from '@storybook/react';
import { useId, VisuallyHidden } from 'react-aria';
import { FunGif, FunGifPreview } from './FunGif';
import { LoadingState } from '../../util/loadable';
export default {
title: 'Components/Fun/FunGif',
} satisfies Meta;
export function Basic(): JSX.Element {
const id = useId();
return (
<>
{/* eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex */}
<div tabIndex={0}>
<FunGif
src="https://media.tenor.com/tN6E5iSxeI8AAAPo/spongebob-spongebob-squarepants.mp4"
width={498}
height={376}
aria-label="Spongebob Spongebob Squarepants GIF"
aria-describedby={id}
/>
</div>
<VisuallyHidden id={id}>
A cartoon of spongebob wearing a top hat is laying on the ground
</VisuallyHidden>
</>
);
}
export function PreviewSizing(): JSX.Element {
return (
<>
<FunGifPreview
src="https://media.tenor.com/tN6E5iSxeI8AAAPo/spongebob-spongebob-squarepants.mp4"
state={LoadingState.Loaded}
width={498}
height={376}
maxHeight={400}
aria-describedby=""
/>
<div style={{ maxWidth: 200 }}>
<FunGifPreview
src="https://media.tenor.com/tN6E5iSxeI8AAAPo/spongebob-spongebob-squarepants.mp4"
state={LoadingState.Loaded}
width={498}
height={376}
maxHeight={400}
aria-describedby=""
/>
</div>
<div style={{ maxHeight: 200 }}>
<FunGifPreview
src="https://media.tenor.com/tN6E5iSxeI8AAAPo/spongebob-spongebob-squarepants.mp4"
state={LoadingState.Loaded}
width={498}
height={376}
maxHeight={200}
aria-describedby=""
/>
</div>
</>
);
}
export function PreviewLoading(): JSX.Element {
const [src, setSrc] = useState<string | null>(null);
useEffect(() => {
setTimeout(() => {
setSrc(
'https://media.tenor.com/tN6E5iSxeI8AAAPo/spongebob-spongebob-squarepants.mp4'
);
}, 2000);
}, []);
return (
<FunGifPreview
src={src}
state={src == null ? LoadingState.Loading : LoadingState.Loaded}
width={498}
height={376}
maxHeight={400}
aria-describedby=""
/>
);
}
export function PreviewError(): JSX.Element {
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
setTimeout(() => {
setError(new Error('yikes!'));
}, 2000);
}, []);
return (
<FunGifPreview
src={null}
state={error == null ? LoadingState.Loading : LoadingState.LoadFailed}
width={498}
height={376}
maxHeight={400}
aria-describedby=""
/>
);
}

View File

@@ -0,0 +1,183 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { CSSProperties, ForwardedRef } from 'react';
import React, { forwardRef, useEffect, useRef, useState } from 'react';
import { useReducedMotion } from '@react-spring/web';
import { SpinnerV2 } from '../SpinnerV2';
import { strictAssert } from '../../util/assert';
import type { Loadable } from '../../util/loadable';
import { LoadingState } from '../../util/loadable';
import { useIntent } from './base/FunImage';
import * as log from '../../logging/log';
import * as Errors from '../../types/errors';
import { isAbortError } from '../../util/isAbortError';
export type FunGifProps = Readonly<{
src: string;
width: number;
height: number;
'aria-label'?: string;
'aria-describedby': string;
ignoreReducedMotion?: boolean;
}>;
export function FunGif(props: FunGifProps): JSX.Element {
if (props.ignoreReducedMotion) {
return <FunGifBase {...props} autoPlay />;
}
return <FunGifReducedMotion {...props} />;
}
/** @internal */
const FunGifBase = forwardRef(function FunGifBase(
props: FunGifProps & { autoPlay: boolean },
ref: ForwardedRef<HTMLVideoElement>
) {
return (
<video
ref={ref}
className="FunGif"
src={props.src}
width={props.width}
height={props.height}
loop
autoPlay={props.autoPlay}
playsInline
muted
disablePictureInPicture
disableRemotePlayback
aria-label={props['aria-label']}
aria-describedby={props['aria-describedby']}
/>
);
});
/** @internal */
function FunGifReducedMotion(props: FunGifProps) {
const videoRef = useRef<HTMLVideoElement>(null);
const intent = useIntent(videoRef);
const reducedMotion = useReducedMotion();
const shouldPlay = !reducedMotion || intent;
useEffect(() => {
strictAssert(videoRef.current, 'Expected video element');
const video = videoRef.current;
if (shouldPlay) {
video.play().catch(error => {
// ignore errors where `play()` was interrupted by `pause()`
if (!isAbortError(error)) {
log.error('FunGif: Playback error', Errors.toLogFormat(error));
}
});
} else {
video.pause();
}
}, [shouldPlay]);
return <FunGifBase {...props} ref={videoRef} autoPlay={shouldPlay} />;
}
export type FunGifPreviewLoadable = Loadable<string>;
export type FunGifPreviewProps = Readonly<{
src: string | null;
state: LoadingState;
width: number;
height: number;
// It would be nice if this were determined by the container, but that's a
// difficult problem because it creates a cycle where the parent's height
// depends on its children, and its children's height depends on its parent.
// As far as I was able to figure out, this could only be done in one dimension
// at a time.
maxHeight: number;
'aria-label'?: string;
'aria-describedby': string;
}>;
export function FunGifPreview(props: FunGifPreviewProps): JSX.Element {
const ref = useRef<HTMLVideoElement>(null);
const [spinner, setSpinner] = useState(false);
const [playbackError, setPlaybackError] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout>>();
useEffect(() => {
const timer = setTimeout(() => {
setSpinner(true);
});
timerRef.current = timer;
return () => {
clearTimeout(timer);
};
}, []);
useEffect(() => {
if (props.src == null) {
return;
}
strictAssert(ref.current != null, 'video ref should not be null');
const video = ref.current;
function onCanPlay() {
video.hidden = false;
clearTimeout(timerRef.current);
setSpinner(false);
setPlaybackError(false);
}
function onError() {
clearTimeout(timerRef.current);
setSpinner(false);
setPlaybackError(true);
}
video.addEventListener('canplay', onCanPlay, { once: true });
video.addEventListener('error', onError, { once: true });
return () => {
video.removeEventListener('canplay', onCanPlay);
video.removeEventListener('error', onError);
};
}, [props.src]);
const hasError = props.state === LoadingState.LoadFailed || playbackError;
return (
<div className="FunGifPreview">
<svg
aria-hidden
className="FunGifPreview__Sizer"
width={props.width}
height={props.height}
style={
{
'--fun-gif-preview-sizer-max-height': `${props.maxHeight}px`,
} as CSSProperties
}
/>
<div className="FunGifPreview__Backdrop" role="status">
{spinner && !hasError && (
<SpinnerV2
className="FunGifPreview__Spinner"
size={36}
strokeWidth={4}
/>
)}
{hasError && <div className="FunGifPreview__ErrorIcon" />}
</div>
{props.src != null && (
<video
ref={ref}
className="FunGifPreview__Video"
src={props.src}
width={props.width}
height={props.height}
loop
autoPlay
playsInline
muted
disablePictureInPicture
disableRemotePlayback
aria-label={props['aria-label']}
aria-describedby={props['aria-describedby']}
/>
)}
</div>
);
}

View File

@@ -7,13 +7,15 @@ import { type ComponentMeta } from '../../storybook/types';
import { packs, recentStickers } from '../stickers/mocks';
import type { FunPickerProps } from './FunPicker';
import { FunPicker } from './FunPicker';
import type { FunProviderProps } from './FunProvider';
import { FunProvider } from './FunProvider';
import { MOCK_RECENT_EMOJIS } from './mocks';
import { MOCK_GIFS_PAGINATED_ONE_PAGE, MOCK_RECENT_EMOJIS } from './mocks';
import { EmojiSkinTone } from './data/emojis';
const { i18n } = window.SignalContext;
type TemplateProps = Omit<FunPickerProps, 'children'>;
type TemplateProps = Omit<FunPickerProps, 'children'> &
Pick<FunProviderProps, 'fetchGifsSearch' | 'fetchGifsFeatured' | 'fetchGif'>;
function Template(props: TemplateProps) {
return (
@@ -25,12 +27,16 @@ function Template(props: TemplateProps) {
recentStickers={recentStickers}
recentGifs={[]}
// Emojis
defaultEmojiSkinTone={EmojiSkinTone.None}
onChangeDefaultEmojiSkinTone={() => null}
emojiSkinToneDefault={EmojiSkinTone.None}
onEmojiSkinToneDefaultChange={() => null}
// Stickers
installedStickerPacks={packs}
showStickerPickerHint={false}
onClearStickerPickerHint={() => null}
// Gifs
fetchGifsSearch={props.fetchGifsSearch}
fetchGifsFeatured={props.fetchGifsFeatured}
fetchGif={props.fetchGif}
>
<FunPicker {...props}>
<Button>Open FunPicker</Button>
@@ -47,10 +53,13 @@ export default {
placement: 'bottom',
defaultOpen: true,
onOpenChange: action('onOpenChange'),
onSelectEmoji: action('onPickEmoji'),
onSelectSticker: action('onPickSticker'),
onSelectGif: action('onPickGif'),
onSelectEmoji: action('onSelectEmoji'),
onSelectSticker: action('onSelectSticker'),
onSelectGif: action('onSelectGif'),
onAddStickerPack: action('onAddStickerPack'),
fetchGifsSearch: () => Promise.resolve(MOCK_GIFS_PAGINATED_ONE_PAGE),
fetchGifsFeatured: () => Promise.resolve(MOCK_GIFS_PAGINATED_ONE_PAGE),
fetchGif: () => Promise.resolve(new Blob([new Uint8Array(1)])),
},
} satisfies ComponentMeta<TemplateProps>;

View File

@@ -1,10 +1,10 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { ReactNode } from 'react';
import React, { memo, useCallback, useState } from 'react';
import React, { memo, useCallback, useEffect, useState } from 'react';
import type { Placement } from 'react-aria';
import { DialogTrigger } from 'react-aria-components';
import { FunPickerTabKey } from './FunConstants';
import { FunPickerTabKey } from './constants';
import { FunPopover } from './base/FunPopover';
import { FunPickerTab, FunTabList, FunTabPanel, FunTabs } from './base/FunTabs';
import type { FunEmojiSelection } from './panels/FunPanelEmojis';
@@ -35,7 +35,7 @@ export const FunPicker = memo(function FunPicker(
): JSX.Element {
const { onOpenChange } = props;
const fun = useFunContext();
const { i18n } = fun;
const { i18n, onClose } = fun;
const [isOpen, setIsOpen] = useState(props.defaultOpen ?? false);
@@ -51,6 +51,12 @@ export const FunPicker = memo(function FunPicker(
setIsOpen(false);
}, []);
useEffect(() => {
if (!isOpen) {
onClose();
}
}, [isOpen, onClose]);
return (
<DialogTrigger isOpen={isOpen} onOpenChange={handleOpenChange}>
{props.children}

View File

@@ -14,7 +14,7 @@ import type { StickerPackType, StickerType } from '../../state/ducks/stickers';
import type { EmojiSkinTone } from './data/emojis';
import { EmojiPickerCategory, type EmojiParentKey } from './data/emojis';
import type { GifType } from './panels/FunPanelGifs';
import type { FunGifsSection, FunStickersSection } from './FunConstants';
import type { FunGifsSection, FunStickersSection } from './constants';
import {
type FunEmojisSection,
FunGifsCategory,
@@ -22,7 +22,9 @@ import {
FunSectionCommon,
FunStickersSectionBase,
toFunStickersPackSection,
} from './FunConstants';
} from './constants';
import type { fetchGifsFeatured, fetchGifsSearch } from './data/gifs';
import type { tenorDownload } from './data/tenor';
export type FunContextSmartProps = Readonly<{
i18n: LocalizerType;
@@ -33,17 +35,25 @@ export type FunContextSmartProps = Readonly<{
recentGifs: ReadonlyArray<GifType>;
// Emojis
defaultEmojiSkinTone: EmojiSkinTone;
onChangeDefaultEmojiSkinTone: (emojiSkinTone: EmojiSkinTone) => void;
emojiSkinToneDefault: EmojiSkinTone;
onEmojiSkinToneDefaultChange: (emojiSkinTone: EmojiSkinTone) => void;
// Stickers
installedStickerPacks: ReadonlyArray<StickerPackType>;
showStickerPickerHint: boolean;
onClearStickerPickerHint: () => unknown;
// GIFs
fetchGifsFeatured: typeof fetchGifsFeatured;
fetchGifsSearch: typeof fetchGifsSearch;
fetchGif: typeof tenorDownload;
}>;
export type FunContextProps = FunContextSmartProps &
Readonly<{
// Open state
onClose: () => void;
// Current Tab
tab: FunPickerTabKey;
onChangeTab: (key: FunPickerTabKey) => unknown;
@@ -101,16 +111,38 @@ export const FunProvider = memo(function FunProvider(
setSearchInput(newSearchInput);
}, []);
const defaultEmojiSection = useMemo((): FunEmojisSection => {
if (props.recentEmojis.length) {
return FunSectionCommon.Recents;
}
return EmojiPickerCategory.SmileysAndPeople;
}, [props.recentEmojis]);
const defaultStickerSection = useMemo((): FunStickersSection => {
if (props.recentStickers.length > 0) {
return FunSectionCommon.Recents;
}
const firstInstalledStickerPack = props.installedStickerPacks.at(0);
if (firstInstalledStickerPack != null) {
return toFunStickersPackSection(firstInstalledStickerPack);
}
return FunStickersSectionBase.StickersSetup;
}, [props.recentStickers, props.installedStickerPacks]);
const defaultGifsSection = useMemo((): FunGifsSection => {
if (props.recentGifs.length > 0) {
return FunSectionCommon.Recents;
}
return FunGifsCategory.Trending;
}, [props.recentGifs]);
// Selected Sections
const [selectedEmojisSection, setSelectedEmojisSection] = useState(
(): FunEmojisSection => {
if (searchQuery !== '') {
return FunSectionCommon.SearchResults;
}
if (props.recentEmojis.length) {
return FunSectionCommon.Recents;
}
return EmojiPickerCategory.SmileysAndPeople;
return defaultEmojiSection;
}
);
const [selectedStickersSection, setSelectedStickersSection] = useState(
@@ -118,14 +150,7 @@ export const FunProvider = memo(function FunProvider(
if (searchQuery !== '') {
return FunSectionCommon.SearchResults;
}
if (props.recentStickers.length > 0) {
return FunSectionCommon.Recents;
}
const firstInstalledStickerPack = props.installedStickerPacks.at(0);
if (firstInstalledStickerPack != null) {
return toFunStickersPackSection(firstInstalledStickerPack);
}
return FunStickersSectionBase.StickersSetup;
return defaultStickerSection;
}
);
const [selectedGifsSection, setSelectedGifsSection] = useState(
@@ -133,10 +158,7 @@ export const FunProvider = memo(function FunProvider(
if (searchQuery !== '') {
return FunSectionCommon.SearchResults;
}
if (props.recentGifs.length > 0) {
return FunSectionCommon.Recents;
}
return FunGifsCategory.Trending;
return defaultGifsSection;
}
);
const handleChangeSelectedEmojisSection = useCallback(
@@ -158,9 +180,18 @@ export const FunProvider = memo(function FunProvider(
[]
);
const handleClose = useCallback(() => {
setSearchInput('');
setSelectedEmojisSection(defaultEmojiSection);
setSelectedStickersSection(defaultStickerSection);
setSelectedGifsSection(defaultGifsSection);
}, [defaultEmojiSection, defaultStickerSection, defaultGifsSection]);
return (
<FunProviderInner
i18n={props.i18n}
// Open state
onClose={handleClose}
// Current Tab
tab={tab}
onChangeTab={handleChangeTab}
@@ -179,12 +210,16 @@ export const FunProvider = memo(function FunProvider(
recentStickers={props.recentStickers}
recentGifs={props.recentGifs}
// Emojis
defaultEmojiSkinTone={props.defaultEmojiSkinTone}
onChangeDefaultEmojiSkinTone={props.onChangeDefaultEmojiSkinTone}
emojiSkinToneDefault={props.emojiSkinToneDefault}
onEmojiSkinToneDefaultChange={props.onEmojiSkinToneDefaultChange}
// Stickers
installedStickerPacks={props.installedStickerPacks}
showStickerPickerHint={props.showStickerPickerHint}
onClearStickerPickerHint={props.onClearStickerPickerHint}
// GIFs
fetchGifsFeatured={props.fetchGifsFeatured}
fetchGifsSearch={props.fetchGifsSearch}
fetchGif={props.fetchGif}
>
{props.children}
</FunProviderInner>

View File

@@ -3,22 +3,24 @@
import React, { useCallback, useMemo } from 'react';
import type { Selection } from 'react-aria-components';
import { ListBox, ListBoxItem } from 'react-aria-components';
import type { EmojiParentKey } from '../data/emojis';
import type { EmojiParentKey } from './data/emojis';
import {
EmojiSkinTone,
getEmojiVariantByParentKeyAndSkinTone,
} from '../data/emojis';
import { strictAssert } from '../../../util/assert';
import { FunEmoji } from '../FunEmoji';
} from './data/emojis';
import { strictAssert } from '../../util/assert';
import { FunStaticEmoji } from './FunEmoji';
import type { LocalizerType } from '../../types/I18N';
export type SkinTonesListBoxProps = Readonly<{
export type FunSkinTonesListProps = Readonly<{
i18n: LocalizerType;
emoji: EmojiParentKey;
skinTone: EmojiSkinTone;
onSelectSkinTone: (skinTone: EmojiSkinTone) => void;
}>;
export function SkinTonesListBox(props: SkinTonesListBoxProps): JSX.Element {
const { onSelectSkinTone } = props;
export function FunSkinTonesList(props: FunSkinTonesListProps): JSX.Element {
const { i18n, onSelectSkinTone } = props;
const handleSelectionChange = useCallback(
(keys: Selection) => {
@@ -32,50 +34,68 @@ export function SkinTonesListBox(props: SkinTonesListBoxProps): JSX.Element {
return (
<ListBox
aria-label={i18n('icu:FunSkinTones__List')}
className="FunSkinTones__ListBox"
orientation="horizontal"
selectedKeys={[props.skinTone]}
selectionMode="single"
disallowEmptySelection
onSelectionChange={handleSelectionChange}
>
<SkinTonesListBoxItem emoji={props.emoji} skinTone={EmojiSkinTone.None} />
<SkinTonesListBoxItem
<FunSkinTonesListItem
emoji={props.emoji}
aria-label={i18n('icu:FunSkinTones__ListItem--Light')}
skinTone={EmojiSkinTone.None}
/>
<FunSkinTonesListItem
emoji={props.emoji}
skinTone={EmojiSkinTone.Type1}
aria-label={i18n('icu:FunSkinTones__ListItem--Light')}
/>
<SkinTonesListBoxItem
<FunSkinTonesListItem
emoji={props.emoji}
skinTone={EmojiSkinTone.Type2}
aria-label={i18n('icu:FunSkinTones__ListItem--MediumLight')}
/>
<SkinTonesListBoxItem
<FunSkinTonesListItem
emoji={props.emoji}
skinTone={EmojiSkinTone.Type3}
aria-label={i18n('icu:FunSkinTones__ListItem--Medium')}
/>
<SkinTonesListBoxItem
<FunSkinTonesListItem
emoji={props.emoji}
skinTone={EmojiSkinTone.Type4}
aria-label={i18n('icu:FunSkinTones__ListItem--MediumDark')}
/>
<SkinTonesListBoxItem
<FunSkinTonesListItem
emoji={props.emoji}
skinTone={EmojiSkinTone.Type5}
aria-label={i18n('icu:FunSkinTones__ListItem--Dark')}
/>
</ListBox>
);
}
type SkinTonesListBoxItemProps = Readonly<{
type FunSkinTonesListItemProps = Readonly<{
emoji: EmojiParentKey;
'aria-label': string;
skinTone: EmojiSkinTone;
}>;
function SkinTonesListBoxItem(props: SkinTonesListBoxItemProps) {
function FunSkinTonesListItem(props: FunSkinTonesListItemProps) {
const variant = useMemo(() => {
return getEmojiVariantByParentKeyAndSkinTone(props.emoji, props.skinTone);
}, [props.emoji, props.skinTone]);
return (
<ListBoxItem id={props.skinTone} className="FunSkinTones__ListBoxItem">
<FunEmoji role="presentation" aria-label="" size={32} emoji={variant} />
<ListBoxItem
id={props.skinTone}
className="FunSkinTones__ListBoxItem"
aria-label={props['aria-label']}
>
<div className="FunSkinTones__ListBoxItemButton">
<FunStaticEmoji role="presentation" size={32} emoji={variant} />
</div>
</ListBoxItem>
);
}

View File

@@ -0,0 +1,36 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import type { Meta } from '@storybook/react';
import { FunSticker, type FunStickerProps } from './FunSticker';
export default {
title: 'Components/Fun/FunSticker',
} satisfies Meta<FunStickerProps>;
export function Default(): JSX.Element {
return (
<>
<p>with reduce motion:</p>
{/* eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex */}
<div tabIndex={0}>
<FunSticker
src="/fixtures/giphy-GVNvOUpeYmI7e.gif"
// src="/fixtures/kitten-1-64-64.jpg"
size={68}
role="img"
aria-label="Sticker"
/>
</div>
<p>without reduce motion:</p>
<FunSticker
src="/fixtures/giphy-GVNvOUpeYmI7e.gif"
// src="/fixtures/kitten-1-64-64.jpg"
size={68}
role="img"
aria-label="Sticker"
ignoreReducedMotion
/>
</>
);
}

View File

@@ -0,0 +1,26 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { FunImage } from './base/FunImage';
import type { FunImageAriaProps } from './types';
export type FunStickerProps = FunImageAriaProps &
Readonly<{
src: string;
size: number;
ignoreReducedMotion?: boolean;
}>;
export function FunSticker(props: FunStickerProps): JSX.Element {
const { src, size, ignoreReducedMotion, ...ariaProps } = props;
return (
<FunImage
{...ariaProps}
className="FunItem__Sticker"
src={src}
width={size}
height={size}
ignoreReducedMotion={ignoreReducedMotion}
/>
);
}

View File

@@ -0,0 +1,62 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { StrictMode } from 'react';
import { Button } from 'react-aria-components';
import { action } from '@storybook/addon-actions';
import enMessages from '../../../_locales/en/messages.json';
import { type ComponentMeta } from '../../storybook/types';
import { setupI18n } from '../../util/setupI18n';
import type { FunStickerPickerProps } from './FunStickerPicker';
import { FunStickerPicker } from './FunStickerPicker';
import { MOCK_RECENT_EMOJIS } from './mocks';
import { FunProvider } from './FunProvider';
import { packs, recentStickers } from '../stickers/mocks';
import { EmojiSkinTone } from './data/emojis';
const i18n = setupI18n('en', enMessages);
type TemplateProps = Omit<FunStickerPickerProps, 'children'>;
function Template(props: TemplateProps): JSX.Element {
return (
<StrictMode>
<FunProvider
i18n={i18n}
// Recents
recentEmojis={MOCK_RECENT_EMOJIS}
recentStickers={recentStickers}
recentGifs={[]}
// Emojis
emojiSkinToneDefault={EmojiSkinTone.None}
onEmojiSkinToneDefaultChange={() => null}
// Stickers
installedStickerPacks={packs}
showStickerPickerHint={false}
onClearStickerPickerHint={() => null}
// Gifs
fetchGifsSearch={() => Promise.reject()}
fetchGifsFeatured={() => Promise.reject()}
fetchGif={() => Promise.reject()}
>
<FunStickerPicker {...props}>
<Button>Open StickerPicker</Button>
</FunStickerPicker>
</FunProvider>
</StrictMode>
);
}
export default {
title: 'Components/Fun/FunStickerPicker',
component: Template,
args: {
placement: 'bottom',
defaultOpen: true,
onSelectSticker: action('onSelectSticker'),
onOpenChange: action('onOpenChange'),
},
} satisfies ComponentMeta<TemplateProps>;
export function Default(props: TemplateProps): JSX.Element {
return <Template {...props} />;
}

View File

@@ -0,0 +1,58 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { ReactNode } from 'react';
import React, { memo, useCallback, useEffect, useState } from 'react';
import type { Placement } from 'react-aria';
import { DialogTrigger } from 'react-aria-components';
import { FunPopover } from './base/FunPopover';
import type { FunStickerSelection } from './panels/FunPanelStickers';
import { FunPanelStickers } from './panels/FunPanelStickers';
import { useFunContext } from './FunProvider';
export type FunStickerPickerProps = Readonly<{
placement?: Placement;
defaultOpen?: boolean;
onOpenChange?: (isOpen: boolean) => void;
onSelectSticker: (stickerSelection: FunStickerSelection) => void;
children: ReactNode;
}>;
export const FunStickerPicker = memo(function FunStickerPicker(
props: FunStickerPickerProps
): JSX.Element {
const { onOpenChange } = props;
const fun = useFunContext();
const { onClose } = fun;
const [isOpen, setIsOpen] = useState(props.defaultOpen ?? false);
const handleOpenChange = useCallback(
(nextIsOpen: boolean) => {
setIsOpen(nextIsOpen);
onOpenChange?.(nextIsOpen);
},
[onOpenChange]
);
const handleClose = useCallback(() => {
setIsOpen(false);
}, []);
useEffect(() => {
if (!isOpen) {
onClose();
}
}, [isOpen, onClose]);
return (
<DialogTrigger isOpen={isOpen} onOpenChange={handleOpenChange}>
{props.children}
<FunPopover placement={props.placement}>
<FunPanelStickers
onSelectSticker={props.onSelectSticker}
onClose={handleClose}
onAddStickerPack={null}
/>
</FunPopover>
</DialogTrigger>
);
});

View File

@@ -1,22 +1,50 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { RefObject } from 'react';
import React, { useRef, useEffect, useState } from 'react';
import type { ForwardedRef, RefObject } from 'react';
import React, { useRef, useEffect, useState, forwardRef } from 'react';
import classNames from 'classnames';
import { isFocusable } from '@react-aria/focus';
import { strictAssert } from '../../../util/assert';
import { useReducedMotion } from '../../../hooks/useReducedMotion';
import type { FunImageAriaProps } from '../types';
export type FunAnimatedImageProps = Readonly<{
role: 'image' | 'presentation';
className?: string;
src: string;
width: number;
height: number;
alt: string;
}>;
export type FunImageProps = FunImageAriaProps &
Readonly<{
className?: string;
src: string;
width: number;
height: number;
ignoreReducedMotion?: boolean;
}>;
export function FunImage(props: FunAnimatedImageProps): JSX.Element {
export function FunImage(props: FunImageProps): JSX.Element {
if (props.ignoreReducedMotion) {
return <FunImageBase {...props} />;
}
return <FunImageReducedMotion {...props} />;
}
/** @internal */
const FunImageBase = forwardRef(function FunImageBase(
props: FunImageProps,
ref: ForwardedRef<HTMLImageElement>
) {
return (
<img
ref={ref}
role={props.role}
aria-label={props['aria-label']}
className={props.className}
src={props.src}
width={props.width}
height={props.height}
draggable={false}
/>
);
});
/** @internal */
function FunImageReducedMotion(props: FunImageProps) {
const imageRef = useRef<HTMLImageElement>(null);
const intent = useIntent(imageRef);
const [staticSource, setStaticSource] = useState<string | null>(null);
@@ -69,16 +97,7 @@ export function FunImage(props: FunAnimatedImageProps): JSX.Element {
{staticSource != null && reducedMotion && !intent && (
<source className="FunImage--StaticSource" srcSet={staticSource} />
)}
{/* Using <img> to benefit from browser */}
<img
ref={imageRef}
role={props.role}
className={props.className}
src={props.src}
width={props.width}
height={props.height}
alt={props.alt}
/>
<FunImageBase {...props} ref={imageRef} />
</picture>
);
}
@@ -108,7 +127,7 @@ function closestElement(
* - However, this will break if elements become focusable/unfocusable during
* their lifetime (this is generally a sign something is being done wrong).
*/
function useIntent(ref: RefObject<HTMLElement>): boolean {
export function useIntent(ref: RefObject<HTMLElement>): boolean {
const [intent, setIntent] = useState(false);
useEffect(() => {

View File

@@ -1,8 +1,7 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { MouseEvent, ReactNode } from 'react';
import type { MouseEvent as ReactMouseEvent, ReactNode } from 'react';
import React from 'react';
import { FunImage } from './FunImage';
/**
* Button
@@ -10,9 +9,8 @@ import { FunImage } from './FunImage';
export type FunItemButtonProps = Readonly<{
'aria-label': string;
'aria-describedby'?: string;
tabIndex: number;
onClick: (event: MouseEvent) => void;
onClick: (event: ReactMouseEvent) => void;
children: ReactNode;
}>;
@@ -22,7 +20,6 @@ export function FunItemButton(props: FunItemButtonProps): JSX.Element {
type="button"
className="FunItem__Button"
aria-label={props['aria-label']}
aria-describedby={props['aria-describedby']}
onClick={props.onClick}
tabIndex={props.tabIndex}
>
@@ -30,48 +27,3 @@ export function FunItemButton(props: FunItemButtonProps): JSX.Element {
</button>
);
}
/**
* Sticker
*/
export type FunItemStickerProps = Readonly<{
src: string;
}>;
export function FunItemSticker(props: FunItemStickerProps): JSX.Element {
return (
<FunImage
role="presentation"
className="FunItem__Sticker"
src={props.src}
width={68}
height={68}
alt=""
/>
);
}
/**
* Gif
*/
export type FunItemGifProps = Readonly<{
src: string;
width: number;
height: number;
}>;
export function FunItemGif(props: FunItemGifProps): JSX.Element {
return (
<FunImage
role="presentation"
className="FunItem__Gif"
src={props.src}
width={props.width}
height={props.height}
// For presentation only
alt=""
/>
);
}

View File

@@ -0,0 +1,158 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { ReactNode, RefObject } from 'react';
import React, { createContext, useContext, useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { strictAssert } from '../../../util/assert';
/**
* Tracks the current `data-key` that has a long-press/long-focus
*/
const FunLightboxKeyContext = createContext<string | null>(null);
export function useFunLightboxKey(): string | null {
return useContext(FunLightboxKeyContext);
}
/**
* Provider
*/
export type FunLightboxProviderProps = Readonly<{
containerRef: RefObject<HTMLDivElement>;
children: ReactNode;
}>;
export function FunLightboxProvider(
props: FunLightboxProviderProps
): JSX.Element {
const [lightboxKey, setLightboxKey] = useState<string | null>(null);
useEffect(() => {
strictAssert(props.containerRef.current, 'Missing container ref');
const container = props.containerRef.current;
let isLongPressed = false;
let lastLongPress: number | null = null;
let currentKey: string | null;
let timer: NodeJS.Timeout | undefined;
function lookupKey(event: Event): string | null {
if (!(event.target instanceof HTMLElement)) {
return null;
}
const closest = event.target.closest('[data-key]');
if (!(closest instanceof HTMLElement)) {
return null;
}
const { key } = closest.dataset;
strictAssert(key, 'Must have key');
return key;
}
function update() {
if (isLongPressed && currentKey != null) {
setLightboxKey(currentKey);
} else {
setLightboxKey(null);
}
}
function onMouseDown(event: MouseEvent) {
currentKey = lookupKey(event);
timer = setTimeout(() => {
isLongPressed = true;
update();
}, 500);
}
function onMouseUp(event: MouseEvent) {
clearTimeout(timer);
if (isLongPressed) {
lastLongPress = event.timeStamp;
isLongPressed = false;
currentKey = null;
update();
}
}
function onMouseMove(event: MouseEvent) {
const foundKey = lookupKey(event);
if (foundKey != null) {
currentKey = lookupKey(event);
update();
}
}
function onClick(event: MouseEvent) {
if (event.timeStamp === lastLongPress) {
event.stopImmediatePropagation();
}
}
container.addEventListener('mousedown', onMouseDown);
container.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
window.addEventListener('click', onClick, { capture: true });
return () => {
container.removeEventListener('mousedown', onMouseDown);
container.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp);
window.addEventListener('click', onClick, { capture: true });
};
}, [props.containerRef]);
return (
<FunLightboxKeyContext.Provider value={lightboxKey}>
{props.children}
</FunLightboxKeyContext.Provider>
);
}
/**
* Portal
*/
export type FunLightboxPortalProps = Readonly<{
children: ReactNode;
}>;
export function FunLightboxPortal(props: FunLightboxPortalProps): JSX.Element {
return createPortal(props.children, document.body);
}
/**
* Backdrop
*/
export type FunLightboxBackdropProps = Readonly<{
children: ReactNode;
}>;
export function FunLightboxBackdrop(
props: FunLightboxBackdropProps
): JSX.Element {
return <div className="FunLightbox__Backdrop">{props.children}</div>;
}
/**
* Dialog
*/
export type FunLightboxDialogProps = Readonly<{
'aria-label': string;
children: ReactNode;
}>;
export function FunLightboxDialog(props: FunLightboxDialogProps): JSX.Element {
return (
<div
role="dialog"
className="FunLightbox__Dialog"
aria-label={props['aria-label']}
>
{props.children}
</div>
);
}

View File

@@ -1,8 +1,11 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useCallback } from 'react';
import { VisuallyHidden } from 'react-aria';
import type { LocalizerType } from '../../../types/I18N';
export type FunSearchProps = Readonly<{
i18n: LocalizerType;
'aria-label': string;
placeholder: string;
searchInput: string;
@@ -10,7 +13,8 @@ export type FunSearchProps = Readonly<{
}>;
export function FunSearch(props: FunSearchProps): JSX.Element {
const { onSearchInputChange } = props;
const { i18n, onSearchInputChange } = props;
const handleChange = useCallback(
event => {
onSearchInputChange(event.target.value);
@@ -18,6 +22,10 @@ export function FunSearch(props: FunSearchProps): JSX.Element {
[onSearchInputChange]
);
const handleClear = useCallback(() => {
onSearchInputChange('');
}, [onSearchInputChange]);
return (
<div className="FunSearch__Container">
<div className="FunSearch__Icon" />
@@ -29,6 +37,19 @@ export function FunSearch(props: FunSearchProps): JSX.Element {
onChange={handleChange}
placeholder={props.placeholder}
/>
{props.searchInput !== '' && (
<button
type="button"
className="FunSearch__Clear"
onClick={handleClear}
>
<span className="FunSearch__ClearButton">
<VisuallyHidden>
{i18n('icu:FunSearch__ClearButtonLabel')}
</VisuallyHidden>
</span>
</button>
)}
</div>
);
}

View File

@@ -279,8 +279,6 @@ export function FunSubNavImage(props: FunSubNavImageProps): JSX.Element {
src={props.src}
width={26}
height={26}
// presentational
alt=""
/>
);
}

View File

@@ -7,7 +7,7 @@ import React, { useCallback } from 'react';
import type { Key } from 'react-aria';
import { useId } from 'react-aria';
import { Tab, TabList, TabPanel, Tabs } from 'react-aria-components';
import type { FunPickerTabKey } from '../FunConstants';
import type { FunPickerTabKey } from '../constants';
export type FunTabsProps = Readonly<{
value: FunPickerTabKey;

View File

@@ -60,17 +60,3 @@ export const FunEmojisSectionOrder: ReadonlyArray<
EmojiPickerCategory.Symbols,
EmojiPickerCategory.Flags,
];
export const FunGifsSectionOrder: ReadonlyArray<
FunSectionCommon.Recents | FunGifsCategory
> = [
FunSectionCommon.Recents,
FunGifsCategory.Trending,
FunGifsCategory.Celebrate,
FunGifsCategory.Love,
FunGifsCategory.ThumbsUp,
FunGifsCategory.Surprised,
FunGifsCategory.Excited,
FunGifsCategory.Sad,
FunGifsCategory.Angry,
];

View File

@@ -52,8 +52,24 @@ export enum EmojiSkinTone {
Type5 = 'EmojiSkinTone.Type5', // 1F3FF
}
export function isValidEmojiSkinTone(value: unknown): value is EmojiSkinTone {
return (
typeof value === 'string' &&
EMOJI_SKIN_TONE_ORDER.includes(value as EmojiSkinTone)
);
}
export const EMOJI_SKIN_TONE_ORDER: ReadonlyArray<EmojiSkinTone> = [
EmojiSkinTone.None,
EmojiSkinTone.Type1,
EmojiSkinTone.Type2,
EmojiSkinTone.Type3,
EmojiSkinTone.Type4,
EmojiSkinTone.Type5,
];
/** @deprecated We should use `EmojiSkinTone` everywhere */
export const SKIN_TONE_TO_NUMBER: Map<EmojiSkinTone, number> = new Map([
export const EMOJI_SKIN_TONE_TO_NUMBER: Map<EmojiSkinTone, number> = new Map([
[EmojiSkinTone.None, 0],
[EmojiSkinTone.Type1, 1],
[EmojiSkinTone.Type2, 2],
@@ -63,24 +79,22 @@ export const SKIN_TONE_TO_NUMBER: Map<EmojiSkinTone, number> = new Map([
]);
/** @deprecated We should use `EmojiSkinTone` everywhere */
export const NUMBER_TO_SKIN_TONE: Map<number, EmojiSkinTone> = new Map([
[0, EmojiSkinTone.None],
[1, EmojiSkinTone.Type1],
[2, EmojiSkinTone.Type2],
[3, EmojiSkinTone.Type3],
[4, EmojiSkinTone.Type4],
[5, EmojiSkinTone.Type5],
export const KEY_TO_EMOJI_SKIN_TONE = new Map<string, EmojiSkinTone>([
['1F3FB', EmojiSkinTone.Type1],
['1F3FC', EmojiSkinTone.Type2],
['1F3FD', EmojiSkinTone.Type3],
['1F3FE', EmojiSkinTone.Type4],
['1F3FF', EmojiSkinTone.Type5],
]);
export type EmojiSkinToneVariant = Exclude<EmojiSkinTone, EmojiSkinTone.None>;
const KeyToEmojiSkinTone: Record<string, EmojiSkinToneVariant> = {
'1F3FB': EmojiSkinTone.Type1,
'1F3FC': EmojiSkinTone.Type2,
'1F3FD': EmojiSkinTone.Type3,
'1F3FE': EmojiSkinTone.Type4,
'1F3FF': EmojiSkinTone.Type5,
};
/** @deprecated We should use `EmojiSkinTone` everywhere */
export const EMOJI_SKIN_TONE_TO_KEY: Map<EmojiSkinTone, string> = new Map([
[EmojiSkinTone.Type1, '1F3FB'],
[EmojiSkinTone.Type2, '1F3FC'],
[EmojiSkinTone.Type3, '1F3FD'],
[EmojiSkinTone.Type4, '1F3FE'],
[EmojiSkinTone.Type5, '1F3FF'],
]);
export type EmojiParentKey = string & { EmojiParentKey: never };
export type EmojiVariantKey = string & { EmojiVariantKey: never };
@@ -94,18 +108,17 @@ export type EmojiEnglishShortName = string & { EmojiEnglishShortName: never };
export type EmojiVariantData = Readonly<{
key: EmojiVariantKey;
value: EmojiVariantValue;
valueNonqualified: EmojiVariantValue | null;
sheetX: number;
sheetY: number;
}>;
type EmojiDefaultSkinToneVariants = Record<
EmojiSkinToneVariant,
EmojiVariantKey
>;
type EmojiDefaultSkinToneVariants = Record<EmojiSkinTone, EmojiVariantKey>;
export type EmojiParentData = Readonly<{
key: EmojiParentKey;
value: EmojiParentValue;
valueNonqualified: EmojiParentValue | null;
unicodeCategory: EmojiUnicodeCategory;
pickerCategory: EmojiPickerCategory | null;
defaultVariant: EmojiVariantKey;
@@ -124,6 +137,7 @@ export type EmojiParentData = Readonly<{
const RawEmojiSkinToneSchema = z.object({
unified: z.string(),
non_qualified: z.union([z.string(), z.null()]),
sheet_x: z.number(),
sheet_y: z.number(),
has_img_apple: z.boolean(),
@@ -133,6 +147,7 @@ const RawEmojiSkinToneMapSchema = z.record(z.string(), RawEmojiSkinToneSchema);
const RawEmojiSchema = z.object({
unified: z.string(),
non_qualified: z.union([z.string(), z.null()]),
category: z.string(),
sort_order: z.number(),
sheet_x: z.number(),
@@ -282,6 +297,9 @@ const EMOJI_INDEX: EmojiIndex = {
function addParent(parent: EmojiParentData, rank: number) {
EMOJI_INDEX.parentByKey[parent.key] = parent;
EMOJI_INDEX.parentKeysByValue[parent.value] = parent.key;
if (parent.valueNonqualified != null) {
EMOJI_INDEX.parentKeysByValue[parent.valueNonqualified] = parent.key;
}
EMOJI_INDEX.parentKeysByName[parent.englishShortNameDefault] = parent.key;
EMOJI_INDEX.unicodeCategories[parent.unicodeCategory].push(parent.key);
if (parent.pickerCategory != null) {
@@ -306,6 +324,9 @@ function addVariant(parentKey: EmojiParentKey, variant: EmojiVariantData) {
EMOJI_INDEX.parentKeysByVariantKeys[variant.key] = parentKey;
EMOJI_INDEX.variantByKey[variant.key] = variant;
EMOJI_INDEX.variantKeysByValue[variant.value] = variant.key;
if (variant.valueNonqualified) {
EMOJI_INDEX.variantKeysByValue[variant.valueNonqualified] = variant.key;
}
}
for (const rawEmoji of RAW_EMOJI_DATA) {
@@ -314,6 +335,10 @@ for (const rawEmoji of RAW_EMOJI_DATA) {
const defaultVariant: EmojiVariantData = {
key: toEmojiVariantKey(rawEmoji.unified),
value: toEmojiVariantValue(rawEmoji.unified),
valueNonqualified:
rawEmoji.non_qualified != null
? toEmojiVariantValue(rawEmoji.non_qualified)
: null,
sheetX: rawEmoji.sheet_x,
sheetY: rawEmoji.sheet_y,
};
@@ -331,6 +356,10 @@ for (const rawEmoji of RAW_EMOJI_DATA) {
const skinToneVariant: EmojiVariantData = {
key: variantKey,
value: toEmojiVariantValue(value.unified),
valueNonqualified:
rawEmoji.non_qualified != null
? toEmojiVariantValue(rawEmoji.non_qualified)
: null,
sheetX: value.sheet_x,
sheetY: value.sheet_y,
};
@@ -339,7 +368,7 @@ for (const rawEmoji of RAW_EMOJI_DATA) {
}
const result: Partial<EmojiDefaultSkinToneVariants> = {};
for (const [key, skinTone] of Object.entries(KeyToEmojiSkinTone)) {
for (const [key, skinTone] of KEY_TO_EMOJI_SKIN_TONE) {
const one = map.get(key) ?? null;
const two = map.get(`${key}-${key}`) ?? null;
const variantKey = one ?? two;
@@ -356,6 +385,10 @@ for (const rawEmoji of RAW_EMOJI_DATA) {
const parent: EmojiParentData = {
key: toEmojiParentKey(rawEmoji.unified),
value: toEmojiParentValue(rawEmoji.unified),
valueNonqualified:
rawEmoji.non_qualified != null
? toEmojiParentValue(rawEmoji.non_qualified)
: null,
unicodeCategory: toEmojiUnicodeCategory(rawEmoji.category),
pickerCategory: toEmojiPickerCategory(rawEmoji.category),
defaultVariant: defaultVariant.key,
@@ -404,16 +437,6 @@ export function getEmojiVariantByKey(key: EmojiVariantKey): EmojiVariantData {
return data;
}
export function getEmojiParentKeyByValueUnsafe(input: string): EmojiParentKey {
strictAssert(
isEmojiParentValue(input),
`Missing emoji parent value for input "${input}"`
);
const key = EMOJI_INDEX.parentKeysByValue[input];
strictAssert(key, `Missing emoji parent key for input "${input}"`);
return key;
}
export function getEmojiParentKeyByValue(
value: EmojiParentValue
): EmojiParentKey {
@@ -492,6 +515,14 @@ export function* _allEmojiVariantKeys(): Iterable<EmojiVariantKey> {
yield* Object.keys(EMOJI_INDEX.variantByKey) as Array<EmojiVariantKey>;
}
export function emojiParentKeyConstant(input: string): EmojiParentKey {
strictAssert(
isEmojiParentValue(input),
`Missing emoji parent for value "${input}"`
);
return getEmojiParentKeyByValue(input);
}
export function emojiVariantConstant(input: string): EmojiVariantData {
strictAssert(
isEmojiVariantValue(input),

View File

@@ -10,7 +10,7 @@ import type {
} from './tenor';
import { tenor, isTenorTailCursor } from './tenor';
const PREVIEW_CONTENT_FORMAT: TenorContentFormat = 'mediumgif';
const PREVIEW_CONTENT_FORMAT: TenorContentFormat = 'tinymp4';
const ATTACHMENT_CONTENT_FORMAT: TenorContentFormat = 'mp4';
function toGif(result: TenorResponseResult): GifType {
@@ -40,7 +40,7 @@ export type GifsPaginated = Readonly<{
gifs: ReadonlyArray<GifType>;
}>;
export async function fetchFeatured(
export async function fetchGifsFeatured(
limit: number,
cursor: TenorNextCursor | null,
signal?: AbortSignal
@@ -48,7 +48,7 @@ export async function fetchFeatured(
const response = await tenor(
'v2/featured',
{
// contentfilter: 'medium',
contentfilter: 'low',
media_filter: [PREVIEW_CONTENT_FORMAT, ATTACHMENT_CONTENT_FORMAT],
limit,
pos: cursor ?? undefined,
@@ -61,7 +61,7 @@ export async function fetchFeatured(
return { next, gifs };
}
export async function fetchSearch(
export async function fetchGifsSearch(
query: string,
limit: number,
cursor: TenorNextCursor | null,
@@ -71,7 +71,7 @@ export async function fetchSearch(
'v2/search',
{
q: query,
contentfilter: 'medium',
contentfilter: 'low',
media_filter: [PREVIEW_CONTENT_FORMAT, ATTACHMENT_CONTENT_FORMAT],
limit,
pos: cursor ?? undefined,

View File

@@ -52,7 +52,7 @@ export function useInfiniteQuery<Query, Page>(
const [edition, setEdition] = useState(0);
const [state, setState] = useState<InfiniteQueryState<Query, Page>>({
query: options.query,
pending: false,
pending: true,
rejected: false,
pages: [],
hasNextPage: false,

View File

@@ -0,0 +1,132 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { strictAssert } from '../../../util/assert';
/** @internal Exported for testing */
export const _SEGMENT_SIZE_BUCKETS: ReadonlyArray<number> = [
// highest to lowest
1024 * 1024, // 1MiB
1024 * 500, // 500 KiB
1024 * 100, // 100 KiB
1024 * 50, // 50 KiB
1024 * 10, // 10 KiB
1024 * 1, // 1 KiB
];
/** @internal Exported for testing */
export type _SegmentRange = Readonly<{
startIndex: number;
endIndexInclusive: number;
sliceStart: number;
segmentSize: number;
sliceSize: number;
}>;
async function fetchContentLength(
url: string,
signal?: AbortSignal
): Promise<number> {
const { messaging } = window.textsecure;
strictAssert(messaging, 'Missing window.textsecure.messaging');
const { response } = await messaging.server.fetchBytesViaProxy({
url,
method: 'HEAD',
signal,
});
const contentLength = Number(response.headers.get('Content-Length'));
strictAssert(
Number.isInteger(contentLength),
'Content-Length must be integer'
);
return contentLength;
}
/** @internal Exported for testing */
export function _getSegmentSize(contentLength: number): number {
const nextLargestSegmentSize = _SEGMENT_SIZE_BUCKETS.find(segmentSize => {
return contentLength >= segmentSize;
});
// If too small, return the content length
return nextLargestSegmentSize ?? contentLength;
}
/** @internal Exported for testing */
export function _getSegmentRanges(
contentLength: number,
segmentSize: number
): ReadonlyArray<_SegmentRange> {
const segmentRanges: Array<_SegmentRange> = [];
const segmentCount = Math.ceil(contentLength / segmentSize);
for (let index = 0; index < segmentCount; index += 1) {
let startIndex = segmentSize * index;
let endIndexInclusive = startIndex + segmentSize - 1;
let sliceSize = segmentSize;
let sliceStart = 0;
if (endIndexInclusive > contentLength) {
endIndexInclusive = contentLength - 1;
startIndex = contentLength - segmentSize;
sliceSize = contentLength % segmentSize;
sliceStart = segmentSize - sliceSize;
}
segmentRanges.push({
startIndex,
endIndexInclusive,
sliceStart,
segmentSize,
sliceSize,
});
}
return segmentRanges;
}
async function fetchSegment(
url: string,
segmentRange: _SegmentRange,
signal?: AbortSignal
): Promise<ArrayBufferView> {
const { messaging } = window.textsecure;
strictAssert(messaging, 'Missing window.textsecure.messaging');
const { data } = await messaging.server.fetchBytesViaProxy({
method: 'GET',
url,
signal,
headers: {
Range: `bytes=${segmentRange.startIndex}-${segmentRange.endIndexInclusive}`,
},
});
strictAssert(
data.buffer.byteLength === segmentRange.segmentSize,
'Response buffer should be exact length of segment range'
);
let slice: ArrayBufferView;
// Trim duplicate bytes from start of last segment
if (segmentRange.sliceStart > 0) {
slice = new Uint8Array(data.buffer.slice(segmentRange.sliceStart));
} else {
slice = data;
}
strictAssert(
slice.byteLength === segmentRange.sliceSize,
'Slice buffer should be exact length of segment range slice'
);
return slice;
}
export async function fetchInSegments(
url: string,
signal?: AbortSignal
): Promise<Blob> {
const contentLength = await fetchContentLength(url, signal);
const segmentSize = _getSegmentSize(contentLength);
const segmentRanges = _getSegmentRanges(contentLength, segmentSize);
const segmentBuffers = await Promise.all(
segmentRanges.map(segmentRange => {
return fetchSegment(url, segmentRange, signal);
})
);
return new Blob(segmentBuffers);
}

View File

@@ -5,6 +5,7 @@ import { z } from 'zod';
import type { Simplify } from 'type-fest';
import { strictAssert } from '../../../util/assert';
import { parseUnknown } from '../../../util/schemas';
import { fetchInSegments } from './segments';
const BASE_URL = 'https://tenor.googleapis.com/v2';
const API_KEY = 'AIzaSyBt6SUfSsCQic2P2VkNkLjsGI7HGWZI95g';
@@ -215,23 +216,18 @@ export async function tenor<Path extends keyof TenorEndpoints>(
url.searchParams.set(key, param);
}
const response = await messaging.server.fetchJsonViaProxy(
url.toString(),
signal
);
const response = await messaging.server.fetchJsonViaProxy({
method: 'GET',
url: url.toString(),
signal,
});
const result = parseUnknown(schema, response.data);
return result;
}
export async function tenorDownload(
export function tenorDownload(
tenorCdnUrl: string,
signal?: AbortSignal
): Promise<Uint8Array> {
const { messaging } = window.textsecure;
strictAssert(messaging, 'Missing window.textsecure.messaging');
const response = await messaging.server.fetchBytesViaProxy(
tenorCdnUrl,
signal
);
return response.data;
): Promise<Blob> {
return fetchInSegments(tenorCdnUrl, signal);
}

View File

@@ -7,6 +7,7 @@ import {
getEmojiParentKeyByEnglishShortName,
isEmojiEnglishShortName,
} from './data/emojis';
import type { GifsPaginated } from './data/gifs';
function getEmoji(input: string): EmojiParentKey {
strictAssert(
@@ -50,3 +51,29 @@ export const MOCK_RECENT_EMOJIS: ReadonlyArray<EmojiParentKey> = [
getEmoji('open_mouth'),
getEmoji('zipper_mouth_face'),
];
export const MOCK_GIFS_PAGINATED_EMPTY: GifsPaginated = {
next: null,
gifs: [],
};
export const MOCK_GIFS_PAGINATED_ONE_PAGE: GifsPaginated = {
next: null,
gifs: Array.from({ length: 30 }, (_, i) => {
return {
id: String(i),
title: '',
description: '',
previewMedia: {
url: 'https://media.tenor.com/ihqN6a3iiYEAAAPo/pikachu-shocked-face-stunned.mp4',
width: 640,
height: 640,
},
attachmentMedia: {
url: 'https://media.tenor.com/ihqN6a3iiYEAAAPo/pikachu-shocked-face-stunned.mp4',
width: 640,
height: 640,
},
};
}),
};

View File

@@ -6,8 +6,8 @@ import { DialogTrigger } from 'react-aria-components';
import type { LocalizerType } from '../../../types/I18N';
import { strictAssert } from '../../../util/assert';
import { missingCaseError } from '../../../util/missingCaseError';
import type { FunEmojisSection } from '../FunConstants';
import { FunEmojisSectionOrder, FunSectionCommon } from '../FunConstants';
import type { FunEmojisSection } from '../constants';
import { FunEmojisSectionOrder, FunSectionCommon } from '../constants';
import {
FunGridCell,
FunGridContainer,
@@ -37,10 +37,10 @@ import type {
EmojiVariantKey,
} from '../data/emojis';
import {
emojiParentKeyConstant,
EmojiPickerCategory,
emojiVariantConstant,
getEmojiParentByKey,
getEmojiParentKeyByValueUnsafe,
getEmojiPickerCategoryParentKeys,
getEmojiVariantByParentKeyAndSkinTone,
isEmojiParentKey,
@@ -55,8 +55,8 @@ import type {
GridSectionNode,
} from '../virtual/useFunVirtualGrid';
import { useFunVirtualGrid } from '../virtual/useFunVirtualGrid';
import { SkinTonesListBox } from '../base/FunSkinTones';
import { FunEmoji } from '../FunEmoji';
import { FunSkinTonesList } from '../FunSkinTones';
import { FunStaticEmoji } from '../FunEmoji';
import { useFunContext } from '../FunProvider';
import { FunResults, FunResultsHeader } from '../base/FunResults';
@@ -252,6 +252,7 @@ export function FunPanelEmojis({
return (
<FunPanel>
<FunSearch
i18n={i18n}
searchInput={searchInput}
onSearchInputChange={onSearchInputChange}
placeholder={i18n('icu:FunPanelEmojis__SearchLabel')}
@@ -342,11 +343,9 @@ export function FunPanelEmojis({
<FunResults aria-busy={false}>
<FunResultsHeader>
{i18n('icu:FunPanelEmojis__SearchResults__EmptyHeading')}{' '}
<FunEmoji
<FunStaticEmoji
size={16}
role="presentation"
// For presentation only
aria-label=""
emoji={emojiVariantConstant('\u{1F641}')}
/>
</FunResultsHeader>
@@ -386,8 +385,8 @@ export function FunPanelEmojis({
{section.id === EmojiPickerCategory.SmileysAndPeople && (
<SectionSkinTonePopover
i18n={i18n}
skinTone={fun.defaultEmojiSkinTone}
onSelectSkinTone={fun.onChangeDefaultEmojiSkinTone}
skinTone={fun.emojiSkinToneDefault}
onSelectSkinTone={fun.onEmojiSkinToneDefaultChange}
/>
)}
</FunGridHeader>
@@ -405,7 +404,7 @@ export function FunPanelEmojis({
rowIndex={row.rowIndex}
cells={row.cells}
focusedCellKey={focusedCellKey}
defaultEmojiSkinTone={fun.defaultEmojiSkinTone}
emojiSkinToneDefault={fun.emojiSkinToneDefault}
onPressEmoji={handlePressEmoji}
/>
);
@@ -426,7 +425,7 @@ type RowProps = Readonly<{
rowIndex: number;
cells: ReadonlyArray<CellLayoutNode>;
focusedCellKey: CellKey | null;
defaultEmojiSkinTone: EmojiSkinTone;
emojiSkinToneDefault: EmojiSkinTone;
onPressEmoji: (event: MouseEvent, emojiSelection: FunEmojiSelection) => void;
}>;
@@ -446,7 +445,7 @@ const Row = memo(function Row(props: RowProps): JSX.Element {
rowIndex={cell.rowIndex}
colIndex={cell.colIndex}
isTabbable={isTabbable}
defaultEmojiSkinTone={props.defaultEmojiSkinTone}
emojiSkinToneDefault={props.emojiSkinToneDefault}
onPressEmoji={props.onPressEmoji}
/>
);
@@ -461,7 +460,7 @@ type CellProps = Readonly<{
colIndex: number;
rowIndex: number;
isTabbable: boolean;
defaultEmojiSkinTone: EmojiSkinTone;
emojiSkinToneDefault: EmojiSkinTone;
onPressEmoji: (event: MouseEvent, emojiSelection: FunEmojiSelection) => void;
}>;
@@ -478,8 +477,8 @@ const Cell = memo(function Cell(props: CellProps): JSX.Element {
const skinTone = useMemo(() => {
// TODO(jamie): Need to implement emoji-specific skin tone preferences
return props.defaultEmojiSkinTone;
}, [props.defaultEmojiSkinTone]);
return props.emojiSkinToneDefault;
}, [props.emojiSkinToneDefault]);
const emojiVariant = useMemo(() => {
return getEmojiVariantByParentKeyAndSkinTone(emojiParent.key, skinTone);
@@ -509,12 +508,7 @@ const Cell = memo(function Cell(props: CellProps): JSX.Element {
aria-label={emojiParent.englishShortNameDefault}
onClick={handleClick}
>
<FunEmoji
role="presentation"
aria-label=""
size={32}
emoji={emojiVariant}
/>
<FunStaticEmoji role="presentation" size={32} emoji={emojiVariant} />
</FunItemButton>
</FunGridCell>
);
@@ -555,8 +549,9 @@ function SectionSkinTonePopover(
<FunGridHeaderPopoverHeader>
{i18n('icu:FunPanelEmojis__SkinTonePicker__ChooseDefaultLabel')}
</FunGridHeaderPopoverHeader>
<SkinTonesListBox
emoji={getEmojiParentKeyByValueUnsafe('\u{270B}')}
<FunSkinTonesList
i18n={i18n}
emoji={emojiParentKeyConstant('\u{270B}')}
skinTone={props.skinTone}
onSelectSkinTone={handleSelectSkinTone}
/>

View File

@@ -11,9 +11,9 @@ import React, {
useRef,
useState,
} from 'react';
import { VisuallyHidden } from 'react-aria';
import { useId, VisuallyHidden } from 'react-aria';
import { LRUCache } from 'lru-cache';
import { FunItemButton, FunItemGif } from '../base/FunItem';
import { FunItemButton } from '../base/FunItem';
import { FunPanel } from '../base/FunPanel';
import { FunScroller } from '../base/FunScroller';
import { FunSearch } from '../base/FunSearch';
@@ -24,8 +24,8 @@ import {
FunSubNavListBoxItem,
} from '../base/FunSubNav';
import { FunWaterfallContainer, FunWaterfallItem } from '../base/FunWaterfall';
import type { FunGifsSection } from '../FunConstants';
import { FunGifsCategory, FunSectionCommon } from '../FunConstants';
import type { FunGifsSection } from '../constants';
import { FunGifsCategory, FunSectionCommon } from '../constants';
import { FunKeyboard } from '../keyboard/FunKeyboard';
import type { WaterfallKeyboardState } from '../keyboard/WaterfallKeyboardDelegate';
import { WaterfallKeyboardDelegate } from '../keyboard/WaterfallKeyboardDelegate';
@@ -33,9 +33,7 @@ import { useInfiniteQuery } from '../data/infinite';
import { missingCaseError } from '../../../util/missingCaseError';
import { strictAssert } from '../../../util/assert';
import type { GifsPaginated } from '../data/gifs';
import { fetchFeatured, fetchSearch } from '../data/gifs';
import { drop } from '../../../util/drop';
import { tenorDownload } from '../data/tenor';
import { useFunContext } from '../FunProvider';
import {
FunResults,
@@ -44,8 +42,21 @@ import {
FunResultsHeader,
FunResultsSpinner,
} from '../base/FunResults';
import { FunEmoji } from '../FunEmoji';
import { FunStaticEmoji } from '../FunEmoji';
import { emojiVariantConstant } from '../data/emojis';
import {
FunLightboxPortal,
FunLightboxBackdrop,
FunLightboxDialog,
FunLightboxProvider,
useFunLightboxKey,
} from '../base/FunLightbox';
import type { tenorDownload } from '../data/tenor';
import { FunGif } from '../FunGif';
import type { LocalizerType } from '../../../types/I18N';
import { isAbortError } from '../../../util/isAbortError';
import * as log from '../../../logging/log';
import * as Errors from '../../../types/errors';
const MAX_CACHE_SIZE = 50 * 1024 * 1024; // 50 MB
const FunGifBlobCache = new LRUCache<string, Blob>({
@@ -95,7 +106,12 @@ type GifsQuery = Readonly<{
}>;
export type FunGifSelection = Readonly<{
attachmentMedia: GifMediaType;
id: string;
title: string;
description: string;
url: string;
width: number;
height: number;
}>;
export type FunPanelGifsProps = Readonly<{
@@ -115,6 +131,9 @@ export function FunPanelGifs({
selectedGifsSection,
onChangeSelectedSelectGifsSection,
recentGifs,
fetchGifsFeatured,
fetchGifsSearch,
fetchGif,
} = fun;
const scrollerRef = useRef<HTMLDivElement>(null);
@@ -142,6 +161,14 @@ export function FunPanelGifs({
});
useEffect(() => {
if (
debouncedQuery.searchQuery === searchQuery &&
debouncedQuery.selectedSection === selectedGifsSection
) {
// don't update twice
return;
}
const query: GifsQuery = {
selectedSection: selectedGifsSection,
searchQuery,
@@ -158,7 +185,7 @@ export function FunPanelGifs({
return () => {
clearTimeout(timeout);
};
}, [searchQuery, selectedGifsSection]);
}, [debouncedQuery, searchQuery, selectedGifsSection]);
const loader = useCallback(
async (
@@ -170,7 +197,7 @@ export function FunPanelGifs({
const limit = cursor != null ? 30 : 10;
if (query.searchQuery !== '') {
return fetchSearch(query.searchQuery, limit, cursor, signal);
return fetchGifsSearch(query.searchQuery, limit, cursor, signal);
}
strictAssert(
query.selectedSection !== FunSectionCommon.SearchResults,
@@ -180,33 +207,33 @@ export function FunPanelGifs({
return { next: null, gifs: recentGifs };
}
if (query.selectedSection === FunGifsCategory.Trending) {
return fetchFeatured(limit, cursor, signal);
return fetchGifsFeatured(limit, cursor, signal);
}
if (query.selectedSection === FunGifsCategory.Celebrate) {
return fetchSearch('celebrate', limit, cursor, signal);
return fetchGifsSearch('celebrate', limit, cursor, signal);
}
if (query.selectedSection === FunGifsCategory.Love) {
return fetchSearch('love', limit, cursor, signal);
return fetchGifsSearch('love', limit, cursor, signal);
}
if (query.selectedSection === FunGifsCategory.ThumbsUp) {
return fetchSearch('thumbs-up', limit, cursor, signal);
return fetchGifsSearch('thumbs-up', limit, cursor, signal);
}
if (query.selectedSection === FunGifsCategory.Surprised) {
return fetchSearch('surprised', limit, cursor, signal);
return fetchGifsSearch('surprised', limit, cursor, signal);
}
if (query.selectedSection === FunGifsCategory.Excited) {
return fetchSearch('excited', limit, cursor, signal);
return fetchGifsSearch('excited', limit, cursor, signal);
}
if (query.selectedSection === FunGifsCategory.Sad) {
return fetchSearch('sad', limit, cursor, signal);
return fetchGifsSearch('sad', limit, cursor, signal);
}
if (query.selectedSection === FunGifsCategory.Angry) {
return fetchSearch('angry', limit, cursor, signal);
return fetchGifsSearch('angry', limit, cursor, signal);
}
throw missingCaseError(query.selectedSection);
},
[recentGifs]
[recentGifs, fetchGifsSearch, fetchGifsFeatured]
);
const hasNextPage = useCallback(
@@ -275,6 +302,7 @@ export function FunPanelGifs({
scrollPaddingStart: 20,
scrollPaddingEnd: 20,
getItemKey,
initialOffset: 100,
});
// Scroll back to top when query changes
@@ -350,6 +378,7 @@ export function FunPanelGifs({
return (
<FunPanel>
<FunSearch
i18n={i18n}
searchInput={searchInput}
onSearchInputChange={handleSearchInputChange}
placeholder={i18n('icu:FunPanelGifs__SearchPlaceholder--Tenor')}
@@ -449,11 +478,9 @@ export function FunPanelGifs({
{!queryState.pending && !queryState.rejected && (
<FunResultsHeader>
{i18n('icu:FunPanelGifs__SearchResults__EmptyHeading')}{' '}
<FunEmoji
<FunStaticEmoji
size={16}
role="presentation"
// For presentation only
aria-label=""
emoji={emojiVariantConstant('\u{1F641}')}
/>
</FunResultsHeader>
@@ -461,34 +488,38 @@ export function FunPanelGifs({
</FunResults>
)}
{count !== 0 && (
<FunKeyboard
scrollerRef={scrollerRef}
keyboard={keyboard}
onStateChange={handleKeyboardStateChange}
>
<FunWaterfallContainer totalSize={virtualizer.getTotalSize()}>
{virtualizer.getVirtualItems().map(item => {
const gif = items[item.index];
const key = String(item.key);
const isTabbable =
selectedItemKey != null
? key === selectedItemKey
: item.index === 0;
return (
<Item
key={key}
gif={gif}
itemKey={key}
itemHeight={item.size}
itemOffset={item.start}
itemLane={item.lane}
isTabbable={isTabbable}
onPressGif={handlePressGif}
/>
);
})}
</FunWaterfallContainer>
</FunKeyboard>
<FunLightboxProvider containerRef={scrollerRef}>
<GifsLightbox i18n={i18n} items={items} />
<FunKeyboard
scrollerRef={scrollerRef}
keyboard={keyboard}
onStateChange={handleKeyboardStateChange}
>
<FunWaterfallContainer totalSize={virtualizer.getTotalSize()}>
{virtualizer.getVirtualItems().map(item => {
const gif = items[item.index];
const key = String(item.key);
const isTabbable =
selectedItemKey != null
? key === selectedItemKey
: item.index === 0;
return (
<Item
key={key}
gif={gif}
itemKey={key}
itemHeight={item.size}
itemOffset={item.start}
itemLane={item.lane}
isTabbable={isTabbable}
onPressGif={handlePressGif}
fetchGif={fetchGif}
/>
);
})}
</FunWaterfallContainer>
</FunKeyboard>
</FunLightboxProvider>
)}
</FunScroller>
</FunPanel>
@@ -503,16 +534,24 @@ const Item = memo(function Item(props: {
itemLane: number;
isTabbable: boolean;
onPressGif: (event: MouseEvent, gifSelection: FunGifSelection) => void;
fetchGif: typeof tenorDownload;
}) {
const { onPressGif } = props;
const { onPressGif, fetchGif } = props;
const handleClick = useCallback(
async (event: MouseEvent) => {
onPressGif(event, {
attachmentMedia: props.gif.attachmentMedia,
id: props.gif.id,
title: props.gif.title,
description: props.gif.description,
url: props.gif.attachmentMedia.url,
width: props.gif.attachmentMedia.width,
height: props.gif.attachmentMedia.height,
});
},
[props.gif, onPressGif]
);
const descriptionId = `FunGifsPanelItem__GifDescription--${props.gif.id}`;
const [src, setSrc] = useState<string | null>(() => {
const cached = readGifMediaFromCache(props.gif.previewMedia);
@@ -528,10 +567,16 @@ const Item = memo(function Item(props: {
const { signal } = controller;
async function download() {
const bytes = await tenorDownload(props.gif.previewMedia.url, signal);
const blob = new Blob([bytes]);
saveGifMediaToCache(props.gif.previewMedia, blob);
setSrc(URL.createObjectURL(blob));
try {
const bytes = await fetchGif(props.gif.previewMedia.url, signal);
const blob = new Blob([bytes]);
saveGifMediaToCache(props.gif.previewMedia, blob);
setSrc(URL.createObjectURL(blob));
} catch (error) {
if (!isAbortError(error)) {
log.error('Failed to download gif', Errors.toLogFormat(error));
}
}
}
drop(download());
@@ -539,7 +584,7 @@ const Item = memo(function Item(props: {
return () => {
controller.abort();
};
}, [props.gif, src]);
}, [props.gif, src, fetchGif]);
useEffect(() => {
return () => {
@@ -559,15 +604,15 @@ const Item = memo(function Item(props: {
>
<FunItemButton
aria-label={props.gif.title}
aria-describedby={descriptionId}
onClick={handleClick}
tabIndex={props.isTabbable ? 0 : -1}
>
{src != null && (
<FunItemGif
<FunGif
src={src}
width={props.gif.previewMedia.width}
height={props.gif.previewMedia.height}
aria-describedby={descriptionId}
/>
)}
<VisuallyHidden id={descriptionId}>
@@ -577,3 +622,59 @@ const Item = memo(function Item(props: {
</FunWaterfallItem>
);
});
function GifsLightbox(props: {
i18n: LocalizerType;
items: ReadonlyArray<GifType>;
}) {
const { i18n } = props;
const key = useFunLightboxKey();
const descriptionId = useId();
const result = useMemo(() => {
if (key == null) {
return null;
}
const gif = props.items.find(item => {
return item.id === key;
});
strictAssert(gif, `Must have gif for "${key}"`);
const blob = readGifMediaFromCache(gif.previewMedia);
strictAssert(blob, 'Missing media');
const url = URL.createObjectURL(blob);
return { gif, url };
}, [props.items, key]);
useEffect(() => {
return () => {
if (result != null) {
URL.revokeObjectURL(result.url);
}
};
}, [result]);
if (result == null) {
return null;
}
return (
<FunLightboxPortal>
<FunLightboxBackdrop>
<FunLightboxDialog
aria-label={i18n('icu:FunPanelGifs__LightboxDialog__Label')}
>
<FunGif
src={result.url}
width={result.gif.previewMedia.width}
height={result.gif.previewMedia.height}
aria-describedby={descriptionId}
ignoreReducedMotion
/>
<VisuallyHidden id={descriptionId}>
{result.gif.description}
</VisuallyHidden>
</FunLightboxDialog>
</FunLightboxBackdrop>
</FunLightboxPortal>
);
}

View File

@@ -8,12 +8,12 @@ import type {
} from '../../../state/ducks/stickers';
import type { LocalizerType } from '../../../types/I18N';
import { strictAssert } from '../../../util/assert';
import type { FunStickersSection } from '../FunConstants';
import type { FunStickersSection } from '../constants';
import {
FunSectionCommon,
FunStickersSectionBase,
toFunStickersPackSection,
} from '../FunConstants';
} from '../constants';
import {
FunGridCell,
FunGridContainer,
@@ -23,7 +23,7 @@ import {
FunGridRowGroup,
FunGridScrollerSection,
} from '../base/FunGrid';
import { FunItemButton, FunItemSticker } from '../base/FunItem';
import { FunItemButton } from '../base/FunItem';
import { FunPanel } from '../base/FunPanel';
import { FunScroller } from '../base/FunScroller';
import { FunSearch } from '../base/FunSearch';
@@ -54,7 +54,15 @@ import type {
import { useFunVirtualGrid } from '../virtual/useFunVirtualGrid';
import { useFunContext } from '../FunProvider';
import { FunResults, FunResultsHeader } from '../base/FunResults';
import { FunEmoji } from '../FunEmoji';
import { FunStaticEmoji } from '../FunEmoji';
import {
FunLightboxPortal,
FunLightboxBackdrop,
FunLightboxDialog,
FunLightboxProvider,
useFunLightboxKey,
} from '../base/FunLightbox';
import { FunSticker } from '../FunSticker';
const STICKER_GRID_COLUMNS = 4;
const STICKER_GRID_CELL_WIDTH = 80;
@@ -267,6 +275,7 @@ export function FunPanelStickers({
return (
<FunPanel>
<FunSearch
i18n={i18n}
searchInput={searchInput}
onSearchInputChange={onSearchInputChange}
placeholder={i18n('icu:FunPanelStickers__SearchPlaceholder')}
@@ -325,73 +334,74 @@ export function FunPanelStickers({
<FunResults aria-busy={false}>
<FunResultsHeader>
{i18n('icu:FunPanelStickers__SearchResults__EmptyHeading')}{' '}
<FunEmoji
<FunStaticEmoji
size={16}
role="presentation"
// For presentation only
aria-label=""
emoji={emojiVariantConstant('\u{1F641}')}
/>
</FunResultsHeader>
</FunResults>
)}
<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}
<FunLightboxProvider containerRef={scrollerRef}>
<StickersLightbox i18n={i18n} stickerLookup={stickerLookup} />
<FunKeyboard
scrollerRef={scrollerRef}
keyboard={keyboard}
onStateChange={handleKeyboardStateChange}
>
{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}
<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}
>
<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}
onPressSticker={handlePressSticker}
/>
);
})}
</FunGridRowGroup>
</FunGridScrollerSection>
);
})}
</FunGridContainer>
</FunKeyboard>
<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}
onPressSticker={handlePressSticker}
/>
);
})}
</FunGridRowGroup>
</FunGridScrollerSection>
);
})}
</FunGridContainer>
</FunKeyboard>
</FunLightboxProvider>
</FunScroller>
</FunPanel>
);
@@ -467,8 +477,46 @@ const Cell = memo(function Cell(props: {
aria-label={sticker.emoji ?? 'Sticker'}
onClick={handleClick}
>
<FunItemSticker src={sticker.url} />
<FunSticker role="presentation" src={sticker.url} size={68} />
</FunItemButton>
</FunGridCell>
);
});
function StickersLightbox(props: {
i18n: LocalizerType;
stickerLookup: StickerLookup;
}) {
const { i18n } = props;
const key = useFunLightboxKey();
const sticker = 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 (sticker == null) {
return null;
}
return (
<FunLightboxPortal>
<FunLightboxBackdrop>
<FunLightboxDialog
aria-label={i18n('icu:FunPanelStickers__LightboxDialog__Label')}
>
<FunSticker
role="img"
aria-label={sticker.emoji ?? ''}
src={sticker.url}
size={512}
ignoreReducedMotion
/>
</FunLightboxDialog>
</FunLightboxBackdrop>
</FunLightboxPortal>
);
}

View File

@@ -0,0 +1,6 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
export type FunImageAriaProps =
| Readonly<{ role: 'img'; 'aria-label': string }>
| Readonly<{ role: 'presentation'; 'aria-label'?: never }>;