mirror of
https://github.com/signalapp/Signal-Desktop.git
synced 2025-12-24 12:19:41 +00:00
Fun picker improvements
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
// Copyright 2025 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
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 {
|
||||
EmojiSkinTone,
|
||||
getEmojiVariantByParentKeyAndSkinTone,
|
||||
} from '../data/emojis';
|
||||
import { strictAssert } from '../../../util/assert';
|
||||
import { FunEmoji } from '../FunEmoji';
|
||||
|
||||
export type SkinTonesListBoxProps = Readonly<{
|
||||
emoji: EmojiParentKey;
|
||||
skinTone: EmojiSkinTone;
|
||||
onSelectSkinTone: (skinTone: EmojiSkinTone) => void;
|
||||
}>;
|
||||
|
||||
export function SkinTonesListBox(props: SkinTonesListBoxProps): JSX.Element {
|
||||
const { onSelectSkinTone } = props;
|
||||
|
||||
const handleSelectionChange = useCallback(
|
||||
(keys: Selection) => {
|
||||
strictAssert(keys !== 'all', 'Expected single selection');
|
||||
strictAssert(keys.size === 1, 'Expected single selection');
|
||||
const [first] = keys.values();
|
||||
onSelectSkinTone(first as EmojiSkinTone);
|
||||
},
|
||||
[onSelectSkinTone]
|
||||
);
|
||||
|
||||
return (
|
||||
<ListBox
|
||||
className="FunSkinTones__ListBox"
|
||||
orientation="horizontal"
|
||||
selectedKeys={[props.skinTone]}
|
||||
selectionMode="single"
|
||||
onSelectionChange={handleSelectionChange}
|
||||
>
|
||||
<SkinTonesListBoxItem emoji={props.emoji} skinTone={EmojiSkinTone.None} />
|
||||
<SkinTonesListBoxItem
|
||||
emoji={props.emoji}
|
||||
skinTone={EmojiSkinTone.Type1}
|
||||
/>
|
||||
<SkinTonesListBoxItem
|
||||
emoji={props.emoji}
|
||||
skinTone={EmojiSkinTone.Type2}
|
||||
/>
|
||||
<SkinTonesListBoxItem
|
||||
emoji={props.emoji}
|
||||
skinTone={EmojiSkinTone.Type3}
|
||||
/>
|
||||
<SkinTonesListBoxItem
|
||||
emoji={props.emoji}
|
||||
skinTone={EmojiSkinTone.Type4}
|
||||
/>
|
||||
<SkinTonesListBoxItem
|
||||
emoji={props.emoji}
|
||||
skinTone={EmojiSkinTone.Type5}
|
||||
/>
|
||||
</ListBox>
|
||||
);
|
||||
}
|
||||
|
||||
type SkinTonesListBoxItemProps = Readonly<{
|
||||
emoji: EmojiParentKey;
|
||||
skinTone: EmojiSkinTone;
|
||||
}>;
|
||||
|
||||
function SkinTonesListBoxItem(props: SkinTonesListBoxItemProps) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user