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;
width: 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;
height: calc(100% + 1px) !important;
vertical-align: baseline;
@ -19,15 +20,14 @@
}
}
.media {
.thumb {
width: 100%;
height: 100%;
}
.sticker {
.media {
width: var(--custom-emoji-size) !important;
height: var(--custom-emoji-size) !important;
display: flex !important;
:global(canvas) {
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 { ObserveFn } from '../../hooks/useIntersectionObserver';
import { IS_WEBM_SUPPORTED } from '../../util/environment';
import renderText from './helpers/renderText';
import { getPropertyHexColor } from '../../util/themeStyle';
import { hexToRgb } from '../../util/switchTheme';
import buildClassName from '../../util/buildClassName';
import { getStickerPreviewHash } from '../../global/helpers';
import { selectIsAlwaysHighPriorityEmoji, selectIsDefaultEmojiStatusPack } from '../../global/selectors';
import safePlay from '../../util/safePlay';
import { selectIsDefaultEmojiStatusPack } from '../../global/selectors';
import useMedia from '../../hooks/useMedia';
import useEnsureCustomEmoji from '../../hooks/useEnsureCustomEmoji';
import { useIsIntersecting } from '../../hooks/useIntersectionObserver';
import useThumbnail from '../../hooks/useThumbnail';
import useCustomEmoji from './hooks/useCustomEmoji';
import useMediaTransition from '../../hooks/useMediaTransition';
import AnimatedSticker from './AnimatedSticker';
import OptimizedVideo from '../ui/OptimizedVideo';
import StickerView from './StickerView';
import styles from './CustomEmoji.module.scss';
import svgPlaceholder from '../../assets/square.svg';
type OwnProps = {
documentId: string;
@ -34,8 +27,9 @@ type OwnProps = {
className?: string;
loopLimit?: number;
withGridFix?: boolean;
withPreview?: boolean;
shouldPreloadPreview?: boolean;
observeIntersection?: ObserveFn;
observeIntersectionForPlaying?: ObserveFn;
onClick?: NoneToVoidFunction;
};
@ -43,35 +37,26 @@ const STICKER_SIZE = 24;
const CustomEmoji: FC<OwnProps> = ({
documentId,
children,
size = STICKER_SIZE,
className,
loopLimit,
withGridFix,
withPreview,
shouldPreloadPreview,
observeIntersection,
observeIntersectionForPlaying,
onClick,
}) => {
// eslint-disable-next-line no-null/no-null
const ref = useRef<HTMLDivElement>(null);
// An alternative to `withGlobal` to avoid adding numerous global containers
const customEmoji = useCustomEmoji(documentId);
const isUnsupportedVideo = customEmoji?.isVideo && !IS_WEBM_SUPPORTED;
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);
useEnsureCustomEmoji(documentId);
const loopCountRef = useRef(0);
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);
useEffect(() => {
@ -88,10 +73,6 @@ const CustomEmoji: FC<OwnProps> = ({
setCustomColor([customColorRgb.r, customColorRgb.g, customColorRgb.b]);
}, [hasCustomColor]);
const isIntersecting = useIsIntersecting(ref, observeIntersection);
useEnsureCustomEmoji(documentId);
const handleVideoEnded = useCallback((e) => {
if (!loopLimit) return;
@ -117,53 +98,6 @@ const CustomEmoji: FC<OwnProps> = ({
}
}, [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 (
<div
ref={ref}
@ -174,11 +108,29 @@ const CustomEmoji: FC<OwnProps> = ({
'emoji',
hasCustomColor && 'custom-color',
withGridFix && styles.withGridFix,
...transitionClassNames,
)}
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>
);
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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