Introduce Reaction Picker

This commit is contained in:
Alexander Zinchuk 2023-04-23 18:11:28 +04:00
parent c2d7234748
commit 22054c4416
62 changed files with 1478 additions and 393 deletions

View File

@ -54,7 +54,9 @@ import {
} from '../../../config';
import { pick } from '../../../util/iteratees';
import { buildStickerFromDocument } from './symbols';
import { buildApiPhoto, buildApiPhotoSize, buildApiThumbnailFromStripped } from './common';
import {
buildApiPhoto, buildApiPhotoSize, buildApiThumbnailFromPath, buildApiThumbnailFromStripped,
} from './common';
import { interpolateArray } from '../../../util/waveform';
import { buildPeer } from '../gramjsBuilders';
import { addPhotoToLocalDb, resolveMessageApiChatId, serializeBytes } from '../helpers';
@ -313,13 +315,14 @@ export function buildApiReaction(reaction: GramJs.TypeReaction): ApiReaction | u
export function buildApiAvailableReaction(availableReaction: GramJs.AvailableReaction): ApiAvailableReaction {
const {
selectAnimation, staticIcon, reaction, title,
selectAnimation, staticIcon, reaction, title, appearAnimation,
inactive, aroundAnimation, centerIcon, effectAnimation, activateAnimation,
premium,
} = availableReaction;
return {
selectAnimation: buildApiDocument(selectAnimation),
appearAnimation: buildApiDocument(appearAnimation),
activateAnimation: buildApiDocument(activateAnimation),
effectAnimation: buildApiDocument(effectAnimation),
staticIcon: buildApiDocument(staticIcon),
@ -609,11 +612,17 @@ export function buildApiDocument(document: GramJs.TypeDocument): ApiDocument | u
id, size, mimeType, date, thumbs, attributes,
} = document;
const thumbnail = thumbs && buildApiThumbnailFromStripped(thumbs);
const photoSize = thumbs && thumbs.find((s: any): s is GramJs.PhotoSize => s instanceof GramJs.PhotoSize);
let thumbnail = thumbs && buildApiThumbnailFromStripped(thumbs);
if (!thumbnail && thumbs && photoSize) {
const photoPath = thumbs.find((s: any): s is GramJs.PhotoPathSize => s instanceof GramJs.PhotoPathSize);
if (photoPath) {
thumbnail = buildApiThumbnailFromPath(photoPath, photoSize);
}
}
let mediaType: ApiDocument['mediaType'] | undefined;
let mediaSize: ApiDocument['mediaSize'] | undefined;
const photoSize = thumbs && thumbs.find((s: any): s is GramJs.PhotoSize => s instanceof GramJs.PhotoSize);
if (photoSize) {
mediaSize = {
width: photoSize.w,

View File

@ -87,8 +87,8 @@ export {
} from './calls';
export {
getAvailableReactions, sendReaction, sendEmojiInteraction, fetchMessageReactionsList,
setDefaultReaction, fetchMessageReactions, sendWatchingEmojiInteraction,
getAvailableReactions, sendReaction, sendEmojiInteraction, fetchMessageReactionsList, clearRecentReactions,
setDefaultReaction, fetchMessageReactions, sendWatchingEmojiInteraction, fetchRecentReactions, fetchTopReactions,
} from './reactions';
export {

View File

@ -1,12 +1,15 @@
import type { ApiChat, ApiReaction } from '../../types';
import { invokeRequest } from './client';
import BigInt from 'big-integer';
import { Api as GramJs } from '../../../lib/gramjs';
import type { ApiChat, ApiReaction } from '../../types';
import { REACTION_LIST_LIMIT, RECENT_REACTIONS_LIMIT, TOP_REACTIONS_LIMIT } from '../../../config';
import { buildInputPeer, buildInputReaction } from '../gramjsBuilders';
import localDb from '../localDb';
import { buildApiAvailableReaction, buildMessagePeerReaction } from '../apiBuilders/messages';
import { REACTION_LIST_LIMIT } from '../../../config';
import { addEntitiesWithPhotosToLocalDb } from '../helpers';
import { buildApiUser } from '../apiBuilders/users';
import { buildApiAvailableReaction, buildApiReaction, buildMessagePeerReaction } from '../apiBuilders/messages';
import { invokeRequest } from './client';
import localDb from '../localDb';
import { addEntitiesWithPhotosToLocalDb } from '../helpers';
export function sendWatchingEmojiInteraction({
chat,
@ -65,6 +68,9 @@ export async function getAvailableReactions() {
if (reaction.aroundAnimation instanceof GramJs.Document) {
localDb.documents[String(reaction.aroundAnimation.id)] = reaction.aroundAnimation;
}
if (reaction.appearAnimation instanceof GramJs.Document) {
localDb.documents[String(reaction.appearAnimation.id)] = reaction.appearAnimation;
}
if (reaction.centerIcon instanceof GramJs.Document) {
localDb.documents[String(reaction.centerIcon.id)] = reaction.centerIcon;
}
@ -74,15 +80,19 @@ export async function getAvailableReactions() {
}
export function sendReaction({
chat, messageId, reactions,
chat, messageId, reactions, shouldAddToRecent,
}: {
chat: ApiChat; messageId: number; reactions?: ApiReaction[];
chat: ApiChat;
messageId: number;
reactions?: ApiReaction[];
shouldAddToRecent?: boolean;
}) {
return invokeRequest(new GramJs.messages.SendReaction({
reaction: reactions?.map((r) => buildInputReaction(r)),
peer: buildInputPeer(chat.id, chat.accessHash),
msgId: messageId,
}), true);
...(shouldAddToRecent && { addToRecent: true }),
}), true, true);
}
export function fetchMessageReactions({
@ -134,3 +144,39 @@ export function setDefaultReaction({
reaction: buildInputReaction(reaction),
}));
}
export async function fetchTopReactions({ hash = '0' }: { hash?: string }) {
const result = await invokeRequest(new GramJs.messages.GetTopReactions({
limit: TOP_REACTIONS_LIMIT,
hash: BigInt(hash),
}));
if (!result || result instanceof GramJs.messages.ReactionsNotModified) {
return undefined;
}
return {
hash: String(result.hash),
reactions: result.reactions.map(buildApiReaction).filter(Boolean),
};
}
export async function fetchRecentReactions({ hash = '0' }: { hash?: string }) {
const result = await invokeRequest(new GramJs.messages.GetRecentReactions({
limit: RECENT_REACTIONS_LIMIT,
hash: BigInt(hash),
}));
if (!result || result instanceof GramJs.messages.ReactionsNotModified) {
return undefined;
}
return {
hash: String(result.hash),
reactions: result.reactions.map(buildApiReaction).filter(Boolean),
};
}
export function clearRecentReactions() {
return invokeRequest(new GramJs.messages.ClearRecentReactions());
}

View File

@ -888,6 +888,8 @@ export function updater(update: Update) {
onUpdate({ '@type': 'updateFavoriteStickers' });
} else if (update instanceof GramJs.UpdateRecentStickers) {
onUpdate({ '@type': 'updateRecentStickers' });
} else if (update instanceof GramJs.UpdateRecentReactions) {
onUpdate({ '@type': 'updateRecentReactions' });
} else if (update instanceof GramJs.UpdateMoveStickerSetToTop) {
if (!update.masks) {
onUpdate({

View File

@ -459,6 +459,7 @@ export interface ApiReactionCount {
export interface ApiAvailableReaction {
selectAnimation?: ApiDocument;
appearAnimation?: ApiDocument;
activateAnimation?: ApiDocument;
effectAnimation?: ApiDocument;
staticIcon?: ApiDocument;

View File

@ -400,6 +400,10 @@ export type ApiUpdateRecentStickers = {
'@type': 'updateRecentStickers';
};
export type ApiUpdateRecentReactions = {
'@type': 'updateRecentReactions';
};
export type ApiUpdateMoveStickerSetToTop = {
'@type': 'updateMoveStickerSetToTop';
isCustomEmoji?: boolean;
@ -638,7 +642,8 @@ export type ApiUpdate = (
ApiUpdatePhoneCall | ApiUpdatePhoneCallSignalingData | ApiUpdatePhoneCallMediaState |
ApiUpdatePhoneCallConnectionState | ApiUpdateBotMenuButton | ApiUpdateTranscribedAudio | ApiUpdateUserEmojiStatus |
ApiUpdateMessageExtendedMedia | ApiUpdateConfig | ApiUpdateTopicNotifyExceptions | ApiUpdatePinnedTopic |
ApiUpdatePinnedTopicsOrder | ApiUpdateTopic | ApiUpdateTopics | ApiUpdateRecentEmojiStatuses | ApiRequestInitApi
ApiUpdatePinnedTopicsOrder | ApiUpdateTopic | ApiUpdateTopics | ApiUpdateRecentEmojiStatuses |
ApiUpdateRecentReactions | ApiRequestInitApi
);
export type OnApiUpdate = (update: ApiUpdate) => void;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

View File

@ -45,6 +45,7 @@ export { default as StickerSetModal } from '../components/common/StickerSetModal
export { default as CustomEmojiSetsModal } from '../components/common/CustomEmojiSetsModal';
export { default as HeaderMenuContainer } from '../components/middle/HeaderMenuContainer';
export { default as MobileSearch } from '../components/middle/MobileSearch';
export { default as ReactionPicker } from '../components/middle/message/ReactionPicker';
export { default as AttachmentModal } from '../components/middle/composer/AttachmentModal';
export { default as PollModal } from '../components/middle/composer/PollModal';

View File

@ -29,6 +29,7 @@ type OwnProps = {
loopLimit?: number;
style?: string;
isBig?: boolean;
noPlay?: boolean;
withGridFix?: boolean;
withSharedAnimation?: boolean;
sharedCanvasRef?: React.RefObject<HTMLCanvasElement>;
@ -48,6 +49,7 @@ const CustomEmoji: FC<OwnProps> = ({
documentId,
size = STICKER_SIZE,
isBig,
noPlay,
className,
loopLimit,
style,
@ -129,6 +131,7 @@ const CustomEmoji: FC<OwnProps> = ({
sticker={customEmoji}
isSmall={!isBig}
size={size}
noPlay={noPlay}
customColor={customColor}
thumbClassName={styles.thumb}
fullMediaClassName={styles.media}

View File

@ -0,0 +1,5 @@
.root {
--emoji-size: 2.5rem;
max-height: calc(100 * var(--vh));
}

View File

@ -1,54 +1,67 @@
import type { FC } from '../../../lib/teact/teact';
import type { RefObject } from 'react';
import React, {
useState, useEffect, memo, useRef, useMemo, useCallback,
} from '../../../lib/teact/teact';
import { getGlobal, withGlobal } from '../../../global';
} from '../../lib/teact/teact';
import { getGlobal, withGlobal } from '../../global';
import type { ApiStickerSet, ApiSticker } from '../../../api/types';
import type { StickerSetOrRecent } from '../../../types';
import type { FC } from '../../lib/teact/teact';
import type {
ApiStickerSet, ApiSticker, ApiReaction, ApiAvailableReaction,
} from '../../api/types';
import type { StickerSetOrReactionsSetOrRecent } from '../../types';
import {
CHAT_STICKER_SET_ID,
FAVORITE_SYMBOL_SET_ID,
POPULAR_SYMBOL_SET_ID,
PREMIUM_STICKER_SET_ID,
RECENT_SYMBOL_SET_ID,
SLIDE_TRANSITION_DURATION,
STICKER_PICKER_MAX_SHARED_COVERS,
STICKER_SIZE_PICKER_HEADER,
} from '../../../config';
import { IS_TOUCH_ENV } from '../../../util/windowEnvironment';
import { MEMO_EMPTY_ARRAY } from '../../../util/memo';
import fastSmoothScroll from '../../../util/fastSmoothScroll';
import buildClassName from '../../../util/buildClassName';
import fastSmoothScrollHorizontal from '../../../util/fastSmoothScrollHorizontal';
import { pickTruthy, unique } from '../../../util/iteratees';
TOP_SYMBOL_SET_ID,
} from '../../config';
import { IS_TOUCH_ENV } from '../../util/windowEnvironment';
import { MEMO_EMPTY_ARRAY } from '../../util/memo';
import fastSmoothScroll from '../../util/fastSmoothScroll';
import buildClassName from '../../util/buildClassName';
import fastSmoothScrollHorizontal from '../../util/fastSmoothScrollHorizontal';
import { pickTruthy, unique } from '../../util/iteratees';
import { isSameReaction } from '../../global/helpers';
import {
selectIsAlwaysHighPriorityEmoji,
selectIsChatWithSelf,
selectIsCurrentUserPremium,
} from '../../../global/selectors';
} from '../../global/selectors';
import useAsyncRendering from '../../right/hooks/useAsyncRendering';
import { useIntersectionObserver } from '../../../hooks/useIntersectionObserver';
import useHorizontalScroll from '../../../hooks/useHorizontalScroll';
import useLang from '../../../hooks/useLang';
import useAsyncRendering from '../right/hooks/useAsyncRendering';
import { useIntersectionObserver } from '../../hooks/useIntersectionObserver';
import useHorizontalScroll from '../../hooks/useHorizontalScroll';
import useLang from '../../hooks/useLang';
import useAppLayout from '../../hooks/useAppLayout';
import Loading from '../../ui/Loading';
import Button from '../../ui/Button';
import StickerButton from '../../common/StickerButton';
import Loading from '../ui/Loading';
import Button from '../ui/Button';
import StickerButton from './StickerButton';
import StickerSet from './StickerSet';
import StickerSetCover from './StickerSetCover';
import StickerSetCover from '../middle/composer/StickerSetCover';
import './StickerPicker.scss';
import '../middle/composer/StickerPicker.scss';
import styles from './CustomEmojiPicker.module.scss';
type OwnProps = {
scrollContainerRef?: RefObject<HTMLDivElement>;
scrollHeaderRef?: RefObject<HTMLDivElement>;
chatId?: string;
className?: string;
loadAndPlay: boolean;
isStatusPicker?: boolean;
idPrefix?: string;
withDefaultTopicIcons?: boolean;
onCustomEmojiSelect: (sticker: ApiSticker) => void;
onReactionSelect?: (reaction: ApiReaction) => void;
selectedReactionIds?: string[];
isStatusPicker?: boolean;
isReactionPicker?: boolean;
onContextMenuOpen?: NoneToVoidFunction;
onContextMenuClose?: NoneToVoidFunction;
onContextMenuClick?: NoneToVoidFunction;
@ -58,7 +71,10 @@ type StateProps = {
customEmojisById?: Record<string, ApiSticker>;
recentCustomEmojiIds?: string[];
recentStatusEmojis?: ApiSticker[];
topReactions?: ApiReaction[];
recentReactions?: ApiReaction[];
stickerSetsById: Record<string, ApiStickerSet>;
availableReactions?: ApiAvailableReaction[];
addedCustomEmojiIds?: string[];
defaultTopicIconsId?: string;
defaultStatusIconsId?: string;
@ -72,20 +88,37 @@ const SMOOTH_SCROLL_DISTANCE = 500;
const HEADER_BUTTON_WIDTH = 52; // px (including margin)
const STICKER_INTERSECTION_THROTTLE = 200;
const DEFAULT_ID_PREFIX = 'custom-emoji-set';
const TOP_REACTIONS_COUNT = 16;
const RECENT_REACTIONS_COUNT = 32;
const FADED_BUTTON_SET_IDS = new Set([RECENT_SYMBOL_SET_ID, FAVORITE_SYMBOL_SET_ID, POPULAR_SYMBOL_SET_ID]);
const STICKER_SET_IDS_WITH_COVER = new Set([
RECENT_SYMBOL_SET_ID,
FAVORITE_SYMBOL_SET_ID,
POPULAR_SYMBOL_SET_ID,
CHAT_STICKER_SET_ID,
PREMIUM_STICKER_SET_ID,
]);
const stickerSetIntersections: boolean[] = [];
const CustomEmojiPicker: FC<OwnProps & StateProps> = ({
scrollContainerRef,
scrollHeaderRef,
className,
loadAndPlay,
addedCustomEmojiIds,
customEmojisById,
recentCustomEmojiIds,
selectedReactionIds,
recentStatusEmojis,
stickerSetsById,
topReactions,
recentReactions,
availableReactions,
idPrefix = DEFAULT_ID_PREFIX,
customEmojiFeaturedIds,
canAnimate,
isReactionPicker,
isStatusPicker,
isSavedMessages,
isCurrentUserPremium,
@ -93,20 +126,28 @@ const CustomEmojiPicker: FC<OwnProps & StateProps> = ({
defaultTopicIconsId,
defaultStatusIconsId,
onCustomEmojiSelect,
onReactionSelect,
onContextMenuOpen,
onContextMenuClose,
onContextMenuClick,
}) => {
// eslint-disable-next-line no-null/no-null
const containerRef = useRef<HTMLDivElement>(null);
let containerRef = useRef<HTMLDivElement>(null);
// eslint-disable-next-line no-null/no-null
const headerRef = useRef<HTMLDivElement>(null);
let headerRef = useRef<HTMLDivElement>(null);
// eslint-disable-next-line no-null/no-null
const sharedCanvasRef = useRef<HTMLCanvasElement>(null);
// eslint-disable-next-line no-null/no-null
const sharedCanvasHqRef = useRef<HTMLCanvasElement>(null);
if (scrollContainerRef) {
containerRef = scrollContainerRef;
}
if (scrollHeaderRef) {
headerRef = scrollHeaderRef;
}
const [activeSetIndex, setActiveSetIndex] = useState<number>(0);
const { isMobile } = useAppLayout();
const recentCustomEmojis = useMemo(() => {
return isStatusPicker
@ -149,9 +190,43 @@ const CustomEmojiPicker: FC<OwnProps & StateProps> = ({
return MEMO_EMPTY_ARRAY;
}
const defaultSets: StickerSetOrRecent[] = [];
const defaultSets: StickerSetOrReactionsSetOrRecent[] = [];
if (isStatusPicker) {
if (isReactionPicker) {
const topReactionsSlice = topReactions?.slice(0, TOP_REACTIONS_COUNT) || [];
if (topReactionsSlice?.length) {
defaultSets.push({
id: TOP_SYMBOL_SET_ID,
accessHash: '',
title: lang('Reactions'),
reactions: topReactionsSlice,
count: topReactionsSlice.length,
isEmoji: true,
});
}
const cleanRecentReactions = (recentReactions || [])
.filter((reaction) => !topReactionsSlice.some((topReaction) => isSameReaction(topReaction, reaction)))
.slice(0, RECENT_REACTIONS_COUNT);
const cleanAvailableReactions = (availableReactions || [])
.map(({ reaction }) => reaction)
.filter((reaction) => {
return !topReactionsSlice.some((topReaction) => isSameReaction(topReaction, reaction))
&& !cleanRecentReactions.some((topReaction) => isSameReaction(topReaction, reaction));
});
if (cleanAvailableReactions?.length || cleanRecentReactions?.length) {
const isPopular = !cleanRecentReactions?.length;
const allRecentReactions = cleanRecentReactions.concat(cleanAvailableReactions);
defaultSets.push({
id: isPopular ? POPULAR_SYMBOL_SET_ID : RECENT_SYMBOL_SET_ID,
accessHash: '',
title: lang(isPopular ? 'PopularReactions' : 'RecentStickers'),
reactions: allRecentReactions,
count: allRecentReactions.length,
isEmoji: true,
});
}
} else if (isStatusPicker) {
const defaultStatusIconsPack = stickerSetsById[defaultStatusIconsId!];
if (defaultStatusIconsPack.stickers?.length) {
const stickers = (defaultStatusIconsPack.stickers || []).concat(recentCustomEmojis || []);
@ -179,7 +254,7 @@ const CustomEmojiPicker: FC<OwnProps & StateProps> = ({
title: lang('RecentStickers'),
stickers: recentCustomEmojis,
count: recentCustomEmojis.length,
isEmoji: true as true,
isEmoji: true,
});
}
@ -192,8 +267,9 @@ const CustomEmojiPicker: FC<OwnProps & StateProps> = ({
...setsToDisplay,
];
}, [
addedCustomEmojiIds, isStatusPicker, withDefaultTopicIcons, recentCustomEmojis,
customEmojiFeaturedIds, stickerSetsById, defaultStatusIconsId, lang, defaultTopicIconsId,
addedCustomEmojiIds, isReactionPicker, isStatusPicker, withDefaultTopicIcons, recentCustomEmojis,
customEmojiFeaturedIds, stickerSetsById, topReactions, availableReactions, lang, recentReactions,
defaultStatusIconsId, defaultTopicIconsId,
]);
const noPopulatedSets = useMemo(() => (
@ -201,10 +277,10 @@ const CustomEmojiPicker: FC<OwnProps & StateProps> = ({
&& allSets.filter((set) => set.stickers?.length).length === 0
), [allSets, areAddedLoaded]);
const canRenderContents = useAsyncRendering([], SLIDE_TRANSITION_DURATION);
const shouldRenderContents = areAddedLoaded && canRenderContents && !noPopulatedSets;
const canRenderContent = useAsyncRendering([], SLIDE_TRANSITION_DURATION);
const shouldRenderContent = areAddedLoaded && canRenderContent && !noPopulatedSets;
useHorizontalScroll(headerRef, !shouldRenderContents);
useHorizontalScroll(headerRef, !(isMobile && shouldRenderContent));
// Scroll container and header when active set changes
useEffect(() => {
@ -232,7 +308,11 @@ const CustomEmojiPicker: FC<OwnProps & StateProps> = ({
onCustomEmojiSelect(emoji);
}, [onCustomEmojiSelect]);
function renderCover(stickerSet: StickerSetOrRecent, index: number) {
const handleReactionSelect = useCallback((reaction: ApiReaction) => {
onReactionSelect?.(reaction);
}, [onReactionSelect]);
function renderCover(stickerSet: StickerSetOrReactionsSetOrRecent, index: number) {
const firstSticker = stickerSet.stickers?.[0];
const buttonClassName = buildClassName(
'symbol-set-button sticker-set-button',
@ -242,25 +322,24 @@ const CustomEmojiPicker: FC<OwnProps & StateProps> = ({
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
) {
if (stickerSet.id === TOP_SYMBOL_SET_ID) {
return undefined;
}
if (STICKER_SET_IDS_WITH_COVER.has(stickerSet.id) || stickerSet.hasThumbnail || !firstSticker) {
const isFaded = FADED_BUTTON_SET_IDS.has(stickerSet.id);
return (
<Button
key={stickerSet.id}
className={buttonClassName}
ariaLabel={stickerSet.title}
round
faded={stickerSet.id === RECENT_SYMBOL_SET_ID || stickerSet.id === FAVORITE_SYMBOL_SET_ID}
faded={isFaded}
color="translucent"
// eslint-disable-next-line react/jsx-no-bind
onClick={() => selectStickerSet(index)}
>
{stickerSet.id === RECENT_SYMBOL_SET_ID ? (
{(stickerSet.id === RECENT_SYMBOL_SET_ID || stickerSet.id === POPULAR_SYMBOL_SET_ID) ? (
<i className="icon-recent" />
) : (
<StickerSetCover
@ -272,29 +351,29 @@ const CustomEmojiPicker: FC<OwnProps & StateProps> = ({
)}
</Button>
);
} else {
return (
<StickerButton
key={stickerSet.id}
sticker={firstSticker}
size={STICKER_SIZE_PICKER_HEADER}
title={stickerSet.title}
className={buttonClassName}
noAnimate={!canAnimate || !loadAndPlay}
observeIntersection={observeIntersectionForCovers}
noContextMenu
isCurrentUserPremium
sharedCanvasRef={withSharedCanvas ? (isHq ? sharedCanvasHqRef : sharedCanvasRef) : undefined}
onClick={selectStickerSet}
clickArg={index}
/>
);
}
return (
<StickerButton
key={stickerSet.id}
sticker={firstSticker}
size={STICKER_SIZE_PICKER_HEADER}
title={stickerSet.title}
className={buttonClassName}
noAnimate={!canAnimate || !loadAndPlay}
observeIntersection={observeIntersectionForCovers}
noContextMenu
isCurrentUserPremium
sharedCanvasRef={withSharedCanvas ? (isHq ? sharedCanvasHqRef : sharedCanvasRef) : undefined}
onClick={selectStickerSet}
clickArg={index}
/>
);
}
const fullClassName = buildClassName('StickerPicker', 'CustomEmojiPicker', className);
const fullClassName = buildClassName('StickerPicker', styles.root, className);
if (!shouldRenderContents) {
if (!shouldRenderContent) {
return (
<div className={fullClassName}>
{noPopulatedSets ? (
@ -322,34 +401,43 @@ const CustomEmojiPicker: FC<OwnProps & StateProps> = ({
ref={containerRef}
className={buildClassName('StickerPicker-main no-selection', IS_TOUCH_ENV ? 'no-scrollbar' : 'custom-scroll')}
>
{allSets.map((stickerSet, i) => (
<StickerSet
key={stickerSet.id}
stickerSet={stickerSet}
loadAndPlay={Boolean(canAnimate && loadAndPlay)}
index={i}
idPrefix={idPrefix}
observeIntersection={observeIntersection}
shouldRender={activeSetIndex >= i - 1 && activeSetIndex <= i + 1}
isSavedMessages={isSavedMessages}
isStatusPicker={isStatusPicker}
shouldHideRecentHeader={withDefaultTopicIcons || isStatusPicker}
withDefaultTopicIcon={withDefaultTopicIcons && stickerSet.id === RECENT_SYMBOL_SET_ID}
withDefaultStatusIcon={isStatusPicker && stickerSet.id === RECENT_SYMBOL_SET_ID}
isCurrentUserPremium={isCurrentUserPremium}
onStickerSelect={handleEmojiSelect}
onContextMenuOpen={onContextMenuOpen}
onContextMenuClose={onContextMenuClose}
onContextMenuClick={onContextMenuClick}
/>
))}
{allSets.map((stickerSet, i) => {
const shouldHideHeader = stickerSet.id === TOP_SYMBOL_SET_ID
|| (stickerSet.id === RECENT_SYMBOL_SET_ID && (withDefaultTopicIcons || isStatusPicker));
return (
<StickerSet
key={stickerSet.id}
stickerSet={stickerSet}
loadAndPlay={Boolean(canAnimate && loadAndPlay)}
index={i}
idPrefix={idPrefix}
observeIntersection={observeIntersection}
shouldRender={activeSetIndex >= i - 1 && activeSetIndex <= i + 1}
isSavedMessages={isSavedMessages}
isStatusPicker={isStatusPicker}
isReactionPicker={isReactionPicker}
shouldHideHeader={shouldHideHeader}
withDefaultTopicIcon={withDefaultTopicIcons && stickerSet.id === RECENT_SYMBOL_SET_ID}
withDefaultStatusIcon={isStatusPicker && stickerSet.id === RECENT_SYMBOL_SET_ID}
isCurrentUserPremium={isCurrentUserPremium}
selectedReactionIds={selectedReactionIds}
availableReactions={availableReactions}
onReactionSelect={handleReactionSelect}
onStickerSelect={handleEmojiSelect}
onContextMenuOpen={onContextMenuOpen}
onContextMenuClose={onContextMenuClose}
onContextMenuClick={onContextMenuClick}
/>
);
})}
</div>
</div>
);
};
export default memo(withGlobal<OwnProps>(
(global, { chatId, isStatusPicker }): StateProps => {
(global, { chatId, isStatusPicker, isReactionPicker }): StateProps => {
const {
stickers: {
setsById: stickerSetsById,
@ -362,6 +450,8 @@ export default memo(withGlobal<OwnProps>(
},
},
recentCustomEmojis: recentCustomEmojiIds,
recentReactions,
topReactions,
} = global;
const isSavedMessages = Boolean(chatId && selectIsChatWithSelf(global, chatId));
@ -378,6 +468,9 @@ export default memo(withGlobal<OwnProps>(
customEmojiFeaturedIds,
defaultTopicIconsId: global.defaultTopicIconsId,
defaultStatusIconsId: global.defaultStatusIconsId,
topReactions: isReactionPicker ? topReactions : undefined,
recentReactions: isReactionPicker ? recentReactions : undefined,
availableReactions: isReactionPicker ? global.availableReactions : undefined,
};
},
)(CustomEmojiPicker));

View File

@ -16,7 +16,7 @@ import useMedia from '../../hooks/useMedia';
import useBuffering from '../../hooks/useBuffering';
import useCanvasBlur from '../../hooks/useCanvasBlur';
import useLang from '../../hooks/useLang';
import useContextMenuPosition from '../../hooks/useContextMenuPosition';
import useMenuPosition from '../../hooks/useMenuPosition';
import useContextMenuHandlers from '../../hooks/useContextMenuHandlers';
import Spinner from '../ui/Spinner';
@ -83,7 +83,7 @@ const GifButton: FC<OwnProps> = ({
const {
positionX, positionY, transformOriginX, transformOriginY, style: menuStyle,
} = useContextMenuPosition(
} = useMenuPosition(
contextMenuPosition,
getTriggerElement,
getRootElement,

View File

@ -0,0 +1,18 @@
.root {
--custom-emoji-size: 2.5rem;
cursor: pointer;
display: inline-block;
width: var(--custom-emoji-size);
height: var(--custom-emoji-size);
border-radius: var(--border-radius-messages-small);
background: transparent no-repeat center;
background-size: contain;
transition: background-color 0.15s ease, opacity 0.3s ease !important;
position: relative;
&.selected,
&:hover {
background-color: var(--color-interactive-element-hover);
}
}

View File

@ -0,0 +1,101 @@
import React, {
memo, useCallback, useMemo, useRef,
} from '../../lib/teact/teact';
import type { FC } from '../../lib/teact/teact';
import type { ApiAvailableReaction, ApiReaction } from '../../api/types';
import type { ObserveFn } from '../../hooks/useIntersectionObserver';
import { EMOJI_SIZE_PICKER } from '../../config';
import buildClassName from '../../util/buildClassName';
import { getDocumentMediaHash, isSameReaction } from '../../global/helpers';
import useBoundsInSharedCanvas from '../../hooks/useBoundsInSharedCanvas';
import useMediaTransition from '../../hooks/useMediaTransition';
import useMedia from '../../hooks/useMedia';
import CustomEmoji from './CustomEmoji';
import AnimatedIconWithPreview from './AnimatedIconWithPreview';
import styles from './ReactionEmoji.module.scss';
type OwnProps = {
reaction: ApiReaction;
availableReactions?: ApiAvailableReaction[];
className?: string;
isSelected?: boolean;
loadAndPlay?: boolean;
observeIntersection?: ObserveFn;
sharedCanvasRef?: React.RefObject<HTMLCanvasElement>;
sharedCanvasHqRef?: React.RefObject<HTMLCanvasElement>;
onClick: (reaction: ApiReaction) => void;
};
const ReactionEmoji: FC<OwnProps> = ({
reaction,
availableReactions,
isSelected,
loadAndPlay,
observeIntersection,
sharedCanvasRef,
sharedCanvasHqRef,
onClick,
}) => {
// eslint-disable-next-line no-null/no-null
const ref = useRef<HTMLDivElement>(null);
const isCustom = 'documentId' in reaction;
const availableReaction = useMemo(() => (
availableReactions?.find((available) => isSameReaction(available.reaction, reaction))
), [availableReactions, reaction]);
const thumbDataUri = availableReaction?.staticIcon?.thumbnail?.dataUri;
const animationId = availableReaction?.selectAnimation?.id;
const bounds = useBoundsInSharedCanvas(ref, sharedCanvasHqRef);
const mediaData = useMedia(
availableReaction?.selectAnimation ? getDocumentMediaHash(availableReaction.selectAnimation) : undefined,
!animationId,
);
const handleClick = useCallback(() => {
onClick(reaction);
}, [onClick, reaction]);
const transitionClassNames = useMediaTransition(mediaData);
const fullClassName = buildClassName(
styles.root,
isSelected && styles.selected,
!isCustom && 'sticker-reaction',
);
return (
<div
ref={ref}
className={fullClassName}
onClick={handleClick}
title={availableReaction?.title}
data-sticker-id={isCustom ? reaction.documentId : undefined}
>
{isCustom ? (
<CustomEmoji
ref={ref}
documentId={reaction.documentId}
size={EMOJI_SIZE_PICKER}
noPlay={!loadAndPlay}
observeIntersectionForPlaying={observeIntersection}
sharedCanvasRef={sharedCanvasRef}
sharedCanvasHqRef={sharedCanvasHqRef}
/>
) : (
<AnimatedIconWithPreview
tgsUrl={mediaData}
thumbDataUri={thumbDataUri}
play={loadAndPlay}
size={EMOJI_SIZE_PICKER}
className={transitionClassNames}
sharedCanvas={sharedCanvasHqRef!.current || undefined}
sharedCanvasCoords={bounds.coords}
/>
)}
</div>
);
};
export default memo(ReactionEmoji);

View File

@ -13,6 +13,8 @@
position: relative;
&.custom-emoji {
color: var(--color-primary);
width: var(--custom-emoji-size);
height: var(--custom-emoji-size);
margin: 0.3125rem;
@ -63,6 +65,10 @@
z-index: 1;
}
&.selected {
background-color: var(--color-interactive-element-hover);
}
&.interactive {
cursor: pointer;

View File

@ -15,7 +15,7 @@ import type { ObserveFn } from '../../hooks/useIntersectionObserver';
import { useIsIntersecting } from '../../hooks/useIntersectionObserver';
import useLang from '../../hooks/useLang';
import useContextMenuHandlers from '../../hooks/useContextMenuHandlers';
import useContextMenuPosition from '../../hooks/useContextMenuPosition';
import useMenuPosition from '../../hooks/useMenuPosition';
import useDynamicColorListener from '../../hooks/useDynamicColorListener';
import StickerView from './StickerView';
@ -35,6 +35,7 @@ type OwnProps<T> = {
isSavedMessages?: boolean;
isStatusPicker?: boolean;
canViewSet?: boolean;
isSelected?: boolean;
isCurrentUserPremium?: boolean;
sharedCanvasRef?: React.RefObject<HTMLCanvasElement>;
observeIntersection: ObserveFn;
@ -68,6 +69,7 @@ const StickerButton = <T extends number | ApiSticker | ApiBotInlineMediaResult |
isStatusPicker,
canViewSet,
observeIntersection,
isSelected,
isCurrentUserPremium,
noShowPremium,
sharedCanvasRef,
@ -123,7 +125,7 @@ const StickerButton = <T extends number | ApiSticker | ApiBotInlineMediaResult |
const {
positionX, positionY, transformOriginX, transformOriginY, style: menuStyle,
} = useContextMenuPosition(
} = useMenuPosition(
contextMenuPosition,
getTriggerElement,
getRootElement,
@ -205,8 +207,8 @@ const StickerButton = <T extends number | ApiSticker | ApiBotInlineMediaResult |
const fullClassName = buildClassName(
'StickerButton',
onClick && 'interactive',
isSelected && 'selected',
isCustomEmoji && 'custom-emoji',
`sticker-button-${id}`,
className,
);

View File

@ -1,36 +1,43 @@
import React, {
memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState,
} from '../../../lib/teact/teact';
import { getActions, getGlobal } from '../../../global';
} from '../../lib/teact/teact';
import { getActions, getGlobal } from '../../global';
import type { FC } from '../../../lib/teact/teact';
import type { ApiSticker } from '../../../api/types';
import type { StickerSetOrRecent } from '../../../types';
import type { ObserveFn } from '../../../hooks/useIntersectionObserver';
import type { FC } from '../../lib/teact/teact';
import type { ApiAvailableReaction, ApiReaction, ApiSticker } from '../../api/types';
import type { StickerSetOrReactionsSetOrRecent } from '../../types';
import type { ObserveFn } from '../../hooks/useIntersectionObserver';
import {
DEFAULT_STATUS_ICON_ID,
DEFAULT_TOPIC_ICON_STICKER_ID,
EMOJI_SIZE_PICKER, FAVORITE_SYMBOL_SET_ID, RECENT_SYMBOL_SET_ID, STICKER_SIZE_PICKER,
} from '../../../config';
import buildClassName from '../../../util/buildClassName';
import { selectIsAlwaysHighPriorityEmoji, selectIsSetPremium } from '../../../global/selectors';
EMOJI_SIZE_PICKER,
FAVORITE_SYMBOL_SET_ID,
POPULAR_SYMBOL_SET_ID,
RECENT_SYMBOL_SET_ID,
STICKER_SIZE_PICKER,
} from '../../config';
import buildClassName from '../../util/buildClassName';
import { selectIsAlwaysHighPriorityEmoji, selectIsSetPremium } from '../../global/selectors';
import { getReactionUniqueKey } from '../../global/helpers';
import useLang from '../../../hooks/useLang';
import useFlag from '../../../hooks/useFlag';
import useMediaTransition from '../../../hooks/useMediaTransition';
import useResizeObserver from '../../../hooks/useResizeObserver';
import { useIsIntersecting } from '../../../hooks/useIntersectionObserver';
import useAppLayout from '../../../hooks/useAppLayout';
import useLang from '../../hooks/useLang';
import useFlag from '../../hooks/useFlag';
import useMediaTransition from '../../hooks/useMediaTransition';
import useResizeObserver from '../../hooks/useResizeObserver';
import { useIsIntersecting } from '../../hooks/useIntersectionObserver';
import useAppLayout from '../../hooks/useAppLayout';
import useWindowSize from '../../hooks/useWindowSize';
import StickerButton from '../../common/StickerButton';
import ConfirmDialog from '../../ui/ConfirmDialog';
import Button from '../../ui/Button';
import StickerButton from './StickerButton';
import ConfirmDialog from '../ui/ConfirmDialog';
import Button from '../ui/Button';
import ReactionEmoji from './ReactionEmoji';
import grey from '../../../assets/icons/forumTopic/grey.svg';
import grey from '../../assets/icons/forumTopic/grey.svg';
type OwnProps = {
stickerSet: StickerSetOrRecent;
stickerSet: StickerSetOrReactionsSetOrRecent;
loadAndPlay: boolean;
index: number;
idPrefix?: string;
@ -38,12 +45,16 @@ type OwnProps = {
favoriteStickers?: ApiSticker[];
isSavedMessages?: boolean;
isStatusPicker?: boolean;
isReactionPicker?: boolean;
isCurrentUserPremium?: boolean;
shouldHideRecentHeader?: boolean;
shouldHideHeader?: boolean;
selectedReactionIds?: string[];
withDefaultTopicIcon?: boolean;
withDefaultStatusIcon?: boolean;
observeIntersection: ObserveFn;
availableReactions?: ApiAvailableReaction[];
onStickerSelect?: (sticker: ApiSticker, isSilent?: boolean, shouldSchedule?: boolean) => void;
onReactionSelect?: (reaction: ApiReaction) => void;
onStickerUnfave?: (sticker: ApiSticker) => void;
onStickerFave?: (sticker: ApiSticker) => void;
onStickerRemoveRecent?: (sticker: ApiSticker) => void;
@ -53,6 +64,10 @@ type OwnProps = {
};
const ITEMS_PER_ROW_FALLBACK = 8;
const ITEMS_MOBILE_PER_ROW_FALLBACK = 7;
const ITEMS_MINI_MOBILE_PER_ROW_FALLBACK = 6;
const MOBILE_WIDTH_THRESHOLD_PX = 440;
const MINI_MOBILE_WIDTH_THRESHOLD_PX = 362;
const StickerSet: FC<OwnProps> = ({
stickerSet,
@ -61,13 +76,17 @@ const StickerSet: FC<OwnProps> = ({
idPrefix,
shouldRender,
favoriteStickers,
availableReactions,
isSavedMessages,
isStatusPicker,
isReactionPicker,
isCurrentUserPremium,
shouldHideRecentHeader,
shouldHideHeader,
withDefaultTopicIcon,
selectedReactionIds,
withDefaultStatusIcon,
observeIntersection,
onReactionSelect,
onStickerSelect,
onStickerUnfave,
onStickerFave,
@ -79,6 +98,7 @@ const StickerSet: FC<OwnProps> = ({
const {
clearRecentStickers,
clearRecentCustomEmoji,
clearRecentReactions,
openPremiumModal,
toggleStickerSet,
loadStickers,
@ -93,10 +113,11 @@ const StickerSet: FC<OwnProps> = ({
const sharedCanvasHqRef = useRef<HTMLCanvasElement>(null);
const lang = useLang();
const { width: windowWidth } = useWindowSize();
const [isConfirmModalOpen, openConfirmModal, closeConfirmModal] = useFlag();
const { isMobile } = useAppLayout();
const [itemsPerRow, setItemsPerRow] = useState(ITEMS_PER_ROW_FALLBACK);
const [itemsPerRow, setItemsPerRow] = useState(getItemsPerRowFallback(windowWidth));
const isIntersecting = useIsIntersecting(ref, observeIntersection);
@ -106,17 +127,22 @@ const StickerSet: FC<OwnProps> = ({
const emojiMarginPx = isMobile ? 8 : 10;
const isRecent = stickerSet.id === RECENT_SYMBOL_SET_ID;
const isFavorite = stickerSet.id === FAVORITE_SYMBOL_SET_ID;
const isPopular = stickerSet.id === POPULAR_SYMBOL_SET_ID;
const isEmoji = stickerSet.isEmoji;
const isPremiumSet = !isRecent && selectIsSetPremium(stickerSet);
const handleClearRecent = useCallback(() => {
if (isEmoji) {
if (isReactionPicker) {
clearRecentReactions();
} else if (isEmoji) {
clearRecentCustomEmoji();
} else {
clearRecentStickers();
}
closeConfirmModal();
}, [clearRecentCustomEmoji, clearRecentStickers, closeConfirmModal, isEmoji]);
}, [
clearRecentCustomEmoji, clearRecentReactions, clearRecentStickers, closeConfirmModal, isEmoji, isReactionPicker,
]);
const handleAddClick = useCallback(() => {
if (isPremiumSet && !isCurrentUserPremium) {
@ -156,10 +182,12 @@ const StickerSet: FC<OwnProps> = ({
const margin = isEmoji ? emojiMarginPx : stickerMarginPx;
const calculateItemsPerRow = useCallback((width: number) => {
if (!width) return ITEMS_PER_ROW_FALLBACK;
if (!width) {
return getItemsPerRowFallback(windowWidth);
}
return Math.floor(width / (itemSize + margin));
}, [itemSize, margin]);
}, [itemSize, margin, windowWidth]);
const handleResize = useCallback((entry: ResizeObserverEntry) => {
setItemsPerRow(calculateItemsPerRow(entry.contentRect.width));
@ -172,7 +200,7 @@ const StickerSet: FC<OwnProps> = ({
}, [calculateItemsPerRow]);
useEffect(() => {
if (isIntersecting && !stickerSet.stickers?.length && stickerSet.accessHash) {
if (isIntersecting && !stickerSet.stickers?.length && !stickerSet.reactions?.length && stickerSet.accessHash) {
loadStickers({
stickerSetInfo: {
id: stickerSet.id,
@ -186,7 +214,7 @@ const StickerSet: FC<OwnProps> = ({
&& stickerSet.stickers?.some(({ isFree }) => !isFree);
const isInstalled = stickerSet.installedDate && !stickerSet.isArchived;
const canCut = !isInstalled && stickerSet.id !== RECENT_SYMBOL_SET_ID;
const canCut = !isInstalled && stickerSet.id !== RECENT_SYMBOL_SET_ID && stickerSet.id !== POPULAR_SYMBOL_SET_ID;
const [isCut, , expand] = useFlag(canCut);
const itemsBeforeCutout = itemsPerRow * 3 - 1;
const totalItemsCount = withDefaultTopicIcon ? stickerSet.count + 1 : stickerSet.count;
@ -194,8 +222,6 @@ const StickerSet: FC<OwnProps> = ({
const heightWhenCut = Math.ceil(Math.min(itemsBeforeCutout, totalItemsCount) / itemsPerRow) * (itemSize + margin);
const height = isCut ? heightWhenCut : Math.ceil(totalItemsCount / itemsPerRow) * (itemSize + margin);
const shouldHideHeader = isRecent && shouldHideRecentHeader;
const favoriteStickerIdsSet = useMemo(() => (
favoriteStickers ? new Set(favoriteStickers.map(({ id }) => id)) : undefined
), [favoriteStickers]);
@ -218,7 +244,7 @@ const StickerSet: FC<OwnProps> = ({
{isRecent && (
<i className="symbol-set-remove icon-close" onClick={openConfirmModal} />
)}
{!isRecent && isEmoji && !isInstalled && (
{!isRecent && isEmoji && !isInstalled && !isPopular && (
<Button
className="symbol-set-add-button"
withPremiumGradient={isPremiumSet && !isCurrentUserPremium}
@ -262,14 +288,33 @@ const StickerSet: FC<OwnProps> = ({
<i className="icon-premium" />
</Button>
)}
{shouldRender && stickerSet.stickers && stickerSet.stickers
.slice(0, isCut ? itemsBeforeCutout : stickerSet.stickers.length)
{shouldRender && stickerSet.reactions?.map((reaction) => {
const reactionId = getReactionUniqueKey(reaction);
const isSelected = reactionId ? selectedReactionIds?.includes(reactionId) : undefined;
return (
<ReactionEmoji
key={`${stickerSet.id}_${reactionId}`}
reaction={reaction}
isSelected={isSelected}
loadAndPlay={loadAndPlay}
availableReactions={availableReactions}
observeIntersection={observeIntersection}
onClick={onReactionSelect!}
sharedCanvasRef={sharedCanvasRef}
sharedCanvasHqRef={sharedCanvasHqRef}
/>
);
})}
{shouldRender && stickerSet.stickers?.slice(0, isCut ? itemsBeforeCutout : stickerSet.stickers.length)
.map((sticker, i) => {
const isHqEmoji = (isRecent || isFavorite)
&& selectIsAlwaysHighPriorityEmoji(getGlobal(), sticker.stickerSetInfo);
const canvasRef = (canCut && i >= itemsBeforeCutout) || isHqEmoji
? sharedCanvasHqRef
: sharedCanvasRef;
const reactionId = sticker.isCustomEmoji ? sticker.id : sticker.emoji;
const isSelected = reactionId ? selectedReactionIds?.includes(reactionId) : undefined;
return (
<StickerButton
@ -285,6 +330,7 @@ const StickerSet: FC<OwnProps> = ({
sharedCanvasRef={canvasRef}
onClick={onStickerSelect}
clickArg={sticker}
isSelected={isSelected}
onUnfaveClick={isFavorite && favoriteStickerIdsSet?.has(sticker.id) ? onStickerUnfave : undefined}
onFaveClick={!favoriteStickerIdsSet?.has(sticker.id) ? onStickerFave : undefined}
onRemoveRecentClick={isRecent ? onStickerRemoveRecent : undefined}
@ -309,7 +355,7 @@ const StickerSet: FC<OwnProps> = ({
{isRecent && (
<ConfirmDialog
text={lang('ClearRecentStickersAlertMessage')}
text={lang(isReactionPicker ? 'ClearRecentReactionsAlertMessage' : 'ClearRecentStickersAlertMessage')}
isOpen={isConfirmModalOpen}
onClose={closeConfirmModal}
confirmHandler={handleClearRecent}
@ -321,3 +367,11 @@ const StickerSet: FC<OwnProps> = ({
};
export default memo(StickerSet);
function getItemsPerRowFallback(windowWidth: number): number {
return windowWidth > MOBILE_WIDTH_THRESHOLD_PX
? ITEMS_PER_ROW_FALLBACK
: (windowWidth < MINI_MOBILE_WIDTH_THRESHOLD_PX
? ITEMS_MINI_MOBILE_PER_ROW_FALLBACK
: ITEMS_MOBILE_PER_ROW_FALLBACK);
}

View File

@ -26,7 +26,6 @@ import '../ui/Modal.scss';
import './Avatar.scss';
import telegramLogoPath from '../../assets/telegram-logo.svg';
import reactionThumbsPath from '../../assets/reaction-thumbs.png';
import lockPreviewPath from '../../assets/lock.png';
import monkeyPath from '../../assets/monkey.svg';
import spoilerMaskPath from '../../assets/spoilers/mask.svg';
@ -81,7 +80,6 @@ const preloadTasks = {
loadModule(Bundles.Main)
.then(preloadFonts),
preloadAvatars(),
preloadImage(reactionThumbsPath),
preloadImage(spoilerMaskPath),
]),
authPhoneNumber: () => Promise.all([

View File

@ -11,7 +11,7 @@ import useFlag from '../../../hooks/useFlag';
import Menu from '../../ui/Menu';
import Portal from '../../ui/Portal';
import CustomEmojiPicker from '../../middle/composer/CustomEmojiPicker';
import CustomEmojiPicker from '../../common/CustomEmojiPicker';
import styles from './StatusPickerMenu.module.scss';
@ -35,6 +35,11 @@ const StatusPickerMenu: FC<OwnProps & StateProps> = ({
}) => {
const { loadFeaturedEmojiStickers } = getActions();
// eslint-disable-next-line no-null/no-null
const scrollHeaderRef = useRef<HTMLDivElement>(null);
// eslint-disable-next-line no-null/no-null
const scrollContainerRef = useRef<HTMLDivElement>(null);
const transformOriginX = useRef<number>();
const [isContextMenuShown, markContextMenuShown, unmarkContextMenuShown] = useFlag();
useEffect(() => {
@ -47,6 +52,15 @@ const StatusPickerMenu: FC<OwnProps & StateProps> = ({
}
}, [areFeaturedStickersLoaded, isOpen, loadFeaturedEmojiStickers]);
const handleResetScrollPosition = useCallback(() => {
if (scrollContainerRef.current) {
scrollContainerRef.current.scrollTop = 0;
}
if (scrollHeaderRef.current) {
scrollHeaderRef.current.scrollLeft = 0;
}
}, []);
const handleEmojiSelect = useCallback((sticker: ApiSticker) => {
onEmojiStatusSelect(sticker);
onClose();
@ -62,11 +76,14 @@ const StatusPickerMenu: FC<OwnProps & StateProps> = ({
onClose={onClose}
transformOriginX={transformOriginX.current}
noCloseOnBackdrop={isContextMenuShown}
onCloseAnimationEnd={handleResetScrollPosition}
>
<CustomEmojiPicker
idPrefix="status-emoji-set-"
loadAndPlay={isOpen}
isStatusPicker
scrollHeaderRef={scrollHeaderRef}
scrollContainerRef={scrollContainerRef}
onContextMenuOpen={markContextMenuShown}
onContextMenuClose={unmarkContextMenuShown}
onCustomEmojiSelect={handleEmojiSelect}

View File

@ -26,7 +26,7 @@ import {
selectIsMediaViewerOpen,
selectIsRightColumnShown,
selectIsServiceChatReady,
selectUser,
selectUser, selectIsReactionPickerOpen,
} from '../../global/selectors';
import buildClassName from '../../util/buildClassName';
import { waitForTransitionEnd } from '../../util/cssAnimationEndListeners';
@ -47,6 +47,7 @@ import { dispatchHeavyAnimationEvent } from '../../hooks/useHeavyAnimationCheck'
import useInterval from '../../hooks/useInterval';
import useAppLayout from '../../hooks/useAppLayout';
import useTimeout from '../../hooks/useTimeout';
import useFlag from '../../hooks/useFlag';
import StickerSetModal from '../common/StickerSetModal.async';
import UnreadCount from '../common/UnreadCounter';
@ -81,6 +82,7 @@ import DeleteFolderDialog from './DeleteFolderDialog.async';
import CustomEmojiSetsModal from '../common/CustomEmojiSetsModal.async';
import DraftRecipientPicker from './DraftRecipientPicker.async';
import AttachBotRecipientPicker from './AttachBotRecipientPicker.async';
import ReactionPicker from '../middle/message/ReactionPicker.async';
import './Main.scss';
@ -131,11 +133,13 @@ type StateProps = {
deleteFolderDialogId?: number;
isPaymentModalOpen?: boolean;
isReceiptModalOpen?: boolean;
isReactionPickerOpen: boolean;
isCurrentUserPremium?: boolean;
};
const APP_OUTDATED_TIMEOUT_MS = 5 * 60 * 1000; // 5 min
const CALL_BUNDLE_LOADING_DELAY_MS = 5000; // 5 sec
const REACTION_PICKER_LOADING_DELAY_MS = 7000; // 7 sec
// eslint-disable-next-line @typescript-eslint/naming-convention
let DEBUG_isLogged = false;
@ -181,6 +185,7 @@ const Main: FC<OwnProps & StateProps> = ({
isPremiumModalOpen,
isPaymentModalOpen,
isReceiptModalOpen,
isReactionPickerOpen,
isCurrentUserPremium,
deleteFolderDialogId,
isMasterTab,
@ -219,6 +224,9 @@ const Main: FC<OwnProps & StateProps> = ({
toggleLeftColumn,
loadRecentEmojiStatuses,
updatePageTitle,
loadTopReactions,
loadRecentReactions,
loadFeaturedEmojiStickers,
} = getActions();
if (DEBUG && !DEBUG_isLogged) {
@ -232,6 +240,9 @@ const Main: FC<OwnProps & StateProps> = ({
void loadBundle(Bundles.Calls);
}, CALL_BUNDLE_LOADING_DELAY_MS);
const [shouldLoadReactionPicker, markShouldLoadReactionPicker] = useFlag(false);
useTimeout(markShouldLoadReactionPicker, REACTION_PICKER_LOADING_DELAY_MS);
// eslint-disable-next-line no-null/no-null
const containerRef = useRef<HTMLDivElement>(null);
@ -266,19 +277,26 @@ const Main: FC<OwnProps & StateProps> = ({
loadContactList();
loadPremiumGifts();
loadDefaultTopicIcons();
loadDefaultStatusIcons();
checkAppVersion();
if (isCurrentUserPremium) {
loadRecentEmojiStatuses();
}
loadTopReactions();
loadRecentReactions();
loadFeaturedEmojiStickers();
}
}, [
lastSyncTime, loadAnimatedEmojis, loadEmojiKeywords, loadNotificationExceptions, loadNotificationSettings,
loadTopInlineBots, updateIsOnline, loadAvailableReactions, loadAppConfig, loadAttachBots, loadContactList,
loadPremiumGifts, checkAppVersion, loadConfig, loadGenericEmojiEffects, loadDefaultTopicIcons,
loadDefaultStatusIcons, loadRecentEmojiStatuses, isCurrentUserPremium, isMasterTab, initMain,
loadPremiumGifts, checkAppVersion, loadConfig, loadGenericEmojiEffects, loadDefaultTopicIcons, loadTopReactions,
loadDefaultStatusIcons, loadRecentReactions, loadRecentEmojiStatuses, isCurrentUserPremium, isMasterTab, initMain,
]);
// Initial Premium API calls
useEffect(() => {
if (lastSyncTime && isMasterTab && isCurrentUserPremium) {
loadDefaultStatusIcons();
loadRecentEmojiStatuses();
}
}, [isCurrentUserPremium, isMasterTab, lastSyncTime, loadDefaultStatusIcons, loadRecentEmojiStatuses]);
// Language-based API calls
useEffect(() => {
if (lastSyncTime && isMasterTab) {
@ -502,6 +520,7 @@ const Main: FC<OwnProps & StateProps> = ({
<PaymentModal isOpen={isPaymentModalOpen} onClose={closePaymentModal} />
<ReceiptModal isOpen={isReceiptModalOpen} onClose={clearReceipt} />
<DeleteFolderDialog deleteFolderDialogId={deleteFolderDialogId} />
<ReactionPicker isOpen={isReactionPickerOpen} shouldLoad={shouldLoadReactionPicker} />
</div>
);
};
@ -559,6 +578,7 @@ export default memo(withGlobal<OwnProps>(
isRightColumnOpen: selectIsRightColumnShown(global, isMobile),
isMediaViewerOpen: selectIsMediaViewerOpen(global),
isForwardModalOpen: selectIsForwardModalOpen(global),
isReactionPickerOpen: selectIsReactionPickerOpen(global),
hasNotifications: Boolean(notifications.length),
hasDialogs: Boolean(dialogs.length),
audioMessage,

View File

@ -5,10 +5,10 @@ import type { ISettings } from '../../../types';
import type { ApiDocument } from '../../../api/types';
import { ApiMediaFormat } from '../../../api/types';
import { IS_COMPACT_MENU } from '../../../util/windowEnvironment';
import useMedia from '../../../hooks/useMedia';
import { getDocumentMediaHash } from '../../../global/helpers';
import buildClassName from '../../../util/buildClassName';
import useAppLayout from '../../../hooks/useAppLayout';
import useMedia from '../../../hooks/useMedia';
import styles from './AttachBotIcon.module.scss';
@ -25,6 +25,7 @@ const COLOR_REPLACE_PATTERN = /#fff/gi;
const AttachBotIcon: FC<OwnProps> = ({
icon, theme,
}) => {
const { isTouchScreen } = useAppLayout();
const mediaData = useMedia(getDocumentMediaHash(icon), false, ApiMediaFormat.Text);
const iconSvg = useMemo(() => {
@ -42,8 +43,8 @@ const AttachBotIcon: FC<OwnProps> = ({
}, [mediaData, theme]);
return (
<i className={buildClassName(styles.root, IS_COMPACT_MENU && styles.compact)}>
<img src={iconSvg} alt="" className={buildClassName(styles.image, IS_COMPACT_MENU && styles.compact)} />
<i className={buildClassName(styles.root, !isTouchScreen && styles.compact)}>
<img src={iconSvg} alt="" className={buildClassName(styles.image, !isTouchScreen && styles.compact)} />
</i>
);
};

View File

@ -29,7 +29,7 @@ import useAsyncRendering from '../../right/hooks/useAsyncRendering';
import useShowTransition from '../../../hooks/useShowTransition';
import useLang from '../../../hooks/useLang';
import useContextMenuHandlers from '../../../hooks/useContextMenuHandlers';
import useContextMenuPosition from '../../../hooks/useContextMenuPosition';
import useMenuPosition from '../../../hooks/useMenuPosition';
import Button from '../../ui/Button';
import EmbeddedMessage from '../../common/EmbeddedMessage';
@ -141,7 +141,7 @@ const ComposerEmbeddedMessage: FC<OwnProps & StateProps> = ({
const {
positionX, positionY, transformOriginX, transformOriginY, style: menuStyle,
} = useContextMenuPosition(
} = useMenuPosition(
contextMenuPosition,
getTriggerElement,
getRootElement,

View File

@ -107,7 +107,3 @@
justify-content: center;
}
}
.CustomEmojiPicker {
--emoji-size: 2.5rem;
}

View File

@ -4,7 +4,7 @@ import React, {
import { getActions, withGlobal } from '../../../global';
import type { ApiStickerSet, ApiSticker, ApiChat } from '../../../api/types';
import type { StickerSetOrRecent } from '../../../types';
import type { StickerSetOrReactionsSetOrRecent } from '../../../types';
import type { FC } from '../../../lib/teact/teact';
import {
@ -33,7 +33,7 @@ import Avatar from '../../common/Avatar';
import Loading from '../../ui/Loading';
import Button from '../../ui/Button';
import StickerButton from '../../common/StickerButton';
import StickerSet from './StickerSet';
import StickerSet from '../../common/StickerSet';
import StickerSetCover from './StickerSetCover';
import PremiumIcon from '../../common/PremiumIcon';
@ -263,7 +263,7 @@ const StickerPicker: FC<OwnProps & StateProps> = ({
removeRecentSticker({ sticker });
}, [removeRecentSticker]);
function renderCover(stickerSet: StickerSetOrRecent, index: number) {
function renderCover(stickerSet: StickerSetOrReactionsSetOrRecent, index: number) {
const firstSticker = stickerSet.stickers?.[0];
const buttonClassName = buildClassName(
'symbol-set-button sticker-set-button',

View File

@ -236,10 +236,19 @@
position: absolute;
font-size: 1rem;
cursor: pointer;
}
border-radius: 50%;
padding: 0.25rem;
transition: background-color 0.15s;
&-container {
text-align: initial;
&:active,
&:focus {
background-color: var(--color-interactive-element-hover);
}
@media (hover: hover) {
&:hover {
background-color: var(--color-interactive-element-hover);
}
}
}
&-button {
@ -253,29 +262,30 @@
@include while-transition() {
overflow: hidden;
}
}
.symbol-set-container {
display: grid;
justify-content: space-between;
grid-template-columns: repeat(auto-fill, var(--emoji-size, 4rem));
grid-gap: 0.625rem;
padding: 0.3125rem;
.symbol-set-container {
display: grid !important;
justify-content: space-between;
grid-template-columns: repeat(auto-fill, var(--emoji-size, 4rem));
grid-gap: 0.625rem;
padding: 0.3125rem;
text-align: initial;
@media (max-width: 600px) {
grid-gap: 0.5rem;
}
@media (max-width: 600px) {
grid-gap: 0.5rem;
}
&:not(.shown) {
display: block;
}
&:not(.shown) {
display: block;
}
&.closing {
transition: none;
}
&.closing {
transition: none;
}
> .EmojiButton,
> .StickerButton {
margin: 0;
}
> .EmojiButton,
> .StickerButton {
margin: 0;
}
}

View File

@ -21,7 +21,7 @@ import Button from '../../ui/Button';
import Menu from '../../ui/Menu';
import Transition from '../../ui/Transition';
import EmojiPicker from './EmojiPicker';
import CustomEmojiPicker from './CustomEmojiPicker';
import CustomEmojiPicker from '../../common/CustomEmojiPicker';
import StickerPicker from './StickerPicker';
import GifPicker from './GifPicker';
import SymbolMenuFooter, { SYMBOL_MENU_TAB_TITLES, SymbolMenuTabs } from './SymbolMenuFooter';
@ -100,7 +100,7 @@ const SymbolMenu: FC<OwnProps & StateProps> = ({
transformOriginY,
style,
}) => {
const { loadPremiumSetStickers, loadFeaturedEmojiStickers } = getActions();
const { loadPremiumSetStickers } = getActions();
const [activeTab, setActiveTab] = useState<number>(0);
const [recentEmojis, setRecentEmojis] = useState<string[]>([]);
const [recentCustomEmojis, setRecentCustomEmojis] = useState<string[]>([]);
@ -124,12 +124,10 @@ const SymbolMenu: FC<OwnProps & StateProps> = ({
}, [canSendPlainText]);
useEffect(() => {
if (!lastSyncTime) return;
if (isCurrentUserPremium) {
if (lastSyncTime && isCurrentUserPremium) {
loadPremiumSetStickers();
}
loadFeaturedEmojiStickers();
}, [isCurrentUserPremium, lastSyncTime, loadFeaturedEmojiStickers, loadPremiumSetStickers]);
}, [isCurrentUserPremium, lastSyncTime, loadPremiumSetStickers]);
useLayoutEffect(() => {
if (!isMobile || isAttachmentModal) {

View File

@ -10,7 +10,7 @@ import type { ApiVideo, ApiSticker } from '../../../api/types';
import { EDITABLE_INPUT_CSS_SELECTOR, EDITABLE_INPUT_MODAL_CSS_SELECTOR } from '../../../config';
import buildClassName from '../../../util/buildClassName';
import useFlag from '../../../hooks/useFlag';
import useContextMenuPosition from '../../../hooks/useContextMenuPosition';
import useMenuPosition from '../../../hooks/useMenuPosition';
import Button from '../../ui/Button';
import Spinner from '../../ui/Spinner';
@ -146,7 +146,7 @@ const SymbolMenuButton: FC<OwnProps> = ({
const {
positionX, positionY, transformOriginX, transformOriginY, style: menuStyle,
} = useContextMenuPosition(
} = useMenuPosition(
contextMenuPosition,
getTriggerElement,
getRootElement,

View File

@ -61,13 +61,14 @@ export type OwnProps = {
messageListType: MessageListType;
noReplies?: boolean;
detectedLanguage?: string;
onClose: () => void;
onCloseAnimationEnd: () => void;
repliesThreadInfo?: ApiThreadInfo;
onClose: NoneToVoidFunction;
onCloseAnimationEnd: NoneToVoidFunction;
};
type StateProps = {
availableReactions?: ApiAvailableReaction[];
topReactions?: ApiReaction[];
customEmojiSetsInfo?: ApiStickerSetInfo[];
customEmojiSets?: ApiStickerSet[];
noOptions?: boolean;
@ -106,8 +107,11 @@ type StateProps = {
threadId?: number;
};
const REACTION_PICKER_APPEARANCE_DURATION_MS = 250;
const ContextMenuContainer: FC<OwnProps & StateProps> = ({
availableReactions,
topReactions,
isOpen,
messageListType,
chatUsername,
@ -182,11 +186,13 @@ const ContextMenuContainer: FC<OwnProps & StateProps> = ({
requestMessageTranslation,
showOriginalMessage,
openMessageLanguageModal,
openReactionPicker,
} = getActions();
const lang = useLang();
const { transitionClassNames } = useShowTransition(isOpen, onCloseAnimationEnd, undefined, false);
const [isMenuOpen, setIsMenuOpen] = useState(true);
const [noAnimationOnClose, setNoAnimationOnClose] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isReportModalOpen, setIsReportModalOpen] = useState(false);
const [isPinModalOpen, setIsPinModalOpen] = useState(false);
@ -255,7 +261,8 @@ const ContextMenuContainer: FC<OwnProps & StateProps> = ({
setIsReportModalOpen(true);
}, []);
const closeMenu = useCallback(() => {
const closeMenu = useCallback((noCloseAnimation = false) => {
setNoAnimationOnClose(noCloseAnimation);
setIsMenuOpen(false);
onClose();
}, [onClose]);
@ -409,11 +416,18 @@ const ContextMenuContainer: FC<OwnProps & StateProps> = ({
const handleToggleReaction = useCallback((reaction: ApiReaction) => {
toggleReaction({
chatId: message.chatId, messageId: message.id, reaction,
chatId: message.chatId, messageId: message.id, reaction, shouldAddToRecent: true,
});
closeMenu();
}, [closeMenu, message, toggleReaction]);
const handleReactionPickerOpen = useCallback((position: IAnchorPosition) => {
openReactionPicker({ chatId: message.chatId, messageId: message.id, position });
setTimeout(() => {
closeMenu(true);
}, REACTION_PICKER_APPEARANCE_DURATION_MS);
}, [closeMenu, message.chatId, message.id]);
const handleTranslate = useCallback(() => {
requestMessageTranslation({
chatId: message.chatId,
@ -453,6 +467,7 @@ const ContextMenuContainer: FC<OwnProps & StateProps> = ({
<div className={buildClassName('ContextMenuContainer', transitionClassNames)}>
<MessageContextMenu
availableReactions={availableReactions}
topReactions={topReactions}
message={message}
isPrivate={isPrivate}
isCurrentUserPremium={isCurrentUserPremium}
@ -488,6 +503,7 @@ const ContextMenuContainer: FC<OwnProps & StateProps> = ({
canSelectLanguage={canSelectLanguage}
hasCustomEmoji={hasCustomEmoji}
customEmojiSets={customEmojiSets}
noTransition={noAnimationOnClose}
isDownloading={isDownloading}
seenByRecentUsers={seenByRecentUsers}
noReplies={noReplies}
@ -515,6 +531,7 @@ const ContextMenuContainer: FC<OwnProps & StateProps> = ({
onShowSeenBy={handleOpenSeenByModal}
onToggleReaction={handleToggleReaction}
onShowReactors={handleOpenReactorListModal}
onReactionPickerOpen={handleReactionPickerOpen}
onTranslate={handleTranslate}
onShowOriginal={handleShowOriginal}
onSelectLanguage={handleSelectLanguage}
@ -617,6 +634,7 @@ export default memo(withGlobal<OwnProps>(
return {
availableReactions: global.availableReactions,
topReactions: global.topReactions,
noOptions,
canSendNow: isScheduled,
canReschedule: isScheduled,

View File

@ -21,9 +21,21 @@
}
&.with-reactions .bubble {
margin-top: 3.5rem;
background: none;
backdrop-filter: none;
box-shadow: none;
padding: 3.5rem 0 0 !important;
}
&.with-reactions .scrollable-content {
background: var(--color-background-compact-menu);
backdrop-filter: blur(10px);
box-shadow: 0 0.25rem 0.5rem 0.125rem var(--color-default-shadow);
border-radius: var(--border-radius-default);
padding: 0.25rem 0;
}
.backdrop {
touch-action: none;
}

View File

@ -16,6 +16,7 @@ import type {
} from '../../../api/types';
import type { IAnchorPosition } from '../../../types';
import { REM } from '../../common/helpers/mediaDimensions';
import { getMessageCopyOptions } from './helpers/copyOptions';
import { disableScrolling, enableScrolling } from '../../../util/scrollLock';
import { getUserFullName } from '../../../global/helpers';
@ -23,7 +24,7 @@ import buildClassName from '../../../util/buildClassName';
import renderText from '../../common/helpers/renderText';
import useFlag from '../../../hooks/useFlag';
import useContextMenuPosition from '../../../hooks/useContextMenuPosition';
import useMenuPosition from '../../../hooks/useMenuPosition';
import useLang from '../../../hooks/useLang';
import useAppLayout from '../../../hooks/useAppLayout';
@ -38,6 +39,7 @@ import './MessageContextMenu.scss';
type OwnProps = {
availableReactions?: ApiAvailableReaction[];
topReactions?: ApiReaction[];
isOpen: boolean;
anchor: IAnchorPosition;
message: ApiMessage | ApiSponsoredMessage;
@ -76,44 +78,48 @@ type OwnProps = {
noReplies?: boolean;
hasCustomEmoji?: boolean;
customEmojiSets?: ApiStickerSet[];
onReply?: () => void;
noTransition?: boolean;
onReply?: NoneToVoidFunction;
onOpenThread?: VoidFunction;
onEdit?: () => void;
onPin?: () => void;
onUnpin?: () => void;
onForward?: () => void;
onDelete?: () => void;
onReport?: () => void;
onFaveSticker?: () => void;
onUnfaveSticker?: () => void;
onSelect?: () => void;
onSend?: () => void;
onReschedule?: () => void;
onClose: () => void;
onCloseAnimationEnd?: () => void;
onCopyLink?: () => void;
onEdit?: NoneToVoidFunction;
onPin?: NoneToVoidFunction;
onUnpin?: NoneToVoidFunction;
onForward?: NoneToVoidFunction;
onDelete?: NoneToVoidFunction;
onReport?: NoneToVoidFunction;
onFaveSticker?: NoneToVoidFunction;
onUnfaveSticker?: NoneToVoidFunction;
onSelect?: NoneToVoidFunction;
onSend?: NoneToVoidFunction;
onReschedule?: NoneToVoidFunction;
onClose: NoneToVoidFunction;
onCloseAnimationEnd?: NoneToVoidFunction;
onCopyLink?: NoneToVoidFunction;
onCopyMessages?: (messageIds: number[]) => void;
onCopyNumber?: () => void;
onDownload?: () => void;
onSaveGif?: () => void;
onCancelVote?: () => void;
onClosePoll?: () => void;
onShowSeenBy?: () => void;
onShowReactors?: () => void;
onAboutAds?: () => void;
onSponsoredHide?: () => void;
onTranslate?: () => void;
onShowOriginal?: () => void;
onSelectLanguage?: () => void;
onCopyNumber?: NoneToVoidFunction;
onDownload?: NoneToVoidFunction;
onSaveGif?: NoneToVoidFunction;
onCancelVote?: NoneToVoidFunction;
onClosePoll?: NoneToVoidFunction;
onShowSeenBy?: NoneToVoidFunction;
onShowReactors?: NoneToVoidFunction;
onAboutAds?: NoneToVoidFunction;
onSponsoredHide?: NoneToVoidFunction;
onTranslate?: NoneToVoidFunction;
onShowOriginal?: NoneToVoidFunction;
onSelectLanguage?: NoneToVoidFunction;
onToggleReaction?: (reaction: ApiReaction) => void;
onReactionPickerOpen?: (position: IAnchorPosition) => void;
};
const SCROLLBAR_WIDTH = 10;
const REACTION_BUBBLE_EXTRA_WIDTH = 32;
const REACTION_SELECTOR_WIDTH_REM = 19.25;
const ANIMATION_DURATION = 200;
const MessageContextMenu: FC<OwnProps> = ({
availableReactions,
topReactions,
isOpen,
message,
isPrivate,
@ -152,6 +158,7 @@ const MessageContextMenu: FC<OwnProps> = ({
seenByRecentUsers,
hasCustomEmoji,
customEmojiSets,
noTransition,
onReply,
onOpenThread,
onEdit,
@ -179,6 +186,7 @@ const MessageContextMenu: FC<OwnProps> = ({
onCopyMessages,
onAboutAds,
onSponsoredHide,
onReactionPickerOpen,
onTranslate,
onShowOriginal,
onSelectLanguage,
@ -255,6 +263,8 @@ const MessageContextMenu: FC<OwnProps> = ({
extraTopPadding: (document.querySelector<HTMLElement>('.MiddleHeader')!).offsetHeight,
marginSides: withReactions ? REACTION_BUBBLE_EXTRA_WIDTH : undefined,
extraMarginTop: extraHeightPinned + extraHeightAudioPlayer,
shouldAvoidNegativePosition: true,
menuElMinWidth: withReactions && isMobile ? REACTION_SELECTOR_WIDTH_REM * REM : undefined,
};
}, [isMobile, withReactions]);
@ -271,7 +281,7 @@ const MessageContextMenu: FC<OwnProps> = ({
const {
positionX, positionY, transformOriginX, transformOriginY, style, menuStyle, withScroll,
} = useContextMenuPosition(anchor, getTriggerElement, getRootElement, getMenuElement, getLayout);
} = useMenuPosition(anchor, getTriggerElement, getRootElement, getMenuElement, getLayout);
useEffect(() => {
disableScrolling(withScroll ? scrollableRef.current : undefined, '.ReactionSelector');
@ -292,20 +302,23 @@ const MessageContextMenu: FC<OwnProps> = ({
className={buildClassName(
'MessageContextMenu', 'fluid', withReactions && 'with-reactions',
)}
shouldSkipTransition={noTransition}
onClose={onClose}
onCloseAnimationEnd={onCloseAnimationEnd}
>
{withReactions && (
<ReactionSelector
enabledReactions={enabledReactions}
topReactions={topReactions}
allAvailableReactions={availableReactions}
currentReactions={!isSponsoredMessage ? message.reactions?.results : undefined}
maxUniqueReactions={maxUniqueReactions}
onToggleReaction={onToggleReaction!}
isPrivate={isPrivate}
availableReactions={availableReactions}
isReady={isReady}
canBuyPremium={canBuyPremium}
isCurrentUserPremium={isCurrentUserPremium}
onShowMore={onReactionPickerOpen!}
/>
)}

View File

@ -0,0 +1,21 @@
import React, { memo } from '../../../lib/teact/teact';
import type { FC } from '../../../lib/teact/teact';
import type { OwnProps } from './ReactionPicker';
import { Bundles } from '../../../util/moduleLoader';
import useModuleLoader from '../../../hooks/useModuleLoader';
interface LocalOwnProps {
shouldLoad?: boolean;
}
const ReactionPickerAsync: FC<OwnProps & LocalOwnProps> = (props) => {
const { isOpen, shouldLoad } = props;
const ReactionPicker = useModuleLoader(Bundles.Extra, 'ReactionPicker', !isOpen && !shouldLoad);
// eslint-disable-next-line react/jsx-props-no-spreading
return ReactionPicker ? <ReactionPicker {...props} /> : undefined;
};
export default memo(ReactionPickerAsync);

View File

@ -0,0 +1,49 @@
.menu {
position: absolute;
z-index: var(--z-reaction-picker);
@media (max-width: 600px) {
max-width: 100%;
left: 0 !important;
right: 0 !important;
}
}
.menuContent {
width: 26.25rem;
height: 26.25rem;
padding: 0 !important;
transform-origin: 50% 3.5rem !important;
&:global(.bubble) {
transform: scale(0.7) !important;
transition: opacity 140ms cubic-bezier(0.2, 0, 0.2, 1), transform 140ms cubic-bezier(0.2, 0, 0.2, 1) !important;
}
&:global(.bubble.open) {
transform: scale(1) !important;
}
@media (max-width: 440px) {
max-width: min(calc(100% - 1rem), 26.25rem);
left: 50% !important;
right: auto !important;
&:global(.bubble) {
transform: scale(0.5) translateX(-50%) !important;
transform-origin: 0 3.5rem !important;
}
&:global(.bubble.open) {
transform: scale(1) translateX(-50%) !important;
}
}
}
.onlyReactions {
height: auto;
}
.hidden {
display: none !important;
}

View File

@ -0,0 +1,191 @@
import React, {
memo, useCallback, useMemo, useRef,
} from '../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../global';
import type { FC } from '../../../lib/teact/teact';
import type {
ApiMessage, ApiReaction, ApiSticker, ApiReactionCustomEmoji,
} from '../../../api/types';
import type { IAnchorPosition } from '../../../types';
import buildClassName from '../../../util/buildClassName';
import { isUserId } from '../../../global/helpers';
import { selectChat, selectChatMessage, selectTabState } from '../../../global/selectors';
import useCurrentOrPrev from '../../../hooks/useCurrentOrPrev';
import useMenuPosition from '../../../hooks/useMenuPosition';
import CustomEmojiPicker from '../../common/CustomEmojiPicker';
import ReactionPickerLimited from './ReactionPickerLimited';
import Menu from '../../ui/Menu';
import styles from './ReactionPicker.module.scss';
export type OwnProps = {
isOpen: boolean;
};
interface StateProps {
withCustomReactions?: boolean;
message?: ApiMessage;
position?: IAnchorPosition;
}
const FULL_PICKER_SHIFT_DELTA = { x: -30, y: -66 };
const LIMITED_PICKER_SHIFT_DELTA = { x: -25, y: -10 };
const ReactionPicker: FC<OwnProps & StateProps> = ({
isOpen,
message,
position,
withCustomReactions,
}) => {
const { toggleReaction, closeReactionPicker } = getActions();
// eslint-disable-next-line no-null/no-null
const scrollHeaderRef = useRef<HTMLDivElement>(null);
// eslint-disable-next-line no-null/no-null
const scrollContainerRef = useRef<HTMLDivElement>(null);
// eslint-disable-next-line no-null/no-null
const limitedScrollContainerRef = useRef<HTMLDivElement>(null);
const renderedMessageId = useCurrentOrPrev(message?.id, true);
const renderedChatId = useCurrentOrPrev(message?.chatId, true);
const storedPosition = useCurrentOrPrev(position, true);
// eslint-disable-next-line no-null/no-null
const menuRef = useRef<HTMLDivElement>(null);
const renderingPosition = useMemo((): IAnchorPosition | undefined => {
if (!storedPosition) {
return undefined;
}
return {
x: storedPosition.x + (withCustomReactions ? FULL_PICKER_SHIFT_DELTA.x : LIMITED_PICKER_SHIFT_DELTA.x),
y: storedPosition.y + (withCustomReactions ? FULL_PICKER_SHIFT_DELTA.y : LIMITED_PICKER_SHIFT_DELTA.y),
};
}, [storedPosition, withCustomReactions]);
const getMenuElement = useCallback(() => menuRef.current, []);
const getLayout = useCallback(() => ({ withPortal: true, isDense: true }), []);
const {
positionX, positionY, transformOriginX, transformOriginY, style,
} = useMenuPosition(renderingPosition, getTriggerElement, getRootElement, getMenuElement, getLayout);
const handleResetScrollPosition = useCallback(() => {
if (scrollContainerRef.current) {
scrollContainerRef.current.scrollTop = 0;
}
if (scrollHeaderRef.current) {
scrollHeaderRef.current.scrollLeft = 0;
}
if (limitedScrollContainerRef.current) {
limitedScrollContainerRef.current.scrollTop = 0;
}
}, []);
const handleToggleCustomReaction = useCallback((sticker: ApiSticker) => {
if (!renderedChatId || !renderedMessageId) {
return;
}
const reaction = sticker.isCustomEmoji
? { documentId: sticker.id } as ApiReactionCustomEmoji
: { emoticon: sticker.emoji } as ApiReaction;
toggleReaction({
chatId: renderedChatId, messageId: renderedMessageId, reaction, shouldAddToRecent: true,
});
closeReactionPicker();
}, [renderedChatId, renderedMessageId]);
const handleToggleReaction = useCallback((reaction: ApiReaction) => {
if (!renderedChatId || !renderedMessageId) {
return;
}
toggleReaction({
chatId: renderedChatId, messageId: renderedMessageId, reaction, shouldAddToRecent: true,
});
closeReactionPicker();
}, [renderedChatId, renderedMessageId]);
const selectedReactionIds = useMemo(() => {
return (message?.reactions?.results || []).reduce<string[]>((acc, { chosenOrder, reaction }) => {
if (chosenOrder !== undefined) {
acc.push('emoticon' in reaction ? reaction.emoticon : reaction.documentId);
}
return acc;
}, []);
}, [message?.reactions?.results]);
const bubbleFullClassName = buildClassName(
styles.menuContent,
!withCustomReactions && styles.onlyReactions,
);
return (
<Menu
isOpen={isOpen}
ref={menuRef}
className={styles.menu}
bubbleClassName={bubbleFullClassName}
withPortal
noCompact
positionX={positionX}
positionY={positionY}
transformOriginX={transformOriginX}
transformOriginY={transformOriginY}
style={style}
onClose={closeReactionPicker}
onCloseAnimationEnd={handleResetScrollPosition}
>
<CustomEmojiPicker
idPrefix="message-emoji-set-"
loadAndPlay={isOpen}
isReactionPicker
className={!withCustomReactions ? styles.hidden : undefined}
scrollHeaderRef={scrollHeaderRef}
scrollContainerRef={scrollContainerRef}
onCustomEmojiSelect={handleToggleCustomReaction}
onReactionSelect={handleToggleReaction}
selectedReactionIds={selectedReactionIds}
/>
{!withCustomReactions && Boolean(renderedChatId) && (
<ReactionPickerLimited
chatId={renderedChatId}
loadAndPlay={isOpen}
scrollContainerRef={limitedScrollContainerRef}
onReactionSelect={handleToggleReaction}
selectedReactionIds={selectedReactionIds}
/>
)}
</Menu>
);
};
export default memo(withGlobal<OwnProps>((global): StateProps => {
const state = selectTabState(global);
const { chatId, messageId, position } = state.reactionPicker || {};
const chat = chatId ? selectChat(global, chatId) : undefined;
const message = chatId && messageId ? selectChatMessage(global, chatId, messageId) : undefined;
const isPrivateChat = chatId ? isUserId(chatId) : false;
const areSomeReactionsAllowed = chat?.fullInfo?.enabledReactions?.type === 'some';
const areCustomReactionsAllowed = chat?.fullInfo?.enabledReactions?.type === 'all'
&& chat?.fullInfo?.enabledReactions?.areCustomAllowed;
return {
message,
position,
withCustomReactions: chat?.isForbidden || areSomeReactionsAllowed
? false
: areCustomReactionsAllowed || isPrivateChat,
};
})(ReactionPicker));
function getTriggerElement(): HTMLElement | null {
return document.querySelector('body');
}
function getRootElement() {
return document.querySelector('body');
}

View File

@ -0,0 +1,11 @@
.root {
--emoji-size: 2.5rem;
}
.wrapper {
position: relative;
height: auto;
max-height: 18rem;
overflow-y: auto;
padding: 0.5rem 0.25rem;
}

View File

@ -0,0 +1,135 @@
import type { RefObject } from 'react';
import React, { memo, useRef, useMemo } from '../../../lib/teact/teact';
import { withGlobal } from '../../../global';
import type { FC } from '../../../lib/teact/teact';
import type { ApiReaction, ApiAvailableReaction, ApiChatReactions } from '../../../api/types';
import { REM } from '../../common/helpers/mediaDimensions';
import buildClassName from '../../../util/buildClassName';
import { getReactionUniqueKey, sortReactions } from '../../../global/helpers';
import { selectChat } from '../../../global/selectors';
import useWindowSize from '../../../hooks/useWindowSize';
import useAppLayout from '../../../hooks/useAppLayout';
import ReactionEmoji from '../../common/ReactionEmoji';
import styles from './ReactionPickerLimited.module.scss';
type OwnProps = {
scrollContainerRef?: RefObject<HTMLDivElement>;
chatId: string;
loadAndPlay: boolean;
onReactionSelect?: (reaction: ApiReaction) => void;
selectedReactionIds?: string[];
};
type StateProps = {
enabledReactions?: ApiChatReactions;
availableReactions?: ApiAvailableReaction[];
topReactions: ApiReaction[];
canAnimate?: boolean;
isSavedMessages?: boolean;
isCurrentUserPremium?: boolean;
};
const REACTION_SIZE = 40;
const GRID_GAP_THRESHOLD = 600;
const MODAL_PADDING_SIZE_REM = 0.5;
const MODAL_MAX_HEIGHT_REM = 18;
const MODAL_MAX_WIDTH_REM = 26.25;
const GRID_GAP_DESKTOP_REM = 0.625;
const GRID_GAP_MOBILE_REM = 0.5;
const ReactionPickerLimited: FC<OwnProps & StateProps> = ({
scrollContainerRef,
loadAndPlay,
enabledReactions,
availableReactions,
topReactions,
selectedReactionIds,
onReactionSelect,
}) => {
// eslint-disable-next-line no-null/no-null
let containerRef = useRef<HTMLDivElement>(null);
// eslint-disable-next-line no-null/no-null
const sharedCanvasRef = useRef<HTMLCanvasElement>(null);
// eslint-disable-next-line no-null/no-null
const sharedCanvasHqRef = useRef<HTMLCanvasElement>(null);
const { width: windowWidth } = useWindowSize();
const { isTouchScreen } = useAppLayout();
if (scrollContainerRef) {
containerRef = scrollContainerRef;
}
const allAvailableReactions = useMemo(() => {
if (!enabledReactions) {
return [];
}
if (enabledReactions.type === 'all') {
return sortReactions((availableReactions || []).map(({ reaction }) => reaction), topReactions);
}
return sortReactions(enabledReactions.allowed, topReactions);
}, [availableReactions, enabledReactions, topReactions]);
const pickerHeight = useMemo(() => {
const pickerWidth = Math.min(MODAL_MAX_WIDTH_REM * REM, windowWidth);
const gapWidth = (windowWidth > GRID_GAP_THRESHOLD ? GRID_GAP_DESKTOP_REM : GRID_GAP_MOBILE_REM) * REM;
const availableWidth = pickerWidth - MODAL_PADDING_SIZE_REM * REM;
const itemsInRow = Math.floor((availableWidth + gapWidth) / (REACTION_SIZE + gapWidth));
const rowsCount = Math.ceil(allAvailableReactions.length / itemsInRow);
const pickerMaxHeight = rowsCount * REACTION_SIZE + (rowsCount + 1) * gapWidth + MODAL_PADDING_SIZE_REM * REM;
return Math.min(MODAL_MAX_HEIGHT_REM * REM, pickerMaxHeight);
}, [allAvailableReactions.length, windowWidth]);
return (
<div className={styles.root} style={`height: ${pickerHeight}px`}>
<div
ref={containerRef}
className={buildClassName(styles.wrapper, 'no-selection', isTouchScreen ? 'no-scrollbar' : 'custom-scroll')}
>
<div className="symbol-set-container shared-canvas-container">
<canvas ref={sharedCanvasRef} className="shared-canvas" />
<canvas ref={sharedCanvasHqRef} className="shared-canvas" />
{allAvailableReactions.map((reaction) => {
const reactionId = getReactionUniqueKey(reaction);
const isSelected = reactionId ? selectedReactionIds?.includes(reactionId) : undefined;
return (
<ReactionEmoji
key={reactionId}
reaction={reaction}
isSelected={isSelected}
loadAndPlay={loadAndPlay}
availableReactions={availableReactions}
onClick={onReactionSelect!}
sharedCanvasRef={sharedCanvasRef}
sharedCanvasHqRef={sharedCanvasHqRef}
/>
);
})}
</div>
</div>
</div>
);
};
export default memo(withGlobal<OwnProps>(
(global, { chatId }): StateProps => {
const chat = selectChat(global, chatId);
const { availableReactions, topReactions } = global;
const { enabledReactions } = chat?.fullInfo || {};
return {
enabledReactions,
availableReactions,
topReactions,
};
},
)(ReactionPickerLimited));

View File

@ -1,103 +1,122 @@
.ReactionSelector {
position: absolute;
height: 3rem;
background: var(--color-background);
height: 2.5rem;
min-width: 3rem;
max-width: calc(100% + 5rem);
max-width: calc(100% + 4rem);
z-index: 100;
border-radius: 3rem;
filter: drop-shadow(0 0.25rem 0.125rem var(--color-default-shadow));
right: -3rem;
top: -3.5rem;
right: -2rem;
top: 0.5rem;
&--isRtl {
right: auto;
left: -3rem;
}
@media (max-width: 600px) {
left: 0;
right: 0;
display: flex;
justify-content: center;
}
&--withBlur {
background: none;
filter: none;
.ReactionSelector__bubble-big,
.ReactionSelector__bubble-small,
.ReactionSelector__items-wrapper {
background: var(--color-background-compact-menu);
backdrop-filter: blur(10px);
}
}
.ReactionSelector__bubble-big,
.ReactionSelector__bubble-small,
.ReactionSelector__items-wrapper {
filter: drop-shadow(0 0.25rem 0.125rem var(--color-default-shadow));
body.is-safari & {
filter: none;
box-shadow: 0 0.25rem 0.125rem var(--color-default-shadow);
}
}
&__bubble-big {
border: 0.5rem solid var(--color-background);
background: var(--color-background);
position: absolute;
display: block;
content: "";
right: 1.5rem;
right: 1.125rem;
bottom: -0.5rem;
width: 1rem;
height: 1rem;
height: 0.5rem;
border-top: 0;
border-left: 0;
border-right: 0;
border-radius: 0 0 50% 50%;
border-radius: 0 0 1rem 1rem;
z-index: -1;
&--isRtl {
right: auto;
left: 1.5rem;
}
@media (max-width: 600px) {
display: none;
}
}
&__bubble-small {
position: absolute;
display: block;
content: "";
right: 1.25rem;
right: 1.125rem;
bottom: -1.25rem;
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
background: var(--color-background);
}
body.is-safari & {
filter: none;
box-shadow: 0 0.25rem 0.125rem var(--color-default-shadow);
}
&--isRtl {
right: auto;
left: 2.125rem;
}
body.is-safari &__bubble-small,
body.is-safari &__bubble-big {
box-shadow: 0 0.25rem 0.125rem var(--color-default-shadow);
@media (max-width: 600px) {
display: none;
}
}
&__items-wrapper {
width: 100%;
height: 100%;
overflow: hidden;
border-radius: 3rem;
background: var(--color-background);
@media (max-width: 600px) {
width: fit-content;
}
}
&__items {
padding: 0 1rem;
padding: 0 0.5rem;
width: 100%;
height: 100%;
overflow-x: auto;
overflow: overlay;
overflow-y: hidden;
display: flex;
cursor: pointer;
align-items: center;
border-radius: 3rem;
}
&--compact {
background: var(--color-background-compact-menu-reactions);
&__show-more {
width: 2rem;
height: 2rem;
top: -2.5rem;
}
&--compact &__items {
padding: 0 0.5rem;
}
&--compact &__bubble-big {
border-color: var(--color-background-compact-menu-reactions);
}
&--compact &__bubble-small {
background: var(--color-background-compact-menu-reactions);
}
&__blocked-button {
width: 2rem !important;
height: 2rem !important;
margin-left: 0.5rem !important;
}
&--compact &__blocked-button {
width: 1.5rem !important;
height: 1.5rem !important;
i {
font-size: 1.25rem;
}
padding: 0;
margin-inline-start: 0.25rem;
margin-inline-end: -0.125rem;
border-radius: 50%;
font-size: 1.5rem;
}
}

View File

@ -1,20 +1,22 @@
import React, {
memo, useMemo, useRef,
memo, useCallback, useMemo, useRef,
} from '../../../lib/teact/teact';
import type { FC } from '../../../lib/teact/teact';
import type {
ApiAvailableReaction, ApiChatReactions, ApiReaction, ApiReactionCount,
} from '../../../api/types';
import type { IAnchorPosition } from '../../../types';
import { getTouchY } from '../../../util/scrollLock';
import { createClassNameBuilder } from '../../../util/buildClassName';
import { IS_COMPACT_MENU } from '../../../util/windowEnvironment';
import { isSameReaction, canSendReaction, getReactionUniqueKey } from '../../../global/helpers';
import useHorizontalScroll from '../../../hooks/useHorizontalScroll';
import {
isSameReaction, canSendReaction, getReactionUniqueKey, sortReactions,
} from '../../../global/helpers';
import useAppLayout from '../../../hooks/useAppLayout';
import useLang from '../../../hooks/useLang';
import ReactionSelectorReaction from './ReactionSelectorReaction';
import Button from '../../ui/Button';
import './ReactionSelector.scss';
@ -22,39 +24,37 @@ type OwnProps = {
enabledReactions?: ApiChatReactions;
onToggleReaction: (reaction: ApiReaction) => void;
isPrivate?: boolean;
availableReactions?: ApiAvailableReaction[];
topReactions?: ApiReaction[];
allAvailableReactions?: ApiAvailableReaction[];
currentReactions?: ApiReactionCount[];
maxUniqueReactions?: number;
isReady?: boolean;
canBuyPremium?: boolean;
isCurrentUserPremium?: boolean;
onShowMore: (position: IAnchorPosition) => void;
};
const cn = createClassNameBuilder('ReactionSelector');
const REACTIONS_AMOUNT = 6;
const ReactionSelector: FC<OwnProps> = ({
availableReactions,
allAvailableReactions,
topReactions,
enabledReactions,
currentReactions,
maxUniqueReactions,
isPrivate,
isReady,
onToggleReaction,
onShowMore,
}) => {
// eslint-disable-next-line no-null/no-null
const itemsScrollRef = useRef<HTMLDivElement>(null);
useHorizontalScroll(itemsScrollRef);
const ref = useRef<HTMLDivElement>(null);
const { isTouchScreen } = useAppLayout();
const lang = useLang();
const handleWheel = (e: React.WheelEvent | React.TouchEvent) => {
const deltaY = 'deltaY' in e ? e.deltaY : getTouchY(e);
if (deltaY && e.cancelable) {
e.preventDefault();
}
};
const reactionsToRender = useMemo(() => {
return availableReactions?.map((availableReaction) => {
const availableReactions = useMemo(() => {
const reactions = allAvailableReactions?.map((availableReaction) => {
if (availableReaction.isInactive) return undefined;
if (!isPrivate && (!enabledReactions || !canSendReaction(availableReaction.reaction, enabledReactions))) {
return undefined;
@ -64,8 +64,17 @@ const ReactionSelector: FC<OwnProps> = ({
return undefined;
}
return availableReaction;
}) || [];
}, [availableReactions, currentReactions, enabledReactions, isPrivate, maxUniqueReactions]);
}).filter(Boolean) || [];
return sortReactions(reactions, topReactions);
}, [allAvailableReactions, currentReactions, enabledReactions, isPrivate, maxUniqueReactions, topReactions]);
const reactionsToRender = useMemo(() => {
return availableReactions.length === REACTIONS_AMOUNT + 1
? availableReactions
: availableReactions.slice(0, REACTIONS_AMOUNT);
}, [availableReactions]);
const withMoreButton = reactionsToRender.length < availableReactions.length;
const userReactionIndexes = useMemo(() => {
const chosenReactions = currentReactions?.filter(({ chosenOrder }) => chosenOrder !== undefined) || [];
@ -74,27 +83,40 @@ const ReactionSelector: FC<OwnProps> = ({
)));
}, [currentReactions, reactionsToRender]);
const handleShowMoreClick = useCallback(() => {
const bound = ref.current?.getBoundingClientRect() || { x: 0, y: 0 };
onShowMore({
x: bound.x,
y: bound.y,
});
}, [onShowMore]);
if (!reactionsToRender.length) return undefined;
return (
<div className={cn('&', IS_COMPACT_MENU && 'compact')} onWheelCapture={handleWheel} onTouchMove={handleWheel}>
<div className={cn('bubble-big')} />
<div className={cn('bubble-small')} />
<div className={cn('&', !isTouchScreen && 'withBlur', lang.isRtl && 'isRtl')} ref={ref}>
<div className={cn('bubble-small', lang.isRtl && 'isRtl')} />
<div className={cn('items-wrapper')}>
<div className={cn('items', ['no-scrollbar'])} ref={itemsScrollRef}>
{reactionsToRender.map((reaction, i) => {
if (!reaction) return undefined;
return (
<ReactionSelectorReaction
key={getReactionUniqueKey(reaction.reaction)}
previewIndex={i}
isReady={isReady}
onToggleReaction={onToggleReaction}
reaction={reaction}
chosen={userReactionIndexes.has(i)}
/>
);
})}
<div className={cn('bubble-big', lang.isRtl && 'isRtl')} />
<div className={cn('items')} dir={lang.isRtl ? 'rtl' : undefined}>
{reactionsToRender.map((reaction, i) => (
<ReactionSelectorReaction
key={getReactionUniqueKey(reaction.reaction)}
isReady={isReady}
onToggleReaction={onToggleReaction}
reaction={reaction}
chosen={userReactionIndexes.has(i)}
/>
))}
{withMoreButton && (
<Button
color="translucent"
className={cn('show-more')}
onClick={handleShowMoreClick}
>
<i className="icon-down" />
</Button>
)}
</div>
</div>
</div>

View File

@ -1,22 +1,11 @@
.ReactionSelectorReaction {
margin-left: 0.5rem;
margin-inline-start: 0.25rem;
position: relative;
min-width: 2rem;
min-height: 2rem;
&:first-child {
margin-left: 0;
}
&__static {
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
background-image: url('../../../assets/reaction-thumbs.png');
background-repeat: no-repeat;
background-size: auto 100%;
margin-inline-start: 0;
}
.AnimatedSticker {
@ -36,8 +25,8 @@
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 120%;
height: 120%;
width: 2.375rem;
height: 2.375rem;
border-radius: 50%;
background-color: var(--color-background-compact-menu-hover);
}

View File

@ -3,7 +3,6 @@ import React, { memo } from '../../../lib/teact/teact';
import type { FC } from '../../../lib/teact/teact';
import type { ApiAvailableReaction, ApiReaction } from '../../../api/types';
import { IS_COMPACT_MENU } from '../../../util/windowEnvironment';
import { createClassNameBuilder } from '../../../util/buildClassName';
import useMedia from '../../../hooks/useMedia';
import useFlag from '../../../hooks/useFlag';
@ -12,11 +11,10 @@ import AnimatedSticker from '../../common/AnimatedSticker';
import './ReactionSelectorReaction.scss';
const REACTION_SIZE = IS_COMPACT_MENU ? 24 : 32;
const REACTION_SIZE = 32;
type OwnProps = {
reaction: ApiAvailableReaction;
previewIndex: number;
isReady?: boolean;
chosen?: boolean;
onToggleReaction: (reaction: ApiReaction) => void;
@ -26,18 +24,16 @@ const cn = createClassNameBuilder('ReactionSelectorReaction');
const ReactionSelectorReaction: FC<OwnProps> = ({
reaction,
previewIndex,
isReady,
chosen,
onToggleReaction,
}) => {
const mediaAppearData = useMedia(`sticker${reaction.appearAnimation?.id}`, !isReady);
const mediaData = useMedia(`document${reaction.selectAnimation?.id}`, !isReady);
const [isActivated, activate, deactivate] = useFlag();
const [isAnimationLoaded, markAnimationLoaded] = useFlag();
const shouldRenderStatic = !isReady || !isAnimationLoaded;
const shouldRenderAnimated = Boolean(isReady && mediaData);
const [isFirstPlay, , unmarkIsFirstPlay] = useFlag(true);
const [isActivated, activate, deactivate] = useFlag();
function handleClick() {
onToggleReaction(reaction.reaction);
@ -45,18 +41,23 @@ const ReactionSelectorReaction: FC<OwnProps> = ({
return (
<div
className={cn('&', IS_COMPACT_MENU && 'compact', chosen && 'chosen')}
className={cn('&', chosen && 'chosen')}
onClick={handleClick}
onMouseEnter={isReady ? activate : undefined}
onMouseEnter={isReady && !isFirstPlay ? activate : undefined}
>
{shouldRenderStatic && (
<div
className={cn('static')}
style={`background-position-x: ${previewIndex * -REACTION_SIZE}px;`}
{!isAnimationLoaded && (
<AnimatedSticker
key={reaction.appearAnimation?.id}
tgsUrl={mediaAppearData}
play={isFirstPlay}
noLoop
size={REACTION_SIZE}
onEnded={unmarkIsFirstPlay}
/>
)}
{shouldRenderAnimated && (
{!isFirstPlay && (
<AnimatedSticker
key={reaction.selectAnimation?.id}
tgsUrl={mediaData}
play={isActivated}
noLoop

View File

@ -21,7 +21,7 @@ import TopicIcon from '../common/TopicIcon';
import InputText from '../ui/InputText';
import FloatingActionButton from '../ui/FloatingActionButton';
import Spinner from '../ui/Spinner';
import CustomEmojiPicker from '../middle/composer/CustomEmojiPicker';
import CustomEmojiPicker from '../common/CustomEmojiPicker';
import Transition from '../ui/Transition';
import styles from './ManageTopic.module.scss';

View File

@ -20,7 +20,7 @@ import InputText from '../ui/InputText';
import FloatingActionButton from '../ui/FloatingActionButton';
import Spinner from '../ui/Spinner';
import Loading from '../ui/Loading';
import CustomEmojiPicker from '../middle/composer/CustomEmojiPicker';
import CustomEmojiPicker from '../common/CustomEmojiPicker';
import Transition from '../ui/Transition';
import styles from './ManageTopic.module.scss';

View File

@ -7,7 +7,7 @@ import { fastRaf } from '../../util/schedulers';
import buildClassName from '../../util/buildClassName';
import useContextMenuHandlers from '../../hooks/useContextMenuHandlers';
import useContextMenuPosition from '../../hooks/useContextMenuPosition';
import useMenuPosition from '../../hooks/useMenuPosition';
import useFlag from '../../hooks/useFlag';
import useLang from '../../hooks/useLang';
@ -124,7 +124,7 @@ const ListItem: FC<OwnProps> = ({
const {
positionX, positionY, transformOriginX, transformOriginY, style: menuStyle,
} = useContextMenuPosition(
} = useMenuPosition(
contextMenuPosition,
getTriggerElement,
getRootElement,

View File

@ -2,17 +2,18 @@ import type { RefObject } from 'react';
import type { FC } from '../../lib/teact/teact';
import React, { memo, useEffect, useRef } from '../../lib/teact/teact';
import useShowTransition from '../../hooks/useShowTransition';
import useKeyboardListNavigation from '../../hooks/useKeyboardListNavigation';
import useVirtualBackdrop from '../../hooks/useVirtualBackdrop';
import useEffectWithPrevDeps from '../../hooks/useEffectWithPrevDeps';
import { IS_BACKDROP_BLUR_SUPPORTED } from '../../util/windowEnvironment';
import captureEscKeyListener from '../../util/captureEscKeyListener';
import buildClassName from '../../util/buildClassName';
import buildStyle from '../../util/buildStyle';
import { dispatchHeavyAnimationEvent } from '../../hooks/useHeavyAnimationCheck';
import useHistoryBack from '../../hooks/useHistoryBack';
import { preventMessageInputBlurWithBubbling } from '../middle/helpers/preventMessageInputBlur';
import { IS_BACKDROP_BLUR_SUPPORTED, IS_COMPACT_MENU } from '../../util/windowEnvironment';
import useShowTransition from '../../hooks/useShowTransition';
import useKeyboardListNavigation from '../../hooks/useKeyboardListNavigation';
import useVirtualBackdrop from '../../hooks/useVirtualBackdrop';
import useEffectWithPrevDeps from '../../hooks/useEffectWithPrevDeps';
import useHistoryBack from '../../hooks/useHistoryBack';
import useAppLayout from '../../hooks/useAppLayout';
import Portal from './Portal';
@ -82,6 +83,7 @@ const Menu: FC<OwnProps> = ({
menuRef = ref;
}
const backdropContainerRef = containerRef || menuRef;
const { isTouchScreen } = useAppLayout();
const {
transitionClassNames,
@ -135,7 +137,7 @@ const Menu: FC<OwnProps> = ({
id={id}
className={buildClassName(
'Menu no-selection',
!noCompact && IS_COMPACT_MENU && 'compact',
!noCompact && !isTouchScreen && 'compact',
!IS_BACKDROP_BLUR_SUPPORTED && 'no-blur',
className,
)}

View File

@ -4,7 +4,7 @@ import React, { useCallback } from '../../lib/teact/teact';
import { IS_TEST } from '../../config';
import buildClassName from '../../util/buildClassName';
import useLang from '../../hooks/useLang';
import { IS_COMPACT_MENU } from '../../util/windowEnvironment';
import useAppLayout from '../../hooks/useAppLayout';
import './MenuItem.scss';
@ -42,6 +42,7 @@ const MenuItem: FC<MenuItemProps> = (props) => {
} = props;
const lang = useLang();
const { isTouchScreen } = useAppLayout();
const handleClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (disabled || !onClick) {
e.stopPropagation();
@ -73,7 +74,7 @@ const MenuItem: FC<MenuItemProps> = (props) => {
className,
disabled && 'disabled',
destructive && 'destructive',
IS_COMPACT_MENU && 'compact',
!isTouchScreen && 'compact',
withWrap && 'wrap',
);

View File

@ -79,6 +79,14 @@ export const PROFILE_SENSITIVE_AREA = 500;
export const TOPIC_LIST_SENSITIVE_AREA = 600;
export const COMMON_CHATS_LIMIT = 100;
export const GROUP_CALL_PARTICIPANTS_LIMIT = 100;
// As in Telegram for Android
// https://github.com/DrKLO/Telegram/blob/51e9947527/TMessagesProj/src/main/java/org/telegram/messenger/MediaDataController.java#L7799
export const TOP_REACTIONS_LIMIT = 100;
// As in Telegram for Android
// https://github.com/DrKLO/Telegram/blob/51e9947527/TMessagesProj/src/main/java/org/telegram/messenger/MediaDataController.java#L7781
export const RECENT_REACTIONS_LIMIT = 50;
export const REACTION_LIST_LIMIT = 100;
export const REACTION_UNREAD_SLICE = 100;
export const MENTION_UNREAD_SLICE = 100;
@ -172,6 +180,8 @@ export const RECENT_STICKERS_LIMIT = 20;
export const RECENT_STATUS_LIMIT = 20;
export const EMOJI_STATUS_LOOP_LIMIT = 2;
export const EMOJI_SIZES = 7;
export const TOP_SYMBOL_SET_ID = 'top';
export const POPULAR_SYMBOL_SET_ID = 'popular';
export const RECENT_SYMBOL_SET_ID = 'recent';
export const FAVORITE_SYMBOL_SET_ID = 'favorite';
export const CHAT_STICKER_SET_ID = 'chatStickers';

View File

@ -11,6 +11,7 @@ import './ui/payments';
import './ui/calls';
import './ui/mediaViewer';
import './ui/passcode';
import './ui/reactions';
import './api/initial';
import './api/chats';

View File

@ -1,7 +1,10 @@
import { addActionHandler, getGlobal, setGlobal } from '../../index';
import { callApi } from '../../../api/gramjs';
import * as mediaLoader from '../../../util/mediaLoader';
import type { ActionReturnType } from '../../types';
import { ApiMediaFormat } from '../../../api/types';
import { ANIMATION_LEVEL_MAX } from '../../../config';
import {
selectChat,
selectChatMessage, selectCurrentChat, selectTabState,
@ -13,12 +16,11 @@ import { addMessageReaction, subtractXForEmojiInteraction, updateUnreadReactions
import {
addChatMessagesById, addChats, addUsers, updateChatMessage,
} from '../../reducers';
import { buildCollectionByKey, omit } from '../../../util/iteratees';
import { ANIMATION_LEVEL_MAX } from '../../../config';
import { isSameReaction, getUserReactions, isMessageLocal } from '../../helpers';
import type { ActionReturnType } from '../../types';
import { updateTabState } from '../../reducers/tabs';
import * as mediaLoader from '../../../util/mediaLoader';
import { buildCollectionByKey, omit } from '../../../util/iteratees';
import { getCurrentTabId } from '../../../util/establishMultitabRole';
import { isSameReaction, getUserReactions, isMessageLocal } from '../../helpers';
const INTERACTION_RANDOM_OFFSET = 40;
@ -38,6 +40,9 @@ addActionHandler('loadAvailableReactions', async (global): Promise<void> => {
if (availableReaction.centerIcon) {
mediaLoader.fetch(`sticker${availableReaction.centerIcon.id}`, ApiMediaFormat.BlobUrl);
}
if (availableReaction.appearAnimation) {
mediaLoader.fetch(`sticker${availableReaction.appearAnimation.id}`, ApiMediaFormat.BlobUrl);
}
});
global = getGlobal();
@ -104,15 +109,20 @@ addActionHandler('sendDefaultReaction', (global, actions, payload): ActionReturn
});
});
addActionHandler('toggleReaction', (global, actions, payload): ActionReturnType => {
const { chatId, reaction, tabId = getCurrentTabId() } = payload;
addActionHandler('toggleReaction', async (global, actions, payload): Promise<void> => {
const {
chatId,
reaction,
shouldAddToRecent,
tabId = getCurrentTabId(),
} = payload;
let { messageId } = payload;
const chat = selectChat(global, chatId);
let message = selectChatMessage(global, chatId, messageId);
if (!chat || !message) {
return undefined;
return;
}
const isInDocumentGroup = Boolean(message.groupedId) && !message.isInAlbum;
@ -131,14 +141,10 @@ addActionHandler('toggleReaction', (global, actions, payload): ActionReturnType
? userReactions.filter((userReaction) => !isSameReaction(userReaction, reaction)) : [...userReactions, reaction];
const limit = selectMaxUserReactions(global);
const reactions = newUserReactions.slice(-limit);
void callApi('sendReaction', { chat, messageId, reactions });
const { animationLevel } = global.settings.byKey;
const tabState = selectTabState(global, tabId);
if (animationLevel === ANIMATION_LEVEL_MAX) {
const newActiveReactions = hasReaction ? omit(tabState.activeReactions, [messageId]) : {
...tabState.activeReactions,
@ -155,7 +161,21 @@ addActionHandler('toggleReaction', (global, actions, payload): ActionReturnType
}, tabId);
}
return addMessageReaction(global, message, reactions);
global = addMessageReaction(global, message, reactions);
setGlobal(global);
try {
await callApi('sendReaction', {
chat,
messageId,
reactions,
shouldAddToRecent,
});
} catch (error) {
global = getGlobal();
global = addMessageReaction(global, message, userReactions);
setGlobal(global);
}
});
addActionHandler('stopActiveReaction', (global, actions, payload): ActionReturnType => {
@ -395,3 +415,45 @@ addActionHandler('readAllReactions', (global, actions, payload): ActionReturnTyp
unreadReactions: undefined,
});
});
addActionHandler('loadTopReactions', async (global): Promise<void> => {
const result = await callApi('fetchTopReactions', {});
if (!result) {
return;
}
global = getGlobal();
global = {
...global,
topReactions: result.reactions,
};
setGlobal(global);
});
addActionHandler('loadRecentReactions', async (global): Promise<void> => {
const result = await callApi('fetchRecentReactions', {});
if (!result) {
return;
}
global = getGlobal();
global = {
...global,
recentReactions: result.reactions,
};
setGlobal(global);
});
addActionHandler('clearRecentReactions', async (global): Promise<void> => {
const result = await callApi('clearRecentReactions');
if (!result) {
return;
}
global = getGlobal();
global = {
...global,
recentReactions: [],
};
setGlobal(global);
});

View File

@ -38,6 +38,10 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => {
actions.loadRecentStickers();
break;
case 'updateRecentReactions':
actions.loadRecentReactions();
break;
case 'updateRecentEmojiStatuses':
actions.loadRecentEmojiStatuses();
break;

View File

@ -0,0 +1,58 @@
import { addActionHandler } from '../../index';
import type { ActionReturnType } from '../../types';
import { getCurrentTabId } from '../../../util/establishMultitabRole';
import { updateTabState } from '../../reducers/tabs';
import { selectTabState } from '../../selectors';
addActionHandler('openChat', (global, actions, payload): ActionReturnType => {
const {
id,
tabId = getCurrentTabId(),
} = payload;
if (id) {
return updateTabState(global, {
reactionPicker: {
chatId: id,
messageId: undefined,
position: undefined,
},
}, tabId);
}
return updateTabState(global, {
reactionPicker: undefined,
}, tabId);
});
addActionHandler('openReactionPicker', (global, actions, payload): ActionReturnType => {
const {
chatId,
messageId,
position,
tabId = getCurrentTabId(),
} = payload!;
return updateTabState(global, {
reactionPicker: {
chatId,
messageId,
position,
},
}, tabId);
});
addActionHandler('closeReactionPicker', (global, actions, payload): ActionReturnType => {
const { tabId = getCurrentTabId() } = payload || {};
const tabState = selectTabState(global, tabId);
return updateTabState(global, {
reactionPicker: {
...tabState.reactionPicker,
messageId: undefined,
position: undefined,
},
}, tabId);
});

View File

@ -334,6 +334,8 @@ export function serializeGlobal<T extends GlobalState>(global: T) {
'topInlineBots',
'recentEmojis',
'recentCustomEmojis',
'topReactions',
'recentReactions',
'push',
'serviceNotifications',
'attachmentSettings',

View File

@ -4,6 +4,7 @@ import type {
ApiReaction,
ApiReactions,
ApiReactionCount,
ApiAvailableReaction,
} from '../../api/types';
import type { GlobalState } from '../types';
@ -49,6 +50,21 @@ export function canSendReaction(reaction: ApiReaction, chatReactions: ApiChatRea
return false;
}
export function sortReactions<T extends ApiAvailableReaction | ApiReaction>(
reactions: T[],
topReactions?: ApiReaction[],
): T[] {
return reactions.slice().sort((left, right) => {
const reactionOne = left ? ('reaction' in left ? left.reaction : left) as ApiReaction : undefined;
const reactionTwo = right ? ('reaction' in right ? right.reaction : right) as ApiReaction : undefined;
const indexOne = topReactions?.findIndex((reaction) => isSameReaction(reaction, reactionOne)) || 0;
const indexTwo = topReactions?.findIndex((reaction) => isSameReaction(reaction, reactionTwo)) || 0;
return (
(indexOne > -1 ? indexOne : Infinity) - (indexTwo > -1 ? indexTwo : Infinity)
);
});
}
export function getUserReactions(message: ApiMessage): ApiReaction[] {
return message.reactions?.results?.filter((r): r is Required<ApiReactionCount> => isReactionChosen(r))
.sort((a, b) => a.chosenOrder - b.chosenOrder)

View File

@ -76,6 +76,8 @@ export const INITIAL_GLOBAL_STATE: GlobalState = {
recentEmojis: ['grinning', 'kissing_heart', 'christmas_tree', 'brain', 'trophy', 'duck', 'cherries'],
recentCustomEmojis: ['5377305978079288312'],
topReactions: [],
recentReactions: [],
stickers: {
setsById: {},

View File

@ -74,3 +74,10 @@ export function selectIsForumPanelOpen<T extends GlobalState>(
tabState.globalSearch.query === undefined || tabState.globalSearch.isClosing
);
}
export function selectIsReactionPickerOpen<T extends GlobalState>(
global: T,
...[tabId = getCurrentTabId()]: TabArgs<T>
) {
const { reactionPicker } = selectTabState(global, tabId);
return Boolean(reactionPicker?.position);
}

View File

@ -68,6 +68,7 @@ import type {
EmojiKeywords,
FocusDirection,
GlobalSearchContent,
IAnchorPosition,
InlineBotSettings,
ISettings,
IThemeSettings,
@ -246,6 +247,12 @@ export type TabState = {
messageId: number;
};
reactionPicker?: {
chatId?: string;
messageId?: number;
position?: IAnchorPosition;
};
inlineBots: {
isLoading: boolean;
byUsername: Record<string, false | InlineBotSettings>;
@ -687,6 +694,8 @@ export type GlobalState = {
recentEmojis: string[];
recentCustomEmojis: string[];
topReactions: ApiReaction[];
recentReactions: ApiReaction[];
stickers: {
setsById: Record<string, ApiStickerSet>;
@ -1717,7 +1726,10 @@ export interface ActionPayloads {
};
// Reactions
loadTopReactions: undefined;
loadRecentReactions: undefined;
loadAvailableReactions: undefined;
clearRecentReactions: undefined;
loadMessageReactions: {
chatId: string;
@ -1728,6 +1740,7 @@ export interface ActionPayloads {
chatId: string;
messageId: number;
reaction: ApiReaction;
shouldAddToRecent?: boolean;
} & WithTabId;
setDefaultReaction: {
@ -1748,6 +1761,13 @@ export interface ActionPayloads {
reaction: ApiReaction;
} & WithTabId;
openReactionPicker: {
chatId: string;
messageId: number;
position: IAnchorPosition;
} & WithTabId;
closeReactionPicker: WithTabId | undefined;
// Media Viewer & Audio Player
openMediaViewer: {
chatId?: string;

View File

@ -11,7 +11,7 @@ import { createCallbackManager } from '../util/callbacks';
import { updateSizes } from '../util/windowSize';
import useForceUpdate from './useForceUpdate';
type MediaQueryCacheKey = 'mobile' | 'tablet' | 'landscape';
type MediaQueryCacheKey = 'mobile' | 'tablet' | 'landscape' | 'touch';
const mediaQueryCache = new Map<MediaQueryCacheKey, MediaQueryList>();
const callbacks = createCallbackManager();
@ -19,6 +19,7 @@ const callbacks = createCallbackManager();
let isMobile: boolean | undefined;
let isTablet: boolean | undefined;
let isLandscape: boolean | undefined;
let isTouchScreen: boolean | undefined;
export function getIsMobile() {
return isMobile;
@ -32,6 +33,7 @@ function handleMediaQueryChange() {
isMobile = mediaQueryCache.get('mobile')?.matches || false;
isTablet = !isMobile && (mediaQueryCache.get('tablet')?.matches || false);
isLandscape = mediaQueryCache.get('landscape')?.matches || false;
isTouchScreen = mediaQueryCache.get('touch')?.matches || false;
updateSizes();
callbacks.runCallbacks();
}
@ -56,6 +58,10 @@ function initMediaQueryCache() {
);
mediaQueryCache.set('landscape', landscapeQuery);
landscapeQuery.addEventListener('change', handleMediaQueryChange);
const isTouchScreenQuery = window.matchMedia('(pointer: coarse)');
mediaQueryCache.set('touch', isTouchScreenQuery);
isTouchScreenQuery.addEventListener('change', handleMediaQueryChange);
}
initMediaQueryCache();
@ -71,5 +77,6 @@ export default function useAppLayout() {
isTablet,
isLandscape,
isDesktop: !isMobile && !isTablet,
isTouchScreen,
};
}

View File

@ -31,7 +31,9 @@ export default function useBoundsInSharedCanvas(
return;
}
const target = container.classList.contains('sticker-set-cover') ? container : container.querySelector('img')!;
const target = container.classList.contains('sticker-set-cover') || container.classList.contains('sticker-reaction')
? container
: container.querySelector('img')!;
const targetBounds = target.getBoundingClientRect();
const canvasBounds = canvas.getBoundingClientRect();

View File

@ -6,7 +6,10 @@ interface Layout {
extraTopPadding?: number;
marginSides?: number;
extraMarginTop?: number;
menuElMinWidth?: number;
shouldAvoidNegativePosition?: boolean;
withPortal?: boolean;
isDense?: boolean; // Allows you to place the menu as close to the edges of the area as possible
}
const MENU_POSITION_VISUAL_COMFORT_SPACE_PX = 16;
@ -15,7 +18,7 @@ const EMPTY_RECT = {
width: 0, left: 0, height: 0, top: 0,
};
export default function useContextMenuPosition(
export default function useMenuPosition(
anchor: IAnchorPosition | undefined,
getTriggerElement: () => HTMLElement | null,
getRootElement: () => HTMLElement | null,
@ -48,21 +51,25 @@ export default function useContextMenuPosition(
extraTopPadding = 0,
marginSides = 0,
extraMarginTop = 0,
menuElMinWidth = 0,
shouldAvoidNegativePosition = false,
withPortal = false,
isDense = false,
} = getLayout?.() || {};
const marginTop = menuEl ? parseInt(getComputedStyle(menuEl).marginTop, 10) + extraMarginTop : undefined;
const { offsetWidth: menuElWidth, offsetHeight: menuElHeight } = menuEl || { offsetWidth: 0, offsetHeight: 0 };
const menuRect = menuEl ? {
width: menuEl.offsetWidth,
height: menuEl.offsetHeight + marginTop!,
width: Math.max(menuElWidth, menuElMinWidth),
height: menuElHeight + marginTop!,
} : EMPTY_RECT;
const rootRect = rootEl ? rootEl.getBoundingClientRect() : EMPTY_RECT;
let horizontalPosition: 'left' | 'right';
let verticalPosition: 'top' | 'bottom';
if (x + menuRect.width + extraPaddingX < rootRect.width + rootRect.left) {
if (isDense || (x + menuRect.width + extraPaddingX < rootRect.width + rootRect.left)) {
x += 3;
horizontalPosition = 'left';
} else if (x - menuRect.width - rootRect.left > 0) {
@ -87,7 +94,7 @@ export default function useContextMenuPosition(
}
}
if (y + menuRect.height < rootRect.height + rootRect.top) {
if (isDense || (y + menuRect.height < rootRect.height + rootRect.top)) {
verticalPosition = 'top';
} else {
verticalPosition = 'bottom';
@ -107,12 +114,23 @@ export default function useContextMenuPosition(
x - triggerRect.left,
rootRect.width - menuRect.width - MENU_POSITION_VISUAL_COMFORT_SPACE_PX,
);
const left = (horizontalPosition === 'left'
? (withPortal
let left = (horizontalPosition === 'left'
? (withPortal || shouldAvoidNegativePosition
? Math.max(MENU_POSITION_VISUAL_COMFORT_SPACE_PX, leftWithPossibleNegative)
: leftWithPossibleNegative)
: (x - triggerRect.left)) + addedXForPortalPositioning;
const top = y - triggerRect.top + addedYForPortalPositioning;
let top = y - triggerRect.top + addedYForPortalPositioning;
if (isDense) {
left = Math.min(left, rootRect.width - menuRect.width - MENU_POSITION_VISUAL_COMFORT_SPACE_PX);
top = Math.min(top, rootRect.height - menuRect.height - MENU_POSITION_VISUAL_COMFORT_SPACE_PX);
}
// Avoid hiding external parts of menus on mobile devices behind the edges of the screen (ReactionSelector for example)
const addedXForMenuPositioning = menuElMinWidth ? Math.max(0, (menuElMinWidth - menuElWidth) / 2) : 0;
if (left - addedXForMenuPositioning < 0 && shouldAvoidNegativePosition) {
left = addedXForMenuPositioning + MENU_POSITION_VISUAL_COMFORT_SPACE_PX;
}
const menuMaxHeight = rootRect.height - MENU_POSITION_BOTTOM_MARGIN - (marginTop || 0);

View File

@ -1278,6 +1278,9 @@ messages.transcribeAudio#269e9a49 peer:InputPeer msg_id:int = messages.Transcrib
messages.getCustomEmojiDocuments#d9ab0f54 document_id:Vector<long> = Vector<Document>;
messages.getEmojiStickers#fbfca18f hash:long = messages.AllStickers;
messages.getFeaturedEmojiStickers#ecf6736 hash:long = messages.FeaturedStickers;
messages.getTopReactions#bb8125ba limit:int hash:long = messages.Reactions;
messages.getRecentReactions#39461db2 limit:int hash:long = messages.Reactions;
messages.clearRecentReactions#9dfeefb4 = Bool;
messages.getExtendedMedia#84f80814 peer:InputPeer id:Vector<int> = Updates;
updates.getState#edd4882a = updates.State;
updates.getDifference#25939651 flags:# pts:int pts_total_limit:flags.0?int date:int qts:int = updates.Difference;

View File

@ -264,6 +264,9 @@
"messages.getFeaturedEmojiStickers",
"messages.readReactions",
"messages.getUnreadReactions",
"messages.getTopReactions",
"messages.getRecentReactions",
"messages.clearRecentReactions",
"messages.readMentions",
"messages.getUnreadMentions",
"help.getPremiumPromo",

View File

@ -219,6 +219,7 @@ $color-message-reaction-own-hover: #b5e0a4;
--z-ui-loader-mask: 2000;
--z-notification: 1700;
--z-confetti: 1600;
--z-reaction-picker: 1200;
--z-right-column: 900;
--z-header-menu: 990;
--z-header-menu-backdrop: 980;

View File

@ -3,7 +3,7 @@ import type {
ApiBotInlineMediaResult, ApiBotInlineResult, ApiBotInlineSwitchPm,
ApiChatInviteImporter,
ApiExportedInvite,
ApiLanguage, ApiMessage, ApiStickerSet,
ApiLanguage, ApiMessage, ApiReaction, ApiStickerSet,
} from '../api/types';
export type TextPart = TeactNode;
@ -223,10 +223,10 @@ export enum SettingsScreens {
DoNotTranslate,
}
export type StickerSetOrRecent = Pick<ApiStickerSet, (
export type StickerSetOrReactionsSetOrRecent = Pick<ApiStickerSet, (
'id' | 'accessHash' | 'title' | 'count' | 'stickers' | 'hasThumbnail' | 'isLottie' | 'isVideos' | 'isEmoji' |
'installedDate' | 'isArchived'
)>;
)> & { reactions?: ApiReaction[] };
export enum LeftColumnContent {
ChatList,

View File

@ -109,7 +109,6 @@ if (IS_OPFS_SUPPORTED) {
export const IS_OFFSET_PATH_SUPPORTED = CSS.supports('offset-rotate: 0deg');
export const IS_BACKDROP_BLUR_SUPPORTED = CSS.supports('backdrop-filter: blur()')
|| CSS.supports('-webkit-backdrop-filter: blur()');
export const IS_COMPACT_MENU = !IS_TOUCH_ENV;
export const IS_INSTALL_PROMPT_SUPPORTED = 'onbeforeinstallprompt' in window;
export const IS_MULTITAB_SUPPORTED = 'BroadcastChannel' in window;
export const IS_OPEN_IN_NEW_TAB_SUPPORTED = IS_MULTITAB_SUPPORTED && !(IS_PWA && IS_MOBILE);