diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts index da96dcf95..10c506dc7 100644 --- a/src/@types/global.d.ts +++ b/src/@types/global.d.ts @@ -18,6 +18,11 @@ declare namespace React { interface VideoHTMLAttributes { srcObject?: MediaStream; } + + interface MouseEvent { + offsetX: number; + offsetY: number; + } } type AnyLiteral = Record; diff --git a/src/api/gramjs/apiBuilders/appConfig.ts b/src/api/gramjs/apiBuilders/appConfig.ts new file mode 100644 index 000000000..c94973e0b --- /dev/null +++ b/src/api/gramjs/apiBuilders/appConfig.ts @@ -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; + 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, 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, + }; +} diff --git a/src/api/gramjs/apiBuilders/chats.ts b/src/api/gramjs/apiBuilders/chats.ts index 5bdf23bc0..088d7862d 100644 --- a/src/api/gramjs/apiBuilders/chats.ts +++ b/src/api/gramjs/apiBuilders/chats.ts @@ -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, }; diff --git a/src/api/gramjs/apiBuilders/messages.ts b/src/api/gramjs/apiBuilders/messages.ts index de934b855..a6920477d 100644 --- a/src/api/gramjs/apiBuilders/messages.ts +++ b/src/api/gramjs/apiBuilders/messages.ts @@ -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, ( '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[], diff --git a/src/api/gramjs/apiBuilders/misc.ts b/src/api/gramjs/apiBuilders/misc.ts index 9c1ceb32b..0e327c405 100644 --- a/src/api/gramjs/apiBuilders/misc.ts +++ b/src/api/gramjs/apiBuilders/misc.ts @@ -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, el) => { + acc[el.key] = buildJson(el.value); + return acc; + }, {}); +} diff --git a/src/api/gramjs/apiBuilders/symbols.ts b/src/api/gramjs/apiBuilders/symbols.ts index 793f95146..ed07574ff 100644 --- a/src/api/gramjs/apiBuilders/symbols.ts +++ b/src/api/gramjs/apiBuilders/symbols.ts @@ -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), + }; +} diff --git a/src/api/gramjs/methods/chats.ts b/src/api/gramjs/methods/chats.ts index d0edd3948..c7bc0db48 100644 --- a/src/api/gramjs/methods/chats.ts +++ b/src/api/gramjs/methods/chats.ts @@ -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(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 }) { diff --git a/src/api/gramjs/methods/index.ts b/src/api/gramjs/methods/index.ts index 42df839a2..b3659fb60 100644 --- a/src/api/gramjs/methods/index.ts +++ b/src/api/gramjs/methods/index.ts @@ -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'; diff --git a/src/api/gramjs/methods/media.ts b/src/api/gramjs/methods/media.ts index fbfa6ee9c..92e7ccde1 100644 --- a/src/api/gramjs/methods/media.ts +++ b/src/api/gramjs/methods/media.ts @@ -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) { diff --git a/src/api/gramjs/methods/messages.ts b/src/api/gramjs/methods/messages.ts index 36b400525..93af244f8 100644 --- a/src/api/gramjs/methods/messages.ts +++ b/src/api/gramjs/methods/messages.ts @@ -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'; diff --git a/src/api/gramjs/methods/reactions.ts b/src/api/gramjs/methods/reactions.ts new file mode 100644 index 000000000..1a086a1ba --- /dev/null +++ b/src/api/gramjs/methods/reactions.ts @@ -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(Boolean as any), + nextOffset, + reactions: reactions.map(buildMessageUserReaction), + count, + }; +} + +export function setDefaultReaction({ + reaction, +}: { + reaction: string; +}) { + return invokeRequest(new GramJs.messages.SetDefaultReaction({ + reaction, + })); +} diff --git a/src/api/gramjs/methods/settings.ts b/src/api/gramjs/methods/settings.ts index d69b12b03..d25b1497b 100644 --- a/src/api/gramjs/methods/settings.ts +++ b/src/api/gramjs/methods/settings.ts @@ -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 { + 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 | diff --git a/src/api/gramjs/methods/symbols.ts b/src/api/gramjs/methods/symbols.ts index f520605fd..0a21fe6c8 100644 --- a/src/api/gramjs/methods/symbols.ts +++ b/src/api/gramjs/methods/symbols.ts @@ -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, diff --git a/src/api/gramjs/updater.ts b/src/api/gramjs/updater.ts index 7220a1677..f20a35e83 100644 --- a/src/api/gramjs/updater.ts +++ b/src/api/gramjs/updater.ts @@ -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'); diff --git a/src/api/types/chats.ts b/src/api/types/chats.ts index 69be7ebdb..436620fc3 100644 --- a/src/api/types/chats.ts +++ b/src/api/types/chats.ts @@ -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; } diff --git a/src/api/types/messages.ts b/src/api/types/messages.ts index ca6ddccf7..8acaa8cf8 100644 --- a/src/api/types/messages.ts +++ b/src/api/types/messages.ts @@ -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 { diff --git a/src/api/types/misc.ts b/src/api/types/misc.ts index 40ca9ef5c..7c83a1457 100644 --- a/src/api/types/misc.ts +++ b/src/api/types/misc.ts @@ -109,3 +109,20 @@ export interface ApiCountryCode extends ApiCountry { prefixes?: string[]; patterns?: string[]; } + +export interface ApiAppConfig { + emojiSounds: Record; + defaultReaction: string; +} + +export interface GramJsEmojiInteraction { + v: number; + a: { + i: number; + t: number; + }[]; +} + +export interface ApiEmojiInteraction { + timestamps: number[]; +} diff --git a/src/api/types/updates.ts b/src/api/types/updates.ts index 4bc3b0d04..9ae2a169a 100644 --- a/src/api/types/updates.ts +++ b/src/api/types/updates.ts @@ -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 ); diff --git a/src/assets/fonts/icomoon.woff b/src/assets/fonts/icomoon.woff index 9cfcfcb6f..2f403f5ef 100644 Binary files a/src/assets/fonts/icomoon.woff and b/src/assets/fonts/icomoon.woff differ diff --git a/src/assets/fonts/icomoon.woff2 b/src/assets/fonts/icomoon.woff2 index 98d10e616..768891efa 100644 Binary files a/src/assets/fonts/icomoon.woff2 and b/src/assets/fonts/icomoon.woff2 differ diff --git a/src/assets/tgs/animatedEmojis/Cumshot.tgs b/src/assets/tgs/animatedEmojis/Cumshot.tgs new file mode 100644 index 000000000..2f316b92d Binary files /dev/null and b/src/assets/tgs/animatedEmojis/Cumshot.tgs differ diff --git a/src/assets/tgs/animatedEmojis/Eggplant.tgs b/src/assets/tgs/animatedEmojis/Eggplant.tgs new file mode 100644 index 000000000..b0412f5c0 Binary files /dev/null and b/src/assets/tgs/animatedEmojis/Eggplant.tgs differ diff --git a/src/assets/tgs/animatedEmojis/Peach.tgs b/src/assets/tgs/animatedEmojis/Peach.tgs new file mode 100644 index 000000000..78d40f9f3 Binary files /dev/null and b/src/assets/tgs/animatedEmojis/Peach.tgs differ diff --git a/src/assets/animatedIcons/CallSchedule.tgs b/src/assets/tgs/calls/CallSchedule.tgs similarity index 100% rename from src/assets/animatedIcons/CallSchedule.tgs rename to src/assets/tgs/calls/CallSchedule.tgs diff --git a/src/assets/animatedIcons/CameraFlip.tgs b/src/assets/tgs/calls/CameraFlip.tgs similarity index 100% rename from src/assets/animatedIcons/CameraFlip.tgs rename to src/assets/tgs/calls/CameraFlip.tgs diff --git a/src/assets/animatedIcons/HandFilled.tgs b/src/assets/tgs/calls/HandFilled.tgs similarity index 100% rename from src/assets/animatedIcons/HandFilled.tgs rename to src/assets/tgs/calls/HandFilled.tgs diff --git a/src/assets/animatedIcons/HandOutline.tgs b/src/assets/tgs/calls/HandOutline.tgs similarity index 100% rename from src/assets/animatedIcons/HandOutline.tgs rename to src/assets/tgs/calls/HandOutline.tgs diff --git a/src/assets/animatedIcons/Speaker.tgs b/src/assets/tgs/calls/Speaker.tgs similarity index 100% rename from src/assets/animatedIcons/Speaker.tgs rename to src/assets/tgs/calls/Speaker.tgs diff --git a/src/assets/animatedIcons/VoiceAllowTalk.tgs b/src/assets/tgs/calls/VoiceAllowTalk.tgs similarity index 100% rename from src/assets/animatedIcons/VoiceAllowTalk.tgs rename to src/assets/tgs/calls/VoiceAllowTalk.tgs diff --git a/src/assets/animatedIcons/VoiceMini.tgs b/src/assets/tgs/calls/VoiceMini.tgs similarity index 100% rename from src/assets/animatedIcons/VoiceMini.tgs rename to src/assets/tgs/calls/VoiceMini.tgs diff --git a/src/assets/animatedIcons/VoiceMuted.tgs b/src/assets/tgs/calls/VoiceMuted.tgs similarity index 100% rename from src/assets/animatedIcons/VoiceMuted.tgs rename to src/assets/tgs/calls/VoiceMuted.tgs diff --git a/src/assets/animatedIcons/VoiceOutlined.tgs b/src/assets/tgs/calls/VoiceOutlined.tgs similarity index 100% rename from src/assets/animatedIcons/VoiceOutlined.tgs rename to src/assets/tgs/calls/VoiceOutlined.tgs diff --git a/src/assets/animatedIcons/VoipGroupRemoved.tgs b/src/assets/tgs/calls/VoipGroupRemoved.tgs similarity index 100% rename from src/assets/animatedIcons/VoipGroupRemoved.tgs rename to src/assets/tgs/calls/VoipGroupRemoved.tgs diff --git a/src/assets/animatedIcons/VoipInvite.tgs b/src/assets/tgs/calls/VoipInvite.tgs similarity index 100% rename from src/assets/animatedIcons/VoipInvite.tgs rename to src/assets/tgs/calls/VoipInvite.tgs diff --git a/src/assets/animatedIcons/VoipMuted.tgs b/src/assets/tgs/calls/VoipMuted.tgs similarity index 100% rename from src/assets/animatedIcons/VoipMuted.tgs rename to src/assets/tgs/calls/VoipMuted.tgs diff --git a/src/assets/animatedIcons/VoipRecordSave.tgs b/src/assets/tgs/calls/VoipRecordSave.tgs similarity index 100% rename from src/assets/animatedIcons/VoipRecordSave.tgs rename to src/assets/tgs/calls/VoipRecordSave.tgs diff --git a/src/assets/animatedIcons/VoipRecordStart.tgs b/src/assets/tgs/calls/VoipRecordStart.tgs similarity index 100% rename from src/assets/animatedIcons/VoipRecordStart.tgs rename to src/assets/tgs/calls/VoipRecordStart.tgs diff --git a/src/assets/animatedIcons/VoipUnmuted.tgs b/src/assets/tgs/calls/VoipUnmuted.tgs similarity index 100% rename from src/assets/animatedIcons/VoipUnmuted.tgs rename to src/assets/tgs/calls/VoipUnmuted.tgs diff --git a/src/assets/TwoFactorSetupMonkeyClose.tgs b/src/assets/tgs/monkeys/TwoFactorSetupMonkeyClose.tgs similarity index 100% rename from src/assets/TwoFactorSetupMonkeyClose.tgs rename to src/assets/tgs/monkeys/TwoFactorSetupMonkeyClose.tgs diff --git a/src/assets/TwoFactorSetupMonkeyCloseAndPeek.tgs b/src/assets/tgs/monkeys/TwoFactorSetupMonkeyCloseAndPeek.tgs similarity index 100% rename from src/assets/TwoFactorSetupMonkeyCloseAndPeek.tgs rename to src/assets/tgs/monkeys/TwoFactorSetupMonkeyCloseAndPeek.tgs diff --git a/src/assets/TwoFactorSetupMonkeyCloseAndPeekToIdle.tgs b/src/assets/tgs/monkeys/TwoFactorSetupMonkeyCloseAndPeekToIdle.tgs similarity index 100% rename from src/assets/TwoFactorSetupMonkeyCloseAndPeekToIdle.tgs rename to src/assets/tgs/monkeys/TwoFactorSetupMonkeyCloseAndPeekToIdle.tgs diff --git a/src/assets/TwoFactorSetupMonkeyIdle.tgs b/src/assets/tgs/monkeys/TwoFactorSetupMonkeyIdle.tgs similarity index 100% rename from src/assets/TwoFactorSetupMonkeyIdle.tgs rename to src/assets/tgs/monkeys/TwoFactorSetupMonkeyIdle.tgs diff --git a/src/assets/TwoFactorSetupMonkeyPeek.tgs b/src/assets/tgs/monkeys/TwoFactorSetupMonkeyPeek.tgs similarity index 100% rename from src/assets/TwoFactorSetupMonkeyPeek.tgs rename to src/assets/tgs/monkeys/TwoFactorSetupMonkeyPeek.tgs diff --git a/src/assets/TwoFactorSetupMonkeyTracking.tgs b/src/assets/tgs/monkeys/TwoFactorSetupMonkeyTracking.tgs similarity index 100% rename from src/assets/TwoFactorSetupMonkeyTracking.tgs rename to src/assets/tgs/monkeys/TwoFactorSetupMonkeyTracking.tgs diff --git a/src/assets/DiscussionGroupsDucks.tgs b/src/assets/tgs/settings/DiscussionGroupsDucks.tgs similarity index 100% rename from src/assets/DiscussionGroupsDucks.tgs rename to src/assets/tgs/settings/DiscussionGroupsDucks.tgs diff --git a/src/assets/FoldersAll.tgs b/src/assets/tgs/settings/FoldersAll.tgs similarity index 100% rename from src/assets/FoldersAll.tgs rename to src/assets/tgs/settings/FoldersAll.tgs diff --git a/src/assets/FoldersNew.tgs b/src/assets/tgs/settings/FoldersNew.tgs similarity index 100% rename from src/assets/FoldersNew.tgs rename to src/assets/tgs/settings/FoldersNew.tgs diff --git a/src/bundles/extra.ts b/src/bundles/extra.ts index 370a80fcb..1f8d58839 100644 --- a/src/bundles/extra.ts +++ b/src/bundles/extra.ts @@ -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'; diff --git a/src/components/common/AnimatedEmoji.tsx b/src/components/common/AnimatedEmoji.tsx index 14e9997dc..a98ef8580 100644 --- a/src/components/common/AnimatedEmoji.tsx +++ b/src/components/common/AnimatedEmoji.tsx @@ -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 = ({ sticker, + effect, + isOwn, + soundId, size = 'medium', observeIntersection, lastSyncTime, forceLoadPreview, + messageId, + chatId, + activeEmojiInteraction, }) => { - // eslint-disable-next-line no-null/no-null - const ref = useRef(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 = ({ 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 (
@@ -78,7 +84,7 @@ const AnimatedEmoji: FC = ({ {!isAnimationLoaded && previewBlobUrl && ( )} - {isMediaLoaded && ( + {isMediaLoaded && localMediaHash && ( = ({ ); }; -export default AnimatedEmoji; +export default memo(AnimatedEmoji); diff --git a/src/components/common/AnimatedSticker.tsx b/src/components/common/AnimatedSticker.tsx index 49cc6f903..f042c299d 100644 --- a/src/components/common/AnimatedSticker.tsx +++ b/src/components/common/AnimatedSticker.tsx @@ -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 = ({ isLowPriority, onLoad, color, + onEnded, }) => { const [animation, setAnimation] = useState(); // eslint-disable-next-line no-null/no-null @@ -89,6 +91,7 @@ const AnimatedSticker: FC = ({ }, onLoad, color, + onEnded, ); if (speed) { @@ -109,7 +112,7 @@ const AnimatedSticker: FC = ({ }); }); } - }, [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; diff --git a/src/components/common/LocalAnimatedEmoji.tsx b/src/components/common/LocalAnimatedEmoji.tsx new file mode 100644 index 000000000..3ae7747b0 --- /dev/null +++ b/src/components/common/LocalAnimatedEmoji.tsx @@ -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 = ({ + 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(); + useEffect(() => { + if (localSticker) { + getAnimationData(localSticker as keyof typeof ANIMATED_STICKERS_PATHS).then((data) => { + setLocalStickerAnimationData(data); + }); + } + }, [localSticker]); + + return ( +
+ {localStickerAnimationData && ( + + )} +
+ ); +}; + +export default memo(LocalAnimatedEmoji); diff --git a/src/components/common/ReactionStaticEmoji.scss b/src/components/common/ReactionStaticEmoji.scss new file mode 100644 index 000000000..d06876964 --- /dev/null +++ b/src/components/common/ReactionStaticEmoji.scss @@ -0,0 +1,4 @@ +.ReactionStaticEmoji { + width: 1rem; + display: block; +} diff --git a/src/components/common/ReactionStaticEmoji.tsx b/src/components/common/ReactionStaticEmoji.tsx new file mode 100644 index 000000000..5079598f0 --- /dev/null +++ b/src/components/common/ReactionStaticEmoji.tsx @@ -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; + className?: string; +}; + +const ReactionStaticEmoji: FC = ({ + reaction, + ref, + className, +}) => { + const staticIconId = getGlobal().availableReactions?.find((l) => l.reaction === reaction)?.staticIcon?.id; + const mediaData = useMedia(`document${staticIconId}`, !staticIconId, ApiMediaFormat.BlobUrl); + + return ( + + ); +}; + +export default memo(ReactionStaticEmoji); diff --git a/src/components/common/TypingStatus.tsx b/src/components/common/TypingStatus.tsx index 915eb5322..e4a58f076 100644 --- a/src/components/common/TypingStatus.tsx +++ b/src/components/common/TypingStatus.tsx @@ -28,7 +28,7 @@ const TypingStatus: FC = ({ typingStatus, typingUser }) = {renderText(typingUserName)} )} {/* fix for translation "username _is_ typing" */} - {lang(typingStatus.action).replace('{user}', '').trim()} + {lang(typingStatus.action).replace('{user}', '').replace('{emoji}', typingStatus.emoji).trim()}

); diff --git a/src/components/common/helpers/animatedAssets.ts b/src/components/common/helpers/animatedAssets.ts index b7dc7743f..691003c8b 100644 --- a/src/components/common/helpers/animatedAssets.ts +++ b/src/components/common/helpers/animatedAssets.ts @@ -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) { diff --git a/src/components/common/hooks/useAnimatedEmoji.ts b/src/components/common/hooks/useAnimatedEmoji.ts new file mode 100644 index 000000000..1814d1e1d --- /dev/null +++ b/src/components/common/hooks/useAnimatedEmoji.ts @@ -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(null); + + // eslint-disable-next-line no-null/no-null + const audioRef = useRef(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(undefined); + const startedInteractions = useRef(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, + }; +} diff --git a/src/components/left/LeftColumn.tsx b/src/components/left/LeftColumn.tsx index 2619792c9..2cfb5eb7e 100644 --- a/src/components/left/LeftColumn.tsx +++ b/src/components/left/LeftColumn.tsx @@ -122,6 +122,7 @@ const LeftColumn: FC = ({ return; case SettingsScreens.GeneralChatBackground: + case SettingsScreens.QuickReaction: setSettingsScreen(SettingsScreens.General); return; case SettingsScreens.GeneralChatBackgroundColor: diff --git a/src/components/left/settings/Settings.scss b/src/components/left/settings/Settings.scss index e00f2fc78..d8c50d268 100644 --- a/src/components/left/settings/Settings.scss +++ b/src/components/left/settings/Settings.scss @@ -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; + } + +} diff --git a/src/components/left/settings/Settings.tsx b/src/components/left/settings/Settings.tsx index 8990172d6..afa8d42ad 100644 --- a/src/components/left/settings/Settings.tsx +++ b/src/components/left/settings/Settings.tsx @@ -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 = ({ isActive={isScreenActive || screen === SettingsScreens.GeneralChatBackgroundColor || screen === SettingsScreens.GeneralChatBackground + || screen === SettingsScreens.QuickReaction || isPrivacyScreen || isFoldersScreen} onReset={handleReset} /> ); + case SettingsScreens.QuickReaction: + return ( + + ); case SettingsScreens.Notifications: return ( diff --git a/src/components/left/settings/SettingsGeneral.tsx b/src/components/left/settings/SettingsGeneral.tsx index e31455bfc..84a5025ed 100644 --- a/src/components/left/settings/SettingsGeneral.tsx +++ b/src/components/left/settings/SettingsGeneral.tsx @@ -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; + defaultReaction?: string; }; const ANIMATION_LEVEL_OPTIONS = [ @@ -60,6 +62,7 @@ const SettingsGeneral: FC = ({ onReset, stickerSetIds, stickerSetsById, + defaultReaction, messageTextSize, animationLevel, messageSendKeyCombo, @@ -189,6 +192,16 @@ const SettingsGeneral: FC = ({

{lang('AccDescrStickers')}

+ {defaultReaction && ( + onScreenSelect(SettingsScreens.QuickReaction)} + > + +
{lang('DoubleTapSetting')}
+
+ )} + ( ]), stickerSetIds: global.stickers.added.setIds, stickerSetsById: global.stickers.setsById, + defaultReaction: global.appConfig?.defaultReaction, }; }, )(SettingsGeneral)); diff --git a/src/components/left/settings/SettingsHeader.tsx b/src/components/left/settings/SettingsHeader.tsx index fbcc028ba..374e6dc7c 100644 --- a/src/components/left/settings/SettingsHeader.tsx +++ b/src/components/left/settings/SettingsHeader.tsx @@ -87,6 +87,8 @@ const SettingsHeader: FC = ({ return

{lang('lng_settings_information')}

; case SettingsScreens.General: return

{lang('General')}

; + case SettingsScreens.QuickReaction: + return

{lang('DoubleTapSetting')}

; case SettingsScreens.Notifications: return

{lang('Notifications')}

; case SettingsScreens.DataStorage: diff --git a/src/components/left/settings/SettingsQuickReaction.tsx b/src/components/left/settings/SettingsQuickReaction.tsx new file mode 100644 index 000000000..a7e06c1ec --- /dev/null +++ b/src/components/left/settings/SettingsQuickReaction.tsx @@ -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 = ({ + 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: <>{l.title}, + value: l.reaction, + }; + }) || []; + + const handleChange = useCallback((reaction: string) => { + setDefaultReaction({ reaction }); + }, [setDefaultReaction]); + + return ( +
+ +
+ ); +}; + +export default memo(withGlobal( + (global) => { + const { availableReactions, appConfig } = global; + + return { + availableReactions, + selectedReaction: appConfig?.defaultReaction, + }; + }, +)(SettingsQuickReaction)); diff --git a/src/components/main/Main.tsx b/src/components/main/Main.tsx index 43b20afe4..f7dfa2c1f 100644 --- a/src/components/main/Main.tsx +++ b/src/components/main/Main.tsx @@ -108,12 +108,14 @@ const Main: FC = ({ loadTopInlineBots, loadEmojiKeywords, loadCountryList, + loadAvailableReactions, loadStickerSets, loadAddedStickers, loadFavoriteStickers, ensureTimeFormat, openStickerSetShortName, checkVersionNotification, + loadAppConfig, } = getDispatch(); const isSynced = Boolean(lastSyncTime); @@ -127,6 +129,8 @@ const Main: FC = ({ useEffect(() => { if (lastSyncTime) { updateIsOnline(true); + loadAppConfig(); + loadAvailableReactions(); loadAnimatedEmojis(); loadNotificationSettings(); loadNotificationExceptions(); @@ -135,7 +139,7 @@ const Main: FC = ({ } }, [ lastSyncTime, loadAnimatedEmojis, loadEmojiKeywords, loadNotificationExceptions, loadNotificationSettings, - loadTopInlineBots, updateIsOnline, + loadTopInlineBots, updateIsOnline, loadAvailableReactions, loadAppConfig, ]); // Language-based API calls diff --git a/src/components/middle/EmojiInteractionAnimation.async.tsx b/src/components/middle/EmojiInteractionAnimation.async.tsx new file mode 100644 index 000000000..dbfb73caf --- /dev/null +++ b/src/components/middle/EmojiInteractionAnimation.async.tsx @@ -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 = (props) => { + const { emojiInteraction } = props; + const EmojiInteractionAnimation = useModuleLoader(Bundles.Extra, 'EmojiInteractionAnimation', !emojiInteraction); + + // eslint-disable-next-line react/jsx-props-no-spreading + return EmojiInteractionAnimation ? : undefined; +}; + +export default memo(EmojiInteractionAnimationAsync); diff --git a/src/components/middle/EmojiInteractionAnimation.scss b/src/components/middle/EmojiInteractionAnimation.scss new file mode 100644 index 000000000..955801fee --- /dev/null +++ b/src/components/middle/EmojiInteractionAnimation.scss @@ -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); + } +} diff --git a/src/components/middle/EmojiInteractionAnimation.tsx b/src/components/middle/EmojiInteractionAnimation.tsx new file mode 100644 index 000000000..eb0782e25 --- /dev/null +++ b/src/components/middle/EmojiInteractionAnimation.tsx @@ -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 = ({ + 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(); + 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 ( +
+ +
+ ); +}; + +export default memo(withGlobal( + (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)); diff --git a/src/components/middle/MessageList.tsx b/src/components/middle/MessageList.tsx index 55c809912..c1d8c67c6 100644 --- a/src/components/middle/MessageList.tsx +++ b/src/components/middle/MessageList.tsx @@ -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 = ({ lastSyncTime, withBottomShift, }) => { - const { loadViewportMessages, setScrollOffset, loadSponsoredMessages } = getDispatch(); + const { + loadViewportMessages, setScrollOffset, loadSponsoredMessages, loadMessageReactions, + } = getDispatch(); // eslint-disable-next-line no-null/no-null const containerRef = useRef(null); @@ -204,6 +208,17 @@ const MessageList: FC = ({ 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 = ({ isViewportNewest={Boolean(isViewportNewest)} isUnread={Boolean(firstUnreadId)} withUsers={withUsers} + areReactionsInMeta={isPrivate} noAvatars={noAvatars} containerRef={containerRef} anchorIdRef={anchorIdRef} diff --git a/src/components/middle/MessageListContent.tsx b/src/components/middle/MessageListContent.tsx index f817e9602..770954890 100644 --- a/src/components/middle/MessageListContent.tsx +++ b/src/components/middle/MessageListContent.tsx @@ -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 = ({ isViewportNewest, isUnread, withUsers, + areReactionsInMeta, noAvatars, containerRef, anchorIdRef, @@ -188,6 +190,7 @@ const MessageListContent: FC = ({ noAvatars={noAvatars} withAvatar={position.isLastInGroup && withUsers && !isOwn && !(message.id === threadTopMessageId)} withSenderName={position.isFirstInGroup && withUsers && !isOwn} + areReactionsInMeta={areReactionsInMeta} threadId={threadId} messageListType={type} noComments={hasLinkedChat === false} diff --git a/src/components/middle/MiddleColumn.tsx b/src/components/middle/MiddleColumn.tsx index c247a8004..84d244447 100644 --- a/src/components/middle/MiddleColumn.tsx +++ b/src/components/middle/MiddleColumn.tsx @@ -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 = ({ isPaymentModalOpen, isReceiptModalOpen, isSeenByModalOpen, + isReactorListModalOpen, animationLevel, shouldSkipHistoryAnimations, currentTransitionKey, @@ -141,6 +150,7 @@ const MiddleColumn: FC = ({ canSubscribe, canStartBot, canRestartBot, + activeEmojiInteraction, }) => { const { openChat, @@ -484,6 +494,7 @@ const MiddleColumn: FC = ({ onClose={clearReceipt} /> +
)} @@ -507,6 +518,9 @@ const MiddleColumn: FC = ({ onUnpin={handleUnpinAllMessages} /> )} + {activeEmojiInteraction && ( + + )}
); }; @@ -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) { diff --git a/src/components/middle/ReactorListModal.async.tsx b/src/components/middle/ReactorListModal.async.tsx new file mode 100644 index 000000000..8d221cbb6 --- /dev/null +++ b/src/components/middle/ReactorListModal.async.tsx @@ -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 = (props) => { + const { isOpen } = props; + const ReactorListModal = useModuleLoader(Bundles.Extra, 'ReactorListModal', !isOpen); + + // eslint-disable-next-line react/jsx-props-no-spreading + return ReactorListModal ? : undefined; +}; + +export default memo(ReactorListModalAsync); diff --git a/src/components/middle/ReactorListModal.scss b/src/components/middle/ReactorListModal.scss new file mode 100644 index 000000000..a66a51df6 --- /dev/null +++ b/src/components/middle/ReactorListModal.scss @@ -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; + } +} diff --git a/src/components/middle/ReactorListModal.tsx b/src/components/middle/ReactorListModal.tsx new file mode 100644 index 000000000..660dfeaee --- /dev/null +++ b/src/components/middle/ReactorListModal.tsx @@ -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 & { + chatId?: string; + messageId?: number; +}; + +const ReactorListModal: FC = ({ + 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(undefined); + const canShowFilters = reactors && reactions && reactors.count >= MIN_REACTIONS_COUNT_FOR_FILTERS + && reactions.results.length > 1; + const chatIdRef = useRef(); + + 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 ( + + {canShowFilters && ( +
+ + {allReactions.map((reaction) => { + const count = reactions?.results.find((l) => l.reaction === reaction)?.count; + return ( + + ); + })} +
+ )} + +
+ {viewportIds?.length ? ( + + {viewportIds?.map( + (userId) => { + const user = usersById[userId]; + const fullName = getUserFullName(user); + const reaction = reactors?.reactions.find((l) => l.userId === userId)?.reaction; + return ( + handleClick(userId)} + > + +
+

{fullName && renderText(fullName)}

+
+ {reaction && } +
+ ); + }, + )} +
+ ) : } +
+ +
+ ); +}; + +export default memo(withGlobal( + (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)); diff --git a/src/components/middle/helpers/calculateMiddleFooterTransforms.ts b/src/components/middle/helpers/calculateMiddleFooterTransforms.ts index af4f31919..7a4eee33b 100644 --- a/src/components/middle/helpers/calculateMiddleFooterTransforms.ts +++ b/src/components/middle/helpers/calculateMiddleFooterTransforms.ts @@ -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) { diff --git a/src/components/middle/message/ContextMenuContainer.tsx b/src/components/middle/message/ContextMenuContainer.tsx index d2e6c3b1c..6925ef7a5 100644 --- a/src/components/middle/message/ContextMenuContainer.tsx +++ b/src/components/middle/message/ContextMenuContainer.tsx @@ -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 = ({ + availableReactions, isOpen, messageListType, chatUsername, @@ -69,13 +83,19 @@ const ContextMenuContainer: FC = ({ 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 = ({ cancelMessageMediaDownload, loadSeenBy, openSeenByModal, + sendReaction, + openReactorListModal, + loadFullChat, + loadReactors, } = getDispatch(); const { transitionClassNames } = useShowTransition(isOpen, onCloseAnimationEnd, undefined, false); @@ -115,7 +139,26 @@ const ContextMenuContainer: FC = ({ } }, [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 = ({ // 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 = ({ 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 = ({ 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 = ({ return (
= ({ onCopyLink={handleCopyLink} onDownload={handleDownloadClick} onShowSeenBy={handleOpenSeenByModal} + onSendReaction={handleSendReaction} + onShowReactors={handleOpenReactorListModal} /> ( } = (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( 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)); diff --git a/src/components/middle/message/Message.scss b/src/components/middle/message/Message.scss index 5d9a2f733..f57d730d3 100644 --- a/src/components/middle/message/Message.scss +++ b/src/components/middle/message/Message.scss @@ -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); diff --git a/src/components/middle/message/Message.tsx b/src/components/middle/message/Message.tsx index 0b64993ed..30544203a 100644 --- a/src/components/middle/message/Message.tsx +++ b/src/components/middle/message/Message.tsx @@ -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: '' }; // eslint-disable-next-line max-len @@ -185,6 +204,8 @@ const Message: FC = ({ noAvatars, withAvatar, withSenderName, + areReactionsInMeta, + reactionsMessage, noComments, appearanceOrder, isFirstInGroup, @@ -216,10 +237,18 @@ const Message: FC = ({ 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 = ({ const ref = useRef(null); // eslint-disable-next-line no-null/no-null const bottomMarkerRef = useRef(null); + // eslint-disable-next-line no-null/no-null + const contentRef = useRef(null); const lang = useLang(); @@ -288,6 +319,8 @@ const Message: FC = ({ 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 = ({ )); 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, groupedId?: string) => { toggleMessageSelection({ @@ -324,7 +360,12 @@ const Message: FC = ({ handleContextMenu, handleDoubleClick, handleContentDoubleClick, + handleMouseMove, + handleSendQuickReaction, + handleMouseLeave, isSwiped, + isQuickReactionVisible, + handleDocumentGroupMouseEnter, } = useOuterHandlers( selectMessage, ref, @@ -335,6 +376,11 @@ const Message: FC = ({ Boolean(isProtected), onContextMenu, handleBeforeContextMenu, + chatId, + isContextMenuShown, + contentRef, + isOwn, + isInDocumentGroup && !isLastInDocumentGroup, ); const { @@ -395,6 +441,7 @@ const Message: FC = ({ 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 = ({ 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 = ({ ); } + function renderMeta(withReactionOffset = false) { + return ( + + ); + } + function renderContent() { const className = buildClassName( 'content-inner', @@ -482,7 +545,7 @@ const Message: FC = ({ 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 = ({ {animatedEmoji && ( + )} + {localSticker && ( + )} {isAlbum && ( @@ -605,19 +689,25 @@ const Message: FC = ({ {poll && ( )} - {!animatedEmoji && textParts && ( + {!hasAnimatedEmoji && textParts && (

{textParts} {shouldInlineMeta && ( - + <> + {hasReactionButtons && !areReactionsOutside ? ( + + ) + : renderMeta()} + )}

)} + {webPage && ( = ({ onContextMenu={handleContextMenu} onDoubleClick={handleDoubleClick} onMouseEnter={isInDocumentGroup && !isLastInDocumentGroup ? handleDocumentGroupMouseEnter : undefined} - onMouseLeave={isInDocumentGroup && !isLastInDocumentGroup ? handleDocumentGroupMouseLeave : undefined} + onMouseMove={withQuickReaction ? handleMouseMove : undefined} + onMouseLeave={(withQuickReaction || (isInDocumentGroup && !isLastInDocumentGroup)) ? handleMouseLeave : undefined} >
= ({ className={buildClassName('message-content-wrapper', contentClassName.includes('text') && 'can-select-text')} >
= ({
{lang('ForwardedMessage')}
)} {renderContent()} - {(!isInDocumentGroup || isLastInDocumentGroup) && !(!webPage && !animatedEmoji && textParts) && ( - + {(!isInDocumentGroup || isLastInDocumentGroup) && !(!webPage && !hasAnimatedEmoji && textParts) && ( + <> + {hasReactionButtons && !areReactionsOutside && (hasAnimatedEmoji || !textParts || webPage) ? ( + + ) : renderMeta()} + )} {canShowActionButton && canForward ? (
{contextMenuPosition && ( = ({ ); }; -function handleDocumentGroupMouseEnter(e: React.MouseEvent) { - const lastGroupElement = getLastElementInDocumentGroup(e.currentTarget); - if (lastGroupElement) { - lastGroupElement.setAttribute(GROUP_MESSAGE_HOVER_ATTRIBUTE, ''); - } -} - -function handleDocumentGroupMouseLeave(e: React.MouseEvent) { - 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( (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( ? 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( isProtected: selectIsMessageProtected(global, message), isFocused, isForwarding, + reactionsMessage, isChatWithSelf, isRepliesChat, isChannel, @@ -906,6 +1005,10 @@ export default memo(withGlobal( 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( ...(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)); diff --git a/src/components/middle/message/MessageContextMenu.scss b/src/components/middle/message/MessageContextMenu.scss index 4125f6d23..415b86daa 100644 --- a/src/components/middle/message/MessageContextMenu.scss +++ b/src/components/middle/message/MessageContextMenu.scss @@ -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 { diff --git a/src/components/middle/message/MessageContextMenu.tsx b/src/components/middle/message/MessageContextMenu.tsx index d39843907..c77e69222 100644 --- a/src/components/middle/message/MessageContextMenu.tsx +++ b/src/components/middle/message/MessageContextMenu.tsx @@ -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 = ({ + availableReactions, isOpen, message, + isPrivate, + enabledReactions, anchor, canSendNow, canReschedule, @@ -80,6 +96,9 @@ const MessageContextMenu: FC = ({ canDownload, isDownloading, canShowSeenBy, + canShowReactionsCount, + canRemoveReaction, + canShowReactionList, seenByRecentUsers, onReply, onEdit, @@ -98,10 +117,18 @@ const MessageContextMenu: FC = ({ onCopyLink, onDownload, onShowSeenBy, + onShowReactors, + onSendReaction, }) => { // eslint-disable-next-line no-null/no-null const menuRef = useRef(null); + // eslint-disable-next-line no-null/no-null + const scrollableRef = useRef(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 = ({ [], ); + 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 = ({ 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 = ({ positionY={positionY} style={style} menuStyle={menuStyle} - className="MessageContextMenu fluid" + className={buildClassName( + 'MessageContextMenu', 'fluid', withReactions && 'with-reactions', + )} onClose={onClose} onCloseAnimationEnd={onCloseAnimationEnd} > - {canSendNow && {lang('MessageScheduleSend')}} - {canReschedule && ( - {lang('MessageScheduleEditTime')} + {canShowReactionList && ( + )} - {canReply && {lang('Reply')}} - {canEdit && {lang('Edit')}} - {canFaveSticker && ( - {lang('AddToFavorites')} - )} - {canUnfaveSticker && ( - {lang('Stickers.RemoveFromFavorites')} - )} - {canCopy && copyOptions.map((options) => ( - {lang(options.label)} - ))} - {canPin && {lang('DialogPin')}} - {canUnpin && {lang('DialogUnpin')}} - {canDownload && ( - - {isDownloading ? lang('lng_context_cancel_download') : lang('lng_media_download')} - - )} - {canForward && {lang('Forward')}} - {canSelect && {lang('Common.Select')}} - {canReport && {lang('lng_context_report_msg')}} - {canShowSeenBy && ( - - {message.seenByUserIds?.length - ? lang('Conversation.ContextMenuSeen', message.seenByUserIds.length, 'i') - : lang('Conversation.ContextMenuNoViews')} -
- {seenByRecentUsers?.map((user) => ( - - ))} -
-
- )} - {canDelete && {lang('Delete')}} + +
+ {canRemoveReaction && Remove Reaction} + {canSendNow && {lang('MessageScheduleSend')}} + {canReschedule && ( + {lang('MessageScheduleEditTime')} + )} + {canReply && {lang('Reply')}} + {canEdit && {lang('Edit')}} + {canFaveSticker && ( + {lang('AddToFavorites')} + )} + {canUnfaveSticker && ( + {lang('Stickers.RemoveFromFavorites')} + )} + {canCopy && copyOptions.map((options) => ( + {lang(options.label)} + ))} + {canPin && {lang('DialogPin')}} + {canUnpin && {lang('DialogUnpin')}} + {canDownload && ( + + {isDownloading ? lang('lng_context_cancel_download') : lang('lng_media_download')} + + )} + {canForward && {lang('Forward')}} + {canSelect && {lang('Common.Select')}} + {canReport && {lang('lng_context_report_msg')}} + {(canShowSeenBy || canShowReactionsCount) && ( + + {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'))} +
+ {seenByRecentUsers?.map((user) => ( + + ))} +
+
+ )} + {canDelete && {lang('Delete')}} +
); }; diff --git a/src/components/middle/message/MessageMeta.scss b/src/components/middle/message/MessageMeta.scss index 11f37f572..e475da993 100644 --- a/src/components/middle/message/MessageMeta.scss +++ b/src/components/middle/message/MessageMeta.scss @@ -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 { diff --git a/src/components/middle/message/MessageMeta.tsx b/src/components/middle/message/MessageMeta.tsx index 436a23e0d..c5315a990 100644 --- a/src/components/middle/message/MessageMeta.tsx +++ b/src/components/middle/message/MessageMeta.tsx @@ -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) => void; + activeReaction?: ActiveReaction; + availableReactions?: ApiAvailableReaction[]; }; const MessageMeta: FC = ({ - 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 = ({ }, [isActivated, lang, message]); return ( - + + {reactions && reactions.map((l) => ( + + ))} {Boolean(message.views) && ( <> diff --git a/src/components/middle/message/ReactionAnimatedEmoji.scss b/src/components/middle/message/ReactionAnimatedEmoji.scss new file mode 100644 index 000000000..54ed223bf --- /dev/null +++ b/src/components/middle/message/ReactionAnimatedEmoji.scss @@ -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; + } + } + } +} diff --git a/src/components/middle/message/ReactionAnimatedEmoji.tsx b/src/components/middle/message/ReactionAnimatedEmoji.tsx new file mode 100644 index 000000000..a98518183 --- /dev/null +++ b/src/components/middle/message/ReactionAnimatedEmoji.tsx @@ -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 = ({ + 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 ( +
+ {shouldRenderStatic && } + {shouldRenderAnimation && ( + <> + + + + )} +
+ ); +}; + +export default memo(ReactionAnimatedEmoji); diff --git a/src/components/middle/message/ReactionButton.tsx b/src/components/middle/message/ReactionButton.tsx new file mode 100644 index 000000000..1fd369c82 --- /dev/null +++ b/src/components/middle/message/ReactionButton.tsx @@ -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 ( + + ); +}; + +export default memo(ReactionButton); diff --git a/src/components/middle/message/ReactionSelector.scss b/src/components/middle/message/ReactionSelector.scss new file mode 100644 index 000000000..6438ad764 --- /dev/null +++ b/src/components/middle/message/ReactionSelector.scss @@ -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; + } +} diff --git a/src/components/middle/message/ReactionSelector.tsx b/src/components/middle/message/ReactionSelector.tsx new file mode 100644 index 000000000..1b9013eb3 --- /dev/null +++ b/src/components/middle/message/ReactionSelector.tsx @@ -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(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 ( +
+ {shouldRenderPreview && } + {shouldRenderAnimated && ( + + )} +
+ ); +}; +const ReactionSelector: FC = ({ + availableReactions, + enabledReactions, + onSendReaction, + isPrivate, + isReady, +}) => { + // eslint-disable-next-line no-null/no-null + const itemsScrollRef = useRef(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 ( +
+
+
+
+
+ {availableReactions?.map((reaction) => { + if (reaction.isInactive + || (!isPrivate && (!enabledReactions || !enabledReactions.includes(reaction.reaction)))) return undefined; + return ( + + ); + })} +
+
+
+ ); +}; + +export default memo(ReactionSelector); diff --git a/src/components/middle/message/Reactions.scss b/src/components/middle/message/Reactions.scss new file mode 100644 index 000000000..6e044ff13 --- /dev/null +++ b/src/components/middle/message/Reactions.scss @@ -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); + } + } + } +} diff --git a/src/components/middle/message/Reactions.tsx b/src/components/middle/message/Reactions.tsx new file mode 100644 index 000000000..dfa476d41 --- /dev/null +++ b/src/components/middle/message/Reactions.tsx @@ -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 = ({ + message, + isOutside, + activeReaction, + availableReactions, + metaChildren, +}) => { + return ( +
+ {message.reactions!.results.map((reaction) => ( + + ))} + {metaChildren} +
+ ); +}; + +export default memo(Reactions); diff --git a/src/components/middle/message/_message-content.scss b/src/components/middle/message/_message-content.scss index 0e912a870..0ba33fece 100644 --- a/src/components/middle/message/_message-content.scss +++ b/src/components/middle/message/_message-content.scss @@ -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: ""; diff --git a/src/components/middle/message/hooks/useOuterHandlers.ts b/src/components/middle/message/hooks/useOuterHandlers.ts index d99b194cc..cfb1fec06 100644 --- a/src/components/middle/message/hooks/useOuterHandlers.ts +++ b/src/components/middle/message/hooks/useOuterHandlers.ts @@ -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, 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, + isOwn: boolean, + shouldHandleMouseLeave: boolean, ) { - const { setReplyingToId } = getDispatch(); + const { setReplyingToId, sendDefaultReaction } = getDispatch(); + const [isQuickReactionVisible, markQuickReactionVisible, unmarkQuickReactionVisible] = useFlag(); const [isSwiped, markSwiped, unmarkSwiped] = useFlag(); + const doubleTapTimeoutRef = useRef(); function handleMouseDown(e: React.MouseEvent) { preventMessageInputBlur(e); handleBeforeContextMenu(e); } - function handleClick(e: React.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) { + 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) { + const { pageX: x, pageY: y } = e; + + sendDefaultReaction({ + chatId, + messageId, + x, + y, + }); + } + + function handleClick(e: React.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) { 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) { + 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) { + const lastGroupElement = getLastElementInDocumentGroup(e.currentTarget); + if (lastGroupElement) { + lastGroupElement.setAttribute(GROUP_MESSAGE_HOVER_ATTRIBUTE, ''); + } +} + +function handleDocumentGroupMouseLeave(e: React.MouseEvent) { + 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; +} diff --git a/src/components/right/RightColumn.tsx b/src/components/right/RightColumn.tsx index 2160ed9d1..a55b0995b 100644 --- a/src/components/right/RightColumn.tsx +++ b/src/components/right/RightColumn.tsx @@ -123,6 +123,7 @@ const RightColumn: FC = ({ case ManagementScreens.ChatAdministrators: case ManagementScreens.ChannelSubscribers: case ManagementScreens.GroupMembers: + case ManagementScreens.Reactions: setManagementScreen(ManagementScreens.Initial); break; case ManagementScreens.GroupUserPermissionsCreate: diff --git a/src/components/right/RightHeader.tsx b/src/components/right/RightHeader.tsx index 6adcd2af7..1c92468b6 100644 --- a/src/components/right/RightHeader.tsx +++ b/src/components/right/RightHeader.tsx @@ -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 = ({ @@ -195,6 +193,8 @@ const RightHeader: FC = ({ 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 = ({ case HeaderContent.MemberList: case HeaderContent.ManageGroupMembers: return

{lang('GroupMembers')}

; + case HeaderContent.ManageReactions: + return

{lang('Reactions')}

; default: return ( <> diff --git a/src/components/right/management/ManageChannel.tsx b/src/components/right/management/ManageChannel.tsx index a6246f952..792d9a1a6 100644 --- a/src/components/right/management/ManageChannel.tsx +++ b/src/components/right/management/ManageChannel.tsx @@ -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 = ({ progress, isSignaturesShown, canChangeInfo, + availableReactionsCount, onScreenSelect, onClose, isActive, @@ -92,6 +94,10 @@ const ManageChannel: FC = ({ 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 = ({ 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 = ({ {lang('ChannelAdministrators')} {adminsCount} + + {lang('Reactions')} + + {enabledReactionsCount}/{availableReactionsCount} + +
( progress, isSignaturesShown, canChangeInfo: getHasAdminRight(chat, 'changeInfo'), + availableReactionsCount: global.availableReactions?.filter((l) => !l.isInactive).length, }; }, )(ManageChannel)); diff --git a/src/components/right/management/ManageGroup.tsx b/src/components/right/management/ManageGroup.tsx index acdbe9b8b..42d38ef09 100644 --- a/src/components/right/management/ManageGroup.tsx +++ b/src/components/right/management/ManageGroup.tsx @@ -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 = ({ onScreenSelect, onClose, isActive, + availableReactionsCount, }) => { const { togglePreHistoryHidden, @@ -100,6 +102,10 @@ const ManageGroup: FC = ({ 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 = ({ 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 = ({ {enabledPermissionsCount}/{TOTAL_PERMISSIONS_COUNT} + + + {lang('Reactions')} + + {enabledReactionsCount}/{availableReactionsCount} + + ( hasLinkedChannel, canChangeInfo: isBasicGroup ? chat.isCreator : getHasAdminRight(chat, 'changeInfo'), canBanUsers: isBasicGroup ? chat.isCreator : getHasAdminRight(chat, 'banUsers'), + availableReactionsCount: global.availableReactions?.filter((l) => !l.isInactive).length, }; }, )(ManageGroup)); diff --git a/src/components/right/management/ManageReactions.tsx b/src/components/right/management/ManageReactions.tsx new file mode 100644 index 000000000..2071c9cd2 --- /dev/null +++ b/src/components/right/management/ManageReactions.tsx @@ -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 = ({ + 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) => { + 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 ( +
+
+
+
+ 0} + label={lang('EnableReactions')} + onChange={handleReactionChange} + /> +
+ {availableReactions?.filter((l) => !l.isInactive).map(({ reaction, title }) => ( +
+ + + {title} +
+ )} + onChange={handleReactionChange} + /> +
+ ))} +
+
+ + + {isLoading ? ( + + ) : ( + + )} + +
+ ); +}; + +export default memo(withGlobal( + (global, { chatId }): StateProps => { + const chat = selectChat(global, chatId)!; + + return { + enabledReactions: chat.fullInfo?.enabledReactions, + availableReactions: global.availableReactions, + chat, + }; + }, +)(ManageReactions)); diff --git a/src/components/right/management/Management.scss b/src/components/right/management/Management.scss index f7d8703ca..5f9e2e68b 100644 --- a/src/components/right/management/Management.scss +++ b/src/components/right/management/Management.scss @@ -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; } diff --git a/src/components/right/management/Management.tsx b/src/components/right/management/Management.tsx index 8ec9f1d5e..c59158fce 100644 --- a/src/components/right/management/Management.tsx +++ b/src/components/right/management/Management.tsx @@ -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 = ({ onChatMemberSelect={onChatMemberSelect} /> ); + + case ManagementScreens.Reactions: + return ( + + ); } return undefined; // Never reached diff --git a/src/config.ts b/src/config.ts index 189241ac4..48dab80a2 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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; diff --git a/src/global/cache.ts b/src/global/cache.ts index 375280f59..5520ff493 100644 --- a/src/global/cache.ts +++ b/src/global/cache.ts @@ -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'])); +} diff --git a/src/global/initial.ts b/src/global/initial.ts index 55c325b62..f9fbb02cc 100644 --- a/src/global/initial.ts +++ b/src/global/initial.ts @@ -184,6 +184,7 @@ export const INITIAL_STATE: GlobalState = { }, twoFaSettings: {}, + activeReactions: {}, shouldShowContextMenuHint: true, diff --git a/src/global/types.ts b/src/global/types.ts index a738e9182..19c78e506 100644 --- a/src/global/types.ts +++ b/src/global/types.ts @@ -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>; gifs: { @@ -305,6 +331,10 @@ export type GlobalState = { globalUserIds?: string[]; }; + availableReactions?: ApiAvailableReaction[]; + activeEmojiInteraction?: ActiveEmojiInteraction; + activeReactions: Record; + localTextSearch: { byChatThreadKey: Record HTMLElement | null, extraPaddingX = 0, extraTopPadding = 0, + marginSides = 0, ) => { const [positionX, setPositionX] = useState<'right' | 'left'>('right'); const [positionY, setPositionY] = useState<'top' | 'bottom'>('bottom'); @@ -33,7 +34,14 @@ export default ( const rootEl = getRootElement(); const triggerRect = triggerEl.getBoundingClientRect(); - const menuRect = menuEl ? { width: menuEl.offsetWidth, height: menuEl.offsetHeight } : emptyRect; + + const marginTop = menuEl ? parseInt(getComputedStyle(menuEl).marginTop, 10) : undefined; + + const menuRect = menuEl ? { + width: menuEl.offsetWidth, + height: menuEl.offsetHeight + marginTop!, + } : emptyRect; + const rootRect = rootEl ? rootEl.getBoundingClientRect() : emptyRect; let horizontalPostition: 'left' | 'right'; @@ -49,6 +57,19 @@ export default ( } setPositionX(horizontalPostition); + if (marginSides + && horizontalPostition === 'right' && (x + extraPaddingX + marginSides >= rootRect.width + rootRect.left)) { + x -= marginSides; + } + + if (marginSides && horizontalPostition === 'left') { + if (x + extraPaddingX + marginSides + menuRect.width >= rootRect.width + rootRect.left) { + x -= marginSides; + } else if (x - marginSides <= 0) { + x += marginSides; + } + } + if (y + menuRect.height < rootRect.height + rootRect.top) { setPositionY('top'); } else { @@ -63,17 +84,17 @@ export default ( ? Math.min(x - triggerRect.left, rootRect.width - menuRect.width - MENU_POSITION_VISUAL_COMFORT_SPACE_PX) : Math.max((x - triggerRect.left), menuRect.width + MENU_POSITION_VISUAL_COMFORT_SPACE_PX); const top = Math.min( - rootRect.height - triggerRect.top + triggerRect.height - MENU_POSITION_BOTTOM_MARGIN, + rootRect.height - triggerRect.top + triggerRect.height - MENU_POSITION_BOTTOM_MARGIN + (marginTop || 0), y - triggerRect.top, ); - const menuMaxHeight = rootRect.height - MENU_POSITION_BOTTOM_MARGIN; + const menuMaxHeight = rootRect.height - MENU_POSITION_BOTTOM_MARGIN - (marginTop || 0); setWithScroll(menuMaxHeight < menuRect.height); setMenuStyle(`max-height: ${menuMaxHeight}px;`); setStyle(`left: ${left}px; top: ${top}px`); }, [ anchor, extraPaddingX, extraTopPadding, - getMenuElement, getRootElement, getTriggerElement, + getMenuElement, getRootElement, getTriggerElement, marginSides, ]); return { diff --git a/src/hooks/useHorizontalScroll.ts b/src/hooks/useHorizontalScroll.ts index ef8333c0b..8013fb96d 100644 --- a/src/hooks/useHorizontalScroll.ts +++ b/src/hooks/useHorizontalScroll.ts @@ -2,7 +2,7 @@ import { useEffect } from '../lib/teact/teact'; export default (container: HTMLElement | null, isDisabled?: boolean) => { useEffect(() => { - if (!container) { + if (!container || isDisabled) { return undefined; } diff --git a/src/lib/gramjs/tl/AllTLObjects.js b/src/lib/gramjs/tl/AllTLObjects.js index 81a5f5255..6243e40a5 100644 --- a/src/lib/gramjs/tl/AllTLObjects.js +++ b/src/lib/gramjs/tl/AllTLObjects.js @@ -1,6 +1,6 @@ const api = require('./api'); -const LAYER = 136; +const LAYER = 137; const tlobjects = {}; for (const tl of Object.values(api)) { diff --git a/src/lib/gramjs/tl/api.d.ts b/src/lib/gramjs/tl/api.d.ts index 06af53205..2a567798b 100644 --- a/src/lib/gramjs/tl/api.d.ts +++ b/src/lib/gramjs/tl/api.d.ts @@ -365,6 +365,7 @@ namespace Api { export type TypePeerSettings = messages.PeerSettings; export type TypeMessageReactionsList = messages.MessageReactionsList; export type TypeAvailableReactions = messages.AvailableReactionsNotModified | messages.AvailableReactions; + export type TypeTranslatedText = messages.TranslateNoResult | messages.TranslateResultText; } export namespace updates { @@ -7028,6 +7029,8 @@ namespace Api { selectAnimation: Api.TypeDocument; activateAnimation: Api.TypeDocument; effectAnimation: Api.TypeDocument; + aroundAnimation?: Api.TypeDocument; + centerIcon?: Api.TypeDocument; }> { // flags: undefined; inactive?: true; @@ -7038,6 +7041,8 @@ namespace Api { selectAnimation: Api.TypeDocument; activateAnimation: Api.TypeDocument; effectAnimation: Api.TypeDocument; + aroundAnimation?: Api.TypeDocument; + centerIcon?: Api.TypeDocument; }; export class ResPQ extends VirtualClass<{ nonce: int128; @@ -8089,6 +8094,12 @@ namespace Api { hash: int; reactions: Api.TypeAvailableReaction[]; }; + export class TranslateNoResult extends VirtualClass {}; + export class TranslateResultText extends VirtualClass<{ + text: string; + }> { + text: string; + }; } export namespace updates { @@ -11262,6 +11273,21 @@ namespace Api { }>, Bool> { reaction: string; }; + export class TranslateText extends Request, messages.TypeTranslatedText> { + // flags: undefined; + peer?: Api.TypeInputPeer; + msgId?: int; + text?: string; + fromLang?: string; + toLang: string; + }; } export namespace updates { @@ -12334,7 +12360,7 @@ namespace Api { | account.RegisterDevice | account.UnregisterDevice | account.UpdateNotifySettings | account.GetNotifySettings | account.ResetNotifySettings | account.UpdateProfile | account.UpdateStatus | account.GetWallPapers | account.ReportPeer | account.CheckUsername | account.UpdateUsername | account.GetPrivacy | account.SetPrivacy | account.DeleteAccount | account.GetAccountTTL | account.SetAccountTTL | account.SendChangePhoneCode | account.ChangePhone | account.UpdateDeviceLocked | account.GetAuthorizations | account.ResetAuthorization | account.GetPassword | account.GetPasswordSettings | account.UpdatePasswordSettings | account.SendConfirmPhoneCode | account.ConfirmPhone | account.GetTmpPassword | account.GetWebAuthorizations | account.ResetWebAuthorization | account.ResetWebAuthorizations | account.GetAllSecureValues | account.GetSecureValue | account.SaveSecureValue | account.DeleteSecureValue | account.GetAuthorizationForm | account.AcceptAuthorization | account.SendVerifyPhoneCode | account.VerifyPhone | account.SendVerifyEmailCode | account.VerifyEmail | account.InitTakeoutSession | account.FinishTakeoutSession | account.ConfirmPasswordEmail | account.ResendPasswordEmail | account.CancelPasswordEmail | account.GetContactSignUpNotification | account.SetContactSignUpNotification | account.GetNotifyExceptions | account.GetWallPaper | account.UploadWallPaper | account.SaveWallPaper | account.InstallWallPaper | account.ResetWallPapers | account.GetAutoDownloadSettings | account.SaveAutoDownloadSettings | account.UploadTheme | account.CreateTheme | account.UpdateTheme | account.SaveTheme | account.InstallTheme | account.GetTheme | account.GetThemes | account.SetContentSettings | account.GetContentSettings | account.GetMultiWallPapers | account.GetGlobalPrivacySettings | account.SetGlobalPrivacySettings | account.ReportProfilePhoto | account.ResetPassword | account.DeclinePasswordReset | account.GetChatThemes | account.SetAuthorizationTTL | account.ChangeAuthorizationSettings | users.GetUsers | users.GetFullUser | users.SetSecureValueErrors | contacts.GetContactIDs | contacts.GetStatuses | contacts.GetContacts | contacts.ImportContacts | contacts.DeleteContacts | contacts.DeleteByPhones | contacts.Block | contacts.Unblock | contacts.GetBlocked | contacts.Search | contacts.ResolveUsername | contacts.GetTopPeers | contacts.ResetTopPeerRating | contacts.ResetSaved | contacts.GetSaved | contacts.ToggleTopPeers | contacts.AddContact | contacts.AcceptContact | contacts.GetLocated | contacts.BlockFromReplies - | messages.GetMessages | messages.GetDialogs | messages.GetHistory | messages.Search | messages.ReadHistory | messages.DeleteHistory | messages.DeleteMessages | messages.ReceivedMessages | messages.SetTyping | messages.SendMessage | messages.SendMedia | messages.ForwardMessages | messages.ReportSpam | messages.GetPeerSettings | messages.Report | messages.GetChats | messages.GetFullChat | messages.EditChatTitle | messages.EditChatPhoto | messages.AddChatUser | messages.DeleteChatUser | messages.CreateChat | messages.GetDhConfig | messages.RequestEncryption | messages.AcceptEncryption | messages.DiscardEncryption | messages.SetEncryptedTyping | messages.ReadEncryptedHistory | messages.SendEncrypted | messages.SendEncryptedFile | messages.SendEncryptedService | messages.ReceivedQueue | messages.ReportEncryptedSpam | messages.ReadMessageContents | messages.GetStickers | messages.GetAllStickers | messages.GetWebPagePreview | messages.ExportChatInvite | messages.CheckChatInvite | messages.ImportChatInvite | messages.GetStickerSet | messages.InstallStickerSet | messages.UninstallStickerSet | messages.StartBot | messages.GetMessagesViews | messages.EditChatAdmin | messages.MigrateChat | messages.SearchGlobal | messages.ReorderStickerSets | messages.GetDocumentByHash | messages.GetSavedGifs | messages.SaveGif | messages.GetInlineBotResults | messages.SetInlineBotResults | messages.SendInlineBotResult | messages.GetMessageEditData | messages.EditMessage | messages.EditInlineBotMessage | messages.GetBotCallbackAnswer | messages.SetBotCallbackAnswer | messages.GetPeerDialogs | messages.SaveDraft | messages.GetAllDrafts | messages.GetFeaturedStickers | messages.ReadFeaturedStickers | messages.GetRecentStickers | messages.SaveRecentSticker | messages.ClearRecentStickers | messages.GetArchivedStickers | messages.GetMaskStickers | messages.GetAttachedStickers | messages.SetGameScore | messages.SetInlineGameScore | messages.GetGameHighScores | messages.GetInlineGameHighScores | messages.GetCommonChats | messages.GetAllChats | messages.GetWebPage | messages.ToggleDialogPin | messages.ReorderPinnedDialogs | messages.GetPinnedDialogs | messages.SetBotShippingResults | messages.SetBotPrecheckoutResults | messages.UploadMedia | messages.SendScreenshotNotification | messages.GetFavedStickers | messages.FaveSticker | messages.GetUnreadMentions | messages.ReadMentions | messages.GetRecentLocations | messages.SendMultiMedia | messages.UploadEncryptedFile | messages.SearchStickerSets | messages.GetSplitRanges | messages.MarkDialogUnread | messages.GetDialogUnreadMarks | messages.ClearAllDrafts | messages.UpdatePinnedMessage | messages.SendVote | messages.GetPollResults | messages.GetOnlines | messages.EditChatAbout | messages.EditChatDefaultBannedRights | messages.GetEmojiKeywords | messages.GetEmojiKeywordsDifference | messages.GetEmojiKeywordsLanguages | messages.GetEmojiURL | messages.GetSearchCounters | messages.RequestUrlAuth | messages.AcceptUrlAuth | messages.HidePeerSettingsBar | messages.GetScheduledHistory | messages.GetScheduledMessages | messages.SendScheduledMessages | messages.DeleteScheduledMessages | messages.GetPollVotes | messages.ToggleStickerSets | messages.GetDialogFilters | messages.GetSuggestedDialogFilters | messages.UpdateDialogFilter | messages.UpdateDialogFiltersOrder | messages.GetOldFeaturedStickers | messages.GetReplies | messages.GetDiscussionMessage | messages.ReadDiscussion | messages.UnpinAllMessages | messages.DeleteChat | messages.DeletePhoneCallHistory | messages.CheckHistoryImport | messages.InitHistoryImport | messages.UploadImportedMedia | messages.StartHistoryImport | messages.GetExportedChatInvites | messages.GetExportedChatInvite | messages.EditExportedChatInvite | messages.DeleteRevokedExportedChatInvites | messages.DeleteExportedChatInvite | messages.GetAdminsWithInvites | messages.GetChatInviteImporters | messages.SetHistoryTTL | messages.CheckHistoryImportPeer | messages.SetChatTheme | messages.GetMessageReadParticipants | messages.GetSearchResultsCalendar | messages.GetSearchResultsPositions | messages.HideChatJoinRequest | messages.HideAllChatJoinRequests | messages.ToggleNoForwards | messages.SaveDefaultSendAs | messages.SendReaction | messages.GetMessagesReactions | messages.GetMessageReactionsList | messages.SetChatAvailableReactions | messages.GetAvailableReactions | messages.SetDefaultReaction + | messages.GetMessages | messages.GetDialogs | messages.GetHistory | messages.Search | messages.ReadHistory | messages.DeleteHistory | messages.DeleteMessages | messages.ReceivedMessages | messages.SetTyping | messages.SendMessage | messages.SendMedia | messages.ForwardMessages | messages.ReportSpam | messages.GetPeerSettings | messages.Report | messages.GetChats | messages.GetFullChat | messages.EditChatTitle | messages.EditChatPhoto | messages.AddChatUser | messages.DeleteChatUser | messages.CreateChat | messages.GetDhConfig | messages.RequestEncryption | messages.AcceptEncryption | messages.DiscardEncryption | messages.SetEncryptedTyping | messages.ReadEncryptedHistory | messages.SendEncrypted | messages.SendEncryptedFile | messages.SendEncryptedService | messages.ReceivedQueue | messages.ReportEncryptedSpam | messages.ReadMessageContents | messages.GetStickers | messages.GetAllStickers | messages.GetWebPagePreview | messages.ExportChatInvite | messages.CheckChatInvite | messages.ImportChatInvite | messages.GetStickerSet | messages.InstallStickerSet | messages.UninstallStickerSet | messages.StartBot | messages.GetMessagesViews | messages.EditChatAdmin | messages.MigrateChat | messages.SearchGlobal | messages.ReorderStickerSets | messages.GetDocumentByHash | messages.GetSavedGifs | messages.SaveGif | messages.GetInlineBotResults | messages.SetInlineBotResults | messages.SendInlineBotResult | messages.GetMessageEditData | messages.EditMessage | messages.EditInlineBotMessage | messages.GetBotCallbackAnswer | messages.SetBotCallbackAnswer | messages.GetPeerDialogs | messages.SaveDraft | messages.GetAllDrafts | messages.GetFeaturedStickers | messages.ReadFeaturedStickers | messages.GetRecentStickers | messages.SaveRecentSticker | messages.ClearRecentStickers | messages.GetArchivedStickers | messages.GetMaskStickers | messages.GetAttachedStickers | messages.SetGameScore | messages.SetInlineGameScore | messages.GetGameHighScores | messages.GetInlineGameHighScores | messages.GetCommonChats | messages.GetAllChats | messages.GetWebPage | messages.ToggleDialogPin | messages.ReorderPinnedDialogs | messages.GetPinnedDialogs | messages.SetBotShippingResults | messages.SetBotPrecheckoutResults | messages.UploadMedia | messages.SendScreenshotNotification | messages.GetFavedStickers | messages.FaveSticker | messages.GetUnreadMentions | messages.ReadMentions | messages.GetRecentLocations | messages.SendMultiMedia | messages.UploadEncryptedFile | messages.SearchStickerSets | messages.GetSplitRanges | messages.MarkDialogUnread | messages.GetDialogUnreadMarks | messages.ClearAllDrafts | messages.UpdatePinnedMessage | messages.SendVote | messages.GetPollResults | messages.GetOnlines | messages.EditChatAbout | messages.EditChatDefaultBannedRights | messages.GetEmojiKeywords | messages.GetEmojiKeywordsDifference | messages.GetEmojiKeywordsLanguages | messages.GetEmojiURL | messages.GetSearchCounters | messages.RequestUrlAuth | messages.AcceptUrlAuth | messages.HidePeerSettingsBar | messages.GetScheduledHistory | messages.GetScheduledMessages | messages.SendScheduledMessages | messages.DeleteScheduledMessages | messages.GetPollVotes | messages.ToggleStickerSets | messages.GetDialogFilters | messages.GetSuggestedDialogFilters | messages.UpdateDialogFilter | messages.UpdateDialogFiltersOrder | messages.GetOldFeaturedStickers | messages.GetReplies | messages.GetDiscussionMessage | messages.ReadDiscussion | messages.UnpinAllMessages | messages.DeleteChat | messages.DeletePhoneCallHistory | messages.CheckHistoryImport | messages.InitHistoryImport | messages.UploadImportedMedia | messages.StartHistoryImport | messages.GetExportedChatInvites | messages.GetExportedChatInvite | messages.EditExportedChatInvite | messages.DeleteRevokedExportedChatInvites | messages.DeleteExportedChatInvite | messages.GetAdminsWithInvites | messages.GetChatInviteImporters | messages.SetHistoryTTL | messages.CheckHistoryImportPeer | messages.SetChatTheme | messages.GetMessageReadParticipants | messages.GetSearchResultsCalendar | messages.GetSearchResultsPositions | messages.HideChatJoinRequest | messages.HideAllChatJoinRequests | messages.ToggleNoForwards | messages.SaveDefaultSendAs | messages.SendReaction | messages.GetMessagesReactions | messages.GetMessageReactionsList | messages.SetChatAvailableReactions | messages.GetAvailableReactions | messages.SetDefaultReaction | messages.TranslateText | updates.GetState | updates.GetDifference | updates.GetChannelDifference | photos.UpdateProfilePhoto | photos.UploadProfilePhoto | photos.DeletePhotos | photos.GetUserPhotos | upload.SaveFilePart | upload.GetFile | upload.SaveBigFilePart | upload.GetWebFile | upload.GetCdnFile | upload.ReuploadCdnFile | upload.GetCdnFileHashes | upload.GetFileHashes diff --git a/src/lib/gramjs/tl/apiTl.js b/src/lib/gramjs/tl/apiTl.js index b9377e8b5..3035db2af 100644 --- a/src/lib/gramjs/tl/apiTl.js +++ b/src/lib/gramjs/tl/apiTl.js @@ -947,9 +947,11 @@ reactionCount#6fb250d1 flags:# chosen:flags.0?true reaction:string count:int = R messageReactions#87b6e36 flags:# min:flags.0?true can_see_list:flags.2?true results:Vector recent_reactons:flags.1?Vector = MessageReactions; messageUserReaction#932844fa user_id:long reaction:string = MessageUserReaction; messages.messageReactionsList#a366923c flags:# count:int reactions:Vector users:Vector next_offset:flags.0?string = messages.MessageReactionsList; -availableReaction#21d7c4b flags:# inactive:flags.0?true reaction:string title:string static_icon:Document appear_animation:Document select_animation:Document activate_animation:Document effect_animation:Document = AvailableReaction; +availableReaction#c077ec01 flags:# inactive:flags.0?true reaction:string title:string static_icon:Document appear_animation:Document select_animation:Document activate_animation:Document effect_animation:Document around_animation:flags.1?Document center_icon:flags.1?Document = AvailableReaction; messages.availableReactionsNotModified#9f071957 = messages.AvailableReactions; messages.availableReactions#768e3aad hash:int reactions:Vector = messages.AvailableReactions; +messages.translateNoResult#67ca4737 = messages.TranslatedText; +messages.translateResultText#a214f7d0 text:string = messages.TranslatedText; ---functions--- initConnection#c1cd5ea9 {X:Type} flags:# api_id:int device_model:string system_version:string app_version:string system_lang_code:string lang_pack:string lang_code:string proxy:flags.0?InputClientProxy params:flags.1?JSONValue query:!X = X; invokeWithLayer#da9b0d0d {X:Type} layer:int query:!X = X; @@ -1083,6 +1085,12 @@ messages.deleteChat#5bd0ee50 chat_id:long = Bool; messages.getMessageReadParticipants#2c6f97b7 peer:InputPeer msg_id:int = Vector; messages.toggleNoForwards#b11eafa2 peer:InputPeer enabled:Bool = Updates; messages.saveDefaultSendAs#ccfddf96 peer:InputPeer send_as:InputPeer = Bool; +messages.sendReaction#25690ce4 flags:# peer:InputPeer msg_id:int reaction:flags.0?string = Updates; +messages.getMessagesReactions#8bba90e6 peer:InputPeer id:Vector = Updates; +messages.getMessageReactionsList#e0ee6b77 flags:# peer:InputPeer id:int reaction:flags.0?string offset:flags.1?string limit:int = messages.MessageReactionsList; +messages.setChatAvailableReactions#14050ea6 peer:InputPeer available_reactions:Vector = Updates; +messages.getAvailableReactions#18dea0ac hash:int = messages.AvailableReactions; +messages.setDefaultReaction#d960c4d4 reaction:string = Bool; updates.getState#edd4882a = updates.State; updates.getDifference#25939651 flags:# pts:int pts_total_limit:flags.0?int date:int qts:int = updates.Difference; updates.getChannelDifference#3173d78 flags:# force:flags.0?true channel:InputChannel filter:ChannelMessagesFilter pts:int limit:int = updates.ChannelDifference; @@ -1096,6 +1104,7 @@ help.getConfig#c4f9186b = Config; help.getNearestDc#1fb33026 = NearestDc; help.getSupport#9cdf08cd = help.Support; help.acceptTermsOfService#ee72f79a id:DataJSON = Bool; +help.getAppConfig#98914110 = JSONValue; help.getCountriesList#735787a8 lang_code:string hash:int = help.CountriesList; channels.readHistory#cc104937 channel:InputChannel max_id:int = Bool; channels.deleteMessages#84c1fd4e channel:InputChannel id:Vector = messages.AffectedMessages; diff --git a/src/lib/gramjs/tl/static/api.json b/src/lib/gramjs/tl/static/api.json index 8ad860617..692a458fc 100644 --- a/src/lib/gramjs/tl/static/api.json +++ b/src/lib/gramjs/tl/static/api.json @@ -190,5 +190,12 @@ "phone.exportGroupCallInvite", "phone.toggleGroupCallStartSubscription", "phone.joinGroupCallPresentation", - "phone.leaveGroupCallPresentation" + "phone.leaveGroupCallPresentation", + "messages.sendReaction", + "messages.getMessagesReactions", + "messages.getMessageReactionsList", + "messages.setChatAvailableReactions", + "messages.getAvailableReactions", + "messages.setDefaultReaction", + "help.getAppConfig" ] diff --git a/src/lib/gramjs/tl/static/api.tl b/src/lib/gramjs/tl/static/api.tl index 7f1f7f43e..b98f3919b 100644 --- a/src/lib/gramjs/tl/static/api.tl +++ b/src/lib/gramjs/tl/static/api.tl @@ -1313,11 +1313,14 @@ messageUserReaction#932844fa user_id:long reaction:string = MessageUserReaction; messages.messageReactionsList#a366923c flags:# count:int reactions:Vector users:Vector next_offset:flags.0?string = messages.MessageReactionsList; -availableReaction#21d7c4b flags:# inactive:flags.0?true reaction:string title:string static_icon:Document appear_animation:Document select_animation:Document activate_animation:Document effect_animation:Document = AvailableReaction; +availableReaction#c077ec01 flags:# inactive:flags.0?true reaction:string title:string static_icon:Document appear_animation:Document select_animation:Document activate_animation:Document effect_animation:Document around_animation:flags.1?Document center_icon:flags.1?Document = AvailableReaction; messages.availableReactionsNotModified#9f071957 = messages.AvailableReactions; messages.availableReactions#768e3aad hash:int reactions:Vector = messages.AvailableReactions; +messages.translateNoResult#67ca4737 = messages.TranslatedText; +messages.translateResultText#a214f7d0 text:string = messages.TranslatedText; + ---functions--- invokeAfterMsg#cb9f372d {X:Type} msg_id:long query:!X = X; @@ -1602,6 +1605,7 @@ messages.getMessageReactionsList#e0ee6b77 flags:# peer:InputPeer id:int reaction messages.setChatAvailableReactions#14050ea6 peer:InputPeer available_reactions:Vector = Updates; messages.getAvailableReactions#18dea0ac hash:int = messages.AvailableReactions; messages.setDefaultReaction#d960c4d4 reaction:string = Bool; +messages.translateText#24ce6dee flags:# peer:flags.0?InputPeer msg_id:flags.0?int text:flags.1?string from_lang:flags.2?string to_lang:string = messages.TranslatedText; updates.getState#edd4882a = updates.State; updates.getDifference#25939651 flags:# pts:int pts_total_limit:flags.0?int date:int qts:int = updates.Difference; @@ -1750,4 +1754,4 @@ stats.getMegagroupStats#dcdf8607 flags:# dark:flags.0?true channel:InputChannel stats.getMessagePublicForwards#5630281b channel:InputChannel msg_id:int offset_rate:int offset_peer:InputPeer offset_id:int limit:int = messages.Messages; stats.getMessageStats#b6e0a3f5 flags:# dark:flags.0?true channel:InputChannel msg_id:int = stats.MessageStats; -// LAYER 136 +// LAYER 137 diff --git a/src/lib/rlottie/RLottie.ts b/src/lib/rlottie/RLottie.ts index c264ca7cd..a3b23071c 100644 --- a/src/lib/rlottie/RLottie.ts +++ b/src/lib/rlottie/RLottie.ts @@ -90,6 +90,7 @@ class RLottie { private params: Params = {}, private onLoad?: () => void, private customColor?: [number, number, number], + private onEnded?: (isDestroyed?: boolean) => void, ) { this.initContainer(); this.initConfig(); @@ -359,6 +360,7 @@ class RLottie { if (delta > 0 && (frameIndex === this.framesCount! - 1 || expectedNextFrameIndex > this.framesCount! - 1)) { if (this.params.noLoop) { this.isAnimating = false; + this.onEnded?.(); return false; } @@ -368,6 +370,7 @@ class RLottie { } else if (delta < 0 && (frameIndex === 0 || expectedNextFrameIndex < 0)) { if (this.params.noLoop) { this.isAnimating = false; + this.onEnded?.(); return false; } diff --git a/src/modules/actions/all.ts b/src/modules/actions/all.ts index 4b2f03d5e..f8e2ae8ac 100644 --- a/src/modules/actions/all.ts +++ b/src/modules/actions/all.ts @@ -23,6 +23,7 @@ import './api/bots'; import './api/settings'; import './api/twoFaSettings'; import './api/payments'; +import './api/reactions'; import './apiUpdaters/initial'; import './apiUpdaters/chats'; diff --git a/src/modules/actions/api/chats.ts b/src/modules/actions/api/chats.ts index 377ba84d5..6910bb098 100644 --- a/src/modules/actions/api/chats.ts +++ b/src/modules/actions/api/chats.ts @@ -1004,6 +1004,22 @@ addReducer('toggleIsProtected', (global, actions, payload) => { void callApi('toggleIsProtected', { chat, isProtected }); }); +addReducer('setChatEnabledReactions', (global, actions, payload) => { + const { chatId, enabledReactions } = payload; + const chat = selectChat(global, chatId); + + if (!chat) return; + + (async () => { + await callApi('setChatEnabledReactions', { + chat, + enabledReactions, + }); + + await loadFullChat(chat); + })(); +}); + async function loadChats(listType: 'active' | 'archived', offsetId?: string, offsetDate?: number) { let global = getGlobal(); diff --git a/src/modules/actions/api/messages.ts b/src/modules/actions/api/messages.ts index 8f9a1208b..f8934c978 100644 --- a/src/modules/actions/api/messages.ts +++ b/src/modules/actions/api/messages.ts @@ -16,7 +16,11 @@ import { } from '../../../api/types'; import { LoadMoreDirection } from '../../../types'; -import { MAX_MEDIA_FILES_FOR_ALBUM, MESSAGE_LIST_SLICE, SERVICE_NOTIFICATIONS_USER_ID } from '../../../config'; +import { + MAX_MEDIA_FILES_FOR_ALBUM, + MESSAGE_LIST_SLICE, + SERVICE_NOTIFICATIONS_USER_ID, +} from '../../../config'; import { IS_IOS } from '../../../util/environment'; import { callApi, cancelApiProgress } from '../../../api/gramjs'; import { diff --git a/src/modules/actions/api/reactions.ts b/src/modules/actions/api/reactions.ts new file mode 100644 index 000000000..02f7c5566 --- /dev/null +++ b/src/modules/actions/api/reactions.ts @@ -0,0 +1,274 @@ +import { addReducer, getGlobal, setGlobal } from '../../../lib/teact/teactn'; +import { callApi } from '../../../api/gramjs'; +import * as mediaLoader from '../../../util/mediaLoader'; +import { ApiAppConfig, ApiMediaFormat } from '../../../api/types'; +import { + selectChat, + selectChatMessage, + selectDefaultReaction, + selectLocalAnimatedEmojiEffectByName, +} from '../../selectors'; +import { addMessageReaction, subtractXForEmojiInteraction } from '../../reducers/reactions'; +import { addUsers, updateChatMessage } from '../../reducers'; +import { buildCollectionByKey, omit } from '../../../util/iteratees'; +import { ANIMATION_LEVEL_MAX } from '../../../config'; + +addReducer('loadAvailableReactions', () => { + (async () => { + const result = await callApi('getAvailableReactions'); + + if (!result) { + return; + } + + // Preload animations + result.forEach((availableReaction) => { + if (availableReaction.aroundAnimation) { + mediaLoader.fetch(`sticker${availableReaction.aroundAnimation.id}`, ApiMediaFormat.Lottie); + } + if (availableReaction.centerIcon) { + mediaLoader.fetch(`sticker${availableReaction.centerIcon.id}`, ApiMediaFormat.Lottie); + } + }); + + setGlobal({ + ...getGlobal(), + availableReactions: result, + }); + })(); +}); + +addReducer('interactWithAnimatedEmoji', (global, actions, payload) => { + const { + emoji, x, y, localEffect, startSize, isReversed, + } = payload!; + + return { + ...global, + activeEmojiInteraction: { + animatedEffect: emoji || localEffect, + x: subtractXForEmojiInteraction(global, x), + y, + startSize, + isReversed, + }, + }; +}); + +addReducer('sendEmojiInteraction', (global, actions, payload) => { + const { + messageId, chatId, emoji, interactions, localEffect, + x, y, startX, startY, startSize, + } = payload!; + + const chat = selectChat(global, chatId); + + if (!chat || (!emoji && !localEffect)) { + return undefined; + } + + void callApi('sendEmojiInteraction', { + chat, + messageId, + emoticon: emoji || selectLocalAnimatedEmojiEffectByName(localEffect), + timestamps: interactions, + }); + + if (!global.activeEmojiInteraction) return undefined; + + return { + ...global, + activeEmojiInteraction: { + ...global.activeEmojiInteraction, + endX: subtractXForEmojiInteraction(global, x), + endY: y, + ...(startX && { x: subtractXForEmojiInteraction(global, startX) }), + ...(startY && { y: startY }), + ...(startSize && { startSize }), + }, + }; +}); + +addReducer('sendDefaultReaction', (global, actions, payload) => { + const { + chatId, messageId, x, y, + } = payload; + const reaction = selectDefaultReaction(global, chatId); + + if (!reaction) return; + + actions.sendReaction({ + chatId, + messageId, + reaction, + x, + y, + }); +}); + +addReducer('sendReaction', (global, actions, payload) => { + const { + chatId, messageId, + }: { messageId: number; chatId: string } = payload; + + let { reaction } = payload; + + const chat = selectChat(global, chatId); + const message = selectChatMessage(global, chatId, messageId); + + if (!chat || !message) { + return undefined; + } + + if (message.reactions?.results?.some((l) => l.reaction === reaction && l.isChosen)) { + reaction = undefined; + } + + void callApi('sendReaction', { chat, messageId, reaction }); + + const { animationLevel } = global.settings.byKey; + + if (animationLevel === ANIMATION_LEVEL_MAX) { + global = { + ...global, + activeReactions: { + ...(reaction ? global.activeReactions : omit(global.activeReactions, [messageId])), + ...(reaction && { + [messageId]: { + reaction, + messageId, + }, + }), + }, + }; + } + + return addMessageReaction(global, chatId, messageId, reaction); +}); + +addReducer('openChat', (global) => { + return { + ...global, + activeReactions: {}, + }; +}); + +addReducer('stopActiveReaction', (global, actions, payload) => { + const { messageId, reaction } = payload; + + if (global.activeReactions[messageId]?.reaction !== reaction) { + return global; + } + + return { + ...global, + activeReactions: omit(global.activeReactions, [messageId]), + }; +}); + +addReducer('setDefaultReaction', (global, actions, payload) => { + const { reaction } = payload; + + (async () => { + const result = await callApi('setDefaultReaction', { reaction }); + + if (!result) { + return; + } + + global = getGlobal(); + setGlobal({ + ...global, + appConfig: { + ...global.appConfig, + defaultReaction: reaction, + } as ApiAppConfig, + }); + })(); +}); + +addReducer('stopActiveEmojiInteraction', (global) => { + return { + ...global, + activeEmojiInteraction: undefined, + }; +}); + +addReducer('loadReactors', (global, actions, payload) => { + const { chatId, messageId, reaction } = payload; + const chat = selectChat(global, chatId); + const message = selectChatMessage(global, chatId, messageId); + if (!chat || !message) { + return; + } + + const offset = message.reactors?.nextOffset; + + (async () => { + const result = await callApi('fetchMessageReactionsList', { + reaction, + chat, + messageId, + offset, + }); + + if (!result) { + return; + } + + global = getGlobal(); + if (result.users?.length) { + global = addUsers(global, buildCollectionByKey(result.users, 'id')); + } + + const { nextOffset, count, reactions } = result; + + setGlobal(updateChatMessage(global, chatId, messageId, { + reactors: { + nextOffset, + count, + reactions: [ + ...(message.reactors?.reactions || []), + ...reactions, + ], + }, + })); + })(); +}); + +addReducer('loadMessageReactions', (global, actions, payload) => { + const { ids, chatId } = payload; + + const chat = selectChat(global, chatId); + + if (!chat) { + return; + } + + callApi('fetchMessageReactions', { ids, chat }); +}); + +addReducer('sendWatchingEmojiInteraction', (global, actions, payload) => { + const { + chatId, emoticon, x, y, startSize, isReversed, + } = payload; + + const chat = selectChat(global, chatId); + + if (!chat || !global.activeEmojiInteraction) { + return undefined; + } + + callApi('sendWatchingEmojiInteraction', { chat, emoticon }); + + return { + ...global, + activeEmojiInteraction: { + ...global.activeEmojiInteraction, + x: subtractXForEmojiInteraction(global, x), + y, + startSize, + isReversed, + }, + }; +}); diff --git a/src/modules/actions/api/settings.ts b/src/modules/actions/api/settings.ts index 3c44cc758..fce7628a3 100644 --- a/src/modules/actions/api/settings.ts +++ b/src/modules/actions/api/settings.ts @@ -602,3 +602,16 @@ addReducer('ensureTimeFormat', (global, actions) => { } })(); }); + +addReducer('loadAppConfig', () => { + (async () => { + const appConfig = await callApi('fetchAppConfig'); + + if (!appConfig) return; + + setGlobal({ + ...getGlobal(), + appConfig, + }); + })(); +}); diff --git a/src/modules/actions/api/symbols.ts b/src/modules/actions/api/symbols.ts index 66e0e27ef..41661fa45 100644 --- a/src/modules/actions/api/symbols.ts +++ b/src/modules/actions/api/symbols.ts @@ -100,6 +100,7 @@ addReducer('loadStickers', (global, actions, payload) => { addReducer('loadAnimatedEmojis', () => { void loadAnimatedEmojis(); + void loadAnimatedEmojiEffects(); }); addReducer('loadSavedGifs', (global) => { @@ -291,6 +292,20 @@ async function loadAnimatedEmojis() { setGlobal(replaceAnimatedEmojis(getGlobal(), { ...set, stickers })); } +async function loadAnimatedEmojiEffects() { + const stickerSet = await callApi('fetchAnimatedEmojiEffects'); + if (!stickerSet) { + return; + } + + const { set, stickers } = stickerSet; + + setGlobal({ + ...getGlobal(), + animatedEmojiEffects: { ...set, stickers }, + }); +} + function unfaveSticker(sticker: ApiSticker) { const global = getGlobal(); diff --git a/src/modules/actions/apiUpdaters/messages.ts b/src/modules/actions/apiUpdaters/messages.ts index 0bfc834ef..76c23e704 100644 --- a/src/modules/actions/apiUpdaters/messages.ts +++ b/src/modules/actions/apiUpdaters/messages.ts @@ -17,7 +17,7 @@ import { deleteChatScheduledMessages, updateThreadUnreadFromForwardedMessage, } from '../../reducers'; -import { GlobalActions, GlobalState } from '../../../global/types'; +import { ActiveEmojiInteraction, GlobalActions, GlobalState } from '../../../global/types'; import { selectChatMessage, selectChatMessages, @@ -39,6 +39,8 @@ import { selectChat, selectIsChatWithBot, selectIsServiceChatReady, + selectLocalAnimatedEmojiEffect, + selectLocalAnimatedEmoji, } from '../../selectors'; import { getMessageContent, isUserId, isMessageLocal } from '../../helpers'; @@ -108,6 +110,26 @@ addReducer('apiUpdate', (global, actions, update: ApiUpdate) => { break; } + case 'updateStartEmojiInteraction': { + const { chatId: currentChatId } = selectCurrentMessageList(global) || {}; + + if (global.activeEmojiInteraction || currentChatId !== update.id) return; + + const localEmoji = selectLocalAnimatedEmoji(global, update.emoji); + + global = { + ...global, + activeEmojiInteraction: { + animatedEffect: localEmoji ? selectLocalAnimatedEmojiEffect(localEmoji) : update.emoji, + messageId: update.messageId, + } as ActiveEmojiInteraction, + }; + + setGlobal(global); + + break; + } + case 'newScheduledMessage': { const { chatId, id, message } = update; @@ -444,6 +466,18 @@ addReducer('apiUpdate', (global, actions, update: ApiUpdate) => { break; } + + case 'updateMessageReactions': { + setGlobal(updateChatMessage( + global, + update.chatId, + update.id, + { + reactions: update.reactions, + }, + )); + break; + } } }); diff --git a/src/modules/actions/ui/messages.ts b/src/modules/actions/ui/messages.ts index 287d5c08f..8a80bed78 100644 --- a/src/modules/actions/ui/messages.ts +++ b/src/modules/actions/ui/messages.ts @@ -642,6 +642,22 @@ addReducer('createServiceNotification', (global, actions, payload) => { }); }); +addReducer('openReactorListModal', (global, actions, payload) => { + const { chatId, messageId } = payload!; + + return { + ...global, + reactorModal: { chatId, messageId }, + }; +}); + +addReducer('closeReactorListModal', (global) => { + return { + ...global, + reactorModal: undefined, + }; +}); + addReducer('openSeenByModal', (global, actions, payload) => { const { chatId, messageId } = payload!; diff --git a/src/modules/helpers/messages.ts b/src/modules/helpers/messages.ts index 76c31d20d..fe95ec89f 100644 --- a/src/modules/helpers/messages.ts +++ b/src/modules/helpers/messages.ts @@ -1,5 +1,5 @@ import { - ApiChat, ApiMessage, ApiMessageEntityTypes, ApiUser, + ApiChat, ApiMessage, ApiMessageEntityTypes, ApiReactions, ApiUser, } from '../../api/types'; import { LangFn } from '../../hooks/useLang'; @@ -271,3 +271,7 @@ export function getMessageContentFilename(message: ApiMessage) { return baseFilename; } + +export function areReactionsEmpty(reactions: ApiReactions) { + return !reactions.results.some((l) => l.count > 0); +} diff --git a/src/modules/reducers/reactions.ts b/src/modules/reducers/reactions.ts new file mode 100644 index 000000000..620d529aa --- /dev/null +++ b/src/modules/reducers/reactions.ts @@ -0,0 +1,82 @@ +import { updateChatMessage } from './messages'; +import { GlobalState } from '../../global/types'; +import { selectChatMessage } from '../selectors'; +import { MIN_SCREEN_WIDTH_FOR_STATIC_LEFT_COLUMN, MIN_SCREEN_WIDTH_FOR_STATIC_RIGHT_COLUMN } from '../../config'; +import { + MIN_LEFT_COLUMN_WIDTH, + SIDE_COLUMN_MAX_WIDTH, +} from '../../components/middle/helpers/calculateMiddleFooterTransforms'; +import { IS_SINGLE_COLUMN_LAYOUT } from '../../util/environment'; +import windowSize from '../../util/windowSize'; + +function getLeftColumnWidth(windowWidth: number) { + if (windowWidth > MIN_SCREEN_WIDTH_FOR_STATIC_RIGHT_COLUMN) { + return Math.min( + Math.max(windowWidth * 0.25, MIN_LEFT_COLUMN_WIDTH), + windowWidth * 0.33, + ); + } + + if (windowWidth > MIN_SCREEN_WIDTH_FOR_STATIC_LEFT_COLUMN) { + return Math.min( + Math.max(windowWidth * 0.33, MIN_LEFT_COLUMN_WIDTH), + windowWidth * 0.4, + ); + } + + return SIDE_COLUMN_MAX_WIDTH; +} + +export function subtractXForEmojiInteraction(global: GlobalState, x: number) { + return x - ((global.isLeftColumnShown && !IS_SINGLE_COLUMN_LAYOUT) + ? global.leftColumnWidth || getLeftColumnWidth(windowSize.get().width) + : 0); +} + +export function addMessageReaction(global: GlobalState, chatId: string, messageId: number, reaction: string) { + const { reactions } = selectChatMessage(global, chatId, messageId) || {}; + + if (!reactions) { + return global; + } + + // Update UI without waiting for server response + let results = reactions.results.map((l) => (l.reaction === reaction + ? { + ...l, + count: l.isChosen ? l.count : l.count + 1, + isChosen: true, + } : (l.isChosen ? { + ...l, + isChosen: false, + count: l.count - 1, + } : l))) + .filter((l) => l.count > 0); + + let { recentReactions } = reactions; + + if (reaction && !results.some((l) => l.reaction === reaction)) { + const { currentUserId } = global; + + results = [...results, { + reaction, + isChosen: true, + count: 1, + }]; + + if (reactions.canSeeList) { + recentReactions = [...(recentReactions || []), { + userId: currentUserId!, + reaction, + }]; + } + } + + return updateChatMessage(global, chatId, messageId, { + reactions: { + ...reactions, + results, + recentReactions, + }, + }); +} diff --git a/src/modules/selectors/messages.ts b/src/modules/selectors/messages.ts index 674e52775..302586e40 100644 --- a/src/modules/selectors/messages.ts +++ b/src/modules/selectors/messages.ts @@ -880,3 +880,27 @@ export function selectSponsoredMessage(global: GlobalState, chatId: string) { return message && message.expiresAt >= Math.round(Date.now() / 1000) ? message : undefined; } + +export function selectDefaultReaction(global: GlobalState, chatId: string) { + if (chatId === SERVICE_NOTIFICATIONS_USER_ID) return undefined; + + const isPrivate = isUserId(chatId); + const defaultReaction = global.appConfig?.defaultReaction; + const { availableReactions } = global; + if (!defaultReaction || !availableReactions?.some( + (l) => l.reaction === defaultReaction && !l.isInactive, + )) { + return undefined; + } + + if (isPrivate) { + return defaultReaction; + } + + const enabledReactions = selectChat(global, chatId)?.fullInfo?.enabledReactions; + if (!enabledReactions?.includes(defaultReaction)) { + return undefined; + } + + return defaultReaction; +} diff --git a/src/modules/selectors/symbols.ts b/src/modules/selectors/symbols.ts index 4d75e690d..c1ae4f681 100644 --- a/src/modules/selectors/symbols.ts +++ b/src/modules/selectors/symbols.ts @@ -43,14 +43,47 @@ export function selectStickersForEmoji(global: GlobalState, emoji: string) { return stickersForEmoji; } +function cleanEmoji(emoji: string) { + // Some emojis (❤️ for example) with a service symbol 'VARIATION SELECTOR-16' are not recognized as animated + return emoji.replace('\ufe0f', ''); +} + export function selectAnimatedEmoji(global: GlobalState, emoji: string) { const { animatedEmojis } = global; if (!animatedEmojis || !animatedEmojis.stickers) { return undefined; } - // Some emojis (❤️ for example) with a service symbol 'VARIATION SELECTOR-16' are not recognized as animated - const cleanedEmoji = emoji.replace('\ufe0f', ''); + const cleanedEmoji = cleanEmoji(emoji); return animatedEmojis.stickers.find((sticker) => sticker.emoji === emoji || sticker.emoji === cleanedEmoji); } + +export function selectAnimatedEmojiEffect(global: GlobalState, emoji: string) { + const { animatedEmojiEffects } = global; + if (!animatedEmojiEffects || !animatedEmojiEffects.stickers) { + return undefined; + } + + const cleanedEmoji = cleanEmoji(emoji); + + return animatedEmojiEffects.stickers.find((sticker) => sticker.emoji === emoji || sticker.emoji === cleanedEmoji); +} + +export function selectAnimatedEmojiSound(global: GlobalState, emoji: string) { + return global?.appConfig?.emojiSounds[cleanEmoji(emoji)]; +} + +export function selectLocalAnimatedEmoji(global: GlobalState, emoji: string) { + const cleanedEmoji = cleanEmoji(emoji); + + return cleanedEmoji === '🍑' ? 'Peach' : (cleanedEmoji === '🍆' ? 'Eggplant' : undefined); +} + +export function selectLocalAnimatedEmojiEffect(emoji: string) { + return emoji === 'Eggplant' ? 'Cumshot' : undefined; +} + +export function selectLocalAnimatedEmojiEffectByName(name: string) { + return name === 'Cumshot' ? '🍆' : undefined; +} diff --git a/src/styles/Telegram T.json b/src/styles/Telegram T.json index e8ffbd808..e18f0102d 100644 --- a/src/styles/Telegram T.json +++ b/src/styles/Telegram T.json @@ -2,7 +2,7 @@ "metadata": { "name": "Telegram T", "lastOpened": 0, - "created": 1637672665426 + "created": 1639657599258 }, "iconSets": [ { @@ -157,13 +157,29 @@ }, { "selection": [ + { + "order": 698, + "id": 48, + "name": "reaction-filled", + "prevSize": 32, + "code": 59796, + "tempChar": "" + }, + { + "order": 695, + "id": 47, + "name": "reactions", + "prevSize": 32, + "code": 59795, + "tempChar": "" + }, { "order": 693, "id": 46, "name": "sidebar", "prevSize": 32, "code": 59794, - "tempChar": "" + "tempChar": "" }, { "order": 690, @@ -171,7 +187,7 @@ "name": "video-stop", "prevSize": 32, "code": 59787, - "tempChar": "" + "tempChar": "" }, { "order": 678, @@ -179,7 +195,7 @@ "name": "speaker", "prevSize": 32, "code": 59777, - "tempChar": "" + "tempChar": "" }, { "order": 679, @@ -187,7 +203,7 @@ "name": "speaker-outline", "prevSize": 32, "code": 59778, - "tempChar": "" + "tempChar": "" }, { "order": 680, @@ -195,7 +211,7 @@ "name": "phone-discard-outline", "prevSize": 32, "code": 59779, - "tempChar": "" + "tempChar": "" }, { "order": 681, @@ -203,7 +219,7 @@ "name": "allow-speak", "prevSize": 32, "code": 59780, - "tempChar": "" + "tempChar": "" }, { "order": 682, @@ -211,7 +227,7 @@ "name": "stop-raising-hand", "prevSize": 32, "code": 59781, - "tempChar": "" + "tempChar": "" }, { "order": 683, @@ -219,7 +235,7 @@ "name": "share-screen", "prevSize": 32, "code": 59782, - "tempChar": "" + "tempChar": "" }, { "order": 684, @@ -227,7 +243,7 @@ "name": "voice-chat", "prevSize": 32, "code": 59783, - "tempChar": "" + "tempChar": "" }, { "order": 689, @@ -235,7 +251,7 @@ "name": "video", "prevSize": 32, "code": 59784, - "tempChar": "" + "tempChar": "" }, { "order": 686, @@ -243,7 +259,7 @@ "name": "noise-suppression", "prevSize": 32, "code": 59785, - "tempChar": "" + "tempChar": "" }, { "order": 688, @@ -251,7 +267,7 @@ "name": "phone-discard", "prevSize": 32, "code": 59786, - "tempChar": "" + "tempChar": "" }, { "order": 667, @@ -259,7 +275,7 @@ "name": "bot-commands-filled", "prevSize": 32, "code": 59775, - "tempChar": "" + "tempChar": "" }, { "order": 664, @@ -267,7 +283,7 @@ "name": "reply-filled", "prevSize": 32, "code": 59776, - "tempChar": "" + "tempChar": "" }, { "order": 656, @@ -275,7 +291,7 @@ "name": "bug", "prevSize": 32, "code": 59774, - "tempChar": "" + "tempChar": "" }, { "order": 619, @@ -283,7 +299,7 @@ "name": "data", "prevSize": 32, "code": 59773, - "tempChar": "" + "tempChar": "" }, { "order": 622, @@ -291,7 +307,7 @@ "name": "darkmode", "prevSize": 32, "code": 59769, - "tempChar": "" + "tempChar": "" }, { "order": 623, @@ -299,7 +315,7 @@ "name": "animations", "prevSize": 32, "code": 59770, - "tempChar": "" + "tempChar": "" }, { "order": 626, @@ -307,7 +323,7 @@ "name": "enter", "prevSize": 32, "code": 59771, - "tempChar": "" + "tempChar": "" }, { "order": 627, @@ -315,7 +331,7 @@ "name": "fontsize", "prevSize": 32, "code": 59772, - "tempChar": "" + "tempChar": "" }, { "order": 630, @@ -323,7 +339,7 @@ "name": "permissions", "prevSize": 32, "code": 59766, - "tempChar": "" + "tempChar": "" }, { "order": 631, @@ -331,7 +347,7 @@ "name": "card", "prevSize": 32, "code": 59767, - "tempChar": "" + "tempChar": "" }, { "order": 634, @@ -339,7 +355,7 @@ "name": "truck", "prevSize": 32, "code": 59768, - "tempChar": "" + "tempChar": "" }, { "order": 663, @@ -347,7 +363,7 @@ "name": "share-filled", "prevSize": 32, "code": 59738, - "tempChar": "" + "tempChar": "" }, { "order": 638, @@ -355,7 +371,7 @@ "name": "bold", "prevSize": 32, "code": 59745, - "tempChar": "" + "tempChar": "" }, { "order": 639, @@ -363,7 +379,7 @@ "name": "bot-command", "prevSize": 32, "code": 59746, - "tempChar": "" + "tempChar": "" }, { "order": 642, @@ -371,7 +387,7 @@ "name": "calendar-filter", "prevSize": 32, "code": 59747, - "tempChar": "" + "tempChar": "" }, { "order": 643, @@ -379,7 +395,7 @@ "name": "comments", "prevSize": 32, "code": 59748, - "tempChar": "" + "tempChar": "" }, { "order": 645, @@ -387,7 +403,7 @@ "name": "comments-sticker", "prevSize": 32, "code": 59749, - "tempChar": "" + "tempChar": "" }, { "order": 646, @@ -395,7 +411,7 @@ "name": "arrow-down", "prevSize": 32, "code": 59750, - "tempChar": "" + "tempChar": "" }, { "order": 668, @@ -403,7 +419,7 @@ "name": "email", "prevSize": 32, "code": 59751, - "tempChar": "" + "tempChar": "" }, { "order": 648, @@ -411,7 +427,7 @@ "name": "italic", "prevSize": 32, "code": 59752, - "tempChar": "" + "tempChar": "" }, { "order": 620, @@ -419,7 +435,7 @@ "name": "link", "prevSize": 32, "code": 59753, - "tempChar": "" + "tempChar": "" }, { "order": 621, @@ -427,7 +443,7 @@ "name": "mention", "prevSize": 32, "code": 59754, - "tempChar": "" + "tempChar": "" }, { "order": 624, @@ -435,7 +451,7 @@ "name": "monospace", "prevSize": 32, "code": 59755, - "tempChar": "" + "tempChar": "" }, { "order": 625, @@ -443,7 +459,7 @@ "name": "next", "prevSize": 32, "code": 59756, - "tempChar": "" + "tempChar": "" }, { "order": 628, @@ -451,7 +467,7 @@ "name": "password-off", "prevSize": 32, "code": 59757, - "tempChar": "" + "tempChar": "" }, { "order": 629, @@ -459,7 +475,7 @@ "name": "pin-list", "prevSize": 32, "code": 59758, - "tempChar": "" + "tempChar": "" }, { "order": 632, @@ -467,7 +483,7 @@ "name": "previous", "prevSize": 32, "code": 59759, - "tempChar": "" + "tempChar": "" }, { "order": 633, @@ -475,7 +491,7 @@ "name": "replace", "prevSize": 32, "code": 59760, - "tempChar": "" + "tempChar": "" }, { "order": 636, @@ -483,7 +499,7 @@ "name": "schedule", "prevSize": 32, "code": 59761, - "tempChar": "" + "tempChar": "" }, { "order": 691, @@ -491,7 +507,7 @@ "name": "strikethrough", "prevSize": 32, "code": 59762, - "tempChar": "" + "tempChar": "" }, { "order": 692, @@ -499,7 +515,7 @@ "name": "underlined", "prevSize": 32, "code": 59763, - "tempChar": "" + "tempChar": "" }, { "order": 641, @@ -507,7 +523,7 @@ "name": "zoom-in", "prevSize": 32, "code": 59764, - "tempChar": "" + "tempChar": "" }, { "order": 649, @@ -515,20 +531,60 @@ "name": "zoom-out", "prevSize": 32, "code": 59765, - "tempChar": "" + "tempChar": "" } ], "id": 2, "metadata": { "name": "Untitled Set", "importSize": { - "width": 768, - "height": 768 + "width": 72, + "height": 72 } }, "height": 1024, "prevSize": 32, "icons": [ + { + "id": 48, + "paths": [ + "M829.156 277.333c12.8 0 25.6 27.022 28.444 41.244 15.644 89.6 41.244 147.911 46.933 238.933 2.844 34.133-28.444 146.489-49.778 167.822 2.844 1.422-21.333 25.6-69.689 73.956-28.444 28.444-65.422 41.244-102.4 42.667 5.689-4.267 11.378-8.533 17.067-14.222 48.356-48.356 75.378-78.222 78.222-86.756 21.333-21.333 51.2-76.8 48.356-110.933-5.689-75.378-28.444-159.289-45.511-236.089-1.422-9.956-1.422-24.178-2.844-39.822v-7.111c-1.422-22.756-2.844-42.667-2.844-42.667 1.422-5.689 4.267-11.378 8.533-15.644 5.689-5.689 14.222-9.956 21.333-9.956 0-1.422 11.378-1.422 24.178-1.422zM493.511 204.8c17.067-17.067 44.089-17.067 61.156 0 11.378 11.378 22.756 21.333 32.711 32.711 7.111 46.933 14.222 93.867 9.956 89.6-38.4-39.822-76.8-75.378-112.356-108.089 1.422-5.689 4.267-9.956 8.533-14.222z", + "M140.8 553.244l240.356 237.511c66.844 66.844 189.156 96.711 261.689 22.756 48.356-48.356 75.378-78.222 78.222-86.756 21.333-21.333 51.2-76.8 48.356-110.933-5.689-91.022-44.089-210.489-61.156-301.511-2.844-14.222-14.222-41.244-28.444-41.244-11.378 0-24.178 0-24.178 0-8.533 0-15.644 4.267-21.333 9.956-4.267 4.267-7.111 9.956-8.533 15.644 0 0 11.378 103.822 12.8 116.622s-7.111 17.067-17.067 8.533c-46.933-46.933-118.044-116.622-210.489-204.8-17.067-17.067-44.089-17.067-61.156 0s-17.067 44.089 0 61.156l142.222 142.222-19.911 19.911-190.578-193.422c-17.067-17.067-44.089-17.067-61.156 0s-17.067 44.089 0 61.156l193.422 193.422-19.911 19.911-173.511-173.511c-17.067-17.067-44.089-17.067-61.156 0s-17.067 44.089 0 61.156l173.511 172.089-19.911 21.333-112.356-112.356c-17.067-17.067-44.089-17.067-61.156 0-15.644 17.067-15.644 44.089 1.422 61.156z" + ], + "attrs": [ + {}, + {} + ], + "grid": 24, + "tags": [ + "reaction-filled" + ], + "isMulticolor": false, + "isMulticolor2": false + }, + { + "id": 47, + "paths": [ + "M541.257 911.848c-7.314 0-14.629 0-20.724-1.219-65.829-6.095-134.095-37.79-181.638-85.333l-249.905-249.905c-19.505-20.724-30.476-46.324-30.476-75.581 0-28.038 10.971-54.857 30.476-74.362 3.657-3.657 7.314-7.314 12.19-9.752-13.41-17.067-20.724-37.79-21.943-59.733v-3.657c0-29.257 10.971-56.076 30.476-75.581 9.752-9.752 20.724-17.067 32.914-23.162 0-1.219 0-2.438 0-4.876v-3.657c0-29.257 10.971-56.076 30.476-75.581 19.505-20.724 46.324-31.695 75.581-31.695 18.286 0 35.352 4.876 51.2 13.41 2.438-3.657 4.876-7.314 8.533-9.752l2.438-3.657c20.724-20.724 47.543-31.695 75.581-31.695s54.857 10.971 75.581 31.695c46.324 45.105 87.771 84.114 123.124 119.467 0-17.067 1.219-19.505 3.657-24.381l13.41-23.162 2.438-2.438c18.286-18.286 40.229-29.257 63.39-30.476 3.657 0 7.314 0 12.19 0s10.971 0 17.067-1.219c26.819 0 51.2 13.41 68.267 39.010 12.19 18.286 19.505 41.448 21.943 54.857 6.095 34.133 14.629 71.924 24.381 112.152 15.848 68.267 34.133 146.286 39.010 209.676 2.438 54.857-36.571 128-65.829 160.914-14.629 20.724-45.105 53.638-86.552 95.086s-96.305 63.39-157.257 64.61zM248.686 199.924c-12.19 0-23.162 4.876-31.695 13.41s-13.41 19.505-13.41 31.695v2.438c0 4.876 1.219 9.752 3.657 15.848l17.067 39.010-43.886 4.876c-9.752 1.219-19.505 4.876-26.819 13.41-8.533 8.533-13.41 19.505-13.41 31.695v2.438c0 10.971 4.876 20.724 13.41 29.257l62.171 63.39-60.952 9.752c-8.533 1.219-17.067 6.095-23.162 12.19-8.533 8.533-13.41 19.505-13.41 31.695s4.876 23.162 13.41 31.695l249.905 248.686c37.79 37.79 91.429 63.39 143.848 68.267 35.352 2.438 87.771-2.438 129.219-45.105 57.295-58.514 76.8-82.895 80.457-88.99l2.438-3.657c24.381-25.6 52.419-85.333 49.981-115.81-3.657-59.733-21.943-134.095-36.571-198.705-9.752-42.667-19.505-81.676-25.6-117.029-1.219-8.533-6.095-21.943-12.19-30.476s-12.19-12.19-15.848-12.19c0 0 0 0 0 0-6.095 0-10.971 0-15.848 1.219h-4.876c-1.219 0-3.657 0-4.876 0-6.095 0-13.41 2.438-21.943 9.752l-4.876 8.533c1.219 12.19 2.438 35.352 3.657 63.39l6.095 92.648-57.295-56.076c-46.324-46.324-107.276-104.838-180.419-175.543-18.286-18.286-47.543-17.067-63.39 0l-1.219 1.219c-4.876 6.095-8.533 13.41-10.971 21.943l-9.752 60.952-51.2-52.419c-8.533-8.533-19.505-13.41-31.695-13.41z", + "M753.371 917.943c-12.19 0-24.381-7.314-28.038-19.505-6.095-15.848 2.438-32.914 18.286-39.010s30.476-15.848 42.667-28.038c58.514-59.733 70.705-76.8 73.143-81.676l1.219-4.876 4.876-4.876c18.286-19.505 42.667-68.267 41.448-92.648-3.657-56.076-19.505-125.562-34.133-191.39-9.752-41.448-18.286-81.676-24.381-118.248-1.219-4.876-3.657-13.41-7.314-18.286-9.752-13.41-6.095-32.914 7.314-42.667s32.914-6.095 42.667 7.314c9.752 14.629 15.848 32.914 17.067 42.667 6.095 34.133 14.629 73.143 24.381 114.59 15.848 69.486 31.695 140.19 36.571 201.143 2.438 45.105-29.257 106.057-54.857 135.314-6.095 10.971-24.381 34.133-82.895 93.867-18.286 19.505-40.229 34.133-64.61 42.667-7.314 2.438-10.971 3.657-13.41 3.657zM856.99 749.714c0 0 0 0 0 0v0z", + "M314.514 605.867c-6.095 0-13.41-2.438-18.286-7.314l-146.286-158.476c-8.533-9.752-8.533-25.6 1.219-34.133s25.6-8.533 34.133 1.219l146.286 158.476c8.533 9.752 8.533 25.6-1.219 34.133-4.876 4.876-10.971 6.095-15.848 6.095z", + "M412.038 520.533c-6.095 0-12.19-2.438-17.067-7.314l-195.048-195.048c-9.752-9.752-9.752-24.381 0-34.133s24.381-9.752 34.133 0l195.048 195.048c9.752 9.752 9.752 24.381 0 34.133-4.876 6.095-10.971 7.314-17.067 7.314z", + "M497.371 423.010c-6.095 0-12.19-2.438-17.067-7.314l-195.048-195.048c-9.752-9.752-9.752-24.381 0-34.133s24.381-9.752 34.133 0l195.048 195.048c9.752 9.752 9.752 24.381 0 34.133-4.876 6.095-10.971 7.314-17.067 7.314z" + ], + "attrs": [ + {}, + {}, + {}, + {}, + {} + ], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 24, + "tags": [ + "reactions" + ] + }, { "id": 46, "paths": [ @@ -2811,7 +2867,7 @@ "name": "select", "prevSize": 32, "code": 59744, - "tempChar": "" + "tempChar": "" }, { "order": 480, @@ -2819,7 +2875,7 @@ "name": "folder", "prevSize": 32, "code": 59667, - "tempChar": "" + "tempChar": "" }, { "order": 481, @@ -2827,7 +2883,7 @@ "name": "bots", "prevSize": 32, "code": 59669, - "tempChar": "" + "tempChar": "" }, { "order": 482, @@ -2835,7 +2891,7 @@ "name": "calendar", "prevSize": 32, "code": 59670, - "tempChar": "" + "tempChar": "" }, { "order": 483, @@ -2843,7 +2899,7 @@ "name": "cloud-download", "prevSize": 32, "code": 59671, - "tempChar": "" + "tempChar": "" }, { "order": 484, @@ -2851,7 +2907,7 @@ "name": "colorize", "prevSize": 32, "code": 59672, - "tempChar": "" + "tempChar": "" }, { "order": 651, @@ -2859,7 +2915,7 @@ "name": "forward", "prevSize": 32, "code": 59687, - "tempChar": "" + "tempChar": "" }, { "order": 650, @@ -2867,7 +2923,7 @@ "name": "reply", "prevSize": 32, "code": 59719, - "tempChar": "" + "tempChar": "" }, { "order": 487, @@ -2875,7 +2931,7 @@ "name": "help", "prevSize": 32, "code": 59690, - "tempChar": "" + "tempChar": "" }, { "order": 488, @@ -2883,7 +2939,7 @@ "name": "info", "prevSize": 32, "code": 59691, - "tempChar": "" + "tempChar": "" }, { "order": 489, @@ -2891,7 +2947,7 @@ "name": "info-filled", "prevSize": 32, "code": 59675, - "tempChar": "" + "tempChar": "" }, { "order": 490, @@ -2899,7 +2955,7 @@ "name": "delete-filled", "prevSize": 32, "code": 59676, - "tempChar": "" + "tempChar": "" }, { "order": 491, @@ -2907,7 +2963,7 @@ "name": "delete", "prevSize": 32, "code": 59677, - "tempChar": "" + "tempChar": "" }, { "order": 492, @@ -2915,7 +2971,7 @@ "name": "edit", "prevSize": 32, "code": 59683, - "tempChar": "" + "tempChar": "" }, { "order": 493, @@ -2923,7 +2979,7 @@ "name": "new-chat-filled", "prevSize": 32, "code": 59705, - "tempChar": "" + "tempChar": "" }, { "order": 494, @@ -2931,7 +2987,7 @@ "name": "send", "prevSize": 32, "code": 59722, - "tempChar": "" + "tempChar": "" }, { "order": 495, @@ -2939,7 +2995,7 @@ "name": "send-outline", "prevSize": 32, "code": 59723, - "tempChar": "" + "tempChar": "" }, { "order": 496, @@ -2947,7 +3003,7 @@ "name": "add-user-filled", "prevSize": 32, "code": 59652, - "tempChar": "" + "tempChar": "" }, { "order": 497, @@ -2955,7 +3011,7 @@ "name": "add-user", "prevSize": 32, "code": 59653, - "tempChar": "" + "tempChar": "" }, { "order": 498, @@ -2963,7 +3019,7 @@ "name": "delete-user", "prevSize": 32, "code": 59678, - "tempChar": "" + "tempChar": "" }, { "order": 499, @@ -2971,7 +3027,7 @@ "name": "microphone", "prevSize": 32, "code": 59701, - "tempChar": "" + "tempChar": "" }, { "order": 500, @@ -2979,7 +3035,7 @@ "name": "microphone-alt", "prevSize": 32, "code": 59707, - "tempChar": "" + "tempChar": "" }, { "order": 501, @@ -2987,7 +3043,7 @@ "name": "poll", "prevSize": 32, "code": 59704, - "tempChar": "" + "tempChar": "" }, { "order": 502, @@ -2995,7 +3051,7 @@ "name": "revote", "prevSize": 32, "code": 59706, - "tempChar": "" + "tempChar": "" }, { "order": 503, @@ -3003,7 +3059,7 @@ "name": "photo", "prevSize": 32, "code": 59712, - "tempChar": "" + "tempChar": "" }, { "order": 504, @@ -3011,7 +3067,7 @@ "name": "document", "prevSize": 32, "code": 59679, - "tempChar": "" + "tempChar": "" }, { "order": 505, @@ -3019,7 +3075,7 @@ "name": "camera", "prevSize": 32, "code": 59662, - "tempChar": "" + "tempChar": "" }, { "order": 506, @@ -3027,7 +3083,7 @@ "name": "camera-add", "prevSize": 32, "code": 59663, - "tempChar": "" + "tempChar": "" }, { "order": 507, @@ -3035,7 +3091,7 @@ "name": "logout", "prevSize": 32, "code": 59698, - "tempChar": "" + "tempChar": "" }, { "order": 508, @@ -3043,7 +3099,7 @@ "name": "saved-messages", "prevSize": 32, "code": 59720, - "tempChar": "" + "tempChar": "" }, { "order": 509, @@ -3051,7 +3107,7 @@ "name": "settings", "prevSize": 32, "code": 59726, - "tempChar": "" + "tempChar": "" }, { "order": 652, @@ -3059,7 +3115,7 @@ "name": "phone", "prevSize": 32, "code": 59711, - "tempChar": "" + "tempChar": "" }, { "order": 653, @@ -3067,7 +3123,7 @@ "name": "attach", "prevSize": 32, "code": 59657, - "tempChar": "" + "tempChar": "" }, { "order": 512, @@ -3075,7 +3131,7 @@ "name": "copy", "prevSize": 32, "code": 59674, - "tempChar": "" + "tempChar": "" }, { "order": 513, @@ -3083,7 +3139,7 @@ "name": "channel", "prevSize": 32, "code": 59665, - "tempChar": "" + "tempChar": "" }, { "order": 514, @@ -3091,7 +3147,7 @@ "name": "group", "prevSize": 32, "code": 59689, - "tempChar": "" + "tempChar": "" }, { "order": 515, @@ -3099,7 +3155,7 @@ "name": "user", "prevSize": 32, "code": 59737, - "tempChar": "" + "tempChar": "" }, { "order": 516, @@ -3107,7 +3163,7 @@ "name": "non-contacts", "prevSize": 32, "code": 59688, - "tempChar": "" + "tempChar": "" }, { "order": 517, @@ -3115,7 +3171,7 @@ "name": "active-sessions", "prevSize": 32, "code": 59650, - "tempChar": "" + "tempChar": "" }, { "order": 518, @@ -3123,7 +3179,7 @@ "name": "admin", "prevSize": 32, "code": 59654, - "tempChar": "" + "tempChar": "" }, { "order": 519, @@ -3131,7 +3187,7 @@ "name": "download", "prevSize": 32, "code": 59681, - "tempChar": "" + "tempChar": "" }, { "order": 520, @@ -3139,7 +3195,7 @@ "name": "location", "prevSize": 32, "code": 59696, - "tempChar": "" + "tempChar": "" }, { "order": 521, @@ -3147,7 +3203,7 @@ "name": "stop", "prevSize": 32, "code": 59730, - "tempChar": "" + "tempChar": "" }, { "order": 523, @@ -3155,7 +3211,7 @@ "name": "archive", "prevSize": 32, "code": 59656, - "tempChar": "" + "tempChar": "" }, { "order": 524, @@ -3163,7 +3219,7 @@ "name": "unarchive", "prevSize": 32, "code": 59731, - "tempChar": "" + "tempChar": "" }, { "order": 525, @@ -3171,7 +3227,7 @@ "name": "readchats", "prevSize": 32, "code": 59699, - "tempChar": "" + "tempChar": "" }, { "order": 526, @@ -3179,7 +3235,7 @@ "name": "unread", "prevSize": 32, "code": 59735, - "tempChar": "" + "tempChar": "" }, { "order": 654, @@ -3187,7 +3243,7 @@ "name": "message", "prevSize": 32, "code": 59700, - "tempChar": "" + "tempChar": "" }, { "order": 659, @@ -3195,7 +3251,7 @@ "name": "lock", "prevSize": 32, "code": 59697, - "tempChar": "" + "tempChar": "" }, { "order": 529, @@ -3203,7 +3259,7 @@ "name": "unlock", "prevSize": 32, "code": 59732, - "tempChar": "" + "tempChar": "" }, { "order": 530, @@ -3211,7 +3267,7 @@ "name": "mute", "prevSize": 32, "code": 59703, - "tempChar": "" + "tempChar": "" }, { "order": 531, @@ -3219,7 +3275,7 @@ "name": "unmute", "prevSize": 32, "code": 59733, - "tempChar": "" + "tempChar": "" }, { "order": 532, @@ -3227,7 +3283,7 @@ "name": "pin", "prevSize": 32, "code": 59713, - "tempChar": "" + "tempChar": "" }, { "order": 533, @@ -3235,7 +3291,7 @@ "name": "unpin", "prevSize": 32, "code": 59734, - "tempChar": "" + "tempChar": "" }, { "order": 534, @@ -3243,7 +3299,7 @@ "name": "smallscreen", "prevSize": 32, "code": 59742, - "tempChar": "" + "tempChar": "" }, { "order": 535, @@ -3251,7 +3307,7 @@ "name": "fullscreen", "prevSize": 32, "code": 59743, - "tempChar": "" + "tempChar": "" }, { "order": 536, @@ -3259,7 +3315,7 @@ "name": "large-pause", "prevSize": 32, "code": 59694, - "tempChar": "" + "tempChar": "" }, { "order": 537, @@ -3267,7 +3323,7 @@ "name": "large-play", "prevSize": 32, "code": 59695, - "tempChar": "" + "tempChar": "" }, { "order": 538, @@ -3275,7 +3331,7 @@ "name": "pause", "prevSize": 32, "code": 59709, - "tempChar": "" + "tempChar": "" }, { "order": 539, @@ -3283,7 +3339,7 @@ "name": "play", "prevSize": 32, "code": 59715, - "tempChar": "" + "tempChar": "" }, { "order": 540, @@ -3291,7 +3347,7 @@ "name": "channelviews", "prevSize": 32, "code": 59666, - "tempChar": "" + "tempChar": "" }, { "order": 541, @@ -3299,7 +3355,7 @@ "name": "message-succeeded", "prevSize": 32, "code": 59648, - "tempChar": "" + "tempChar": "" }, { "order": 657, @@ -3307,7 +3363,7 @@ "name": "message-read", "prevSize": 32, "code": 59649, - "tempChar": "" + "tempChar": "" }, { "order": 543, @@ -3315,7 +3371,7 @@ "name": "message-pending", "prevSize": 32, "code": 59724, - "tempChar": "" + "tempChar": "" }, { "order": 544, @@ -3323,7 +3379,7 @@ "name": "message-failed", "prevSize": 32, "code": 59725, - "tempChar": "" + "tempChar": "" }, { "order": 545, @@ -3331,7 +3387,7 @@ "name": "favorite", "prevSize": 32, "code": 59710, - "tempChar": "" + "tempChar": "" }, { "order": 546, @@ -3339,7 +3395,7 @@ "name": "keyboard", "prevSize": 32, "code": 59716, - "tempChar": "" + "tempChar": "" }, { "order": 547, @@ -3347,7 +3403,7 @@ "name": "delete-left", "prevSize": 32, "code": 59717, - "tempChar": "" + "tempChar": "" }, { "order": 548, @@ -3355,7 +3411,7 @@ "name": "recent", "prevSize": 32, "code": 59718, - "tempChar": "" + "tempChar": "" }, { "order": 549, @@ -3363,7 +3419,7 @@ "name": "gifs", "prevSize": 32, "code": 59727, - "tempChar": "" + "tempChar": "" }, { "order": 550, @@ -3371,7 +3427,7 @@ "name": "stickers", "prevSize": 32, "code": 59739, - "tempChar": "" + "tempChar": "" }, { "order": 551, @@ -3379,7 +3435,7 @@ "name": "smile", "prevSize": 32, "code": 59728, - "tempChar": "" + "tempChar": "" }, { "order": 552, @@ -3387,7 +3443,7 @@ "name": "animals", "prevSize": 32, "code": 59655, - "tempChar": "" + "tempChar": "" }, { "order": 553, @@ -3395,7 +3451,7 @@ "name": "eats", "prevSize": 32, "code": 59682, - "tempChar": "" + "tempChar": "" }, { "order": 554, @@ -3403,7 +3459,7 @@ "name": "sport", "prevSize": 32, "code": 59729, - "tempChar": "" + "tempChar": "" }, { "order": 555, @@ -3411,7 +3467,7 @@ "name": "car", "prevSize": 32, "code": 59664, - "tempChar": "" + "tempChar": "" }, { "order": 556, @@ -3419,7 +3475,7 @@ "name": "lamp", "prevSize": 32, "code": 59692, - "tempChar": "" + "tempChar": "" }, { "order": 557, @@ -3427,7 +3483,7 @@ "name": "language", "prevSize": 32, "code": 59693, - "tempChar": "" + "tempChar": "" }, { "order": 558, @@ -3435,7 +3491,7 @@ "name": "flag", "prevSize": 32, "code": 59686, - "tempChar": "" + "tempChar": "" }, { "order": 559, @@ -3443,7 +3499,7 @@ "name": "more", "prevSize": 32, "code": 59702, - "tempChar": "" + "tempChar": "" }, { "order": 560, @@ -3451,7 +3507,7 @@ "name": "search", "prevSize": 32, "code": 59721, - "tempChar": "" + "tempChar": "" }, { "order": 561, @@ -3459,7 +3515,7 @@ "name": "remove", "prevSize": 32, "code": 59740, - "tempChar": "" + "tempChar": "" }, { "order": 562, @@ -3467,7 +3523,7 @@ "name": "add", "prevSize": 32, "code": 59651, - "tempChar": "" + "tempChar": "" }, { "order": 563, @@ -3475,7 +3531,7 @@ "name": "check", "prevSize": 32, "code": 59668, - "tempChar": "" + "tempChar": "" }, { "order": 564, @@ -3483,7 +3539,7 @@ "name": "close", "prevSize": 32, "code": 59673, - "tempChar": "" + "tempChar": "" }, { "order": 610, @@ -3491,7 +3547,7 @@ "name": "arrow-left", "prevSize": 32, "code": 59661, - "tempChar": "" + "tempChar": "" }, { "order": 566, @@ -3499,7 +3555,7 @@ "name": "arrow-right", "prevSize": 32, "code": 59708, - "tempChar": "" + "tempChar": "" }, { "order": 567, @@ -3507,7 +3563,7 @@ "name": "down", "prevSize": 32, "code": 59680, - "tempChar": "" + "tempChar": "" }, { "order": 568, @@ -3515,7 +3571,7 @@ "name": "up", "prevSize": 32, "code": 59736, - "tempChar": "" + "tempChar": "" }, { "order": 569, @@ -3523,7 +3579,7 @@ "name": "eye-closed", "prevSize": 32, "code": 59685, - "tempChar": "" + "tempChar": "" }, { "order": 570, @@ -3531,7 +3587,7 @@ "name": "eye", "prevSize": 32, "code": 59684, - "tempChar": "" + "tempChar": "" }, { "order": 571, @@ -3539,7 +3595,7 @@ "name": "muted-chat", "prevSize": 32, "code": 59741, - "tempChar": "" + "tempChar": "" }, { "order": 572, @@ -3547,7 +3603,7 @@ "name": "avatar-archived-chats", "prevSize": 32, "code": 59658, - "tempChar": "" + "tempChar": "" }, { "order": 573, @@ -3555,7 +3611,7 @@ "name": "avatar-deleted-account", "prevSize": 32, "code": 59659, - "tempChar": "" + "tempChar": "" }, { "order": 574, @@ -3563,7 +3619,7 @@ "name": "avatar-saved-messages", "prevSize": 32, "code": 59660, - "tempChar": "" + "tempChar": "" }, { "order": 575, @@ -3571,7 +3627,7 @@ "name": "pinned-chat", "prevSize": 32, "code": 59714, - "tempChar": "" + "tempChar": "" } ], "prevSize": 32, diff --git a/src/styles/_variables.scss b/src/styles/_variables.scss index 72ecc8983..e7befe130 100644 --- a/src/styles/_variables.scss +++ b/src/styles/_variables.scss @@ -56,6 +56,11 @@ $color-user-6: #ee7aae; $color-user-7: #6ec9cb; $color-user-8: #faa774; +$color-message-reaction: #ebf3fd; +$color-message-reaction-hover: #c5def9; +$color-message-reaction-own: #cef0ba; +$color-message-reaction-own-hover: #b5e0a4; + :root { --color-background: #{$color-white}; --color-background-selected: #f4f4f5; @@ -116,6 +121,11 @@ $color-user-8: #faa774; --color-accent-own: #{$color-text-green}; --color-message-meta-own: #{$color-text-green}; + --color-message-reaction: $color-message-reaction; + --color-message-reaction-hover: $color-message-reaction-hover; + --color-message-reaction-own: $color-message-reaction-own; + --color-message-reaction-hover-own: $color-message-reaction-own-hover; + --color-reply-hover: #{blend-normal(rgba($color-text-secondary, 0.08), $color-white)}; --color-reply-active: #{blend-normal(rgba($color-text-secondary, 0.16), $color-white)}; --color-reply-own-hover: #{blend-normal(rgba($color-text-green, 0.12), $color-light-green)}; diff --git a/src/styles/icons.scss b/src/styles/icons.scss index 7668eb52f..82ab7bd23 100644 --- a/src/styles/icons.scss +++ b/src/styles/icons.scss @@ -50,6 +50,12 @@ .icon-volume-3:before { content: "\e991"; } +.icon-reaction-filled:before { + content: "\e994"; +} +.icon-reactions:before { + content: "\e993"; +} .icon-sidebar:before { content: "\e992"; } diff --git a/src/styles/themes.json b/src/styles/themes.json index 9610f2362..18103b19c 100644 --- a/src/styles/themes.json +++ b/src/styles/themes.json @@ -38,5 +38,9 @@ "--color-code-own": ["#3c7940", "#FFFFFF"], "--color-code-bg": ["#70757914", "#ffffff26"], "--color-code-own-bg": ["#70757914", "#ffffff26"], - "--color-composer-button": ["#707579CC", "#AAAAAACC"] + "--color-composer-button": ["#707579CC", "#AAAAAACC"], + "--color-message-reaction": ["#ebf3fd", "#2b2a35"], + "--color-message-reaction-hover": ["#c5def9", "#343147"], + "--color-message-reaction-own": ["#cef0ba", "#7a68ca"], + "--color-message-reaction-hover-own": ["#b5e0a4", "#7567bc"] } diff --git a/src/types/index.ts b/src/types/index.ts index deaa7d56e..0663b5fb6 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -201,6 +201,7 @@ export enum SettingsScreens { TwoFaRecoveryEmail, TwoFaRecoveryEmailCode, TwoFaCongratulations, + QuickReaction, } export type StickerSetOrRecent = Pick = { Down: true, @@ -32,7 +33,9 @@ function isTextBox(target: EventTarget | null) { return inputTypes.indexOf(type.toLowerCase()) > -1; } -const getTouchY = (e: WheelEvent | TouchEvent) => ('changedTouches' in e ? e.changedTouches[0].clientY : 0); +export const getTouchY = (e: WheelEvent | TouchEvent | React.WheelEvent | React.TouchEvent) => { + return ('changedTouches' in e ? e.changedTouches[0].clientY : 0); +}; const preventDefault = (e: WheelEvent | TouchEvent) => { const deltaY = 'deltaY' in e ? e.deltaY : getTouchY(e); @@ -46,6 +49,7 @@ const preventDefault = (e: WheelEvent | TouchEvent) => { // Prevent bottom overscroll || (scrollLockEl.scrollTop >= (scrollLockEl.scrollHeight - scrollLockEl.offsetHeight) && deltaY >= 0) ) { + if (excludedClosestSelector && (e.target as HTMLElement).closest(excludedClosestSelector)) return; e.preventDefault(); } }; @@ -56,8 +60,9 @@ function preventDefaultForScrollKeys(e: KeyboardEvent) { } } -export function disableScrolling(el?: HTMLElement | null) { +export function disableScrolling(el?: HTMLElement | null, _excludedClosestSelector?: string) { scrollLockEl = el; + excludedClosestSelector = _excludedClosestSelector; // Disable scrolling in Chrome document.addEventListener('wheel', preventDefault, { passive: false }); document.addEventListener('touchmove', preventDefault, { passive: false }); @@ -66,6 +71,7 @@ export function disableScrolling(el?: HTMLElement | null) { export function enableScrolling() { scrollLockEl = undefined; + excludedClosestSelector = undefined; document.removeEventListener('wheel', preventDefault); // Enable scrolling in Chrome document.removeEventListener('touchmove', preventDefault); // eslint-disable-next-line no-null/no-null