Message: Support custom reactions (#2217)

This commit is contained in:
Alexander Zinchuk 2022-12-27 02:45:57 +01:00
parent aa9958db3a
commit d127f11f13
62 changed files with 1069 additions and 748 deletions

View File

@ -25,6 +25,8 @@ interface GramJsAppConfig extends LimitsConfig {
reactions_uniq_max: number;
chat_read_mark_size_threshold: number;
chat_read_mark_expire_period: number;
reactions_user_max_default: number;
reactions_user_max_premium: number;
autologin_domains: string[];
autologin_token: string;
url_auth_domains: string[];
@ -77,6 +79,8 @@ export function buildAppConfig(json: GramJs.TypeJSONValue): ApiAppConfig {
premiumPromoOrder: appConfig.premium_promo_order,
isPremiumPurchaseBlocked: appConfig.premium_purchase_blocked,
defaultEmojiStatusesStickerSetId: appConfig.default_emoji_statuses_stickerset_id,
maxUserReactionsDefault: appConfig.reactions_user_max_default,
maxUserReactionsPremium: appConfig.reactions_user_max_premium,
limits: {
uploadMaxFileparts: getLimit(appConfig, 'upload_max_fileparts', 'uploadMaxFileparts'),
stickersFaved: getLimit(appConfig, 'stickers_faved_limit', 'stickersFaved'),

View File

@ -12,6 +12,7 @@ import type {
ApiChatInviteImporter,
ApiChatSettings,
ApiSendAsPeerId,
ApiChatReactions,
} from '../../types';
import { pick, pickTruthy } from '../../../util/iteratees';
import {
@ -462,14 +463,18 @@ export function buildApiChatSettings({
};
}
export function buildApiChatReactions(availableReactions?: GramJs.TypeChatReactions): string[] | undefined {
if (availableReactions instanceof GramJs.ChatReactionsAll) {
// TODO Hack before custom reactions are implemented
// eslint-disable-next-line max-len
return ['👍', '👎', '❤', '🔥', '🥰', '👏', '😁', '🤔', '🤯', '😱', '🤬', '😢', '🎉', '🤩', '🤮', '💩', '🙏', '👌', '🕊', '🤡', '🥱', '🥴', '😍', '🐳', '❤‍🔥', '🌚', '🌭', '💯', '🤣', '⚡', '🍌', '🏆', '💔', '🤨', '😐', '🍓', '🍾', '💋', '🖕', '😈', '😴', '😭', '🤓', '👻', '👨‍💻', '👀', '🎃', '🙈', '😇', '😨', '🤝', '✍️', '🤗', '🫡'];
export function buildApiChatReactions(chatReactions?: GramJs.TypeChatReactions): ApiChatReactions | undefined {
if (chatReactions instanceof GramJs.ChatReactionsAll) {
return {
type: 'all',
areCustomAllowed: chatReactions.allowCustom,
};
}
if (availableReactions instanceof GramJs.ChatReactionsSome) {
return availableReactions.reactions.map(buildApiReaction).filter(Boolean);
if (chatReactions instanceof GramJs.ChatReactionsSome) {
return {
type: 'some',
allowed: chatReactions.reactions.map(buildApiReaction).filter(Boolean),
};
}
return undefined;

View File

@ -34,6 +34,8 @@ import type {
ApiWebDocument,
ApiMessageEntityDefault,
ApiMessageExtendedMediaPreview,
ApiReaction,
ApiReactionEmoji,
} from '../../types';
import {
ApiMessageEntityTypes,
@ -223,20 +225,30 @@ export function buildMessageReactions(reactions: GramJs.MessageReactions): ApiRe
return {
canSeeList,
results: results.map(buildReactionCount).filter(Boolean),
results: results.map(buildReactionCount).filter(Boolean).sort(reactionCountComparator),
recentReactions: recentReactions?.map(buildMessagePeerReaction).filter(Boolean),
};
}
function reactionCountComparator(a: ApiReactionCount, b: ApiReactionCount) {
const diff = b.count - a.count;
if (diff) return diff;
if (a.chosenOrder !== undefined && b.chosenOrder !== undefined) {
return a.chosenOrder - b.chosenOrder;
}
if (a.chosenOrder !== undefined) return 1;
if (b.chosenOrder !== undefined) return -1;
return 0;
}
function buildReactionCount(reactionCount: GramJs.ReactionCount): ApiReactionCount | undefined {
const { chosenOrder, count, reaction } = reactionCount;
// TODO: Add custom reactions support
const apiReaction = buildApiReaction(reaction);
if (!apiReaction) return undefined;
return {
isChosen: chosenOrder !== undefined, // TODO: Add custom reactions support
chosenOrder,
count,
reaction: apiReaction,
};
@ -247,7 +259,6 @@ export function buildMessagePeerReaction(userReaction: GramJs.MessagePeerReactio
peerId, reaction, big, unread,
} = userReaction;
// TODO: Add custom reactions support
const apiReaction = buildApiReaction(reaction);
if (!apiReaction) return undefined;
@ -259,11 +270,19 @@ export function buildMessagePeerReaction(userReaction: GramJs.MessagePeerReactio
};
}
export function buildApiReaction(reaction: GramJs.TypeReaction): string | undefined {
export function buildApiReaction(reaction: GramJs.TypeReaction): ApiReaction | undefined {
if (reaction instanceof GramJs.ReactionEmoji) {
return reaction.emoticon;
return {
emoticon: reaction.emoticon,
};
}
// TODO: Add custom reactions support
if (reaction instanceof GramJs.ReactionCustomEmoji) {
return {
documentId: reaction.documentId.toString(),
};
}
return undefined;
}
@ -281,7 +300,7 @@ export function buildApiAvailableReaction(availableReaction: GramJs.AvailableRea
staticIcon: buildApiDocument(staticIcon),
aroundAnimation: aroundAnimation ? buildApiDocument(aroundAnimation) : undefined,
centerIcon: centerIcon ? buildApiDocument(centerIcon) : undefined,
reaction,
reaction: { emoticon: reaction } as ApiReactionEmoji,
title,
isInactive: inactive,
isPremium: premium,

View File

@ -6,7 +6,7 @@ import type {
} from '../../types';
import type { ApiPrivacySettings, ApiPrivacyKey, PrivacyVisibility } from '../../../types';
import { buildApiDocument } from './messages';
import { buildApiDocument, buildApiReaction } from './messages';
import { buildApiPeerId, getApiChatIdFromMtpPeer } from './peers';
import { pick } from '../../../util/iteratees';
import { getServerTime } from '../../../util/serverTime';
@ -224,8 +224,7 @@ export function buildApiUrlAuthResult(result: GramJs.TypeUrlAuthResult): ApiUrlA
}
export function buildApiConfig(config: GramJs.Config): ApiConfig {
const defaultReaction = config.reactionsDefault
&& 'emoticon' in config.reactionsDefault ? config.reactionsDefault.emoticon : undefined;
const defaultReaction = config.reactionsDefault && buildApiReaction(config.reactionsDefault);
return {
expiresAt: config.expires,
gifSearchUsername: config.gifSearchUsername,

View File

@ -171,7 +171,7 @@ export function buildStickerSetCovered(coveredStickerSet: GramJs.TypeStickerSetC
export function buildApiEmojiInteraction(json: GramJsEmojiInteraction): ApiEmojiInteraction {
return {
timestamps: json.a.map((l) => l.t),
timestamps: json.a.map(({ t }) => t),
};
}

View File

@ -20,6 +20,8 @@ import type {
ApiThemeParameters,
ApiPoll,
ApiRequestInputInvoice,
ApiChatReactions,
ApiReaction,
} from '../../types';
import {
ApiMessageEntityTypes,
@ -547,15 +549,34 @@ export function buildInputInvoice(invoice: ApiRequestInputInvoice) {
}
}
export function buildInputReaction(reaction?: string) {
if (!reaction) return new GramJs.ReactionEmpty();
return new GramJs.ReactionEmoji({
emoticon: reaction,
});
export function buildInputReaction(reaction?: ApiReaction) {
if (reaction && 'emoticon' in reaction) {
return new GramJs.ReactionEmoji({
emoticon: reaction.emoticon,
});
}
if (reaction && 'documentId' in reaction) {
return new GramJs.ReactionCustomEmoji({
documentId: BigInt(reaction.documentId),
});
}
return new GramJs.ReactionEmpty();
}
export function buildInputChatReactions(chatReactions: string[]) {
return new GramJs.ChatReactionsSome({
reactions: chatReactions.map(buildInputReaction),
});
export function buildInputChatReactions(chatReactions?: ApiChatReactions) {
if (chatReactions?.type === 'all') {
return new GramJs.ChatReactionsAll({
allowCustom: chatReactions.areCustomAllowed,
});
}
if (chatReactions?.type === 'some') {
return new GramJs.ChatReactionsSome({
reactions: chatReactions.allowed.map(buildInputReaction),
});
}
return new GramJs.ChatReactionsNone();
}

View File

@ -12,7 +12,7 @@ import type {
ApiChatBannedRights,
ApiChatAdminRights,
ApiGroupCall,
ApiUserStatus, ApiPhoto,
ApiUserStatus, ApiPhoto, ApiChatReactions,
} from '../../types';
import {
@ -1266,7 +1266,7 @@ export async function importChatInvite({ hash }: { hash: string }) {
export function setChatEnabledReactions({
chat, enabledReactions,
}: {
chat: ApiChat; enabledReactions: string[];
chat: ApiChat; enabledReactions?: ApiChatReactions;
}) {
return invokeRequest(new GramJs.messages.SetChatAvailableReactions({
peer: buildInputPeer(chat.id, chat.accessHash),

View File

@ -42,7 +42,7 @@ export {
faveSticker, fetchStickers, fetchSavedGifs, saveGif, searchStickers, installStickerSet, uninstallStickerSet,
searchGifs, fetchAnimatedEmojis, fetchStickersForEmoji, fetchEmojiKeywords, fetchAnimatedEmojiEffects,
removeRecentSticker, clearRecentStickers, fetchCustomEmoji, fetchPremiumGifts, fetchCustomEmojiSets,
fetchFeaturedEmojiStickers,
fetchFeaturedEmojiStickers, fetchGenericEmojiEffects,
} from './symbols';
export {

View File

@ -1,4 +1,4 @@
import type { ApiChat } from '../../types';
import type { ApiChat, ApiReaction } from '../../types';
import { invokeRequest } from './client';
import { Api as GramJs } from '../../../lib/gramjs';
import { buildInputPeer, buildInputReaction } from '../gramjsBuilders';
@ -74,12 +74,12 @@ export async function getAvailableReactions() {
}
export function sendReaction({
chat, messageId, reaction,
chat, messageId, reactions,
}: {
chat: ApiChat; messageId: number; reaction?: string;
chat: ApiChat; messageId: number; reactions?: ApiReaction[];
}) {
return invokeRequest(new GramJs.messages.SendReaction({
...(reaction && { reaction: [buildInputReaction(reaction)] }),
reaction: reactions?.map((r) => buildInputReaction(r)),
peer: buildInputPeer(chat.id, chat.accessHash),
msgId: messageId,
}), true);
@ -99,7 +99,7 @@ export function fetchMessageReactions({
export async function fetchMessageReactionsList({
chat, messageId, reaction, offset,
}: {
chat: ApiChat; messageId: number; reaction?: string; offset?: string;
chat: ApiChat; messageId: number; reaction?: ApiReaction; offset?: string;
}) {
const result = await invokeRequest(new GramJs.messages.GetMessageReactionsList({
peer: buildInputPeer(chat.id, chat.accessHash),
@ -128,7 +128,7 @@ export async function fetchMessageReactionsList({
export function setDefaultReaction({
reaction,
}: {
reaction: string;
reaction: ApiReaction;
}) {
return invokeRequest(new GramJs.messages.SetDefaultReaction({
reaction: buildInputReaction(reaction),

View File

@ -218,6 +218,21 @@ export async function fetchAnimatedEmojiEffects() {
};
}
export async function fetchGenericEmojiEffects() {
const result = await invokeRequest(new GramJs.messages.GetStickerSet({
stickerset: new GramJs.InputStickerSetEmojiGenericAnimations(),
}));
if (!(result instanceof GramJs.messages.StickerSet)) {
return undefined;
}
return {
set: buildStickerSet(result.set),
stickers: processStickerResult(result.documents),
};
}
export async function fetchPremiumGifts() {
const result = await invokeRequest(new GramJs.messages.GetStickerSet({
stickerset: new GramJs.InputStickerSetPremiumGifts(),

View File

@ -1,4 +1,6 @@
import type { ApiMessage, ApiPhoto, ApiStickerSet } from './messages';
import type {
ApiChatReactions, ApiMessage, ApiPhoto, ApiStickerSet,
} from './messages';
import type { ApiBotCommand } from './bots';
import type { ApiChatInviteImporter } from './misc';
import type { ApiFakeType, ApiUsername } from './users';
@ -101,7 +103,7 @@ export interface ApiChatFullInfo {
};
linkedChatId?: string;
botCommands?: ApiBotCommand[];
enabledReactions?: string[];
enabledReactions?: ApiChatReactions;
sendAsId?: string;
canViewStatistics?: boolean;
recentRequesterIds?: string[];

View File

@ -436,15 +436,15 @@ export interface ApiReactions {
export interface ApiUserReaction {
userId: string;
reaction: string;
reaction: ApiReaction;
isBig?: boolean;
isUnread?: boolean;
}
export interface ApiReactionCount {
isChosen?: boolean;
chosenOrder?: number;
count: number;
reaction: string;
reaction: ApiReaction;
}
export interface ApiAvailableReaction {
@ -454,12 +454,34 @@ export interface ApiAvailableReaction {
staticIcon?: ApiDocument;
centerIcon?: ApiDocument;
aroundAnimation?: ApiDocument;
reaction: string;
reaction: ApiReactionEmoji;
title: string;
isInactive?: boolean;
isPremium?: boolean;
}
type ApiChatReactionsAll = {
type: 'all';
areCustomAllowed?: true;
};
type ApiChatReactionsSome = {
type: 'some';
allowed: ApiReaction[];
};
export type ApiChatReactions = ApiChatReactionsAll | ApiChatReactionsSome;
export type ApiReactionEmoji = {
emoticon: string;
};
export type ApiReactionCustomEmoji = {
documentId: string;
};
export type ApiReaction = ApiReactionEmoji | ApiReactionCustomEmoji;
export interface ApiThreadInfo {
threadId: number;
chatId: string;

View File

@ -1,4 +1,4 @@
import type { ApiDocument, ApiPhoto } from './messages';
import type { ApiDocument, ApiPhoto, ApiReaction } from './messages';
import type { ApiUser } from './users';
import type { ApiLimitType } from '../../global/types';
@ -174,12 +174,14 @@ export interface ApiAppConfig {
premiumPromoOrder: string[];
defaultEmojiStatusesStickerSetId: string;
maxUniqueReactions: number;
maxUserReactionsDefault: number;
maxUserReactionsPremium: number;
limits: Record<ApiLimitType, readonly [number, number]>;
}
export interface ApiConfig {
expiresAt: number;
defaultReaction?: string;
defaultReaction?: ApiReaction;
gifSearchUsername?: string;
maxGroupSize: number;
}

View File

@ -29,7 +29,7 @@
}
.root, .media, .thumb {
border-radius: 0 !important;
border-radius: var(--custom-emoji-border-radius) !important;
}
.highlightCatch {

View File

@ -1,35 +1,62 @@
import type { RefObject } from 'react';
import type { FC } from '../../lib/teact/teact';
import React, { memo } from '../../lib/teact/teact';
import { getGlobal } from '../../global';
import React, { memo, useMemo } 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 { ApiMediaFormat } from '../../api/types';
import useMedia from '../../hooks/useMedia';
import buildClassName from '../../util/buildClassName';
import { isSameReaction } from '../../global/helpers';
import useMediaTransition from '../../hooks/useMediaTransition';
import useMedia from '../../hooks/useMedia';
import CustomEmoji from './CustomEmoji';
import blankUrl from '../../assets/blank.png';
import './ReactionStaticEmoji.scss';
type OwnProps = {
reaction: string;
ref?: RefObject<HTMLImageElement>;
reaction: ApiReaction;
availableReactions?: ApiAvailableReaction[];
className?: string;
size?: number;
observeIntersection?: ObserveFn;
};
const ReactionStaticEmoji: FC<OwnProps> = ({
reaction,
ref,
availableReactions,
className,
size,
observeIntersection,
}) => {
const staticIconId = getGlobal().availableReactions?.find((l) => l.reaction === reaction)?.staticIcon?.id;
const isCustom = 'documentId' in reaction;
const availableReaction = useMemo(() => (
availableReactions?.find((available) => isSameReaction(available.reaction, reaction))
), [availableReactions, reaction]);
const staticIconId = availableReaction?.staticIcon?.id;
const mediaData = useMedia(`document${staticIconId}`, !staticIconId, ApiMediaFormat.BlobUrl);
const transitionClassNames = useMediaTransition(mediaData);
if (isCustom) {
return (
<CustomEmoji
documentId={reaction.documentId}
className={buildClassName('ReactionStaticEmoji', className)}
size={size}
observeIntersectionForPlaying={observeIntersection}
/>
);
}
return (
<img
className={buildClassName('ReactionStaticEmoji', className)}
ref={ref}
src={mediaData}
alt=""
className={buildClassName('ReactionStaticEmoji', transitionClassNames, className)}
style={size ? `width: ${size}px; height: ${size}px` : undefined}
src={mediaData || blankUrl}
alt={availableReaction?.title}
/>
);
};

View File

@ -7,19 +7,22 @@ import { addCustomEmojiCallback, removeCustomEmojiCallback } from '../../../util
import useEnsureCustomEmoji from '../../../hooks/useEnsureCustomEmoji';
export default function useCustomEmoji(documentId: string) {
const [customEmoji, setCustomEmoji] = useState<ApiSticker | undefined>(getGlobal().customEmojis.byId[documentId]);
export default function useCustomEmoji(documentId?: string) {
const [customEmoji, setCustomEmoji] = useState<ApiSticker | undefined>(
documentId ? getGlobal().customEmojis.byId[documentId] : undefined,
);
useEnsureCustomEmoji(documentId);
const handleGlobalChange = useCallback(() => {
if (!documentId) return;
setCustomEmoji(getGlobal().customEmojis.byId[documentId]);
}, [documentId]);
useEffect(handleGlobalChange, [documentId, handleGlobalChange]);
useEffect(() => {
if (customEmoji) return undefined;
if (customEmoji || !documentId) return undefined;
addCustomEmojiCallback(handleGlobalChange, documentId);

View File

@ -357,9 +357,7 @@
}
.SettingsDefaultReaction {
.ReactionStaticEmoji {
width: 1.5rem;
height: 1.5rem;
.current-default-reaction {
margin-inline-end: 2rem;
}
}

View File

@ -1,11 +1,9 @@
import type { FC } from '../../../lib/teact/teact';
import React, { memo, useCallback } from '../../../lib/teact/teact';
import React, { memo, useCallback, useMemo } from '../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../global';
import type { FC } from '../../../lib/teact/teact';
import type { ApiAvailableReaction } from '../../../api/types';
import { selectIsCurrentUserPremium } from '../../../global/selectors';
import useHistoryBack from '../../../hooks/useHistoryBack';
import ReactionStaticEmoji from '../../common/ReactionStaticEmoji';
@ -18,14 +16,12 @@ type OwnProps = {
type StateProps = {
availableReactions?: ApiAvailableReaction[];
isPremium?: boolean;
selectedReaction?: string;
};
const SettingsQuickReaction: FC<OwnProps & StateProps> = ({
isActive,
availableReactions,
isPremium,
selectedReaction,
onReset,
}) => {
@ -36,17 +32,23 @@ const SettingsQuickReaction: FC<OwnProps & StateProps> = ({
onBack: onReset,
});
const options = availableReactions?.filter((l) => (
!(l.isInactive || (!isPremium && l.isPremium))
)).map((l) => {
return {
label: <><ReactionStaticEmoji reaction={l.reaction} />{l.title}</>,
value: l.reaction,
};
}) || [];
const options = useMemo(() => (
(availableReactions || []).filter((availableReaction) => !availableReaction.isInactive)
.map((availableReaction) => ({
label: (
<>
<ReactionStaticEmoji reaction={availableReaction.reaction} availableReactions={availableReactions} />
{availableReaction.title}
</>
),
value: availableReaction.reaction.emoticon,
}))
), [availableReactions]);
const handleChange = useCallback((reaction: string) => {
setDefaultReaction({ reaction });
setDefaultReaction({
reaction: { emoticon: reaction },
});
}, [setDefaultReaction]);
return (
@ -64,12 +66,10 @@ const SettingsQuickReaction: FC<OwnProps & StateProps> = ({
export default memo(withGlobal<OwnProps>(
(global) => {
const { availableReactions, config } = global;
const isPremium = selectIsCurrentUserPremium(global);
return {
availableReactions,
selectedReaction: config?.defaultReaction,
isPremium,
};
},
)(SettingsQuickReaction));

View File

@ -6,10 +6,16 @@ import { getActions, withGlobal } from '../../../global';
import type { FC } from '../../../lib/teact/teact';
import { SettingsScreens } from '../../../types';
import type { ISettings } from '../../../types';
import type { ApiSticker, ApiStickerSet } from '../../../api/types';
import type {
ApiAvailableReaction,
ApiReaction,
ApiSticker,
ApiStickerSet,
} from '../../../api/types';
import renderText from '../../common/helpers/renderText';
import { pick } from '../../../util/iteratees';
import { REM } from '../../common/helpers/mediaDimensions';
import { useIntersectionObserver } from '../../../hooks/useIntersectionObserver';
import useHistoryBack from '../../../hooks/useHistoryBack';
@ -20,6 +26,8 @@ import Checkbox from '../../ui/Checkbox';
import ListItem from '../../ui/ListItem';
import StickerSetCard from '../../common/StickerSetCard';
const DEFAULT_REACTION_SIZE = 1.5 * REM;
type OwnProps = {
isActive?: boolean;
onScreenSelect: (screen: SettingsScreens) => void;
@ -34,7 +42,8 @@ type StateProps =
addedSetIds?: string[];
customEmojiSetIds?: string[];
stickerSetsById: Record<string, ApiStickerSet>;
defaultReaction?: string;
defaultReaction?: ApiReaction;
availableReactions?: ApiAvailableReaction[];
};
const SettingsStickers: FC<OwnProps & StateProps> = ({
@ -45,6 +54,7 @@ const SettingsStickers: FC<OwnProps & StateProps> = ({
defaultReaction,
shouldSuggestStickers,
shouldLoopStickers,
availableReactions,
onReset,
onScreenSelect,
}) => {
@ -109,7 +119,12 @@ const SettingsStickers: FC<OwnProps & StateProps> = ({
// eslint-disable-next-line react/jsx-no-bind
onClick={() => onScreenSelect(SettingsScreens.QuickReaction)}
>
<ReactionStaticEmoji reaction={defaultReaction} />
<ReactionStaticEmoji
reaction={defaultReaction}
className="current-default-reaction"
size={DEFAULT_REACTION_SIZE}
availableReactions={availableReactions}
/>
<div className="title">{lang('DoubleTapSetting')}</div>
</ListItem>
)}

View File

@ -191,6 +191,7 @@ const Main: FC<StateProps> = ({
loadAttachBots,
loadContactList,
loadCustomEmojis,
loadGenericEmojiEffects,
closePaymentModal,
clearReceipt,
checkAppVersion,
@ -213,6 +214,7 @@ const Main: FC<StateProps> = ({
loadAppConfig();
loadAvailableReactions();
loadAnimatedEmojis();
loadGenericEmojiEffects();
loadNotificationSettings();
loadNotificationExceptions();
loadTopInlineBots();
@ -225,7 +227,7 @@ const Main: FC<StateProps> = ({
}, [
lastSyncTime, loadAnimatedEmojis, loadEmojiKeywords, loadNotificationExceptions, loadNotificationSettings,
loadTopInlineBots, updateIsOnline, loadAvailableReactions, loadAppConfig, loadAttachBots, loadContactList,
loadPremiumGifts, checkAppVersion, loadConfig,
loadPremiumGifts, checkAppVersion, loadConfig, loadGenericEmojiEffects,
]);
// Language-based API calls

View File

@ -17,7 +17,6 @@ import { formatCurrency } from '../../../util/formatCurrency';
import Button from '../../ui/Button';
import PremiumLimitPreview from './common/PremiumLimitPreview';
import PremiumFeaturePreviewVideo from './previews/PremiumFeaturePreviewVideo';
import PremiumFeaturePreviewReactions from './previews/PremiumFeaturePreviewReactions';
import SliderDots from '../../common/SliderDots';
import PremiumFeaturePreviewStickers from './previews/PremiumFeaturePreviewStickers';
@ -25,7 +24,7 @@ import styles from './PremiumFeatureModal.module.scss';
export const PREMIUM_FEATURE_TITLES: Record<string, string> = {
double_limits: 'PremiumPreviewLimits',
unique_reactions: 'PremiumPreviewReactions',
infinite_reactions: 'PremiumPreviewReactions2',
premium_stickers: 'PremiumPreviewStickers',
animated_emoji: 'PremiumPreviewEmoji',
no_ads: 'PremiumPreviewNoAds',
@ -39,7 +38,7 @@ export const PREMIUM_FEATURE_TITLES: Record<string, string> = {
export const PREMIUM_FEATURE_DESCRIPTIONS: Record<string, string> = {
double_limits: 'PremiumPreviewLimitsDescription',
unique_reactions: 'PremiumPreviewReactionsDescription',
infinite_reactions: 'PremiumPreviewReactions2Description',
premium_stickers: 'PremiumPreviewStickersDescription',
no_ads: 'PremiumPreviewNoAdsDescription',
animated_emoji: 'PremiumPreviewEmojiDescription',
@ -57,7 +56,7 @@ export const PREMIUM_FEATURE_SECTIONS = [
'faster_download',
'voice_to_text',
'no_ads',
'unique_reactions',
'infinite_reactions',
'premium_stickers',
'animated_emoji',
'advanced_chat_management',
@ -242,21 +241,6 @@ const PremiumFeatureModal: FC<OwnProps> = ({
</div>
);
}
if (section === 'unique_reactions') {
return (
<div className={styles.slide}>
<div className={styles.frame}>
<PremiumFeaturePreviewReactions isActive={currentSlideIndex === index} />
</div>
<h1 className={styles.title}>
{lang(PREMIUM_FEATURE_TITLES.unique_reactions)}
</h1>
<div className={styles.description}>
{renderText(lang(PREMIUM_FEATURE_DESCRIPTIONS.unique_reactions), ['br'])}
</div>
</div>
);
}
if (section === 'premium_stickers') {
return (

View File

@ -48,7 +48,7 @@ const LIMIT_ACCOUNTS = 4;
const PREMIUM_FEATURE_COLOR_ICONS: Record<string, string> = {
double_limits: PremiumLimits,
unique_reactions: PremiumReactions,
infinite_reactions: PremiumReactions,
premium_stickers: PremiumStickers,
animated_emoji: PremiumEmoji,
no_ads: PremiumAds,

View File

@ -1,34 +0,0 @@
.root {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
.sticker {
--x: 0px;
--y: 0px;
--scale: 0;
transition: 0.25s ease-in-out transform, 0.25s ease-in-out opacity;
position: absolute;
transform:
translate(var(--x), var(--y))
scale(var(--scale));
opacity: var(--scale);
canvas {
width: 100% !important;
height: 100% !important;
}
}
.effect-sticker {
composes: sticker;
z-index: 2;
pointer-events: none;
canvas {
width: 100% !important;
height: 100% !important;
}
}

View File

@ -1,161 +0,0 @@
import type { FC } from '../../../../lib/teact/teact';
import React, {
memo, useCallback, useEffect, useRef, useState,
} from '../../../../lib/teact/teact';
import { withGlobal } from '../../../../global';
import type { GlobalState } from '../../../../global/types';
import type { ApiAvailableReaction } from '../../../../api/types';
import cycleRestrict from '../../../../util/cycleRestrict';
import useMedia from '../../../../hooks/useMedia';
import useInterval from '../../../../hooks/useInterval';
import useFlag from '../../../../hooks/useFlag';
import AnimatedSticker from '../../../common/AnimatedSticker';
import styles from './PremiumFeaturePreviewReactions.module.scss';
type OwnProps = {
isActive: boolean;
};
type StateProps = {
availableReactions: GlobalState['availableReactions'];
};
const EMOJI_SIZE_MULTIPLIER = 0.2;
const EFFECT_SIZE_MULTIPLIER = 0.6;
const ROTATE_INTERVAL = 3000;
const CLICK_DELAY = 4000;
const MAX_EMOJIS = 15;
const AnimatedCircleReaction: FC<{
size: number;
realIndex: number;
reaction: ApiAvailableReaction;
index: number;
maxLength: number;
handleClick: (index: number) => void;
isActivated: boolean;
canPlay: boolean;
}> = ({
size, realIndex, isActivated, canPlay,
reaction, index, maxLength, handleClick,
}) => {
const mediaData = useMedia(`document${reaction.activateAnimation?.id}`);
const mediaDataAround = useMedia(`document${reaction.aroundAnimation?.id}`);
const [isAnimated, animate, inanimate] = useFlag(isActivated);
const [isEffectEnded, markEffectEnded, unmarkEffectEnded] = useFlag(false);
const circleSize = (size - size * EMOJI_SIZE_MULTIPLIER) / 2;
const t = index / maxLength;
const angle = t * (Math.PI * 2);
const totalAngle = angle - (Math.PI / 6) * Math.cos(angle);
const scaleNotFull = 0.2 + (0.7 * (Math.sin(totalAngle) + 1)) / 2;
const scale = scaleNotFull > 0.85 ? 1 : scaleNotFull;
const x = Math.cos(totalAngle) * circleSize;
const y = Math.sin(totalAngle) * circleSize * 0.6;
const handleClickEmoji = useCallback(() => {
handleClick(realIndex);
}, [handleClick, realIndex]);
useEffect(() => {
if (isActivated) {
animate();
unmarkEffectEnded();
}
}, [isActivated, animate, unmarkEffectEnded]);
return (
<>
{isActivated && !isEffectEnded && (
<AnimatedSticker
className={styles.effectSticker}
tgsUrl={mediaDataAround}
play={canPlay}
isLowPriority
noLoop
size={EFFECT_SIZE_MULTIPLIER * size}
style={`--x: ${x}px; --y: ${y}px; --scale: ${scale};`}
onEnded={markEffectEnded}
/>
)}
<AnimatedSticker
className={styles.sticker}
tgsUrl={mediaData}
onClick={handleClickEmoji}
play={isAnimated && canPlay}
noLoop
size={EMOJI_SIZE_MULTIPLIER * size}
style={`--x: ${x}px; --y: ${y}px; --scale: ${scale};`}
onEnded={inanimate}
/>
</>
);
};
const PremiumFeaturePreviewReactions: FC<OwnProps & StateProps> = ({
availableReactions, isActive,
}) => {
// eslint-disable-next-line no-null/no-null
const containerRef = useRef<HTMLDivElement>(null);
const [isIntervalPaused, pauseInterval, unpauseInterval] = useFlag();
const lastUnpauseTimeout = useRef<NodeJS.Timeout>();
const [offset, setOffset] = useState(0);
const [size, setSize] = useState(0);
const renderedReactions = availableReactions?.filter((l) => l.isPremium)?.slice(0, MAX_EMOJIS) || [];
useInterval(() => {
setOffset((current) => cycleRestrict(renderedReactions.length, current + 1));
}, isIntervalPaused || !isActive ? undefined : ROTATE_INTERVAL);
const handleClickEmoji = useCallback((i: number) => {
setOffset(i);
pauseInterval();
if (lastUnpauseTimeout.current) clearTimeout(lastUnpauseTimeout.current);
lastUnpauseTimeout.current = setTimeout(() => {
unpauseInterval();
}, CLICK_DELAY);
}, [pauseInterval, unpauseInterval]);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
setSize(container.closest('.modal-dialog')!.clientWidth);
}, []);
return (
<div
className={styles.root}
ref={containerRef}
>
{renderedReactions.map((l, i) => {
return (
<AnimatedCircleReaction
size={size}
reaction={l}
realIndex={i}
index={(i - offset + renderedReactions.length / 4) % renderedReactions.length}
maxLength={renderedReactions.length}
handleClick={handleClickEmoji}
isActivated={offset === i}
canPlay={isActive}
/>
);
})}
</div>
);
};
export default memo(withGlobal<OwnProps>(
(global): StateProps => {
return {
availableReactions: global.availableReactions,
};
},
)(PremiumFeaturePreviewReactions));

View File

@ -242,7 +242,7 @@ const MessageList: FC<OwnProps & StateProps> = ({
if (!messageIds || !messagesById) {
return;
}
const ids = messageIds.filter((l) => messagesById[l]?.reactions);
const ids = messageIds.filter((id) => messagesById[id]?.reactions);
if (!ids.length) return;
@ -592,7 +592,6 @@ const MessageList: FC<OwnProps & StateProps> = ({
isViewportNewest={Boolean(isViewportNewest)}
isUnread={Boolean(firstUnreadId)}
withUsers={withUsers}
areReactionsInMeta={isPrivate}
noAvatars={noAvatars}
containerRef={containerRef}
anchorIdRef={anchorIdRef}

View File

@ -39,7 +39,6 @@ interface OwnProps {
threadId: number;
type: MessageListType;
isReady: boolean;
areReactionsInMeta: boolean;
isScrollingRef: { current: boolean | undefined };
isScrollPatchNeededRef: { current: boolean | undefined };
threadTopMessageId: number | undefined;
@ -60,7 +59,6 @@ const MessageListContent: FC<OwnProps> = ({
isViewportNewest,
isUnread,
withUsers,
areReactionsInMeta,
noAvatars,
containerRef,
anchorIdRef,
@ -202,7 +200,6 @@ const MessageListContent: FC<OwnProps> = ({
noAvatars={noAvatars}
withAvatar={position.isLastInGroup && withUsers && !isOwn && !(message.id === threadTopMessageId)}
withSenderName={position.isFirstInGroup && withUsers && !isOwn}
areReactionsInMeta={areReactionsInMeta}
threadId={threadId}
messageListType={type}
noComments={hasLinkedChat === false}

View File

@ -11,6 +11,12 @@
margin-bottom: 0.5rem;
}
.icon-heart {
width: 1.125rem;
height: 1.125rem;
margin-right: 0.25rem;
}
.reaction-filter-emoji {
margin-right: 0.25rem;
}

View File

@ -4,17 +4,19 @@ import React, {
} from '../../lib/teact/teact';
import { getActions, getGlobal, withGlobal } from '../../global';
import type { ApiMessage } from '../../api/types';
import type { ApiAvailableReaction, ApiMessage, ApiReaction } from '../../api/types';
import type { AnimationLevel } from '../../types';
import { LoadMoreDirection } from '../../types';
import useLang from '../../hooks/useLang';
import { selectChatMessage } from '../../global/selectors';
import useInfiniteScroll from '../../hooks/useInfiniteScroll';
import useFlag from '../../hooks/useFlag';
import buildClassName from '../../util/buildClassName';
import { formatIntegerCompact } from '../../util/textFormat';
import { unique } from '../../util/iteratees';
import { isSameReaction, getReactionUniqueKey } from '../../global/helpers';
import useLang from '../../hooks/useLang';
import useInfiniteScroll from '../../hooks/useInfiniteScroll';
import useFlag from '../../hooks/useFlag';
import InfiniteScroll from '../ui/InfiniteScroll';
import Modal from '../ui/Modal';
@ -37,6 +39,7 @@ export type StateProps = Pick<ApiMessage, 'reactors' | 'reactions' | 'seenByUser
chatId?: string;
messageId?: number;
animationLevel: AnimationLevel;
availableReactions?: ApiAvailableReaction[];
};
const ReactorListModal: FC<OwnProps & StateProps> = ({
@ -47,6 +50,7 @@ const ReactorListModal: FC<OwnProps & StateProps> = ({
messageId,
seenByUserIds,
animationLevel,
availableReactions,
}) => {
const {
loadReactors,
@ -59,7 +63,7 @@ const ReactorListModal: FC<OwnProps & StateProps> = ({
const lang = useLang();
const [isClosing, startClosing, stopClosing] = useFlag(false);
const [chosenTab, setChosenTab] = useState<string | undefined>(undefined);
const [chosenTab, setChosenTab] = useState<ApiReaction | undefined>(undefined);
const canShowFilters = reactors && reactions && reactors.count >= MIN_REACTIONS_COUNT_FOR_FILTERS
&& reactions.results.length > 1;
const chatIdRef = useRef<string>();
@ -99,14 +103,22 @@ const ReactorListModal: FC<OwnProps & StateProps> = ({
}, [chatId, loadReactors, messageId]);
const allReactions = useMemo(() => {
return reactors?.reactions ? unique(reactors.reactions.map((l) => l.reaction)) : [];
const uniqueReactions: ApiReaction[] = [];
reactors?.reactions?.forEach(({ reaction }) => {
if (!uniqueReactions.some((r) => isSameReaction(r, reaction))) {
uniqueReactions.push(reaction);
}
});
return uniqueReactions;
}, [reactors]);
const userIds = useMemo(() => {
if (chosenTab) {
return reactors?.reactions.filter((l) => l.reaction === chosenTab).map((l) => l.userId);
return reactors?.reactions
.filter(({ reaction }) => isSameReaction(reaction, chosenTab))
.map(({ userId }) => userId);
}
return unique(reactors?.reactions.map((l) => l.userId).concat(seenByUserIds || []) || []);
return unique(reactors?.reactions.map(({ userId }) => userId).concat(seenByUserIds || []) || []);
}, [chosenTab, reactors, seenByUserIds]);
const [viewportIds, getMore] = useInfiniteScroll(
@ -138,17 +150,22 @@ const ReactorListModal: FC<OwnProps & StateProps> = ({
{reactors?.count && formatIntegerCompact(reactors.count)}
</Button>
{allReactions.map((reaction) => {
const count = reactions?.results.find((l) => l.reaction === reaction)?.count;
const count = reactions?.results
.find((reactionsCount) => isSameReaction(reactionsCount.reaction, reaction))?.count;
return (
<Button
key={reaction}
className={buildClassName(chosenTab === reaction && 'chosen')}
key={getReactionUniqueKey(reaction)}
className={buildClassName(isSameReaction(chosenTab, reaction) && 'chosen')}
size="tiny"
ripple
// eslint-disable-next-line react/jsx-no-bind
onClick={() => setChosenTab(reaction)}
>
<ReactionStaticEmoji reaction={reaction} className="reaction-filter-emoji" />
<ReactionStaticEmoji
reaction={reaction}
className="reaction-filter-emoji"
availableReactions={availableReactions}
/>
{count && formatIntegerCompact(count)}
</Button>
);
@ -166,19 +183,26 @@ const ReactorListModal: FC<OwnProps & StateProps> = ({
{viewportIds?.flatMap(
(userId) => {
const user = usersById[userId];
const userReactions = reactors?.reactions.filter((l) => l.userId === userId);
const userReactions = reactors?.reactions.filter((reactor) => reactor.userId === userId);
const items: React.ReactNode[] = [];
userReactions?.forEach((r) => {
if (chosenTab && !isSameReaction(r.reaction, chosenTab)) return;
items.push(
<ListItem
key={`${userId}-${r.reaction}`}
key={`${userId}-${getReactionUniqueKey(r.reaction)}`}
className="chat-item-clickable reactors-list-item"
// eslint-disable-next-line react/jsx-no-bind
onClick={() => handleClick(userId)}
>
<Avatar user={user} size="small" animationLevel={animationLevel} withVideo />
<FullNameTitle peer={user} withEmojiStatus />
{r.reaction && <ReactionStaticEmoji className="reactors-list-emoji" reaction={r.reaction} />}
{r.reaction && (
<ReactionStaticEmoji
className="reactors-list-emoji"
reaction={r.reaction}
availableReactions={availableReactions}
/>
)}
</ListItem>,
);
});

View File

@ -160,7 +160,7 @@ const StickerPicker: FC<OwnProps & StateProps> = ({
if (isCurrentUserPremium) {
const addedPremiumStickers = existingAddedSetIds
.map((l) => l.stickers?.filter((sticker) => sticker.hasEffect))
.map(({ stickers }) => stickers?.filter((sticker) => sticker.hasEffect))
.flat()
.filter(Boolean);

View File

@ -6,7 +6,7 @@ import { getActions, getGlobal, withGlobal } from '../../../global';
import type { MessageListType } from '../../../global/types';
import type {
ApiAvailableReaction, ApiStickerSetInfo, ApiMessage, ApiStickerSet,
ApiAvailableReaction, ApiStickerSetInfo, ApiMessage, ApiStickerSet, ApiChatReactions, ApiReaction,
} from '../../../api/types';
import type { IAlbum, IAnchorPosition } from '../../../types';
@ -28,7 +28,6 @@ import {
} from '../../../global/helpers';
import { SERVICE_NOTIFICATIONS_USER_ID, TME_LINK_PREFIX } from '../../../config';
import buildClassName from '../../../util/buildClassName';
import { REM } from '../../common/helpers/mediaDimensions';
import { copyTextToClipboard } from '../../../util/clipboard';
import useShowTransition from '../../../hooks/useShowTransition';
@ -42,8 +41,6 @@ import PinMessageModal from '../../common/PinMessageModal';
import MessageContextMenu from './MessageContextMenu';
import ConfirmDialog from '../../ui/ConfirmDialog';
const START_SIZE = 2 * REM;
export type OwnProps = {
isOpen: boolean;
chatUsername?: string;
@ -67,7 +64,6 @@ type StateProps = {
canShowReactionsCount?: boolean;
canBuyPremium?: boolean;
canShowReactionList?: boolean;
canRemoveReaction?: boolean;
canUnpin?: boolean;
canDelete?: boolean;
canReport?: boolean;
@ -87,7 +83,7 @@ type StateProps = {
canClosePoll?: boolean;
activeDownloads: number[];
canShowSeenBy?: boolean;
enabledReactions?: string[];
enabledReactions?: ApiChatReactions;
canScheduleUntilOnline?: boolean;
maxUniqueReactions?: number;
};
@ -115,7 +111,6 @@ const ContextMenuContainer: FC<OwnProps & StateProps> = ({
canReport,
canShowReactionsCount,
canShowReactionList,
canRemoveReaction,
canEdit,
enabledReactions,
maxUniqueReactions,
@ -150,7 +145,6 @@ const ContextMenuContainer: FC<OwnProps & StateProps> = ({
cancelMessageMediaDownload,
loadSeenBy,
openSeenByModal,
sendReaction,
openReactorListModal,
loadFullChat,
loadReactors,
@ -159,6 +153,7 @@ const ContextMenuContainer: FC<OwnProps & StateProps> = ({
loadStickers,
cancelPollVote,
closePoll,
toggleReaction,
} = getActions();
const lang = useLang();
@ -376,12 +371,12 @@ const ContextMenuContainer: FC<OwnProps & StateProps> = ({
closeMenu();
}, [closeMenu, message, saveGif]);
const handleSendReaction = useCallback((reaction: string | undefined, x: number, y: number) => {
sendReaction({
chatId: message.chatId, messageId: message.id, reaction, x, y, startSize: START_SIZE,
const handleToggleReaction = useCallback((reaction: ApiReaction) => {
toggleReaction({
chatId: message.chatId, messageId: message.id, reaction,
});
closeMenu();
}, [closeMenu, message.chatId, message.id, sendReaction]);
}, [closeMenu, message, toggleReaction]);
const reportMessageIds = useMemo(() => (album ? album.messages : [message]).map(({ id }) => id), [album, message]);
@ -408,7 +403,6 @@ const ContextMenuContainer: FC<OwnProps & StateProps> = ({
anchor={anchor}
canShowReactionsCount={canShowReactionsCount}
canShowReactionList={canShowReactionList}
canRemoveReaction={canRemoveReaction}
canSendNow={canSendNow}
canReschedule={canReschedule}
canReply={canReply}
@ -453,7 +447,7 @@ const ContextMenuContainer: FC<OwnProps & StateProps> = ({
onCancelVote={handleCancelVote}
onClosePoll={openClosePollDialog}
onShowSeenBy={handleOpenSeenByModal}
onSendReaction={handleSendReaction}
onToggleReaction={handleToggleReaction}
onShowReactors={handleOpenReactorListModal}
/>
<DeleteMessageModal
@ -529,7 +523,6 @@ export default memo(withGlobal<OwnProps>(
const isAction = isActionMessage(message);
const canShowReactionsCount = !isLocal && !isChannel && !isScheduled && !isAction && !isPrivate && message.reactions
&& !areReactionsEmpty(message.reactions) && message.reactions.canSeeList;
const canRemoveReaction = isPrivate && message.reactions?.results?.some((l) => l.isChosen);
const isProtected = selectIsMessageProtected(global, message);
const canCopyNumber = Boolean(message.content.contact);
const isCurrentUserPremium = selectIsCurrentUserPremium(global);
@ -569,7 +562,6 @@ export default memo(withGlobal<OwnProps>(
hasFullInfo: Boolean(chat?.fullInfo),
canShowReactionsCount,
canShowReactionList: !isLocal && !isAction && !isScheduled && chat?.id !== SERVICE_NOTIFICATIONS_USER_ID,
canRemoveReaction,
canBuyPremium: !isCurrentUserPremium && !selectIsPremiumPurchaseBlocked(global),
customEmojiSetsInfo,
customEmojiSets,

View File

@ -0,0 +1,35 @@
.root {
position: absolute;
z-index: 10;
}
.particle {
position: absolute;
width: 1rem;
height: 1rem;
border-radius: 0.25rem;
offset-path: var(--offset-path);
offset-rotate: 0deg;
animation: 1.5s particle ease-out;
}
@keyframes particle {
0% {
offset-distance: 0%;
transform: scale(1);
}
50% {
transform: scale(1.25);
}
75% {
opacity: 1;
}
100% {
offset-distance: 100%;
opacity: 0;
transform: scale(1);
}
}

View File

@ -0,0 +1,56 @@
import React, { memo, useMemo } from '../../../lib/teact/teact';
import type { FC } from '../../../lib/teact/teact';
import type { ApiReactionCustomEmoji } from '../../../api/types';
import { getStickerPreviewHash } from '../../../global/helpers';
import { IS_OFFSET_PATH_SUPPORTED } from '../../../util/environment';
import useMedia from '../../../hooks/useMedia';
import styles from './CustomReactionAnimation.module.scss';
type OwnProps = {
reaction: ApiReactionCustomEmoji;
};
const EFFECT_AMOUNT = 7;
const CustomReactionAnimation: FC<OwnProps> = ({
reaction,
}) => {
const stickerHash = getStickerPreviewHash(reaction.documentId);
const previewMediaData = useMedia(stickerHash);
const paths: string[] = useMemo(() => {
if (!IS_OFFSET_PATH_SUPPORTED) return [];
return Array.from({ length: EFFECT_AMOUNT }).map(() => generateRandomDropPath());
}, []);
if (!previewMediaData) return undefined;
return (
<div className={styles.root}>
{paths.map((path) => {
const style = `--offset-path: path('${path}');`;
return (
<img
src={previewMediaData}
alt=""
className={styles.particle}
style={style}
/>
);
})}
</div>
);
};
export default memo(CustomReactionAnimation);
function generateRandomDropPath() {
const x = (10 + Math.random() * 60) * (Math.random() > 0.5 ? 1 : -1);
const y = 20 + Math.random() * 80;
return `M 0 0 C 0 0 ${x} ${-y - 20} ${x} ${y}`;
}

View File

@ -72,6 +72,8 @@
}
.quick-reaction {
--custom-emoji-size: 2rem;
cursor: pointer;
position: absolute;
right: -0.5rem;
@ -79,7 +81,7 @@
display: flex;
align-items: center;
justify-content: center;
transform: scale(1);
transform: scale(0.75);
opacity: 0;
transition: transform 0.2s ease-out, opacity 0.2s ease-out;
transition-delay: 0.2s;
@ -90,16 +92,12 @@
&:hover {
transition-delay: unset;
transform: scale(1.4);
}
.ReactionStaticEmoji {
width: 1.125rem;
transform: scale(1);
}
}
&.last-in-list .quick-reaction:hover {
transform: translateY(-0.1875rem) scale(1.4);
transform: translateY(-0.1875rem) scale(1);
}
&.own .quick-reaction {

View File

@ -19,6 +19,8 @@ import type {
ApiAvailableReaction,
ApiChatMember,
ApiUsername,
ApiReaction,
ApiStickerSet,
} from '../../../api/types';
import type {
AnimationLevel, FocusDirection, IAlbum, ISettings,
@ -83,7 +85,11 @@ import {
import buildClassName from '../../../util/buildClassName';
import useEnsureMessage from '../../../hooks/useEnsureMessage';
import useContextMenuHandlers from '../../../hooks/useContextMenuHandlers';
import { calculateDimensionsForMessageMedia, ROUND_VIDEO_DIMENSIONS_PX } from '../../common/helpers/mediaDimensions';
import {
calculateDimensionsForMessageMedia,
REM,
ROUND_VIDEO_DIMENSIONS_PX,
} from '../../common/helpers/mediaDimensions';
import { buildContentClassName } from './helpers/buildContentClassName';
import { getMinMediaWidth, calculateMediaDimensions } from './helpers/mediaDimensions';
import { calculateAlbumLayout } from './helpers/calculateAlbumLayout';
@ -153,7 +159,6 @@ type OwnProps =
noAvatars?: boolean;
withAvatar?: boolean;
withSenderName?: boolean;
areReactionsInMeta?: boolean;
threadId: number;
messageListType: MessageListType;
noComments: boolean;
@ -194,6 +199,7 @@ type StateProps = {
highlight?: string;
animatedEmoji?: string;
animatedCustomEmoji?: string;
genericEffects?: ApiStickerSet;
isInSelectMode?: boolean;
isSelected?: boolean;
isGroupSelected?: boolean;
@ -207,8 +213,8 @@ type StateProps = {
threadInfo?: ApiThreadInfo;
reactionMessage?: ApiMessage;
availableReactions?: ApiAvailableReaction[];
defaultReaction?: string;
activeReaction?: ActiveReaction;
defaultReaction?: ApiReaction;
activeReactions?: ActiveReaction[];
activeEmojiInteractions?: ActiveEmojiInteraction[];
hasUnreadReaction?: boolean;
isTranscribing?: boolean;
@ -226,7 +232,6 @@ type MetaPosition =
type ReactionsPosition =
'inside'
| 'outside'
| 'in-meta'
| 'none';
const NBSP = '\u00A0';
@ -236,6 +241,7 @@ const APPENDIX_OWN = { __html: '<svg width="9" height="20" xmlns="http://www.w3.
const APPENDIX_NOT_OWN = { __html: '<svg width="9" height="20" xmlns="http://www.w3.org/2000/svg"><defs><filter x="-50%" y="-14.7%" width="200%" height="141.2%" filterUnits="objectBoundingBox" id="a"><feOffset dy="1" in="SourceAlpha" result="shadowOffsetOuter1"/><feGaussianBlur stdDeviation="1" in="shadowOffsetOuter1" result="shadowBlurOuter1"/><feColorMatrix values="0 0 0 0 0.0621962482 0 0 0 0 0.138574144 0 0 0 0 0.185037364 0 0 0 0.15 0" in="shadowBlurOuter1"/></filter></defs><g fill="none" fill-rule="evenodd"><path d="M3 17h6V0c-.193 2.84-.876 5.767-2.05 8.782-.904 2.325-2.446 4.485-4.625 6.48A1 1 0 003 17z" fill="#000" filter="url(#a)"/><path d="M3 17h6V0c-.193 2.84-.876 5.767-2.05 8.782-.904 2.325-2.446 4.485-4.625 6.48A1 1 0 003 17z" fill="#FFF" class="corner"/></g></svg>' };
const APPEARANCE_DELAY = 10;
const NO_MEDIA_CORNERS_THRESHOLD = 18;
const QUICK_REACTION_SIZE = 2 * REM;
const Message: FC<OwnProps & StateProps> = ({
message,
@ -247,7 +253,6 @@ const Message: FC<OwnProps & StateProps> = ({
noAvatars,
withAvatar,
withSenderName,
areReactionsInMeta,
noComments,
appearanceOrder,
isFirstInGroup,
@ -288,6 +293,7 @@ const Message: FC<OwnProps & StateProps> = ({
highlight,
animatedEmoji,
animatedCustomEmoji,
genericEffects,
isInSelectMode,
isSelected,
isGroupSelected,
@ -295,7 +301,7 @@ const Message: FC<OwnProps & StateProps> = ({
reactionMessage,
availableReactions,
defaultReaction,
activeReaction,
activeReactions,
activeEmojiInteractions,
messageListType,
isPinnedList,
@ -502,7 +508,7 @@ const Message: FC<OwnProps & StateProps> = ({
Boolean(message.inlineButtons) && 'has-inline-buttons',
isSwiped && 'is-swiped',
transitionClassNames,
(Boolean(activeReaction) || hasActiveStickerEffect) && 'has-active-reaction',
(Boolean(activeReactions) || hasActiveStickerEffect) && 'has-active-reaction',
);
const {
@ -545,9 +551,7 @@ const Message: FC<OwnProps & StateProps> = ({
}
let reactionsPosition!: ReactionsPosition;
if (areReactionsInMeta) {
reactionsPosition = 'in-meta';
} else if (hasReactions) {
if (hasReactions) {
if (isCustomShape || ((photo || video) && !hasText)) {
reactionsPosition = 'outside';
} else if (asForwarded) {
@ -655,13 +659,10 @@ const Message: FC<OwnProps & StateProps> = ({
const meta = (
<MessageMeta
message={message}
reactionMessage={reactionMessage}
outgoingStatus={outgoingStatus}
signature={signature}
withReactions={reactionsPosition === 'in-meta'}
withReactionOffset={reactionsPosition === 'inside'}
availableReactions={availableReactions}
activeReaction={activeReaction}
onClick={handleMetaClick}
/>
);
@ -672,10 +673,12 @@ const Message: FC<OwnProps & StateProps> = ({
return (
<Reactions
activeReaction={activeReaction}
activeReactions={activeReactions}
message={reactionMessage!}
metaChildren={meta}
availableReactions={availableReactions}
genericEffects={genericEffects}
observeIntersection={observeIntersectionForPlaying}
/>
);
}
@ -1100,10 +1103,15 @@ const Message: FC<OwnProps & StateProps> = ({
)}
{withQuickReactionButton && (
<div
className={buildClassName('quick-reaction', isQuickReactionVisible && !activeReaction && 'visible')}
className={buildClassName('quick-reaction', isQuickReactionVisible && !activeReactions && 'visible')}
onClick={handleSendQuickReaction}
>
<ReactionStaticEmoji reaction={defaultReaction!} />
<ReactionStaticEmoji
reaction={defaultReaction}
size={QUICK_REACTION_SIZE}
availableReactions={availableReactions}
observeIntersection={observeIntersectionForPlaying}
/>
</div>
)}
</div>
@ -1114,8 +1122,10 @@ const Message: FC<OwnProps & StateProps> = ({
<Reactions
message={reactionMessage!}
isOutside
activeReaction={activeReaction}
activeReactions={activeReactions}
availableReactions={availableReactions}
genericEffects={genericEffects}
observeIntersection={observeIntersectionForPlaying}
/>
)}
</div>
@ -1260,7 +1270,7 @@ export default memo(withGlobal<OwnProps>(
threadInfo: actualThreadInfo,
availableReactions: global.availableReactions,
defaultReaction: isMessageLocal(message) ? undefined : selectDefaultReaction(global, chatId),
activeReaction: reactionMessage && global.activeReactions[reactionMessage.id],
activeReactions: reactionMessage && global.activeReactions[reactionMessage.id],
activeEmojiInteractions: global.activeEmojiInteractions,
...(isOutgoing && { outgoingStatus: selectOutgoingStatus(global, message, messageListType === 'scheduled') }),
...(typeof uploadProgress === 'number' && { uploadProgress }),
@ -1271,6 +1281,7 @@ export default memo(withGlobal<OwnProps>(
isPremium: selectIsCurrentUserPremium(global),
animationLevel: global.settings.byKey.animationLevel,
senderAdminMember,
genericEffects: global.genericEmojiEffects,
};
},
)(Message));

View File

@ -1,11 +1,11 @@
import React, {
memo, useMemo, useCallback, useEffect, useRef,
memo, useCallback, useEffect, useRef,
} from '../../../lib/teact/teact';
import { getActions } from '../../../global';
import type { FC } from '../../../lib/teact/teact';
import type {
ApiAvailableReaction, ApiMessage, ApiSponsoredMessage, ApiStickerSet, ApiUser,
ApiAvailableReaction, ApiChatReactions, ApiMessage, ApiReaction, ApiSponsoredMessage, ApiStickerSet, ApiUser,
} from '../../../api/types';
import type { IAnchorPosition } from '../../../types';
@ -35,7 +35,7 @@ type OwnProps = {
anchor: IAnchorPosition;
message: ApiMessage | ApiSponsoredMessage;
canSendNow?: boolean;
enabledReactions?: string[];
enabledReactions?: ApiChatReactions;
maxUniqueReactions?: number;
canReschedule?: boolean;
canReply?: boolean;
@ -45,7 +45,6 @@ type OwnProps = {
canReport?: boolean;
canShowReactionsCount?: boolean;
canShowReactionList?: boolean;
canRemoveReaction?: boolean;
canBuyPremium?: boolean;
canEdit?: boolean;
canForward?: boolean;
@ -90,7 +89,7 @@ type OwnProps = {
onShowReactors?: () => void;
onAboutAds?: () => void;
onSponsoredHide?: () => void;
onSendReaction?: (reaction: string | undefined, x: number, y: number) => void;
onToggleReaction?: (reaction: ApiReaction) => void;
};
const SCROLLBAR_WIDTH = 10;
@ -128,7 +127,6 @@ const MessageContextMenu: FC<OwnProps> = ({
isDownloading,
canShowSeenBy,
canShowReactionsCount,
canRemoveReaction,
canShowReactionList,
seenByRecentUsers,
hasCustomEmoji,
@ -155,7 +153,7 @@ const MessageContextMenu: FC<OwnProps> = ({
onClosePoll,
onShowSeenBy,
onShowReactors,
onSendReaction,
onToggleReaction,
onCopyMessages,
onAboutAds,
onSponsoredHide,
@ -166,18 +164,13 @@ const MessageContextMenu: FC<OwnProps> = ({
// eslint-disable-next-line no-null/no-null
const scrollableRef = useRef<HTMLDivElement>(null);
const lang = useLang();
const noReactions = !isPrivate && !enabledReactions?.length;
const noReactions = !isPrivate && !enabledReactions;
const withReactions = canShowReactionList && !noReactions;
const isSponsoredMessage = !('id' in message);
const messageId = !isSponsoredMessage ? message.id : '';
const [isReady, markIsReady, unmarkIsReady] = useFlag();
const currentReactions = useMemo(() => {
if (isSponsoredMessage) return undefined;
return message.reactions?.results.map((reaction) => reaction.reaction);
}, [isSponsoredMessage, message]);
const handleAfterCopy = useCallback(() => {
showNotification({
message: lang('Share.Link.Copied'),
@ -239,10 +232,6 @@ const MessageContextMenu: FC<OwnProps> = ({
};
}, [withReactions]);
const handleRemoveReaction = useCallback(() => {
onSendReaction!(undefined, 0, 0);
}, [onSendReaction]);
useEffect(() => {
if (!isOpen) {
unmarkIsReady();
@ -280,12 +269,12 @@ const MessageContextMenu: FC<OwnProps> = ({
onClose={onClose}
onCloseAnimationEnd={onCloseAnimationEnd}
>
{canShowReactionList && (
{withReactions && (
<ReactionSelector
enabledReactions={enabledReactions}
currentReactions={currentReactions}
currentReactions={!isSponsoredMessage ? message.reactions?.results : undefined}
maxUniqueReactions={maxUniqueReactions}
onSendReaction={onSendReaction!}
onToggleReaction={onToggleReaction!}
isPrivate={isPrivate}
availableReactions={availableReactions}
isReady={isReady}
@ -299,7 +288,6 @@ const MessageContextMenu: FC<OwnProps> = ({
style={menuStyle}
ref={scrollableRef}
>
{canRemoveReaction && <MenuItem icon="heart-outline" onClick={handleRemoveReaction}>Remove Reaction</MenuItem>}
{canSendNow && <MenuItem icon="send-outline" onClick={onSend}>{lang('MessageScheduleSend')}</MenuItem>}
{canReschedule && (
<MenuItem icon="schedule" onClick={onReschedule}>{lang('MessageScheduleEditTime')}</MenuItem>

View File

@ -13,12 +13,6 @@
max-width: 100%;
user-select: none;
.ReactionAnimatedEmoji {
width: 1rem;
height: 1rem;
margin-right: 0.25rem;
}
.message-time,
.message-imported,
.message-signature,

View File

@ -3,7 +3,6 @@ import React, { memo, useMemo } from '../../../lib/teact/teact';
import { getActions } from '../../../global';
import type { ApiAvailableReaction, ApiMessage, ApiMessageOutgoingStatus } from '../../../api/types';
import type { ActiveReaction } from '../../../global/types';
import { formatDateTimeToString, formatTime } from '../../../util/dateFormat';
import { formatIntegerCompact } from '../../../util/textFormat';
@ -14,32 +13,29 @@ import useFlag from '../../../hooks/useFlag';
import buildClassName from '../../../util/buildClassName';
import MessageOutgoingStatus from '../../common/MessageOutgoingStatus';
import ReactionAnimatedEmoji from './ReactionAnimatedEmoji';
import './MessageMeta.scss';
type OwnProps = {
message: ApiMessage;
reactionMessage?: ApiMessage;
withReactions?: boolean;
withReactionOffset?: boolean;
outgoingStatus?: ApiMessageOutgoingStatus;
signature?: string;
onClick: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
activeReaction?: ActiveReaction;
availableReactions?: ApiAvailableReaction[];
onClick: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
};
const MessageMeta: FC<OwnProps> = ({
message, outgoingStatus, signature, onClick, withReactions,
activeReaction, withReactionOffset, availableReactions,
reactionMessage,
message,
outgoingStatus,
signature,
withReactionOffset,
onClick,
}) => {
const { showNotification } = getActions();
const lang = useLang();
const [isActivated, markActivated] = useFlag();
const reactions = withReactions && reactionMessage?.reactions?.results.filter((l) => l.count > 0);
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation();
@ -80,14 +76,6 @@ const MessageMeta: FC<OwnProps> = ({
onClick={onClick}
data-ignore-on-paste
>
{reactions && reactions.map((l) => (
<ReactionAnimatedEmoji
activeReaction={activeReaction}
reaction={l.reaction}
isInMeta
availableReactions={availableReactions}
/>
))}
{Boolean(message.views) && (
<>
<span className="message-views">

View File

@ -0,0 +1,45 @@
.root {
--custom-emoji-border-radius: 0.25rem;
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 1.125rem;
height: 1.125rem;
margin-right: 0.25rem;
z-index: 2;
&.is-custom-emoji {
margin-right: 0.375rem;
}
}
.animated-icon, .effect {
position: fixed;
top: -0.375rem;
left: -0.375rem;
pointer-events: none;
&.effect {
top: -2.5rem;
left: -2.5rem;
}
&:not(:global(.open)) {
opacity: 1 !important;
}
&:global(.closing) {
opacity: 0 !important;
}
}
.animating {
// Fix for redundant scroll on iOS
transform: translateZ(0);
// Fix for redundant scroll in Firefox
contain: layout;
}

View File

@ -1,52 +0,0 @@
.ReactionAnimatedEmoji {
position: relative;
display: flex;
align-items: center;
justify-content: center;
&.is-animating {
// Fix for redundant scroll on iOS
transform: translateZ(0);
// Fix for redundant scroll in Firefox
contain: layout;
}
.AnimatedSticker {
position: fixed;
top: -0.375rem;
left: -0.375rem;
pointer-events: none;
&.effect {
top: -2.5rem;
left: -2.5rem;
}
&:not(.open) {
opacity: 1 !important;
}
&.closing {
opacity: 0 !important;
}
}
&.in-meta {
.AnimatedSticker {
top: -0.4375rem;
left: -0.4375rem;
&.effect {
top: -2.5625rem;
left: -2.5625rem;
}
// Fix for weird positioning in Chrome
canvas {
position: absolute;
left: 0;
top: 0;
}
}
}
}

View File

@ -1,41 +1,87 @@
import type { FC } from '../../../lib/teact/teact';
import React, { memo, useCallback } from '../../../lib/teact/teact';
import React, {
memo, useCallback, useMemo, useRef,
} from '../../../lib/teact/teact';
import { getActions } from '../../../global';
import type { FC } from '../../../lib/teact/teact';
import type { ActiveReaction } from '../../../global/types';
import type { ApiAvailableReaction } from '../../../api/types';
import type { ApiAvailableReaction, ApiReaction, ApiStickerSet } from '../../../api/types';
import type { ObserveFn } from '../../../hooks/useIntersectionObserver';
import buildClassName from '../../../util/buildClassName';
import { isSameReaction } from '../../../global/helpers';
import { REM } from '../../common/helpers/mediaDimensions';
import useMedia from '../../../hooks/useMedia';
import useShowTransition from '../../../hooks/useShowTransition';
import useFlag from '../../../hooks/useFlag';
import { useIsIntersecting } from '../../../hooks/useIntersectionObserver';
import useCustomEmoji from '../../common/hooks/useCustomEmoji';
import CustomEmoji from '../../common/CustomEmoji';
import ReactionStaticEmoji from '../../common/ReactionStaticEmoji';
import AnimatedSticker from '../../common/AnimatedSticker';
import CustomReactionAnimation from './CustomReactionAnimation';
import './ReactionAnimatedEmoji.scss';
import styles from './ReactionAnimatedEmoji.module.scss';
type OwnProps = {
reaction: string;
activeReaction?: ActiveReaction;
isInMeta?: boolean;
reaction: ApiReaction;
activeReactions?: ActiveReaction[];
availableReactions?: ApiAvailableReaction[];
genericEffects?: ApiStickerSet;
observeIntersection?: ObserveFn;
};
const CENTER_ICON_SIZE = 30;
const EFFECT_SIZE = 100;
const CENTER_ICON_SIZE = 1.875 * REM;
const EFFECT_SIZE = 6.25 * REM;
const ReactionAnimatedEmoji: FC<OwnProps> = ({
reaction,
activeReaction,
isInMeta,
genericEffects,
activeReactions,
availableReactions,
observeIntersection,
}) => {
const { stopActiveReaction } = getActions();
const availableReaction = availableReactions?.find((r) => r.reaction === reaction);
// eslint-disable-next-line no-null/no-null
const ref = useRef<HTMLDivElement>(null);
const isCustom = 'documentId' in reaction;
const availableReaction = useMemo(() => (
availableReactions?.find((r) => isSameReaction(r.reaction, reaction))
), [availableReactions, reaction]);
const centerIconId = availableReaction?.centerIcon?.id;
const effectId = availableReaction?.aroundAnimation?.id;
const customEmoji = useCustomEmoji(isCustom ? reaction.documentId : undefined);
const assignedEffectId = useMemo(() => {
if (!isCustom) return availableReaction?.aroundAnimation?.id;
if (!customEmoji) return undefined;
const assignedId = availableReactions?.find((available) => available.reaction.emoticon === customEmoji.emoji)
?.aroundAnimation?.id;
return assignedId;
}, [availableReaction, availableReactions, customEmoji, isCustom]);
const effectId = useMemo(() => {
if (assignedEffectId) {
return assignedEffectId;
}
if (!genericEffects?.stickers) {
return undefined;
}
const { stickers } = genericEffects;
const randomIndex = Math.floor(Math.random() * stickers.length);
return stickers[randomIndex].id;
}, [assignedEffectId, genericEffects]);
const isIntersecting = useIsIntersecting(ref, observeIntersection);
const mediaHashCenterIcon = centerIconId && `sticker${centerIconId}`;
const mediaHashEffect = effectId && `sticker${effectId}`;
@ -43,51 +89,67 @@ const ReactionAnimatedEmoji: FC<OwnProps> = ({
const mediaDataCenterIcon = useMedia(mediaHashCenterIcon, !centerIconId);
const mediaDataEffect = useMedia(mediaHashEffect, !effectId);
const shouldPlay = Boolean(activeReaction?.reaction === reaction && mediaDataCenterIcon && mediaDataEffect);
const activeReaction = useMemo(() => (
activeReactions?.find((active) => isSameReaction(active.reaction, reaction))
), [activeReactions, reaction]);
const shouldPlay = Boolean(activeReaction && (isCustom || mediaDataCenterIcon) && mediaDataEffect);
const {
shouldRender: shouldRenderAnimation,
transitionClassNames: animationClassNames,
} = useShowTransition(shouldPlay, undefined, true, 'slow');
const handleEnded = useCallback(() => {
stopActiveReaction({ messageId: activeReaction?.messageId, reaction });
if (!activeReaction?.messageId) return;
stopActiveReaction({ messageId: activeReaction.messageId, reaction });
}, [activeReaction?.messageId, reaction, stopActiveReaction]);
const [isAnimationLoaded, markAnimationLoaded, unmarkAnimationLoaded] = useFlag();
const shouldRenderStatic = !shouldPlay || !isAnimationLoaded;
const shouldRenderStatic = !isCustom && (!shouldPlay || !isAnimationLoaded);
const className = buildClassName(
'ReactionAnimatedEmoji',
isInMeta && 'in-meta',
shouldRenderAnimation && 'is-animating',
styles.root,
shouldRenderAnimation && styles.animating,
isCustom && styles.isCustomEmoji,
);
return (
<div className={className}>
{shouldRenderStatic && <ReactionStaticEmoji reaction={reaction} />}
<div className={className} ref={ref}>
{shouldRenderStatic && <ReactionStaticEmoji reaction={reaction} availableReactions={availableReactions} />}
{isCustom && (
<CustomEmoji
documentId={reaction.documentId}
className={styles.customEmoji}
observeIntersectionForPlaying={observeIntersection}
/>
)}
{shouldRenderAnimation && (
<>
<AnimatedSticker
key={centerIconId}
className={animationClassNames}
size={CENTER_ICON_SIZE}
tgsUrl={mediaDataCenterIcon}
play
noLoop
forceOnHeavyAnimation
onLoad={markAnimationLoaded}
onEnded={unmarkAnimationLoaded}
/>
<AnimatedSticker
key={effectId}
className={buildClassName('effect', animationClassNames)}
className={buildClassName(styles.effect, animationClassNames)}
size={EFFECT_SIZE}
tgsUrl={mediaDataEffect}
play
play={isIntersecting}
noLoop
forceOnHeavyAnimation
onEnded={handleEnded}
/>
{isCustom ? (
!assignedEffectId && isIntersecting && <CustomReactionAnimation reaction={reaction} />
) : (
<AnimatedSticker
key={centerIconId}
className={buildClassName(styles.animatedIcon, animationClassNames)}
size={CENTER_ICON_SIZE}
tgsUrl={mediaDataCenterIcon}
play={isIntersecting}
noLoop
forceOnHeavyAnimation
onLoad={markAnimationLoaded}
onEnded={unmarkAnimationLoaded}
/>
)}
</>
)}
</div>

View File

@ -1,14 +1,16 @@
import type { FC } from '../../../lib/teact/teact';
import React, { memo, useCallback, useMemo } from '../../../lib/teact/teact';
import { getActions, getGlobal } from '../../../global';
import type { FC } from '../../../lib/teact/teact';
import type { ObserveFn } from '../../../hooks/useIntersectionObserver';
import type {
ApiAvailableReaction, ApiMessage, ApiReactionCount, ApiUser,
ApiAvailableReaction, ApiMessage, ApiReactionCount, ApiStickerSet, ApiUser,
} from '../../../api/types';
import type { ActiveReaction } from '../../../global/types';
import buildClassName from '../../../util/buildClassName';
import { formatIntegerCompact } from '../../../util/textFormat';
import { isSameReaction, isReactionChosen } from '../../../global/helpers';
import Button from '../../ui/Button';
import Avatar from '../../common/Avatar';
@ -17,25 +19,28 @@ import AnimatedCounter from '../../common/AnimatedCounter';
import './Reactions.scss';
const MAX_REACTORS_AVATARS = 3;
const ReactionButton: FC<{
reaction: ApiReactionCount;
message: ApiMessage;
activeReaction?: ActiveReaction;
activeReactions?: ActiveReaction[];
availableReactions?: ApiAvailableReaction[];
withRecentReactors?: boolean;
genericEffects?: ApiStickerSet;
observeIntersection?: ObserveFn;
}> = ({
reaction,
message,
activeReaction,
activeReactions,
availableReactions,
withRecentReactors,
genericEffects,
observeIntersection,
}) => {
const { sendReaction } = getActions();
const { toggleReaction } = getActions();
const { recentReactions } = message.reactions!;
const recentReactors = useMemo(() => {
if (!recentReactions || reaction.count > MAX_REACTORS_AVATARS) {
if (!withRecentReactors || !recentReactions) {
return undefined;
}
@ -43,29 +48,31 @@ const ReactionButton: FC<{
const usersById = getGlobal().users.byId;
return recentReactions
.filter((recentReaction) => recentReaction.reaction === reaction.reaction)
.filter((recentReaction) => isSameReaction(recentReaction.reaction, reaction.reaction))
.map((recentReaction) => usersById[recentReaction.userId])
.filter(Boolean) as ApiUser[];
}, [reaction, recentReactions]);
}, [reaction.reaction, recentReactions, withRecentReactors]);
const handleClick = useCallback(() => {
sendReaction({
reaction: reaction.isChosen ? undefined : reaction.reaction,
toggleReaction({
reaction: reaction.reaction,
chatId: message.chatId,
messageId: message.id,
});
}, [message, reaction, sendReaction]);
}, [message, reaction, toggleReaction]);
return (
<Button
className={buildClassName(reaction.isChosen && 'chosen')}
className={buildClassName(isReactionChosen(reaction) && 'chosen')}
size="tiny"
onClick={handleClick}
>
<ReactionAnimatedEmoji
activeReaction={activeReaction}
activeReactions={activeReactions}
reaction={reaction.reaction}
availableReactions={availableReactions}
genericEffects={genericEffects}
observeIntersection={observeIntersection}
/>
{recentReactors?.length ? (
<div className="avatars">

View File

@ -3,24 +3,28 @@ import React, {
} from '../../../lib/teact/teact';
import type { FC } from '../../../lib/teact/teact';
import type { ApiAvailableReaction } from '../../../api/types';
import type {
ApiAvailableReaction, ApiChatReactions, ApiReaction, ApiReactionCount,
} from '../../../api/types';
import useHorizontalScroll from '../../../hooks/useHorizontalScroll';
import useFlag from '../../../hooks/useFlag';
import { getTouchY } from '../../../util/scrollLock';
import { createClassNameBuilder } from '../../../util/buildClassName';
import { IS_COMPACT_MENU } from '../../../util/environment';
import { isSameReaction, canSendReaction, getReactionUniqueKey } from '../../../global/helpers';
import useHorizontalScroll from '../../../hooks/useHorizontalScroll';
import useFlag from '../../../hooks/useFlag';
import ReactionSelectorReaction from './ReactionSelectorReaction';
import './ReactionSelector.scss';
type OwnProps = {
enabledReactions?: string[];
onSendReaction: (reaction: string, x: number, y: number) => void;
enabledReactions?: ApiChatReactions;
onToggleReaction: (reaction: ApiReaction) => void;
isPrivate?: boolean;
availableReactions?: ApiAvailableReaction[];
currentReactions?: string[];
currentReactions?: ApiReactionCount[];
maxUniqueReactions?: number;
isReady?: boolean;
canBuyPremium?: boolean;
@ -36,7 +40,7 @@ const ReactionSelector: FC<OwnProps> = ({
maxUniqueReactions,
isPrivate,
isReady,
onSendReaction,
onToggleReaction,
}) => {
// eslint-disable-next-line no-null/no-null
const itemsScrollRef = useRef<HTMLDivElement>(null);
@ -57,15 +61,26 @@ const ReactionSelector: FC<OwnProps> = ({
};
const reactionsToRender = useMemo(() => {
return availableReactions?.map((reaction) => {
if (reaction.isInactive) return undefined;
if (!isPrivate && (!enabledReactions || !enabledReactions.includes(reaction.reaction))) return undefined;
return availableReactions?.map((availableReaction) => {
if (availableReaction.isInactive) return undefined;
if (!isPrivate && (!enabledReactions || !canSendReaction(availableReaction.reaction, enabledReactions))) {
return undefined;
}
if (maxUniqueReactions && currentReactions && currentReactions.length >= maxUniqueReactions
&& !currentReactions.includes(reaction.reaction)) return undefined;
return reaction;
&& !currentReactions.some(({ reaction }) => isSameReaction(reaction, availableReaction.reaction))) {
return undefined;
}
return availableReaction;
}) || [];
}, [availableReactions, currentReactions, enabledReactions, isPrivate, maxUniqueReactions]);
const userReactionIndexes = useMemo(() => {
const chosenReactions = currentReactions?.filter(({ chosenOrder }) => chosenOrder !== undefined) || [];
return new Set(chosenReactions.map(({ reaction }) => (
reactionsToRender.findIndex((r) => r && isSameReaction(r.reaction, reaction))
)));
}, [currentReactions, reactionsToRender]);
if (!reactionsToRender.length) return undefined;
return (
@ -78,11 +93,12 @@ const ReactionSelector: FC<OwnProps> = ({
if (!reaction) return undefined;
return (
<ReactionSelectorReaction
key={reaction.reaction}
key={getReactionUniqueKey(reaction.reaction)}
previewIndex={i}
isReady={isReady}
onSendReaction={onSendReaction}
onToggleReaction={onToggleReaction}
reaction={reaction}
chosen={userReactionIndexes.has(i)}
/>
);
})}

View File

@ -29,4 +29,16 @@
min-width: 1.5rem;
min-height: 1.5rem;
}
&--chosen::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 120%;
height: 120%;
border-radius: 50%;
background-color: var(--color-background-compact-menu-hover);
}
}

View File

@ -1,7 +1,7 @@
import type { FC } from '../../../lib/teact/teact';
import React, { memo, useRef } from '../../../lib/teact/teact';
import React, { memo } from '../../../lib/teact/teact';
import type { ApiAvailableReaction } from '../../../api/types';
import type { FC } from '../../../lib/teact/teact';
import type { ApiAvailableReaction, ApiReaction } from '../../../api/types';
import { IS_COMPACT_MENU } from '../../../util/environment';
import { createClassNameBuilder } from '../../../util/buildClassName';
@ -18,17 +18,19 @@ type OwnProps = {
reaction: ApiAvailableReaction;
previewIndex: number;
isReady?: boolean;
onSendReaction: (reaction: string, x: number, y: number) => void;
chosen?: boolean;
onToggleReaction: (reaction: ApiReaction) => void;
};
const cn = createClassNameBuilder('ReactionSelectorReaction');
const ReactionSelectorReaction: FC<OwnProps> = ({
reaction, previewIndex, onSendReaction, isReady,
reaction,
previewIndex,
isReady,
chosen,
onToggleReaction,
}) => {
// eslint-disable-next-line no-null/no-null
const containerRef = useRef<HTMLDivElement>(null);
const mediaData = useMedia(`document${reaction.selectAnimation?.id}`, !isReady);
const [isActivated, activate, deactivate] = useFlag();
@ -38,17 +40,13 @@ const ReactionSelectorReaction: FC<OwnProps> = ({
const shouldRenderAnimated = Boolean(isReady && mediaData);
function handleClick() {
if (!containerRef.current) return;
const { x, y } = containerRef.current.getBoundingClientRect();
onSendReaction(reaction.reaction, x, y);
onToggleReaction(reaction.reaction);
}
return (
<div
className={cn('&', IS_COMPACT_MENU && 'compact')}
className={cn('&', IS_COMPACT_MENU && 'compact', chosen && 'chosen')}
onClick={handleClick}
ref={containerRef}
onMouseEnter={isReady ? activate : undefined}
>
{shouldRenderStatic && (

View File

@ -23,12 +23,7 @@
text-transform: none;
color: var(--accent-color);
overflow: visible;
.ReactionAnimatedEmoji, .icon-heart {
width: 1.125rem;
height: 1.125rem;
margin-right: 0.25rem;
}
line-height: 1.25rem;
.avatars {
display: flex;

View File

@ -1,9 +1,11 @@
import React, { memo, useMemo } from '../../../lib/teact/teact';
import type { FC } from '../../../lib/teact/teact';
import React, { memo } from '../../../lib/teact/teact';
import type { ApiAvailableReaction, ApiMessage } from '../../../api/types';
import type { ApiAvailableReaction, ApiMessage, ApiStickerSet } from '../../../api/types';
import type { ActiveReaction } from '../../../global/types';
import type { ObserveFn } from '../../../hooks/useIntersectionObserver';
import { getReactionUniqueKey } from '../../../global/helpers';
import buildClassName from '../../../util/buildClassName';
import ReactionButton from './ReactionButton';
@ -13,27 +15,40 @@ import './Reactions.scss';
type OwnProps = {
message: ApiMessage;
isOutside?: boolean;
activeReaction?: ActiveReaction;
activeReactions?: ActiveReaction[];
availableReactions?: ApiAvailableReaction[];
metaChildren?: React.ReactNode;
genericEffects?: ApiStickerSet;
observeIntersection?: ObserveFn;
};
const MAX_RECENT_AVATARS = 3;
const Reactions: FC<OwnProps> = ({
message,
isOutside,
activeReaction,
activeReactions,
availableReactions,
metaChildren,
genericEffects,
observeIntersection,
}) => {
const totalCount = useMemo(() => (
message.reactions!.results.reduce((acc, reaction) => acc + reaction.count, 0)
), [message]);
return (
<div className={buildClassName('Reactions', isOutside && 'is-outside')}>
{message.reactions!.results.map((reaction) => (
<ReactionButton
key={reaction.reaction}
key={getReactionUniqueKey(reaction.reaction)}
reaction={reaction}
message={message}
activeReaction={activeReaction}
activeReactions={activeReactions}
availableReactions={availableReactions}
withRecentReactors={totalCount <= MAX_RECENT_AVATARS}
genericEffects={genericEffects}
observeIntersection={observeIntersection}
/>
))}
{metaChildren}

View File

@ -712,7 +712,7 @@
--custom-emoji-size: var(--emoji-only-size);
.emoji {
.AnimatedEmoji {
width: var(--emoji-only-size);
height: var(--emoji-only-size);
}

View File

@ -63,13 +63,10 @@ export default function useOuterHandlers(
}
}
function handleSendQuickReaction(e: React.MouseEvent) {
const { x, y } = e.currentTarget.getBoundingClientRect();
function handleSendQuickReaction() {
sendDefaultReaction({
chatId,
messageId,
x,
y,
});
}
@ -90,14 +87,10 @@ export default function useOuterHandlers(
}
}
function handleDoubleTap(e: React.MouseEvent<HTMLDivElement, MouseEvent>) {
const { pageX: x, pageY: y } = e;
function handleDoubleTap() {
sendDefaultReaction({
chatId,
messageId,
x,
y,
});
}
@ -112,7 +105,7 @@ export default function useOuterHandlers(
if (doubleTapTimeoutRef.current) {
clearInterval(doubleTapTimeoutRef.current);
doubleTapTimeoutRef.current = undefined;
handleDoubleTap(e);
handleDoubleTap();
return;
}

View File

@ -6,7 +6,7 @@ import React, {
import { getActions, withGlobal } from '../../../global';
import { ManagementScreens, ManagementProgress } from '../../../types';
import type { ApiChat, ApiExportedInvite } from '../../../api/types';
import type { ApiAvailableReaction, ApiChat, ApiExportedInvite } from '../../../api/types';
import { ApiMediaFormat } from '../../../api/types';
import { getChatAvatarHash, getHasAdminRight, isChatPublic } from '../../../global/helpers';
@ -43,7 +43,7 @@ type StateProps = {
canInvite?: boolean;
exportedInvites?: ApiExportedInvite[];
lastSyncTime?: number;
availableReactionsCount?: number;
availableReactions?: ApiAvailableReaction[];
};
const CHANNEL_TITLE_EMPTY = 'Channel title can\'t be empty';
@ -58,10 +58,10 @@ const ManageChannel: FC<OwnProps & StateProps> = ({
canInvite,
exportedInvites,
lastSyncTime,
availableReactionsCount,
isActive,
availableReactions,
onScreenSelect,
onClose,
isActive,
}) => {
const {
updateChat,
@ -191,7 +191,21 @@ const ManageChannel: FC<OwnProps & StateProps> = ({
openChat({ id: undefined });
}, [chat.isCreator, chat.id, closeDeleteDialog, closeManagement, leaveChannel, deleteChannel, openChat]);
const enabledReactionsCount = chat.fullInfo?.enabledReactions?.length || 0;
const chatReactionsDescription = useMemo(() => {
if (!chat.fullInfo?.enabledReactions) {
return lang('ReactionsOff');
}
if (chat.fullInfo.enabledReactions.type === 'all') {
return lang('ReactionsAll');
}
const enabledLength = chat.fullInfo.enabledReactions.allowed.length;
const totalLength = availableReactions?.filter((reaction) => !reaction.isInactive).length || 0;
const text = totalLength ? `${enabledLength} / ${totalLength}` : `${enabledLength}`;
return text;
}, [availableReactions, chat, lang]);
const isChannelPublic = useMemo(() => isChatPublic(chat), [chat]);
if (chat.isRestricted || chat.isForbidden) {
@ -275,7 +289,7 @@ const ManageChannel: FC<OwnProps & StateProps> = ({
>
<span className="title">{lang('Reactions')}</span>
<span className="subtitle" dir="auto">
{enabledReactionsCount}/{availableReactionsCount}
{chatReactionsDescription}
</span>
</ListItem>
<div className="ListItem no-selection narrow">
@ -358,7 +372,7 @@ export default memo(withGlobal<OwnProps>(
canInvite: getHasAdminRight(chat, 'inviteUsers'),
lastSyncTime: global.lastSyncTime,
exportedInvites: invites,
availableReactionsCount: global.availableReactions?.filter((l) => !l.isInactive).length,
availableReactions: global.availableReactions,
};
},
)(ManageChannel));

View File

@ -6,7 +6,9 @@ import React, {
import { getActions, withGlobal } from '../../../global';
import { ManagementScreens, ManagementProgress } from '../../../types';
import type { ApiChat, ApiChatBannedRights, ApiExportedInvite } from '../../../api/types';
import type {
ApiAvailableReaction, ApiChat, ApiChatBannedRights, ApiExportedInvite,
} from '../../../api/types';
import { ApiMediaFormat } from '../../../api/types';
import {
@ -51,8 +53,8 @@ type StateProps = {
canInvite?: boolean;
exportedInvites?: ApiExportedInvite[];
lastSyncTime?: number;
availableReactionsCount?: number;
isChannelsPremiumLimitReached: boolean;
availableReactions?: ApiAvailableReaction[];
};
const GROUP_TITLE_EMPTY = 'Group title can\'t be empty';
@ -71,13 +73,13 @@ const ManageGroup: FC<OwnProps & StateProps> = ({
canChangeInfo,
canBanUsers,
canInvite,
onScreenSelect,
onClose,
isActive,
exportedInvites,
lastSyncTime,
availableReactionsCount,
isChannelsPremiumLimitReached,
availableReactions,
onScreenSelect,
onClose,
}) => {
const {
togglePreHistoryHidden,
@ -212,7 +214,21 @@ const ManageGroup: FC<OwnProps & StateProps> = ({
checkbox.checked = !chat.fullInfo?.isPreHistoryHidden;
}, [isChannelsPremiumLimitReached, chat.fullInfo?.isPreHistoryHidden]);
const enabledReactionsCount = chat.fullInfo?.enabledReactions?.length || 0;
const chatReactionsDescription = useMemo(() => {
if (!chat.fullInfo?.enabledReactions) {
return lang('ReactionsOff');
}
if (chat.fullInfo.enabledReactions.type === 'all') {
return lang('ReactionsAll');
}
const enabledLength = chat.fullInfo.enabledReactions.allowed.length;
const totalLength = availableReactions?.filter((reaction) => !reaction.isInactive).length || 0;
const text = totalLength ? `${enabledLength} / ${totalLength}` : `${enabledLength}`;
return text;
}, [availableReactions, chat, lang]);
const enabledPermissionsCount = useMemo(() => {
if (!chat.defaultBannedRights) {
@ -330,7 +346,7 @@ const ManageGroup: FC<OwnProps & StateProps> = ({
>
<span className="title">{lang('Reactions')}</span>
<span className="subtitle" dir="auto">
{enabledReactionsCount}/{availableReactionsCount}
{chatReactionsDescription}
</span>
</ListItem>
<ListItem
@ -437,8 +453,8 @@ export default memo(withGlobal<OwnProps>(
canInvite: isBasicGroup ? chat.isCreator : getHasAdminRight(chat, 'inviteUsers'),
exportedInvites: invites,
lastSyncTime: global.lastSyncTime,
availableReactionsCount: global.availableReactions?.filter((l) => !l.isInactive).length,
isChannelsPremiumLimitReached: global.limitReachedModal?.limit === 'channels',
availableReactions: global.availableReactions,
};
},
)(ManageGroup));

View File

@ -4,8 +4,11 @@ import React, {
} from '../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../global';
import type { ApiAvailableReaction, ApiChat } from '../../../api/types';
import type {
ApiAvailableReaction, ApiChat, ApiChatReactions, ApiReaction,
} from '../../../api/types';
import { isSameReaction } from '../../../global/helpers';
import { selectChat } from '../../../global/selectors';
import useLang from '../../../hooks/useLang';
import useHistoryBack from '../../../hooks/useHistoryBack';
@ -14,6 +17,7 @@ import ReactionStaticEmoji from '../../common/ReactionStaticEmoji';
import Checkbox from '../../ui/Checkbox';
import FloatingActionButton from '../../ui/FloatingActionButton';
import Spinner from '../../ui/Spinner';
import RadioGroup from '../../ui/RadioGroup';
type OwnProps = {
chatId: string;
@ -24,7 +28,7 @@ type OwnProps = {
type StateProps = {
chat?: ApiChat;
availableReactions?: ApiAvailableReaction[];
enabledReactions?: string[];
enabledReactions?: ApiChatReactions;
};
const ManageReactions: FC<OwnProps & StateProps> = ({
@ -39,13 +43,24 @@ const ManageReactions: FC<OwnProps & StateProps> = ({
const lang = useLang();
const [isTouched, setIsTouched] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [localEnabledReactions, setLocalEnabledReactions] = useState(enabledReactions || []);
const [localEnabledReactions, setLocalEnabledReactions] = useState<ApiChatReactions | undefined>(enabledReactions);
useHistoryBack({
isActive,
onBack: onClose,
});
const reactionsOptions = useMemo(() => [{
value: 'all',
label: lang('AllReactions'),
}, {
value: 'some',
label: lang('SomeReactions'),
}, {
value: 'none',
label: lang('NoReactions'),
}], [lang]);
const handleSaveReactions = useCallback(() => {
if (!chat) return;
setIsLoading(true);
@ -59,24 +74,46 @@ const ManageReactions: FC<OwnProps & StateProps> = ({
useEffect(() => {
setIsLoading(false);
setIsTouched(false);
setLocalEnabledReactions(enabledReactions || []);
setLocalEnabledReactions(enabledReactions);
}, [enabledReactions]);
const availableActiveReactions = useMemo<ApiAvailableReaction[] | undefined>(
() => availableReactions?.filter((l) => !l.isInactive),
() => availableReactions?.filter(({ isInactive }) => !isInactive),
[availableReactions],
);
const handleReactionsOptionChange = useCallback((value: string) => {
if (value === 'all') {
setLocalEnabledReactions({ type: 'all' });
} else if (value === 'some') {
setLocalEnabledReactions({
type: 'some',
allowed: enabledReactions?.type === 'some' ? enabledReactions.allowed : [],
});
} else {
setLocalEnabledReactions(undefined);
}
setIsTouched(true);
}, [enabledReactions]);
const handleReactionChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
if (!chat || !availableActiveReactions) return;
const { name, checked } = e.currentTarget;
const newEnabledReactions = name === 'all' ? (checked ? availableActiveReactions.map((l) => l.reaction) : [])
: (!checked
? localEnabledReactions.filter((l) => l !== name)
: [...localEnabledReactions, name]);
setLocalEnabledReactions(newEnabledReactions);
if (localEnabledReactions?.type === 'some') {
const reaction = { emoticon: name } as ApiReaction;
if (checked) {
setLocalEnabledReactions({
type: 'some',
allowed: [...localEnabledReactions.allowed, reaction],
});
} else {
setLocalEnabledReactions({
type: 'some',
allowed: localEnabledReactions.allowed.filter((local) => !isSameReaction(local, reaction)),
});
}
}
setIsTouched(true);
}, [availableActiveReactions, chat, localEnabledReactions]);
@ -84,31 +121,43 @@ const ManageReactions: FC<OwnProps & StateProps> = ({
<div className="Management">
<div className="custom-scroll">
<div className="section">
<div className="ListItem no-selection">
<Checkbox
name="all"
checked={!localEnabledReactions || localEnabledReactions.length > 0}
label={lang('EnableReactions')}
onChange={handleReactionChange}
/>
</div>
{availableActiveReactions?.map(({ reaction, title }) => (
<div className="ListItem no-selection">
<Checkbox
name={reaction}
checked={!localEnabledReactions || localEnabledReactions?.includes(reaction)}
disabled={localEnabledReactions?.length === 0}
label={(
<div className="Reaction">
<ReactionStaticEmoji reaction={reaction} />
{title}
</div>
)}
onChange={handleReactionChange}
/>
</div>
))}
<h3 className="section-heading">
{lang('AvailableReactions')}
</h3>
<RadioGroup
selected={localEnabledReactions?.type || 'none'}
name="reactions"
options={reactionsOptions}
onChange={handleReactionsOptionChange}
/>
<p className="section-info mt-4">
{localEnabledReactions?.type === 'all' && lang('EnableAllReactionsInfo')}
{localEnabledReactions?.type === 'some' && lang('EnableSomeReactionsInfo')}
{!localEnabledReactions && lang('DisableReactionsInfo')}
</p>
</div>
{localEnabledReactions?.type === 'some' && (
<div className="section">
<h3 className="section-heading">
{lang('AvailableReactions')}
</h3>
{availableActiveReactions?.map(({ reaction, title }) => (
<div className="ListItem no-selection">
<Checkbox
name={reaction.emoticon}
checked={localEnabledReactions?.allowed.some((r) => isSameReaction(reaction, r))}
label={(
<div className="Reaction">
<ReactionStaticEmoji reaction={reaction} availableReactions={availableReactions} />
{title}
</div>
)}
onChange={handleReactionChange}
/>
</div>
))}
</div>
)}
</div>
<FloatingActionButton

View File

@ -7,6 +7,7 @@ import {
selectChatMessage, selectCurrentChat,
selectDefaultReaction,
selectLocalAnimatedEmojiEffectByName,
selectMaxUserReactions,
selectMessageIdsByGroupId,
} from '../../selectors';
import { addMessageReaction, subtractXForEmojiInteraction, updateUnreadReactions } from '../../reducers/reactions';
@ -15,7 +16,7 @@ import {
} from '../../reducers';
import { buildCollectionByKey, omit } from '../../../util/iteratees';
import { ANIMATION_LEVEL_MAX } from '../../../config';
import { isMessageLocal } from '../../helpers';
import { isSameReaction, getUserReactions, isMessageLocal } from '../../helpers';
const INTERACTION_RANDOM_OFFSET = 40;
@ -85,30 +86,24 @@ addActionHandler('sendEmojiInteraction', (global, actions, payload) => {
addActionHandler('sendDefaultReaction', (global, actions, payload) => {
const {
chatId, messageId, x, y,
chatId, messageId,
} = payload;
const reaction = selectDefaultReaction(global, chatId);
const message = selectChatMessage(global, chatId, messageId);
if (!reaction || !message || isMessageLocal(message)) return;
actions.sendReaction({
actions.toggleReaction({
chatId,
messageId,
reaction,
x,
y,
});
});
addActionHandler('sendReaction', (global, actions, payload) => {
const {
chatId,
}: { chatId: string } = payload;
addActionHandler('toggleReaction', (global, actions, payload) => {
const { chatId, reaction } = payload;
let { messageId } = payload;
let { reaction } = payload;
const chat = selectChat(global, chatId);
let message = selectChatMessage(global, chatId, messageId);
@ -125,30 +120,38 @@ addActionHandler('sendReaction', (global, actions, payload) => {
: message;
messageId = message?.id || messageId;
if (message.reactions?.results?.some((l) => l.reaction === reaction && l.isChosen)) {
reaction = undefined;
}
const userReactions = getUserReactions(message);
const hasReaction = userReactions.some((userReaction) => isSameReaction(userReaction, reaction));
void callApi('sendReaction', { chat, messageId, reaction });
const newUserReactions = hasReaction
? 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;
if (animationLevel === ANIMATION_LEVEL_MAX) {
const newActiveReactions = hasReaction ? omit(global.activeReactions, [messageId]) : {
...global.activeReactions,
[messageId]: [
...(global.activeReactions[messageId] || []),
{
messageId,
reaction,
},
],
};
global = {
...global,
activeReactions: {
...(reaction ? global.activeReactions : omit(global.activeReactions, [messageId])),
...(reaction && {
[messageId]: {
reaction,
messageId,
},
}),
},
activeReactions: newActiveReactions,
};
}
return addMessageReaction(global, chatId, messageId, reaction);
return addMessageReaction(global, message, reactions);
});
addActionHandler('openChat', (global) => {
@ -161,13 +164,21 @@ addActionHandler('openChat', (global) => {
addActionHandler('stopActiveReaction', (global, actions, payload) => {
const { messageId, reaction } = payload;
if (global.activeReactions[messageId]?.reaction !== reaction) {
if (!global.activeReactions[messageId]?.some((active) => isSameReaction(active.reaction, reaction))) {
return global;
}
const newMessageActiveReactions = global.activeReactions[messageId]
.filter((active) => !isSameReaction(active.reaction, reaction));
const newActiveReactions = newMessageActiveReactions.length ? {
...global.activeReactions,
[messageId]: newMessageActiveReactions,
} : omit(global.activeReactions, [messageId]);
return {
...global,
activeReactions: omit(global.activeReactions, [messageId]),
activeReactions: newActiveReactions,
};
});
@ -200,7 +211,7 @@ addActionHandler('stopActiveEmojiInteraction', (global, actions, payload) => {
return {
...global,
activeEmojiInteractions: global.activeEmojiInteractions?.filter((l) => l.id !== id),
activeEmojiInteractions: global.activeEmojiInteractions?.filter((active) => active.id !== id),
};
});
@ -349,16 +360,16 @@ addActionHandler('animateUnreadReaction', (global, actions, payload) => {
if (!message) return undefined;
const unread = message.reactions?.recentReactions?.find((l) => l.isUnread);
const unread = message.reactions?.recentReactions?.filter(({ isUnread }) => isUnread);
if (!unread) return undefined;
const reaction = unread?.reaction;
const reactions = unread.map((recent) => recent.reaction);
return [messageId, {
return [messageId, reactions.map((r) => ({
messageId,
reaction,
}];
reaction: r,
}))];
}).filter(Boolean)),
},
};

View File

@ -167,9 +167,21 @@ addActionHandler('loadGreetingStickers', async (global) => {
});
});
addActionHandler('loadFeaturedStickers', (global) => {
addActionHandler('loadFeaturedStickers', async (global) => {
const { hash } = global.stickers.featured || {};
void loadFeaturedStickers(hash);
const featuredStickers = await callApi('fetchFeaturedStickers', { hash });
if (!featuredStickers) {
return;
}
global = getGlobal();
setGlobal(updateStickerSets(
global,
'featured',
featuredStickers.hash,
featuredStickers.sets,
));
});
addActionHandler('loadPremiumGifts', async () => {
@ -193,9 +205,39 @@ addActionHandler('loadStickers', (global, actions, payload) => {
void loadStickers(stickerSetInfo);
});
addActionHandler('loadAnimatedEmojis', () => {
void loadAnimatedEmojis();
void loadAnimatedEmojiEffects();
addActionHandler('loadAnimatedEmojis', async (global) => {
const [emojis, effects] = await Promise.all([
callApi('fetchAnimatedEmojis'),
callApi('fetchAnimatedEmojiEffects'),
]);
if (!emojis || !effects) {
return;
}
global = getGlobal();
global = replaceAnimatedEmojis(global, { ...emojis.set, stickers: emojis.stickers });
global = {
...global,
animatedEmojiEffects: { ...effects.set, stickers: effects.stickers },
};
setGlobal(global);
});
addActionHandler('loadGenericEmojiEffects', async (global) => {
const stickerSet = await callApi('fetchGenericEmojiEffects');
if (!stickerSet) {
return;
}
global = getGlobal();
const { set, stickers } = stickerSet;
setGlobal({
...global,
genericEmojiEffects: { ...set, stickers },
});
});
addActionHandler('loadSavedGifs', (global) => {
@ -405,20 +447,6 @@ async function loadFavoriteStickers(hash?: string) {
});
}
async function loadFeaturedStickers(hash?: string) {
const featuredStickers = await callApi('fetchFeaturedStickers', { hash });
if (!featuredStickers) {
return;
}
setGlobal(updateStickerSets(
getGlobal(),
'featured',
featuredStickers.hash,
featuredStickers.sets,
));
}
async function loadStickers(stickerSetInfo: ApiStickerSetInfo) {
const stickerSet = await callApi(
'fetchStickers',
@ -453,31 +481,6 @@ async function loadStickers(stickerSetInfo: ApiStickerSetInfo) {
setGlobal(global);
}
async function loadAnimatedEmojis() {
const stickerSet = await callApi('fetchAnimatedEmojis');
if (!stickerSet) {
return;
}
const { set, stickers } = stickerSet;
setGlobal(replaceAnimatedEmojis(getGlobal(), { ...set, stickers }));
}
async function loadAnimatedEmojiEffects() {
const stickerSet = await callApi('fetchAnimatedEmojiEffects');
if (!stickerSet) {
return;
}
const { set, stickers } = stickerSet;
setGlobal({
...getGlobal(),
animatedEmojiEffects: { ...set, stickers },
});
}
function unfaveSticker(sticker: ApiSticker) {
const global = getGlobal();

View File

@ -351,6 +351,28 @@ function unsafeMigrateCache(cached: GlobalState, initialState: GlobalState) {
return acc;
}, {} as Record<string, ApiChat>);
}
// TODO Remove in Apr 2023 (this was re-designed but can be hardcoded in cache)
if (cached.messages.byChatId) {
const wasUpdated = Object.values(cached.messages.byChatId)
.some((messages) => Object.values(messages.byId).some(({ reactions }) => {
return reactions?.results[0]?.reaction && typeof reactions.results[0].reaction !== 'string';
}));
if (!wasUpdated) {
for (const messages of Object.values(cached.messages.byChatId)) {
for (const message of Object.values(messages.byId)) {
delete message.reactions;
}
}
}
}
if (typeof cached.config?.defaultReaction === 'string') {
cached.config.defaultReaction = { emoticon: cached.config.defaultReaction };
}
if (typeof cached.availableReactions?.[0].reaction === 'string') {
cached.availableReactions = cached.availableReactions
.map((r) => ({ ...r, reaction: { emoticon: r.reaction as unknown as string } }));
}
}
function updateCache() {

View File

@ -1,5 +1,5 @@
import type {
ApiChat, ApiMessage, ApiMessageEntityTextUrl, ApiReactions, ApiUser,
ApiChat, ApiMessage, ApiMessageEntityTextUrl, ApiUser,
} from '../../api/types';
import { ApiMessageEntityTypes } from '../../api/types';
import type { LangFn } from '../../hooks/useLang';
@ -237,10 +237,6 @@ export function getMessageContentFilename(message: ApiMessage) {
return baseFilename;
}
export function areReactionsEmpty(reactions: ApiReactions) {
return !reactions.results.some((l) => l.count > 0);
}
export function isGeoLiveExpired(message: ApiMessage, timestamp = Date.now() / 1000) {
const { location } = message.content;
if (location?.type !== 'geoLive') return false;

View File

@ -1,5 +1,9 @@
import type {
ApiMessage, ApiReactions,
ApiChatReactions,
ApiMessage,
ApiReaction,
ApiReactions,
ApiReactionCount,
} from '../../api/types';
import type { GlobalState } from '../types';
@ -12,3 +16,53 @@ export function checkIfHasUnreadReactions(global: GlobalState, reactions: ApiRea
({ isUnread, userId }) => isUnread && userId !== currentUserId,
);
}
export function areReactionsEmpty(reactions: ApiReactions) {
return !reactions.results.some((l) => l.count > 0);
}
export function isSameReaction(first?: ApiReaction, second?: ApiReaction) {
if (!first || !second) {
return false;
}
if ('emoticon' in first && 'emoticon' in second) {
return first.emoticon === second.emoticon;
}
if ('documentId' in first && 'documentId' in second) {
return first.documentId === second.documentId;
}
return false;
}
export function canSendReaction(reaction: ApiReaction, chatReactions: ApiChatReactions) {
if (chatReactions.type === 'all') {
return 'emoticon' in reaction || chatReactions.areCustomAllowed;
}
if (chatReactions.type === 'some') {
return chatReactions.allowed.some((r) => isSameReaction(r, reaction));
}
return false;
}
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)
.map((r) => r.reaction) || [];
}
export function getReactionUniqueKey(reaction: ApiReaction) {
if ('emoticon' in reaction) {
return reaction.emoticon;
}
return reaction.documentId;
}
export function isReactionChosen(reaction: ApiReactionCount) {
return reaction.chosenOrder !== undefined;
}

View File

@ -1,6 +1,6 @@
import { updateChatMessage } from './messages';
import type { GlobalState } from '../types';
import { selectChatMessage } from '../selectors';
import type { ApiChat, ApiMessage, ApiReaction } from '../../api/types';
import { MIN_SCREEN_WIDTH_FOR_STATIC_LEFT_COLUMN, MIN_SCREEN_WIDTH_FOR_STATIC_RIGHT_COLUMN } from '../../config';
import {
MIN_LEFT_COLUMN_WIDTH,
@ -9,7 +9,8 @@ import {
import { IS_SINGLE_COLUMN_LAYOUT } from '../../util/environment';
import windowSize from '../../util/windowSize';
import { updateChat } from './chats';
import type { ApiChat } from '../../api/types';
import { isSameReaction, isReactionChosen } from '../helpers';
import { updateChatMessage } from './messages';
function getLeftColumnWidth(windowWidth: number) {
if (windowWidth > MIN_SCREEN_WIDTH_FOR_STATIC_RIGHT_COLUMN) {
@ -35,44 +36,54 @@ export function subtractXForEmojiInteraction(global: GlobalState, x: number) {
: 0);
}
export function addMessageReaction(global: GlobalState, chatId: string, messageId: number, reaction: string) {
const reactions = selectChatMessage(global, chatId, messageId)?.reactions || { results: [] };
export function addMessageReaction(
global: GlobalState, message: ApiMessage, userReactions: ApiReaction[],
) {
const currentReactions = message.reactions || { results: [] };
// Update UI without waiting for server response
let results = reactions.results.map((l) => (l.reaction === reaction
? {
...l,
count: l.isChosen ? l.count : l.count + 1,
isChosen: true,
} : (l.isChosen ? {
...l,
isChosen: false,
count: l.count - 1,
} : l)))
.filter((l) => l.count > 0);
const results = currentReactions.results.map((current) => (
isReactionChosen(current) ? {
...current,
chosenOrder: undefined,
count: current.count - 1,
} : current
)).filter(({ count }) => count > 0);
let { recentReactions } = reactions;
if (reaction && !results.some((l) => l.reaction === reaction)) {
const { currentUserId } = global;
results = [...results, {
reaction,
isChosen: true,
count: 1,
}];
if (reactions.canSeeList) {
recentReactions = [...(recentReactions || []), {
userId: currentUserId!,
userReactions.forEach((reaction, i) => {
const existingIndex = results.findIndex((r) => isSameReaction(r.reaction, reaction));
if (existingIndex > -1) {
results[existingIndex] = {
...results[existingIndex],
chosenOrder: i,
count: results[existingIndex].count + 1,
};
} else {
results.push({
reaction,
}];
chosenOrder: i,
count: 1,
});
}
});
let { recentReactions = [] } = currentReactions;
if (recentReactions.length) {
recentReactions = recentReactions.filter(({ userId }) => userId !== global.currentUserId);
}
return updateChatMessage(global, chatId, messageId, {
userReactions.forEach((reaction) => {
const { currentUserId } = global;
recentReactions.unshift({
userId: currentUserId!,
reaction,
});
});
return updateChatMessage(global, message.chatId, message.id, {
reactions: {
...reactions,
...currentReactions,
results,
recentReactions,
},

View File

@ -16,7 +16,9 @@ import { LOCAL_MESSAGE_MIN_ID, REPLIES_USER_ID, SERVICE_NOTIFICATIONS_USER_ID }
import {
selectChat, selectChatBot, selectIsChatWithBot, selectIsChatWithSelf,
} from './chats';
import { selectIsUserOrChatContact, selectUser, selectUserStatus } from './users';
import {
selectIsCurrentUserPremium, selectIsUserOrChatContact, selectUser, selectUserStatus,
} from './users';
import {
getSendingState,
isChatChannel,
@ -41,6 +43,7 @@ import {
getMessageDocument,
getMessageWebPagePhoto,
getMessageOriginalId,
canSendReaction,
} from '../helpers';
import { findLast } from '../../util/iteratees';
import { selectIsStickerFavorite } from './symbols';
@ -953,10 +956,7 @@ export function selectDefaultReaction(global: GlobalState, chatId: string) {
const isPrivate = isUserId(chatId);
const defaultReaction = global.config?.defaultReaction;
const { availableReactions } = global;
if (!defaultReaction || !availableReactions?.some(
(l) => l.reaction === defaultReaction && !l.isInactive,
)) {
if (!defaultReaction) {
return undefined;
}
@ -964,14 +964,20 @@ export function selectDefaultReaction(global: GlobalState, chatId: string) {
return defaultReaction;
}
const enabledReactions = selectChat(global, chatId)?.fullInfo?.enabledReactions;
if (!enabledReactions?.includes(defaultReaction)) {
const chatReactions = selectChat(global, chatId)?.fullInfo?.enabledReactions;
if (!chatReactions || !canSendReaction(defaultReaction, chatReactions)) {
return undefined;
}
return defaultReaction;
}
export function selectMaxUserReactions(global: GlobalState): number {
const isPremium = selectIsCurrentUserPremium(global);
const { maxUserReactionsPremium = 3, maxUserReactionsDefault = 1 } = global.appConfig || {};
return isPremium ? maxUserReactionsPremium : maxUserReactionsDefault;
}
// Slow, not to be used in `withGlobal`
export function selectVisibleUsers(global: GlobalState) {
const { chatId, threadId } = selectCurrentMessageList(global) || {};

View File

@ -47,6 +47,8 @@ import type {
ApiReceipt,
ApiPaymentCredentials,
ApiConfig,
ApiReaction,
ApiChatReactions,
} from '../api/types';
import type {
FocusDirection,
@ -99,7 +101,7 @@ export interface ActiveEmojiInteraction {
export interface ActiveReaction {
messageId?: number;
reaction?: string;
reaction?: ApiReaction;
}
export interface Thread {
@ -341,6 +343,7 @@ export type GlobalState = {
animatedEmojis?: ApiStickerSet;
animatedEmojiEffects?: ApiStickerSet;
genericEmojiEffects?: ApiStickerSet;
premiumGifts?: ApiStickerSet;
emojiKeywords: Partial<Record<LangCode, EmojiKeywords>>;
@ -395,7 +398,7 @@ export type GlobalState = {
availableReactions?: ApiAvailableReaction[];
activeEmojiInteractions?: ActiveEmojiInteraction[];
activeReactions: Record<number, ActiveReaction>;
activeReactions: Record<number, ActiveReaction[]>;
localTextSearch: {
byChatThreadKey: Record<string, {
@ -812,6 +815,38 @@ export interface ActionPayloads {
ids: number[];
};
// Reactions
loadAvailableReactions: never;
loadMessageReactions: {
chatId: string;
ids: number[];
};
toggleReaction: {
chatId: string;
messageId: number;
reaction: ApiReaction;
};
setDefaultReaction: {
reaction: ApiReaction;
};
sendDefaultReaction: {
chatId: string;
messageId: number;
};
setChatEnabledReactions: {
chatId: string;
enabledReactions?: ApiChatReactions;
};
stopActiveReaction: {
messageId: number;
reaction: ApiReaction;
};
// Media Viewer & Audio Player
openMediaViewer: {
chatId?: string;
@ -931,6 +966,7 @@ export interface ActionPayloads {
};
loadAnimatedEmojis: never;
loadGreetingStickers: never;
loadGenericEmojiEffects: never;
addRecentSticker: {
sticker: ApiSticker;
@ -1270,7 +1306,7 @@ export type NonTypedActionNames = (
'joinChannel' | 'leaveChannel' | 'deleteChannel' | 'toggleChatPinned' | 'toggleChatArchived' | 'toggleChatUnread' |
'loadChatFolders' | 'loadRecommendedChatFolders' | 'editChatFolder' | 'addChatFolder' | 'deleteChatFolder' |
'updateChat' | 'toggleSignatures' | 'loadGroupsForDiscussion' | 'linkDiscussionGroup' | 'unlinkDiscussionGroup' |
'loadMoreMembers' | 'setActiveChatFolder' | 'openNextChat' | 'setChatEnabledReactions' |
'loadMoreMembers' | 'setActiveChatFolder' | 'openNextChat' |
'addChatMembers' | 'deleteChatMember' | 'openPreviousChat' | 'editChatFolders' | 'toggleIsProtected' |
// messages
'loadViewportMessages' | 'selectMessage' | 'sendMessage' | 'cancelSendingMessage' | 'pinMessage' | 'deleteMessages' |
@ -1278,12 +1314,11 @@ export type NonTypedActionNames = (
'editMessage' | 'deleteHistory' | 'enterMessageSelectMode' | 'toggleMessageSelection' | 'exitMessageSelectMode' |
'openTelegramLink' | 'openChatByUsername' | 'requestThreadInfoUpdate' | 'setScrollOffset' | 'unpinAllMessages' |
'setReplyingToId' | 'editLastMessage' | 'saveDraft' | 'clearDraft' | 'loadPinnedMessages' |
'toggleMessageWebPage' | 'replyToNextMessage' | 'deleteChatUser' | 'deleteChat' | 'sendReaction' |
'toggleMessageWebPage' | 'replyToNextMessage' | 'deleteChatUser' | 'deleteChat' |
'reportMessages' | 'sendMessageAction' | 'focusNextReply' | 'openChatByInvite' | 'loadSeenBy' |
'loadSponsoredMessages' | 'viewSponsoredMessage' | 'loadSendAs' | 'saveDefaultSendAs' | 'loadAvailableReactions' |
'stopActiveEmojiInteraction' | 'interactWithAnimatedEmoji' | 'loadReactors' | 'setDefaultReaction' |
'sendDefaultReaction' | 'sendEmojiInteraction' | 'sendWatchingEmojiInteraction' | 'loadMessageReactions' |
'stopActiveReaction' | 'copySelectedMessages' | 'copyMessagesByIds' |
'loadSponsoredMessages' | 'viewSponsoredMessage' | 'loadSendAs' | 'saveDefaultSendAs' |
'stopActiveEmojiInteraction' | 'interactWithAnimatedEmoji' | 'loadReactors' |
'sendEmojiInteraction' | 'sendWatchingEmojiInteraction' | 'copySelectedMessages' | 'copyMessagesByIds' |
'setEditingId' |
// scheduled messages
'loadScheduledHistory' | 'sendScheduledMessages' | 'rescheduleMessage' | 'deleteScheduledMessages' |

View File

@ -32,8 +32,9 @@ function notifyCustomEmojiRender(emojiId: string) {
addCustomEmojiInputRenderCallback(notifyCustomEmojiRender);
export default function useEnsureCustomEmoji(id: string) {
export default function useEnsureCustomEmoji(id?: string) {
const lastSyncTime = useLastSyncTime();
if (!id) return;
notifyCustomEmojiRender(id);
if (getGlobal().customEmojis.byId[id]) {

View File

@ -185,6 +185,7 @@ $color-message-reaction-own-hover: #b5e0a4;
--right-column-width: 26.5rem;
--header-height: 3.5rem;
--custom-emoji-size: 1.25rem;
--custom-emoji-border-radius: 0;
--symbol-menu-width: 26.25rem;
--symbol-menu-height: 23.25rem;

View File

@ -110,9 +110,9 @@ if (IS_OPFS_SUPPORTED) {
})();
}
export const IS_BACKDROP_BLUR_SUPPORTED = !IS_TEST && (
CSS.supports('backdrop-filter: blur()') || CSS.supports('-webkit-backdrop-filter: blur()')
);
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_SCROLL_PATCH_NEEDED = !IS_MAC_OS && !IS_IOS && !IS_ANDROID;
export const IS_INSTALL_PROMPT_SUPPORTED = 'onbeforeinstallprompt' in window;