Message: Support emoji-only shape for custom emojis (#2098)

This commit is contained in:
Alexander Zinchuk 2022-11-01 18:53:29 +01:00
parent 3bffde60cb
commit b1b09d9f41
30 changed files with 338 additions and 175 deletions

View File

@ -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;

View File

@ -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: {

View File

@ -401,6 +401,7 @@ export interface ApiMessage {
isProtected?: boolean;
transcriptionId?: string;
isTranscriptionError?: boolean;
emojiOnlyCount?: number;
reactors?: {
nextOffset?: string;
count: number;

View File

@ -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,

View File

@ -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;
}
}

View File

@ -21,13 +21,16 @@ import styles from './CustomEmoji.module.scss';
import svgPlaceholder from '../../assets/square.svg';
type OwnProps = {
ref?: React.RefObject<HTMLDivElement>;
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<OwnProps> = ({
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<HTMLDivElement>(null);
let containerRef = useRef<HTMLDivElement>(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<OwnProps> = ({
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<OwnProps> = ({
return (
<div
ref={ref}
ref={containerRef}
className={buildClassName(
styles.root,
className,
@ -110,12 +119,13 @@ const CustomEmoji: FC<OwnProps> = ({
withGridFix && styles.withGridFix,
)}
onClick={onClick}
style={style}
>
{!customEmoji ? (
<img className={styles.thumb} src={svgPlaceholder} alt="Emoji" />
) : (
<StickerView
containerRef={ref}
containerRef={containerRef}
sticker={customEmoji}
isSmall
size={size}
@ -126,6 +136,7 @@ const CustomEmoji: FC<OwnProps> = ({
loopLimit={loopLimit}
shouldPreloadPreview={shouldPreloadPreview}
observeIntersection={observeIntersection}
forceOnHeavyAnimation={forceOnHeavyAnimation}
observeIntersectionForPlaying={observeIntersectionForPlaying}
onVideoEnded={handleVideoEnded}
onAnimatedStickerLoop={handleStickerLoop}

View File

@ -1,6 +1,6 @@
.DotAnimation {
display: inline-flex;
align-items: baseline;
align-items: center;
.ellipsis {
display: flex;

View File

@ -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<OwnProps> = ({ content, className }) => {
const lang = useLang();
return (
<span className={buildClassName('DotAnimation', className)} dir={lang.isRtl ? 'rtl' : 'auto'}>
{content}
{renderText(content)}
<span className="ellipsis" />
</span>
);

View File

@ -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<OwnProps & StateProps> = ({
}
}, [userId, loadFullUser, loadProfilePhotos, lastSyncTime, withFullInfo, withMediaViewer]);
const handleAvatarViewerOpen = useCallback((e: ReactMouseEvent<HTMLDivElement, MouseEvent>, hasMedia: boolean) => {
const handleAvatarViewerOpen = useCallback((e: React.MouseEvent<HTMLDivElement, MouseEvent>, hasMedia: boolean) => {
if (user && hasMedia) {
e.stopPropagation();
openMediaViewer({
@ -101,7 +103,7 @@ const PrivateChatInfo: FC<OwnProps & StateProps> = ({
return withDots ? (
<DotAnimation className="status" content={status} />
) : (
<span className="status" dir="auto">{status}</span>
<span className="status" dir="auto">{renderText(status)}</span>
);
}
@ -120,7 +122,7 @@ const PrivateChatInfo: FC<OwnProps & StateProps> = ({
}
return (
<span className={`status ${isUserOnline(user, userStatus) ? 'online' : ''}`}>
<span className={buildClassName('status', isUserOnline(user, userStatus) && 'online')}>
{withUsername && user.username && <span className="handle">{user.username}</span>}
<span className="user-status" dir="auto">{getUserStatus(lang, user, userStatus, serverTimeOffset)}</span>
</span>

View File

@ -9,6 +9,8 @@
.media {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}

View File

@ -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<OwnProps> = ({
loopLimit,
shouldLoop = false,
shouldPreloadPreview,
forceOnHeavyAnimation,
observeIntersection,
observeIntersectionForPlaying,
noLoad,
@ -118,6 +120,7 @@ const StickerView: FC<OwnProps> = ({
play={shouldPlay}
color={customColor}
noLoop={!shouldLoop}
forceOnHeavyAnimation={forceOnHeavyAnimation}
isLowPriority={!selectIsAlwaysHighPriorityEmoji(getGlobal(), stickerSetInfo)}
onLoad={markPlayerReady}
onLoop={onAnimatedStickerLoop}

View File

@ -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;

View File

@ -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,

View File

@ -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 (
<CustomEmoji documentId={entity.documentId} observeIntersection={observeIntersection} withGridFix />
<CustomEmoji
documentId={entity.documentId}
observeIntersection={observeIntersection}
withGridFix={!emojiSize}
size={emojiSize}
/>
);
}
return text;
@ -418,7 +434,12 @@ function processEntity(
return <Spoiler messageId={messageId}>{renderNestedMessagePart()}</Spoiler>;
case ApiMessageEntityTypes.CustomEmoji:
return (
<CustomEmoji documentId={entity.documentId} observeIntersection={observeIntersection} withGridFix />
<CustomEmoji
documentId={entity.documentId}
observeIntersection={observeIntersection}
withGridFix={!emojiSize}
size={emojiSize}
/>
);
default:
return renderNestedMessagePart();

View File

@ -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<number[] | undefined>(undefined);
const startedInteractions = useRef<number | undefined>(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,
};

View File

@ -83,7 +83,8 @@ const EmojiInteractionAnimation: FC<OwnProps & StateProps> = ({
}, PLAYING_DURATION);
}, [stop]);
const effectTgsUrl = useMedia(`sticker${effectAnimationId}`, !effectAnimationId);
const effectHash = effectAnimationId && `sticker${effectAnimationId}`;
const effectTgsUrl = useMedia(effectHash, !effectAnimationId);
if (!activeEmojiInteraction.startSize) {
return undefined;

View File

@ -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';

View File

@ -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;
}

View File

@ -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');

View File

@ -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;

View File

@ -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<OwnProps & StateProps> = ({
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 (
<CustomEmoji
ref={ref}
documentId={customEmojiId}
size={size}
forceOnHeavyAnimation
observeIntersection={observeIntersection}
className={buildClassName('AnimatedEmoji media-inner', sticker?.id === LIKE_STICKER_ID && 'like-sticker-thumb')}
onClick={handleClick}
style={style}
/>
);
};
export default memo(withGlobal<OwnProps>((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));

View File

@ -1,6 +1,4 @@
.AnimatedEmoji {
margin-bottom: 0.75rem;
&.like-sticker-thumb img {
transform: scale(0.8);
}

View File

@ -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<OwnProps & StateProps> = ({
serverTimeOffset,
highlight,
animatedEmoji,
animatedCustomEmoji,
isInSelectMode,
isSelected,
isGroupSelected,
@ -348,7 +353,7 @@ const Message: FC<OwnProps & StateProps> = ({
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<OwnProps & StateProps> = ({
});
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<OwnProps & StateProps> = ({
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<OwnProps & StateProps> = ({
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<OwnProps & StateProps> = ({
onStopEffect={stopStickerEffect}
/>
)}
{animatedCustomEmoji && (
<AnimatedCustomEmoji
customEmojiId={animatedCustomEmoji}
withEffects={isUserId(chatId)}
isOwn={isOwn}
observeIntersection={observeIntersectionForMedia}
lastSyncTime={lastSyncTime}
forceLoadPreview={isLocal}
messageId={messageId}
chatId={chatId}
activeEmojiInteractions={activeEmojiInteractions}
/>
)}
{animatedEmoji && (
<AnimatedEmoji
emoji={animatedEmoji}
@ -1114,10 +1133,11 @@ export default memo(withGlobal<OwnProps>(
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<OwnProps>(
serverTimeOffset,
highlight,
animatedEmoji,
animatedCustomEmoji,
isInSelectMode: selectIsInSelectMode(global),
isSelected,
isGroupSelected: (

View File

@ -36,8 +36,12 @@ const ReactionAnimatedEmoji: FC<OwnProps> = ({
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 {

View File

@ -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);
}
}
}
}
}

View File

@ -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');
}

View File

@ -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';

View File

@ -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) {

View File

@ -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;

View File

@ -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;