mirror of
https://github.com/signalapp/Signal-Desktop.git
synced 2025-12-24 20:26:24 +00:00
Fun picker improvements
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
111
ts/components/fun/FunGif.stories.tsx
Normal file
111
ts/components/fun/FunGif.stories.tsx
Normal 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=""
|
||||
/>
|
||||
);
|
||||
}
|
||||
183
ts/components/fun/FunGif.tsx
Normal file
183
ts/components/fun/FunGif.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
36
ts/components/fun/FunSticker.stories.tsx
Normal file
36
ts/components/fun/FunSticker.stories.tsx
Normal 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
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
26
ts/components/fun/FunSticker.tsx
Normal file
26
ts/components/fun/FunSticker.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
62
ts/components/fun/FunStickerPicker.stories.tsx
Normal file
62
ts/components/fun/FunStickerPicker.stories.tsx
Normal 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} />;
|
||||
}
|
||||
58
ts/components/fun/FunStickerPicker.tsx
Normal file
58
ts/components/fun/FunStickerPicker.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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=""
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
158
ts/components/fun/base/FunLightbox.tsx
Normal file
158
ts/components/fun/base/FunLightbox.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -279,8 +279,6 @@ export function FunSubNavImage(props: FunSubNavImageProps): JSX.Element {
|
||||
src={props.src}
|
||||
width={26}
|
||||
height={26}
|
||||
// presentational
|
||||
alt=""
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
132
ts/components/fun/data/segments.ts
Normal file
132
ts/components/fun/data/segments.ts
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
6
ts/components/fun/types.tsx
Normal file
6
ts/components/fun/types.tsx
Normal 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 }>;
|
||||
Reference in New Issue
Block a user