Custom Emoji: Fixes, optimizations, and refactoring (#2095)

This commit is contained in:
Alexander Zinchuk 2022-11-01 18:53:15 +01:00
parent 7712058fe4
commit 1fda24ab92
15 changed files with 309 additions and 292 deletions

3
src/assets/square.svg Normal file
View File

@ -0,0 +1,3 @@
<svg fill="white" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 512 512" xml:space="preserve">
<rect fill="#777777" fill-opacity="0.1" x="64" y="64" width="384" height="384" rx="50" />
</svg>

After

Width:  |  Height:  |  Size: 255 B

View File

@ -3,8 +3,9 @@
vertical-align: text-bottom; vertical-align: text-bottom;
width: var(--custom-emoji-size); width: var(--custom-emoji-size);
height: var(--custom-emoji-size); height: var(--custom-emoji-size);
position: relative;
&.with-grid-fix .media { &.with-grid-fix .media, &.with-grid-fix .thumb {
width: calc(100% + 1px) !important; width: calc(100% + 1px) !important;
height: calc(100% + 1px) !important; height: calc(100% + 1px) !important;
vertical-align: baseline; vertical-align: baseline;
@ -19,15 +20,14 @@
} }
} }
.media { .thumb {
width: 100%; width: 100%;
height: 100%; height: 100%;
} }
.sticker { .media {
width: var(--custom-emoji-size) !important; width: var(--custom-emoji-size) !important;
height: var(--custom-emoji-size) !important; height: var(--custom-emoji-size) !important;
display: flex !important;
:global(canvas) { :global(canvas) {
width: var(--custom-emoji-size) !important; width: var(--custom-emoji-size) !important;

View File

@ -6,26 +6,19 @@ import { getGlobal } from '../../global';
import type { FC, TeactNode } from '../../lib/teact/teact'; import type { FC, TeactNode } from '../../lib/teact/teact';
import type { ObserveFn } from '../../hooks/useIntersectionObserver'; import type { ObserveFn } from '../../hooks/useIntersectionObserver';
import { IS_WEBM_SUPPORTED } from '../../util/environment';
import renderText from './helpers/renderText';
import { getPropertyHexColor } from '../../util/themeStyle'; import { getPropertyHexColor } from '../../util/themeStyle';
import { hexToRgb } from '../../util/switchTheme'; import { hexToRgb } from '../../util/switchTheme';
import buildClassName from '../../util/buildClassName'; import buildClassName from '../../util/buildClassName';
import { getStickerPreviewHash } from '../../global/helpers';
import { selectIsAlwaysHighPriorityEmoji, selectIsDefaultEmojiStatusPack } from '../../global/selectors';
import safePlay from '../../util/safePlay'; import safePlay from '../../util/safePlay';
import { selectIsDefaultEmojiStatusPack } from '../../global/selectors';
import useMedia from '../../hooks/useMedia';
import useEnsureCustomEmoji from '../../hooks/useEnsureCustomEmoji'; import useEnsureCustomEmoji from '../../hooks/useEnsureCustomEmoji';
import { useIsIntersecting } from '../../hooks/useIntersectionObserver';
import useThumbnail from '../../hooks/useThumbnail';
import useCustomEmoji from './hooks/useCustomEmoji'; import useCustomEmoji from './hooks/useCustomEmoji';
import useMediaTransition from '../../hooks/useMediaTransition';
import AnimatedSticker from './AnimatedSticker'; import StickerView from './StickerView';
import OptimizedVideo from '../ui/OptimizedVideo';
import styles from './CustomEmoji.module.scss'; import styles from './CustomEmoji.module.scss';
import svgPlaceholder from '../../assets/square.svg';
type OwnProps = { type OwnProps = {
documentId: string; documentId: string;
@ -34,8 +27,9 @@ type OwnProps = {
className?: string; className?: string;
loopLimit?: number; loopLimit?: number;
withGridFix?: boolean; withGridFix?: boolean;
withPreview?: boolean; shouldPreloadPreview?: boolean;
observeIntersection?: ObserveFn; observeIntersection?: ObserveFn;
observeIntersectionForPlaying?: ObserveFn;
onClick?: NoneToVoidFunction; onClick?: NoneToVoidFunction;
}; };
@ -43,35 +37,26 @@ const STICKER_SIZE = 24;
const CustomEmoji: FC<OwnProps> = ({ const CustomEmoji: FC<OwnProps> = ({
documentId, documentId,
children,
size = STICKER_SIZE, size = STICKER_SIZE,
className, className,
loopLimit, loopLimit,
withGridFix, withGridFix,
withPreview, shouldPreloadPreview,
observeIntersection, observeIntersection,
observeIntersectionForPlaying,
onClick, onClick,
}) => { }) => {
// eslint-disable-next-line no-null/no-null // eslint-disable-next-line no-null/no-null
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
// An alternative to `withGlobal` to avoid adding numerous global containers // An alternative to `withGlobal` to avoid adding numerous global containers
const customEmoji = useCustomEmoji(documentId); const customEmoji = useCustomEmoji(documentId);
const isUnsupportedVideo = customEmoji?.isVideo && !IS_WEBM_SUPPORTED; useEnsureCustomEmoji(documentId);
const mediaHash = customEmoji && `sticker${customEmoji.id}`;
const mediaData = useMedia(mediaHash);
const shouldLoadPreview = !mediaData && (withPreview || isUnsupportedVideo);
const previewMediaHash = shouldLoadPreview && customEmoji && getStickerPreviewHash(customEmoji.id);
const previewMediaData = useMedia(previewMediaHash);
const thumbDataUri = useThumbnail(customEmoji);
const shouldDisplayPreview = Boolean(mediaData ? isUnsupportedVideo : previewMediaData);
const transitionClassNames = useMediaTransition(shouldDisplayPreview ? previewMediaData : mediaData);
const loopCountRef = useRef(0); const loopCountRef = useRef(0);
const [shouldLoop, setShouldLoop] = useState(true); const [shouldLoop, setShouldLoop] = useState(true);
const [customColor, setCustomColor] = useState<[number, number, number] | undefined>();
const [customColor, setCustomColor] = useState<[number, number, number] | undefined>();
const hasCustomColor = customEmoji && selectIsDefaultEmojiStatusPack(getGlobal(), customEmoji.stickerSetInfo); const hasCustomColor = customEmoji && selectIsDefaultEmojiStatusPack(getGlobal(), customEmoji.stickerSetInfo);
useEffect(() => { useEffect(() => {
@ -88,10 +73,6 @@ const CustomEmoji: FC<OwnProps> = ({
setCustomColor([customColorRgb.r, customColorRgb.g, customColorRgb.b]); setCustomColor([customColorRgb.r, customColorRgb.g, customColorRgb.b]);
}, [hasCustomColor]); }, [hasCustomColor]);
const isIntersecting = useIsIntersecting(ref, observeIntersection);
useEnsureCustomEmoji(documentId);
const handleVideoEnded = useCallback((e) => { const handleVideoEnded = useCallback((e) => {
if (!loopLimit) return; if (!loopLimit) return;
@ -117,53 +98,6 @@ const CustomEmoji: FC<OwnProps> = ({
} }
}, [loopLimit]); }, [loopLimit]);
function renderContent() {
if (!customEmoji || (!thumbDataUri && !mediaData)) {
return (children && renderText(children, ['emoji']));
}
if (!mediaData && !previewMediaData) {
return (
<img className={styles.media} src={thumbDataUri} alt={customEmoji.emoji} />
);
}
if (shouldDisplayPreview || isUnsupportedVideo || (!customEmoji.isVideo && !customEmoji.isLottie)) {
return (
<img className={styles.media} src={previewMediaData || mediaData} alt={customEmoji.emoji} />
);
}
if (customEmoji.isVideo) {
return (
<OptimizedVideo
canPlay={isIntersecting && shouldLoop}
className={styles.media}
src={mediaData}
playsInline
muted
loop={!loopLimit}
disablePictureInPicture
onEnded={handleVideoEnded}
/>
);
}
return (
<AnimatedSticker
size={size}
key={mediaData}
className={styles.sticker}
tgsUrl={mediaData}
play={isIntersecting}
color={customColor}
noLoop={!shouldLoop}
isLowPriority={!selectIsAlwaysHighPriorityEmoji(getGlobal(), customEmoji.stickerSetInfo)}
onLoop={handleStickerLoop}
/>
);
}
return ( return (
<div <div
ref={ref} ref={ref}
@ -174,11 +108,29 @@ const CustomEmoji: FC<OwnProps> = ({
'emoji', 'emoji',
hasCustomColor && 'custom-color', hasCustomColor && 'custom-color',
withGridFix && styles.withGridFix, withGridFix && styles.withGridFix,
...transitionClassNames,
)} )}
onClick={onClick} onClick={onClick}
> >
{renderContent()} {!customEmoji ? (
<img className={styles.thumb} src={svgPlaceholder} alt="Emoji" />
) : (
<StickerView
containerRef={ref}
sticker={customEmoji}
isSmall
size={size}
customColor={customColor}
thumbClassName={styles.thumb}
fullMediaClassName={styles.media}
shouldLoop={shouldLoop}
loopLimit={loopLimit}
shouldPreloadPreview={shouldPreloadPreview}
observeIntersection={observeIntersection}
observeIntersectionForPlaying={observeIntersectionForPlaying}
onVideoEnded={handleVideoEnded}
onAnimatedStickerLoop={handleStickerLoop}
/>
)}
</div> </div>
); );
}; };

View File

@ -2,33 +2,24 @@ import type { MouseEvent as ReactMouseEvent, ReactNode } from 'react';
import React, { import React, {
memo, useCallback, useEffect, useMemo, useRef, memo, useCallback, useEffect, useMemo, useRef,
} from '../../lib/teact/teact'; } from '../../lib/teact/teact';
import { getActions, getGlobal } from '../../global'; import { getActions } from '../../global';
import type { ApiBotInlineMediaResult, ApiSticker } from '../../api/types'; import type { ApiBotInlineMediaResult, ApiSticker } from '../../api/types';
import { ApiMediaFormat } from '../../api/types';
import buildClassName from '../../util/buildClassName'; import buildClassName from '../../util/buildClassName';
import { preventMessageInputBlurWithBubbling } from '../middle/helpers/preventMessageInputBlur'; import { preventMessageInputBlurWithBubbling } from '../middle/helpers/preventMessageInputBlur';
import safePlay from '../../util/safePlay'; import { IS_TOUCH_ENV } from '../../util/environment';
import { IS_TOUCH_ENV, IS_WEBM_SUPPORTED } from '../../util/environment';
import { selectIsAlwaysHighPriorityEmoji } from '../../global/selectors';
import { getStickerPreviewHash } from '../../global/helpers';
import type { ObserveFn } from '../../hooks/useIntersectionObserver'; import type { ObserveFn } from '../../hooks/useIntersectionObserver';
import { useIsIntersecting } from '../../hooks/useIntersectionObserver'; import { useIsIntersecting } from '../../hooks/useIntersectionObserver';
import useMedia from '../../hooks/useMedia';
import useShowTransition from '../../hooks/useShowTransition';
import useFlag from '../../hooks/useFlag';
import useLang from '../../hooks/useLang'; import useLang from '../../hooks/useLang';
import useContextMenuHandlers from '../../hooks/useContextMenuHandlers'; import useContextMenuHandlers from '../../hooks/useContextMenuHandlers';
import useContextMenuPosition from '../../hooks/useContextMenuPosition'; import useContextMenuPosition from '../../hooks/useContextMenuPosition';
import useThumbnail from '../../hooks/useThumbnail';
import AnimatedSticker from './AnimatedSticker'; import StickerView from './StickerView';
import Button from '../ui/Button'; import Button from '../ui/Button';
import Menu from '../ui/Menu'; import Menu from '../ui/Menu';
import MenuItem from '../ui/MenuItem'; import MenuItem from '../ui/MenuItem';
import OptimizedVideo from '../ui/OptimizedVideo';
import './StickerButton.scss'; import './StickerButton.scss';
@ -72,31 +63,14 @@ const StickerButton = <T extends number | ApiSticker | ApiBotInlineMediaResult |
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const lang = useLang(); const lang = useLang();
const localMediaHash = `sticker${sticker.id}`; const {
const stickerSelector = `sticker-button-${sticker.id}`; id, isCustomEmoji, hasEffect: isPremium, stickerSetInfo,
} = sticker;
const isLocked = !isCurrentUserPremium && isPremium;
const isIntersecting = useIsIntersecting(ref, observeIntersection); const isIntersecting = useIsIntersecting(ref, observeIntersection);
const shouldLoad = isIntersecting;
const thumbDataUri = useThumbnail(sticker);
const previewBlobUrl = useMedia(getStickerPreviewHash(sticker.id), !isIntersecting, ApiMediaFormat.BlobUrl);
const shouldPlay = isIntersecting && !noAnimate; const shouldPlay = isIntersecting && !noAnimate;
const lottieData = useMedia(sticker.isLottie && localMediaHash, !shouldPlay);
const [isLottieLoaded, markLoaded, unmarkLoaded] = useFlag(Boolean(lottieData));
const canLottiePlay = isLottieLoaded && shouldPlay;
const isVideo = sticker.isVideo && IS_WEBM_SUPPORTED;
const isCustomEmoji = sticker.isCustomEmoji;
const videoBlobUrl = useMedia(isVideo && localMediaHash, !shouldPlay, ApiMediaFormat.BlobUrl);
const canVideoPlay = Boolean(isVideo && videoBlobUrl && shouldPlay);
const isPremiumSticker = sticker.hasEffect;
const isLocked = !isCurrentUserPremium && isPremiumSticker;
const { transitionClassNames: previewTransitionClassNames } = useShowTransition(
Boolean(previewBlobUrl || canLottiePlay),
undefined,
undefined,
'slow',
);
const { const {
isContextMenuOpen, contextMenuPosition, isContextMenuOpen, contextMenuPosition,
@ -125,24 +99,6 @@ const StickerButton = <T extends number | ApiSticker | ApiBotInlineMediaResult |
getMenuElement, getMenuElement,
); );
// To avoid flickering
useEffect(() => {
if (!shouldPlay) {
unmarkLoaded();
}
}, [unmarkLoaded, shouldPlay]);
useEffect(() => {
if (!isVideo || !ref.current) return;
const video = ref.current.querySelector('video');
if (!video) return;
if (canVideoPlay) {
safePlay(video);
} else {
video.pause();
}
}, [isVideo, canVideoPlay]);
useEffect(() => { useEffect(() => {
if (!isIntersecting) handleContextMenuClose(); if (!isIntersecting) handleContextMenuClose();
}, [handleContextMenuClose, isIntersecting]); }, [handleContextMenuClose, isIntersecting]);
@ -189,8 +145,8 @@ const StickerButton = <T extends number | ApiSticker | ApiBotInlineMediaResult |
}, [clickArg, onClick]); }, [clickArg, onClick]);
const handleOpenSet = useCallback(() => { const handleOpenSet = useCallback(() => {
openStickerSet({ stickerSetInfo: sticker.stickerSetInfo }); openStickerSet({ stickerSetInfo });
}, [openStickerSet, sticker]); }, [openStickerSet, stickerSetInfo]);
const shouldShowCloseButton = !IS_TOUCH_ENV && onRemoveRecentClick; const shouldShowCloseButton = !IS_TOUCH_ENV && onRemoveRecentClick;
@ -198,15 +154,14 @@ const StickerButton = <T extends number | ApiSticker | ApiBotInlineMediaResult |
'StickerButton', 'StickerButton',
onClick && 'interactive', onClick && 'interactive',
isCustomEmoji && 'custom-emoji', isCustomEmoji && 'custom-emoji',
stickerSelector, `sticker-button-${id}`,
className, className,
); );
const style = (thumbDataUri && !canLottiePlay && !canVideoPlay) ? `background-image: url('${thumbDataUri}');` : '';
const contextMenuItems = useMemo(() => { const contextMenuItems = useMemo(() => {
if (noContextMenu || isCustomEmoji) return [];
const items: ReactNode[] = []; const items: ReactNode[] = [];
if (noContextMenu || isCustomEmoji) return items;
if (onUnfaveClick) { if (onUnfaveClick) {
items.push( items.push(
@ -261,36 +216,21 @@ const StickerButton = <T extends number | ApiSticker | ApiBotInlineMediaResult |
ref={ref} ref={ref}
className={fullClassName} className={fullClassName}
title={title || (sticker?.emoji)} title={title || (sticker?.emoji)}
style={style} data-sticker-id={id}
data-sticker-id={sticker.id}
onMouseDown={handleMouseDown} onMouseDown={handleMouseDown}
onClick={handleClick} onClick={handleClick}
onContextMenu={handleContextMenu} onContextMenu={handleContextMenu}
> >
{!canLottiePlay && !canVideoPlay && ( <StickerView
// eslint-disable-next-line jsx-a11y/alt-text containerRef={ref}
<img src={previewBlobUrl} className={previewTransitionClassNames} /> sticker={sticker}
)} isSmall
{isVideo && ( size={size}
<OptimizedVideo shouldLoop
canPlay={canVideoPlay} shouldPreloadPreview
className={previewTransitionClassNames} noLoad={!shouldLoad}
src={videoBlobUrl} noPlay={!shouldPlay}
loop />
playsInline
disablePictureInPicture
muted
/>
)}
{shouldPlay && lottieData && (
<AnimatedSticker
tgsUrl={lottieData}
play
size={size}
isLowPriority={!selectIsAlwaysHighPriorityEmoji(getGlobal(), sticker.stickerSetInfo)}
onLoad={markLoaded}
/>
)}
{isLocked && ( {isLocked && (
<div <div
className="sticker-locked" className="sticker-locked"
@ -298,7 +238,7 @@ const StickerButton = <T extends number | ApiSticker | ApiBotInlineMediaResult |
<i className="icon-lock-badge" /> <i className="icon-lock-badge" />
</div> </div>
)} )}
{isPremiumSticker && !isLocked && ( {isPremium && !isLocked && (
<div className="sticker-premium"> <div className="sticker-premium">
<i className="icon-premium" /> <i className="icon-premium" />
</div> </div>

View File

@ -0,0 +1,21 @@
.thumb {
width: 100%;
height: 100%;
&:global(.closing) {
transition-delay: 150ms;
}
}
.media {
position: absolute;
width: 100%;
height: 100%;
}
.thumb, .media {
// @optimization
&:not(:global(.shown)) {
display: block !important;
}
}

View File

@ -0,0 +1,145 @@
import React, { memo, useState } from '../../lib/teact/teact';
import { getGlobal } from '../../global';
import type { FC } from '../../lib/teact/teact';
import type { ObserveFn } from '../../hooks/useIntersectionObserver';
import type { ApiSticker } from '../../api/types';
import { IS_WEBM_SUPPORTED } from '../../util/environment';
import * as mediaLoader from '../../util/mediaLoader';
import buildClassName from '../../util/buildClassName';
import { getStickerPreviewHash } from '../../global/helpers';
import { selectIsAlwaysHighPriorityEmoji } from '../../global/selectors';
import useMedia from '../../hooks/useMedia';
import { useIsIntersecting } from '../../hooks/useIntersectionObserver';
import useThumbnail from '../../hooks/useThumbnail';
import useMediaTransition from '../../hooks/useMediaTransition';
import useFlag from '../../hooks/useFlag';
import AnimatedSticker from './AnimatedSticker';
import OptimizedVideo from '../ui/OptimizedVideo';
import styles from './StickerView.module.scss';
type OwnProps = {
containerRef: React.RefObject<HTMLDivElement>;
sticker: ApiSticker;
thumbClassName?: string;
fullMediaHash?: string;
fullMediaClassName?: string;
isSmall?: boolean;
size?: number;
customColor?: [number, number, number];
loopLimit?: number;
shouldLoop?: boolean;
shouldPreloadPreview?: boolean;
observeIntersection?: ObserveFn;
observeIntersectionForPlaying?: ObserveFn;
noLoad?: boolean;
noPlay?: boolean;
cacheBuster?: number;
onVideoEnded?: AnyToVoidFunction;
onAnimatedStickerLoop?: AnyToVoidFunction;
};
const STICKER_SIZE = 24;
const StickerView: FC<OwnProps> = ({
containerRef,
sticker,
thumbClassName,
fullMediaHash,
fullMediaClassName,
isSmall,
size = STICKER_SIZE,
customColor,
loopLimit,
shouldLoop = false,
shouldPreloadPreview,
observeIntersection,
observeIntersectionForPlaying,
noLoad,
noPlay,
cacheBuster,
onVideoEnded,
onAnimatedStickerLoop,
}) => {
const {
id, isLottie, stickerSetInfo, emoji,
} = sticker;
const isUnsupportedVideo = sticker.isVideo && !IS_WEBM_SUPPORTED;
const isVideo = sticker.isVideo && !isUnsupportedVideo;
const isStatic = !isLottie && !isVideo;
const previewMediaHash = getStickerPreviewHash(sticker.id);
const isIntersectingForLoad = useIsIntersecting(containerRef, observeIntersection);
const shouldLoad = isIntersectingForLoad && !noLoad;
const isIntersectingForPlaying = useIsIntersecting(containerRef, observeIntersectionForPlaying);
const shouldPlay = isIntersectingForPlaying && !noPlay;
const thumbDataUri = useThumbnail(sticker);
// Use preview instead of thumb but only if it's already loaded
const [preloadedPreviewData] = useState(mediaLoader.getFromMemory(previewMediaHash));
const thumbData = preloadedPreviewData || thumbDataUri;
const shouldForcePreview = isUnsupportedVideo || (isStatic && isSmall);
fullMediaHash ||= shouldForcePreview ? previewMediaHash : `sticker${id}`;
// If preloaded preview is forced, it will render as thumb, so no need to load it again
const shouldSkipFullMedia = Boolean(fullMediaHash === previewMediaHash && preloadedPreviewData);
const fullMediaData = useMedia(fullMediaHash, !shouldLoad || shouldSkipFullMedia, undefined, cacheBuster);
const [isPlayerReady, markPlayerReady] = useFlag(Boolean(isLottie && fullMediaData));
const isFullMediaReady = fullMediaData && (isStatic || isPlayerReady);
const fullMediaClassNames = useMediaTransition(isFullMediaReady);
const thumbClassNames = useMediaTransition(!isFullMediaReady);
// Preload preview for Message Input and local message
useMedia(previewMediaHash, !shouldLoad || !shouldPreloadPreview, undefined, cacheBuster);
return (
<>
<img
src={thumbData}
className={buildClassName(styles.thumb, thumbClassName, thumbClassNames)}
alt=""
/>
{isLottie ? (
<AnimatedSticker
size={size}
key={fullMediaData}
className={buildClassName(styles.media, fullMediaClassName, fullMediaClassNames)}
tgsUrl={fullMediaData}
play={shouldPlay}
color={customColor}
noLoop={!shouldLoop}
isLowPriority={!selectIsAlwaysHighPriorityEmoji(getGlobal(), stickerSetInfo)}
onLoad={markPlayerReady}
onLoop={onAnimatedStickerLoop}
/>
) : isVideo ? (
<OptimizedVideo
canPlay={shouldPlay && shouldLoop}
className={buildClassName(styles.media, fullMediaClassName, fullMediaClassNames)}
src={fullMediaData}
playsInline
muted
loop={!loopLimit}
disablePictureInPicture
onPlay={markPlayerReady}
onEnded={onVideoEnded}
/>
) : (
<img
className={buildClassName(styles.media, fullMediaClassName, fullMediaClassNames)}
src={fullMediaData}
alt={emoji}
/>
)}
</>
);
};
export default memo(StickerView);

View File

@ -310,9 +310,7 @@ function processEntity(
if (entity.type === ApiMessageEntityTypes.CustomEmoji) { if (entity.type === ApiMessageEntityTypes.CustomEmoji) {
return ( return (
<CustomEmoji documentId={entity.documentId} observeIntersection={observeIntersection} withGridFix> <CustomEmoji documentId={entity.documentId} observeIntersection={observeIntersection} withGridFix />
{renderNestedMessagePart()}
</CustomEmoji>
); );
} }
return text; return text;
@ -420,9 +418,7 @@ function processEntity(
return <Spoiler messageId={messageId}>{renderNestedMessagePart()}</Spoiler>; return <Spoiler messageId={messageId}>{renderNestedMessagePart()}</Spoiler>;
case ApiMessageEntityTypes.CustomEmoji: case ApiMessageEntityTypes.CustomEmoji:
return ( return (
<CustomEmoji documentId={entity.documentId} observeIntersection={observeIntersection} withGridFix> <CustomEmoji documentId={entity.documentId} observeIntersection={observeIntersection} withGridFix />
{renderNestedMessagePart()}
</CustomEmoji>
); );
default: default:
return renderNestedMessagePart(); return renderNestedMessagePart();

View File

@ -10,14 +10,10 @@ const handlers = new Set<AnyToVoidFunction>();
let prevGlobal: GlobalState | undefined; let prevGlobal: GlobalState | undefined;
addCallback((global: GlobalState) => { addCallback((global: GlobalState) => {
const customEmojiById = global.customEmojis.byId; if (global.customEmojis.byId !== prevGlobal?.customEmojis.byId) {
for (const handler of handlers) {
if (customEmojiById === prevGlobal?.customEmojis.byId) { handler();
return; }
}
for (const handler of handlers) {
handler();
} }
prevGlobal = global; prevGlobal = global;
@ -30,13 +26,11 @@ export default function useCustomEmoji(documentId: string) {
setCustomEmoji(getGlobal().customEmojis.byId[documentId]); setCustomEmoji(getGlobal().customEmojis.byId[documentId]);
}, [documentId]); }, [documentId]);
useEffect(() => { useEffect(handleGlobalChange, [documentId, handleGlobalChange]);
if (!documentId) return;
handleGlobalChange();
}, [documentId, handleGlobalChange]);
useEffect(() => { useEffect(() => {
if (customEmoji) return undefined; if (customEmoji) return undefined;
handlers.add(handleGlobalChange); handlers.add(handleGlobalChange);
return () => { return () => {

View File

@ -38,7 +38,7 @@ const CustomEmojiButton: FC<OwnProps> = ({
onMouseDown={handleClick} onMouseDown={handleClick}
title={emoji.emoji} title={emoji.emoji}
> >
<CustomEmoji documentId={emoji.id} size={CUSTOM_EMOJI_SIZE} withPreview /> <CustomEmoji documentId={emoji.id} size={CUSTOM_EMOJI_SIZE} shouldPreloadPreview />
</div> </div>
); );
}; };

View File

@ -1,26 +1,29 @@
import type { ApiMessageEntityCustomEmoji, ApiSticker } from '../../../../api/types'; import type { ApiMessageEntityCustomEmoji, ApiSticker } from '../../../../api/types';
import { getCustomEmojiPreviewMediaData } from '../../../../util/customEmojiManager'; import { getCustomEmojiPreviewMediaData } from '../../../../util/customEmojiManager';
import placeholderSrc from '../../../../assets/square.svg';
export const INPUT_CUSTOM_EMOJI_SELECTOR = 'img[data-document-id]'; export const INPUT_CUSTOM_EMOJI_SELECTOR = 'img[data-document-id]';
export function buildCustomEmojiHtml(emoji: ApiSticker) { export function buildCustomEmojiHtml(emoji: ApiSticker) {
const mediaData = getCustomEmojiPreviewMediaData(emoji.id); const mediaData = getCustomEmojiPreviewMediaData(emoji.id);
const src = mediaData && `src="${mediaData}"`;
return `<img return `<img
class="custom-emoji emoji emoji-small" class="custom-emoji emoji emoji-small${!mediaData ? ' placeholder' : ''}"
draggable="false" draggable="false"
alt="${emoji.emoji}" alt="${emoji.emoji}"
data-document-id="${emoji.id}" data-document-id="${emoji.id}"
${src} />`; src="${mediaData || placeholderSrc}"
/>`;
} }
export function buildCustomEmojiHtmlFromEntity(rawText: string, entity: ApiMessageEntityCustomEmoji) { export function buildCustomEmojiHtmlFromEntity(rawText: string, entity: ApiMessageEntityCustomEmoji) {
const mediaData = getCustomEmojiPreviewMediaData(entity.documentId); const mediaData = getCustomEmojiPreviewMediaData(entity.documentId);
const src = mediaData && `src="${mediaData}"`;
return `<img return `<img
class="custom-emoji emoji emoji-small" class="custom-emoji emoji emoji-small${!mediaData ? ' placeholder' : ''}"
draggable="false" draggable="false"
alt="${rawText}" alt="${rawText}"
data-document-id="${entity.documentId}" data-document-id="${entity.documentId}"
${src} />`; src="${mediaData || placeholderSrc}"
/>`;
} }

View File

@ -919,7 +919,8 @@ const Message: FC<OwnProps & StateProps> = ({
<CustomEmoji <CustomEmoji
documentId={senderEmojiStatus.documentId} documentId={senderEmojiStatus.documentId}
loopLimit={EMOJI_STATUS_LOOP_LIMIT} loopLimit={EMOJI_STATUS_LOOP_LIMIT}
observeIntersection={observeIntersectionForAnimatedStickers} observeIntersection={observeIntersectionForMedia}
observeIntersectionForPlaying={observeIntersectionForAnimatedStickers}
/> />
)} )}
{!asForwarded && !senderEmojiStatus && senderIsPremium && <PremiumIcon />} {!asForwarded && !senderEmojiStatus && senderIsPremium && <PremiumIcon />}

View File

@ -5,7 +5,7 @@ import type { ApiMessage } from '../../../api/types';
import { ApiMediaFormat } from '../../../api/types'; import { ApiMediaFormat } from '../../../api/types';
import { getStickerDimensions } from '../../common/helpers/mediaDimensions'; import { getStickerDimensions } from '../../common/helpers/mediaDimensions';
import { getMessageMediaFormat, getMessageMediaHash, getStickerPreviewHash } from '../../../global/helpers'; import { getMessageMediaHash } from '../../../global/helpers';
import buildClassName from '../../../util/buildClassName'; import buildClassName from '../../../util/buildClassName';
import { IS_WEBM_SUPPORTED } from '../../../util/environment'; import { IS_WEBM_SUPPORTED } from '../../../util/environment';
import { getActions } from '../../../global'; import { getActions } from '../../../global';
@ -13,13 +13,11 @@ import { getActions } from '../../../global';
import type { ObserveFn } from '../../../hooks/useIntersectionObserver'; import type { ObserveFn } from '../../../hooks/useIntersectionObserver';
import { useIsIntersecting } from '../../../hooks/useIntersectionObserver'; import { useIsIntersecting } from '../../../hooks/useIntersectionObserver';
import useMedia from '../../../hooks/useMedia'; import useMedia from '../../../hooks/useMedia';
import useMediaTransition from '../../../hooks/useMediaTransition';
import useFlag from '../../../hooks/useFlag'; import useFlag from '../../../hooks/useFlag';
import useThumbnail from '../../../hooks/useThumbnail';
import useLang from '../../../hooks/useLang'; import useLang from '../../../hooks/useLang';
import StickerView from '../../common/StickerView';
import AnimatedSticker from '../../common/AnimatedSticker'; import AnimatedSticker from '../../common/AnimatedSticker';
import OptimizedVideo from '../../ui/OptimizedVideo';
import './Sticker.scss'; import './Sticker.scss';
@ -45,56 +43,27 @@ const Sticker: FC<OwnProps> = ({
const { showNotification, openStickerSet } = getActions(); const { showNotification, openStickerSet } = getActions();
const lang = useLang(); const lang = useLang();
// eslint-disable-next-line no-null/no-null // eslint-disable-next-line no-null/no-null
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const sticker = message.content.sticker!; const sticker = message.content.sticker!;
const { const { stickerSetInfo, isVideo, hasEffect } = sticker;
isLottie, stickerSetInfo, isVideo, hasEffect,
} = sticker;
const canDisplayVideo = IS_WEBM_SUPPORTED;
const isMemojiSticker = 'isMissing' in stickerSetInfo;
const [isPlayingEffect, startPlayingEffect, stopPlayingEffect] = useFlag(); const mediaHash = sticker.isPreloadedGlobally ? undefined : (
const shouldLoad = useIsIntersecting(ref, observeIntersection); getMessageMediaHash(message, isVideo && !IS_WEBM_SUPPORTED ? 'pictogram' : 'inline')!
const shouldPlay = useIsIntersecting(ref, observeIntersectionForPlaying);
const mediaHash = sticker.isPreloadedGlobally ? `sticker${sticker.id}` : getMessageMediaHash(message, 'inline')!;
const mediaHashEffect = `sticker${sticker.id}?size=f`;
const previewMediaHash = isVideo && !canDisplayVideo && (
sticker.isPreloadedGlobally ? getStickerPreviewHash(sticker.id) : getMessageMediaHash(message, 'pictogram'));
const previewBlobUrl = useMedia(previewMediaHash);
const thumbDataUri = useThumbnail(sticker);
const previewUrl = previewBlobUrl || thumbDataUri;
const mediaData = useMedia(
mediaHash,
!shouldLoad,
getMessageMediaFormat(message, 'inline'),
lastSyncTime,
); );
const canLoad = useIsIntersecting(ref, observeIntersection);
const canPlay = useIsIntersecting(ref, observeIntersectionForPlaying);
const mediaHashEffect = `sticker${sticker.id}?size=f`;
const effectBlobUrl = useMedia( const effectBlobUrl = useMedia(
mediaHashEffect, mediaHashEffect,
!shouldLoad || !hasEffect, !canLoad || !hasEffect,
ApiMediaFormat.BlobUrl, ApiMediaFormat.BlobUrl,
lastSyncTime, lastSyncTime,
); );
const [isPlayingEffect, startPlayingEffect, stopPlayingEffect] = useFlag();
const isMediaLoaded = Boolean(mediaData);
const [isLottieLoaded, markLottieLoaded] = useFlag(isMediaLoaded);
const isMediaReady = isLottie ? isLottieLoaded : isMediaLoaded;
const transitionClassNames = useMediaTransition(isMediaReady);
const { width, height } = getStickerDimensions(sticker);
const thumbClassName = buildClassName('thumbnail', !thumbDataUri && 'empty');
const stickerClassName = buildClassName(
'Sticker media-inner',
isMemojiSticker && 'inactive',
hasEffect && !message.isOutgoing && 'reversed',
);
const handleEffectEnded = useCallback(() => { const handleEffectEnded = useCallback(() => {
stopPlayingEffect(); stopPlayingEffect();
@ -102,11 +71,11 @@ const Sticker: FC<OwnProps> = ({
}, [onStopEffect, stopPlayingEffect]); }, [onStopEffect, stopPlayingEffect]);
useEffect(() => { useEffect(() => {
if (hasEffect && shouldPlay && shouldPlayEffect) { if (hasEffect && canPlay && shouldPlayEffect) {
startPlayingEffect(); startPlayingEffect();
onPlayEffect?.(); onPlayEffect?.();
} }
}, [hasEffect, shouldPlayEffect, onPlayEffect, shouldPlay, startPlayingEffect]); }, [hasEffect, canPlay, onPlayEffect, shouldPlayEffect, startPlayingEffect]);
const openModal = useCallback(() => { const openModal = useCallback(() => {
openStickerSet({ openStickerSet({
@ -132,50 +101,33 @@ const Sticker: FC<OwnProps> = ({
openModal(); openModal();
}, [hasEffect, isPlayingEffect, lang, onPlayEffect, openModal, showNotification, startPlayingEffect]); }, [hasEffect, isPlayingEffect, lang, onPlayEffect, openModal, showNotification, startPlayingEffect]);
const isMemojiSticker = 'isMissing' in stickerSetInfo;
const { width, height } = getStickerDimensions(sticker);
const className = buildClassName(
'Sticker media-inner',
isMemojiSticker && 'inactive',
hasEffect && !message.isOutgoing && 'reversed',
);
return ( return (
<div ref={ref} className={stickerClassName} onClick={!isMemojiSticker ? handleClick : undefined}> <div
{(!isMediaReady || (isVideo && !canDisplayVideo)) && ( ref={ref}
<img className={className}
src={previewUrl} style={`width: ${width}px; height: ${height}px;`}
width={width} onClick={!isMemojiSticker ? handleClick : undefined}
height={height} >
alt="" <StickerView
className={thumbClassName} containerRef={ref}
/> sticker={sticker}
)} fullMediaHash={mediaHash}
{!isLottie && !isVideo && ( fullMediaClassName="full-media"
<img size={width}
src={mediaData as string} shouldLoop={shouldLoop}
width={width} noLoad={!canLoad}
height={height} noPlay={!canPlay}
alt="" cacheBuster={lastSyncTime}
className={buildClassName('full-media', transitionClassNames)} />
/> {hasEffect && canLoad && isPlayingEffect && (
)}
{isVideo && canDisplayVideo && isMediaReady && (
<OptimizedVideo
canPlay={shouldPlay}
src={mediaData as string}
width={width}
height={height}
playsInline
disablePictureInPicture
loop={shouldLoop}
muted
/>
)}
{isLottie && isMediaLoaded && (
<AnimatedSticker
key={mediaHash}
className={buildClassName('full-media', transitionClassNames)}
tgsUrl={mediaData}
size={width}
play={shouldPlay}
noLoop={!shouldLoop}
onLoad={markLottieLoaded}
/>
)}
{hasEffect && shouldLoad && isPlayingEffect && (
<AnimatedSticker <AnimatedSticker
key={mediaHashEffect} key={mediaHashEffect}
className="effect-sticker" className="effect-sticker"

View File

@ -85,8 +85,7 @@ export function updateStickerSet(
setIds = setIds.filter((id) => id !== stickerSetId); setIds = setIds.filter((id) => id !== stickerSetId);
} }
const customEmojiById = isCustomEmoji && currentStickerSet.stickers const customEmojiById = isCustomEmoji && update.stickers && buildCollectionByKey(update.stickers, 'id');
&& buildCollectionByKey(currentStickerSet.stickers, 'id');
return { return {
...global, ...global,

View File

@ -21,13 +21,18 @@ const updateLastRendered = throttle(() => {
RENDER_HISTORY.clear(); RENDER_HISTORY.clear();
}, THROTTLE, false); }, THROTTLE, false);
export default function useEnsureCustomEmoji(id: string) { export function notifyCustomEmojiRender(emojiId: string) {
RENDER_HISTORY.add(id); RENDER_HISTORY.add(emojiId);
updateLastRendered(); updateLastRendered();
}
export default function useEnsureCustomEmoji(id: string) {
notifyCustomEmojiRender(id);
if (getGlobal().customEmojis.byId[id]) { if (getGlobal().customEmojis.byId[id]) {
return; return;
} }
LOAD_QUEUE.add(id); LOAD_QUEUE.add(id);
loadFromQueue(); loadFromQueue();
} }

View File

@ -1,18 +1,23 @@
import { ApiMediaFormat } from '../api/types'; import { ApiMediaFormat } from '../api/types';
import { getStickerPreviewHash } from '../global/helpers'; import { getStickerPreviewHash } from '../global/helpers';
import { notifyCustomEmojiRender } from '../hooks/useEnsureCustomEmoji';
import * as mediaLoader from './mediaLoader'; import * as mediaLoader from './mediaLoader';
import { throttle } from './schedulers'; import { throttle } from './schedulers';
const DOM_PROCESS_THROTTLE = 500; const DOM_PROCESS_THROTTLE = 500;
function processDomForCustomEmoji() { function processDomForCustomEmoji() {
const emojis = document.querySelectorAll<HTMLImageElement>('img[data-document-id]:not([src])'); const emojis = document.querySelectorAll<HTMLImageElement>('.custom-emoji.placeholder');
emojis.forEach((emoji) => { emojis.forEach((emoji) => {
const mediaHash = getStickerPreviewHash(emoji.dataset.documentId!); const emojiId = emoji.dataset.documentId!;
const mediaHash = getStickerPreviewHash(emojiId);
const mediaData = mediaLoader.getFromMemory(mediaHash); const mediaData = mediaLoader.getFromMemory(mediaHash);
if (mediaData) { if (mediaData) {
emoji.src = mediaData; emoji.src = mediaData;
emoji.classList.remove('placeholder');
notifyCustomEmojiRender(emojiId);
} }
}); });
} }
@ -23,6 +28,7 @@ export function getCustomEmojiPreviewMediaData(emojiId: string) {
const mediaHash = getStickerPreviewHash(emojiId); const mediaHash = getStickerPreviewHash(emojiId);
const data = mediaLoader.getFromMemory(mediaHash); const data = mediaLoader.getFromMemory(mediaHash);
if (data) { if (data) {
notifyCustomEmojiRender(emojiId);
return data; return data;
} }