diff --git a/src/api/gramjs/apiBuilders/appConfig.ts b/src/api/gramjs/apiBuilders/appConfig.ts index c76c77c4d..a87b7e450 100644 --- a/src/api/gramjs/apiBuilders/appConfig.ts +++ b/src/api/gramjs/apiBuilders/appConfig.ts @@ -48,6 +48,7 @@ function buildEmojiSounds(appConfig: GramJsAppConfig) { fileReference: Buffer.from(atob(l.file_reference_base64 .replace(/-/g, '+') .replace(/_/g, '/'))), + size: BigInt(0), } as GramJs.Document); acc[key] = l.id; diff --git a/src/api/gramjs/apiBuilders/messages.ts b/src/api/gramjs/apiBuilders/messages.ts index 93f6f9af6..1c35479f9 100644 --- a/src/api/gramjs/apiBuilders/messages.ts +++ b/src/api/gramjs/apiBuilders/messages.ts @@ -56,6 +56,7 @@ import { buildPeer } from '../gramjsBuilders'; import { addPhotoToLocalDb, resolveMessageApiChatId, serializeBytes } from '../helpers'; import { buildApiPeerId, getApiChatIdFromMtpPeer, isPeerUser } from './peers'; import { buildApiCallDiscardReason } from './calls'; +import parseEmojiOnlyString from '../../../util/parseEmojiOnlyString'; const LOCAL_MEDIA_UPLOADING_TEMP_ID = 'temp'; const INPUT_WAVEFORM_LENGTH = 63; @@ -170,6 +171,7 @@ export function buildApiMessageWithChatId(chatId: string, mtpMessage: UniversalM const groupedId = mtpMessage.groupedId && String(mtpMessage.groupedId); const isInAlbum = Boolean(groupedId) && !(content.document || content.audio || content.sticker); const shouldHideKeyboardButtons = mtpMessage.replyMarkup instanceof GramJs.ReplyKeyboardHide; + const emojiOnlyCount = content.text && parseEmojiOnlyString(content.text.text); return { id: mtpMessage.id, @@ -183,6 +185,7 @@ export function buildApiMessageWithChatId(chatId: string, mtpMessage: UniversalM isFromScheduled: mtpMessage.fromScheduled, isSilent: mtpMessage.silent, reactions: mtpMessage.reactions && buildMessageReactions(mtpMessage.reactions), + ...(emojiOnlyCount && { emojiOnlyCount }), ...(replyToMsgId && { replyToMessageId: replyToMsgId }), ...(replyToPeerId && { replyToChatId: getApiChatIdFromMtpPeer(replyToPeerId) }), ...(replyToTopId && { replyToTopMessageId: replyToTopId }), @@ -1197,6 +1200,7 @@ export function buildLocalMessage( const localId = getNextLocalMessageId(); const media = attachment && buildUploadingMedia(attachment); const isChannel = chat.type === 'chatTypeChannel'; + const emojiOnlyCount = text && parseEmojiOnlyString(text); return { id: localId, @@ -1217,6 +1221,7 @@ export function buildLocalMessage( date: scheduledAt || Math.round(Date.now() / 1000) + serverTimeOffset, isOutgoing: !isChannel, senderId: sendAs?.id || currentUserId, + ...(emojiOnlyCount && { emojiOnlyCount }), ...(replyingTo && { replyToMessageId: replyingTo }), ...(replyingToTopId && { replyToTopMessageId: replyingToTopId }), ...(groupedId && { @@ -1256,6 +1261,7 @@ export function buildLocalForwardedMessage( text: content.text.text, entities: content.text.entities?.filter((entity) => entity.type !== ApiMessageEntityTypes.CustomEmoji), } : content.text; + const emojiOnlyCount = content.text && parseEmojiOnlyString(content.text.text); const updatedContent = { ...content, @@ -1272,6 +1278,7 @@ export function buildLocalForwardedMessage( sendingState: 'messageSendingStatePending', groupedId, isInAlbum, + ...(emojiOnlyCount && { emojiOnlyCount }), // Forward info doesn't get added when users forwards his own messages, also when forwarding audio ...(senderId !== currentUserId && !isAudio && !noAuthors && { forwardInfo: { diff --git a/src/api/types/messages.ts b/src/api/types/messages.ts index 9e6337fcb..8e546a18d 100644 --- a/src/api/types/messages.ts +++ b/src/api/types/messages.ts @@ -401,6 +401,7 @@ export interface ApiMessage { isProtected?: boolean; transcriptionId?: string; isTranscriptionError?: boolean; + emojiOnlyCount?: number; reactors?: { nextOffset?: string; count: number; diff --git a/src/components/common/AnimatedIconFromSticker.tsx b/src/components/common/AnimatedIconFromSticker.tsx index 08e9ce77a..2227a1eab 100644 --- a/src/components/common/AnimatedIconFromSticker.tsx +++ b/src/components/common/AnimatedIconFromSticker.tsx @@ -20,7 +20,7 @@ function AnimatedIconFromSticker(props: OwnProps) { } = props; const thumbDataUri = sticker?.thumbnail?.dataUri; - const localMediaHash = `sticker${sticker?.id}`; + const localMediaHash = sticker && `sticker${sticker.id}`; const previewBlobUrl = useMedia( sticker ? getStickerPreviewHash(sticker.id) : undefined, noLoad && !forcePreview, diff --git a/src/components/common/CustomEmoji.module.scss b/src/components/common/CustomEmoji.module.scss index 2559cbf7c..881fb565d 100644 --- a/src/components/common/CustomEmoji.module.scss +++ b/src/components/common/CustomEmoji.module.scss @@ -5,14 +5,11 @@ height: var(--custom-emoji-size); position: relative; + border-radius: 0 !important; + &.with-grid-fix .media, &.with-grid-fix .thumb { width: calc(100% + 1px) !important; height: calc(100% + 1px) !important; - vertical-align: baseline; - } - - :global(.emoji-small) { - vertical-align: baseline !important; // Fix for fallback on Windows, when custom emoji not ready } &:global(.custom-color) { @@ -29,8 +26,10 @@ width: var(--custom-emoji-size) !important; height: var(--custom-emoji-size) !important; + border-radius: 0 !important; + :global(canvas) { - width: var(--custom-emoji-size) !important; - height: var(--custom-emoji-size) !important; + width: 100% !important; + height: 100% !important; } } diff --git a/src/components/common/CustomEmoji.tsx b/src/components/common/CustomEmoji.tsx index 237c5ef8b..8fd873dfd 100644 --- a/src/components/common/CustomEmoji.tsx +++ b/src/components/common/CustomEmoji.tsx @@ -21,13 +21,16 @@ import styles from './CustomEmoji.module.scss'; import svgPlaceholder from '../../assets/square.svg'; type OwnProps = { + ref?: React.RefObject; documentId: string; children?: TeactNode; size?: number; className?: string; loopLimit?: number; + style?: string; withGridFix?: boolean; shouldPreloadPreview?: boolean; + forceOnHeavyAnimation?: boolean; observeIntersection?: ObserveFn; observeIntersectionForPlaying?: ObserveFn; onClick?: NoneToVoidFunction; @@ -36,18 +39,24 @@ type OwnProps = { const STICKER_SIZE = 24; const CustomEmoji: FC = ({ + ref, documentId, size = STICKER_SIZE, className, loopLimit, + style, withGridFix, shouldPreloadPreview, + forceOnHeavyAnimation, observeIntersection, observeIntersectionForPlaying, onClick, }) => { // eslint-disable-next-line no-null/no-null - const ref = useRef(null); + let containerRef = useRef(null); + if (ref) { + containerRef = ref; + } // An alternative to `withGlobal` to avoid adding numerous global containers const customEmoji = useCustomEmoji(documentId); @@ -60,11 +69,11 @@ const CustomEmoji: FC = ({ const hasCustomColor = customEmoji && selectIsDefaultEmojiStatusPack(getGlobal(), customEmoji.stickerSetInfo); useEffect(() => { - if (!hasCustomColor || !ref.current) { + if (!hasCustomColor || !containerRef.current) { setCustomColor(undefined); return; } - const hexColor = getPropertyHexColor(getComputedStyle(ref.current), '--emoji-status-color'); + const hexColor = getPropertyHexColor(getComputedStyle(containerRef.current), '--emoji-status-color'); if (!hexColor) { setCustomColor(undefined); return; @@ -100,7 +109,7 @@ const CustomEmoji: FC = ({ return (
= ({ withGridFix && styles.withGridFix, )} onClick={onClick} + style={style} > {!customEmoji ? ( Emoji ) : ( = ({ loopLimit={loopLimit} shouldPreloadPreview={shouldPreloadPreview} observeIntersection={observeIntersection} + forceOnHeavyAnimation={forceOnHeavyAnimation} observeIntersectionForPlaying={observeIntersectionForPlaying} onVideoEnded={handleVideoEnded} onAnimatedStickerLoop={handleStickerLoop} diff --git a/src/components/common/DotAnimation.scss b/src/components/common/DotAnimation.scss index 9c9ef1602..7220b5d8b 100644 --- a/src/components/common/DotAnimation.scss +++ b/src/components/common/DotAnimation.scss @@ -1,6 +1,6 @@ .DotAnimation { display: inline-flex; - align-items: baseline; + align-items: center; .ellipsis { display: flex; diff --git a/src/components/common/DotAnimation.tsx b/src/components/common/DotAnimation.tsx index 9a48723c7..c5e1ddfb7 100644 --- a/src/components/common/DotAnimation.tsx +++ b/src/components/common/DotAnimation.tsx @@ -1,8 +1,11 @@ -import type { FC } from '../../lib/teact/teact'; import React from '../../lib/teact/teact'; -import useLang from '../../hooks/useLang'; +import type { FC } from '../../lib/teact/teact'; + import buildClassName from '../../util/buildClassName'; +import renderText from './helpers/renderText'; + +import useLang from '../../hooks/useLang'; import './DotAnimation.scss'; @@ -15,7 +18,7 @@ const DotAnimation: FC = ({ content, className }) => { const lang = useLang(); return ( - {content} + {renderText(content)} ); diff --git a/src/components/common/PrivateChatInfo.tsx b/src/components/common/PrivateChatInfo.tsx index 59d650aac..f28ee6184 100644 --- a/src/components/common/PrivateChatInfo.tsx +++ b/src/components/common/PrivateChatInfo.tsx @@ -1,8 +1,7 @@ -import type { MouseEvent as ReactMouseEvent } from 'react'; -import type { FC } from '../../lib/teact/teact'; import React, { useEffect, useCallback, memo } from '../../lib/teact/teact'; import { getActions, withGlobal } from '../../global'; +import type { FC } from '../../lib/teact/teact'; import type { ApiUser, ApiTypingStatus, ApiUserStatus } from '../../api/types'; import type { GlobalState } from '../../global/types'; import type { AnimationLevel } from '../../types'; @@ -10,6 +9,9 @@ import { MediaViewerOrigin } from '../../types'; import { selectChatMessages, selectUser, selectUserStatus } from '../../global/selectors'; import { getUserStatus, isUserOnline } from '../../global/helpers'; +import buildClassName from '../../util/buildClassName'; +import renderText from './helpers/renderText'; + import useLang from '../../hooks/useLang'; import Avatar from './Avatar'; @@ -79,7 +81,7 @@ const PrivateChatInfo: FC = ({ } }, [userId, loadFullUser, loadProfilePhotos, lastSyncTime, withFullInfo, withMediaViewer]); - const handleAvatarViewerOpen = useCallback((e: ReactMouseEvent, hasMedia: boolean) => { + const handleAvatarViewerOpen = useCallback((e: React.MouseEvent, hasMedia: boolean) => { if (user && hasMedia) { e.stopPropagation(); openMediaViewer({ @@ -101,7 +103,7 @@ const PrivateChatInfo: FC = ({ return withDots ? ( ) : ( - {status} + {renderText(status)} ); } @@ -120,7 +122,7 @@ const PrivateChatInfo: FC = ({ } return ( - + {withUsername && user.username && {user.username}} {getUserStatus(lang, user, userStatus, serverTimeOffset)} diff --git a/src/components/common/StickerView.module.scss b/src/components/common/StickerView.module.scss index 85ff19304..daccab1bf 100644 --- a/src/components/common/StickerView.module.scss +++ b/src/components/common/StickerView.module.scss @@ -9,6 +9,8 @@ .media { position: absolute; + top: 0; + left: 0; width: 100%; height: 100%; } diff --git a/src/components/common/StickerView.tsx b/src/components/common/StickerView.tsx index bca69295c..12cf7064a 100644 --- a/src/components/common/StickerView.tsx +++ b/src/components/common/StickerView.tsx @@ -34,6 +34,7 @@ type OwnProps = { loopLimit?: number; shouldLoop?: boolean; shouldPreloadPreview?: boolean; + forceOnHeavyAnimation?: boolean; observeIntersection?: ObserveFn; observeIntersectionForPlaying?: ObserveFn; noLoad?: boolean; @@ -57,6 +58,7 @@ const StickerView: FC = ({ loopLimit, shouldLoop = false, shouldPreloadPreview, + forceOnHeavyAnimation, observeIntersection, observeIntersectionForPlaying, noLoad, @@ -118,6 +120,7 @@ const StickerView: FC = ({ play={shouldPlay} color={customColor} noLoop={!shouldLoop} + forceOnHeavyAnimation={forceOnHeavyAnimation} isLowPriority={!selectIsAlwaysHighPriorityEmoji(getGlobal(), stickerSetInfo)} onLoad={markPlayerReady} onLoop={onAnimatedStickerLoop} diff --git a/src/components/common/helpers/parseEmojiOnlyString.ts b/src/components/common/helpers/parseEmojiOnlyString.ts deleted file mode 100644 index ffc799499..000000000 --- a/src/components/common/helpers/parseEmojiOnlyString.ts +++ /dev/null @@ -1,30 +0,0 @@ -import twemojiRegex from '../../../lib/twemojiRegex'; - -const DETECT_UP_TO = 3; -const MAX_LENGTH = DETECT_UP_TO * 8; // Maximum 8 per one emoji. -const RE_EMOJI_ONLY = new RegExp(`^(?:${twemojiRegex.source})+$`, ''); - -const parseEmojiOnlyString = (text: string): number | false => { - if (text.length > MAX_LENGTH) { - return false; - } - - const isEmojiOnly = Boolean(text.match(RE_EMOJI_ONLY)); - if (!isEmojiOnly) { - return false; - } - - let emojiCount = 0; - while (twemojiRegex.exec(text)) { - emojiCount++; - - if (emojiCount > DETECT_UP_TO) { - twemojiRegex.lastIndex = 0; - return false; - } - } - - return emojiCount; -}; - -export default parseEmojiOnlyString; diff --git a/src/components/common/helpers/renderMessageText.ts b/src/components/common/helpers/renderMessageText.ts index d0e352758..52e7f3cff 100644 --- a/src/components/common/helpers/renderMessageText.ts +++ b/src/components/common/helpers/renderMessageText.ts @@ -18,7 +18,7 @@ import trimText from '../../../util/trimText'; export function renderMessageText( message: ApiMessage, highlight?: string, - shouldRenderHqEmoji?: boolean, + emojiSize?: number, isSimple?: boolean, truncateLength?: number, isProtected?: boolean, @@ -35,7 +35,7 @@ export function renderMessageText( trimText(text, truncateLength), entities, highlight, - shouldRenderHqEmoji, + emojiSize, undefined, message.id, isSimple, diff --git a/src/components/common/helpers/renderTextWithEntities.tsx b/src/components/common/helpers/renderTextWithEntities.tsx index 380dd1a32..1c5935100 100644 --- a/src/components/common/helpers/renderTextWithEntities.tsx +++ b/src/components/common/helpers/renderTextWithEntities.tsx @@ -25,11 +25,13 @@ interface IOrganizedEntity { nestedEntities: IOrganizedEntity[]; } +const HQ_EMOJI_THRESHOLD = 64; + export function renderTextWithEntities( text: string, entities?: ApiMessageEntity[], highlight?: string, - shouldRenderHqEmoji?: boolean, + emojiSize?: number, shouldRenderAsHtml?: boolean, messageId?: number, isSimple?: boolean, @@ -37,7 +39,7 @@ export function renderTextWithEntities( observeIntersection?: ObserveFn, ) { if (!entities || !entities.length) { - return renderMessagePart(text, highlight, shouldRenderHqEmoji, shouldRenderAsHtml, isSimple); + return renderMessagePart(text, highlight, emojiSize, shouldRenderAsHtml, isSimple); } const result: TextPart[] = []; @@ -66,7 +68,7 @@ export function renderTextWithEntities( } if (textBefore) { renderResult.push(...renderMessagePart( - textBefore, highlight, shouldRenderHqEmoji, shouldRenderAsHtml, isSimple, + textBefore, highlight, emojiSize, shouldRenderAsHtml, isSimple, ) as TextPart[]); } } @@ -110,7 +112,15 @@ export function renderTextWithEntities( const newEntity = shouldRenderAsHtml ? processEntityAsHtml(entity, entityContent, nestedEntityContent) : processEntity( - entity, entityContent, nestedEntityContent, highlight, messageId, isSimple, isProtected, observeIntersection, + entity, + entityContent, + nestedEntityContent, + highlight, + messageId, + isSimple, + isProtected, + observeIntersection, + emojiSize, ); if (Array.isArray(newEntity)) { @@ -128,7 +138,7 @@ export function renderTextWithEntities( } if (textAfter) { renderResult.push(...renderMessagePart( - textAfter, highlight, shouldRenderHqEmoji, shouldRenderAsHtml, isSimple, + textAfter, highlight, emojiSize, shouldRenderAsHtml, isSimple, ) as TextPart[]); } } @@ -181,7 +191,7 @@ export function getTextWithEntitiesAsHtml(formattedText?: ApiFormattedText) { function renderMessagePart( content: TextPart | TextPart[], highlight?: string, - shouldRenderHqEmoji?: boolean, + emojiSize?: number, shouldRenderAsHtml?: boolean, isSimple?: boolean, ) { @@ -189,7 +199,7 @@ function renderMessagePart( const result: TextPart[] = []; content.forEach((c) => { - result.push(...renderMessagePart(c, highlight, shouldRenderHqEmoji, shouldRenderAsHtml, isSimple)); + result.push(...renderMessagePart(c, highlight, emojiSize, shouldRenderAsHtml, isSimple)); }); return result; @@ -199,7 +209,7 @@ function renderMessagePart( return renderText(content, ['escape_html', 'emoji_html', 'br_html']); } - const emojiFilter = shouldRenderHqEmoji ? 'hq_emoji' : 'emoji'; + const emojiFilter = emojiSize && emojiSize > HQ_EMOJI_THRESHOLD ? 'hq_emoji' : 'emoji'; const filters: TextFilter[] = [emojiFilter]; if (!isSimple) { @@ -288,6 +298,7 @@ function processEntity( isSimple?: boolean, isProtected?: boolean, observeIntersection?: ObserveFn, + emojiSize?: number, ) { const entityText = typeof entityContent === 'string' && entityContent; const renderedContent = nestedEntityContent.length ? nestedEntityContent : entityContent; @@ -310,7 +321,12 @@ function processEntity( if (entity.type === ApiMessageEntityTypes.CustomEmoji) { return ( - + ); } return text; @@ -418,7 +434,12 @@ function processEntity( return {renderNestedMessagePart()}; case ApiMessageEntityTypes.CustomEmoji: return ( - + ); default: return renderNestedMessagePart(); diff --git a/src/components/common/hooks/useAnimatedEmoji.ts b/src/components/common/hooks/useAnimatedEmoji.ts index 7e439c969..e8a84cd80 100644 --- a/src/components/common/hooks/useAnimatedEmoji.ts +++ b/src/components/common/hooks/useAnimatedEmoji.ts @@ -1,11 +1,15 @@ import { useCallback, useEffect, useRef, } from '../../../lib/teact/teact'; -import safePlay from '../../../util/safePlay'; import { getActions } from '../../../global'; -import useMedia from '../../../hooks/useMedia'; + import type { ActiveEmojiInteraction } from '../../../global/types'; + +import safePlay from '../../../util/safePlay'; import { selectLocalAnimatedEmojiEffectByName } from '../../../global/selectors'; +import buildStyle from '../../../util/buildStyle'; + +import useMedia from '../../../hooks/useMedia'; const SIZE = 104; const INTERACTION_BUNCH_TIME = 1000; @@ -20,6 +24,7 @@ export default function useAnimatedEmoji( isOwn?: boolean, localEffect?: string, emoji?: string, + preferredSize?: number, ) { const { interactWithAnimatedEmoji, sendEmojiInteraction, sendWatchingEmojiInteraction, @@ -35,7 +40,8 @@ export default function useAnimatedEmoji( const soundMediaData = useMedia(soundId ? `document${soundId}` : undefined, !soundId); - const style = `width: ${SIZE}px; height: ${SIZE}px;`; + const size = preferredSize || SIZE; + const style = buildStyle(`width: ${size}px`, `height: ${size}px`, (emoji || localEffect) && 'cursor: pointer'); const interactions = useRef(undefined); const startedInteractions = useRef(undefined); @@ -87,7 +93,7 @@ export default function useAnimatedEmoji( emoji, x, y, - startSize: SIZE, + startSize: size, isReversed: !isOwn, }); @@ -102,7 +108,7 @@ export default function useAnimatedEmoji( : TIME_DEFAULT); }, [ chatId, emoji, hasEffect, interactWithAnimatedEmoji, isOwn, - localEffect, messageId, play, sendInteractionBunch, + localEffect, messageId, play, sendInteractionBunch, size, ]); // Set an end anchor for remote activated interaction @@ -126,7 +132,7 @@ export default function useAnimatedEmoji( id, chatId, emoticon: localEffect ? selectLocalAnimatedEmojiEffectByName(localEffect) : emoji, - startSize: SIZE, + startSize: size, x, y, isReversed: !isOwn, @@ -134,12 +140,12 @@ export default function useAnimatedEmoji( play(); }); }, [ - activeEmojiInteractions, chatId, emoji, isOwn, localEffect, messageId, play, sendWatchingEmojiInteraction, + activeEmojiInteractions, chatId, emoji, isOwn, localEffect, messageId, play, sendWatchingEmojiInteraction, size, ]); return { ref, - size: SIZE, + size, style, handleClick, }; diff --git a/src/components/middle/EmojiInteractionAnimation.tsx b/src/components/middle/EmojiInteractionAnimation.tsx index bba0a7014..cfeed3f95 100644 --- a/src/components/middle/EmojiInteractionAnimation.tsx +++ b/src/components/middle/EmojiInteractionAnimation.tsx @@ -83,7 +83,8 @@ const EmojiInteractionAnimation: FC = ({ }, PLAYING_DURATION); }, [stop]); - const effectTgsUrl = useMedia(`sticker${effectAnimationId}`, !effectAnimationId); + const effectHash = effectAnimationId && `sticker${effectAnimationId}`; + const effectTgsUrl = useMedia(effectHash, !effectAnimationId); if (!activeEmojiInteraction.startSize) { return undefined; diff --git a/src/components/middle/composer/MessageInput.tsx b/src/components/middle/composer/MessageInput.tsx index d9221b2a9..9dfd38958 100644 --- a/src/components/middle/composer/MessageInput.tsx +++ b/src/components/middle/composer/MessageInput.tsx @@ -20,7 +20,7 @@ import useLayoutEffectWithPrevDeps from '../../../hooks/useLayoutEffectWithPrevD import useFlag from '../../../hooks/useFlag'; import { isHeavyAnimating } from '../../../hooks/useHeavyAnimationCheck'; import useLang from '../../../hooks/useLang'; -import parseEmojiOnlyString from '../../common/helpers/parseEmojiOnlyString'; +import parseEmojiOnlyString from '../../../util/parseEmojiOnlyString'; import { isSelectionInsideInput } from './helpers/selection'; import renderText from '../../common/helpers/renderText'; diff --git a/src/components/middle/composer/helpers/customEmoji.ts b/src/components/middle/composer/helpers/customEmoji.ts index 6b63ea2f8..8b5b3084a 100644 --- a/src/components/middle/composer/helpers/customEmoji.ts +++ b/src/components/middle/composer/helpers/customEmoji.ts @@ -1,5 +1,9 @@ import type { ApiMessageEntityCustomEmoji, ApiSticker } from '../../../../api/types'; + +import { EMOJI_SIZES } from '../../../../config'; +import { REM } from '../../../common/helpers/mediaDimensions'; import { getCustomEmojiPreviewMediaData } from '../../../../util/customEmojiManager'; + import placeholderSrc from '../../../../assets/square.svg'; export const INPUT_CUSTOM_EMOJI_SELECTOR = 'img[data-document-id]'; @@ -27,3 +31,10 @@ export function buildCustomEmojiHtmlFromEntity(rawText: string, entity: ApiMessa src="${mediaData || placeholderSrc}" />`; } + +export function getCustomEmojiSize(maxEmojisInLine: number): number | undefined { + if (maxEmojisInLine > EMOJI_SIZES) return undefined; + + const size = (6 - (maxEmojisInLine * 0.625)) * REM; // Should be the same as in _message-content.scss + return size; +} diff --git a/src/components/middle/composer/hooks/useStickerTooltip.ts b/src/components/middle/composer/hooks/useStickerTooltip.ts index 0b4d0106a..d38449826 100644 --- a/src/components/middle/composer/hooks/useStickerTooltip.ts +++ b/src/components/middle/composer/hooks/useStickerTooltip.ts @@ -5,7 +5,7 @@ import type { ApiSticker } from '../../../../api/types'; import { EMOJI_IMG_REGEX } from '../../../../config'; import { IS_EMOJI_SUPPORTED } from '../../../../util/environment'; -import parseEmojiOnlyString from '../../../common/helpers/parseEmojiOnlyString'; +import parseEmojiOnlyString from '../../../../util/parseEmojiOnlyString'; import { prepareForRegExp } from '../helpers/prepareForRegExp'; const STARTS_ENDS_ON_EMOJI_IMG_REGEX = new RegExp(`^${EMOJI_IMG_REGEX.source}$`, 'g'); diff --git a/src/components/middle/helpers/calculateMiddleFooterTransforms.ts b/src/components/middle/helpers/calculateMiddleFooterTransforms.ts index 7a4eee33b..553f7e9f4 100644 --- a/src/components/middle/helpers/calculateMiddleFooterTransforms.ts +++ b/src/components/middle/helpers/calculateMiddleFooterTransforms.ts @@ -3,8 +3,8 @@ import { MIN_SCREEN_WIDTH_FOR_STATIC_RIGHT_COLUMN, MOBILE_SCREEN_MAX_WIDTH, } from '../../../config'; +import { REM } from '../../common/helpers/mediaDimensions'; -const REM = 16; // px const MAX_TOOLBAR_WIDTH = 32 * REM; const MAX_MESSAGES_LIST_WIDTH = 45.5 * REM; export const SIDE_COLUMN_MAX_WIDTH = 26.5 * REM; diff --git a/src/components/middle/message/AnimatedCustomEmoji.tsx b/src/components/middle/message/AnimatedCustomEmoji.tsx new file mode 100644 index 000000000..4905f54e2 --- /dev/null +++ b/src/components/middle/message/AnimatedCustomEmoji.tsx @@ -0,0 +1,82 @@ +import type { FC } from '../../../lib/teact/teact'; +import React, { memo } from '../../../lib/teact/teact'; +import { withGlobal } from '../../../global'; + +import type { ApiSticker } from '../../../api/types'; +import type { ActiveEmojiInteraction } from '../../../global/types'; +import type { ObserveFn } from '../../../hooks/useIntersectionObserver'; + +import { LIKE_STICKER_ID } from '../../common/helpers/mediaDimensions'; +import { + selectAnimatedEmojiEffect, + selectAnimatedEmojiSound, +} from '../../../global/selectors'; +import buildClassName from '../../../util/buildClassName'; +import { getCustomEmojiSize } from '../composer/helpers/customEmoji'; +import useAnimatedEmoji from '../../common/hooks/useAnimatedEmoji'; + +import CustomEmoji from '../../common/CustomEmoji'; + +import './AnimatedEmoji.scss'; + +type OwnProps = { + customEmojiId: string; + withEffects: boolean; + isOwn?: boolean; + size?: 'large' | 'medium' | 'small'; + lastSyncTime?: number; + forceLoadPreview?: boolean; + messageId?: number; + chatId?: string; + activeEmojiInteractions?: ActiveEmojiInteraction[]; + observeIntersection?: ObserveFn; +}; + +interface StateProps { + sticker?: ApiSticker; + effect?: ApiSticker; + soundId?: string; +} + +const AnimatedCustomEmoji: FC = ({ + isOwn, + customEmojiId, + messageId, + chatId, + activeEmojiInteractions, + sticker, + effect, + soundId, + observeIntersection, +}) => { + const { + ref, + size, + style, + handleClick, + } = useAnimatedEmoji( + chatId, messageId, soundId, activeEmojiInteractions, isOwn, undefined, effect?.emoji, getCustomEmojiSize(1), + ); + + return ( + + ); +}; + +export default memo(withGlobal((global, { customEmojiId, withEffects }) => { + const sticker = global.customEmojis.byId[customEmojiId]; + return { + sticker, + effect: sticker?.emoji && withEffects ? selectAnimatedEmojiEffect(global, sticker.emoji) : undefined, + soundId: sticker?.emoji && selectAnimatedEmojiSound(global, sticker.emoji), + }; +})(AnimatedCustomEmoji)); diff --git a/src/components/middle/message/AnimatedEmoji.scss b/src/components/middle/message/AnimatedEmoji.scss index 039329e32..fbb7e740d 100644 --- a/src/components/middle/message/AnimatedEmoji.scss +++ b/src/components/middle/message/AnimatedEmoji.scss @@ -1,6 +1,4 @@ .AnimatedEmoji { - margin-bottom: 0.75rem; - &.like-sticker-thumb img { transform: scale(0.8); } diff --git a/src/components/middle/message/Message.tsx b/src/components/middle/message/Message.tsx index 005c85426..033167dd5 100644 --- a/src/components/middle/message/Message.tsx +++ b/src/components/middle/message/Message.tsx @@ -67,19 +67,20 @@ import { isChatWithRepliesBot, getMessageCustomShape, isChatChannel, - getMessageSingleEmoji, + getMessageSingleRegularEmoji, getSenderTitle, getUserColorKey, areReactionsEmpty, getMessageHtmlId, isGeoLiveExpired, + getMessageSingleCustomEmoji, } 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 { ROUND_VIDEO_DIMENSIONS_PX } from '../../common/helpers/mediaDimensions'; -import { buildContentClassName, isEmojiOnlyMessage } from './helpers/buildContentClassName'; +import { buildContentClassName } from './helpers/buildContentClassName'; import { getMinMediaWidth, calculateMediaDimensions } from './helpers/mediaDimensions'; import { calculateAlbumLayout } from './helpers/calculateAlbumLayout'; import renderText from '../../common/helpers/renderText'; @@ -94,6 +95,7 @@ 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'; @@ -104,6 +106,7 @@ import MessageMeta from './MessageMeta'; import ContextMenuContainer from './ContextMenuContainer.async'; import Sticker from './Sticker'; import AnimatedEmoji from './AnimatedEmoji'; +import AnimatedCustomEmoji from './AnimatedCustomEmoji'; import Photo from './Photo'; import Video from './Video'; import Contact from './Contact'; @@ -182,6 +185,7 @@ type StateProps = { serverTimeOffset: number; highlight?: string; animatedEmoji?: string; + animatedCustomEmoji?: string; isInSelectMode?: boolean; isSelected?: boolean; isGroupSelected?: boolean; @@ -272,6 +276,7 @@ const Message: FC = ({ serverTimeOffset, highlight, animatedEmoji, + animatedCustomEmoji, isInSelectMode, isSelected, isGroupSelected, @@ -348,7 +353,7 @@ const Message: FC = ({ const hasReply = isReplyMessage(message) && !shouldHideReply; const hasThread = Boolean(threadInfo) && messageListType === 'thread'; const customShape = getMessageCustomShape(message); - const hasAnimatedEmoji = animatedEmoji; + const hasAnimatedEmoji = animatedEmoji || animatedCustomEmoji; const hasReactions = reactionMessage?.reactions && !areReactionsEmpty(reactionMessage.reactions); const asForwarded = ( forwardInfo @@ -513,10 +518,11 @@ const Message: FC = ({ }); const withAppendix = contentClassName.includes('has-appendix'); + const emojiSize = message.emojiOnlyCount && getCustomEmojiSize(message.emojiOnlyCount); const textParts = renderMessageText( message, highlight, - isEmojiOnlyMessage(customShape), + emojiSize, undefined, undefined, isProtected, @@ -528,7 +534,7 @@ const Message: FC = ({ metaPosition = 'none'; } else if (isInDocumentGroupNotLast) { metaPosition = 'none'; - } else if (textParts && !hasAnimatedEmoji && !webPage) { + } else if (textParts && !webPage && !hasAnimatedEmoji) { metaPosition = 'in-text'; } else { metaPosition = 'standalone'; @@ -538,7 +544,7 @@ const Message: FC = ({ if (areReactionsInMeta) { reactionsPosition = 'in-meta'; } else if (hasReactions) { - if (customShape || ((photo || video || hasAnimatedEmoji) && !textParts)) { + if (customShape || ((photo || video) && !textParts)) { reactionsPosition = 'outside'; } else if (asForwarded) { metaPosition = 'standalone'; @@ -700,6 +706,19 @@ const Message: FC = ({ onStopEffect={stopStickerEffect} /> )} + {animatedCustomEmoji && ( + + )} {animatedEmoji && ( ( const { query: highlight } = selectCurrentTextSearch(global) || {}; - const singleEmoji = getMessageSingleEmoji(message); + const singleEmoji = getMessageSingleRegularEmoji(message); const animatedEmoji = singleEmoji && ( selectAnimatedEmoji(global, singleEmoji) || selectLocalAnimatedEmoji(global, singleEmoji) ) ? singleEmoji : undefined; + const animatedCustomEmoji = getMessageSingleCustomEmoji(message); let isSelected: boolean; if (album?.messages) { @@ -1167,6 +1187,7 @@ export default memo(withGlobal( serverTimeOffset, highlight, animatedEmoji, + animatedCustomEmoji, isInSelectMode: selectIsInSelectMode(global), isSelected, isGroupSelected: ( diff --git a/src/components/middle/message/ReactionAnimatedEmoji.tsx b/src/components/middle/message/ReactionAnimatedEmoji.tsx index 8e923fb35..85d556671 100644 --- a/src/components/middle/message/ReactionAnimatedEmoji.tsx +++ b/src/components/middle/message/ReactionAnimatedEmoji.tsx @@ -36,8 +36,12 @@ const ReactionAnimatedEmoji: FC = ({ const availableReaction = availableReactions?.find((r) => r.reaction === reaction); const centerIconId = availableReaction?.centerIcon?.id; const effectId = availableReaction?.aroundAnimation?.id; - const mediaDataCenterIcon = useMedia(`sticker${centerIconId}`, !centerIconId); - const mediaDataEffect = useMedia(`sticker${effectId}`, !effectId); + + const mediaHashCenterIcon = centerIconId && `sticker${centerIconId}`; + const mediaHashEffect = effectId && `sticker${effectId}`; + + const mediaDataCenterIcon = useMedia(mediaHashCenterIcon, !centerIconId); + const mediaDataEffect = useMedia(mediaHashEffect, !effectId); const shouldPlay = Boolean(activeReaction?.reaction === reaction && mediaDataCenterIcon && mediaDataEffect); const { diff --git a/src/components/middle/message/_message-content.scss b/src/components/middle/message/_message-content.scss index b07cf09a2..c755f649c 100644 --- a/src/components/middle/message/_message-content.scss +++ b/src/components/middle/message/_message-content.scss @@ -13,9 +13,11 @@ &.has-action-button { max-width: min(29rem, calc(100vw - 7rem)); + .MessageList.no-avatars & { max-width: min(29rem, calc(100vw - 4.5rem)); } + .Message.own & { max-width: min(30rem, calc(100vw - 4.5rem)); } @@ -681,7 +683,7 @@ } } - .emoji { + .emoji:not(.custom-emoji) { display: inline-block; color: transparent; @@ -692,84 +694,52 @@ } &.emoji-only { + --emoji-only-size: 1.25rem; + font-size: var(--emoji-only-size); + min-width: 6rem; + + --custom-emoji-size: var(--emoji-only-size); + + .emoji { + width: var(--emoji-only-size); + height: var(--emoji-only-size); + } + + .custom-emoji { + line-height: 0; + vertical-align: bottom; + } + .text-content { - margin-bottom: 0; - text-shadow: 1px 1px 0 white, -1px -1px 0 white, -1px 1px 0 white, 1px -1px 0 white; + margin-bottom: 1.25rem; word-break: normal; - img.emoji { - filter: drop-shadow(1px 1px 0 white) drop-shadow(-1px 1px 0 white) drop-shadow(1px -1px 0 white) - drop-shadow(-1px -1px 0 white); - } + line-height: var(--emoji-only-size); .MessageMeta { text-shadow: none; + bottom: 0; + right: 0; + line-height: normal; } } } - &.emoji-only-1 { - min-width: 8rem; - font-size: 4.5rem; + @for $i from 1 through 7 { + &.emoji-only-#{$i} { + $size: 6 - ($i * 0.625) + rem; - .content-inner { - height: 7rem; - } + --emoji-only-size: #{$size}; - .text-content { - line-height: 1.5; - text-align: center; - } + @if $i <= 3 { + .text-content { + text-shadow: 1px 1px 0 white, -1px -1px 0 white, -1px 1px 0 white, 1px -1px 0 white; + margin-bottom: 0.25rem; - .Message.was-edited & { - min-width: 10rem; - } - - .emoji { - width: 5rem; - height: 5rem; - } - } - - &.emoji-only-2 { - font-size: 4rem; - margin-top: 0.5rem; - min-width: 10rem; - @media (max-width: 600px) { - margin-top: 0.375rem; - } - - &.has-comments { - margin-top: 1.25rem; - } - - .Message.was-edited & { - min-width: 12rem; - } - - .emoji { - width: 4rem; - height: 4rem; - margin-right: 0.375rem; - } - } - - &.emoji-only-3 { - font-size: 3rem; - margin-top: 1.75rem; - min-width: 12rem; - - &.has-comments { - margin-top: 2.5rem; - } - - .Message.was-edited & { - min-width: 14rem; - } - - .emoji { - width: 3rem; - height: 3rem; - margin-right: 0.375rem; + img.emoji { + filter: drop-shadow(1px 1px 0 white) drop-shadow(-1px 1px 0 white) drop-shadow(1px -1px 0 white) drop-shadow(-1px -1px 0 white); + } + } + } } } diff --git a/src/components/middle/message/helpers/buildContentClassName.ts b/src/components/middle/message/helpers/buildContentClassName.ts index afb60cdb6..ef739c4fa 100644 --- a/src/components/middle/message/helpers/buildContentClassName.ts +++ b/src/components/middle/message/helpers/buildContentClassName.ts @@ -1,11 +1,8 @@ import type { ApiMessage } from '../../../../api/types'; +import { EMOJI_SIZES } from '../../../../config'; import { getMessageContent } from '../../../../global/helpers'; -export function isEmojiOnlyMessage(customShape?: boolean | number) { - return typeof customShape === 'number'; -} - export function buildContentClassName( message: ApiMessage, { @@ -44,8 +41,11 @@ export function buildContentClassName( const isMediaWithNoText = isMedia && !hasText; const isViaBot = Boolean(message.viaBotId); - if (isEmojiOnlyMessage(customShape)) { - classNames.push(`emoji-only emoji-only-${customShape}`); + if (message.emojiOnlyCount) { + classNames.push('emoji-only'); + if (message.emojiOnlyCount <= EMOJI_SIZES) { + classNames.push(`emoji-only-${message.emojiOnlyCount}`); + } } else if (hasText) { classNames.push('text'); } diff --git a/src/config.ts b/src/config.ts index d8f69f4a4..af4adbb97 100644 --- a/src/config.ts +++ b/src/config.ts @@ -153,6 +153,7 @@ export const STICKER_SIZE_JOIN_REQUESTS = 140; export const STICKER_SIZE_INVITES = 140; export const RECENT_STICKERS_LIMIT = 20; export const EMOJI_STATUS_LOOP_LIMIT = 2; +export const EMOJI_SIZES = 7; export const RECENT_SYMBOL_SET_ID = 'recent'; export const FAVORITE_SYMBOL_SET_ID = 'favorite'; export const CHAT_STICKER_SET_ID = 'chatStickers'; diff --git a/src/global/helpers/messages.ts b/src/global/helpers/messages.ts index 62142d31a..58132513a 100644 --- a/src/global/helpers/messages.ts +++ b/src/global/helpers/messages.ts @@ -13,7 +13,6 @@ import { import { getUserFullName } from './users'; import { IS_OPUS_SUPPORTED, isWebpSupported } from '../../util/environment'; import { getChatTitle, isUserId } from './chats'; -import parseEmojiOnlyString from '../../components/common/helpers/parseEmojiOnlyString'; import { getGlobal } from '../index'; const RE_LINK = new RegExp(RE_LINK_TEMPLATE, 'i'); @@ -69,34 +68,45 @@ export function getMessageText(message: ApiMessage) { return CONTENT_NOT_SUPPORTED; } -export function getMessageCustomShape(message: ApiMessage): boolean | number { +export function getMessageCustomShape(message: ApiMessage): boolean { const { - text, sticker, photo, video, audio, voice, document, poll, webPage, contact, + text, sticker, photo, video, audio, voice, document, poll, webPage, contact, action, game, invoice, location, } = message.content; if (sticker || (video?.isRound)) { return true; } - if (!text || text.entities?.length || photo || video || audio || voice || document || poll || webPage || contact) { + if (!text || photo || video || audio || voice || document || poll || webPage || contact || action || game || invoice + || location) { return false; } - // This is a "dual-intent" method used to limit calls of `parseEmojiOnlyString`. - return parseEmojiOnlyString(text.text) || false; + const hasOtherFormatting = text?.entities?.some((entity) => entity.type !== ApiMessageEntityTypes.CustomEmoji); + + return Boolean(message.emojiOnlyCount && !hasOtherFormatting); } -export function getMessageSingleEmoji(message: ApiMessage) { +export function getMessageSingleRegularEmoji(message: ApiMessage) { const { text } = message.content; - if (!(text && text.text.length <= 6) || text.entities?.length) { + + if (text?.entities?.length || message.emojiOnlyCount !== 1) { return undefined; } - if (getMessageCustomShape(message) !== 1) { + return text!.text; +} + +export function getMessageSingleCustomEmoji(message: ApiMessage): string | undefined { + const { text } = message.content; + + if (text?.entities?.length !== 1 + || text.entities[0].type !== ApiMessageEntityTypes.CustomEmoji + || message.emojiOnlyCount !== 1) { return undefined; } - return text.text; + return text.entities[0].documentId; } export function getFirstLinkInMessage(message: ApiMessage) { diff --git a/src/lib/rlottie/RLottie.ts b/src/lib/rlottie/RLottie.ts index 97610c8dc..f0ac20ad7 100644 --- a/src/lib/rlottie/RLottie.ts +++ b/src/lib/rlottie/RLottie.ts @@ -380,16 +380,18 @@ class RLottie { ctx, onLoad, isOnLoadFired, isPaused, } = containerData; + if (onLoad && !isOnLoadFired) { + containerData.isOnLoadFired = true; + onLoad(); + + ctx.putImageData(imageData, 0, 0); // Always render first frame + } + if (isPaused) { return; } ctx.putImageData(imageData, 0, 0); - - if (onLoad && !isOnLoadFired) { - containerData.isOnLoadFired = true; - onLoad(); - } }); this.prevFrameIndex = frameIndex; diff --git a/src/util/parseEmojiOnlyString.ts b/src/util/parseEmojiOnlyString.ts new file mode 100644 index 000000000..59011b902 --- /dev/null +++ b/src/util/parseEmojiOnlyString.ts @@ -0,0 +1,37 @@ +import twemojiRegex from '../lib/twemojiRegex'; + +const DETECT_UP_TO = 100; +const MAX_LENGTH = DETECT_UP_TO * 8; // Maximum 8 per one emoji. +const RE_EMOJI_ONLY = new RegExp(`^(?:${twemojiRegex.source})+$`, ''); + +const parseEmojiOnlyString = (text: string): number | false => { + const lines = text.split('\n'); + const textWithoutNewlines = lines.join(''); + if (textWithoutNewlines.length > MAX_LENGTH) { + return false; + } + + const isEmojiOnly = Boolean(textWithoutNewlines.match(RE_EMOJI_ONLY)); + if (!isEmojiOnly) { + return false; + } + const countPerLine = lines.map((line) => { + let emojiCount = 0; + while (twemojiRegex.exec(line)) { + emojiCount++; + + if (emojiCount > DETECT_UP_TO) { + twemojiRegex.lastIndex = 0; + return -1; + } + } + + return emojiCount; + }); + + if (countPerLine.some((count) => count === -1)) return false; + + return Math.max(...countPerLine); +}; + +export default parseEmojiOnlyString;