diff --git a/src/components/common/AnimatedSticker.tsx b/src/components/common/AnimatedSticker.tsx index 64df49049..59bd48bf6 100644 --- a/src/components/common/AnimatedSticker.tsx +++ b/src/components/common/AnimatedSticker.tsx @@ -2,7 +2,7 @@ import type { RefObject } from 'react'; import type { FC } from '../../lib/teact/teact'; import React, { - useEffect, useRef, memo, useCallback, useState, + useEffect, useRef, memo, useCallback, useState, useMemo, } from '../../lib/teact/teact'; import { fastRaf } from '../../util/schedulers'; @@ -16,7 +16,7 @@ import generateIdFor from '../../util/generateIdFor'; export type OwnProps = { ref?: RefObject; - id?: string; + animationId?: string; className?: string; style?: string; tgsUrl?: string; @@ -26,9 +26,11 @@ export type OwnProps = { noLoop?: boolean; size: number; quality?: number; + color?: [number, number, number]; isLowPriority?: boolean; forceOnHeavyAnimation?: boolean; - color?: [number, number, number]; + sharedCanvas?: HTMLCanvasElement; + sharedCanvasCoords?: { x: number; y: number }; onClick?: NoneToVoidFunction; onLoad?: NoneToVoidFunction; onEnded?: NoneToVoidFunction; @@ -57,7 +59,7 @@ setTimeout(ensureLottie, LOTTIE_LOAD_DELAY); const AnimatedSticker: FC = ({ ref, - id, + animationId, className, style, tgsUrl, @@ -70,6 +72,8 @@ const AnimatedSticker: FC = ({ isLowPriority, color, forceOnHeavyAnimation, + sharedCanvas, + sharedCanvasCoords, onClick, onLoad, onEnded, @@ -81,6 +85,8 @@ const AnimatedSticker: FC = ({ containerRef = ref; } + const containerId = useMemo(() => generateIdFor(ID_STORE, true), []); + const [animation, setAnimation] = useState(); const wasPlaying = useRef(false); const isFrozen = useRef(false); @@ -92,25 +98,28 @@ const AnimatedSticker: FC = ({ playSegmentRef.current = playSegment; useEffect(() => { - if (animation || !tgsUrl) { + if (animation || !tgsUrl || (sharedCanvas && !sharedCanvasCoords)) { return; } const exec = () => { - if (!containerRef.current) { + const container = containerRef.current || sharedCanvas; + if (!container) { return; } const newAnimation = RLottie.init( - containerRef.current, + containerId, + container, onLoad, - id || generateIdFor(ID_STORE, true), + animationId || generateIdFor(ID_STORE, true), tgsUrl, { noLoop, size, quality, isLowPriority, + coords: sharedCanvasCoords, }, color, onEnded, @@ -135,7 +144,10 @@ const AnimatedSticker: FC = ({ }); }); } - }, [color, animation, tgsUrl, isLowPriority, noLoop, onLoad, quality, size, speed, onEnded, onLoop, id]); + }, [ + animation, animationId, tgsUrl, color, isLowPriority, noLoop, onLoad, quality, size, speed, onEnded, onLoop, + containerId, sharedCanvas, sharedCanvasCoords, + ]); useEffect(() => { if (!animation) return; @@ -144,32 +156,30 @@ const AnimatedSticker: FC = ({ }, [color, animation]); useEffect(() => { - const container = containerRef.current!; - return () => { if (animation) { - animation.removeContainer(container); + animation.removeContainer(containerId); } }; - }, [animation]); + }, [animation, containerId]); const playAnimation = useCallback((shouldRestart = false) => { if (animation && (playRef.current || playSegmentRef.current)) { if (playSegmentRef.current) { animation.playSegment(playSegmentRef.current); } else { - animation.play(shouldRestart, containerRef.current!); + animation.play(shouldRestart, containerId); } } - }, [animation]); + }, [animation, containerId]); const pauseAnimation = useCallback(() => { if (!animation) { return; } - animation.pause(containerRef.current!); - }, [animation]); + animation.pause(containerId); + }, [animation, containerId]); const freezeAnimation = useCallback(() => { isFrozen.current = true; @@ -241,6 +251,10 @@ const AnimatedSticker: FC = ({ // then we can play again. useBackgroundMode(freezeAnimation, unfreezeAnimationOnRaf); + if (sharedCanvas) { + return undefined; + } + return (
; + sharedCanvasHqRef?: React.RefObject; + withTranslucentThumb?: boolean; shouldPreloadPreview?: boolean; forceOnHeavyAnimation?: boolean; observeIntersectionForLoading?: ObserveFn; @@ -40,7 +43,7 @@ type OwnProps = { onClick?: NoneToVoidFunction; }; -const STICKER_SIZE = 24; +const STICKER_SIZE = 20; const CustomEmoji: FC = ({ ref, @@ -50,9 +53,12 @@ const CustomEmoji: FC = ({ loopLimit, style, withGridFix, + withSharedAnimation, + sharedCanvasRef, + sharedCanvasHqRef, + withTranslucentThumb, shouldPreloadPreview, forceOnHeavyAnimation, - withSharedAnimation, observeIntersectionForLoading, observeIntersectionForPlaying, onClick, @@ -121,6 +127,8 @@ const CustomEmoji: FC = ({ } }, [loopLimit]); + const isHq = customEmoji?.stickerSetInfo && selectIsAlwaysHighPriorityEmoji(getGlobal(), customEmoji.stickerSetInfo); + return (
= ({ observeIntersectionForLoading={observeIntersectionForLoading} observeIntersectionForPlaying={observeIntersectionForPlaying} withSharedAnimation={withSharedAnimation} + sharedCanvasRef={isHq ? sharedCanvasHqRef : sharedCanvasRef} + withTranslucentThumb={withTranslucentThumb} onVideoEnded={handleVideoEnded} onAnimatedStickerLoop={handleStickerLoop} /> diff --git a/src/components/common/EmbeddedMessage.tsx b/src/components/common/EmbeddedMessage.tsx index da57fba98..b1ac1fbf3 100644 --- a/src/components/common/EmbeddedMessage.tsx +++ b/src/components/common/EmbeddedMessage.tsx @@ -13,7 +13,6 @@ import { import renderText from './helpers/renderText'; import { getPictogramDimensions } from './helpers/mediaDimensions'; import buildClassName from '../../util/buildClassName'; -import { renderMessageSummary } from './helpers/renderMessageText'; import type { ObserveFn } from '../../hooks/useIntersectionObserver'; import { useIsIntersecting } from '../../hooks/useIntersectionObserver'; @@ -22,11 +21,11 @@ import useThumbnail from '../../hooks/useThumbnail'; import useLang from '../../hooks/useLang'; import ActionMessage from '../middle/ActionMessage'; +import MessageSummary from './MessageSummary'; import './EmbeddedMessage.scss'; type OwnProps = { - observeIntersection?: ObserveFn; className?: string; message?: ApiMessage; sender?: ApiUser | ApiChat; @@ -35,6 +34,8 @@ type OwnProps = { noUserColors?: boolean; isProtected?: boolean; hasContextMenu?: boolean; + observeIntersectionForLoading?: ObserveFn; + observeIntersectionForPlaying?: ObserveFn; onClick: NoneToVoidFunction; }; @@ -49,12 +50,13 @@ const EmbeddedMessage: FC = ({ isProtected, noUserColors, hasContextMenu, - observeIntersection, + observeIntersectionForLoading, + observeIntersectionForPlaying, onClick, }) => { // eslint-disable-next-line no-null/no-null const ref = useRef(null); - const isIntersecting = useIsIntersecting(ref, observeIntersection); + const isIntersecting = useIsIntersecting(ref, observeIntersectionForLoading); const mediaBlobUrl = useMedia(message && getMessageMediaHash(message, 'pictogram'), !isIntersecting); const mediaThumbnail = useThumbnail(message); @@ -80,9 +82,20 @@ const EmbeddedMessage: FC = ({ {!message ? ( customText || NBSP ) : isActionMessage(message) ? ( - + ) : ( - renderMessageSummary(lang, message, Boolean(mediaThumbnail)) + )}

{renderText(senderTitle || title || NBSP)}
diff --git a/src/components/common/MessageSummary.tsx b/src/components/common/MessageSummary.tsx new file mode 100644 index 000000000..8a3811d4f --- /dev/null +++ b/src/components/common/MessageSummary.tsx @@ -0,0 +1,83 @@ +import React, { memo } from '../../lib/teact/teact'; + +import type { ApiMessage } from '../../api/types'; +import type { ObserveFn } from '../../hooks/useIntersectionObserver'; +import type { LangFn } from '../../hooks/useLang'; + +import { ApiMessageEntityTypes } from '../../api/types'; +import trimText from '../../util/trimText'; +import { + getMessageSummaryDescription, + getMessageSummaryEmoji, + getMessageSummaryText, + TRUNCATED_SUMMARY_LENGTH, +} from '../../global/helpers'; +import renderText from './helpers/renderText'; + +import MessageText from './MessageText'; + +interface OwnProps { + lang: LangFn; + message: ApiMessage; + noEmoji?: boolean; + highlight?: string; + truncateLength?: number; + observeIntersectionForLoading?: ObserveFn; + observeIntersectionForPlaying?: ObserveFn; + withTranslucentThumbs?: boolean; +} + +function MessageSummary({ + lang, + message, + noEmoji = false, + highlight, + truncateLength = TRUNCATED_SUMMARY_LENGTH, + observeIntersectionForLoading, + observeIntersectionForPlaying, + withTranslucentThumbs, +}: OwnProps) { + const { text, entities } = message.content.text || {}; + + const hasSpoilers = entities?.some((e) => e.type === ApiMessageEntityTypes.Spoiler); + const hasCustomEmoji = entities?.some((e) => e.type === ApiMessageEntityTypes.CustomEmoji); + if (!text || (!hasSpoilers && !hasCustomEmoji)) { + const trimmedText = trimText(getMessageSummaryText(lang, message, noEmoji), truncateLength); + + return ( + + {highlight ? ( + renderText(trimmedText, ['emoji', 'highlight'], { highlight }) + ) : ( + renderText(trimmedText) + )} + + ); + } + + function renderMessageText() { + return ( + + ); + } + + const emoji = !noEmoji && getMessageSummaryEmoji(message); + + return ( + <> + {[ + emoji ? renderText(`${emoji} `) : undefined, + getMessageSummaryDescription(lang, message, renderMessageText()), + ].flat().filter(Boolean)} + + ); +} + +export default memo(MessageSummary); diff --git a/src/components/common/MessageText.tsx b/src/components/common/MessageText.tsx new file mode 100644 index 000000000..69a2ec9ee --- /dev/null +++ b/src/components/common/MessageText.tsx @@ -0,0 +1,79 @@ +import React, { memo, useMemo, useRef } from '../../lib/teact/teact'; + +import type { ApiMessage } from '../../api/types'; +import type { ObserveFn } from '../../hooks/useIntersectionObserver'; + +import { ApiMessageEntityTypes } from '../../api/types'; +import trimText from '../../util/trimText'; +import { getMessageText } from '../../global/helpers'; +import { renderTextWithEntities } from './helpers/renderTextWithEntities'; + +interface OwnProps { + message: ApiMessage; + emojiSize?: number; + highlight?: string; + isSimple?: boolean; + truncateLength?: number; + isProtected?: boolean; + observeIntersectionForLoading?: ObserveFn; + observeIntersectionForPlaying?: ObserveFn; + withTranslucentThumbs?: boolean; + shouldRenderAsHtml?: boolean; +} + +const MIN_CUSTOM_EMOJIS_FOR_SHARED_CANVAS = 1; + +function MessageText({ + message, + emojiSize, + highlight, + isSimple, + truncateLength, + isProtected, + observeIntersectionForLoading, + observeIntersectionForPlaying, + withTranslucentThumbs, + shouldRenderAsHtml, +}: OwnProps) { + // eslint-disable-next-line no-null/no-null + const sharedCanvasRef = useRef(null); + // eslint-disable-next-line no-null/no-null + const sharedCanvasHqRef = useRef(null); + + const { text, entities } = message.content.text || {}; + const withSharedCanvas = useMemo(() => { + const customEmojisCount = entities?.filter((e) => e.type === ApiMessageEntityTypes.CustomEmoji).length || 0; + return customEmojisCount >= MIN_CUSTOM_EMOJIS_FOR_SHARED_CANVAS; + }, [entities]) || 0; + + if (!text) { + const contentNotSupportedText = getMessageText(message); + return contentNotSupportedText ? [trimText(contentNotSupportedText, truncateLength)] : undefined as any; + } + + return ( + <> + {[ + withSharedCanvas && , + withSharedCanvas && , + renderTextWithEntities( + trimText(text!, truncateLength), + entities, + highlight, + emojiSize, + shouldRenderAsHtml, + message.id, + isSimple, + isProtected, + observeIntersectionForLoading, + observeIntersectionForPlaying, + withTranslucentThumbs, + sharedCanvasRef, + sharedCanvasHqRef, + ), + ].flat().filter(Boolean)} + + ); +} + +export default memo(MessageText); diff --git a/src/components/common/StickerButton.tsx b/src/components/common/StickerButton.tsx index cc93c412f..aec5447b6 100644 --- a/src/components/common/StickerButton.tsx +++ b/src/components/common/StickerButton.tsx @@ -33,6 +33,7 @@ type OwnProps = { isSavedMessages?: boolean; canViewSet?: boolean; isCurrentUserPremium?: boolean; + sharedCanvasRef?: React.RefObject; observeIntersection: ObserveFn; onClick?: (arg: OwnProps['clickArg'], isSilent?: boolean, shouldSchedule?: boolean) => void; clickArg: T; @@ -47,16 +48,17 @@ const StickerButton = ) => { const { openStickerSet, openPremiumModal } = getActions(); // eslint-disable-next-line no-null/no-null @@ -231,6 +233,7 @@ const StickerButton = {isLocked && (
= ({ // eslint-disable-next-line no-null/no-null const containerRef = useRef(null); + // eslint-disable-next-line no-null/no-null + const sharedCanvasRef = useRef(null); + const lang = useLang(); const prevStickerSet = usePrevious(stickerSet); @@ -148,17 +151,21 @@ const StickerSetModal: FC = ({ {renderingStickerSet?.stickers ? ( <>
- {renderingStickerSet.stickers.map((sticker) => ( - - ))} +
+ + {renderingStickerSet.stickers.map((sticker) => ( + + ))} +
{inlineButton && ( diff --git a/src/components/middle/MessageListContent.tsx b/src/components/middle/MessageListContent.tsx index fc49c0a64..76a627b4c 100644 --- a/src/components/middle/MessageListContent.tsx +++ b/src/components/middle/MessageListContent.tsx @@ -143,8 +143,9 @@ const MessageListContent: FC = ({ = ({ const containerRef = useRef(null); // eslint-disable-next-line no-null/no-null const headerRef = useRef(null); + // eslint-disable-next-line no-null/no-null + const sharedCanvasRef = useRef(null); + // eslint-disable-next-line no-null/no-null + const sharedCanvasHqRef = useRef(null); + const [activeSetIndex, setActiveSetIndex] = useState(0); const { observe: observeIntersection } = useIntersectionObserver({ @@ -179,12 +188,16 @@ const CustomEmojiPicker: FC = ({ index === activeSetIndex && 'activated', ); + const withSharedCanvas = index < STICKER_PICKER_MAX_SHARED_COVERS; + const isHq = selectIsAlwaysHighPriorityEmoji(getGlobal(), stickerSet as ApiStickerSet); + if (stickerSet.id === RECENT_SYMBOL_SET_ID || stickerSet.id === FAVORITE_SYMBOL_SET_ID || stickerSet.id === CHAT_STICKER_SET_ID || stickerSet.id === PREMIUM_STICKER_SET_ID || stickerSet.hasThumbnail - || !firstSticker) { + || !firstSticker + ) { return ( @@ -219,6 +233,7 @@ const CustomEmojiPicker: FC = ({ observeIntersection={observeIntersectionForCovers} noContextMenu isCurrentUserPremium + sharedCanvasRef={withSharedCanvas ? (isHq ? sharedCanvasHqRef : sharedCanvasRef) : undefined} onClick={selectStickerSet} clickArg={index} /> @@ -246,7 +261,11 @@ const CustomEmojiPicker: FC = ({ ref={headerRef} className="StickerPicker-header no-selection no-scrollbar" > - {allSets.map(renderCover)} +
+ + + {allSets.map(renderCover)} +
= ({ const containerRef = useRef(null); // eslint-disable-next-line no-null/no-null const headerRef = useRef(null); + // eslint-disable-next-line no-null/no-null + const sharedCanvasRef = useRef(null); + const [activeSetIndex, setActiveSetIndex] = useState(0); const sendMessageAction = useSendMessageAction(chat!.id, threadId); @@ -260,12 +263,15 @@ const StickerPicker: FC = ({ index === activeSetIndex && 'activated', ); + const withSharedCanvas = index < STICKER_PICKER_MAX_SHARED_COVERS; + if (stickerSet.id === RECENT_SYMBOL_SET_ID || stickerSet.id === FAVORITE_SYMBOL_SET_ID || stickerSet.id === CHAT_STICKER_SET_ID || stickerSet.id === PREMIUM_STICKER_SET_ID || stickerSet.hasThumbnail - || !firstSticker) { + || !firstSticker + ) { return ( @@ -306,6 +313,7 @@ const StickerPicker: FC = ({ observeIntersection={observeIntersectionForCovers} noContextMenu isCurrentUserPremium + sharedCanvasRef={withSharedCanvas ? sharedCanvasRef : undefined} onClick={selectStickerSet} clickArg={index} /> @@ -335,7 +343,10 @@ const StickerPicker: FC = ({ ref={headerRef} className="StickerPicker-header no-selection no-scrollbar" > - {allSets.map(renderCover)} +
+ + {allSets.map(renderCover)} +
= ({ openPremiumModal, toggleStickerSet, } = getActions(); + // eslint-disable-next-line no-null/no-null const ref = useRef(null); + + // eslint-disable-next-line no-null/no-null + const sharedCanvasRef = useRef(null); + // eslint-disable-next-line no-null/no-null + const sharedCanvas2Ref = useRef(null); + const [isConfirmModalOpen, openConfirmModal, closeConfirmModal] = useFlag(); - const [isExpanded, expand] = useFlag(!stickerSet.isEmoji); const lang = useLang(); useOnIntersect(ref, observeIntersection); @@ -79,6 +85,7 @@ const StickerSet: FC = ({ const transitionClassNames = useMediaTransition(shouldRender); const isRecent = stickerSet.id === RECENT_SYMBOL_SET_ID; + const isFavorite = stickerSet.id === FAVORITE_SYMBOL_SET_ID; const isEmoji = stickerSet.isEmoji; const isPremiumSet = !isRecent && selectIsSetPremium(stickerSet); @@ -113,11 +120,11 @@ const StickerSet: FC = ({ ? Math.floor((windowSize.get().width - MOBILE_CONTAINER_PADDING) / (itemSize + margin)) : itemsPerRow; - const shouldCutSet = isEmoji && !isExpanded && !stickerSet.installedDate && stickerSet.id !== RECENT_SYMBOL_SET_ID; - const itemsBeforeCutout = shouldCutSet ? stickersPerRow * 3 : Infinity; - const height = Math.ceil(( - !shouldCutSet ? stickerSet.count : Math.min(itemsBeforeCutout, stickerSet.count)) - / stickersPerRow) * (itemSize + margin); + const canCut = !stickerSet.installedDate && stickerSet.id !== RECENT_SYMBOL_SET_ID; + const [isCut, , expand] = useFlag(canCut); + const itemsBeforeCutout = stickersPerRow * 3 - 1; + const heightWhenCut = Math.ceil(Math.min(itemsBeforeCutout, stickerSet.count) / stickersPerRow) * (itemSize + margin); + const height = isCut ? heightWhenCut : Math.ceil(stickerSet.count / stickersPerRow) * (itemSize + margin); const favoriteStickerIdsSet = useMemo(() => ( favoriteStickers ? new Set(favoriteStickers.map(({ id }) => id)) : undefined @@ -154,32 +161,46 @@ const StickerSet: FC = ({ )}
+ + {(isRecent || isFavorite || canCut) && } {shouldRender && stickerSet.stickers && stickerSet.stickers - .slice(0, !isExpanded ? (itemsBeforeCutout - 1) : stickerSet.stickers.length) - .map((sticker) => ( - - ))} - {!isExpanded && stickerSet.count > itemsBeforeCutout && ( + .slice(0, isCut ? itemsBeforeCutout : stickerSet.stickers.length) + .map((sticker, i) => { + const isHqEmoji = (isRecent || isFavorite) + && selectIsAlwaysHighPriorityEmoji(getGlobal(), sticker.stickerSetInfo); + const canvasRef = (canCut && i >= itemsBeforeCutout) || isHqEmoji + ? sharedCanvas2Ref + : sharedCanvasRef; + + return ( + + ); + })} + {isCut && stickerSet.count > itemsBeforeCutout && ( )}
diff --git a/src/components/middle/composer/StickerSetCover.tsx b/src/components/middle/composer/StickerSetCover.tsx index 8b371a3d9..cf2349c20 100644 --- a/src/components/middle/composer/StickerSetCover.tsx +++ b/src/components/middle/composer/StickerSetCover.tsx @@ -1,16 +1,19 @@ import type { FC } from '../../../lib/teact/teact'; import React, { memo, useRef } from '../../../lib/teact/teact'; +import { getGlobal } from '../../../global'; import type { ApiStickerSet } from '../../../api/types'; import type { ObserveFn } from '../../../hooks/useIntersectionObserver'; import { STICKER_SIZE_PICKER_HEADER } from '../../../config'; +import { selectIsAlwaysHighPriorityEmoji } from '../../../global/selectors'; import { IS_WEBM_SUPPORTED } from '../../../util/environment'; import { getFirstLetters } from '../../../util/textFormat'; import buildClassName from '../../../util/buildClassName'; import { useIsIntersecting } from '../../../hooks/useIntersectionObserver'; import useMedia from '../../../hooks/useMedia'; import useMediaTransition from '../../../hooks/useMediaTransition'; +import useSharedCanvasCoords from '../../../hooks/useSharedCanvasCoords'; import AnimatedSticker from '../../common/AnimatedSticker'; import OptimizedVideo from '../../ui/OptimizedVideo'; @@ -22,6 +25,7 @@ type OwnProps = { size?: number; noAnimate?: boolean; observeIntersection: ObserveFn; + sharedCanvasRef?: React.RefObject; }; const StickerSetCover: FC = ({ @@ -29,20 +33,23 @@ const StickerSetCover: FC = ({ size = STICKER_SIZE_PICKER_HEADER, noAnimate, observeIntersection, + sharedCanvasRef, }) => { // eslint-disable-next-line no-null/no-null - const ref = useRef(null); + const containerRef = useRef(null); const { hasThumbnail, isLottie, isVideos: isVideo } = stickerSet; - const isIntersecting = useIsIntersecting(ref, observeIntersection); + const isIntersecting = useIsIntersecting(containerRef, observeIntersection); const mediaData = useMedia((hasThumbnail || isLottie) && `stickerSet${stickerSet.id}`, !isIntersecting); const isReady = mediaData && (!isVideo || IS_WEBM_SUPPORTED); const transitionClassNames = useMediaTransition(isReady); + const sharedCanvasCoords = useSharedCanvasCoords(containerRef, sharedCanvasRef); + return ( -
+
{isReady ? ( isLottie ? ( = ({ tgsUrl={mediaData} size={size} play={isIntersecting && !noAnimate} + isLowPriority={!selectIsAlwaysHighPriorityEmoji(getGlobal(), stickerSet)} + sharedCanvas={sharedCanvasRef?.current || undefined} + sharedCanvasCoords={sharedCanvasCoords} /> ) : isVideo ? ( EMOJI_SIZES) return undefined; - const size = (6 - (maxEmojisInLine * 0.625)) * REM; // Should be the same as in _message-content.scss - return size; + return (6 - (maxEmojisInLine * 0.625)) * REM; // Should be the same as in _message-content.scss } diff --git a/src/components/middle/message/Message.scss b/src/components/middle/message/Message.scss index cb3c348ac..752af77ac 100644 --- a/src/components/middle/message/Message.scss +++ b/src/components/middle/message/Message.scss @@ -23,6 +23,7 @@ var(--meta-safe-area-base) + var(--meta-safe-author-width) + var(--meta-safe-area-extra-width) ); --color-voice-transcribe: var(--color-voice-transcribe-button); + --thumbs-background: var(--color-background); --deleting-translate-x: -50%; --select-message-scale: 0.9; @@ -201,6 +202,7 @@ --deleting-translate-x: 50%; --color-text-green: var(--color-accent-own); --color-voice-transcribe: var(--color-voice-transcribe-button-own); + --thumbs-background: var(--color-background-own); @media (min-width: 1921px) { --max-width: 30vw; diff --git a/src/components/middle/message/Message.tsx b/src/components/middle/message/Message.tsx index 277e31d8c..f8784568e 100644 --- a/src/components/middle/message/Message.tsx +++ b/src/components/middle/message/Message.tsx @@ -74,12 +74,11 @@ import { areReactionsEmpty, getMessageHtmlId, isGeoLiveExpired, - getMessageSingleCustomEmoji, + getMessageSingleCustomEmoji, hasMessageText, } from '../../../global/helpers'; import buildClassName from '../../../util/buildClassName'; import useEnsureMessage from '../../../hooks/useEnsureMessage'; import useContextMenuHandlers from '../../../hooks/useContextMenuHandlers'; -import { renderMessageText } from '../../common/helpers/renderMessageText'; import { calculateDimensionsForMessageMedia, ROUND_VIDEO_DIMENSIONS_PX } from '../../common/helpers/mediaDimensions'; import { buildContentClassName } from './helpers/buildContentClassName'; import { getMinMediaWidth, calculateMediaDimensions } from './helpers/mediaDimensions'; @@ -96,7 +95,6 @@ import useOuterHandlers from './hooks/useOuterHandlers'; import useInnerHandlers from './hooks/useInnerHandlers'; import { getServerTime } from '../../../util/serverTime'; import { isElementInViewport } from '../../../util/isElementInViewport'; -import { getCustomEmojiSize } from '../composer/helpers/customEmoji'; import Button from '../../ui/Button'; import Avatar from '../../common/Avatar'; @@ -130,6 +128,8 @@ import PremiumIcon from '../../common/PremiumIcon'; import FakeIcon from '../../common/FakeIcon'; import './Message.scss'; +import MessageText from '../../common/MessageText'; +import { getCustomEmojiSize } from '../composer/helpers/customEmoji'; type MessagePositionProperties = { isFirstInGroup: boolean; @@ -356,15 +356,15 @@ const Message: FC = ({ const isScheduled = messageListType === 'scheduled' || message.isScheduled; const hasReply = isReplyMessage(message) && !shouldHideReply; const hasThread = Boolean(threadInfo) && messageListType === 'thread'; - const customShape = getMessageCustomShape(message); - const hasAnimatedEmoji = customShape && (animatedEmoji || animatedCustomEmoji); + const isCustomShape = getMessageCustomShape(message); + const hasAnimatedEmoji = isCustomShape && (animatedEmoji || animatedCustomEmoji); const hasReactions = reactionMessage?.reactions && !areReactionsEmpty(reactionMessage.reactions); const asForwarded = ( forwardInfo && (!isChatWithSelf || isScheduled) && !isRepliesChat && !forwardInfo.isLinkedChannelPost - && !customShape + && !isCustomShape ); const isAlbum = Boolean(album) && album!.messages.length > 1 && !album?.messages.some((msg) => Object.keys(msg.content).length === 0); @@ -509,7 +509,7 @@ const Message: FC = ({ const contentClassName = buildContentClassName(message, { hasReply, - customShape, + isCustomShape, isLastInGroup, asForwarded, hasThread, @@ -522,24 +522,15 @@ const Message: FC = ({ }); const withAppendix = contentClassName.includes('has-appendix'); + const hasText = hasMessageText(message); const emojiSize = message.emojiOnlyCount && getCustomEmojiSize(message.emojiOnlyCount); - const textParts = renderMessageText( - message, - highlight, - emojiSize, - undefined, - undefined, - isProtected, - observeIntersectionForMedia, - observeIntersectionForAnimatedStickers, - ); let metaPosition!: MetaPosition; if (phoneCall) { metaPosition = 'none'; } else if (isInDocumentGroupNotLast) { metaPosition = 'none'; - } else if (textParts && !webPage && !hasAnimatedEmoji) { + } else if (hasText && !webPage && !hasAnimatedEmoji) { metaPosition = 'in-text'; } else { metaPosition = 'standalone'; @@ -549,7 +540,7 @@ const Message: FC = ({ if (areReactionsInMeta) { reactionsPosition = 'in-meta'; } else if (hasReactions) { - if (customShape || ((photo || video) && !textParts)) { + if (isCustomShape || ((photo || video) && !hasText)) { reactionsPosition = 'outside'; } else if (asForwarded) { metaPosition = 'standalone'; @@ -686,7 +677,7 @@ const Message: FC = ({ hasReply && 'reply-message', noMediaCorners && 'no-media-corners', ); - const hasCustomAppendix = isLastInGroup && !textParts && !asForwarded && !hasThread; + const hasCustomAppendix = isLastInGroup && !hasText && !asForwarded && !hasThread; const textContentClass = buildClassName( 'text-content', metaPosition === 'in-text' && 'with-meta', @@ -702,7 +693,8 @@ const Message: FC = ({ noUserColors={isOwn} isProtected={isProtected} sender={replyMessageSender} - observeIntersection={observeIntersectionForMedia} + observeIntersectionForLoading={observeIntersectionForMedia} + observeIntersectionForPlaying={observeIntersectionForAnimatedStickers} onClick={handleReplyClick} /> )} @@ -877,9 +869,17 @@ const Message: FC = ({

)} - {!hasAnimatedEmoji && textParts && ( + {!hasAnimatedEmoji && hasText && (
- {textParts} + {metaPosition === 'in-text' && renderReactionsAndMeta()}
)} @@ -925,9 +925,9 @@ const Message: FC = ({ function renderSenderName() { const media = photo || video || location; - const shouldRender = !(customShape && !viaBotId) && ( + const shouldRender = !(isCustomShape && !viaBotId) && ( (withSenderName && !media) || asForwarded || viaBotId || forceSenderName - ) && !isInDocumentGroupNotFirst && !(hasReply && customShape); + ) && !isInDocumentGroupNotFirst && !(hasReply && isCustomShape); if (!shouldRender) { return undefined; @@ -935,7 +935,7 @@ const Message: FC = ({ let senderTitle; let senderColor; - if (senderPeer && !(customShape && viaBotId)) { + if (senderPeer && !(isCustomShape && viaBotId)) { senderTitle = getSenderTitle(lang, senderPeer); if (!asForwarded) { diff --git a/src/components/middle/message/helpers/buildContentClassName.ts b/src/components/middle/message/helpers/buildContentClassName.ts index 44a2ad696..0da0c8ff6 100644 --- a/src/components/middle/message/helpers/buildContentClassName.ts +++ b/src/components/middle/message/helpers/buildContentClassName.ts @@ -7,7 +7,7 @@ export function buildContentClassName( message: ApiMessage, { hasReply, - customShape, + isCustomShape, isLastInGroup, asForwarded, hasThread, @@ -19,7 +19,7 @@ export function buildContentClassName( withVoiceTranscription, }: { hasReply?: boolean; - customShape?: boolean | number; + isCustomShape?: boolean | number; isLastInGroup?: boolean; asForwarded?: boolean; hasThread?: boolean; @@ -54,7 +54,7 @@ export function buildContentClassName( classNames.push('has-action-button'); } - if (customShape) { + if (isCustomShape) { classNames.push('custom-shape'); if (video?.isRound) { classNames.push('round'); @@ -115,7 +115,7 @@ export function buildContentClassName( classNames.push('force-sender-name'); } - if (!customShape) { + if (!isCustomShape) { classNames.push('has-shadow'); if (isMedia && hasComments) { diff --git a/src/components/middle/message/helpers/copyOptions.ts b/src/components/middle/message/helpers/copyOptions.ts index bd4ff44ab..49c6cc978 100644 --- a/src/components/middle/message/helpers/copyOptions.ts +++ b/src/components/middle/message/helpers/copyOptions.ts @@ -74,7 +74,7 @@ export function getMessageCopyOptions( document.execCommand('copy'); } else { const clipboardText = renderMessageText( - message, undefined, undefined, undefined, undefined, undefined, undefined, undefined, true, + message, undefined, undefined, undefined, undefined, undefined, true, ); if (clipboardText) copyHtmlToClipboard(clipboardText.join(''), getMessageTextWithSpoilers(message)!); } diff --git a/src/components/right/StickerSetResult.tsx b/src/components/right/StickerSetResult.tsx index 613dbe2e3..55075d2d3 100644 --- a/src/components/right/StickerSetResult.tsx +++ b/src/components/right/StickerSetResult.tsx @@ -1,6 +1,6 @@ import type { FC } from '../../lib/teact/teact'; import React, { - useEffect, memo, useMemo, useCallback, + useEffect, memo, useMemo, useCallback, useRef, } from '../../lib/teact/teact'; import { getActions, withGlobal } from '../../global'; @@ -36,6 +36,9 @@ const StickerSetResult: FC = ({ }) => { const { loadStickers, toggleStickerSet, openStickerSet } = getActions(); + // eslint-disable-next-line no-null/no-null + const sharedCanvasRef = useRef(null); + const lang = useLang(); const isAdded = set && Boolean(set.installedDate); const areStickersLoaded = Boolean(set?.stickers); @@ -96,7 +99,8 @@ const StickerSetResult: FC = ({ {lang(isAdded ? 'Stickers.Installed' : 'Stickers.Install')}
-
+
+ {!canRenderStickers && } {canRenderStickers && displayedStickers.map((sticker) => ( = ({ onClick={handleStickerClick} noContextMenu isCurrentUserPremium={isCurrentUserPremium} + sharedCanvasRef={sharedCanvasRef} /> ))}
diff --git a/src/config.ts b/src/config.ts index 9a2ea74c1..7be04d87f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -142,6 +142,7 @@ export const EMOJI_SIZE_PICKER = 40; export const COMPOSER_EMOJI_SIZE_PICKER = 32; export const STICKER_SIZE_GENERAL_SETTINGS = 48; export const STICKER_SIZE_PICKER_HEADER = 32; +export const STICKER_PICKER_MAX_SHARED_COVERS = 20; export const STICKER_SIZE_SEARCH = 64; export const STICKER_SIZE_MODAL = 64; export const EMOJI_SIZE_MODAL = 40; diff --git a/src/global/helpers/messageSummary.ts b/src/global/helpers/messageSummary.ts index a1d7079ed..041851ef8 100644 --- a/src/global/helpers/messageSummary.ts +++ b/src/global/helpers/messageSummary.ts @@ -1,8 +1,8 @@ +import type { TeactNode } from '../../lib/teact/teact'; import type { ApiMessage } from '../../api/types'; import { ApiMessageEntityTypes } from '../../api/types'; import { CONTENT_NOT_SUPPORTED } from '../../config'; -import type { TextPart } from '../../types'; import type { LangFn } from '../../hooks/useLang'; import trimText from '../../util/trimText'; @@ -108,7 +108,7 @@ export function getMessageSummaryEmoji(message: ApiMessage, noReactions = true) export function getMessageSummaryDescription( lang: LangFn, message: ApiMessage, - truncatedText?: string | TextPart[], + truncatedText?: string | TeactNode, noReactions = true, isExtended = false, ) { @@ -127,7 +127,7 @@ export function getMessageSummaryDescription( game, } = message.content; - let summary: string | TextPart[] | undefined; + let summary: string | TeactNode | undefined; if (message.groupedId) { summary = truncatedText || lang('lng_in_dlg_album'); diff --git a/src/global/helpers/messages.ts b/src/global/helpers/messages.ts index 58132513a..5d762fc93 100644 --- a/src/global/helpers/messages.ts +++ b/src/global/helpers/messages.ts @@ -50,22 +50,20 @@ export function getMessageTranscription(message: ApiMessage) { return transcriptionId && global.transcriptions[transcriptionId]?.text; } -export function getMessageText(message: ApiMessage) { +export function hasMessageText(message: ApiMessage) { const { text, sticker, photo, video, audio, voice, document, poll, webPage, contact, invoice, location, game, action, } = message.content; - if (text) { - return text.text; - } + return Boolean(text) || !( + sticker || photo || video || audio || voice || document || contact || poll || webPage || invoice || location + || game || action?.phoneCall + ); +} - if (sticker || photo || video || audio || voice || document - || contact || poll || webPage || invoice || location || game || action?.phoneCall) { - return undefined; - } - - return CONTENT_NOT_SUPPORTED; +export function getMessageText(message: ApiMessage) { + return hasMessageText(message) ? message.content.text?.text || CONTENT_NOT_SUPPORTED : undefined; } export function getMessageCustomShape(message: ApiMessage): boolean { diff --git a/src/global/helpers/renderMessageSummaryHtml.ts b/src/global/helpers/renderMessageSummaryHtml.ts index fae4c3069..b018e4683 100644 --- a/src/global/helpers/renderMessageSummaryHtml.ts +++ b/src/global/helpers/renderMessageSummaryHtml.ts @@ -10,7 +10,7 @@ export function renderMessageSummaryHtml( const emoji = getMessageSummaryEmoji(message); const emojiWithSpace = emoji ? `${emoji} ` : ''; const text = renderMessageText( - message, undefined, undefined, undefined, undefined, undefined, undefined, undefined, true, + message, undefined, undefined, undefined, undefined, undefined, true, )?.join(''); const description = getMessageSummaryDescription(lang, message, text, true, true); diff --git a/src/global/selectors/symbols.ts b/src/global/selectors/symbols.ts index ff8930b1c..c8c06153d 100644 --- a/src/global/selectors/symbols.ts +++ b/src/global/selectors/symbols.ts @@ -139,10 +139,11 @@ export function selectLocalAnimatedEmojiEffectByName(name: string) { return name === 'Cumshot' ? '🍆' : undefined; } -export function selectIsDefaultEmojiStatusPack(global: GlobalState, pack: ApiStickerSetInfo) { - return 'id' in pack && pack.id === global.appConfig?.defaultEmojiStatusesStickerSetId; +export function selectIsDefaultEmojiStatusPack(global: GlobalState, stickerSet: ApiStickerSetInfo | ApiStickerSet) { + return 'id' in stickerSet && stickerSet.id === global.appConfig?.defaultEmojiStatusesStickerSetId; } -export function selectIsAlwaysHighPriorityEmoji(global: GlobalState, pack: ApiStickerSetInfo) { - return selectIsDefaultEmojiStatusPack(global, pack) || ('id' in pack && pack.id === RESTRICTED_EMOJI_SET_ID); +export function selectIsAlwaysHighPriorityEmoji(global: GlobalState, stickerSet: ApiStickerSetInfo | ApiStickerSet) { + return selectIsDefaultEmojiStatusPack(global, stickerSet) + || ('id' in stickerSet && stickerSet.id === RESTRICTED_EMOJI_SET_ID); } diff --git a/src/hooks/useSharedCanvasCoords.ts b/src/hooks/useSharedCanvasCoords.ts new file mode 100644 index 000000000..2699e7ce9 --- /dev/null +++ b/src/hooks/useSharedCanvasCoords.ts @@ -0,0 +1,26 @@ +import { useEffect, useMemo, useState } from '../lib/teact/teact'; + +export default function useSharedCanvasCoords( + containerRef: React.RefObject, + sharedCanvasRef?: React.RefObject, +) { + const [x, setX] = useState(); + const [y, setY] = useState(); + + useEffect(() => { + if (!sharedCanvasRef?.current) { + return; + } + + const container = containerRef.current!; + const target = container.classList.contains('sticker-set-cover') ? container : container.querySelector('img')!; + const targetBounds = target.getBoundingClientRect(); + const canvasBounds = sharedCanvasRef!.current!.getBoundingClientRect(); + + // Factor coords are used to support rendering while being rescaled (e.g. message appearance animation) + setX((targetBounds.left - canvasBounds.left) / canvasBounds.width); + setY((targetBounds.top - canvasBounds.top) / canvasBounds.height); + }, [containerRef, sharedCanvasRef]); + + return useMemo(() => (x !== undefined && y !== undefined ? { x, y } : undefined), [x, y]); +} diff --git a/src/lib/rlottie/RLottie.ts b/src/lib/rlottie/RLottie.ts index b4bc4d247..2d98088a2 100644 --- a/src/lib/rlottie/RLottie.ts +++ b/src/lib/rlottie/RLottie.ts @@ -13,6 +13,7 @@ interface Params { size?: number; quality?: number; isLowPriority?: boolean; + coords?: { x: number; y: number }; } type Frames = ArrayBuffer[]; @@ -36,11 +37,13 @@ let lastWorkerIndex = -1; class RLottie { // Config - private containers = new Map(); @@ -87,7 +90,7 @@ class RLottie { private lastRenderAt?: number; static init(...args: ConstructorParameters) { - const [container, onLoad, id] = args; + const [container, canvas, onLoad, id, , params] = args; let instance = instancesById.get(id); if (!instance) { @@ -95,14 +98,15 @@ class RLottie { instance = new RLottie(...args); instancesById.set(id, instance); } else { - instance.addContainer(container, onLoad); + instance.addContainer(container, canvas, onLoad, params?.coords); } return instance; } constructor( - container: HTMLDivElement, + containerId: string, + container: HTMLDivElement | HTMLCanvasElement, onLoad: NoneToVoidFunction | undefined, private id: string, private tgsUrl: string, @@ -111,14 +115,18 @@ class RLottie { private onEnded?: (isDestroyed?: boolean) => void, private onLoop?: () => void, ) { - this.addContainer(container, onLoad); + this.addContainer(containerId, container, onLoad, params.coords); this.initConfig(); this.initRenderer(); } - public removeContainer(container: HTMLDivElement) { - this.containers.get(container)!.canvas.remove(); - this.containers.delete(container); + public removeContainer(containerId: string) { + const containerData = this.containers.get(containerId)!; + if (!containerData.isSharedCanvas) { + this.containers.get(containerId)!.canvas.remove(); + } + + this.containers.delete(containerId); if (!this.containers.size) { this.destroy(); @@ -129,9 +137,9 @@ class RLottie { return this.isAnimating || this.isWaiting; } - play(forceRestart = false, container?: HTMLDivElement) { - if (container) { - this.containers.get(container)!.isPaused = false; + play(forceRestart = false, containerId?: string) { + if (containerId) { + this.containers.get(containerId)!.isPaused = false; } if (this.isEnded && forceRestart) { @@ -143,9 +151,9 @@ class RLottie { this.doPlay(); } - pause(container?: HTMLDivElement) { - if (container) { - this.containers.get(container)!.isPaused = true; + pause(containerId?: string) { + if (containerId) { + this.containers.get(containerId)!.isPaused = true; const areAllContainersPaused = Array.from(this.containers.values()).every(({ isPaused }) => isPaused); if (!areAllContainersPaused) { @@ -178,47 +186,85 @@ class RLottie { this.params.noLoop = noLoop; } - private addContainer(container: HTMLDivElement, onLoad?: NoneToVoidFunction) { - if (!(container.parentNode instanceof HTMLElement)) { - throw new Error('[RLottie] Container is not mounted'); - } + private addContainer( + containerId: string, + container: HTMLDivElement | HTMLCanvasElement, + onLoad?: NoneToVoidFunction, + coords?: Params['coords'], + ) { + const { isLowPriority, quality = isLowPriority ? LOW_PRIORITY_QUALITY : HIGH_PRIORITY_QUALITY } = this.params; + let imgSize: number; + // Reduced quality only looks acceptable on high DPR screens + const sizeFactor = Math.max(DPR * quality, 1); - let { size } = this.params; + if (container instanceof HTMLDivElement) { + if (!(container.parentNode instanceof HTMLElement)) { + throw new Error('[RLottie] Container is not mounted'); + } - if (!size) { - size = ( - container.offsetWidth - || parseInt(container.style.width, 10) - || container.parentNode.offsetWidth - ); + let { size } = this.params; if (!size) { - throw new Error('[RLottie] Failed to detect width from container'); + size = ( + container.offsetWidth + || parseInt(container.style.width, 10) + || container.parentNode.offsetWidth + ); + + if (!size) { + throw new Error('[RLottie] Failed to detect width from container'); + } } + + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d')!; + + canvas.style.width = `${size}px`; + canvas.style.height = `${size}px`; + + imgSize = Math.round(size * sizeFactor); + + canvas.width = imgSize; + canvas.height = imgSize; + + container.appendChild(canvas); + + this.containers.set(containerId, { + canvas, ctx, onLoad, + }); + } else { + if (!container.offsetParent) { + throw new Error('[RLottie] Shared canvas is not mounted'); + } + + const canvas = container; + const ctx = canvas.getContext('2d')!; + + imgSize = Math.round(this.params.size! * sizeFactor); + + const expectedWidth = Math.round(canvas.offsetWidth * sizeFactor); + const expectedHeight = Math.round(canvas.offsetHeight * sizeFactor); + if (canvas.width !== expectedWidth || canvas.height !== expectedHeight) { + canvas.width = expectedWidth; + canvas.height = expectedHeight; + } + + this.containers.set(containerId, { + canvas, + ctx, + isSharedCanvas: true, + coords: { + x: Math.round((coords?.x || 0) * canvas.width), + y: Math.round((coords?.y || 0) * canvas.height), + }, + onLoad, + }); } - const canvas = document.createElement('canvas'); - canvas.dataset.id = this.id; - const ctx = canvas.getContext('2d')!; - - canvas.style.width = `${size}px`; - canvas.style.height = `${size}px`; - - const { isLowPriority, quality = isLowPriority ? LOW_PRIORITY_QUALITY : HIGH_PRIORITY_QUALITY } = this.params; - // Reduced quality only looks acceptable on high DPR screens - const imgSize = Math.round(size * Math.max(DPR * quality, 1)); - - canvas.width = imgSize; - canvas.height = imgSize; - - container.appendChild(canvas); - if (!this.imgSize) { this.imgSize = imgSize; } - this.containers.set(container, { canvas, ctx, onLoad }); - if (this.isRendererInited) { this.doPlay(); } @@ -371,15 +417,16 @@ class RLottie { /* eslint-enable prefer-destructuring */ } } + const imageData = new ImageData(arr, this.imgSize, this.imgSize); this.containers.forEach((containerData) => { const { - ctx, isLoaded, isPaused, onLoad, + ctx, isLoaded, isPaused, coords: { x, y } = {}, onLoad, } = containerData; if (!isLoaded || !isPaused) { - ctx.putImageData(imageData, 0, 0); + ctx.putImageData(imageData, x || 0, y || 0); } if (!isLoaded) { diff --git a/src/styles/index.scss b/src/styles/index.scss index 36d83041e..87cda912b 100644 --- a/src/styles/index.scss +++ b/src/styles/index.scss @@ -286,6 +286,20 @@ div[role="button"] { left: 0; } +.shared-canvas-container { + position: relative; +} + +.shared-canvas { + position: absolute; + top: 0; + left: 0; + z-index: 1; + width: 100%; + height: 100%; + pointer-events: none; +} + @keyframes grow-icon { 0% { transform: scale(0.5);