Introduce Reactions and Animated Emoji Interactions (#1583)

This commit is contained in:
Alexander Zinchuk 2022-01-21 17:29:08 +01:00
parent e6a2f4b2b6
commit d3d9d440a6
126 changed files with 3654 additions and 358 deletions

View File

@ -18,6 +18,11 @@ declare namespace React {
interface VideoHTMLAttributes {
srcObject?: MediaStream;
}
interface MouseEvent {
offsetX: number;
offsetY: number;
}
}
type AnyLiteral = Record<string, any>;

View File

@ -0,0 +1,46 @@
/* eslint-disable @typescript-eslint/naming-convention */
import BigInt from 'big-integer';
import localDb from '../localDb';
import { Api as GramJs } from '../../../lib/gramjs';
import { ApiAppConfig } from '../../types';
import { buildJson } from './misc';
type GramJsAppConfig = {
emojies_sounds: Record<string, {
id: string;
access_hash: string;
file_reference_base64: string;
}>;
emojies_send_dice: string[];
groupcall_video_participants_max: number;
reactions_default: string;
reactions_uniq_max: number;
};
function buildEmojiSounds(appConfig: GramJsAppConfig) {
const { emojies_sounds } = appConfig;
return Object.keys(emojies_sounds).reduce((acc: Record<string, string>, key) => {
const l = emojies_sounds[key];
localDb.documents[l.id] = new GramJs.Document({
id: BigInt(l.id),
accessHash: BigInt(l.access_hash),
dcId: 1,
mimeType: 'audio/ogg',
fileReference: Buffer.from(atob(l.file_reference_base64
.replace(/-/g, '+')
.replace(/_/g, '/'))),
} as GramJs.Document);
acc[key] = l.id;
return acc;
}, {});
}
export function buildApiConfig(json: GramJs.TypeJSONValue): ApiAppConfig {
const appConfig = buildJson(json) as GramJsAppConfig;
return {
emojiSounds: buildEmojiSounds(appConfig),
defaultReaction: appConfig.reactions_default,
};
}

View File

@ -303,6 +303,7 @@ export function buildChatTypingStatus(
serverTimeOffset: number,
) {
let action: string = '';
let emoticon: string | undefined;
if (update.action instanceof GramJs.SendMessageCancelAction) {
return undefined;
} else if (update.action instanceof GramJs.SendMessageTypingAction) {
@ -333,10 +334,16 @@ export function buildChatTypingStatus(
action = 'lng_send_action_choose_sticker';
} else if (update.action instanceof GramJs.SpeakingInGroupCallAction) {
return undefined;
} else if (update.action instanceof GramJs.SendMessageEmojiInteractionSeen) {
action = 'lng_user_action_watching_animations';
emoticon = update.action.emoticon;
} else if (update.action instanceof GramJs.SendMessageEmojiInteraction) {
return undefined;
}
return {
action,
...(emoticon && { emoji: emoticon }),
...(!(update instanceof GramJs.UpdateUserTyping) && { userId: getApiChatIdFromMtpPeer(update.fromId) }),
timestamp: Date.now() + serverTimeOffset * 1000,
};

View File

@ -22,8 +22,12 @@ import {
ApiThreadInfo,
ApiInvoice,
ApiGroupCall,
ApiUser,
ApiReactions,
ApiReactionCount,
ApiUserReaction,
ApiAvailableReaction,
ApiSponsoredMessage,
ApiUser,
} from '../../types';
import {
@ -141,7 +145,7 @@ type UniversalMessage = (
& Pick<Partial<GramJs.Message & GramJs.MessageService>, (
'out' | 'message' | 'entities' | 'fromId' | 'peerId' | 'fwdFrom' | 'replyTo' | 'replyMarkup' | 'post' |
'media' | 'action' | 'views' | 'editDate' | 'editHide' | 'mediaUnread' | 'groupedId' | 'mentioned' | 'viaBotId' |
'replies' | 'fromScheduled' | 'postAuthor' | 'noforwards'
'replies' | 'fromScheduled' | 'postAuthor' | 'noforwards' | 'reactions'
)>
);
@ -192,6 +196,7 @@ export function buildApiMessageWithChatId(chatId: string, mtpMessage: UniversalM
senderId: fromId || (mtpMessage.out && mtpMessage.post && currentUserId) || chatId,
views: mtpMessage.views,
isFromScheduled: mtpMessage.fromScheduled,
reactions: mtpMessage.reactions && buildMessageReactions(mtpMessage.reactions),
...(replyToMsgId && { replyToMessageId: replyToMsgId }),
...(replyToPeerId && { replyToChatId: getApiChatIdFromMtpPeer(replyToPeerId) }),
...(replyToTopId && { replyToTopMessageId: replyToTopId }),
@ -214,6 +219,54 @@ export function buildApiMessageWithChatId(chatId: string, mtpMessage: UniversalM
};
}
export function buildMessageReactions(reactions: GramJs.MessageReactions): ApiReactions {
const {
recentReactons, results, canSeeList,
} = reactions;
return {
canSeeList,
results: results.map(buildReactionCount),
recentReactions: recentReactons?.map(buildMessageUserReaction),
};
}
function buildReactionCount(reactionCount: GramJs.ReactionCount): ApiReactionCount {
const { chosen, count, reaction } = reactionCount;
return {
isChosen: chosen,
count,
reaction,
};
}
export function buildMessageUserReaction(userReaction: GramJs.MessageUserReaction): ApiUserReaction {
const { userId, reaction } = userReaction;
return {
userId: buildApiPeerId(userId, 'user'),
reaction,
};
}
export function buildApiAvailableReaction(availableReaction: GramJs.AvailableReaction): ApiAvailableReaction {
const {
selectAnimation, staticIcon, reaction, title,
inactive, aroundAnimation, centerIcon,
} = availableReaction;
return {
selectAnimation: buildApiDocument(selectAnimation),
staticIcon: buildApiDocument(staticIcon),
aroundAnimation: aroundAnimation ? buildApiDocument(aroundAnimation) : undefined,
centerIcon: centerIcon ? buildApiDocument(centerIcon) : undefined,
reaction,
title,
isInactive: inactive,
};
}
export function buildMessageTextContent(
message: string,
entities?: GramJs.TypeMessageEntity[],

View File

@ -1,6 +1,8 @@
import { Api as GramJs } from '../../../lib/gramjs';
import { ApiCountry, ApiSession, ApiWallpaper } from '../../types';
import {
ApiCountry, ApiSession, ApiWallpaper,
} from '../../types';
import { ApiPrivacySettings, ApiPrivacyKey, PrivacyVisibility } from '../../../types';
import { buildApiDocument } from './messages';
@ -155,3 +157,16 @@ export function buildApiCountryList(countries: GramJs.help.Country[]) {
general: generalList,
};
}
export function buildJson(json: GramJs.TypeJSONValue): any {
if (json instanceof GramJs.JsonNull) return undefined;
if (json instanceof GramJs.JsonString
|| json instanceof GramJs.JsonBool
|| json instanceof GramJs.JsonNumber) return json.value;
if (json instanceof GramJs.JsonArray) return json.value.map(buildJson);
return json.value.reduce((acc: Record<string, any>, el) => {
acc[el.key] = buildJson(el.value);
return acc;
}, {});
}

View File

@ -1,5 +1,7 @@
import { Api as GramJs } from '../../../lib/gramjs';
import { ApiSticker, ApiStickerSet } from '../../types';
import {
ApiEmojiInteraction, ApiSticker, ApiStickerSet, GramJsEmojiInteraction,
} from '../../types';
import { MEMOJI_STICKER_ID } from '../../../config';
import { buildApiThumbnailFromCached, buildApiThumbnailFromPath } from './common';
@ -108,3 +110,9 @@ export function buildStickerSetCovered(coveredStickerSet: GramJs.TypeStickerSetC
return stickerSet;
}
export function buildApiEmojiInteraction(json: GramJsEmojiInteraction): ApiEmojiInteraction {
return {
timestamps: json.a.map((l) => l.t),
};
}

View File

@ -341,6 +341,7 @@ async function getFullChatInfo(chatId: string): Promise<{
exportedInvite,
botInfo,
call,
availableReactions,
} = result.fullChat;
const members = buildChatMembers(participants);
@ -358,6 +359,7 @@ async function getFullChatInfo(chatId: string): Promise<{
inviteLink: exportedInvite.link,
}),
groupCallId: call?.id.toString(),
enabledReactions: availableReactions,
},
users: result.users.map(buildApiUser).filter<ApiUser>(Boolean as any),
groupCall: call ? {
@ -403,6 +405,7 @@ async function getFullChannelInfo(
hiddenPrehistory,
call,
botInfo,
availableReactions,
defaultSendAs,
} = result.fullChat;
@ -454,6 +457,7 @@ async function getFullChannelInfo(
groupCallId: call ? String(call.id) : undefined,
linkedChatId: linkedChatId ? buildApiPeerId(linkedChatId, 'chat') : undefined,
botCommands,
enabledReactions: availableReactions,
sendAsId: defaultSendAs ? getApiChatIdFromMtpPeer(defaultSendAs) : undefined,
},
users: [...(users || []), ...(bannedUsers || []), ...(adminUsers || [])],
@ -1143,6 +1147,17 @@ export async function importChatInvite({ hash }: { hash: string }) {
return buildApiChatFromPreview(updates.chats[0]);
}
export function setChatEnabledReactions({
chat, enabledReactions,
}: {
chat: ApiChat; enabledReactions: string[];
}) {
return invokeRequest(new GramJs.messages.SetChatAvailableReactions({
peer: buildInputPeer(chat.id, chat.accessHash),
availableReactions: enabledReactions,
}), true);
}
export function toggleIsProtected({
chat, isProtected,
}: { chat: ApiChat; isProtected: boolean }) {

View File

@ -10,7 +10,7 @@ export {
fetchChats, fetchFullChat, searchChats, requestChatUpdate,
saveDraft, clearDraft, fetchChat, updateChatMutedState,
createChannel, joinChannel, deleteChatUser, deleteChat, leaveChannel, deleteChannel, createGroupChat, editChatPhoto,
toggleChatPinned, toggleChatArchived, toggleDialogUnread,
toggleChatPinned, toggleChatArchived, toggleDialogUnread, setChatEnabledReactions,
fetchChatFolders, editChatFolder, deleteChatFolder, fetchRecommendedChatFolders,
getChatByUsername, togglePreHistoryHidden, updateChatDefaultBannedRights, updateChatMemberBannedRights,
updateChatTitle, updateChatAbout, toggleSignatures, updateChatAdmin, fetchGroupsForDiscussion, setDiscussionGroup,
@ -34,7 +34,7 @@ export {
export {
fetchStickerSets, fetchRecentStickers, fetchFavoriteStickers, fetchFeaturedStickers,
faveSticker, fetchStickers, fetchSavedGifs, searchStickers, installStickerSet, uninstallStickerSet,
searchGifs, fetchAnimatedEmojis, fetchStickersForEmoji, fetchEmojiKeywords,
searchGifs, fetchAnimatedEmojis, fetchStickersForEmoji, fetchEmojiKeywords, fetchAnimatedEmojiEffects,
} from './symbols';
export {
@ -47,7 +47,7 @@ export {
fetchAuthorizations, terminateAuthorization, terminateAllAuthorizations,
fetchNotificationExceptions, fetchNotificationSettings, updateContactSignUpNotification, updateNotificationSettings,
fetchLanguages, fetchLangPack, fetchPrivacySettings, setPrivacySettings, registerDevice, unregisterDevice,
updateIsOnline, fetchContentSettings, updateContentSettings, fetchLangStrings, fetchCountryList,
updateIsOnline, fetchContentSettings, updateContentSettings, fetchLangStrings, fetchCountryList, fetchAppConfig,
} from './settings';
export {
@ -67,3 +67,8 @@ export {
editGroupCallTitle, editGroupCallParticipant, exportGroupCallInvite, fetchGroupCallParticipants,
joinGroupCallPresentation, leaveGroupCall, leaveGroupCallPresentation, toggleGroupCallStartSubscription,
} from './calls';
export {
getAvailableReactions, sendReaction, sendEmojiInteraction, fetchMessageReactionsList,
setDefaultReaction, fetchMessageReactions, sendWatchingEmojiInteraction,
} from './reactions';

View File

@ -17,9 +17,10 @@ import { getEntityTypeById } from '../gramjsBuilders';
import * as cacheApi from '../../../util/cacheApi';
type EntityType = (
'msg' | 'sticker' | 'wallpaper' | 'gif' | 'channel' | 'chat' | 'user' | 'photo' | 'stickerSet' | 'webDocument'
'msg' | 'sticker' | 'wallpaper' | 'gif' | 'channel' | 'chat' | 'user' | 'photo' | 'stickerSet' | 'webDocument' |
'document'
);
const MEDIA_ENTITY_TYPES = new Set(['msg', 'sticker', 'gif', 'wallpaper', 'photo', 'webDocument']);
const MEDIA_ENTITY_TYPES = new Set(['msg', 'sticker', 'gif', 'wallpaper', 'photo', 'webDocument', 'document']);
export default async function downloadMedia(
{
@ -75,7 +76,9 @@ async function download(
) {
const mediaMatch = url.startsWith('webDocument')
? url.match(/(webDocument):(.+)/)
: url.match(/(avatar|profile|photo|msg|stickerSet|sticker|wallpaper|gif|file)([-\d\w./]+)(?::\d+)?(\?size=\w+)?/);
: url.match(
/(avatar|profile|photo|msg|stickerSet|sticker|wallpaper|gif|file|document)([-\d\w./]+)(?::\d+)?(\?size=\w+)?/,
);
if (!mediaMatch) {
return undefined;
}
@ -102,7 +105,8 @@ async function download(
if (mediaMatch[1] === 'avatar' || mediaMatch[1] === 'profile') {
entityType = getEntityTypeById(entityId);
} else {
entityType = mediaMatch[1] as 'msg' | 'sticker' | 'wallpaper' | 'gif' | 'stickerSet' | 'photo' | 'webDocument';
entityType = mediaMatch[1] as 'msg' | 'sticker' | 'wallpaper' | 'gif' | 'stickerSet' | 'photo' | 'webDocument' |
'document';
}
switch (entityType) {
@ -130,6 +134,9 @@ async function download(
case 'webDocument':
entity = localDb.webDocuments[entityId];
break;
case 'document':
entity = localDb.documents[entityId];
break;
}
if (!entity) {

View File

@ -1,21 +1,21 @@
import { Api as GramJs } from '../../../lib/gramjs';
import {
ApiChat,
ApiAttachment,
ApiChat,
ApiGlobalMessageSearchType,
ApiMessage,
OnApiUpdate,
ApiMessageSearchType,
ApiUser,
ApiSticker,
ApiVideo,
ApiNewPoll,
ApiMessageEntity,
ApiMessageSearchType,
ApiNewPoll,
ApiOnProgress,
ApiReportReason,
ApiSticker,
ApiThreadInfo,
ApiUser,
ApiVideo,
MAIN_THREAD_ID,
MESSAGE_DELETED,
ApiGlobalMessageSearchType,
ApiReportReason,
OnApiUpdate,
ApiSponsoredMessage,
ApiSendMessageAction,
ApiContact,
@ -31,23 +31,23 @@ import {
import { invokeRequest, uploadFile } from './client';
import {
buildApiMessage,
buildLocalForwardedMessage,
buildLocalMessage,
buildWebPage,
buildLocalForwardedMessage,
buildApiSponsoredMessage,
} from '../apiBuilders/messages';
import { buildApiUser } from '../apiBuilders/users';
import {
buildInputEntity,
buildInputMediaDocument,
buildInputPeer,
buildInputPoll,
buildInputReportReason,
buildMtpMessageEntity,
generateRandomBigInt,
getEntityTypeById,
buildInputMediaDocument,
buildInputPoll,
buildMtpMessageEntity,
isMessageWithMedia,
isServiceMessageWithMedia,
buildInputReportReason,
buildSendMessageAction,
} from '../gramjsBuilders';
import localDb from '../localDb';

View File

@ -0,0 +1,136 @@
import { ApiChat, ApiUser } from '../../types';
import { invokeRequest } from './client';
import { Api as GramJs } from '../../../lib/gramjs';
import { buildInputPeer } from '../gramjsBuilders';
import localDb from '../localDb';
import { buildApiAvailableReaction, buildMessageUserReaction } from '../apiBuilders/messages';
import { REACTION_LIST_LIMIT } from '../../../config';
import { addEntitiesWithPhotosToLocalDb } from '../helpers';
import { buildApiUser } from '../apiBuilders/users';
export function sendWatchingEmojiInteraction({
chat,
emoticon,
}: {
chat: ApiChat; emoticon: string;
}) {
return invokeRequest(new GramJs.messages.SetTyping({
peer: buildInputPeer(chat.id, chat.accessHash),
action: new GramJs.SendMessageEmojiInteractionSeen({
emoticon,
}),
}));
}
export function sendEmojiInteraction({
chat,
emoticon,
messageId,
timestamps,
}: {
chat: ApiChat; messageId: number; emoticon: string; timestamps: number[];
}) {
return invokeRequest(new GramJs.messages.SetTyping({
peer: buildInputPeer(chat.id, chat.accessHash),
action: new GramJs.SendMessageEmojiInteraction({
emoticon,
msgId: messageId,
interaction: new GramJs.DataJSON({
data: JSON.stringify({
v: 1,
a: timestamps.map((t: number) => ({
t,
i: 1,
})),
}),
}),
}),
}));
}
export async function getAvailableReactions() {
const result = await invokeRequest(new GramJs.messages.GetAvailableReactions({}));
if (!result || result instanceof GramJs.messages.AvailableReactionsNotModified) {
return undefined;
}
result.reactions.forEach((reaction) => {
if (reaction.staticIcon instanceof GramJs.Document) {
localDb.documents[String(reaction.staticIcon.id)] = reaction.staticIcon;
}
if (reaction.selectAnimation instanceof GramJs.Document) {
localDb.documents[String(reaction.selectAnimation.id)] = reaction.selectAnimation;
}
if (reaction.aroundAnimation instanceof GramJs.Document) {
localDb.documents[String(reaction.aroundAnimation.id)] = reaction.aroundAnimation;
}
if (reaction.centerIcon instanceof GramJs.Document) {
localDb.documents[String(reaction.centerIcon.id)] = reaction.centerIcon;
}
});
return result.reactions.map(buildApiAvailableReaction);
}
export function sendReaction({
chat, messageId, reaction,
}: {
chat: ApiChat; messageId: number; reaction?: string;
}) {
return invokeRequest(new GramJs.messages.SendReaction({
...(reaction && { reaction }),
peer: buildInputPeer(chat.id, chat.accessHash),
msgId: messageId,
}), true);
}
export function fetchMessageReactions({
ids, chat,
}: {
ids: number[]; chat: ApiChat;
}) {
return invokeRequest(new GramJs.messages.GetMessagesReactions({
id: ids,
peer: buildInputPeer(chat.id, chat.accessHash),
}), true);
}
export async function fetchMessageReactionsList({
chat, messageId, reaction, offset,
}: {
chat: ApiChat; messageId: number; reaction?: string; offset?: string;
}) {
const result = await invokeRequest(new GramJs.messages.GetMessageReactionsList({
peer: buildInputPeer(chat.id, chat.accessHash),
id: messageId,
...(reaction && { reaction }),
limit: REACTION_LIST_LIMIT,
...(offset && { offset }),
}));
if (!result) {
return undefined;
}
addEntitiesWithPhotosToLocalDb(result.users);
const { nextOffset, reactions, count } = result;
return {
users: result.users.map(buildApiUser).filter<ApiUser>(Boolean as any),
nextOffset,
reactions: reactions.map(buildMessageUserReaction),
count,
};
}
export function setDefaultReaction({
reaction,
}: {
reaction: string;
}) {
return invokeRequest(new GramJs.messages.SetDefaultReaction({
reaction,
}));
}

View File

@ -2,24 +2,35 @@ import BigInt from 'big-integer';
import { Api as GramJs } from '../../../lib/gramjs';
import {
ApiChat, ApiLangString, ApiLanguage, ApiNotifyException, ApiUser, ApiWallpaper,
ApiAppConfig,
ApiChat,
ApiLangString,
ApiLanguage,
ApiNotifyException,
ApiUser,
ApiWallpaper,
} from '../../types';
import { ApiPrivacyKey, InputPrivacyRules, LangCode } from '../../../types';
import { BLOCKED_LIST_LIMIT, DEFAULT_LANG_PACK, LANG_PACKS } from '../../../config';
import {
buildApiWallpaper, buildApiSession, buildPrivacyRules, buildApiNotifyException, buildApiCountryList,
buildApiCountryList,
buildApiNotifyException,
buildApiSession,
buildApiWallpaper,
buildPrivacyRules,
} from '../apiBuilders/misc';
import { buildApiUser } from '../apiBuilders/users';
import { buildApiChatFromPreview } from '../apiBuilders/chats';
import { buildInputPrivacyKey, buildInputPeer, buildInputEntity } from '../gramjsBuilders';
import { invokeRequest, uploadFile, getClient } from './client';
import { buildInputEntity, buildInputPeer, buildInputPrivacyKey } from '../gramjsBuilders';
import { getClient, invokeRequest, uploadFile } from './client';
import { omitVirtualClassFields } from '../apiBuilders/helpers';
import { buildCollectionByKey } from '../../../util/iteratees';
import { getServerTime } from '../../../util/serverTime';
import { buildApiPeerId, getApiChatIdFromMtpPeer } from '../apiBuilders/peers';
import localDb from '../localDb';
import { buildApiConfig } from '../apiBuilders/appConfig';
const MAX_INT_32 = 2 ** 31 - 1;
const BETA_LANG_CODES = ['ar', 'fa', 'id', 'ko', 'uz'];
@ -429,6 +440,13 @@ export function updateContentSettings(isEnabled: boolean) {
}));
}
export async function fetchAppConfig(): Promise<ApiAppConfig | undefined> {
const result = await invokeRequest(new GramJs.help.GetAppConfig());
if (!result) return undefined;
return buildApiConfig(result);
}
function updateLocalDb(
result: (
GramJs.account.PrivacyRules | GramJs.contacts.Blocked | GramJs.contacts.BlockedSlice |

View File

@ -130,6 +130,21 @@ export async function fetchAnimatedEmojis() {
};
}
export async function fetchAnimatedEmojiEffects() {
const result = await invokeRequest(new GramJs.messages.GetStickerSet({
stickerset: new GramJs.InputStickerSetAnimatedEmojiAnimations(),
}));
if (!(result instanceof GramJs.messages.StickerSet)) {
return undefined;
}
return {
set: buildStickerSet(result.set),
stickers: processStickerResult(result.documents),
};
}
export async function searchStickers({ query, hash = '0' }: { query: string; hash?: string }) {
const result = await invokeRequest(new GramJs.messages.SearchStickerSets({
q: query,

View File

@ -13,6 +13,7 @@ import {
buildPollResults,
buildApiMessageFromNotification,
buildMessageDraft,
buildMessageReactions,
} from './apiBuilders/messages';
import {
buildChatMember,
@ -46,6 +47,7 @@ import {
getGroupCallId,
} from './apiBuilders/calls';
import { buildApiPeerId, getApiChatIdFromMtpPeer } from './apiBuilders/peers';
import { buildApiEmojiInteraction } from './apiBuilders/symbols';
type Update = (
(GramJs.TypeUpdate | GramJs.TypeUpdates) & { _entities?: (GramJs.TypeUser | GramJs.TypeChat)[] }
@ -292,6 +294,13 @@ export function updater(update: Update, originRequest?: GramJs.AnyRequest) {
chatId: message.chatId,
message,
});
} else if (update instanceof GramJs.UpdateMessageReactions) {
onUpdate({
'@type': 'updateMessageReactions',
id: update.msgId,
chatId: getApiChatIdFromMtpPeer(update.peer),
reactions: buildMessageReactions(update.reactions),
});
} else if (update instanceof GramJs.UpdateDeleteMessages) {
onUpdate({
'@type': 'deleteMessages',
@ -614,11 +623,21 @@ export function updater(update: Update, originRequest?: GramJs.AnyRequest) {
? buildApiPeerId(update.userId, 'user')
: buildApiPeerId(update.chatId, 'chat');
onUpdate({
'@type': 'updateChatTypingStatus',
id,
typingStatus: buildChatTypingStatus(update, serverTimeOffset),
});
if (update.action instanceof GramJs.SendMessageEmojiInteraction) {
onUpdate({
'@type': 'updateStartEmojiInteraction',
id,
emoji: update.action.emoticon,
messageId: update.action.msgId,
interaction: buildApiEmojiInteraction(JSON.parse(update.action.interaction.data)),
});
} else {
onUpdate({
'@type': 'updateChatTypingStatus',
id,
typingStatus: buildChatTypingStatus(update, serverTimeOffset),
});
}
} else if (update instanceof GramJs.UpdateChannelUserTyping) {
const id = buildApiPeerId(update.channelId, 'channel');

View File

@ -64,6 +64,7 @@ export interface ApiTypingStatus {
userId?: string;
action: string;
timestamp: number;
emoji?: string;
}
export interface ApiChatFullInfo {
@ -86,6 +87,7 @@ export interface ApiChatFullInfo {
};
linkedChatId?: string;
botCommands?: ApiBotCommand[];
enabledReactions?: string[];
sendAsId?: string;
}

View File

@ -268,6 +268,39 @@ export interface ApiMessage {
isFromScheduled?: boolean;
seenByUserIds?: string[];
isProtected?: boolean;
reactors?: {
nextOffset?: string;
count: number;
reactions: ApiUserReaction[];
};
reactions?: ApiReactions;
}
export interface ApiReactions {
canSeeList?: boolean;
results: ApiReactionCount[];
recentReactions?: ApiUserReaction[];
}
export interface ApiUserReaction {
userId: string;
reaction: string;
}
export interface ApiReactionCount {
isChosen?: boolean;
count: number;
reaction: string;
}
export interface ApiAvailableReaction {
selectAnimation?: ApiDocument;
staticIcon?: ApiDocument;
centerIcon?: ApiDocument;
aroundAnimation?: ApiDocument;
reaction: string;
title: string;
isInactive?: boolean;
}
export interface ApiThreadInfo {

View File

@ -109,3 +109,20 @@ export interface ApiCountryCode extends ApiCountry {
prefixes?: string[];
patterns?: string[];
}
export interface ApiAppConfig {
emojiSounds: Record<string, string>;
defaultReaction: string;
}
export interface GramJsEmojiInteraction {
v: number;
a: {
i: number;
t: number;
}[];
}
export interface ApiEmojiInteraction {
timestamps: number[];
}

View File

@ -7,10 +7,11 @@ import {
ApiChatFolder,
} from './chats';
import {
ApiFormattedText, ApiMessage, ApiPhoto, ApiPoll, ApiStickerSet, ApiThreadInfo,
ApiFormattedText, ApiMessage, ApiPhoto, ApiPoll, ApiReactions, ApiStickerSet, ApiThreadInfo,
} from './messages';
import { ApiUser, ApiUserFullInfo, ApiUserStatus } from './users';
import {
ApiEmojiInteraction,
ApiError, ApiInviteInfo, ApiNotifyException, ApiSessionData,
} from './misc';
import {
@ -102,6 +103,14 @@ export type ApiUpdateChatTypingStatus = {
typingStatus: ApiTypingStatus | undefined;
};
export type ApiUpdateStartEmojiInteraction = {
'@type': 'updateStartEmojiInteraction';
id: string;
emoji: string;
messageId: number;
interaction: ApiEmojiInteraction;
};
export type ApiUpdateChatFullInfo = {
'@type': 'updateChatFullInfo';
id: string;
@ -284,6 +293,13 @@ export type ApiUpdateDraftMessage = {
replyingToId?: number;
};
export type ApiUpdateMessageReactions = {
'@type': 'updateMessageReactions';
id: number;
chatId: string;
reactions: ApiReactions;
};
export type ApiDeleteContact = {
'@type': 'deleteContact';
id: string;
@ -435,13 +451,13 @@ export type ApiUpdate = (
ApiUpdateMessageSendSucceeded | ApiUpdateMessageSendFailed | ApiUpdateServiceNotification |
ApiDeleteContact | ApiUpdateUser | ApiUpdateUserStatus | ApiUpdateUserFullInfo | ApiUpdateDeleteProfilePhotos |
ApiUpdateAvatar | ApiUpdateMessageImage | ApiUpdateDraftMessage |
ApiUpdateError | ApiUpdateResetContacts |
ApiUpdateError | ApiUpdateResetContacts | ApiUpdateStartEmojiInteraction |
ApiUpdateFavoriteStickers | ApiUpdateStickerSet |
ApiUpdateNewScheduledMessage | ApiUpdateScheduledMessageSendSucceeded | ApiUpdateScheduledMessage |
ApiUpdateDeleteScheduledMessages | ApiUpdateResetMessages |
ApiUpdateTwoFaError | ApiUpdateTwoFaStateWaitCode |
ApiUpdateNotifySettings | ApiUpdateNotifyExceptions | ApiUpdatePeerBlocked | ApiUpdatePrivacy |
ApiUpdateServerTimeOffset | ApiUpdateShowInvite |
ApiUpdateServerTimeOffset | ApiUpdateShowInvite | ApiUpdateMessageReactions |
ApiUpdateGroupCallParticipants | ApiUpdateGroupCallConnection | ApiUpdateGroupCall | ApiUpdateGroupCallStreams |
ApiUpdateGroupCallConnectionState | ApiUpdateGroupCallLeavePresentation | ApiUpdateGroupCallChatId
);

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -12,6 +12,8 @@ export { default as PinMessageModal } from '../components/common/PinMessageModal
export { default as UnpinAllMessagesModal } from '../components/common/UnpinAllMessagesModal';
export { default as MessageSelectToolbar } from '../components/middle/MessageSelectToolbar';
export { default as SeenByModal } from '../components/common/SeenByModal';
export { default as ReactorListModal } from '../components/middle/ReactorListModal';
export { default as EmojiInteractionAnimation } from '../components/middle/EmojiInteractionAnimation';
export { default as LeftSearch } from '../components/left/search/LeftSearch';
export { default as Settings } from '../components/left/settings/Settings';

View File

@ -1,14 +1,15 @@
import React, {
FC, useCallback, useRef, useState,
FC, memo,
} from '../../lib/teact/teact';
import { ApiMediaFormat, ApiSticker } from '../../api/types';
import { ActiveEmojiInteraction } from '../../global/types';
import { LIKE_STICKER_ID } from './helpers/mediaDimensions';
import { ObserveFn, useIsIntersecting } from '../../hooks/useIntersectionObserver';
import useMedia from '../../hooks/useMedia';
import useMediaTransition from '../../hooks/useMediaTransition';
import useFlag from '../../hooks/useFlag';
import useAnimatedEmoji from './hooks/useAnimatedEmoji';
import AnimatedSticker from './AnimatedSticker';
@ -16,30 +17,43 @@ import './AnimatedEmoji.scss';
type OwnProps = {
sticker: ApiSticker;
effect?: ApiSticker;
isOwn?: boolean;
soundId?: string;
observeIntersection?: ObserveFn;
size?: 'large' | 'medium' | 'small';
lastSyncTime?: number;
forceLoadPreview?: boolean;
messageId?: number;
chatId?: string;
activeEmojiInteraction?: ActiveEmojiInteraction;
};
const QUALITY = 1;
const WIDTH = {
large: 160,
medium: 128,
small: 104,
};
const AnimatedEmoji: FC<OwnProps> = ({
sticker,
effect,
isOwn,
soundId,
size = 'medium',
observeIntersection,
lastSyncTime,
forceLoadPreview,
messageId,
chatId,
activeEmojiInteraction,
}) => {
// eslint-disable-next-line no-null/no-null
const ref = useRef<HTMLDivElement>(null);
const {
markAnimationLoaded,
isAnimationLoaded,
ref,
width,
style,
handleClick,
playKey,
} = useAnimatedEmoji(size, chatId, messageId, soundId, activeEmojiInteraction, isOwn, undefined, effect?.emoji);
const [isAnimationLoaded, markAnimationLoaded] = useFlag();
const localMediaHash = `sticker${sticker.id}`;
const isIntersecting = useIsIntersecting(ref, observeIntersection);
@ -56,19 +70,11 @@ const AnimatedEmoji: FC<OwnProps> = ({
const mediaData = useMedia(localMediaHash, !isIntersecting, ApiMediaFormat.Lottie, lastSyncTime);
const isMediaLoaded = Boolean(mediaData);
const [playKey, setPlayKey] = useState(String(Math.random()));
const handleClick = useCallback(() => {
setPlayKey(String(Math.random()));
}, []);
const width = WIDTH[size];
const style = `width: ${width}px; height: ${width}px;`;
return (
<div
ref={ref}
className="AnimatedEmoji media-inner"
// @ts-ignore
// @ts-ignore teact feature
style={style}
onClick={handleClick}
>
@ -78,7 +84,7 @@ const AnimatedEmoji: FC<OwnProps> = ({
{!isAnimationLoaded && previewBlobUrl && (
<img src={previewBlobUrl} className={transitionClassNames} alt="" />
)}
{isMediaLoaded && (
{isMediaLoaded && localMediaHash && (
<AnimatedSticker
key={localMediaHash}
id={localMediaHash}
@ -94,4 +100,4 @@ const AnimatedEmoji: FC<OwnProps> = ({
);
};
export default AnimatedEmoji;
export default memo(AnimatedEmoji);

View File

@ -20,6 +20,7 @@ type OwnProps = {
isLowPriority?: boolean;
onLoad?: NoneToVoidFunction;
color?: [number, number, number];
onEnded?: NoneToVoidFunction;
};
type RLottieClass = typeof import('../../lib/rlottie/RLottie').default;
@ -54,6 +55,7 @@ const AnimatedSticker: FC<OwnProps> = ({
isLowPriority,
onLoad,
color,
onEnded,
}) => {
const [animation, setAnimation] = useState<RLottieInstance>();
// eslint-disable-next-line no-null/no-null
@ -89,6 +91,7 @@ const AnimatedSticker: FC<OwnProps> = ({
},
onLoad,
color,
onEnded,
);
if (speed) {
@ -109,7 +112,7 @@ const AnimatedSticker: FC<OwnProps> = ({
});
});
}
}, [color, animation, animationData, id, isLowPriority, noLoop, onLoad, quality, size, speed]);
}, [color, animation, animationData, id, isLowPriority, noLoop, onLoad, quality, size, speed, onEnded]);
useEffect(() => {
if (!animation) return;

View File

@ -0,0 +1,85 @@
import React, {
FC, memo, useEffect, useState,
} from '../../lib/teact/teact';
import { ActiveEmojiInteraction } from '../../global/types';
import { ObserveFn, useIsIntersecting } from '../../hooks/useIntersectionObserver';
import getAnimationData, { ANIMATED_STICKERS_PATHS } from './helpers/animatedAssets';
import useAnimatedEmoji from './hooks/useAnimatedEmoji';
import AnimatedSticker from './AnimatedSticker';
const QUALITY = 1;
type OwnProps = {
localSticker?: string;
localEffect?: string;
isOwn?: boolean;
soundId?: string;
observeIntersection?: ObserveFn;
size?: 'large' | 'medium' | 'small';
lastSyncTime?: number;
forceLoadPreview?: boolean;
messageId?: number;
chatId?: string;
activeEmojiInteraction?: ActiveEmojiInteraction;
};
const LocalAnimatedEmoji: FC<OwnProps> = ({
localSticker,
localEffect,
isOwn,
soundId,
size = 'medium',
observeIntersection,
messageId,
chatId,
activeEmojiInteraction,
}) => {
const {
playKey,
ref,
style,
width,
handleClick,
markAnimationLoaded,
} = useAnimatedEmoji(size, chatId, messageId, soundId, activeEmojiInteraction, isOwn, localEffect);
const id = `local_emoji_${localSticker}`;
const isIntersecting = useIsIntersecting(ref, observeIntersection);
const [localStickerAnimationData, setLocalStickerAnimationData] = useState<AnyLiteral>();
useEffect(() => {
if (localSticker) {
getAnimationData(localSticker as keyof typeof ANIMATED_STICKERS_PATHS).then((data) => {
setLocalStickerAnimationData(data);
});
}
}, [localSticker]);
return (
<div
ref={ref}
className="AnimatedEmoji media-inner"
// @ts-ignore teact feature
style={style}
onClick={handleClick}
>
{localStickerAnimationData && (
<AnimatedSticker
key={id}
id={id}
animationData={localStickerAnimationData}
size={width}
quality={QUALITY}
play={isIntersecting && playKey}
noLoop
onLoad={markAnimationLoaded}
/>
)}
</div>
);
};
export default memo(LocalAnimatedEmoji);

View File

@ -0,0 +1,4 @@
.ReactionStaticEmoji {
width: 1rem;
display: block;
}

View File

@ -0,0 +1,36 @@
import { RefObject } from 'react';
import React, { FC, memo } from '../../lib/teact/teact';
import { getGlobal } from '../../lib/teact/teactn';
import { ApiMediaFormat } from '../../api/types';
import useMedia from '../../hooks/useMedia';
import buildClassName from '../../util/buildClassName';
import './ReactionStaticEmoji.scss';
type OwnProps = {
reaction: string;
ref?: RefObject<HTMLImageElement>;
className?: string;
};
const ReactionStaticEmoji: FC<OwnProps> = ({
reaction,
ref,
className,
}) => {
const staticIconId = getGlobal().availableReactions?.find((l) => l.reaction === reaction)?.staticIcon?.id;
const mediaData = useMedia(`document${staticIconId}`, !staticIconId, ApiMediaFormat.BlobUrl);
return (
<img
className={buildClassName('ReactionStaticEmoji', className)}
ref={ref}
src={mediaData}
alt=""
/>
);
};
export default memo(ReactionStaticEmoji);

View File

@ -28,7 +28,7 @@ const TypingStatus: FC<OwnProps & StateProps> = ({ typingStatus, typingUser }) =
<span className="sender-name" dir="auto">{renderText(typingUserName)}</span>
)}
{/* fix for translation "username _is_ typing" */}
{lang(typingStatus.action).replace('{user}', '').trim()}
{lang(typingStatus.action).replace('{user}', '').replace('{emoji}', typingStatus.emoji).trim()}
<span className="ellipsis" />
</p>
);

View File

@ -3,35 +3,41 @@ import { ApiMediaFormat } from '../../../api/types';
import * as mediaLoader from '../../../util/mediaLoader';
// @ts-ignore
import MonkeyIdle from '../../../assets/TwoFactorSetupMonkeyIdle.tgs';
import MonkeyIdle from '../../../assets/tgs/monkeys/TwoFactorSetupMonkeyIdle.tgs';
// @ts-ignore
import MonkeyTracking from '../../../assets/TwoFactorSetupMonkeyTracking.tgs';
import MonkeyTracking from '../../../assets/tgs/monkeys/TwoFactorSetupMonkeyTracking.tgs';
// @ts-ignore
import MonkeyClose from '../../../assets/TwoFactorSetupMonkeyClose.tgs';
import MonkeyClose from '../../../assets/tgs/monkeys/TwoFactorSetupMonkeyClose.tgs';
// @ts-ignore
import MonkeyPeek from '../../../assets/TwoFactorSetupMonkeyPeek.tgs';
import MonkeyPeek from '../../../assets/tgs/monkeys/TwoFactorSetupMonkeyPeek.tgs';
// @ts-ignore
import FoldersAll from '../../../assets/FoldersAll.tgs';
import FoldersAll from '../../../assets/tgs/settings/FoldersAll.tgs';
// @ts-ignore
import FoldersNew from '../../../assets/FoldersNew.tgs';
import FoldersNew from '../../../assets/tgs/settings/FoldersNew.tgs';
// @ts-ignore
import DiscussionGroups from '../../../assets/DiscussionGroupsDucks.tgs';
import DiscussionGroups from '../../../assets/tgs/settings/DiscussionGroupsDucks.tgs';
// @ts-ignore
import CameraFlip from '../../../assets/animatedIcons/CameraFlip.tgs';
import CameraFlip from '../../../assets/tgs/calls/CameraFlip.tgs';
// @ts-ignore
import HandFilled from '../../../assets/animatedIcons/HandFilled.tgs';
import HandFilled from '../../../assets/tgs/calls/HandFilled.tgs';
// @ts-ignore
import HandOutline from '../../../assets/animatedIcons/HandOutline.tgs';
import HandOutline from '../../../assets/tgs/calls/HandOutline.tgs';
// @ts-ignore
import Speaker from '../../../assets/animatedIcons/Speaker.tgs';
import Speaker from '../../../assets/tgs/calls/Speaker.tgs';
// @ts-ignore
import VoiceAllowTalk from '../../../assets/animatedIcons/VoiceAllowTalk.tgs';
import VoiceAllowTalk from '../../../assets/tgs/calls/VoiceAllowTalk.tgs';
// @ts-ignore
import VoiceMini from '../../../assets/animatedIcons/VoiceMini.tgs';
import VoiceMini from '../../../assets/tgs/calls/VoiceMini.tgs';
// @ts-ignore
import VoiceMuted from '../../../assets/animatedIcons/VoiceMuted.tgs';
import VoiceMuted from '../../../assets/tgs/calls/VoiceMuted.tgs';
// @ts-ignore
import VoiceOutlined from '../../../assets/animatedIcons/VoiceOutlined.tgs';
import VoiceOutlined from '../../../assets/tgs/calls/VoiceOutlined.tgs';
// @ts-ignore
import Peach from '../../../assets/tgs/animatedEmojis/Peach.tgs';
// @ts-ignore
import Eggplant from '../../../assets/tgs/animatedEmojis/Eggplant.tgs';
// @ts-ignore
import Cumshot from '../../../assets/tgs/animatedEmojis/Cumshot.tgs';
export const ANIMATED_STICKERS_PATHS = {
MonkeyIdle,
@ -49,6 +55,9 @@ export const ANIMATED_STICKERS_PATHS = {
VoiceMini,
VoiceMuted,
VoiceOutlined,
Peach,
Eggplant,
Cumshot,
};
export default function getAnimationData(name: keyof typeof ANIMATED_STICKERS_PATHS) {

View File

@ -0,0 +1,155 @@
import {
useCallback, useEffect, useRef, useState,
} from '../../../lib/teact/teact';
import safePlay from '../../../util/safePlay';
import { getDispatch } from '../../../lib/teact/teactn';
import useMedia from '../../../hooks/useMedia';
import { ActiveEmojiInteraction } from '../../../global/types';
import useFlag from '../../../hooks/useFlag';
import { selectLocalAnimatedEmojiEffectByName } from '../../../modules/selectors';
const WIDTH = {
large: 160,
medium: 128,
small: 104,
};
const INTERACTION_BUNCH_TIME = 1000;
const MS_DIVIDER = 1000;
const TIME_DEFAULT = 0;
export default function useAnimatedEmoji(
size: 'large' | 'medium' | 'small',
chatId?: string,
messageId?: number,
soundId?: string,
activeEmojiInteraction?: ActiveEmojiInteraction,
isOwn?: boolean,
localEffect?: string,
emoji?: string,
) {
const {
interactWithAnimatedEmoji, sendEmojiInteraction, sendWatchingEmojiInteraction,
} = getDispatch();
const hasEffect = localEffect || emoji;
const [isAnimationLoaded, markAnimationLoaded] = useFlag();
// eslint-disable-next-line no-null/no-null
const ref = useRef<HTMLDivElement>(null);
// eslint-disable-next-line no-null/no-null
const audioRef = useRef<HTMLAudioElement | undefined>(null);
const soundMediaData = useMedia(soundId ? `document${soundId}` : undefined, !soundId);
const width = WIDTH[size];
const style = `width: ${width}px; height: ${width}px;`;
const [playKey, setPlayKey] = useState(String(Math.random()));
const interactions = useRef<number[] | undefined>(undefined);
const startedInteractions = useRef<number | undefined>(undefined);
const sendInteractionBunch = useCallback(() => {
const container = ref.current;
if (!container) return;
sendEmojiInteraction({
chatId,
messageId,
localEffect,
emoji,
interactions: interactions.current,
});
startedInteractions.current = undefined;
interactions.current = undefined;
}, [sendEmojiInteraction, chatId, messageId, localEffect, emoji]);
const play = useCallback(() => {
setPlayKey(String(Math.random()));
const audio = audioRef.current;
if (soundMediaData) {
if (audio) {
audio.pause();
audio.remove();
}
audioRef.current = new Audio();
audioRef.current.src = soundMediaData;
safePlay(audioRef.current);
audioRef.current.addEventListener('ended', () => {
audioRef.current = undefined;
}, { once: true });
}
}, [soundMediaData]);
const handleClick = useCallback(() => {
play();
const container = ref.current;
if (!hasEffect || !container || !messageId || !chatId) {
return;
}
const { x, y } = container.getBoundingClientRect();
interactWithAnimatedEmoji({
localEffect,
emoji,
x,
y,
startSize: width,
isReversed: !isOwn,
});
if (!interactions.current) {
interactions.current = [];
startedInteractions.current = performance.now();
setTimeout(sendInteractionBunch, INTERACTION_BUNCH_TIME);
}
interactions.current.push(startedInteractions.current
? (performance.now() - startedInteractions.current) / MS_DIVIDER
: TIME_DEFAULT);
}, [
chatId, emoji, hasEffect, interactWithAnimatedEmoji, isOwn,
localEffect, messageId, play, sendInteractionBunch, width,
]);
// Set an end anchor for remote activated interaction
useEffect(() => {
const container = ref.current;
if (!container || !activeEmojiInteraction) return;
const {
messageId: selectedMessageId, endX, endY,
} = activeEmojiInteraction;
if (!endX && !endY && selectedMessageId === messageId) {
const { x, y } = container.getBoundingClientRect();
sendWatchingEmojiInteraction({
chatId,
emoticon: localEffect ? selectLocalAnimatedEmojiEffectByName(localEffect) : emoji,
startSize: width,
x,
y,
isReversed: !isOwn,
});
play();
}
}, [
activeEmojiInteraction, chatId, emoji, isOwn, localEffect, messageId, play, sendWatchingEmojiInteraction, width,
]);
return {
playKey,
ref,
style,
width,
handleClick,
markAnimationLoaded,
isAnimationLoaded,
};
}

View File

@ -122,6 +122,7 @@ const LeftColumn: FC<StateProps> = ({
return;
case SettingsScreens.GeneralChatBackground:
case SettingsScreens.QuickReaction:
setSettingsScreen(SettingsScreens.General);
return;
case SettingsScreens.GeneralChatBackgroundColor:

View File

@ -301,3 +301,19 @@
.username-link {
color: var(--color-links);
}
.settings-quick-reaction {
.ReactionStaticEmoji {
margin-inline-end: 1rem;
width: 1.5rem
}
}
.SettingsDefaultReaction {
.ReactionStaticEmoji {
width: 1.5rem;
height: 1.5rem;
margin-inline-end: 2rem;
}
}

View File

@ -23,6 +23,7 @@ import SettingsPrivacyActiveSessions from './SettingsPrivacyActiveSessions';
import SettingsPrivacyBlockedUsers from './SettingsPrivacyBlockedUsers';
import SettingsTwoFa from './twoFa/SettingsTwoFa';
import SettingsPrivacyVisibilityExceptionList from './SettingsPrivacyVisibilityExceptionList';
import SettingsQuickReaction from './SettingsQuickReaction';
import './Settings.scss';
@ -180,10 +181,15 @@ const Settings: FC<OwnProps> = ({
isActive={isScreenActive
|| screen === SettingsScreens.GeneralChatBackgroundColor
|| screen === SettingsScreens.GeneralChatBackground
|| screen === SettingsScreens.QuickReaction
|| isPrivacyScreen || isFoldersScreen}
onReset={handleReset}
/>
);
case SettingsScreens.QuickReaction:
return (
<SettingsQuickReaction onScreenSelect={onScreenSelect} isActive={isScreenActive} onReset={handleReset} />
);
case SettingsScreens.Notifications:
return (
<SettingsNotifications onScreenSelect={onScreenSelect} isActive={isScreenActive} onReset={handleReset} />

View File

@ -20,6 +20,7 @@ import Checkbox from '../../ui/Checkbox';
import RadioGroup, { IRadioOption } from '../../ui/RadioGroup';
import SettingsStickerSet from './SettingsStickerSet';
import StickerSetModal from '../../common/StickerSetModal.async';
import ReactionStaticEmoji from '../../common/ReactionStaticEmoji';
type OwnProps = {
isActive?: boolean;
@ -38,6 +39,7 @@ type StateProps =
)> & {
stickerSetIds?: string[];
stickerSetsById?: Record<string, ApiStickerSet>;
defaultReaction?: string;
};
const ANIMATION_LEVEL_OPTIONS = [
@ -60,6 +62,7 @@ const SettingsGeneral: FC<OwnProps & StateProps> = ({
onReset,
stickerSetIds,
stickerSetsById,
defaultReaction,
messageTextSize,
animationLevel,
messageSendKeyCombo,
@ -189,6 +192,16 @@ const SettingsGeneral: FC<OwnProps & StateProps> = ({
<div className="settings-item">
<h4 className="settings-item-header" dir={lang.isRtl ? 'rtl' : undefined}>{lang('AccDescrStickers')}</h4>
{defaultReaction && (
<ListItem
className="SettingsDefaultReaction"
onClick={() => onScreenSelect(SettingsScreens.QuickReaction)}
>
<ReactionStaticEmoji reaction={defaultReaction} />
<div className="title">{lang('DoubleTapSetting')}</div>
</ListItem>
)}
<Checkbox
label={lang('SuggestStickers')}
checked={shouldSuggestStickers}
@ -237,6 +250,7 @@ export default memo(withGlobal<OwnProps>(
]),
stickerSetIds: global.stickers.added.setIds,
stickerSetsById: global.stickers.setsById,
defaultReaction: global.appConfig?.defaultReaction,
};
},
)(SettingsGeneral));

View File

@ -87,6 +87,8 @@ const SettingsHeader: FC<OwnProps> = ({
return <h3>{lang('lng_settings_information')}</h3>;
case SettingsScreens.General:
return <h3>{lang('General')}</h3>;
case SettingsScreens.QuickReaction:
return <h3>{lang('DoubleTapSetting')}</h3>;
case SettingsScreens.Notifications:
return <h3>{lang('Notifications')}</h3>;
case SettingsScreens.DataStorage:

View File

@ -0,0 +1,65 @@
import React, { FC, memo, useCallback } from '../../../lib/teact/teact';
import { getDispatch, withGlobal } from '../../../lib/teact/teactn';
import { SettingsScreens } from '../../../types';
import { ApiAvailableReaction } from '../../../api/types';
import useHistoryBack from '../../../hooks/useHistoryBack';
import ReactionStaticEmoji from '../../common/ReactionStaticEmoji';
import RadioGroup from '../../ui/RadioGroup';
type OwnProps = {
isActive?: boolean;
onScreenSelect: (screen: SettingsScreens) => void;
onReset: () => void;
};
type StateProps = {
availableReactions?: ApiAvailableReaction[];
selectedReaction?: string;
};
const SettingsQuickReaction: FC<OwnProps & StateProps> = ({
isActive,
onReset,
onScreenSelect,
availableReactions,
selectedReaction,
}) => {
const { setDefaultReaction } = getDispatch();
useHistoryBack(isActive, onReset, onScreenSelect, SettingsScreens.General);
const options = availableReactions?.filter((l) => !l.isInactive).map((l) => {
return {
label: <><ReactionStaticEmoji reaction={l.reaction} />{l.title}</>,
value: l.reaction,
};
}) || [];
const handleChange = useCallback((reaction: string) => {
setDefaultReaction({ reaction });
}, [setDefaultReaction]);
return (
<div className="settings-content settings-item custom-scroll settings-quick-reaction">
<RadioGroup
name="quick-reaction-settings"
options={options}
selected={selectedReaction}
onChange={handleChange}
/>
</div>
);
};
export default memo(withGlobal<OwnProps>(
(global) => {
const { availableReactions, appConfig } = global;
return {
availableReactions,
selectedReaction: appConfig?.defaultReaction,
};
},
)(SettingsQuickReaction));

View File

@ -108,12 +108,14 @@ const Main: FC<StateProps> = ({
loadTopInlineBots,
loadEmojiKeywords,
loadCountryList,
loadAvailableReactions,
loadStickerSets,
loadAddedStickers,
loadFavoriteStickers,
ensureTimeFormat,
openStickerSetShortName,
checkVersionNotification,
loadAppConfig,
} = getDispatch();
const isSynced = Boolean(lastSyncTime);
@ -127,6 +129,8 @@ const Main: FC<StateProps> = ({
useEffect(() => {
if (lastSyncTime) {
updateIsOnline(true);
loadAppConfig();
loadAvailableReactions();
loadAnimatedEmojis();
loadNotificationSettings();
loadNotificationExceptions();
@ -135,7 +139,7 @@ const Main: FC<StateProps> = ({
}
}, [
lastSyncTime, loadAnimatedEmojis, loadEmojiKeywords, loadNotificationExceptions, loadNotificationSettings,
loadTopInlineBots, updateIsOnline,
loadTopInlineBots, updateIsOnline, loadAvailableReactions, loadAppConfig,
]);
// Language-based API calls

View File

@ -0,0 +1,15 @@
import React, { FC, memo } from '../../lib/teact/teact';
import { OwnProps } from './EmojiInteractionAnimation';
import { Bundles } from '../../util/moduleLoader';
import useModuleLoader from '../../hooks/useModuleLoader';
const EmojiInteractionAnimationAsync: FC<OwnProps> = (props) => {
const { emojiInteraction } = props;
const EmojiInteractionAnimation = useModuleLoader(Bundles.Extra, 'EmojiInteractionAnimation', !emojiInteraction);
// eslint-disable-next-line react/jsx-props-no-spreading
return EmojiInteractionAnimation ? <EmojiInteractionAnimation {...props} /> : undefined;
};
export default memo(EmojiInteractionAnimationAsync);

View File

@ -0,0 +1,89 @@
.EmojiInteractionAnimation {
--start-x: 0;
--start-y: 0;
--scale: 0;
--end-scale: 0;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 1000;
@keyframes hide-reaction-reversed {
from {
transform: translate(100%, -50%) scaleX(-1) scale(1);
}
to {
left: var(--end-x, var(--start-x));
top: var(--end-y, var(--start-y));
transform: translate(50%, 0) scale(var(--end-scale, 0));
}
}
@keyframes show-reaction-reversed {
from {
transform: translate(50%, 0) scaleX(-1) scale(var(--scale, 0));
}
to {
transform: translate(100%, -50%) scaleX(-1) scale(1);
}
}
@keyframes hide-reaction {
from {
transform: translate(-50%, -50%) scale(1);
}
to {
left: var(--end-x, var(--start-x));
top: var(--end-y, var(--start-y));
transform: translate(0, 0) scale(var(--end-scale, 0));
}
}
@keyframes show-reaction {
from {
transform: translate(0, 0) scale(var(--scale, 0));
}
to {
transform: translate(-50%, -50%) scale(1);
}
}
.AnimatedSticker {
position: absolute;
top: var(--start-y);
left: var(--start-x);
transform: scale(var(--scale), 0);
transform-origin: left top;
transition: 0.25s cubic-bezier(.29,.81,.27,.99) opacity;
}
&.reversed .AnimatedSticker {
transform: scale(var(--scale), 0) scaleX(-1);
}
&.playing .AnimatedSticker {
animation: show-reaction forwards 0.25s cubic-bezier(.29,.81,.27,.99);
}
&.reversed.playing .AnimatedSticker {
animation: show-reaction-reversed forwards 0.25s cubic-bezier(.29,.81,.27,.99);
}
&.hiding .AnimatedSticker {
opacity: 0;
animation: hide-reaction forwards 0.25s cubic-bezier(.29,.81,.27,.99);
}
&.reversed.hiding .AnimatedSticker {
animation: hide-reaction-reversed forwards 0.25s cubic-bezier(.29,.81,.27,.99);
}
}

View File

@ -0,0 +1,122 @@
import React, {
FC, memo, useCallback, useEffect, useState,
} from '../../lib/teact/teact';
import { getDispatch, withGlobal } from '../../lib/teact/teactn';
import { ActiveEmojiInteraction } from '../../global/types';
import { ApiMediaFormat } from '../../api/types';
import useFlag from '../../hooks/useFlag';
import useMedia from '../../hooks/useMedia';
import buildClassName from '../../util/buildClassName';
import {
selectAnimatedEmojiEffect,
} from '../../modules/selectors';
import { REM } from '../common/helpers/mediaDimensions';
import getAnimationData, { ANIMATED_STICKERS_PATHS } from '../common/helpers/animatedAssets';
import AnimatedSticker from '../common/AnimatedSticker';
import './EmojiInteractionAnimation.scss';
export type OwnProps = {
emojiInteraction: ActiveEmojiInteraction;
};
type StateProps = {
effectAnimationId?: string;
localEffectAnimation?: string;
isReversed?: boolean;
};
const HIDE_ANIMATION_DURATION = 250;
const PLAYING_DURATION = 3000;
const END_SIZE = 1.125 * REM;
const EFFECT_SIZE = 240;
const EmojiInteractionAnimation: FC<OwnProps & StateProps> = ({
emojiInteraction,
effectAnimationId,
localEffectAnimation,
isReversed,
}) => {
const { stopActiveEmojiInteraction } = getDispatch();
const [isHiding, startHiding] = useFlag(false);
const [isPlaying, startPlaying] = useFlag(false);
const stop = useCallback(() => {
startHiding();
setTimeout(() => {
stopActiveEmojiInteraction();
}, HIDE_ANIMATION_DURATION);
}, [startHiding, stopActiveEmojiInteraction]);
useEffect(() => {
document.addEventListener('touchstart', stop);
document.addEventListener('touchmove', stop);
document.addEventListener('mousedown', stop);
document.addEventListener('wheel', stop);
return () => {
document.removeEventListener('touchstart', stop);
document.removeEventListener('touchmove', stop);
document.removeEventListener('mousedown', stop);
document.removeEventListener('wheel', stop);
};
}, [stop]);
useEffect(() => {
setTimeout(stop, PLAYING_DURATION);
}, [stop]);
const mediaDataEffect = useMedia(`sticker${effectAnimationId}`, !effectAnimationId, ApiMediaFormat.Lottie);
const [localEffectAnimationData, setLocalEffectAnimationData] = useState<AnyLiteral>();
useEffect(() => {
if (localEffectAnimation) {
getAnimationData(localEffectAnimation as keyof typeof ANIMATED_STICKERS_PATHS).then((data) => {
setLocalEffectAnimationData(data);
});
}
}, [localEffectAnimation]);
const scale = (emojiInteraction.startSize || 0) / EFFECT_SIZE;
const endScale = END_SIZE / EFFECT_SIZE;
return (
<div
className={buildClassName(
'EmojiInteractionAnimation', isHiding && 'hiding', isPlaying && 'playing', isReversed && 'reversed',
)}
// @ts-ignore teact feature
style={`--end-scale: ${endScale}; --scale: ${scale}; --start-x: ${emojiInteraction.x}px;`
+ `--start-y: ${emojiInteraction.y}px;${
emojiInteraction.endX
&& emojiInteraction.endY ? `--end-x: ${emojiInteraction.endX}px; --end-y: ${emojiInteraction.endY}px;` : ''}`}
>
<AnimatedSticker
id={`effect_${effectAnimationId}`}
size={EFFECT_SIZE}
animationData={(localEffectAnimationData || mediaDataEffect) as AnyLiteral}
play={isPlaying}
noLoop
onLoad={startPlaying}
/>
</div>
);
};
export default memo(withGlobal<OwnProps>(
(global, { emojiInteraction }): StateProps => {
const animatedEffect = emojiInteraction.animatedEffect !== undefined
&& selectAnimatedEmojiEffect(global, emojiInteraction.animatedEffect);
return {
effectAnimationId: animatedEffect ? animatedEffect.id : undefined,
localEffectAnimation: !animatedEffect && emojiInteraction.animatedEffect
&& Object.keys(ANIMATED_STICKERS_PATHS).includes(emojiInteraction.animatedEffect)
? emojiInteraction.animatedEffect : undefined,
isReversed: emojiInteraction.isReversed,
};
},
)(EmojiInteractionAnimation));

View File

@ -47,6 +47,7 @@ import fastSmoothScroll, { isAnimatingScroll } from '../../util/fastSmoothScroll
import renderText from '../common/helpers/renderText';
import useLang from '../../hooks/useLang';
import useWindowSize from '../../hooks/useWindowSize';
import useInterval from '../../hooks/useInterval';
import Loading from '../ui/Loading';
import MessageListContent from './MessageListContent';
@ -92,6 +93,7 @@ type StateProps = {
lastSyncTime?: number;
};
const MESSAGE_REACTIONS_POLLING_INTERVAL = 15 * 1000;
const BOTTOM_THRESHOLD = 20;
const UNREAD_DIVIDER_TOP = 10;
const UNREAD_DIVIDER_TOP_WITH_TOOLS = 60;
@ -135,7 +137,9 @@ const MessageList: FC<OwnProps & StateProps> = ({
lastSyncTime,
withBottomShift,
}) => {
const { loadViewportMessages, setScrollOffset, loadSponsoredMessages } = getDispatch();
const {
loadViewportMessages, setScrollOffset, loadSponsoredMessages, loadMessageReactions,
} = getDispatch();
// eslint-disable-next-line no-null/no-null
const containerRef = useRef<HTMLDivElement>(null);
@ -204,6 +208,17 @@ const MessageList: FC<OwnProps & StateProps> = ({
return groupMessages(orderBy(listedMessages, ['date', 'id']), memoUnreadDividerBeforeIdRef.current);
}, [messageIds, messagesById, threadFirstMessageId, threadTopMessageId]);
useInterval(() => {
if (!messageIds || !messagesById) {
return;
}
const ids = messageIds.filter((l) => messagesById[l]?.reactions);
if (!ids.length) return;
loadMessageReactions({ chatId, ids });
}, MESSAGE_REACTIONS_POLLING_INTERVAL);
const loadMoreAround = useMemo(() => {
if (type !== 'thread') {
return undefined;
@ -520,6 +535,7 @@ const MessageList: FC<OwnProps & StateProps> = ({
isViewportNewest={Boolean(isViewportNewest)}
isUnread={Boolean(firstUnreadId)}
withUsers={withUsers}
areReactionsInMeta={isPrivate}
noAvatars={noAvatars}
containerRef={containerRef}
anchorIdRef={anchorIdRef}

View File

@ -34,6 +34,7 @@ interface OwnProps {
threadId: number;
type: MessageListType;
isReady: boolean;
areReactionsInMeta: boolean;
isScrollingRef: { current: boolean | undefined };
isScrollPatchNeededRef: { current: boolean | undefined };
threadTopMessageId: number | undefined;
@ -53,6 +54,7 @@ const MessageListContent: FC<OwnProps> = ({
isViewportNewest,
isUnread,
withUsers,
areReactionsInMeta,
noAvatars,
containerRef,
anchorIdRef,
@ -188,6 +190,7 @@ 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

@ -4,7 +4,11 @@ import React, {
import { getDispatch, withGlobal } from '../../lib/teact/teactn';
import { ApiChatBannedRights, MAIN_THREAD_ID } from '../../api/types';
import { MessageListType, MessageList as GlobalMessageList } from '../../global/types';
import {
MessageListType,
MessageList as GlobalMessageList,
ActiveEmojiInteraction,
} from '../../global/types';
import { ThemeKey } from '../../types';
import {
@ -67,6 +71,8 @@ import UnpinAllMessagesModal from '../common/UnpinAllMessagesModal.async';
import PaymentModal from '../payment/PaymentModal.async';
import ReceiptModal from '../payment/ReceiptModal.async';
import SeenByModal from '../common/SeenByModal.async';
import EmojiInteractionAnimation from './EmojiInteractionAnimation.async';
import ReactorListModal from './ReactorListModal.async';
import './MiddleColumn.scss';
@ -94,6 +100,7 @@ type StateProps = {
isPaymentModalOpen?: boolean;
isReceiptModalOpen?: boolean;
isSeenByModalOpen: boolean;
isReactorListModalOpen: boolean;
animationLevel?: number;
shouldSkipHistoryAnimations?: boolean;
currentTransitionKey: number;
@ -102,6 +109,7 @@ type StateProps = {
canSubscribe?: boolean;
canStartBot?: boolean;
canRestartBot?: boolean;
activeEmojiInteraction?: ActiveEmojiInteraction;
};
const CLOSE_ANIMATION_DURATION = IS_SINGLE_COLUMN_LAYOUT ? 450 + ANIMATION_END_DELAY : undefined;
@ -134,6 +142,7 @@ const MiddleColumn: FC<StateProps> = ({
isPaymentModalOpen,
isReceiptModalOpen,
isSeenByModalOpen,
isReactorListModalOpen,
animationLevel,
shouldSkipHistoryAnimations,
currentTransitionKey,
@ -141,6 +150,7 @@ const MiddleColumn: FC<StateProps> = ({
canSubscribe,
canStartBot,
canRestartBot,
activeEmojiInteraction,
}) => {
const {
openChat,
@ -484,6 +494,7 @@ const MiddleColumn: FC<StateProps> = ({
onClose={clearReceipt}
/>
<SeenByModal isOpen={isSeenByModalOpen} />
<ReactorListModal isOpen={isReactorListModalOpen} />
</div>
</>
)}
@ -507,6 +518,9 @@ const MiddleColumn: FC<StateProps> = ({
onUnpin={handleUnpinAllMessages}
/>
)}
{activeEmojiInteraction && (
<EmojiInteractionAnimation emojiInteraction={activeEmojiInteraction} />
)}
</div>
);
};
@ -520,7 +534,7 @@ export default memo(withGlobal(
const { messageLists } = global.messages;
const currentMessageList = selectCurrentMessageList(global);
const { isLeftColumnShown, chats: { listIds } } = global;
const { isLeftColumnShown, chats: { listIds }, activeEmojiInteraction } = global;
const state: StateProps = {
theme,
@ -535,8 +549,10 @@ export default memo(withGlobal(
isPaymentModalOpen: global.payment.isPaymentModalOpen,
isReceiptModalOpen: Boolean(global.payment.receipt),
isSeenByModalOpen: Boolean(global.seenByModal),
isReactorListModalOpen: Boolean(global.reactorModal),
animationLevel: global.settings.byKey.animationLevel,
currentTransitionKey: Math.max(0, global.messages.messageLists.length - 1),
activeEmojiInteraction,
};
if (!currentMessageList || !listIds.active) {

View File

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

View File

@ -0,0 +1,38 @@
.ReactorListModal {
--color-reaction: var(--color-message-reaction);
--hover-color-reaction: var(--color-message-reaction-hover);
--accent-color: var(--color-primary);
.modal-content {
overflow: hidden;
}
.Reactions {
margin-bottom: 0.5rem;
}
.reaction-filter-emoji {
margin-right: 0.25rem;
}
.reactor-list {
max-height: 400px;
overflow: auto;
overflow-x: hidden;
}
.reactors-list-item {
margin: 0;
}
.reactors-list-item .ListItem-button {
display: flex;
align-items: center;
}
.reactors-list-emoji {
width: 1.5rem;
height: 1.5rem;
margin-inline-start: auto;
}
}

View File

@ -0,0 +1,203 @@
import React, {
FC, useCallback, memo, useMemo, useEffect, useState, useRef,
} from '../../lib/teact/teact';
import { getDispatch, getGlobal, withGlobal } from '../../lib/teact/teactn';
import { ApiMessage } from '../../api/types';
import { LoadMoreDirection } from '../../types';
import useLang from '../../hooks/useLang';
import { selectChatMessage } from '../../modules/selectors';
import useInfiniteScroll from '../../hooks/useInfiniteScroll';
import { getUserFullName } from '../../modules/helpers';
import renderText from '../common/helpers/renderText';
import useFlag from '../../hooks/useFlag';
import buildClassName from '../../util/buildClassName';
import { formatIntegerCompact } from '../../util/textFormat';
import { unique } from '../../util/iteratees';
import InfiniteScroll from '../ui/InfiniteScroll';
import Modal from '../ui/Modal';
import Button from '../ui/Button';
import Avatar from '../common/Avatar';
import ListItem from '../ui/ListItem';
import ReactionStaticEmoji from '../common/ReactionStaticEmoji';
import Loading from '../ui/Loading';
import './ReactorListModal.scss';
const MIN_REACTIONS_COUNT_FOR_FILTERS = 10;
export type OwnProps = {
isOpen: boolean;
};
export type StateProps = Pick<ApiMessage, 'reactors' | 'reactions' | 'seenByUserIds'> & {
chatId?: string;
messageId?: number;
};
const ReactorListModal: FC<OwnProps & StateProps> = ({
isOpen,
reactors,
reactions,
chatId,
messageId,
seenByUserIds,
}) => {
const {
loadReactors,
closeReactorListModal,
openChat,
} = getDispatch();
// No need for expensive global updates on users, so we avoid them
const usersById = getGlobal().users.byId;
const lang = useLang();
const [isClosing, startClosing, stopClosing] = useFlag(false);
const [chosenTab, setChosenTab] = useState<string | undefined>(undefined);
const canShowFilters = reactors && reactions && reactors.count >= MIN_REACTIONS_COUNT_FOR_FILTERS
&& reactions.results.length > 1;
const chatIdRef = useRef<string>();
useEffect(() => {
if (isClosing && !isOpen) {
stopClosing();
setChosenTab(undefined);
}
}, [isClosing, isOpen, stopClosing]);
const handleCloseAnimationEnd = useCallback(() => {
if (chatIdRef.current) {
openChat({ id: chatIdRef.current });
}
closeReactorListModal();
}, [closeReactorListModal, openChat]);
const handleClose = useCallback(() => {
startClosing();
}, [startClosing]);
const handleClick = useCallback((userId: string) => {
chatIdRef.current = userId;
handleClose();
}, [handleClose]);
const handleLoadMore = useCallback(() => {
loadReactors({
chatId,
messageId,
});
}, [chatId, loadReactors, messageId]);
const allReactions = useMemo(() => {
return reactors?.reactions ? unique(reactors.reactions.map((l) => l.reaction)) : [];
}, [reactors?.reactions]);
const userIds = useMemo(() => {
if (chosenTab) {
return reactors?.reactions.filter((l) => l.reaction === chosenTab).map((l) => l.userId);
}
return unique(reactors?.reactions.map((l) => l.userId).concat(seenByUserIds || []) || []);
}, [chosenTab, reactors?.reactions, seenByUserIds]);
const [viewportIds, getMore] = useInfiniteScroll(
handleLoadMore, userIds, reactors && reactors.nextOffset === undefined,
);
useEffect(() => {
getMore?.({ direction: LoadMoreDirection.Backwards });
}, [getMore]);
return (
<Modal
isOpen={isOpen && !isClosing}
onClose={handleClose}
className="ReactorListModal narrow"
title={lang('Reactions')}
onCloseAnimationEnd={handleCloseAnimationEnd}
>
{canShowFilters && (
<div className="Reactions">
<Button
className={buildClassName(!chosenTab && 'chosen')}
size="tiny"
ripple
onClick={() => setChosenTab(undefined)}
>
<i className="icon-reaction-filled" />
{reactors?.count && formatIntegerCompact(reactors.count)}
</Button>
{allReactions.map((reaction) => {
const count = reactions?.results.find((l) => l.reaction === reaction)?.count;
return (
<Button
className={buildClassName(chosenTab === reaction && 'chosen')}
size="tiny"
ripple
onClick={() => setChosenTab(reaction)}
>
<ReactionStaticEmoji reaction={reaction} className="reaction-filter-emoji" />
{count && formatIntegerCompact(count)}
</Button>
);
})}
</div>
)}
<div dir={lang.isRtl ? 'rtl' : undefined}>
{viewportIds?.length ? (
<InfiniteScroll
className="reactor-list custom-scroll"
items={viewportIds}
onLoadMore={getMore}
>
{viewportIds?.map(
(userId) => {
const user = usersById[userId];
const fullName = getUserFullName(user);
const reaction = reactors?.reactions.find((l) => l.userId === userId)?.reaction;
return (
<ListItem
key={userId}
className="chat-item-clickable reactors-list-item"
onClick={() => handleClick(userId)}
>
<Avatar user={user} size="medium" />
<div className="title">
<h3 dir="auto">{fullName && renderText(fullName)}</h3>
</div>
{reaction && <ReactionStaticEmoji className="reactors-list-emoji" reaction={reaction} />}
</ListItem>
);
},
)}
</InfiniteScroll>
) : <Loading />}
</div>
<Button
className="confirm-dialog-button"
isText
onClick={closeReactorListModal}
>
{lang('Close')}
</Button>
</Modal>
);
};
export default memo(withGlobal<OwnProps>(
(global): StateProps => {
const { chatId, messageId } = global.reactorModal || {};
const message = chatId && messageId ? selectChatMessage(global, chatId, messageId) : undefined;
return {
chatId,
messageId,
reactions: message?.reactions,
reactors: message?.reactors,
seenByUserIds: message?.seenByUserIds,
};
},
)(ReactorListModal));

View File

@ -7,8 +7,8 @@ import {
const REM = 16; // px
const MAX_TOOLBAR_WIDTH = 32 * REM;
const MAX_MESSAGES_LIST_WIDTH = 45.5 * REM;
const SIDE_COLUMN_MAX_WIDTH = 26.5 * REM;
const MIN_LEFT_COLUMN_WIDTH = 18 * REM;
export const SIDE_COLUMN_MAX_WIDTH = 26.5 * REM;
export const MIN_LEFT_COLUMN_WIDTH = 18 * REM;
const UNPIN_BUTTON_WIDTH = 16.125 * REM;
export default function calculateMiddleFooterTransforms(windowWidth: number, canPost?: boolean) {

View File

@ -4,7 +4,7 @@ import React, {
import { getDispatch, getGlobal, withGlobal } from '../../../lib/teact/teactn';
import { MessageListType } from '../../../global/types';
import { ApiMessage } from '../../../api/types';
import { ApiAvailableReaction, ApiMessage } from '../../../api/types';
import { IAlbum, IAnchorPosition } from '../../../types';
import {
selectActiveDownloadIds,
@ -13,12 +13,16 @@ import {
selectCurrentMessageList,
selectIsMessageProtected,
} from '../../../modules/selectors';
import { isChatGroup, isOwnMessage } from '../../../modules/helpers';
import { SEEN_BY_MEMBERS_EXPIRE, SEEN_BY_MEMBERS_CHAT_MAX } from '../../../config';
import {
isActionMessage, isChatChannel,
isChatGroup, isOwnMessage, areReactionsEmpty, isUserId,
} from '../../../modules/helpers';
import { SEEN_BY_MEMBERS_EXPIRE, SEEN_BY_MEMBERS_CHAT_MAX, SERVICE_NOTIFICATIONS_USER_ID } from '../../../config';
import { getDayStartAt } from '../../../util/dateFormat';
import { copyTextToClipboard } from '../../../util/clipboard';
import useShowTransition from '../../../hooks/useShowTransition';
import useFlag from '../../../hooks/useFlag';
import { REM } from '../../common/helpers/mediaDimensions';
import DeleteMessageModal from '../../common/DeleteMessageModal';
import ReportMessageModal from '../../common/ReportMessageModal';
@ -26,6 +30,8 @@ import PinMessageModal from '../../common/PinMessageModal';
import MessageContextMenu from './MessageContextMenu';
import CalendarModal from '../../common/CalendarModal';
const START_SIZE = 2 * REM;
export type OwnProps = {
isOpen: boolean;
chatUsername?: string;
@ -38,11 +44,15 @@ export type OwnProps = {
};
type StateProps = {
availableReactions?: ApiAvailableReaction[];
noOptions?: boolean;
canSendNow?: boolean;
canReschedule?: boolean;
canReply?: boolean;
canPin?: boolean;
canShowReactionsCount?: boolean;
canShowReactionList?: boolean;
canRemoveReaction?: boolean;
canUnpin?: boolean;
canDelete?: boolean;
canReport?: boolean;
@ -51,14 +61,18 @@ type StateProps = {
canFaveSticker?: boolean;
canUnfaveSticker?: boolean;
canCopy?: boolean;
isPrivate?: boolean;
hasFullInfo?: boolean;
canCopyLink?: boolean;
canSelect?: boolean;
canDownload?: boolean;
activeDownloads: number[];
canShowSeenBy?: boolean;
enabledReactions?: string[];
};
const ContextMenuContainer: FC<OwnProps & StateProps> = ({
availableReactions,
isOpen,
messageListType,
chatUsername,
@ -69,13 +83,19 @@ const ContextMenuContainer: FC<OwnProps & StateProps> = ({
onCloseAnimationEnd,
noOptions,
canSendNow,
hasFullInfo,
canReschedule,
canReply,
canPin,
canUnpin,
canDelete,
canReport,
canShowReactionsCount,
canShowReactionList,
canRemoveReaction,
canEdit,
enabledReactions,
isPrivate,
canForward,
canFaveSticker,
canUnfaveSticker,
@ -100,6 +120,10 @@ const ContextMenuContainer: FC<OwnProps & StateProps> = ({
cancelMessageMediaDownload,
loadSeenBy,
openSeenByModal,
sendReaction,
openReactorListModal,
loadFullChat,
loadReactors,
} = getDispatch();
const { transitionClassNames } = useShowTransition(isOpen, onCloseAnimationEnd, undefined, false);
@ -115,7 +139,26 @@ const ContextMenuContainer: FC<OwnProps & StateProps> = ({
}
}, [loadSeenBy, isOpen, message.chatId, message.id, canShowSeenBy]);
useEffect(() => {
if (canShowReactionsCount && isOpen) {
loadReactors({ chatId: message.chatId, messageId: message.id });
}
}, [canShowReactionsCount, isOpen, loadReactors, message.chatId, message.id]);
useEffect(() => {
if (!hasFullInfo && !isPrivate && isOpen) {
loadFullChat({ chatId: message.chatId });
}
}, [hasFullInfo, isOpen, isPrivate, loadFullChat, message.chatId]);
const seenByRecentUsers = useMemo(() => {
if (message.reactions?.recentReactions?.length) {
// No need for expensive global updates on users, so we avoid them
const usersById = getGlobal().users.byId;
return message.reactions?.recentReactions?.slice(0, 3).map(({ userId }) => usersById[userId]).filter(Boolean);
}
if (!message.seenByUserIds) {
return undefined;
}
@ -123,7 +166,7 @@ const ContextMenuContainer: FC<OwnProps & StateProps> = ({
// No need for expensive global updates on users, so we avoid them
const usersById = getGlobal().users.byId;
return message.seenByUserIds?.slice(0, 3).map((id) => usersById[id]).filter(Boolean);
}, [message.seenByUserIds]);
}, [message.reactions?.recentReactions, message.seenByUserIds]);
const isDownloading = album ? album.messages.some((msg) => activeDownloads.includes(msg.id))
: activeDownloads.includes(message.id);
@ -231,6 +274,11 @@ const ContextMenuContainer: FC<OwnProps & StateProps> = ({
openSeenByModal({ chatId: message.chatId, messageId: message.id });
}, [closeMenu, message.chatId, message.id, openSeenByModal]);
const handleOpenReactorListModal = useCallback(() => {
closeMenu();
openReactorListModal({ chatId: message.chatId, messageId: message.id });
}, [closeMenu, openReactorListModal, message.chatId, message.id]);
const handleRescheduleMessage = useCallback((date: Date) => {
rescheduleMessage({
chatId: message.chatId,
@ -255,6 +303,13 @@ const ContextMenuContainer: FC<OwnProps & StateProps> = ({
closeMenu();
}, [album, message, closeMenu, isDownloading, cancelMessageMediaDownload, downloadMessageMedia]);
const handleSendReaction = useCallback((reaction: string | undefined, x: number, y: number) => {
sendReaction({
chatId: message.chatId, messageId: message.id, reaction, x, y, startSize: START_SIZE,
});
closeMenu();
}, [closeMenu, message.chatId, message.id, sendReaction]);
const reportMessageIds = useMemo(() => (album ? album.messages : [message]).map(({ id }) => id), [album, message]);
if (noOptions) {
@ -269,9 +324,15 @@ const ContextMenuContainer: FC<OwnProps & StateProps> = ({
return (
<div className={['ContextMenuContainer', transitionClassNames].join(' ')}>
<MessageContextMenu
availableReactions={availableReactions}
message={message}
isPrivate={isPrivate}
isOpen={isMenuOpen}
enabledReactions={enabledReactions}
anchor={anchor}
canShowReactionsCount={canShowReactionsCount}
canShowReactionList={canShowReactionList}
canRemoveReaction={canRemoveReaction}
canSendNow={canSendNow}
canReschedule={canReschedule}
canReply={canReply}
@ -306,6 +367,8 @@ const ContextMenuContainer: FC<OwnProps & StateProps> = ({
onCopyLink={handleCopyLink}
onDownload={handleDownloadClick}
onShowSeenBy={handleOpenSeenByModal}
onSendReaction={handleSendReaction}
onShowReactors={handleOpenReactorListModal}
/>
<DeleteMessageModal
isOpen={isDeleteModalOpen}
@ -361,15 +424,22 @@ export default memo(withGlobal<OwnProps>(
} = (threadId && selectAllowedMessageActions(global, message, threadId)) || {};
const isPinned = messageListType === 'pinned';
const isScheduled = messageListType === 'scheduled';
const isChannel = chat && isChatChannel(chat);
const canShowSeenBy = Boolean(chat
&& isChatGroup(chat)
&& isOwnMessage(message)
&& chat.membersCount
&& chat.membersCount < SEEN_BY_MEMBERS_CHAT_MAX
&& message.date > Date.now() / 1000 - SEEN_BY_MEMBERS_EXPIRE);
const isPrivate = chat && isUserId(chat.id);
const isAction = isActionMessage(message);
const canShowReactionsCount = !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);
return {
availableReactions: global.availableReactions,
noOptions,
canSendNow: isScheduled,
canReschedule: isScheduled,
@ -388,6 +458,12 @@ export default memo(withGlobal<OwnProps>(
canDownload: !isProtected && canDownload,
activeDownloads,
canShowSeenBy,
enabledReactions: chat?.fullInfo?.enabledReactions,
isPrivate,
hasFullInfo: Boolean(chat?.fullInfo),
canShowReactionsCount,
canShowReactionList: !isAction && !isScheduled && chat?.id !== SERVICE_NOTIFICATIONS_USER_ID,
canRemoveReaction,
};
},
)(ContextMenuContainer));

View File

@ -7,6 +7,8 @@
--background-color: var(--color-background);
--hover-color: var(--color-reply-hover);
--color-reaction: var(--color-message-reaction);
--hover-color-reaction: var(--color-message-reaction-hover);
--active-color: var(--color-reply-active);
--max-width: 29rem;
--accent-color: var(--color-primary);
@ -53,6 +55,42 @@
}
}
.quick-reaction {
cursor: pointer;
position: absolute;
right: -0.5rem;
bottom: -0.375rem;
display: flex;
align-items: center;
justify-content: center;
transform: scale(1);
opacity: 0;
transition: transform 0.2s ease-out, opacity 0.2s ease-out;
transition-delay: 0.2s;
&.visible {
opacity: 1 !important;
}
&:hover {
transition-delay: unset;
transform: scale(1.4);
}
.ReactionStaticEmoji {
width: 1.125rem;
}
}
&.last-in-list .quick-reaction:hover {
transform: translateY(-0.1875rem) scale(1.4);
}
&.own .quick-reaction {
right: auto;
left: -0.5rem;
}
&.last-in-group {
margin-bottom: 0.625rem;
}
@ -82,6 +120,12 @@
}
}
&.has-active-reaction {
.message-content-wrapper {
z-index: 1;
}
}
&:not(.own) {
padding-left: 2.5rem;
@ -102,6 +146,8 @@
flex-direction: row-reverse;
--background-color: var(--color-background-own);
--hover-color: var(--color-reply-own-hover);
--color-reaction: var(--color-message-reaction-own);
--hover-color-reaction: var(--color-message-reaction-hover-own);
--active-color: var(--color-reply-own-active);
--max-width: 30rem;
--accent-color: var(--color-accent-own);

View File

@ -8,14 +8,14 @@ import React, {
} from '../../../lib/teact/teact';
import { getDispatch, withGlobal } from '../../../lib/teact/teactn';
import { MessageListType } from '../../../global/types';
import { ActiveEmojiInteraction, ActiveReaction, MessageListType } from '../../../global/types';
import {
ApiMessage,
ApiMessageOutgoingStatus,
ApiUser,
ApiChat,
ApiSticker,
ApiThreadInfo,
ApiThreadInfo, ApiAvailableReaction,
} from '../../../api/types';
import {
AudioOrigin, FocusDirection, IAlbum, ISettings,
@ -45,7 +45,13 @@ import {
selectAllowedMessageActions,
selectIsDownloading,
selectThreadInfo,
selectAnimatedEmojiEffect,
selectAnimatedEmojiSound,
selectMessageIdsByGroupId,
selectLocalAnimatedEmoji,
selectIsMessageProtected,
selectLocalAnimatedEmojiEffect,
selectDefaultReaction,
} from '../../../modules/selectors';
import {
getMessageContent,
@ -60,6 +66,7 @@ import {
getMessageSingleEmoji,
getSenderTitle,
getUserColorKey,
areReactionsEmpty,
} from '../../../modules/helpers';
import buildClassName from '../../../util/buildClassName';
import useEnsureMessage from '../../../hooks/useEnsureMessage';
@ -98,6 +105,9 @@ import Album from './Album';
import RoundVideo from './RoundVideo';
import InlineButtons from './InlineButtons';
import CommentButton from './CommentButton';
import Reactions from './Reactions';
import ReactionStaticEmoji from '../../common/ReactionStaticEmoji';
import LocalAnimatedEmoji from '../../common/LocalAnimatedEmoji';
import './Message.scss';
@ -119,6 +129,7 @@ type OwnProps =
noAvatars?: boolean;
withAvatar?: boolean;
withSenderName?: boolean;
areReactionsInMeta?: boolean;
threadId: number;
messageListType: MessageListType;
noComments: boolean;
@ -136,6 +147,7 @@ type StateProps = {
isThreadTop?: boolean;
shouldHideReply?: boolean;
replyMessage?: ApiMessage;
reactionsMessage?: ApiMessage;
replyMessageSender?: ApiUser | ApiChat;
outgoingStatus?: ApiMessageOutgoingStatus;
uploadProgress?: number;
@ -153,6 +165,10 @@ type StateProps = {
highlight?: string;
isSingleEmoji?: boolean;
animatedEmoji?: ApiSticker;
localSticker?: string;
localEffect?: string;
animatedEmojiEffect?: ApiSticker;
animatedEmojiSoundId?: string;
isInSelectMode?: boolean;
isSelected?: boolean;
isGroupSelected?: boolean;
@ -164,10 +180,13 @@ type StateProps = {
shouldLoopStickers?: boolean;
autoLoadFileMaxSizeMb: number;
threadInfo?: ApiThreadInfo;
defaultReaction?: string;
activeReaction?: ActiveReaction;
activeEmojiInteraction?: ActiveEmojiInteraction;
availableReactions?: ApiAvailableReaction[];
};
const NBSP = '\u00A0';
const GROUP_MESSAGE_HOVER_ATTRIBUTE = 'data-is-document-group-hover';
// eslint-disable-next-line max-len
const APPENDIX_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="M6 17H0V0c.193 2.84.876 5.767 2.05 8.782.904 2.325 2.446 4.485 4.625 6.48A1 1 0 016 17z" fill="#000" filter="url(#a)"/><path d="M6 17H0V0c.193 2.84.876 5.767 2.05 8.782.904 2.325 2.446 4.485 4.625 6.48A1 1 0 016 17z" fill="#EEFFDE" class="corner"/></g></svg>' };
// eslint-disable-next-line max-len
@ -185,6 +204,8 @@ const Message: FC<OwnProps & StateProps> = ({
noAvatars,
withAvatar,
withSenderName,
areReactionsInMeta,
reactionsMessage,
noComments,
appearanceOrder,
isFirstInGroup,
@ -216,10 +237,18 @@ const Message: FC<OwnProps & StateProps> = ({
lastSyncTime,
highlight,
animatedEmoji,
localSticker,
localEffect,
animatedEmojiEffect,
animatedEmojiSoundId,
isInSelectMode,
isSelected,
isGroupSelected,
threadId,
defaultReaction,
activeReaction,
activeEmojiInteraction,
availableReactions,
messageListType,
isPinnedList,
isDownloading,
@ -239,6 +268,8 @@ const Message: FC<OwnProps & StateProps> = ({
const ref = useRef<HTMLDivElement>(null);
// eslint-disable-next-line no-null/no-null
const bottomMarkerRef = useRef<HTMLDivElement>(null);
// eslint-disable-next-line no-null/no-null
const contentRef = useRef<HTMLDivElement>(null);
const lang = useLang();
@ -288,6 +319,8 @@ const Message: FC<OwnProps & StateProps> = ({
const {
text, photo, video, audio, voice, document, sticker, contact, poll, webPage, invoice,
} = getMessageContent(message);
const hasReactionButtons = !areReactionsInMeta && reactionsMessage?.reactions
&& !areReactionsEmpty(reactionsMessage.reactions);
const textParts = renderMessageText(message, highlight, isEmojiOnlyMessage(customShape));
const isContextMenuShown = contextMenuPosition !== undefined;
const signature = (
@ -308,6 +341,9 @@ const Message: FC<OwnProps & StateProps> = ({
));
const avatarPeer = forwardInfo && (isChatWithSelf || isRepliesChat || !sender) ? originSender : sender;
const senderPeer = forwardInfo ? originSender : sender;
const hasAnimatedEmoji = localSticker || animatedEmoji;
const areReactionsOutside = hasReactionButtons
&& (asForwarded || customShape || ((photo || video || hasAnimatedEmoji) && !textParts));
const selectMessage = useCallback((e?: React.MouseEvent<HTMLDivElement, MouseEvent>, groupedId?: string) => {
toggleMessageSelection({
@ -324,7 +360,12 @@ const Message: FC<OwnProps & StateProps> = ({
handleContextMenu,
handleDoubleClick,
handleContentDoubleClick,
handleMouseMove,
handleSendQuickReaction,
handleMouseLeave,
isSwiped,
isQuickReactionVisible,
handleDocumentGroupMouseEnter,
} = useOuterHandlers(
selectMessage,
ref,
@ -335,6 +376,11 @@ const Message: FC<OwnProps & StateProps> = ({
Boolean(isProtected),
onContextMenu,
handleBeforeContextMenu,
chatId,
isContextMenuShown,
contentRef,
isOwn,
isInDocumentGroup && !isLastInDocumentGroup,
);
const {
@ -395,6 +441,7 @@ const Message: FC<OwnProps & StateProps> = ({
Boolean(message.inlineButtons) && 'has-inline-buttons',
isSwiped && 'is-swiped',
transitionClassNames,
Boolean(activeReaction) && 'has-active-reaction',
);
const contentClassName = buildContentClassName(message, {
hasReply,
@ -410,6 +457,7 @@ const Message: FC<OwnProps & StateProps> = ({
threadInfo && (!isInDocumentGroup || isLastInDocumentGroup) && messageListType === 'thread' && !noComments
);
const withAppendix = contentClassName.includes('has-appendix');
const withQuickReaction = !IS_TOUCH_ENV && defaultReaction && (!isInDocumentGroup || isLastInDocumentGroup);
useEnsureMessage(
isRepliesChat && message.replyToChatId ? message.replyToChatId : chatId,
@ -474,6 +522,21 @@ const Message: FC<OwnProps & StateProps> = ({
);
}
function renderMeta(withReactionOffset = false) {
return (
<MessageMeta
message={message}
withReactionOffset={withReactionOffset}
withReactions={areReactionsInMeta}
outgoingStatus={outgoingStatus}
signature={signature}
onClick={handleMetaClick}
activeReaction={activeReaction}
availableReactions={availableReactions}
/>
);
}
function renderContent() {
const className = buildClassName(
'content-inner',
@ -482,7 +545,7 @@ const Message: FC<OwnProps & StateProps> = ({
noMediaCorners && 'no-media-corners',
);
const hasCustomAppendix = isLastInGroup && !textParts && !asForwarded && !hasThread;
const shouldInlineMeta = !webPage && !animatedEmoji && textParts;
const shouldInlineMeta = !webPage && !hasAnimatedEmoji && textParts;
const textContentClass = buildClassName(
'text-content',
shouldInlineMeta && 'with-meta',
@ -513,10 +576,31 @@ const Message: FC<OwnProps & StateProps> = ({
{animatedEmoji && (
<AnimatedEmoji
size="small"
isOwn={isOwn}
sticker={animatedEmoji}
effect={animatedEmojiEffect}
soundId={animatedEmojiSoundId}
observeIntersection={observeIntersectionForMedia}
lastSyncTime={lastSyncTime}
forceLoadPreview={isLocal}
messageId={messageId}
chatId={chatId}
activeEmojiInteraction={activeEmojiInteraction}
/>
)}
{localSticker && (
<LocalAnimatedEmoji
size="small"
isOwn={isOwn}
localSticker={localSticker}
localEffect={localEffect}
soundId={animatedEmojiSoundId}
observeIntersection={observeIntersectionForMedia}
lastSyncTime={lastSyncTime}
forceLoadPreview={isLocal}
messageId={messageId}
chatId={chatId}
activeEmojiInteraction={activeEmojiInteraction}
/>
)}
{isAlbum && (
@ -605,19 +689,25 @@ const Message: FC<OwnProps & StateProps> = ({
{poll && (
<Poll message={message} poll={poll} onSendVote={handleVoteSend} />
)}
{!animatedEmoji && textParts && (
{!hasAnimatedEmoji && textParts && (
<p className={textContentClass} dir="auto">
{textParts}
{shouldInlineMeta && (
<MessageMeta
message={message}
outgoingStatus={outgoingStatus}
signature={signature}
onClick={handleMetaClick}
/>
<>
{hasReactionButtons && !areReactionsOutside ? (
<Reactions
activeReaction={activeReaction}
message={reactionsMessage!}
metaChildren={renderMeta(true)}
availableReactions={availableReactions}
/>
)
: renderMeta()}
</>
)}
</p>
)}
{webPage && (
<WebPage
message={message}
@ -705,7 +795,8 @@ const Message: FC<OwnProps & StateProps> = ({
onContextMenu={handleContextMenu}
onDoubleClick={handleDoubleClick}
onMouseEnter={isInDocumentGroup && !isLastInDocumentGroup ? handleDocumentGroupMouseEnter : undefined}
onMouseLeave={isInDocumentGroup && !isLastInDocumentGroup ? handleDocumentGroupMouseLeave : undefined}
onMouseMove={withQuickReaction ? handleMouseMove : undefined}
onMouseLeave={(withQuickReaction || (isInDocumentGroup && !isLastInDocumentGroup)) ? handleMouseLeave : undefined}
>
<div
ref={bottomMarkerRef}
@ -734,6 +825,7 @@ const Message: FC<OwnProps & StateProps> = ({
className={buildClassName('message-content-wrapper', contentClassName.includes('text') && 'can-select-text')}
>
<div
ref={contentRef}
className={contentClassName}
// @ts-ignore
style={style}
@ -743,13 +835,17 @@ const Message: FC<OwnProps & StateProps> = ({
<div className="message-title">{lang('ForwardedMessage')}</div>
)}
{renderContent()}
{(!isInDocumentGroup || isLastInDocumentGroup) && !(!webPage && !animatedEmoji && textParts) && (
<MessageMeta
message={message}
outgoingStatus={outgoingStatus}
signature={signature}
onClick={handleMetaClick}
/>
{(!isInDocumentGroup || isLastInDocumentGroup) && !(!webPage && !hasAnimatedEmoji && textParts) && (
<>
{hasReactionButtons && !areReactionsOutside && (hasAnimatedEmoji || !textParts || webPage) ? (
<Reactions
activeReaction={activeReaction}
message={reactionsMessage!}
metaChildren={renderMeta(true)}
availableReactions={availableReactions}
/>
) : renderMeta()}
</>
)}
{canShowActionButton && canForward ? (
<Button
@ -778,10 +874,26 @@ const Message: FC<OwnProps & StateProps> = ({
{withAppendix && (
<div className="svg-appendix" dangerouslySetInnerHTML={isOwn ? APPENDIX_OWN : APPENDIX_NOT_OWN} />
)}
{withQuickReaction && (
<div
className={buildClassName('quick-reaction', isQuickReactionVisible && !activeReaction && 'visible')}
onClick={handleSendQuickReaction}
>
<ReactionStaticEmoji reaction={defaultReaction!} />
</div>
)}
</div>
{message.inlineButtons && (
<InlineButtons message={message} onClick={clickInlineButton} />
)}
{areReactionsOutside && (
<Reactions
message={reactionsMessage!}
isOutside
activeReaction={activeReaction}
availableReactions={availableReactions}
/>
)}
</div>
{contextMenuPosition && (
<ContextMenuContainer
@ -799,35 +911,11 @@ const Message: FC<OwnProps & StateProps> = ({
);
};
function handleDocumentGroupMouseEnter(e: React.MouseEvent<HTMLDivElement>) {
const lastGroupElement = getLastElementInDocumentGroup(e.currentTarget);
if (lastGroupElement) {
lastGroupElement.setAttribute(GROUP_MESSAGE_HOVER_ATTRIBUTE, '');
}
}
function handleDocumentGroupMouseLeave(e: React.MouseEvent<HTMLDivElement>) {
const lastGroupElement = getLastElementInDocumentGroup(e.currentTarget);
if (lastGroupElement) {
lastGroupElement.removeAttribute(GROUP_MESSAGE_HOVER_ATTRIBUTE);
}
}
function getLastElementInDocumentGroup(element: Element) {
let current: Element | null = element;
do {
current = current.nextElementSibling;
} while (current && !current.classList.contains('last-in-document-group'));
return current;
}
export default memo(withGlobal<OwnProps>(
(global, ownProps): StateProps => {
const { focusedMessage, forwardMessages, lastSyncTime } = global;
const {
message, album, withSenderName, withAvatar, threadId, messageListType,
message, album, withSenderName, withAvatar, threadId, messageListType, isLastInDocumentGroup,
} = ownProps;
const {
id, chatId, viaBotId, replyToChatId, replyToMessageId, isOutgoing, threadInfo,
@ -884,6 +972,16 @@ export default memo(withGlobal<OwnProps>(
? selectThreadInfo(global, threadInfo.chatId, threadInfo.threadId) || threadInfo
: undefined;
const firstMessageInGroupId = message.groupedId
&& selectMessageIdsByGroupId(global, chatId, message.groupedId)?.[0];
const reactionsMessage = firstMessageInGroupId && !album
? (isLastInDocumentGroup
? selectChatMessage(global, chatId, firstMessageInGroupId)
: undefined)
: message;
const localSticker = singleEmoji ? selectLocalAnimatedEmoji(global, singleEmoji) : undefined;
return {
theme: selectTheme(global),
chatUsername,
@ -898,6 +996,7 @@ export default memo(withGlobal<OwnProps>(
isProtected: selectIsMessageProtected(global, message),
isFocused,
isForwarding,
reactionsMessage,
isChatWithSelf,
isRepliesChat,
isChannel,
@ -906,6 +1005,10 @@ export default memo(withGlobal<OwnProps>(
highlight,
isSingleEmoji: Boolean(singleEmoji),
animatedEmoji: singleEmoji ? selectAnimatedEmoji(global, singleEmoji) : undefined,
animatedEmojiEffect: singleEmoji && isUserId(chatId) ? selectAnimatedEmojiEffect(global, singleEmoji) : undefined,
animatedEmojiSoundId: singleEmoji ? selectAnimatedEmojiSound(global, singleEmoji) : undefined,
localSticker,
localEffect: localSticker && isUserId(chatId) ? selectLocalAnimatedEmojiEffect(localSticker) : undefined,
isInSelectMode: selectIsInSelectMode(global),
isSelected,
isGroupSelected: (
@ -922,6 +1025,10 @@ export default memo(withGlobal<OwnProps>(
...(isOutgoing && { outgoingStatus: selectOutgoingStatus(global, message, messageListType === 'scheduled') }),
...(typeof uploadProgress === 'number' && { uploadProgress }),
...(isFocused && { focusDirection, noFocusHighlight, isResizingContainer }),
defaultReaction: selectDefaultReaction(global, chatId),
activeReaction: global.activeReactions[id],
activeEmojiInteraction: global.activeEmojiInteraction,
availableReactions: global.availableReactions,
};
},
)(Message));

View File

@ -2,11 +2,22 @@
position: absolute;
font-size: 1rem;
.scrollable-content {
overflow: auto;
overflow: overlay;
padding: 0.5rem 0;
overscroll-behavior: contain;
}
.bubble {
transform: scale(0.5);
transition: opacity .15s cubic-bezier(0.2, 0, 0.2, 1), transform .15s cubic-bezier(0.2, 0, 0.2, 1) !important;
overflow: auto;
overflow: overlay;
overflow: initial;
padding: 0;
}
&.with-reactions .bubble {
margin-top: 3.5rem;
}
.backdrop {

View File

@ -2,31 +2,39 @@ import React, {
FC, memo, useCallback, useEffect, useRef,
} from '../../../lib/teact/teact';
import { ApiMessage, ApiUser } from '../../../api/types';
import { ApiAvailableReaction, ApiMessage, ApiUser } from '../../../api/types';
import { IAnchorPosition } from '../../../types';
import { getMessageCopyOptions } from './helpers/copyOptions';
import { disableScrolling, enableScrolling } from '../../../util/scrollLock';
import useContextMenuPosition from '../../../hooks/useContextMenuPosition';
import useLang from '../../../hooks/useLang';
import buildClassName from '../../../util/buildClassName';
import useFlag from '../../../hooks/useFlag';
import Menu from '../../ui/Menu';
import MenuItem from '../../ui/MenuItem';
import Avatar from '../../common/Avatar';
import ReactionSelector from './ReactionSelector';
import './MessageContextMenu.scss';
type OwnProps = {
availableReactions?: ApiAvailableReaction[];
isOpen: boolean;
anchor: IAnchorPosition;
message: ApiMessage;
canSendNow?: boolean;
enabledReactions?: string[];
canReschedule?: boolean;
canReply?: boolean;
canPin?: boolean;
canUnpin?: boolean;
canDelete?: boolean;
canReport?: boolean;
canShowReactionsCount?: boolean;
canShowReactionList?: boolean;
canRemoveReaction?: boolean;
canEdit?: boolean;
canForward?: boolean;
canFaveSticker?: boolean;
@ -34,6 +42,7 @@ type OwnProps = {
canCopy?: boolean;
canCopyLink?: boolean;
canSelect?: boolean;
isPrivate?: boolean;
canDownload?: boolean;
isDownloading?: boolean;
canShowSeenBy?: boolean;
@ -55,13 +64,20 @@ type OwnProps = {
onCopyLink?: () => void;
onDownload?: () => void;
onShowSeenBy?: () => void;
onShowReactors?: () => void;
onSendReaction: (reaction: string | undefined, x: number, y: number) => void;
};
const SCROLLBAR_WIDTH = 10;
const REACTION_BUBBLE_EXTRA_WIDTH = 32;
const ANIMATION_DURATION = 200;
const MessageContextMenu: FC<OwnProps> = ({
availableReactions,
isOpen,
message,
isPrivate,
enabledReactions,
anchor,
canSendNow,
canReschedule,
@ -80,6 +96,9 @@ const MessageContextMenu: FC<OwnProps> = ({
canDownload,
isDownloading,
canShowSeenBy,
canShowReactionsCount,
canRemoveReaction,
canShowReactionList,
seenByRecentUsers,
onReply,
onEdit,
@ -98,10 +117,18 @@ const MessageContextMenu: FC<OwnProps> = ({
onCopyLink,
onDownload,
onShowSeenBy,
onShowReactors,
onSendReaction,
}) => {
// eslint-disable-next-line no-null/no-null
const menuRef = useRef<HTMLDivElement>(null);
// eslint-disable-next-line no-null/no-null
const scrollableRef = useRef<HTMLDivElement>(null);
const copyOptions = getMessageCopyOptions(message, onClose, canCopyLink ? onCopyLink : undefined);
const noReactions = !isPrivate && !enabledReactions?.length;
const withReactions = canShowReactionList && !noReactions;
const [isReady, markIsReady, unmarkIsReady] = useFlag();
const getTriggerElement = useCallback(() => {
return document.querySelector(`.Transition__slide--active > .MessageList div[data-message-id="${message.id}"]`);
@ -117,6 +144,21 @@ const MessageContextMenu: FC<OwnProps> = ({
[],
);
const handleRemoveReaction = useCallback(() => {
onSendReaction(undefined, 0, 0);
}, [onSendReaction]);
useEffect(() => {
if (!isOpen) {
unmarkIsReady();
return;
}
setTimeout(() => {
markIsReady();
}, ANIMATION_DURATION);
}, [isOpen, markIsReady, unmarkIsReady]);
const {
positionX, positionY, style, menuStyle, withScroll,
} = useContextMenuPosition(
@ -126,10 +168,11 @@ const MessageContextMenu: FC<OwnProps> = ({
getMenuElement,
SCROLLBAR_WIDTH,
(document.querySelector('.MiddleHeader') as HTMLElement).offsetHeight,
withReactions ? REACTION_BUBBLE_EXTRA_WIDTH : undefined,
);
useEffect(() => {
disableScrolling(withScroll ? menuRef.current : undefined);
disableScrolling(withScroll ? scrollableRef.current : undefined, '.ReactionSelector');
return enableScrolling;
}, [withScroll]);
@ -144,51 +187,79 @@ const MessageContextMenu: FC<OwnProps> = ({
positionY={positionY}
style={style}
menuStyle={menuStyle}
className="MessageContextMenu fluid"
className={buildClassName(
'MessageContextMenu', 'fluid', withReactions && 'with-reactions',
)}
onClose={onClose}
onCloseAnimationEnd={onCloseAnimationEnd}
>
{canSendNow && <MenuItem icon="send-outline" onClick={onSend}>{lang('MessageScheduleSend')}</MenuItem>}
{canReschedule && (
<MenuItem icon="schedule" onClick={onReschedule}>{lang('MessageScheduleEditTime')}</MenuItem>
{canShowReactionList && (
<ReactionSelector
enabledReactions={enabledReactions}
onSendReaction={onSendReaction}
isPrivate={isPrivate}
availableReactions={availableReactions}
isReady={isReady}
/>
)}
{canReply && <MenuItem icon="reply" onClick={onReply}>{lang('Reply')}</MenuItem>}
{canEdit && <MenuItem icon="edit" onClick={onEdit}>{lang('Edit')}</MenuItem>}
{canFaveSticker && (
<MenuItem icon="favorite" onClick={onFaveSticker}>{lang('AddToFavorites')}</MenuItem>
)}
{canUnfaveSticker && (
<MenuItem icon="favorite" onClick={onUnfaveSticker}>{lang('Stickers.RemoveFromFavorites')}</MenuItem>
)}
{canCopy && copyOptions.map((options) => (
<MenuItem key={options.label} icon="copy" onClick={options.handler}>{lang(options.label)}</MenuItem>
))}
{canPin && <MenuItem icon="pin" onClick={onPin}>{lang('DialogPin')}</MenuItem>}
{canUnpin && <MenuItem icon="unpin" onClick={onUnpin}>{lang('DialogUnpin')}</MenuItem>}
{canDownload && (
<MenuItem icon="download" onClick={onDownload}>
{isDownloading ? lang('lng_context_cancel_download') : lang('lng_media_download')}
</MenuItem>
)}
{canForward && <MenuItem icon="forward" onClick={onForward}>{lang('Forward')}</MenuItem>}
{canSelect && <MenuItem icon="select" onClick={onSelect}>{lang('Common.Select')}</MenuItem>}
{canReport && <MenuItem icon="flag" onClick={onReport}>{lang('lng_context_report_msg')}</MenuItem>}
{canShowSeenBy && (
<MenuItem icon="group" onClick={onShowSeenBy} disabled={!message.seenByUserIds?.length}>
{message.seenByUserIds?.length
? lang('Conversation.ContextMenuSeen', message.seenByUserIds.length, 'i')
: lang('Conversation.ContextMenuNoViews')}
<div className="avatars">
{seenByRecentUsers?.map((user) => (
<Avatar
size="micro"
user={user}
/>
))}
</div>
</MenuItem>
)}
{canDelete && <MenuItem destructive icon="delete" onClick={onDelete}>{lang('Delete')}</MenuItem>}
<div
className="scrollable-content custom-scroll"
// @ts-ignore teact feature
style={menuStyle}
ref={scrollableRef}
>
{canRemoveReaction && <MenuItem icon="reactions" onClick={handleRemoveReaction}>Remove Reaction</MenuItem>}
{canSendNow && <MenuItem icon="send-outline" onClick={onSend}>{lang('MessageScheduleSend')}</MenuItem>}
{canReschedule && (
<MenuItem icon="schedule" onClick={onReschedule}>{lang('MessageScheduleEditTime')}</MenuItem>
)}
{canReply && <MenuItem icon="reply" onClick={onReply}>{lang('Reply')}</MenuItem>}
{canEdit && <MenuItem icon="edit" onClick={onEdit}>{lang('Edit')}</MenuItem>}
{canFaveSticker && (
<MenuItem icon="favorite" onClick={onFaveSticker}>{lang('AddToFavorites')}</MenuItem>
)}
{canUnfaveSticker && (
<MenuItem icon="favorite" onClick={onUnfaveSticker}>{lang('Stickers.RemoveFromFavorites')}</MenuItem>
)}
{canCopy && copyOptions.map((options) => (
<MenuItem key={options.label} icon="copy" onClick={options.handler}>{lang(options.label)}</MenuItem>
))}
{canPin && <MenuItem icon="pin" onClick={onPin}>{lang('DialogPin')}</MenuItem>}
{canUnpin && <MenuItem icon="unpin" onClick={onUnpin}>{lang('DialogUnpin')}</MenuItem>}
{canDownload && (
<MenuItem icon="download" onClick={onDownload}>
{isDownloading ? lang('lng_context_cancel_download') : lang('lng_media_download')}
</MenuItem>
)}
{canForward && <MenuItem icon="forward" onClick={onForward}>{lang('Forward')}</MenuItem>}
{canSelect && <MenuItem icon="select" onClick={onSelect}>{lang('Common.Select')}</MenuItem>}
{canReport && <MenuItem icon="flag" onClick={onReport}>{lang('lng_context_report_msg')}</MenuItem>}
{(canShowSeenBy || canShowReactionsCount) && (
<MenuItem
icon={canShowReactionsCount ? 'reactions' : 'group'}
onClick={canShowReactionsCount ? onShowReactors : onShowSeenBy}
disabled={!canShowReactionsCount && !message.seenByUserIds?.length}
>
{canShowReactionsCount && message.reactors?.count ? (
canShowSeenBy && message.seenByUserIds?.length
? lang('Chat.OutgoingContextMixedReactionCount', [message.reactors.count, message.seenByUserIds.length])
: lang('Chat.ContextReactionCount', message.reactors.count, 'i'))
: (message.seenByUserIds?.length
? lang('Conversation.ContextMenuSeen', message.seenByUserIds.length, 'i')
: lang('Conversation.ContextMenuNoViews'))}
<div className="avatars">
{seenByRecentUsers?.map((user) => (
<Avatar
size="micro"
user={user}
/>
))}
</div>
</MenuItem>
)}
{canDelete && <MenuItem destructive icon="delete" onClick={onDelete}>{lang('Delete')}</MenuItem>}
</div>
</Menu>
);
};

View File

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

View File

@ -2,31 +2,42 @@ import React, {
FC, memo, useMemo,
} from '../../../lib/teact/teact';
import { ApiMessage, ApiMessageOutgoingStatus } from '../../../api/types';
import { ApiAvailableReaction, ApiMessage, ApiMessageOutgoingStatus } from '../../../api/types';
import { ActiveReaction } from '../../../global/types';
import { formatDateTimeToString, formatTime } from '../../../util/dateFormat';
import { formatIntegerCompact } from '../../../util/textFormat';
import MessageOutgoingStatus from '../../common/MessageOutgoingStatus';
import renderText from '../../common/helpers/renderText';
import useLang from '../../../hooks/useLang';
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;
withReactions?: boolean;
withReactionOffset?: boolean;
outgoingStatus?: ApiMessageOutgoingStatus;
signature?: string;
onClick: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
activeReaction?: ActiveReaction;
availableReactions?: ApiAvailableReaction[];
};
const MessageMeta: FC<OwnProps> = ({
message, outgoingStatus, signature, onClick,
message, outgoingStatus, signature, onClick, withReactions,
activeReaction, withReactionOffset, availableReactions,
}) => {
const lang = useLang();
const [isActivated, markActivated] = useFlag();
const reactions = withReactions && message.reactions?.results.filter((l) => l.count > 0);
const title = useMemo(() => {
if (!isActivated) return undefined;
const createDateTime = formatDateTimeToString(message.date * 1000, lang.code);
@ -47,7 +58,19 @@ const MessageMeta: FC<OwnProps> = ({
}, [isActivated, lang, message]);
return (
<span className="MessageMeta" dir={lang.isRtl ? 'rtl' : 'ltr'} onClick={onClick}>
<span
className={buildClassName('MessageMeta', withReactionOffset && 'reactions-offset')}
dir={lang.isRtl ? 'rtl' : 'ltr'}
onClick={onClick}
>
{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,50 @@
.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;
}
}
}
}

View File

@ -0,0 +1,92 @@
import React, { FC, memo, useCallback } from '../../../lib/teact/teact';
import { getDispatch } from '../../../lib/teact/teactn';
import { ActiveReaction } from '../../../global/types';
import { ApiAvailableReaction, ApiMediaFormat } from '../../../api/types';
import buildClassName from '../../../util/buildClassName';
import useMedia from '../../../hooks/useMedia';
import useShowTransition from '../../../hooks/useShowTransition';
import useFlag from '../../../hooks/useFlag';
import ReactionStaticEmoji from '../../common/ReactionStaticEmoji';
import AnimatedSticker from '../../common/AnimatedSticker';
import './ReactionAnimatedEmoji.scss';
type OwnProps = {
reaction: string;
activeReaction?: ActiveReaction;
isInMeta?: boolean;
availableReactions?: ApiAvailableReaction[];
};
const CENTER_ICON_SIZE = 30;
const EFFECT_SIZE = 100;
const ReactionAnimatedEmoji: FC<OwnProps> = ({
reaction,
activeReaction,
isInMeta,
availableReactions,
}) => {
const { stopActiveReaction } = getDispatch();
const availableReaction = availableReactions?.find((r) => r.reaction === reaction);
const centerIconId = availableReaction?.centerIcon?.id;
const effectId = availableReaction?.aroundAnimation?.id;
const mediaDataCenterIcon = useMedia(`sticker${centerIconId}`, !centerIconId, ApiMediaFormat.Lottie);
const mediaDataEffect = useMedia(`sticker${effectId}`, !effectId, ApiMediaFormat.Lottie);
const shouldPlay = Boolean(activeReaction?.reaction === reaction && mediaDataCenterIcon && mediaDataEffect);
const {
shouldRender: shouldRenderAnimation,
transitionClassNames: animationClassNames,
} = useShowTransition(shouldPlay, undefined, true, 'slow');
const handleEnded = useCallback(() => {
stopActiveReaction({ messageId: activeReaction?.messageId, reaction });
}, [activeReaction?.messageId, reaction, stopActiveReaction]);
const [isAnimationLoaded, markAnimationLoaded, unmarkAnimationLoaded] = useFlag();
const shouldRenderStatic = !shouldPlay || !isAnimationLoaded;
const className = buildClassName(
'ReactionAnimatedEmoji',
isInMeta && 'in-meta',
shouldRenderAnimation && 'is-animating',
);
return (
<div className={className}>
{shouldRenderStatic && <ReactionStaticEmoji reaction={reaction} />}
{shouldRenderAnimation && (
<>
<AnimatedSticker
key={centerIconId}
id={`reaction_emoji_${centerIconId}`}
className={animationClassNames}
size={CENTER_ICON_SIZE}
animationData={mediaDataCenterIcon as AnyLiteral}
play
noLoop
onLoad={markAnimationLoaded}
onEnded={unmarkAnimationLoaded}
/>
<AnimatedSticker
key={effectId}
id={`reaction_effect_${effectId}`}
className={buildClassName('effect', animationClassNames)}
size={EFFECT_SIZE}
animationData={mediaDataEffect as AnyLiteral}
play
noLoop
onEnded={handleEnded}
/>
</>
)}
</div>
);
};
export default memo(ReactionAnimatedEmoji);

View File

@ -0,0 +1,79 @@
import React, {
FC, memo, useCallback, useMemo,
} from '../../../lib/teact/teact';
import { getDispatch, getGlobal } from '../../../lib/teact/teactn';
import {
ApiAvailableReaction, ApiMessage, ApiReactionCount, ApiUser,
} from '../../../api/types';
import { ActiveReaction } from '../../../global/types';
import buildClassName from '../../../util/buildClassName';
import { formatIntegerCompact } from '../../../util/textFormat';
import Button from '../../ui/Button';
import Avatar from '../../common/Avatar';
import ReactionAnimatedEmoji from './ReactionAnimatedEmoji';
import './Reactions.scss';
const MAX_REACTORS_AVATARS = 3;
const ReactionButton: FC<{
reaction: ApiReactionCount;
message: ApiMessage;
activeReaction?: ActiveReaction;
availableReactions?: ApiAvailableReaction[];
}> = ({
reaction,
message,
activeReaction,
availableReactions,
}) => {
const { sendReaction } = getDispatch();
const { recentReactions } = message.reactions!;
const recentReactors = useMemo(() => {
if (!recentReactions || reaction.count > MAX_REACTORS_AVATARS) {
return undefined;
}
// No need for expensive global updates on users, so we avoid them
const usersById = getGlobal().users.byId;
return recentReactions
.filter((recentReaction) => recentReaction.reaction === reaction.reaction)
.map((recentReaction) => usersById[recentReaction.userId])
.filter(Boolean) as ApiUser[];
}, [reaction, recentReactions]);
const handleClick = useCallback(() => {
sendReaction({
reaction: reaction.isChosen ? undefined : reaction.reaction,
chatId: message.chatId,
messageId: message.id,
});
}, [message, reaction, sendReaction]);
return (
<Button
className={buildClassName(reaction.isChosen && 'chosen')}
size="tiny"
onClick={handleClick}
>
<ReactionAnimatedEmoji
activeReaction={activeReaction}
reaction={reaction.reaction}
availableReactions={availableReactions}
/>
{recentReactors?.length ? (
<div className="avatars">
{recentReactors.map((user) => <Avatar user={user} size="micro" />)}
</div>
) : formatIntegerCompact(reaction.count)}
</Button>
);
};
export default memo(ReactionButton);

View File

@ -0,0 +1,91 @@
.ReactionSelector {
position: absolute;
height: 3rem;
background: var(--color-background);
min-width: 3rem;
max-width: calc(100% + 5rem);
z-index: 100;
border-radius: 3rem;
filter: drop-shadow(0 0.25rem 0.125rem var(--color-default-shadow));
right: -3rem;
top: -3.5rem;
.bubble-big {
position: absolute;
display: block;
content: "";
right: 1.5rem;
bottom: -0.5rem;
width: 1rem;
height: 1rem;
border-radius: 50%;
background: var(--color-background);
z-index: -1;
}
.bubble-small {
position: absolute;
display: block;
content: "";
right: 1.25rem;
bottom: -1.25rem;
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
background: var(--color-background);
}
body.is-safari & {
filter: none;
box-shadow: 0 0.25rem 0.125rem var(--color-default-shadow);
}
body.is-safari .bubble-small, body.is-safari .bubble-big {
box-shadow: 0 0.25rem 0.125rem var(--color-default-shadow);
}
.items-wrapper {
width: 100%;
height: 100%;
overflow: hidden;
border-radius: 3rem;
}
.items {
padding: 0 1rem;
width: 100%;
height: 100%;
overflow-x: auto;
overflow: overlay;
overflow-y: hidden;
display: flex;
cursor: pointer;
align-items: center;
border-radius: 3rem;
}
.reaction {
margin-left: 0.5rem;
position: relative;
min-width: 2rem;
min-height: 2rem;
&:first-child {
margin-left: 0;
}
}
.ReactionStaticEmoji {
width: 2rem;
position: absolute;
top: 0;
left: 0;
transform: scale(0.9);
}
.AnimatedSticker {
position: absolute;
top: 0;
left: 0;
}
}

View File

@ -0,0 +1,122 @@
import React, {
FC, memo, useLayoutEffect, useRef,
} from '../../../lib/teact/teact';
import { ApiAvailableReaction, ApiMediaFormat } from '../../../api/types';
import useMedia from '../../../hooks/useMedia';
import useHorizontalScroll from '../../../hooks/useHorizontalScroll';
import useFlag from '../../../hooks/useFlag';
import { getTouchY } from '../../../util/scrollLock';
import ReactionStaticEmoji from '../../common/ReactionStaticEmoji';
import AnimatedSticker from '../../common/AnimatedSticker';
import './ReactionSelector.scss';
const REACTION_SIZE = 32;
type OwnProps = {
enabledReactions?: string[];
onSendReaction: (reaction: string, x: number, y: number) => void;
isPrivate?: boolean;
availableReactions?: ApiAvailableReaction[];
isReady?: boolean;
};
const AvailableReaction: FC<{
reaction: ApiAvailableReaction;
isReady?: boolean;
onSendReaction: (reaction: string, x: number, y: number) => void;
}> = ({ reaction, onSendReaction, isReady }) => {
// eslint-disable-next-line no-null/no-null
const containerRef = useRef<HTMLDivElement>(null);
const [isActivated, activate, deactivate] = useFlag();
const mediaData = useMedia(`document${reaction.selectAnimation?.id}`, !isReady, ApiMediaFormat.Lottie);
const [isAnimationLoaded, markAnimationLoaded] = useFlag();
function handleClick() {
if (!containerRef.current) return;
const { x, y } = containerRef.current.getBoundingClientRect();
onSendReaction(reaction.reaction, x, y);
}
const shouldRenderPreview = !isAnimationLoaded;
const shouldRenderAnimated = mediaData;
const shouldPlay = isReady && isActivated;
return (
<div
className="reaction"
onClick={handleClick}
ref={containerRef}
onMouseEnter={isReady ? activate : undefined}
>
{shouldRenderPreview && <ReactionStaticEmoji reaction={reaction.reaction} />}
{shouldRenderAnimated && (
<AnimatedSticker
id={`select_${reaction.reaction}`}
animationData={mediaData as AnyLiteral}
play={shouldPlay}
noLoop
onEnded={deactivate}
size={REACTION_SIZE}
onLoad={markAnimationLoaded}
/>
)}
</div>
);
};
const ReactionSelector: FC<OwnProps> = ({
availableReactions,
enabledReactions,
onSendReaction,
isPrivate,
isReady,
}) => {
// eslint-disable-next-line no-null/no-null
const itemsScrollRef = useRef<HTMLDivElement>(null);
const [isHorizontalScrollEnabled, enableHorizontalScroll] = useFlag(false);
useHorizontalScroll(itemsScrollRef.current, !isHorizontalScrollEnabled);
useLayoutEffect(() => {
enableHorizontalScroll();
}, [enableHorizontalScroll]);
const handleWheel = (e: React.WheelEvent | React.TouchEvent) => {
if (!itemsScrollRef) return;
const deltaY = 'deltaY' in e ? e.deltaY : getTouchY(e);
if (deltaY) {
e.preventDefault();
}
};
if ((!isPrivate && !enabledReactions?.length) || !availableReactions) return undefined;
return (
<div className="ReactionSelector" onWheelCapture={handleWheel} onTouchMove={handleWheel}>
<div className="bubble-big" />
<div className="bubble-small" />
<div className="items-wrapper">
<div className="items no-scrollbar" ref={itemsScrollRef}>
{availableReactions?.map((reaction) => {
if (reaction.isInactive
|| (!isPrivate && (!enabledReactions || !enabledReactions.includes(reaction.reaction)))) return undefined;
return (
<AvailableReaction
key={reaction.reaction}
isReady={isReady}
onSendReaction={onSendReaction}
reaction={reaction}
/>
);
})}
</div>
</div>
</div>
);
};
export default memo(ReactionSelector);

View File

@ -0,0 +1,84 @@
.Reactions {
display: flex;
flex-direction: row;
width: 100%;
flex-wrap: wrap;
margin-top: 0.25rem;
overflow: visible;
.Button {
--reaction-background: var(--color-reaction);
display: flex;
flex-direction: row;
height: 1.75rem;
white-space: nowrap;
width: auto;
margin: 0.125rem;
padding: 0 0.5rem;
border: 2px solid transparent;
background-color: var(--reaction-background) !important;
border-radius: 1.75rem;
font-weight: 500;
font-variant-numeric: tabular-nums;
text-transform: none;
color: var(--accent-color);
overflow: visible;
.ReactionAnimatedEmoji, .icon-reaction-filled {
width: 1.125rem;
height: 1.125rem;
margin-right: 0.25rem;
}
.avatars {
display: flex;
.Avatar {
margin: 0 0 0 -0.25rem;
border: 0.0625rem solid var(--reaction-background);
width: 1.25rem;
height: 1.25rem;
&:first-child {
margin: 0;
}
}
}
&.chosen {
border-color: var(--accent-color);
}
&:hover {
--reaction-background: var(--hover-color-reaction);
}
&:first-of-type {
margin-left: 0;
}
&:last-of-type {
margin-right: 0;
}
}
&.is-outside {
margin-top: 0.125rem;
}
&.is-outside .Button {
--reaction-background: var(--pattern-color);
color: white;
.theme-dark & {
color: var(--accent-color);
}
&.chosen {
border-color: white;
.theme-dark & {
border-color: var(--accent-color);
}
}
}
}

View File

@ -0,0 +1,43 @@
import React, { FC, memo } from '../../../lib/teact/teact';
import { ApiAvailableReaction, ApiMessage } from '../../../api/types';
import { ActiveReaction } from '../../../global/types';
import buildClassName from '../../../util/buildClassName';
import ReactionButton from './ReactionButton';
import './Reactions.scss';
type OwnProps = {
message: ApiMessage;
isOutside?: boolean;
activeReaction?: ActiveReaction;
availableReactions?: ApiAvailableReaction[];
metaChildren?: React.ReactNode;
};
const Reactions: FC<OwnProps> = ({
message,
isOutside,
activeReaction,
availableReactions,
metaChildren,
}) => {
return (
<div className={buildClassName('Reactions', isOutside && 'is-outside')}>
{message.reactions!.results.map((reaction) => (
<ReactionButton
key={reaction.reaction}
reaction={reaction}
message={message}
activeReaction={activeReaction}
availableReactions={availableReactions}
/>
))}
{metaChildren}
</div>
);
};
export default memo(Reactions);

View File

@ -104,6 +104,40 @@
}
}
.MessageMeta.reactions-offset {
position: relative;
top: .375rem;
bottom: auto !important;
float: right;
line-height: 1;
height: calc(var(--message-meta-height, 1rem));
margin-left: auto;
margin-right: -.5rem;
align-self: flex-end;
.MessageOutgoingStatus .Transition {
max-height: calc(var(--message-meta-height, 1rem));
overflow: hidden;
}
html[data-message-text-size="12"] & {
top: .25rem;
}
html[data-message-text-size="17"] & {
top: .4375rem;
}
html[data-message-text-size="18"] &,
html[data-message-text-size="19"] & {
top: .5rem;
}
html[data-message-text-size="20"] & {
top: .5625rem;
}
}
&.document:not(.text) {
&::after {
content: "";

View File

@ -1,5 +1,5 @@
import { RefObject } from 'react';
import React, { useEffect } from '../../../../lib/teact/teact';
import React, { useEffect, useRef } from '../../../../lib/teact/teact';
import { getDispatch } from '../../../../lib/teact/teactn';
import { IS_ANDROID, IS_TOUCH_ENV } from '../../../../util/environment';
@ -8,9 +8,14 @@ import { captureEvents, SwipeDirection } from '../../../../util/captureEvents';
import useFlag from '../../../../hooks/useFlag';
import { preventMessageInputBlur } from '../../helpers/preventMessageInputBlur';
import stopEvent from '../../../../util/stopEvent';
import { REM } from '../../../common/helpers/mediaDimensions';
const ANDROID_KEYBOARD_HIDE_DELAY_MS = 350;
const SWIPE_ANIMATION_DURATION = 150;
const QUICK_REACTION_DOUBLE_TAP_DELAY = 200;
const QUICK_REACTION_AREA_WIDTH = 3 * REM;
const QUICK_REACTION_AREA_HEIGHT = Number(REM);
const GROUP_MESSAGE_HOVER_ATTRIBUTE = 'data-is-document-group-hover';
export default function useOuterHandlers(
selectMessage: (e?: React.MouseEvent<HTMLDivElement, MouseEvent>, groupedId?: string) => void,
@ -22,20 +27,53 @@ export default function useOuterHandlers(
isProtected: boolean,
onContextMenu: (e: React.MouseEvent) => void,
handleBeforeContextMenu: (e: React.MouseEvent) => void,
chatId: string,
isContextMenuShown: boolean,
contentRef: RefObject<HTMLDivElement>,
isOwn: boolean,
shouldHandleMouseLeave: boolean,
) {
const { setReplyingToId } = getDispatch();
const { setReplyingToId, sendDefaultReaction } = getDispatch();
const [isQuickReactionVisible, markQuickReactionVisible, unmarkQuickReactionVisible] = useFlag();
const [isSwiped, markSwiped, unmarkSwiped] = useFlag();
const doubleTapTimeoutRef = useRef<NodeJS.Timeout>();
function handleMouseDown(e: React.MouseEvent<HTMLDivElement, MouseEvent>) {
preventMessageInputBlur(e);
handleBeforeContextMenu(e);
}
function handleClick(e: React.MouseEvent<HTMLDivElement, MouseEvent>) {
if (isInSelectMode) {
selectMessage(e);
} else if (IS_ANDROID) {
function handleMouseMove(e: React.MouseEvent) {
const container = contentRef.current;
if (!container) return;
const { clientX, clientY } = e;
const {
x, width, y, height,
} = container.getBoundingClientRect();
const isVisibleX = Math.abs((isOwn ? (clientX - x) : (x + width - clientX))) < QUICK_REACTION_AREA_WIDTH;
const isVisibleY = Math.abs(y + height - clientY) < QUICK_REACTION_AREA_HEIGHT;
if (isVisibleX && isVisibleY) {
markQuickReactionVisible();
} else {
unmarkQuickReactionVisible();
}
}
function handleSendQuickReaction(e: React.MouseEvent) {
const { x, y } = e.currentTarget.getBoundingClientRect();
sendDefaultReaction({
chatId,
messageId,
x,
y,
});
}
function handleTap(e: React.MouseEvent<HTMLDivElement, MouseEvent>) {
if (IS_ANDROID) {
const target = e.target as HTMLDivElement;
if (!target.classList.contains('text-content') && !target.classList.contains('Message')) {
return;
@ -51,6 +89,38 @@ export default function useOuterHandlers(
}
}
function handleDoubleTap(e: React.MouseEvent<HTMLDivElement, MouseEvent>) {
const { pageX: x, pageY: y } = e;
sendDefaultReaction({
chatId,
messageId,
x,
y,
});
}
function handleClick(e: React.MouseEvent<HTMLDivElement, MouseEvent>) {
if (isInSelectMode) {
selectMessage(e);
return;
}
if (!IS_TOUCH_ENV) return;
if (doubleTapTimeoutRef.current) {
clearInterval(doubleTapTimeoutRef.current);
doubleTapTimeoutRef.current = undefined;
handleDoubleTap(e);
return;
}
doubleTapTimeoutRef.current = setTimeout(() => {
doubleTapTimeoutRef.current = undefined;
handleTap(e);
}, QUICK_REACTION_DOUBLE_TAP_DELAY);
}
function handleContextMenu(e: React.MouseEvent<HTMLDivElement, MouseEvent>) {
if (IS_ANDROID) {
if ((e.target as HTMLElement).matches('a[href]')) {
@ -65,6 +135,8 @@ export default function useOuterHandlers(
}
function handleContainerDoubleClick() {
if (IS_TOUCH_ENV) return;
setReplyingToId({ messageId });
}
@ -73,7 +145,7 @@ export default function useOuterHandlers(
}
useEffect(() => {
if (!IS_TOUCH_ENV || isInSelectMode || !canReply) {
if (!IS_TOUCH_ENV || isInSelectMode || !canReply || isContextMenuShown) {
return undefined;
}
@ -104,7 +176,14 @@ export default function useOuterHandlers(
startedAt = undefined;
},
});
}, [containerRef, isInSelectMode, messageId, setReplyingToId, markSwiped, unmarkSwiped, canReply]);
}, [
containerRef, isInSelectMode, messageId, setReplyingToId, markSwiped, unmarkSwiped, canReply, isContextMenuShown,
]);
function handleMouseLeave(e: React.MouseEvent<HTMLDivElement>) {
unmarkQuickReactionVisible();
if (shouldHandleMouseLeave) handleDocumentGroupMouseLeave(e);
}
return {
handleMouseDown: !isInSelectMode ? handleMouseDown : undefined,
@ -112,6 +191,35 @@ export default function useOuterHandlers(
handleContextMenu: !isInSelectMode ? handleContextMenu : (isProtected ? stopEvent : undefined),
handleDoubleClick: !isInSelectMode ? handleContainerDoubleClick : undefined,
handleContentDoubleClick: !IS_TOUCH_ENV ? stopPropagation : undefined,
handleMouseMove,
handleSendQuickReaction,
handleMouseLeave,
isSwiped,
isQuickReactionVisible,
handleDocumentGroupMouseEnter,
};
}
function handleDocumentGroupMouseEnter(e: React.MouseEvent<HTMLDivElement>) {
const lastGroupElement = getLastElementInDocumentGroup(e.currentTarget);
if (lastGroupElement) {
lastGroupElement.setAttribute(GROUP_MESSAGE_HOVER_ATTRIBUTE, '');
}
}
function handleDocumentGroupMouseLeave(e: React.MouseEvent<HTMLDivElement>) {
const lastGroupElement = getLastElementInDocumentGroup(e.currentTarget);
if (lastGroupElement) {
lastGroupElement.removeAttribute(GROUP_MESSAGE_HOVER_ATTRIBUTE);
}
}
function getLastElementInDocumentGroup(element: Element) {
let current: Element | null = element;
do {
current = current.nextElementSibling;
} while (current && !current.classList.contains('last-in-document-group'));
return current;
}

View File

@ -123,6 +123,7 @@ const RightColumn: FC<StateProps> = ({
case ManagementScreens.ChatAdministrators:
case ManagementScreens.ChannelSubscribers:
case ManagementScreens.GroupMembers:
case ManagementScreens.Reactions:
setManagementScreen(ManagementScreens.Initial);
break;
case ManagementScreens.GroupUserPermissionsCreate:

View File

@ -17,10 +17,7 @@ import {
selectUser,
} from '../../modules/selectors';
import {
getCanAddContact,
isChatAdmin,
isChatChannel,
isUserId,
getCanAddContact, isChatAdmin, isChatChannel, isUserId,
} from '../../modules/helpers';
import useCurrentOrPrev from '../../hooks/useCurrentOrPrev';
import useLang from '../../hooks/useLang';
@ -84,6 +81,7 @@ enum HeaderContent {
GifSearch,
PollResults,
AddingMembers,
ManageReactions,
}
const RightHeader: FC<OwnProps & StateProps> = ({
@ -195,6 +193,8 @@ const RightHeader: FC<OwnProps & StateProps> = ({
HeaderContent.ManageGroupMembers
) : managementScreen === ManagementScreens.GroupAddAdmins ? (
HeaderContent.ManageGroupAddAdmins
) : managementScreen === ManagementScreens.Reactions ? (
HeaderContent.ManageReactions
) : undefined // Never reached
) : undefined; // When column is closed
@ -278,6 +278,8 @@ const RightHeader: FC<OwnProps & StateProps> = ({
case HeaderContent.MemberList:
case HeaderContent.ManageGroupMembers:
return <h3>{lang('GroupMembers')}</h3>;
case HeaderContent.ManageReactions:
return <h3>{lang('Reactions')}</h3>;
default:
return (
<>

View File

@ -36,6 +36,7 @@ type StateProps = {
progress?: ManagementProgress;
isSignaturesShown: boolean;
canChangeInfo?: boolean;
availableReactionsCount?: number;
};
const CHANNEL_TITLE_EMPTY = 'Channel title can\'t be empty';
@ -46,6 +47,7 @@ const ManageChannel: FC<OwnProps & StateProps> = ({
progress,
isSignaturesShown,
canChangeInfo,
availableReactionsCount,
onScreenSelect,
onClose,
isActive,
@ -92,6 +94,10 @@ const ManageChannel: FC<OwnProps & StateProps> = ({
onScreenSelect(ManagementScreens.Discussion);
}, [onScreenSelect]);
const handleClickReactions = useCallback(() => {
onScreenSelect(ManagementScreens.Reactions);
}, [onScreenSelect]);
const handleClickAdministrators = useCallback(() => {
onScreenSelect(ManagementScreens.ChatAdministrators);
}, [onScreenSelect]);
@ -148,6 +154,8 @@ const ManageChannel: FC<OwnProps & StateProps> = ({
openChat({ id: undefined });
}, [chat.isCreator, chat.id, closeDeleteDialog, closeManagement, leaveChannel, deleteChannel, openChat]);
const enabledReactionsCount = chat.fullInfo?.enabledReactions?.length || 0;
if (chat.isRestricted) {
return undefined;
}
@ -202,6 +210,17 @@ const ManageChannel: FC<OwnProps & StateProps> = ({
<span className="title">{lang('ChannelAdministrators')}</span>
<span className="subtitle">{adminsCount}</span>
</ListItem>
<ListItem
icon="reactions"
multiline
onClick={handleClickReactions}
disabled={!canChangeInfo}
>
<span className="title">{lang('Reactions')}</span>
<span className="subtitle" dir="auto">
{enabledReactionsCount}/{availableReactionsCount}
</span>
</ListItem>
<div className="ListItem no-selection narrow">
<Checkbox
checked={isSignaturesShown}
@ -261,6 +280,7 @@ export default memo(withGlobal<OwnProps>(
progress,
isSignaturesShown,
canChangeInfo: getHasAdminRight(chat, 'changeInfo'),
availableReactionsCount: global.availableReactions?.filter((l) => !l.isInactive).length,
};
},
)(ManageChannel));

View File

@ -40,6 +40,7 @@ type StateProps = {
hasLinkedChannel: boolean;
canChangeInfo?: boolean;
canBanUsers?: boolean;
availableReactionsCount?: number;
};
const GROUP_TITLE_EMPTY = 'Group title can\'t be empty';
@ -59,6 +60,7 @@ const ManageGroup: FC<OwnProps & StateProps> = ({
onScreenSelect,
onClose,
isActive,
availableReactionsCount,
}) => {
const {
togglePreHistoryHidden,
@ -100,6 +102,10 @@ const ManageGroup: FC<OwnProps & StateProps> = ({
onScreenSelect(ManagementScreens.Discussion);
}, [onScreenSelect]);
const handleClickReactions = useCallback(() => {
onScreenSelect(ManagementScreens.Reactions);
}, [onScreenSelect]);
const handleClickPermissions = useCallback(() => {
onScreenSelect(ManagementScreens.GroupPermissions);
}, [onScreenSelect]);
@ -154,6 +160,8 @@ const ManageGroup: FC<OwnProps & StateProps> = ({
togglePreHistoryHidden({ chatId: chat.id, isEnabled: !isPreHistoryHidden });
}, [chat, togglePreHistoryHidden]);
const enabledReactionsCount = chat.fullInfo?.enabledReactions?.length || 0;
const enabledPermissionsCount = useMemo(() => {
if (!chat.defaultBannedRights) {
return 0;
@ -257,6 +265,18 @@ const ManageGroup: FC<OwnProps & StateProps> = ({
{enabledPermissionsCount}/{TOTAL_PERMISSIONS_COUNT}
</span>
</ListItem>
<ListItem
icon="reactions"
multiline
onClick={handleClickReactions}
disabled={!canChangeInfo}
>
<span className="title">{lang('Reactions')}</span>
<span className="subtitle" dir="auto">
{enabledReactionsCount}/{availableReactionsCount}
</span>
</ListItem>
<ListItem
icon="admin"
multiline
@ -332,6 +352,7 @@ export default memo(withGlobal<OwnProps>(
hasLinkedChannel,
canChangeInfo: isBasicGroup ? chat.isCreator : getHasAdminRight(chat, 'changeInfo'),
canBanUsers: isBasicGroup ? chat.isCreator : getHasAdminRight(chat, 'banUsers'),
availableReactionsCount: global.availableReactions?.filter((l) => !l.isInactive).length,
};
},
)(ManageGroup));

View File

@ -0,0 +1,131 @@
import React, {
FC, memo, useCallback, useEffect, useState,
} from '../../../lib/teact/teact';
import { getDispatch, withGlobal } from '../../../lib/teact/teactn';
import { ApiAvailableReaction, ApiChat } from '../../../api/types';
import { selectChat } from '../../../modules/selectors';
import useLang from '../../../hooks/useLang';
import useHistoryBack from '../../../hooks/useHistoryBack';
import ReactionStaticEmoji from '../../common/ReactionStaticEmoji';
import Checkbox from '../../ui/Checkbox';
import FloatingActionButton from '../../ui/FloatingActionButton';
import Spinner from '../../ui/Spinner';
type OwnProps = {
chatId: string;
onClose: NoneToVoidFunction;
isActive: boolean;
};
type StateProps = {
chat?: ApiChat;
availableReactions?: ApiAvailableReaction[];
enabledReactions?: string[];
};
const ManageReactions: FC<OwnProps & StateProps> = ({
availableReactions,
enabledReactions,
chat,
isActive,
onClose,
}) => {
const { setChatEnabledReactions } = getDispatch();
const lang = useLang();
const [isTouched, setIsTouched] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [localEnabledReactions, setLocalEnabledReactions] = useState(enabledReactions);
useHistoryBack(isActive, onClose);
const handleSaveReactions = useCallback(() => {
if (!chat) return;
setIsLoading(true);
setChatEnabledReactions({
chatId: chat.id,
enabledReactions: localEnabledReactions,
});
}, [chat, localEnabledReactions, setChatEnabledReactions]);
useEffect(() => {
setIsLoading(false);
setIsTouched(false);
setLocalEnabledReactions(enabledReactions || []);
}, [enabledReactions]);
const handleReactionChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
if (!chat || !availableReactions) return;
const { name, checked } = e.currentTarget;
const newEnabledReactions = name === 'all' ? (checked ? availableReactions.map((l) => l.reaction) : [])
: (!checked
? localEnabledReactions.filter((l) => l !== name)
: [...localEnabledReactions, name]);
setLocalEnabledReactions(newEnabledReactions);
setIsTouched(true);
}, [availableReactions, chat, localEnabledReactions]);
return (
<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>
{availableReactions?.filter((l) => !l.isInactive).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>
))}
</div>
</div>
<FloatingActionButton
isShown={isTouched}
onClick={handleSaveReactions}
ariaLabel={lang('Save')}
disabled={isLoading}
>
{isLoading ? (
<Spinner color="white" />
) : (
<i className="icon-check" />
)}
</FloatingActionButton>
</div>
);
};
export default memo(withGlobal<OwnProps>(
(global, { chatId }): StateProps => {
const chat = selectChat(global, chatId)!;
return {
enabledReactions: chat.fullInfo?.enabledReactions,
availableReactions: global.availableReactions,
chat,
};
},
)(ManageReactions));

View File

@ -55,6 +55,16 @@
.ListItem {
margin: 0 -.75rem;
.Reaction {
display: flex;
align-items: center;
}
.ReactionStaticEmoji {
width: 1.5rem;
margin-right: 1rem;
}
&:last-child {
margin-bottom: 0;
}

View File

@ -18,6 +18,7 @@ import ManageGroupRecentActions from './ManageGroupRecentActions';
import ManageGroupAdminRights from './ManageGroupAdminRights';
import ManageGroupMembers from './ManageGroupMembers';
import ManageGroupUserPermissionsCreate from './ManageGroupUserPermissionsCreate';
import ManageReactions from './ManageReactions';
export type OwnProps = {
chatId: string;
@ -239,6 +240,15 @@ const Management: FC<OwnProps & StateProps> = ({
onChatMemberSelect={onChatMemberSelect}
/>
);
case ManagementScreens.Reactions:
return (
<ManageReactions
chatId={chatId}
isActive={isActive}
onClose={onClose}
/>
);
}
return undefined; // Never reached

View File

@ -58,6 +58,7 @@ export const PROFILE_PHOTOS_LIMIT = 40;
export const PROFILE_SENSITIVE_AREA = 500;
export const COMMON_CHATS_LIMIT = 100;
export const GROUP_CALL_PARTICIPANTS_LIMIT = 100;
export const REACTION_LIST_LIMIT = 100;
export const TOP_CHAT_MESSAGES_PRELOAD_LIMIT = 20;
export const ALL_CHATS_PRELOAD_DISABLED = false;

View File

@ -198,6 +198,10 @@ function migrateCache(cached: GlobalState, initialState: GlobalState) {
if (!cached.messages.sponsoredByChatId) {
cached.messages.sponsoredByChatId = {};
}
if (!cached.activeReactions) {
cached.activeReactions = {};
}
}
function updateCache() {
@ -243,6 +247,7 @@ function updateCache() {
settings: reduceSettings(global),
chatFolders: reduceChatFolders(global),
groupCalls: reduceGroupCalls(global),
availableReactions: reduceAvailableReactions(global),
};
const json = JSON.stringify(reducedGlobal);
@ -346,3 +351,8 @@ function reduceGroupCalls(global: GlobalState): GlobalState['groupCalls'] {
isFallbackConfirmOpen: undefined,
};
}
function reduceAvailableReactions(global: GlobalState): GlobalState['availableReactions'] {
return global.availableReactions
?.map((r) => pick(r, ['reaction', 'staticIcon', 'title', 'isInactive']));
}

View File

@ -184,6 +184,7 @@ export const INITIAL_STATE: GlobalState = {
},
twoFaSettings: {},
activeReactions: {},
shouldShowContextMenuHint: true,

View File

@ -23,6 +23,8 @@ import {
ApiCountryCode,
ApiCountry,
ApiGroupCall,
ApiAvailableReaction,
ApiAppConfig,
ApiSponsoredMessage,
} from '../api/types';
import {
@ -61,6 +63,23 @@ export interface MessageList {
type: MessageListType;
}
export interface ActiveEmojiInteraction {
x: number;
y: number;
messageId?: number;
endX?: number;
endY?: number;
startSize?: number;
reaction?: string;
animatedEffect?: string;
isReversed?: boolean;
}
export interface ActiveReaction {
messageId?: number;
reaction?: string;
}
export interface Thread {
listedIds?: number[];
outlyingIds?: number[];
@ -86,6 +105,7 @@ export interface ServiceNotification {
}
export type GlobalState = {
appConfig?: ApiAppConfig;
isChatInfoShown: boolean;
isLeftColumnShown: boolean;
isPollModalOpen?: boolean;
@ -212,6 +232,11 @@ export type GlobalState = {
messageId: number;
};
reactorModal?: {
chatId: string;
messageId: number;
};
fileUploads: {
byMessageLocalId: Record<string, {
progress: number;
@ -254,6 +279,7 @@ export type GlobalState = {
};
animatedEmojis?: ApiStickerSet;
animatedEmojiEffects?: ApiStickerSet;
emojiKeywords: Partial<Record<LangCode, EmojiKeywords>>;
gifs: {
@ -305,6 +331,10 @@ export type GlobalState = {
globalUserIds?: string[];
};
availableReactions?: ApiAvailableReaction[];
activeEmojiInteraction?: ActiveEmojiInteraction;
activeReactions: Record<number, ActiveReaction>;
localTextSearch: {
byChatThreadKey: Record<string, {
isActive: boolean;
@ -483,7 +513,8 @@ export type ActionTypes = (
'toggleChatInfo' | 'setIsUiReady' | 'addRecentEmoji' | 'addRecentSticker' | 'toggleLeftColumn' |
'toggleSafeLinkModal' | 'openHistoryCalendar' | 'closeHistoryCalendar' | 'disableContextMenuHint' |
'setNewChatMembersDialogState' | 'disableHistoryAnimations' | 'setLeftColumnWidth' | 'resetLeftColumnWidth' |
'openSeenByModal' | 'closeSeenByModal' |
'openSeenByModal' | 'closeSeenByModal' | 'closeReactorListModal' |
'openReactorListModal' |
// auth
'setAuthPhoneNumber' | 'setAuthCode' | 'setAuthPassword' | 'signUp' | 'returnToAuthPhoneNumber' | 'signOut' |
'setAuthRememberMe' | 'clearAuthError' | 'uploadProfilePhoto' | 'goToAuthQrCode' | 'clearCache' |
@ -495,7 +526,7 @@ export type ActionTypes = (
'joinChannel' | 'leaveChannel' | 'deleteChannel' | 'toggleChatPinned' | 'toggleChatArchived' | 'toggleChatUnread' |
'loadChatFolders' | 'loadRecommendedChatFolders' | 'editChatFolder' | 'addChatFolder' | 'deleteChatFolder' |
'updateChat' | 'toggleSignatures' | 'loadGroupsForDiscussion' | 'linkDiscussionGroup' | 'unlinkDiscussionGroup' |
'loadProfilePhotos' | 'loadMoreMembers' | 'setActiveChatFolder' | 'openNextChat' |
'loadProfilePhotos' | 'loadMoreMembers' | 'setActiveChatFolder' | 'openNextChat' | 'setChatEnabledReactions' |
'addChatMembers' | 'deleteChatMember' | 'openPreviousChat' | 'editChatFolders' | 'toggleIsProtected' |
// messages
'loadViewportMessages' | 'selectMessage' | 'sendMessage' | 'cancelSendingMessage' | 'pinMessage' | 'deleteMessages' |
@ -503,9 +534,12 @@ export type ActionTypes = (
'editMessage' | 'deleteHistory' | 'enterMessageSelectMode' | 'toggleMessageSelection' | 'exitMessageSelectMode' |
'openTelegramLink' | 'openChatByUsername' | 'requestThreadInfoUpdate' | 'setScrollOffset' | 'unpinAllMessages' |
'setReplyingToId' | 'setEditingId' | 'editLastMessage' | 'saveDraft' | 'clearDraft' | 'loadPinnedMessages' |
'toggleMessageWebPage' | 'replyToNextMessage' | 'deleteChatUser' | 'deleteChat' |
'toggleMessageWebPage' | 'replyToNextMessage' | 'deleteChatUser' | 'deleteChat' | 'sendReaction' |
'reportMessages' | 'sendMessageAction' | 'focusNextReply' | 'openChatByInvite' | 'loadSeenBy' |
'loadSponsoredMessages' | 'viewSponsoredMessage' | 'loadSendAs' | 'saveDefaultSendAs' |
'loadSponsoredMessages' | 'viewSponsoredMessage' | 'loadSendAs' | 'saveDefaultSendAs' | 'loadAvailableReactions' |
'stopActiveEmojiInteraction' | 'interactWithAnimatedEmoji' | 'loadReactors' | 'setDefaultReaction' |
'sendDefaultReaction' | 'sendEmojiInteraction' | 'sendWatchingEmojiInteraction' | 'loadMessageReactions' |
'stopActiveReaction' |
// downloads
'downloadSelectedMessages' | 'downloadMessageMedia' | 'cancelMessageMediaDownload' |
// scheduled messages
@ -541,7 +575,7 @@ export type ActionTypes = (
'updateWebNotificationSettings' | 'loadLanguages' | 'loadPrivacySettings' | 'setPrivacyVisibility' |
'setPrivacySettings' | 'loadNotificationExceptions' | 'setThemeSettings' | 'updateIsOnline' |
'loadContentSettings' | 'updateContentSettings' |
'loadCountryList' | 'ensureTimeFormat' |
'loadCountryList' | 'ensureTimeFormat' | 'loadAppConfig' |
// stickers & GIFs
'loadStickerSets' | 'loadAddedStickers' | 'loadRecentStickers' | 'loadFavoriteStickers' | 'loadFeaturedStickers' |
'loadStickers' | 'setStickerSearchQuery' | 'loadSavedGifs' | 'setGifSearchQuery' | 'searchMoreGifs' |

Some files were not shown because too many files have changed in this diff Show More