From 2b6e3b3f8cefb6d3ff313fb6338993f7ad076a86 Mon Sep 17 00:00:00 2001 From: Alexander Zinchuk Date: Fri, 3 Mar 2023 14:30:19 +0100 Subject: [PATCH] Custom Emoji: Dynamically update color (#2681) --- src/components/common/CustomEmoji.tsx | 21 +----- src/components/common/ProfileInfo.scss | 9 +-- src/components/common/StickerButton.tsx | 21 +----- src/components/left/main/Chat.scss | 12 ++-- src/components/left/main/Chat.tsx | 6 -- src/components/left/main/LeftMainHeader.scss | 2 +- .../left/main/StatusPickerMenu.module.scss | 4 ++ src/components/middle/MiddleHeader.scss | 2 +- .../composer/hooks/useInputCustomEmojis.ts | 30 ++++++-- .../message/CustomEmojiEffect.module.scss | 2 +- .../middle/message/_message-content.scss | 2 +- src/hooks/useDynamicColorListener.ts | 70 +++++++++++++++++++ 12 files changed, 119 insertions(+), 62 deletions(-) create mode 100644 src/hooks/useDynamicColorListener.ts diff --git a/src/components/common/CustomEmoji.tsx b/src/components/common/CustomEmoji.tsx index ec6cd0185..b52262aa2 100644 --- a/src/components/common/CustomEmoji.tsx +++ b/src/components/common/CustomEmoji.tsx @@ -1,5 +1,5 @@ import React, { - memo, useCallback, useEffect, useRef, useState, + memo, useCallback, useRef, useState, } from '../../lib/teact/teact'; import { getGlobal } from '../../global'; @@ -7,13 +7,12 @@ import type { FC, TeactNode } from '../../lib/teact/teact'; import type { ObserveFn } from '../../hooks/useIntersectionObserver'; import { ApiMessageEntityTypes } from '../../api/types'; -import { getPropertyHexColor } from '../../util/themeStyle'; -import { hexToRgb } from '../../util/switchTheme'; import buildClassName from '../../util/buildClassName'; import safePlay from '../../util/safePlay'; import { selectIsAlwaysHighPriorityEmoji } from '../../global/selectors'; import useCustomEmoji from './hooks/useCustomEmoji'; +import useDynamicColorListener from '../../hooks/useDynamicColorListener'; import StickerView from './StickerView'; @@ -75,22 +74,8 @@ const CustomEmoji: FC = ({ const loopCountRef = useRef(0); const [shouldLoop, setShouldLoop] = useState(true); - const [customColor, setCustomColor] = useState<[number, number, number] | undefined>(); const hasCustomColor = customEmoji?.shouldUseTextColor; - - useEffect(() => { - if (!hasCustomColor) { - setCustomColor(undefined); - return; - } - const hexColor = getPropertyHexColor(getComputedStyle(containerRef.current!), '--color-text'); - if (!hexColor) { - setCustomColor(undefined); - return; - } - const customColorRgb = hexToRgb(hexColor); - setCustomColor([customColorRgb.r, customColorRgb.g, customColorRgb.b]); - }, [hasCustomColor]); + const { rgbColor: customColor } = useDynamicColorListener(containerRef, !hasCustomColor); const handleVideoEnded = useCallback((e) => { if (!loopLimit) return; diff --git a/src/components/common/ProfileInfo.scss b/src/components/common/ProfileInfo.scss index 5689ea4d2..647b168ba 100644 --- a/src/components/common/ProfileInfo.scss +++ b/src/components/common/ProfileInfo.scss @@ -28,9 +28,10 @@ .VerifiedIcon, .PremiumIcon { - z-index: 2; --color-fill: var(--color-white); --color-checkmark: var(--color-primary); + + z-index: 2; opacity: 0.8; } @@ -41,10 +42,10 @@ } .custom-emoji { + --custom-emoji-size: 1.5rem; + + color: var(--color-white); pointer-events: auto; cursor: pointer; - - --custom-emoji-size: 1.5rem; - --color-text: var(--color-white); } } diff --git a/src/components/common/StickerButton.tsx b/src/components/common/StickerButton.tsx index fe663d5ec..bd56aa86c 100644 --- a/src/components/common/StickerButton.tsx +++ b/src/components/common/StickerButton.tsx @@ -1,6 +1,6 @@ import type { MouseEvent as ReactMouseEvent, ReactNode } from 'react'; import React, { - memo, useCallback, useEffect, useMemo, useRef, useState, + memo, useCallback, useEffect, useMemo, useRef, } from '../../lib/teact/teact'; import { getActions } from '../../global'; @@ -9,8 +9,6 @@ import type { ApiBotInlineMediaResult, ApiSticker } from '../../api/types'; import buildClassName from '../../util/buildClassName'; import { preventMessageInputBlurWithBubbling } from '../middle/helpers/preventMessageInputBlur'; import { IS_TOUCH_ENV } from '../../util/environment'; -import { getPropertyHexColor } from '../../util/themeStyle'; -import { hexToRgb } from '../../util/switchTheme'; import { getServerTimeOffset } from '../../util/serverTime'; import type { ObserveFn } from '../../hooks/useIntersectionObserver'; @@ -18,6 +16,7 @@ import { useIsIntersecting } from '../../hooks/useIntersectionObserver'; import useLang from '../../hooks/useLang'; import useContextMenuHandlers from '../../hooks/useContextMenuHandlers'; import useContextMenuPosition from '../../hooks/useContextMenuPosition'; +import useDynamicColorListener from '../../hooks/useDynamicColorListener'; import StickerView from './StickerView'; import Button from '../ui/Button'; @@ -87,22 +86,8 @@ const StickerButton = (null); const lang = useLang(); - const [customColor, setCustomColor] = useState<[number, number, number] | undefined>(); const hasCustomColor = sticker.shouldUseTextColor; - - useEffect(() => { - if (!hasCustomColor) { - setCustomColor(undefined); - return; - } - const hexColor = getPropertyHexColor(getComputedStyle(ref.current!), '--color-text'); - if (!hexColor) { - setCustomColor(undefined); - return; - } - const customColorRgb = hexToRgb(hexColor); - setCustomColor([customColorRgb.r, customColorRgb.g, customColorRgb.b]); - }, [hasCustomColor]); + const { rgbColor: customColor } = useDynamicColorListener(ref, !hasCustomColor); const { id, isCustomEmoji, hasEffect: isPremium, stickerSetInfo, diff --git a/src/components/left/main/Chat.scss b/src/components/left/main/Chat.scss index 1f8be02c3..ef07cb933 100644 --- a/src/components/left/main/Chat.scss +++ b/src/components/left/main/Chat.scss @@ -73,8 +73,8 @@ &.selected:not(.forum):hover { --background-color: var(--color-chat-active) !important; - .custom-emoji { - --color-text: var(--color-white); + .title .custom-emoji { + color: var(--color-white); } .VerifiedIcon, .PremiumIcon { @@ -192,10 +192,6 @@ background: var(--background-color); } - .custom-emoji { - --color-text: var(--color-primary); - } - .avatar-badge-wrapper { position: absolute; bottom: 0; @@ -216,6 +212,10 @@ .info { transition: opacity 300ms ease, transform var(--layer-transition); + .title .custom-emoji { + color: var(--color-primary); + } + .icon-muted { font-size: 1.25rem; margin-top: -0.0625rem; diff --git a/src/components/left/main/Chat.tsx b/src/components/left/main/Chat.tsx index dc793f290..c611fd855 100644 --- a/src/components/left/main/Chat.tsx +++ b/src/components/left/main/Chat.tsx @@ -75,7 +75,6 @@ type StateProps = { isMuted?: boolean; user?: ApiUser; userStatus?: ApiUserStatus; - isEmojiStatusColored?: boolean; actionTargetUserIds?: string[]; actionTargetMessage?: ApiMessage; actionTargetChatId?: string; @@ -103,7 +102,6 @@ const Chat: FC = ({ isMuted, user, userStatus, - isEmojiStatusColored, actionTargetUserIds, lastMessageSender, lastMessageOutgoingStatus, @@ -256,7 +254,6 @@ const Chat: FC = ({ withEmojiStatus isSavedMessages={chatId === user?.id && user?.isSelf} observeIntersection={observeIntersection} - key={!isMobile && isEmojiStatusColored ? `${isSelected}` : undefined} /> {isMuted && }
@@ -331,8 +328,6 @@ export default memo(withGlobal( const typingStatus = selectThreadParam(global, chatId, MAIN_THREAD_ID, 'typingStatus'); - const statusEmoji = user?.emojiStatus && global.customEmojis.byId[user.emojiStatus.documentId]; - return { chat, isMuted: selectIsChatMuted(chat, selectNotifySettings(global), selectNotifyExceptions(global)), @@ -354,7 +349,6 @@ export default memo(withGlobal( userStatus, lastMessageTopic, typingStatus, - isEmojiStatusColored: statusEmoji?.shouldUseTextColor, }; }, )(Chat)); diff --git a/src/components/left/main/LeftMainHeader.scss b/src/components/left/main/LeftMainHeader.scss index 8395ee22c..e668a5cf7 100644 --- a/src/components/left/main/LeftMainHeader.scss +++ b/src/components/left/main/LeftMainHeader.scss @@ -102,7 +102,7 @@ .emoji-status { --custom-emoji-size: 1.5rem; - --color-text: var(--color-primary); + color: var(--color-primary); } .PremiumIcon { diff --git a/src/components/left/main/StatusPickerMenu.module.scss b/src/components/left/main/StatusPickerMenu.module.scss index d756e1b4f..fe06352a4 100644 --- a/src/components/left/main/StatusPickerMenu.module.scss +++ b/src/components/left/main/StatusPickerMenu.module.scss @@ -14,4 +14,8 @@ right: 0.5rem !important; width: calc(100vw - 1rem); } + + :global(.custom-emoji) { + color: var(--color-primary); + } } diff --git a/src/components/middle/MiddleHeader.scss b/src/components/middle/MiddleHeader.scss index d96f81a22..3dcd4e444 100644 --- a/src/components/middle/MiddleHeader.scss +++ b/src/components/middle/MiddleHeader.scss @@ -320,7 +320,7 @@ } .custom-emoji { - --color-text: var(--color-primary); + color: var(--color-primary); } } diff --git a/src/components/middle/composer/hooks/useInputCustomEmojis.ts b/src/components/middle/composer/hooks/useInputCustomEmojis.ts index 5b6472b6f..66bbe4471 100644 --- a/src/components/middle/composer/hooks/useInputCustomEmojis.ts +++ b/src/components/middle/composer/hooks/useInputCustomEmojis.ts @@ -21,6 +21,8 @@ import { REM } from '../../../common/helpers/mediaDimensions'; import useResizeObserver from '../../../../hooks/useResizeObserver'; import useBackgroundMode from '../../../../hooks/useBackgroundMode'; import useThrottledCallback from '../../../../hooks/useThrottledCallback'; +import useDynamicColorListener from '../../../../hooks/useDynamicColorListener'; +import useEffectWithPrevDeps from '../../../../hooks/useEffectWithPrevDeps'; const SIZE = 1.25 * REM; const THROTTLE_MS = 300; @@ -41,6 +43,7 @@ export default function useInputCustomEmojis( prefixId?: string, isActive?: boolean, ) { + const { rgbColor: textColor } = useDynamicColorListener(inputRef); const mapRef = useRef>(new Map()); const removeContainers = useCallback((ids: string[]) => { @@ -60,11 +63,12 @@ export default function useInputCustomEmojis( const customEmojies = Array.from(inputRef.current.querySelectorAll('.custom-emoji')); customEmojies.forEach((element) => { - const id = `${prefixId || ''}${element.dataset.uniqueId!}`; - const documentId = element.dataset.documentId!; - if (!id) { + if (!element.dataset.uniqueId) { return; } + const id = `${prefixId || ''}${element.dataset.uniqueId}${textColor?.join(',') || ''}`; + const documentId = element.dataset.documentId!; + removedContainers.delete(id); const mediaUrl = getCustomEmojiMediaDataForInput(documentId); @@ -95,9 +99,11 @@ export default function useInputCustomEmojis( sharedCanvasHqRef, absoluteContainerRef, uniqueId: id, + containerId: prefixId || id, mediaUrl, isHq, position: { x, y }, + textColor, }); animation.play(); @@ -105,7 +111,7 @@ export default function useInputCustomEmojis( }); removeContainers(Array.from(removedContainers)); - }, [absoluteContainerRef, inputRef, prefixId, removeContainers, sharedCanvasHqRef, sharedCanvasRef]); + }, [absoluteContainerRef, textColor, inputRef, prefixId, removeContainers, sharedCanvasHqRef, sharedCanvasRef]); useEffect(() => { addCustomEmojiInputRenderCallback(synchronizeElements); @@ -127,6 +133,12 @@ export default function useInputCustomEmojis( }); }, [getHtml, synchronizeElements, inputRef, removeContainers, sharedCanvasRef, isActive]); + useEffectWithPrevDeps(([prevTextColor]) => { + if (textColor !== prevTextColor) { + synchronizeElements(); + } + }, [textColor, synchronizeElements]); + const throttledSynchronizeElements = useThrottledCallback( synchronizeElements, [synchronizeElements], @@ -163,32 +175,38 @@ function createPlayer({ sharedCanvasHqRef, absoluteContainerRef, uniqueId, + containerId, mediaUrl, position, isHq, + textColor, }: { customEmoji: ApiSticker; sharedCanvasRef: React.RefObject; sharedCanvasHqRef: React.RefObject; absoluteContainerRef: React.RefObject; uniqueId: string; + containerId: string; mediaUrl: string; position: { x: number; y: number }; isHq?: boolean; + textColor?: [number, number, number]; }): CustomEmojiPlayer { if (customEmoji.isLottie) { const lottie = RLottie.init( - uniqueId, + containerId, isHq ? sharedCanvasHqRef.current! : sharedCanvasRef.current!, undefined, - customEmoji.id, + uniqueId, mediaUrl, { size: SIZE, coords: position, isLowPriority: !isHq, }, + customEmoji.shouldUseTextColor ? textColor : undefined, ); + return { play: () => lottie.play(), pause: () => lottie.pause(), diff --git a/src/components/middle/message/CustomEmojiEffect.module.scss b/src/components/middle/message/CustomEmojiEffect.module.scss index a8e867fba..e4b962414 100644 --- a/src/components/middle/message/CustomEmojiEffect.module.scss +++ b/src/components/middle/message/CustomEmojiEffect.module.scss @@ -4,7 +4,7 @@ } .particle { - --color-text: var(--color-primary); + color: var(--color-primary); position: absolute; width: 1rem; diff --git a/src/components/middle/message/_message-content.scss b/src/components/middle/message/_message-content.scss index d059c4215..ee4390185 100644 --- a/src/components/middle/message/_message-content.scss +++ b/src/components/middle/message/_message-content.scss @@ -281,7 +281,7 @@ color: var(--color-user-#{$i}); .custom-emoji { - --color-text: var(--color-user-#{$i}); + color: var(--color-user-#{$i}); } .PremiumIcon { diff --git a/src/hooks/useDynamicColorListener.ts b/src/hooks/useDynamicColorListener.ts new file mode 100644 index 000000000..d74daa800 --- /dev/null +++ b/src/hooks/useDynamicColorListener.ts @@ -0,0 +1,70 @@ +import { + useCallback, useEffect, useRef, useState, +} from '../lib/teact/teact'; +import { hexToRgb } from '../util/switchTheme'; +import { getPropertyHexColor } from '../util/themeStyle'; +import useResizeObserver from './useResizeObserver'; +import useSyncEffect from './useSyncEffect'; + +// Delay required to prevent constant re-rendering on theme change +const TRANSITION_STYLE = '0.1s color linear 50ms'; + +export default function useDynamicColorListener(ref?: React.RefObject, isDisabled?: boolean) { + const [hexColor, setHexColor] = useState(); + const rgbColorRef = useRef<[number, number, number] | undefined>(); + + const updateColor = useCallback(() => { + if (!ref?.current || isDisabled) { + setHexColor(undefined); + return; + } + + const currentHexColor = getPropertyHexColor(getComputedStyle(ref.current), 'color'); + setHexColor(currentHexColor); + }, [isDisabled, ref]); + + // Element does not receive `transitionend` event if parent has `display: none`. + // We will receive `resize` event when parent is shown again. + useResizeObserver(!isDisabled ? ref : undefined, updateColor); + + // Update RGB color only when hex color changes + useSyncEffect(() => { + if (!hexColor) { + rgbColorRef.current = undefined; + return; + } + + const { r, g, b } = hexToRgb(hexColor); + rgbColorRef.current = [r, g, b]; + }, [hexColor]); + + useEffect(() => { + if (!ref?.current) { + return undefined; + } + + updateColor(); + + if (isDisabled) { + return undefined; + } + + function handleTransitionEnd(e: TransitionEvent) { + if (e.propertyName !== 'color') return; + updateColor(); + } + + const el = ref.current; + el.addEventListener('transitionend', handleTransitionEnd); + el.style.setProperty('transition', TRANSITION_STYLE, 'important'); + return () => { + el.removeEventListener('transitionend', handleTransitionEnd); + el.style.removeProperty('transition'); + }; + }, [isDisabled, ref, updateColor]); + + return { + hexColor, + rgbColor: rgbColorRef.current, + }; +}