Composer: Support animated emojis in input (#2143)

This commit is contained in:
Alexander Zinchuk 2022-12-06 13:29:44 +01:00
parent 980e1e062d
commit a7ebcaf664
22 changed files with 604 additions and 186 deletions

View File

@ -13,7 +13,6 @@ import buildClassName from '../../util/buildClassName';
import safePlay from '../../util/safePlay';
import { selectIsAlwaysHighPriorityEmoji, selectIsDefaultEmojiStatusPack } from '../../global/selectors';
import useEnsureCustomEmoji from '../../hooks/useEnsureCustomEmoji';
import useCustomEmoji from './hooks/useCustomEmoji';
import StickerView from './StickerView';
@ -72,7 +71,6 @@ const CustomEmoji: FC<OwnProps> = ({
// An alternative to `withGlobal` to avoid adding numerous global containers
const customEmoji = useCustomEmoji(documentId);
useEnsureCustomEmoji(documentId);
const loopCountRef = useRef(0);
const [shouldLoop, setShouldLoop] = useState(true);

View File

@ -1,27 +1,17 @@
import { useCallback, useEffect, useState } from '../../../lib/teact/teact';
import { addCallback } from '../../../lib/teact/teactn';
import { getGlobal } from '../../../global';
import type { GlobalState } from '../../../global/types';
import type { ApiSticker } from '../../../api/types';
const handlers = new Set<AnyToVoidFunction>();
import { addCustomEmojiCallback, removeCustomEmojiCallback } from '../../../util/customEmojiManager';
let prevGlobal: GlobalState | undefined;
addCallback((global: GlobalState) => {
if (global.customEmojis.byId !== prevGlobal?.customEmojis.byId) {
for (const handler of handlers) {
handler();
}
}
prevGlobal = global;
});
import useEnsureCustomEmoji from '../../../hooks/useEnsureCustomEmoji';
export default function useCustomEmoji(documentId: string) {
const [customEmoji, setCustomEmoji] = useState<ApiSticker | undefined>(getGlobal().customEmojis.byId[documentId]);
useEnsureCustomEmoji(documentId);
const handleGlobalChange = useCallback(() => {
setCustomEmoji(getGlobal().customEmojis.byId[documentId]);
}, [documentId]);
@ -31,10 +21,10 @@ export default function useCustomEmoji(documentId: string) {
useEffect(() => {
if (customEmoji) return undefined;
handlers.add(handleGlobalChange);
addCustomEmojiCallback(handleGlobalChange, documentId);
return () => {
handlers.delete(handleGlobalChange);
removeCustomEmojiCallback(handleGlobalChange);
};
}, [customEmoji, documentId, handleGlobalChange]);

View File

@ -58,6 +58,7 @@ import useNativeCopySelectedMessages from '../../hooks/useNativeCopySelectedMess
import useMedia from '../../hooks/useMedia';
import useLayoutEffectWithPrevDeps from '../../hooks/useLayoutEffectWithPrevDeps';
import useEffectWithPrevDeps from '../../hooks/useEffectWithPrevDeps';
import { useResizeObserver } from '../../hooks/useResizeObserver';
import Loading from '../ui/Loading';
import MessageListContent from './MessageListContent';
@ -297,26 +298,10 @@ const MessageList: FC<OwnProps & StateProps> = ({
}, [updateStickyDates, hasTools, type, setScrollOffset, chatId, threadId]);
// Container resize observer (caused by Composer reply/webpage panels)
useEffect(() => {
if (!('ResizeObserver' in window) || process.env.APP_ENV === 'perf') {
return undefined;
}
const observer = new ResizeObserver(([entry]) => {
// During animation
if (!(entry.target as HTMLDivElement).offsetParent) {
return;
}
setContainerHeight(entry.contentRect.height);
});
observer.observe(containerRef.current!);
return () => {
observer.disconnect();
};
const handleResize = useCallback((entry: ResizeObserverEntry) => {
setContainerHeight(entry.contentRect.height);
}, []);
useResizeObserver(containerRef, handleResize);
// Memorize height for scroll animation
const { height: windowHeight } = useWindowSize();

View File

@ -1,5 +1,7 @@
.AttachMenu {
align-self: flex-end;
position: absolute;
right: 0;
bottom: 0;
&--button {
&:focus {

View File

@ -77,6 +77,7 @@
.drop-target {
position: relative;
overflow: hidden;
&::before,
&::after {

View File

@ -249,12 +249,21 @@
.message-input-wrapper {
display: flex;
.input-scroller {
margin-right: 0.5rem;
padding-right: 2.5rem;
}
> .Spinner {
align-self: center;
--spinner-size: 1.5rem;
margin-right: -0.5rem;
}
.inline-bot-spinner {
margin-right: 3rem;
}
> .AttachMenu > .Button,
> .Button {
flex-shrink: 0;
@ -402,6 +411,45 @@
}
}
.input-scroller {
min-height: 3.5rem;
max-height: 26rem;
overflow: hidden;
&.overflown {
overflow-y: auto;
}
@media (max-width: 600px) {
min-height: 2.875rem;
max-height: 16rem;
}
& > .input-scroller-content {
position: relative;
}
/* stylelint-disable-next-line plugin/no-low-performance-animation-properties */
transition: height 100ms ease;
body.animation-level-0 & {
transition: none !important;
}
.emoji {
width: 1.25rem;
height: 1.25rem;
margin: 0 1px -5px;
vertical-align: 0;
pointer-events: none;
}
.custom-emoji {
margin: 0;
vertical-align: text-top;
}
}
#message-input-text,
#caption-input-text {
position: relative;
@ -410,16 +458,18 @@
padding: calc((3.25rem - var(--composer-text-size, 1rem) * 1.375) / 2 - var(--border-width, 0) * 2)
calc(0.9rem - var(--border-width));
overflow: hidden;
height: auto;
line-height: 1.375;
font-family: var(--font-family);
unicode-bidi: plaintext;
text-align: initial;
font-size: var(--composer-text-size, 1rem);
&.overflown {
overflow-y: auto;
overflow-x: hidden;
}
background: transparent !important;
border: none !important;
border-radius: 0 !important;
box-shadow: none !important;
caret-color: var(--color-text);
&.touched,
&:focus {
@ -496,6 +546,7 @@
position: absolute;
top: 0;
left: 0;
right: 0;
opacity: 0;
pointer-events: none;
z-index: -10;
@ -509,30 +560,13 @@
.form-control {
margin-bottom: 0;
background: transparent !important;
border: none !important;
border-radius: 0 !important;
box-shadow: none !important;
caret-color: var(--color-text);
min-height: 3.5rem;
max-height: 26rem;
line-height: 1.3125;
padding: calc((3.5rem - var(--composer-text-size, 1rem) * 1.3125) / 2) 0;
white-space: pre-wrap;
height: auto;
@media (max-width: 600px) {
height: 2.875rem;
min-height: 2.875rem;
max-height: 16rem;
padding: calc((2.875rem - var(--composer-text-size, 1rem) * 1.3125) / 2) 0;
overflow: auto;
}
/* stylelint-disable-next-line plugin/no-low-performance-animation-properties */
transition: height 100ms ease;
body.animation-level-0 & {
transition: none !important;
}
}
@ -562,25 +596,22 @@
margin-top: 0.75rem;
}
}
.emoji {
width: 1.25rem;
height: 1.25rem;
margin: 0 1px -5px;
vertical-align: 0;
pointer-events: none;
}
.custom-emoji {
vertical-align: text-top;
}
}
#caption-input-text {
.form-control {
height: 3.25rem;
border: 1px solid var(--color-borders-input);
border-radius: var(--border-radius-default);
margin-bottom: 0.5rem;
.input-scroller {
min-height: 3.25rem;
max-height: 15rem;
margin-right: 0.5rem;
&:has(.form-control:focus) {
border-color: var(--color-primary);
}
}
.placeholder-text {

View File

@ -1261,7 +1261,7 @@ const Composer: FC<OwnProps & StateProps> = ({
onSuppressedFocus={closeSymbolMenu}
/>
{isInlineBotLoading && Boolean(inlineBotId) && (
<Spinner color="gray" />
<Spinner color="gray" className="inline-bot-spinner" />
)}
{withScheduledButton && (
<Button

View File

@ -17,13 +17,15 @@ import {
} from '../../../util/environment';
import captureKeyboardListeners from '../../../util/captureKeyboardListeners';
import { getIsDirectTextInputDisabled } from '../../../util/directInputManager';
import parseEmojiOnlyString from '../../../util/parseEmojiOnlyString';
import { isSelectionInsideInput } from './helpers/selection';
import renderText from '../../common/helpers/renderText';
import useLayoutEffectWithPrevDeps from '../../../hooks/useLayoutEffectWithPrevDeps';
import useFlag from '../../../hooks/useFlag';
import { isHeavyAnimating } from '../../../hooks/useHeavyAnimationCheck';
import useLang from '../../../hooks/useLang';
import parseEmojiOnlyString from '../../../util/parseEmojiOnlyString';
import { isSelectionInsideInput } from './helpers/selection';
import renderText from '../../common/helpers/renderText';
import useInputCustomEmojis from './hooks/useInputCustomEmojis';
import TextFormatter from './TextFormatter';
@ -32,6 +34,8 @@ const CONTEXT_MENU_CLOSE_DELAY_MS = 100;
const FOCUS_DELAY_MS = 350;
const TRANSITION_DURATION_FACTOR = 50;
const SCROLLER_CLASS = 'input-scroller';
type OwnProps = {
id: string;
chatId: string;
@ -58,6 +62,7 @@ type StateProps = {
};
const MAX_INPUT_HEIGHT = IS_SINGLE_COLUMN_LAYOUT ? 256 : 416;
const MAX_ATTACHMENT_MODAL_INPUT_HEIGHT = 240;
const TAB_INDEX_PRIORITY_TIMEOUT = 2000;
// Heuristics allowing the user to make a triple click
const SELECTION_RECALCULATE_DELAY_MS = 260;
@ -112,6 +117,14 @@ const MessageInput: FC<OwnProps & StateProps> = ({
const selectionTimeoutRef = useRef<number>(null);
// eslint-disable-next-line no-null/no-null
const cloneRef = useRef<HTMLDivElement>(null);
// eslint-disable-next-line no-null/no-null
const scrollerCloneRef = useRef<HTMLDivElement>(null);
// eslint-disable-next-line no-null/no-null
const sharedCanvasRef = useRef<HTMLCanvasElement>(null);
// eslint-disable-next-line no-null/no-null
const sharedCanvasHqRef = useRef<HTMLCanvasElement>(null);
// eslint-disable-next-line no-null/no-null
const absoluteContainerRef = useRef<HTMLDivElement>(null);
const lang = useLang();
const isContextMenuOpenRef = useRef(false);
@ -120,10 +133,40 @@ const MessageInput: FC<OwnProps & StateProps> = ({
const [selectedRange, setSelectedRange] = useState<Range>();
const [isTextFormatterDisabled, setIsTextFormatterDisabled] = useState<boolean>(false);
useInputCustomEmojis(html, inputRef, sharedCanvasRef, sharedCanvasHqRef, absoluteContainerRef);
const updateInputHeight = useCallback((willSend = false) => {
const scroller = inputRef.current!.closest<HTMLDivElement>(`.${SCROLLER_CLASS}`)!;
const clone = scrollerCloneRef.current!;
const currentHeight = Number(scroller.style.height.replace('px', ''));
const maxHeight = isAttachmentModalInput ? MAX_ATTACHMENT_MODAL_INPUT_HEIGHT : MAX_INPUT_HEIGHT;
const newHeight = Math.min(clone.scrollHeight, maxHeight);
if (newHeight === currentHeight) {
return;
}
const transitionDuration = Math.round(
TRANSITION_DURATION_FACTOR * Math.log(Math.abs(newHeight - currentHeight)),
);
const exec = () => {
scroller.style.height = `${newHeight}px`;
scroller.style.transitionDuration = `${transitionDuration}ms`;
scroller.classList.toggle('overflown', clone.scrollHeight > maxHeight);
};
if (willSend) {
// Sync with sending animation
requestAnimationFrame(exec);
} else {
exec();
}
}, [isAttachmentModalInput]);
useEffect(() => {
if (!isAttachmentModalInput) return;
updateInputHeight(false);
}, [isAttachmentModalInput]);
}, [isAttachmentModalInput, updateInputHeight]);
useLayoutEffectWithPrevDeps(([prevHtml]) => {
if (html !== inputRef.current!.innerHTML) {
@ -331,33 +374,6 @@ const MessageInput: FC<OwnProps & StateProps> = ({
}
}
function updateInputHeight(willSend = false) {
const input = inputRef.current!;
const clone = cloneRef.current!;
const currentHeight = Number(input.style.height.replace('px', ''));
const newHeight = Math.min(clone.scrollHeight, MAX_INPUT_HEIGHT);
if (newHeight === currentHeight) {
return;
}
const transitionDuration = Math.round(
TRANSITION_DURATION_FACTOR * Math.log(Math.abs(newHeight - currentHeight)),
);
const exec = () => {
input.style.height = `${newHeight}px`;
input.style.transitionDuration = `${transitionDuration}ms`;
input.classList.toggle('overflown', clone.scrollHeight > MAX_INPUT_HEIGHT);
};
if (willSend) {
// Sync with sending animation
requestAnimationFrame(exec);
} else {
exec();
}
}
useEffect(() => {
if (IS_TOUCH_ENV) {
return;
@ -450,37 +466,47 @@ const MessageInput: FC<OwnProps & StateProps> = ({
}, [shouldSuppressFocus]);
const className = buildClassName(
'form-control custom-scroll',
'form-control',
html.length > 0 && 'touched',
shouldSuppressFocus && 'focus-disabled',
);
return (
<div id={id} onClick={shouldSuppressFocus ? onSuppressedFocus : undefined} dir={lang.isRtl ? 'rtl' : undefined}>
<div
ref={inputRef}
id={editableInputId || EDITABLE_INPUT_ID}
className={className}
contentEditable
role="textbox"
dir="auto"
tabIndex={0}
onClick={focusInput}
onChange={handleChange}
onKeyDown={handleKeyDown}
onMouseDown={handleMouseDown}
onContextMenu={IS_ANDROID ? handleAndroidContextMenu : undefined}
onTouchCancel={IS_ANDROID ? processSelectionWithTimeout : undefined}
aria-label={placeholder}
/>
<div className={buildClassName('custom-scroll', SCROLLER_CLASS)}>
<div className="input-scroller-content">
<div
ref={inputRef}
id={editableInputId || EDITABLE_INPUT_ID}
className={className}
contentEditable
role="textbox"
dir="auto"
tabIndex={0}
onClick={focusInput}
onChange={handleChange}
onKeyDown={handleKeyDown}
onMouseDown={handleMouseDown}
onContextMenu={IS_ANDROID ? handleAndroidContextMenu : undefined}
onTouchCancel={IS_ANDROID ? processSelectionWithTimeout : undefined}
aria-label={placeholder}
/>
{!forcedPlaceholder && <span className="placeholder-text" dir="auto">{placeholder}</span>}
<canvas ref={sharedCanvasRef} className="shared-canvas" />
<canvas ref={sharedCanvasHqRef} className="shared-canvas" />
<div ref={absoluteContainerRef} className="absolute-video-container" />
</div>
</div>
<div ref={scrollerCloneRef} className={buildClassName('custom-scroll', SCROLLER_CLASS, 'clone')}>
<div className="input-scroller-content">
<div ref={cloneRef} className={buildClassName(className, 'clone')} dir="auto" />
</div>
</div>
{captionLimit && (
<div className="max-length-indicator" dir="auto">
{captionLimit}
</div>
)}
<div ref={cloneRef} className={buildClassName(className, 'clone')} dir="auto" />
{!forcedPlaceholder && <span className="placeholder-text" dir="auto">{placeholder}</span>}
<TextFormatter
isOpen={isTextFormatterOpen}
anchorPosition={textFormatterAnchorPosition}

View File

@ -10,6 +10,8 @@ import { selectIsAlwaysHighPriorityEmoji } from '../../../global/selectors';
import { IS_WEBM_SUPPORTED } from '../../../util/environment';
import { getFirstLetters } from '../../../util/textFormat';
import buildClassName from '../../../util/buildClassName';
import { getStickerPreviewHash } from '../../../global/helpers';
import { useIsIntersecting } from '../../../hooks/useIntersectionObserver';
import useMedia from '../../../hooks/useMedia';
import useMediaTransition from '../../../hooks/useMediaTransition';
@ -42,8 +44,13 @@ const StickerSetCover: FC<OwnProps> = ({
const isIntersecting = useIsIntersecting(containerRef, observeIntersection);
const mediaData = useMedia((hasThumbnail || isLottie) && `stickerSet${stickerSet.id}`, !isIntersecting);
const isReady = mediaData && (!isVideo || IS_WEBM_SUPPORTED);
const shouldFallbackToStatic = stickerSet.stickers && isVideo && !IS_WEBM_SUPPORTED;
const staticHash = shouldFallbackToStatic && getStickerPreviewHash(stickerSet.stickers![0].id);
const staticMediaData = useMedia(staticHash, !isIntersecting);
const mediaHash = ((hasThumbnail && !shouldFallbackToStatic) || isLottie) && `stickerSet${stickerSet.id}`;
const mediaData = useMedia(mediaHash, !isIntersecting);
const isReady = mediaData || staticMediaData;
const transitionClassNames = useMediaTransition(isReady);
const bounds = useBoundsInSharedCanvas(containerRef, sharedCanvasRef);
@ -61,7 +68,7 @@ const StickerSetCover: FC<OwnProps> = ({
sharedCanvas={sharedCanvasRef?.current || undefined}
sharedCanvasCoords={bounds.coords}
/>
) : isVideo ? (
) : (isVideo && !shouldFallbackToStatic) ? (
<OptimizedVideo
className={buildClassName(styles.video, transitionClassNames)}
src={mediaData}
@ -71,7 +78,7 @@ const StickerSetCover: FC<OwnProps> = ({
/>
) : (
<img
src={mediaData}
src={mediaData || staticMediaData}
className={transitionClassNames}
alt=""
/>

View File

@ -82,7 +82,7 @@ const SymbolMenuFooter: FC<OwnProps> = ({
{renderTabButton(SymbolMenuTabs.Stickers)}
{renderTabButton(SymbolMenuTabs.GIFs)}
{activeTab === SymbolMenuTabs.Emoji && (
{(activeTab === SymbolMenuTabs.Emoji || activeTab === SymbolMenuTabs.CustomEmoji) && (
<Button
className="symbol-delete-button"
onClick={onRemoveSymbol}

View File

@ -1,5 +1,6 @@
.TextFormatter {
transform: translate(-50%, -3.25rem);
z-index: 1;
&,
&-link-control {

View File

@ -1,37 +1,38 @@
import type { ApiMessageEntityCustomEmoji, ApiSticker } from '../../../../api/types';
import { ApiMessageEntityTypes } from '../../../../api/types';
import { getGlobal } from '../../../../global';
import { EMOJI_SIZES } from '../../../../config';
import { REM } from '../../../common/helpers/mediaDimensions';
import { getCustomEmojiPreviewMediaData } from '../../../../util/customEmojiManager';
import placeholderSrc from '../../../../assets/square.svg';
import { getInputCustomEmojiParams } from '../../../../util/customEmojiManager';
export const INPUT_CUSTOM_EMOJI_SELECTOR = 'img[data-document-id]';
export function buildCustomEmojiHtml(emoji: ApiSticker) {
const mediaData = getCustomEmojiPreviewMediaData(emoji.id);
const [isPlaceholder, src, uniqueId] = getInputCustomEmojiParams(emoji);
return `<img
class="custom-emoji emoji emoji-small${!mediaData ? ' placeholder' : ''}"
class="custom-emoji emoji emoji-small ${isPlaceholder ? 'placeholder' : ''}"
draggable="false"
alt="${emoji.emoji}"
data-document-id="${emoji.id}"
${uniqueId ? `data-unique-id="${uniqueId}"` : ''}
data-entity-type="${ApiMessageEntityTypes.CustomEmoji}"
src="${mediaData || placeholderSrc}"
src="${src}"
/>`;
}
export function buildCustomEmojiHtmlFromEntity(rawText: string, entity: ApiMessageEntityCustomEmoji) {
const mediaData = getCustomEmojiPreviewMediaData(entity.documentId);
const customEmoji = getGlobal().customEmojis.byId[entity.documentId];
const [isPlaceholder, src, uniqueId] = getInputCustomEmojiParams(customEmoji);
return `<img
class="custom-emoji emoji emoji-small${!mediaData ? ' placeholder' : ''}"
class="custom-emoji emoji emoji-small ${isPlaceholder ? 'placeholder' : ''}"
draggable="false"
alt="${rawText}"
data-document-id="${entity.documentId}"
${uniqueId ? `data-unique-id="${uniqueId}"` : ''}
data-entity-type="${ApiMessageEntityTypes.CustomEmoji}"
src="${mediaData || placeholderSrc}"
src="${src}"
/>`;
}

View File

@ -35,8 +35,8 @@ const useEditing = (
if (prevEditedMessage?.id === editedMessage.id) {
return;
}
const html = getTextWithEntitiesAsHtml(editingDraft?.text.length ? editingDraft : editedMessage.content.text);
const text = !prevEditedMessage && editingDraft?.text.length ? editingDraft : editedMessage.content.text;
const html = getTextWithEntitiesAsHtml(text);
setHtml(html);
// `fastRaf` would execute syncronously in this case
requestAnimationFrame(() => {

View File

@ -0,0 +1,198 @@
import {
useCallback, useEffect, useRef,
} from '../../../../lib/teact/teact';
import RLottie from '../../../../lib/rlottie/RLottie';
import type { ApiSticker } from '../../../../api/types';
import { getGlobal } from '../../../../global';
import { selectIsAlwaysHighPriorityEmoji } from '../../../../global/selectors';
import generateIdFor from '../../../../util/generateIdFor';
import {
addCustomEmojiInputRenderCallback,
getCustomEmojiMediaDataForInput,
removeCustomEmojiInputRenderCallback,
} from '../../../../util/customEmojiManager';
import { round } from '../../../../util/math';
import { fastRaf } from '../../../../util/schedulers';
import AbsoluteVideo from '../../../../util/AbsoluteVideo';
import { useResizeObserver } from '../../../../hooks/useResizeObserver';
import useBackgroundMode from '../../../../hooks/useBackgroundMode';
const ID_STORE = {};
const SIZE = 20;
type Player = {
play: () => void;
pause: () => void;
destroy: () => void;
updatePosition: (x: number, y: number) => void;
};
export default function useInputCustomEmojis(
html: string,
inputRef: React.RefObject<HTMLDivElement>,
sharedCanvasRef: React.RefObject<HTMLCanvasElement>,
sharedCanvasHqRef: React.RefObject<HTMLCanvasElement>,
absoluteContainerRef: React.RefObject<HTMLElement>,
) {
const mapRef = useRef<Map<string, Player>>(new Map());
const removeContainers = useCallback((ids: string[]) => {
ids.forEach((id) => {
const player = mapRef.current.get(id);
if (player) {
player.destroy();
mapRef.current.delete(id);
}
});
}, []);
const synchronizeElements = useCallback(() => {
if (!inputRef.current || !sharedCanvasRef.current || !sharedCanvasHqRef.current) return;
const global = getGlobal();
const removedContainers = new Set(mapRef.current.keys());
const customEmojies = Array.from(inputRef.current.querySelectorAll<HTMLElement>('.custom-emoji'));
customEmojies.forEach((element) => {
const id = element.dataset.uniqueId!;
const documentId = element.dataset.documentId!;
if (!id) {
return;
}
removedContainers.delete(id);
const mediaUrl = getCustomEmojiMediaDataForInput(documentId);
if (!mediaUrl) {
return;
}
const canvasBounds = sharedCanvasRef.current!.getBoundingClientRect();
const elementBounds = element.getBoundingClientRect();
const x = round((elementBounds.left - canvasBounds.left) / canvasBounds.width, 4);
const y = round((elementBounds.top - canvasBounds.top) / canvasBounds.height, 4);
if (mapRef.current.has(id)) {
const player = mapRef.current.get(id)!;
player.updatePosition(x, y);
return;
}
const customEmoji = global.customEmojis.byId[documentId];
if (!customEmoji) {
return;
}
const isHq = customEmoji?.stickerSetInfo && selectIsAlwaysHighPriorityEmoji(global, customEmoji.stickerSetInfo);
const animation = createPlayer({
customEmoji,
sharedCanvasRef,
sharedCanvasHqRef,
absoluteContainerRef,
uniqueId: id,
mediaUrl,
isHq,
position: { x, y },
});
animation.play();
mapRef.current.set(id, animation);
});
removeContainers(Array.from(removedContainers));
}, [absoluteContainerRef, inputRef, removeContainers, sharedCanvasHqRef, sharedCanvasRef]);
useEffect(() => {
addCustomEmojiInputRenderCallback(synchronizeElements);
return () => {
removeCustomEmojiInputRenderCallback(synchronizeElements);
};
}, [synchronizeElements]);
useEffect(() => {
if (!html || !inputRef.current || !sharedCanvasRef.current) {
removeContainers(Array.from(mapRef.current.keys()));
return;
}
synchronizeElements();
}, [html, inputRef, removeContainers, sharedCanvasRef, synchronizeElements]);
useResizeObserver(sharedCanvasRef, synchronizeElements, true);
const freezeAnimation = useCallback(() => {
mapRef.current.forEach((player) => {
player.pause();
});
}, []);
const unfreezeAnimation = useCallback(() => {
mapRef.current.forEach((player) => {
player.play();
});
}, []);
const unfreezeAnimationOnRaf = useCallback(() => {
fastRaf(unfreezeAnimation);
}, [unfreezeAnimation]);
// Pausing frame may not happen in background,
// so we need to make sure it happens right after focusing,
// then we can play again.
useBackgroundMode(freezeAnimation, unfreezeAnimationOnRaf);
}
function createPlayer({
customEmoji,
sharedCanvasRef,
sharedCanvasHqRef,
absoluteContainerRef,
uniqueId,
mediaUrl,
position,
isHq,
} : {
customEmoji: ApiSticker;
sharedCanvasRef: React.RefObject<HTMLCanvasElement>;
sharedCanvasHqRef: React.RefObject<HTMLCanvasElement>;
absoluteContainerRef: React.RefObject<HTMLElement>;
uniqueId: string;
mediaUrl: string;
position: { x: number; y: number };
isHq?: boolean;
}): Player {
if (customEmoji.isLottie) {
const lottie = RLottie.init(
uniqueId,
isHq ? sharedCanvasHqRef.current! : sharedCanvasRef.current!,
undefined,
generateIdFor(ID_STORE, true),
mediaUrl,
{
size: SIZE,
coords: position,
isLowPriority: !isHq,
},
);
return {
play: () => lottie.play(),
pause: () => lottie.pause(),
destroy: () => lottie.removeContainer(uniqueId),
updatePosition: (x: number, y: number) => lottie.setSharedCanvasCoords(uniqueId, { x, y }),
};
}
if (customEmoji.isVideo) {
const absoluteVideo = new AbsoluteVideo(mediaUrl, absoluteContainerRef.current!, { size: SIZE, position });
return {
play: () => absoluteVideo.play(),
pause: () => absoluteVideo.pause(),
destroy: () => absoluteVideo.destroy(),
updatePosition: (x: number, y: number) => absoluteVideo.updatePosition({ x, y }),
};
}
throw new Error('Unsupported custom emoji type');
}

View File

@ -8,12 +8,17 @@ import './Spinner.scss';
const Spinner: FC<{
color?: 'blue' | 'white' | 'black' | 'green' | 'gray' | 'yellow';
backgroundColor?: 'light' | 'dark';
className?: string;
}> = ({
color = 'blue',
backgroundColor,
className,
}) => {
return (
<div className={buildClassName('Spinner', color, backgroundColor && 'with-background', `bg-${backgroundColor}`)}>
<div className={buildClassName(
'Spinner', className, color, backgroundColor && 'with-background', `bg-${backgroundColor}`,
)}
>
<div />
</div>
);

View File

@ -1,10 +1,11 @@
import {
useCallback, useEffect, useLayoutEffect, useMemo, useState,
useCallback, useLayoutEffect, useMemo, useState,
} from '../lib/teact/teact';
import { throttle } from '../util/schedulers';
import { round } from '../util/math';
import { useResizeObserver } from './useResizeObserver';
export default function useBoundsInSharedCanvas(
containerRef: React.RefObject<HTMLDivElement>,
sharedCanvasRef?: React.RefObject<HTMLCanvasElement>,
@ -32,26 +33,7 @@ export default function useBoundsInSharedCanvas(
useLayoutEffect(recalculate, [recalculate]);
useEffect(() => {
if (!('ResizeObserver' in window) || !sharedCanvasRef?.current) {
return undefined;
}
const observer = new ResizeObserver(throttle(([entry]) => {
// During animation
if (!(entry.target as HTMLCanvasElement).offsetParent) {
return;
}
recalculate();
}, 300, false));
observer.observe(sharedCanvasRef.current);
return () => {
observer.disconnect();
};
}, [recalculate, sharedCanvasRef]);
useResizeObserver(sharedCanvasRef, recalculate, true);
const coords = useMemo(() => (x !== undefined && y !== undefined ? { x, y } : undefined), [x, y]);

View File

@ -1,4 +1,5 @@
import { getActions, getGlobal } from '../global';
import { addCustomEmojiInputRenderCallback } from '../util/customEmojiManager';
import { throttle } from '../util/schedulers';
@ -24,11 +25,13 @@ const updateLastRendered = throttle(() => {
RENDER_HISTORY.clear();
}, THROTTLE, false);
export function notifyCustomEmojiRender(emojiId: string) {
function notifyCustomEmojiRender(emojiId: string) {
RENDER_HISTORY.add(emojiId);
updateLastRendered();
}
addCustomEmojiInputRenderCallback(notifyCustomEmojiRender);
export default function useEnsureCustomEmoji(id: string) {
const lastSyncTime = useLastSyncTime();
notifyCustomEmojiRender(id);

View File

@ -0,0 +1,32 @@
import { useEffect } from '../lib/teact/teact';
import { throttle } from '../util/schedulers';
const THROTTLE = 300;
export function useResizeObserver(
ref: React.RefObject<HTMLElement> | undefined,
onResize: (entry: ResizeObserverEntry) => void,
withThrottle = false,
) {
useEffect(() => {
if (!('ResizeObserver' in window) || !ref?.current) {
return undefined;
}
const callback: ResizeObserverCallback = ([entry]) => {
// During animation
if (!(entry.target as HTMLElement).offsetParent) {
return;
}
onResize(entry);
};
const observer = new ResizeObserver(withThrottle ? throttle(callback, THROTTLE, false) : callback);
observer.observe(ref.current);
return () => {
observer.disconnect();
};
}, [onResize, ref, withThrottle]);
}

View File

@ -206,7 +206,7 @@ class RLottie {
setSharedCanvasCoords(containerId: string, newCoords: Params['coords']) {
const containerInfo = this.containers.get(containerId)!;
const {
canvas, ctx, isPaused, coords,
canvas, ctx,
} = containerInfo;
if (!canvas.dataset.isJustCleaned || canvas.dataset.isJustCleaned === 'false') {
@ -224,12 +224,10 @@ class RLottie {
y: Math.round((newCoords?.y || 0) * canvas.height),
};
if (isPaused || !this.isPlaying()) {
const frame = this.getFrame(this.prevFrameIndex) || this.getFrame(Math.round(this.approxFrameIndex));
const frame = this.getFrame(this.prevFrameIndex) || this.getFrame(Math.round(this.approxFrameIndex));
if (frame && frame !== WAITING) {
ctx.drawImage(frame, coords!.x, coords!.y);
}
if (frame && frame !== WAITING) {
ctx.drawImage(frame, containerInfo.coords.x, containerInfo.coords.y);
}
}

View File

@ -290,7 +290,7 @@ div[role="button"] {
position: relative;
}
.shared-canvas {
.shared-canvas, .absolute-video-container {
position: absolute;
top: 0;
left: 0;

71
src/util/AbsoluteVideo.ts Normal file
View File

@ -0,0 +1,71 @@
import safePlay from './safePlay';
type AbsoluteVideoOptions = {
position: { x: number; y: number };
noLoop?: boolean;
size: number;
};
export default class AbsoluteVideo {
private video?: HTMLVideoElement;
private isPlaying = false;
constructor(
videoUrl: string,
private container: HTMLElement,
private options: AbsoluteVideoOptions,
) {
this.video = document.createElement('video');
this.video.src = videoUrl;
this.video.disablePictureInPicture = true;
this.video.muted = true;
this.video.style.position = 'absolute';
this.video.load();
if (!this.options.noLoop) {
this.video.loop = true;
}
this.container.appendChild(this.video);
this.recalculatePositionAndSize();
}
public play() {
if (this.isPlaying || !this.video) return;
this.recalculatePositionAndSize();
if (this.video.paused) {
safePlay(this.video);
}
this.isPlaying = true;
}
public pause() {
if (!this.isPlaying || !this.video) return;
if (!this.video.paused) {
this.video.pause();
}
this.isPlaying = false;
}
public destroy() {
this.pause();
this.video?.remove();
this.video = undefined;
}
public updatePosition(position: AbsoluteVideoOptions['position']) {
this.options.position = position;
this.recalculatePositionAndSize();
}
private recalculatePositionAndSize() {
if (!this.video) return;
const { size, position: { x, y } } = this.options;
const { width, height } = this.container.getBoundingClientRect();
this.video.style.left = `${Math.round(x * width)}px`;
this.video.style.top = `${Math.round(y * height)}px`;
this.video.style.width = `${size}px`;
this.video.style.height = `${size}px`;
}
}

View File

@ -1,37 +1,124 @@
import { addCallback } from '../lib/teact/teactn';
import { getGlobal } from '../global';
import { ApiMediaFormat } from '../api/types';
import type { ApiSticker } from '../api/types';
import type { GlobalState } from '../global/types';
import { getStickerPreviewHash } from '../global/helpers';
import { notifyCustomEmojiRender } from '../hooks/useEnsureCustomEmoji';
import * as mediaLoader from './mediaLoader';
import { throttle } from './schedulers';
import generateIdFor from './generateIdFor';
import { IS_WEBM_SUPPORTED } from './environment';
import placeholderSrc from '../assets/square.svg';
import blankSrc from '../assets/blank.png';
type CustomEmojiLoadCallback = (customEmojis: GlobalState['customEmojis']) => void;
type CustomEmojiInputRenderCallback = (emojiId: string) => void;
const ID_STORE = {};
const DOM_PROCESS_THROTTLE = 500;
const INPUT_WAITING_CUSTOM_EMOJI_IDS: Set<string> = new Set();
const handlers = new Map<CustomEmojiLoadCallback, string>();
const renderHandlers = new Set<CustomEmojiInputRenderCallback>();
let prevGlobal: GlobalState | undefined;
addCallback((global: GlobalState) => {
if (global.customEmojis.byId !== prevGlobal?.customEmojis.byId) {
for (const entry of handlers) {
const [handler, id] = entry;
if (global.customEmojis.byId[id]) {
handler(global.customEmojis);
}
}
checkInputCustomEmojiLoad(global.customEmojis);
}
prevGlobal = global;
});
export function addCustomEmojiCallback(handler: CustomEmojiLoadCallback, emojiId: string) {
handlers.set(handler, emojiId);
}
export function removeCustomEmojiCallback(handler: CustomEmojiLoadCallback) {
handlers.delete(handler);
}
export function addCustomEmojiInputRenderCallback(handler: AnyToVoidFunction) {
renderHandlers.add(handler);
}
export function removeCustomEmojiInputRenderCallback(handler: AnyToVoidFunction) {
renderHandlers.delete(handler);
}
const callInputRenderHandlers = throttle((emojiId: string) => {
renderHandlers.forEach((handler) => handler(emojiId));
}, DOM_PROCESS_THROTTLE);
function processDomForCustomEmoji() {
const emojis = document.querySelectorAll<HTMLImageElement>('.custom-emoji.placeholder');
emojis.forEach((emoji) => {
const emojiId = emoji.dataset.documentId!;
const mediaHash = getStickerPreviewHash(emojiId);
const mediaData = mediaLoader.getFromMemory(mediaHash);
if (mediaData) {
emoji.src = mediaData;
emoji.classList.remove('placeholder');
const customEmoji = getGlobal().customEmojis.byId[emoji.dataset.documentId!];
if (!customEmoji) {
INPUT_WAITING_CUSTOM_EMOJI_IDS.add(emoji.dataset.documentId!);
return;
}
const [isPlaceholder, src, uniqueId] = getInputCustomEmojiParams(customEmoji);
notifyCustomEmojiRender(emojiId);
if (!isPlaceholder) {
emoji.src = src;
emoji.classList.remove('placeholder');
if (uniqueId) emoji.dataset.uniqueId = uniqueId;
callInputRenderHandlers(customEmoji.id);
}
});
}
export const processMessageInputForCustomEmoji = throttle(processDomForCustomEmoji, DOM_PROCESS_THROTTLE);
export function getCustomEmojiPreviewMediaData(emojiId: string) {
const mediaHash = getStickerPreviewHash(emojiId);
function checkInputCustomEmojiLoad(customEmojis: GlobalState['customEmojis']) {
const loaded = Array.from(INPUT_WAITING_CUSTOM_EMOJI_IDS).filter((id) => Boolean(customEmojis.byId[id]));
if (loaded.length) {
loaded.forEach((id) => INPUT_WAITING_CUSTOM_EMOJI_IDS.delete(id));
processMessageInputForCustomEmoji();
}
}
export function getCustomEmojiMediaDataForInput(emojiId: string, isPreview?: boolean) {
const mediaHash = isPreview ? getStickerPreviewHash(emojiId) : `sticker${emojiId}`;
const data = mediaLoader.getFromMemory(mediaHash);
if (data) {
notifyCustomEmojiRender(emojiId);
return data;
}
mediaLoader.fetch(mediaHash, ApiMediaFormat.BlobUrl).then(() => processMessageInputForCustomEmoji());
fetchAndProcess(mediaHash);
return undefined;
}
function fetchAndProcess(mediaHash: string) {
return mediaLoader.fetch(mediaHash, ApiMediaFormat.BlobUrl).then(() => {
processMessageInputForCustomEmoji();
});
}
export function getInputCustomEmojiParams(customEmoji?: ApiSticker) {
if (!customEmoji) return [true, placeholderSrc, undefined];
const shouldUseStaticFallback = !IS_WEBM_SUPPORTED && customEmoji.isVideo;
const isUsingSharedCanvas = customEmoji.isLottie || (customEmoji.isVideo && !shouldUseStaticFallback);
if (isUsingSharedCanvas) {
fetchAndProcess(`sticker${customEmoji.id}`);
return [false, blankSrc, generateIdFor(ID_STORE, true)];
}
const mediaData = getCustomEmojiMediaDataForInput(customEmoji.id, shouldUseStaticFallback);
return [!mediaData, mediaData || placeholderSrc, undefined];
}