Introduce Reactions and Animated Emoji Interactions (#1583)
This commit is contained in:
parent
e6a2f4b2b6
commit
d3d9d440a6
5
src/@types/global.d.ts
vendored
5
src/@types/global.d.ts
vendored
@ -18,6 +18,11 @@ declare namespace React {
|
||||
interface VideoHTMLAttributes {
|
||||
srcObject?: MediaStream;
|
||||
}
|
||||
|
||||
interface MouseEvent {
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
}
|
||||
}
|
||||
|
||||
type AnyLiteral = Record<string, any>;
|
||||
|
||||
46
src/api/gramjs/apiBuilders/appConfig.ts
Normal file
46
src/api/gramjs/apiBuilders/appConfig.ts
Normal file
@ -0,0 +1,46 @@
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
import BigInt from 'big-integer';
|
||||
import localDb from '../localDb';
|
||||
import { Api as GramJs } from '../../../lib/gramjs';
|
||||
import { ApiAppConfig } from '../../types';
|
||||
import { buildJson } from './misc';
|
||||
|
||||
type GramJsAppConfig = {
|
||||
emojies_sounds: Record<string, {
|
||||
id: string;
|
||||
access_hash: string;
|
||||
file_reference_base64: string;
|
||||
}>;
|
||||
emojies_send_dice: string[];
|
||||
groupcall_video_participants_max: number;
|
||||
reactions_default: string;
|
||||
reactions_uniq_max: number;
|
||||
};
|
||||
|
||||
function buildEmojiSounds(appConfig: GramJsAppConfig) {
|
||||
const { emojies_sounds } = appConfig;
|
||||
return Object.keys(emojies_sounds).reduce((acc: Record<string, string>, key) => {
|
||||
const l = emojies_sounds[key];
|
||||
localDb.documents[l.id] = new GramJs.Document({
|
||||
id: BigInt(l.id),
|
||||
accessHash: BigInt(l.access_hash),
|
||||
dcId: 1,
|
||||
mimeType: 'audio/ogg',
|
||||
fileReference: Buffer.from(atob(l.file_reference_base64
|
||||
.replace(/-/g, '+')
|
||||
.replace(/_/g, '/'))),
|
||||
} as GramJs.Document);
|
||||
|
||||
acc[key] = l.id;
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
export function buildApiConfig(json: GramJs.TypeJSONValue): ApiAppConfig {
|
||||
const appConfig = buildJson(json) as GramJsAppConfig;
|
||||
|
||||
return {
|
||||
emojiSounds: buildEmojiSounds(appConfig),
|
||||
defaultReaction: appConfig.reactions_default,
|
||||
};
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -22,8 +22,12 @@ import {
|
||||
ApiThreadInfo,
|
||||
ApiInvoice,
|
||||
ApiGroupCall,
|
||||
ApiUser,
|
||||
ApiReactions,
|
||||
ApiReactionCount,
|
||||
ApiUserReaction,
|
||||
ApiAvailableReaction,
|
||||
ApiSponsoredMessage,
|
||||
ApiUser,
|
||||
} from '../../types';
|
||||
|
||||
import {
|
||||
@ -141,7 +145,7 @@ type UniversalMessage = (
|
||||
& Pick<Partial<GramJs.Message & GramJs.MessageService>, (
|
||||
'out' | 'message' | 'entities' | 'fromId' | 'peerId' | 'fwdFrom' | 'replyTo' | 'replyMarkup' | 'post' |
|
||||
'media' | 'action' | 'views' | 'editDate' | 'editHide' | 'mediaUnread' | 'groupedId' | 'mentioned' | 'viaBotId' |
|
||||
'replies' | 'fromScheduled' | 'postAuthor' | 'noforwards'
|
||||
'replies' | 'fromScheduled' | 'postAuthor' | 'noforwards' | 'reactions'
|
||||
)>
|
||||
);
|
||||
|
||||
@ -192,6 +196,7 @@ export function buildApiMessageWithChatId(chatId: string, mtpMessage: UniversalM
|
||||
senderId: fromId || (mtpMessage.out && mtpMessage.post && currentUserId) || chatId,
|
||||
views: mtpMessage.views,
|
||||
isFromScheduled: mtpMessage.fromScheduled,
|
||||
reactions: mtpMessage.reactions && buildMessageReactions(mtpMessage.reactions),
|
||||
...(replyToMsgId && { replyToMessageId: replyToMsgId }),
|
||||
...(replyToPeerId && { replyToChatId: getApiChatIdFromMtpPeer(replyToPeerId) }),
|
||||
...(replyToTopId && { replyToTopMessageId: replyToTopId }),
|
||||
@ -214,6 +219,54 @@ export function buildApiMessageWithChatId(chatId: string, mtpMessage: UniversalM
|
||||
};
|
||||
}
|
||||
|
||||
export function buildMessageReactions(reactions: GramJs.MessageReactions): ApiReactions {
|
||||
const {
|
||||
recentReactons, results, canSeeList,
|
||||
} = reactions;
|
||||
|
||||
return {
|
||||
canSeeList,
|
||||
results: results.map(buildReactionCount),
|
||||
recentReactions: recentReactons?.map(buildMessageUserReaction),
|
||||
};
|
||||
}
|
||||
|
||||
function buildReactionCount(reactionCount: GramJs.ReactionCount): ApiReactionCount {
|
||||
const { chosen, count, reaction } = reactionCount;
|
||||
|
||||
return {
|
||||
isChosen: chosen,
|
||||
count,
|
||||
reaction,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildMessageUserReaction(userReaction: GramJs.MessageUserReaction): ApiUserReaction {
|
||||
const { userId, reaction } = userReaction;
|
||||
|
||||
return {
|
||||
userId: buildApiPeerId(userId, 'user'),
|
||||
reaction,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildApiAvailableReaction(availableReaction: GramJs.AvailableReaction): ApiAvailableReaction {
|
||||
const {
|
||||
selectAnimation, staticIcon, reaction, title,
|
||||
inactive, aroundAnimation, centerIcon,
|
||||
} = availableReaction;
|
||||
|
||||
return {
|
||||
selectAnimation: buildApiDocument(selectAnimation),
|
||||
staticIcon: buildApiDocument(staticIcon),
|
||||
aroundAnimation: aroundAnimation ? buildApiDocument(aroundAnimation) : undefined,
|
||||
centerIcon: centerIcon ? buildApiDocument(centerIcon) : undefined,
|
||||
reaction,
|
||||
title,
|
||||
isInactive: inactive,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildMessageTextContent(
|
||||
message: string,
|
||||
entities?: GramJs.TypeMessageEntity[],
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import { Api as GramJs } from '../../../lib/gramjs';
|
||||
|
||||
import { ApiCountry, ApiSession, ApiWallpaper } from '../../types';
|
||||
import {
|
||||
ApiCountry, ApiSession, ApiWallpaper,
|
||||
} from '../../types';
|
||||
import { ApiPrivacySettings, ApiPrivacyKey, PrivacyVisibility } from '../../../types';
|
||||
|
||||
import { buildApiDocument } from './messages';
|
||||
@ -155,3 +157,16 @@ export function buildApiCountryList(countries: GramJs.help.Country[]) {
|
||||
general: generalList,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildJson(json: GramJs.TypeJSONValue): any {
|
||||
if (json instanceof GramJs.JsonNull) return undefined;
|
||||
if (json instanceof GramJs.JsonString
|
||||
|| json instanceof GramJs.JsonBool
|
||||
|| json instanceof GramJs.JsonNumber) return json.value;
|
||||
if (json instanceof GramJs.JsonArray) return json.value.map(buildJson);
|
||||
|
||||
return json.value.reduce((acc: Record<string, any>, el) => {
|
||||
acc[el.key] = buildJson(el.value);
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@ -341,6 +341,7 @@ async function getFullChatInfo(chatId: string): Promise<{
|
||||
exportedInvite,
|
||||
botInfo,
|
||||
call,
|
||||
availableReactions,
|
||||
} = result.fullChat;
|
||||
|
||||
const members = buildChatMembers(participants);
|
||||
@ -358,6 +359,7 @@ async function getFullChatInfo(chatId: string): Promise<{
|
||||
inviteLink: exportedInvite.link,
|
||||
}),
|
||||
groupCallId: call?.id.toString(),
|
||||
enabledReactions: availableReactions,
|
||||
},
|
||||
users: result.users.map(buildApiUser).filter<ApiUser>(Boolean as any),
|
||||
groupCall: call ? {
|
||||
@ -403,6 +405,7 @@ async function getFullChannelInfo(
|
||||
hiddenPrehistory,
|
||||
call,
|
||||
botInfo,
|
||||
availableReactions,
|
||||
defaultSendAs,
|
||||
} = result.fullChat;
|
||||
|
||||
@ -454,6 +457,7 @@ async function getFullChannelInfo(
|
||||
groupCallId: call ? String(call.id) : undefined,
|
||||
linkedChatId: linkedChatId ? buildApiPeerId(linkedChatId, 'chat') : undefined,
|
||||
botCommands,
|
||||
enabledReactions: availableReactions,
|
||||
sendAsId: defaultSendAs ? getApiChatIdFromMtpPeer(defaultSendAs) : undefined,
|
||||
},
|
||||
users: [...(users || []), ...(bannedUsers || []), ...(adminUsers || [])],
|
||||
@ -1143,6 +1147,17 @@ export async function importChatInvite({ hash }: { hash: string }) {
|
||||
return buildApiChatFromPreview(updates.chats[0]);
|
||||
}
|
||||
|
||||
export function setChatEnabledReactions({
|
||||
chat, enabledReactions,
|
||||
}: {
|
||||
chat: ApiChat; enabledReactions: string[];
|
||||
}) {
|
||||
return invokeRequest(new GramJs.messages.SetChatAvailableReactions({
|
||||
peer: buildInputPeer(chat.id, chat.accessHash),
|
||||
availableReactions: enabledReactions,
|
||||
}), true);
|
||||
}
|
||||
|
||||
export function toggleIsProtected({
|
||||
chat, isProtected,
|
||||
}: { chat: ApiChat; isProtected: boolean }) {
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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';
|
||||
|
||||
136
src/api/gramjs/methods/reactions.ts
Normal file
136
src/api/gramjs/methods/reactions.ts
Normal file
@ -0,0 +1,136 @@
|
||||
import { ApiChat, ApiUser } from '../../types';
|
||||
import { invokeRequest } from './client';
|
||||
import { Api as GramJs } from '../../../lib/gramjs';
|
||||
import { buildInputPeer } from '../gramjsBuilders';
|
||||
import localDb from '../localDb';
|
||||
import { buildApiAvailableReaction, buildMessageUserReaction } from '../apiBuilders/messages';
|
||||
import { REACTION_LIST_LIMIT } from '../../../config';
|
||||
import { addEntitiesWithPhotosToLocalDb } from '../helpers';
|
||||
import { buildApiUser } from '../apiBuilders/users';
|
||||
|
||||
export function sendWatchingEmojiInteraction({
|
||||
chat,
|
||||
emoticon,
|
||||
}: {
|
||||
chat: ApiChat; emoticon: string;
|
||||
}) {
|
||||
return invokeRequest(new GramJs.messages.SetTyping({
|
||||
peer: buildInputPeer(chat.id, chat.accessHash),
|
||||
action: new GramJs.SendMessageEmojiInteractionSeen({
|
||||
emoticon,
|
||||
}),
|
||||
}));
|
||||
}
|
||||
|
||||
export function sendEmojiInteraction({
|
||||
chat,
|
||||
emoticon,
|
||||
messageId,
|
||||
timestamps,
|
||||
}: {
|
||||
chat: ApiChat; messageId: number; emoticon: string; timestamps: number[];
|
||||
}) {
|
||||
return invokeRequest(new GramJs.messages.SetTyping({
|
||||
peer: buildInputPeer(chat.id, chat.accessHash),
|
||||
action: new GramJs.SendMessageEmojiInteraction({
|
||||
emoticon,
|
||||
msgId: messageId,
|
||||
interaction: new GramJs.DataJSON({
|
||||
data: JSON.stringify({
|
||||
v: 1,
|
||||
a: timestamps.map((t: number) => ({
|
||||
t,
|
||||
i: 1,
|
||||
})),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getAvailableReactions() {
|
||||
const result = await invokeRequest(new GramJs.messages.GetAvailableReactions({}));
|
||||
|
||||
if (!result || result instanceof GramJs.messages.AvailableReactionsNotModified) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
result.reactions.forEach((reaction) => {
|
||||
if (reaction.staticIcon instanceof GramJs.Document) {
|
||||
localDb.documents[String(reaction.staticIcon.id)] = reaction.staticIcon;
|
||||
}
|
||||
if (reaction.selectAnimation instanceof GramJs.Document) {
|
||||
localDb.documents[String(reaction.selectAnimation.id)] = reaction.selectAnimation;
|
||||
}
|
||||
if (reaction.aroundAnimation instanceof GramJs.Document) {
|
||||
localDb.documents[String(reaction.aroundAnimation.id)] = reaction.aroundAnimation;
|
||||
}
|
||||
if (reaction.centerIcon instanceof GramJs.Document) {
|
||||
localDb.documents[String(reaction.centerIcon.id)] = reaction.centerIcon;
|
||||
}
|
||||
});
|
||||
|
||||
return result.reactions.map(buildApiAvailableReaction);
|
||||
}
|
||||
|
||||
export function sendReaction({
|
||||
chat, messageId, reaction,
|
||||
}: {
|
||||
chat: ApiChat; messageId: number; reaction?: string;
|
||||
}) {
|
||||
return invokeRequest(new GramJs.messages.SendReaction({
|
||||
...(reaction && { reaction }),
|
||||
peer: buildInputPeer(chat.id, chat.accessHash),
|
||||
msgId: messageId,
|
||||
}), true);
|
||||
}
|
||||
|
||||
export function fetchMessageReactions({
|
||||
ids, chat,
|
||||
}: {
|
||||
ids: number[]; chat: ApiChat;
|
||||
}) {
|
||||
return invokeRequest(new GramJs.messages.GetMessagesReactions({
|
||||
id: ids,
|
||||
peer: buildInputPeer(chat.id, chat.accessHash),
|
||||
}), true);
|
||||
}
|
||||
|
||||
export async function fetchMessageReactionsList({
|
||||
chat, messageId, reaction, offset,
|
||||
}: {
|
||||
chat: ApiChat; messageId: number; reaction?: string; offset?: string;
|
||||
}) {
|
||||
const result = await invokeRequest(new GramJs.messages.GetMessageReactionsList({
|
||||
peer: buildInputPeer(chat.id, chat.accessHash),
|
||||
id: messageId,
|
||||
...(reaction && { reaction }),
|
||||
limit: REACTION_LIST_LIMIT,
|
||||
...(offset && { offset }),
|
||||
}));
|
||||
|
||||
if (!result) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
addEntitiesWithPhotosToLocalDb(result.users);
|
||||
|
||||
const { nextOffset, reactions, count } = result;
|
||||
|
||||
return {
|
||||
users: result.users.map(buildApiUser).filter<ApiUser>(Boolean as any),
|
||||
nextOffset,
|
||||
reactions: reactions.map(buildMessageUserReaction),
|
||||
count,
|
||||
};
|
||||
}
|
||||
|
||||
export function setDefaultReaction({
|
||||
reaction,
|
||||
}: {
|
||||
reaction: string;
|
||||
}) {
|
||||
return invokeRequest(new GramJs.messages.SetDefaultReaction({
|
||||
reaction,
|
||||
}));
|
||||
}
|
||||
@ -2,24 +2,35 @@ import BigInt from 'big-integer';
|
||||
import { Api as GramJs } from '../../../lib/gramjs';
|
||||
|
||||
import {
|
||||
ApiChat, ApiLangString, ApiLanguage, ApiNotifyException, ApiUser, ApiWallpaper,
|
||||
ApiAppConfig,
|
||||
ApiChat,
|
||||
ApiLangString,
|
||||
ApiLanguage,
|
||||
ApiNotifyException,
|
||||
ApiUser,
|
||||
ApiWallpaper,
|
||||
} from '../../types';
|
||||
import { ApiPrivacyKey, InputPrivacyRules, LangCode } from '../../../types';
|
||||
|
||||
import { BLOCKED_LIST_LIMIT, DEFAULT_LANG_PACK, LANG_PACKS } from '../../../config';
|
||||
import {
|
||||
buildApiWallpaper, buildApiSession, buildPrivacyRules, buildApiNotifyException, buildApiCountryList,
|
||||
buildApiCountryList,
|
||||
buildApiNotifyException,
|
||||
buildApiSession,
|
||||
buildApiWallpaper,
|
||||
buildPrivacyRules,
|
||||
} from '../apiBuilders/misc';
|
||||
|
||||
import { buildApiUser } from '../apiBuilders/users';
|
||||
import { buildApiChatFromPreview } from '../apiBuilders/chats';
|
||||
import { buildInputPrivacyKey, buildInputPeer, buildInputEntity } from '../gramjsBuilders';
|
||||
import { invokeRequest, uploadFile, getClient } from './client';
|
||||
import { buildInputEntity, buildInputPeer, buildInputPrivacyKey } from '../gramjsBuilders';
|
||||
import { getClient, invokeRequest, uploadFile } from './client';
|
||||
import { omitVirtualClassFields } from '../apiBuilders/helpers';
|
||||
import { buildCollectionByKey } from '../../../util/iteratees';
|
||||
import { getServerTime } from '../../../util/serverTime';
|
||||
import { buildApiPeerId, getApiChatIdFromMtpPeer } from '../apiBuilders/peers';
|
||||
import localDb from '../localDb';
|
||||
import { buildApiConfig } from '../apiBuilders/appConfig';
|
||||
|
||||
const MAX_INT_32 = 2 ** 31 - 1;
|
||||
const BETA_LANG_CODES = ['ar', 'fa', 'id', 'ko', 'uz'];
|
||||
@ -429,6 +440,13 @@ export function updateContentSettings(isEnabled: boolean) {
|
||||
}));
|
||||
}
|
||||
|
||||
export async function fetchAppConfig(): Promise<ApiAppConfig | undefined> {
|
||||
const result = await invokeRequest(new GramJs.help.GetAppConfig());
|
||||
if (!result) return undefined;
|
||||
|
||||
return buildApiConfig(result);
|
||||
}
|
||||
|
||||
function updateLocalDb(
|
||||
result: (
|
||||
GramJs.account.PrivacyRules | GramJs.contacts.Blocked | GramJs.contacts.BlockedSlice |
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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');
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -109,3 +109,20 @@ export interface ApiCountryCode extends ApiCountry {
|
||||
prefixes?: string[];
|
||||
patterns?: string[];
|
||||
}
|
||||
|
||||
export interface ApiAppConfig {
|
||||
emojiSounds: Record<string, string>;
|
||||
defaultReaction: string;
|
||||
}
|
||||
|
||||
export interface GramJsEmojiInteraction {
|
||||
v: number;
|
||||
a: {
|
||||
i: number;
|
||||
t: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface ApiEmojiInteraction {
|
||||
timestamps: number[];
|
||||
}
|
||||
|
||||
@ -7,10 +7,11 @@ import {
|
||||
ApiChatFolder,
|
||||
} from './chats';
|
||||
import {
|
||||
ApiFormattedText, ApiMessage, ApiPhoto, ApiPoll, ApiStickerSet, ApiThreadInfo,
|
||||
ApiFormattedText, ApiMessage, ApiPhoto, ApiPoll, ApiReactions, ApiStickerSet, ApiThreadInfo,
|
||||
} from './messages';
|
||||
import { ApiUser, ApiUserFullInfo, ApiUserStatus } from './users';
|
||||
import {
|
||||
ApiEmojiInteraction,
|
||||
ApiError, ApiInviteInfo, ApiNotifyException, ApiSessionData,
|
||||
} from './misc';
|
||||
import {
|
||||
@ -102,6 +103,14 @@ export type ApiUpdateChatTypingStatus = {
|
||||
typingStatus: ApiTypingStatus | undefined;
|
||||
};
|
||||
|
||||
export type ApiUpdateStartEmojiInteraction = {
|
||||
'@type': 'updateStartEmojiInteraction';
|
||||
id: string;
|
||||
emoji: string;
|
||||
messageId: number;
|
||||
interaction: ApiEmojiInteraction;
|
||||
};
|
||||
|
||||
export type ApiUpdateChatFullInfo = {
|
||||
'@type': 'updateChatFullInfo';
|
||||
id: string;
|
||||
@ -284,6 +293,13 @@ export type ApiUpdateDraftMessage = {
|
||||
replyingToId?: number;
|
||||
};
|
||||
|
||||
export type ApiUpdateMessageReactions = {
|
||||
'@type': 'updateMessageReactions';
|
||||
id: number;
|
||||
chatId: string;
|
||||
reactions: ApiReactions;
|
||||
};
|
||||
|
||||
export type ApiDeleteContact = {
|
||||
'@type': 'deleteContact';
|
||||
id: string;
|
||||
@ -435,13 +451,13 @@ export type ApiUpdate = (
|
||||
ApiUpdateMessageSendSucceeded | ApiUpdateMessageSendFailed | ApiUpdateServiceNotification |
|
||||
ApiDeleteContact | ApiUpdateUser | ApiUpdateUserStatus | ApiUpdateUserFullInfo | ApiUpdateDeleteProfilePhotos |
|
||||
ApiUpdateAvatar | ApiUpdateMessageImage | ApiUpdateDraftMessage |
|
||||
ApiUpdateError | ApiUpdateResetContacts |
|
||||
ApiUpdateError | ApiUpdateResetContacts | ApiUpdateStartEmojiInteraction |
|
||||
ApiUpdateFavoriteStickers | ApiUpdateStickerSet |
|
||||
ApiUpdateNewScheduledMessage | ApiUpdateScheduledMessageSendSucceeded | ApiUpdateScheduledMessage |
|
||||
ApiUpdateDeleteScheduledMessages | ApiUpdateResetMessages |
|
||||
ApiUpdateTwoFaError | ApiUpdateTwoFaStateWaitCode |
|
||||
ApiUpdateNotifySettings | ApiUpdateNotifyExceptions | ApiUpdatePeerBlocked | ApiUpdatePrivacy |
|
||||
ApiUpdateServerTimeOffset | ApiUpdateShowInvite |
|
||||
ApiUpdateServerTimeOffset | ApiUpdateShowInvite | ApiUpdateMessageReactions |
|
||||
ApiUpdateGroupCallParticipants | ApiUpdateGroupCallConnection | ApiUpdateGroupCall | ApiUpdateGroupCallStreams |
|
||||
ApiUpdateGroupCallConnectionState | ApiUpdateGroupCallLeavePresentation | ApiUpdateGroupCallChatId
|
||||
);
|
||||
|
||||
Binary file not shown.
Binary file not shown.
BIN
src/assets/tgs/animatedEmojis/Cumshot.tgs
Normal file
BIN
src/assets/tgs/animatedEmojis/Cumshot.tgs
Normal file
Binary file not shown.
BIN
src/assets/tgs/animatedEmojis/Eggplant.tgs
Normal file
BIN
src/assets/tgs/animatedEmojis/Eggplant.tgs
Normal file
Binary file not shown.
BIN
src/assets/tgs/animatedEmojis/Peach.tgs
Normal file
BIN
src/assets/tgs/animatedEmojis/Peach.tgs
Normal file
Binary file not shown.
@ -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';
|
||||
|
||||
@ -1,14 +1,15 @@
|
||||
import React, {
|
||||
FC, useCallback, useRef, useState,
|
||||
FC, memo,
|
||||
} from '../../lib/teact/teact';
|
||||
|
||||
import { ApiMediaFormat, ApiSticker } from '../../api/types';
|
||||
import { ActiveEmojiInteraction } from '../../global/types';
|
||||
|
||||
import { LIKE_STICKER_ID } from './helpers/mediaDimensions';
|
||||
import { ObserveFn, useIsIntersecting } from '../../hooks/useIntersectionObserver';
|
||||
import useMedia from '../../hooks/useMedia';
|
||||
import useMediaTransition from '../../hooks/useMediaTransition';
|
||||
import useFlag from '../../hooks/useFlag';
|
||||
import useAnimatedEmoji from './hooks/useAnimatedEmoji';
|
||||
|
||||
import AnimatedSticker from './AnimatedSticker';
|
||||
|
||||
@ -16,30 +17,43 @@ import './AnimatedEmoji.scss';
|
||||
|
||||
type OwnProps = {
|
||||
sticker: ApiSticker;
|
||||
effect?: ApiSticker;
|
||||
isOwn?: boolean;
|
||||
soundId?: string;
|
||||
observeIntersection?: ObserveFn;
|
||||
size?: 'large' | 'medium' | 'small';
|
||||
lastSyncTime?: number;
|
||||
forceLoadPreview?: boolean;
|
||||
messageId?: number;
|
||||
chatId?: string;
|
||||
activeEmojiInteraction?: ActiveEmojiInteraction;
|
||||
};
|
||||
|
||||
const QUALITY = 1;
|
||||
const WIDTH = {
|
||||
large: 160,
|
||||
medium: 128,
|
||||
small: 104,
|
||||
};
|
||||
|
||||
const AnimatedEmoji: FC<OwnProps> = ({
|
||||
sticker,
|
||||
effect,
|
||||
isOwn,
|
||||
soundId,
|
||||
size = 'medium',
|
||||
observeIntersection,
|
||||
lastSyncTime,
|
||||
forceLoadPreview,
|
||||
messageId,
|
||||
chatId,
|
||||
activeEmojiInteraction,
|
||||
}) => {
|
||||
// eslint-disable-next-line no-null/no-null
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const {
|
||||
markAnimationLoaded,
|
||||
isAnimationLoaded,
|
||||
ref,
|
||||
width,
|
||||
style,
|
||||
handleClick,
|
||||
playKey,
|
||||
} = useAnimatedEmoji(size, chatId, messageId, soundId, activeEmojiInteraction, isOwn, undefined, effect?.emoji);
|
||||
|
||||
const [isAnimationLoaded, markAnimationLoaded] = useFlag();
|
||||
const localMediaHash = `sticker${sticker.id}`;
|
||||
|
||||
const isIntersecting = useIsIntersecting(ref, observeIntersection);
|
||||
@ -56,19 +70,11 @@ const AnimatedEmoji: FC<OwnProps> = ({
|
||||
const mediaData = useMedia(localMediaHash, !isIntersecting, ApiMediaFormat.Lottie, lastSyncTime);
|
||||
const isMediaLoaded = Boolean(mediaData);
|
||||
|
||||
const [playKey, setPlayKey] = useState(String(Math.random()));
|
||||
const handleClick = useCallback(() => {
|
||||
setPlayKey(String(Math.random()));
|
||||
}, []);
|
||||
|
||||
const width = WIDTH[size];
|
||||
const style = `width: ${width}px; height: ${width}px;`;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="AnimatedEmoji media-inner"
|
||||
// @ts-ignore
|
||||
// @ts-ignore teact feature
|
||||
style={style}
|
||||
onClick={handleClick}
|
||||
>
|
||||
@ -78,7 +84,7 @@ const AnimatedEmoji: FC<OwnProps> = ({
|
||||
{!isAnimationLoaded && previewBlobUrl && (
|
||||
<img src={previewBlobUrl} className={transitionClassNames} alt="" />
|
||||
)}
|
||||
{isMediaLoaded && (
|
||||
{isMediaLoaded && localMediaHash && (
|
||||
<AnimatedSticker
|
||||
key={localMediaHash}
|
||||
id={localMediaHash}
|
||||
@ -94,4 +100,4 @@ const AnimatedEmoji: FC<OwnProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default AnimatedEmoji;
|
||||
export default memo(AnimatedEmoji);
|
||||
|
||||
@ -20,6 +20,7 @@ type OwnProps = {
|
||||
isLowPriority?: boolean;
|
||||
onLoad?: NoneToVoidFunction;
|
||||
color?: [number, number, number];
|
||||
onEnded?: NoneToVoidFunction;
|
||||
};
|
||||
|
||||
type RLottieClass = typeof import('../../lib/rlottie/RLottie').default;
|
||||
@ -54,6 +55,7 @@ const AnimatedSticker: FC<OwnProps> = ({
|
||||
isLowPriority,
|
||||
onLoad,
|
||||
color,
|
||||
onEnded,
|
||||
}) => {
|
||||
const [animation, setAnimation] = useState<RLottieInstance>();
|
||||
// eslint-disable-next-line no-null/no-null
|
||||
@ -89,6 +91,7 @@ const AnimatedSticker: FC<OwnProps> = ({
|
||||
},
|
||||
onLoad,
|
||||
color,
|
||||
onEnded,
|
||||
);
|
||||
|
||||
if (speed) {
|
||||
@ -109,7 +112,7 @@ const AnimatedSticker: FC<OwnProps> = ({
|
||||
});
|
||||
});
|
||||
}
|
||||
}, [color, animation, animationData, id, isLowPriority, noLoop, onLoad, quality, size, speed]);
|
||||
}, [color, animation, animationData, id, isLowPriority, noLoop, onLoad, quality, size, speed, onEnded]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!animation) return;
|
||||
|
||||
85
src/components/common/LocalAnimatedEmoji.tsx
Normal file
85
src/components/common/LocalAnimatedEmoji.tsx
Normal file
@ -0,0 +1,85 @@
|
||||
import React, {
|
||||
FC, memo, useEffect, useState,
|
||||
} from '../../lib/teact/teact';
|
||||
|
||||
import { ActiveEmojiInteraction } from '../../global/types';
|
||||
|
||||
import { ObserveFn, useIsIntersecting } from '../../hooks/useIntersectionObserver';
|
||||
import getAnimationData, { ANIMATED_STICKERS_PATHS } from './helpers/animatedAssets';
|
||||
import useAnimatedEmoji from './hooks/useAnimatedEmoji';
|
||||
|
||||
import AnimatedSticker from './AnimatedSticker';
|
||||
|
||||
const QUALITY = 1;
|
||||
|
||||
type OwnProps = {
|
||||
localSticker?: string;
|
||||
localEffect?: string;
|
||||
isOwn?: boolean;
|
||||
soundId?: string;
|
||||
observeIntersection?: ObserveFn;
|
||||
size?: 'large' | 'medium' | 'small';
|
||||
lastSyncTime?: number;
|
||||
forceLoadPreview?: boolean;
|
||||
messageId?: number;
|
||||
chatId?: string;
|
||||
activeEmojiInteraction?: ActiveEmojiInteraction;
|
||||
};
|
||||
|
||||
const LocalAnimatedEmoji: FC<OwnProps> = ({
|
||||
localSticker,
|
||||
localEffect,
|
||||
isOwn,
|
||||
soundId,
|
||||
size = 'medium',
|
||||
observeIntersection,
|
||||
messageId,
|
||||
chatId,
|
||||
activeEmojiInteraction,
|
||||
}) => {
|
||||
const {
|
||||
playKey,
|
||||
ref,
|
||||
style,
|
||||
width,
|
||||
handleClick,
|
||||
markAnimationLoaded,
|
||||
} = useAnimatedEmoji(size, chatId, messageId, soundId, activeEmojiInteraction, isOwn, localEffect);
|
||||
const id = `local_emoji_${localSticker}`;
|
||||
|
||||
const isIntersecting = useIsIntersecting(ref, observeIntersection);
|
||||
|
||||
const [localStickerAnimationData, setLocalStickerAnimationData] = useState<AnyLiteral>();
|
||||
useEffect(() => {
|
||||
if (localSticker) {
|
||||
getAnimationData(localSticker as keyof typeof ANIMATED_STICKERS_PATHS).then((data) => {
|
||||
setLocalStickerAnimationData(data);
|
||||
});
|
||||
}
|
||||
}, [localSticker]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="AnimatedEmoji media-inner"
|
||||
// @ts-ignore teact feature
|
||||
style={style}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{localStickerAnimationData && (
|
||||
<AnimatedSticker
|
||||
key={id}
|
||||
id={id}
|
||||
animationData={localStickerAnimationData}
|
||||
size={width}
|
||||
quality={QUALITY}
|
||||
play={isIntersecting && playKey}
|
||||
noLoop
|
||||
onLoad={markAnimationLoaded}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(LocalAnimatedEmoji);
|
||||
4
src/components/common/ReactionStaticEmoji.scss
Normal file
4
src/components/common/ReactionStaticEmoji.scss
Normal file
@ -0,0 +1,4 @@
|
||||
.ReactionStaticEmoji {
|
||||
width: 1rem;
|
||||
display: block;
|
||||
}
|
||||
36
src/components/common/ReactionStaticEmoji.tsx
Normal file
36
src/components/common/ReactionStaticEmoji.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
import { RefObject } from 'react';
|
||||
import React, { FC, memo } from '../../lib/teact/teact';
|
||||
import { getGlobal } from '../../lib/teact/teactn';
|
||||
|
||||
import { ApiMediaFormat } from '../../api/types';
|
||||
|
||||
import useMedia from '../../hooks/useMedia';
|
||||
import buildClassName from '../../util/buildClassName';
|
||||
|
||||
import './ReactionStaticEmoji.scss';
|
||||
|
||||
type OwnProps = {
|
||||
reaction: string;
|
||||
ref?: RefObject<HTMLImageElement>;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const ReactionStaticEmoji: FC<OwnProps> = ({
|
||||
reaction,
|
||||
ref,
|
||||
className,
|
||||
}) => {
|
||||
const staticIconId = getGlobal().availableReactions?.find((l) => l.reaction === reaction)?.staticIcon?.id;
|
||||
const mediaData = useMedia(`document${staticIconId}`, !staticIconId, ApiMediaFormat.BlobUrl);
|
||||
|
||||
return (
|
||||
<img
|
||||
className={buildClassName('ReactionStaticEmoji', className)}
|
||||
ref={ref}
|
||||
src={mediaData}
|
||||
alt=""
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(ReactionStaticEmoji);
|
||||
@ -28,7 +28,7 @@ const TypingStatus: FC<OwnProps & StateProps> = ({ typingStatus, typingUser }) =
|
||||
<span className="sender-name" dir="auto">{renderText(typingUserName)}</span>
|
||||
)}
|
||||
{/* fix for translation "username _is_ typing" */}
|
||||
{lang(typingStatus.action).replace('{user}', '').trim()}
|
||||
{lang(typingStatus.action).replace('{user}', '').replace('{emoji}', typingStatus.emoji).trim()}
|
||||
<span className="ellipsis" />
|
||||
</p>
|
||||
);
|
||||
|
||||
@ -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) {
|
||||
|
||||
155
src/components/common/hooks/useAnimatedEmoji.ts
Normal file
155
src/components/common/hooks/useAnimatedEmoji.ts
Normal file
@ -0,0 +1,155 @@
|
||||
import {
|
||||
useCallback, useEffect, useRef, useState,
|
||||
} from '../../../lib/teact/teact';
|
||||
import safePlay from '../../../util/safePlay';
|
||||
import { getDispatch } from '../../../lib/teact/teactn';
|
||||
import useMedia from '../../../hooks/useMedia';
|
||||
import { ActiveEmojiInteraction } from '../../../global/types';
|
||||
import useFlag from '../../../hooks/useFlag';
|
||||
import { selectLocalAnimatedEmojiEffectByName } from '../../../modules/selectors';
|
||||
|
||||
const WIDTH = {
|
||||
large: 160,
|
||||
medium: 128,
|
||||
small: 104,
|
||||
};
|
||||
const INTERACTION_BUNCH_TIME = 1000;
|
||||
const MS_DIVIDER = 1000;
|
||||
const TIME_DEFAULT = 0;
|
||||
|
||||
export default function useAnimatedEmoji(
|
||||
size: 'large' | 'medium' | 'small',
|
||||
chatId?: string,
|
||||
messageId?: number,
|
||||
soundId?: string,
|
||||
activeEmojiInteraction?: ActiveEmojiInteraction,
|
||||
isOwn?: boolean,
|
||||
localEffect?: string,
|
||||
emoji?: string,
|
||||
) {
|
||||
const {
|
||||
interactWithAnimatedEmoji, sendEmojiInteraction, sendWatchingEmojiInteraction,
|
||||
} = getDispatch();
|
||||
|
||||
const hasEffect = localEffect || emoji;
|
||||
const [isAnimationLoaded, markAnimationLoaded] = useFlag();
|
||||
|
||||
// eslint-disable-next-line no-null/no-null
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
// eslint-disable-next-line no-null/no-null
|
||||
const audioRef = useRef<HTMLAudioElement | undefined>(null);
|
||||
|
||||
const soundMediaData = useMedia(soundId ? `document${soundId}` : undefined, !soundId);
|
||||
|
||||
const width = WIDTH[size];
|
||||
const style = `width: ${width}px; height: ${width}px;`;
|
||||
|
||||
const [playKey, setPlayKey] = useState(String(Math.random()));
|
||||
const interactions = useRef<number[] | undefined>(undefined);
|
||||
const startedInteractions = useRef<number | undefined>(undefined);
|
||||
const sendInteractionBunch = useCallback(() => {
|
||||
const container = ref.current;
|
||||
|
||||
if (!container) return;
|
||||
|
||||
sendEmojiInteraction({
|
||||
chatId,
|
||||
messageId,
|
||||
localEffect,
|
||||
emoji,
|
||||
interactions: interactions.current,
|
||||
});
|
||||
startedInteractions.current = undefined;
|
||||
interactions.current = undefined;
|
||||
}, [sendEmojiInteraction, chatId, messageId, localEffect, emoji]);
|
||||
|
||||
const play = useCallback(() => {
|
||||
setPlayKey(String(Math.random()));
|
||||
|
||||
const audio = audioRef.current;
|
||||
if (soundMediaData) {
|
||||
if (audio) {
|
||||
audio.pause();
|
||||
audio.remove();
|
||||
}
|
||||
audioRef.current = new Audio();
|
||||
audioRef.current.src = soundMediaData;
|
||||
safePlay(audioRef.current);
|
||||
audioRef.current.addEventListener('ended', () => {
|
||||
audioRef.current = undefined;
|
||||
}, { once: true });
|
||||
}
|
||||
}, [soundMediaData]);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
play();
|
||||
|
||||
const container = ref.current;
|
||||
|
||||
if (!hasEffect || !container || !messageId || !chatId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { x, y } = container.getBoundingClientRect();
|
||||
|
||||
interactWithAnimatedEmoji({
|
||||
localEffect,
|
||||
emoji,
|
||||
x,
|
||||
y,
|
||||
startSize: width,
|
||||
isReversed: !isOwn,
|
||||
});
|
||||
|
||||
if (!interactions.current) {
|
||||
interactions.current = [];
|
||||
startedInteractions.current = performance.now();
|
||||
setTimeout(sendInteractionBunch, INTERACTION_BUNCH_TIME);
|
||||
}
|
||||
|
||||
interactions.current.push(startedInteractions.current
|
||||
? (performance.now() - startedInteractions.current) / MS_DIVIDER
|
||||
: TIME_DEFAULT);
|
||||
}, [
|
||||
chatId, emoji, hasEffect, interactWithAnimatedEmoji, isOwn,
|
||||
localEffect, messageId, play, sendInteractionBunch, width,
|
||||
]);
|
||||
|
||||
// Set an end anchor for remote activated interaction
|
||||
useEffect(() => {
|
||||
const container = ref.current;
|
||||
|
||||
if (!container || !activeEmojiInteraction) return;
|
||||
|
||||
const {
|
||||
messageId: selectedMessageId, endX, endY,
|
||||
} = activeEmojiInteraction;
|
||||
|
||||
if (!endX && !endY && selectedMessageId === messageId) {
|
||||
const { x, y } = container.getBoundingClientRect();
|
||||
|
||||
sendWatchingEmojiInteraction({
|
||||
chatId,
|
||||
emoticon: localEffect ? selectLocalAnimatedEmojiEffectByName(localEffect) : emoji,
|
||||
startSize: width,
|
||||
x,
|
||||
y,
|
||||
isReversed: !isOwn,
|
||||
});
|
||||
play();
|
||||
}
|
||||
}, [
|
||||
activeEmojiInteraction, chatId, emoji, isOwn, localEffect, messageId, play, sendWatchingEmojiInteraction, width,
|
||||
]);
|
||||
|
||||
return {
|
||||
playKey,
|
||||
ref,
|
||||
style,
|
||||
width,
|
||||
handleClick,
|
||||
markAnimationLoaded,
|
||||
isAnimationLoaded,
|
||||
};
|
||||
}
|
||||
@ -122,6 +122,7 @@ const LeftColumn: FC<StateProps> = ({
|
||||
return;
|
||||
|
||||
case SettingsScreens.GeneralChatBackground:
|
||||
case SettingsScreens.QuickReaction:
|
||||
setSettingsScreen(SettingsScreens.General);
|
||||
return;
|
||||
case SettingsScreens.GeneralChatBackgroundColor:
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -23,6 +23,7 @@ import SettingsPrivacyActiveSessions from './SettingsPrivacyActiveSessions';
|
||||
import SettingsPrivacyBlockedUsers from './SettingsPrivacyBlockedUsers';
|
||||
import SettingsTwoFa from './twoFa/SettingsTwoFa';
|
||||
import SettingsPrivacyVisibilityExceptionList from './SettingsPrivacyVisibilityExceptionList';
|
||||
import SettingsQuickReaction from './SettingsQuickReaction';
|
||||
|
||||
import './Settings.scss';
|
||||
|
||||
@ -180,10 +181,15 @@ const Settings: FC<OwnProps> = ({
|
||||
isActive={isScreenActive
|
||||
|| screen === SettingsScreens.GeneralChatBackgroundColor
|
||||
|| screen === SettingsScreens.GeneralChatBackground
|
||||
|| screen === SettingsScreens.QuickReaction
|
||||
|| isPrivacyScreen || isFoldersScreen}
|
||||
onReset={handleReset}
|
||||
/>
|
||||
);
|
||||
case SettingsScreens.QuickReaction:
|
||||
return (
|
||||
<SettingsQuickReaction onScreenSelect={onScreenSelect} isActive={isScreenActive} onReset={handleReset} />
|
||||
);
|
||||
case SettingsScreens.Notifications:
|
||||
return (
|
||||
<SettingsNotifications onScreenSelect={onScreenSelect} isActive={isScreenActive} onReset={handleReset} />
|
||||
|
||||
@ -20,6 +20,7 @@ import Checkbox from '../../ui/Checkbox';
|
||||
import RadioGroup, { IRadioOption } from '../../ui/RadioGroup';
|
||||
import SettingsStickerSet from './SettingsStickerSet';
|
||||
import StickerSetModal from '../../common/StickerSetModal.async';
|
||||
import ReactionStaticEmoji from '../../common/ReactionStaticEmoji';
|
||||
|
||||
type OwnProps = {
|
||||
isActive?: boolean;
|
||||
@ -38,6 +39,7 @@ type StateProps =
|
||||
)> & {
|
||||
stickerSetIds?: string[];
|
||||
stickerSetsById?: Record<string, ApiStickerSet>;
|
||||
defaultReaction?: string;
|
||||
};
|
||||
|
||||
const ANIMATION_LEVEL_OPTIONS = [
|
||||
@ -60,6 +62,7 @@ const SettingsGeneral: FC<OwnProps & StateProps> = ({
|
||||
onReset,
|
||||
stickerSetIds,
|
||||
stickerSetsById,
|
||||
defaultReaction,
|
||||
messageTextSize,
|
||||
animationLevel,
|
||||
messageSendKeyCombo,
|
||||
@ -189,6 +192,16 @@ const SettingsGeneral: FC<OwnProps & StateProps> = ({
|
||||
<div className="settings-item">
|
||||
<h4 className="settings-item-header" dir={lang.isRtl ? 'rtl' : undefined}>{lang('AccDescrStickers')}</h4>
|
||||
|
||||
{defaultReaction && (
|
||||
<ListItem
|
||||
className="SettingsDefaultReaction"
|
||||
onClick={() => onScreenSelect(SettingsScreens.QuickReaction)}
|
||||
>
|
||||
<ReactionStaticEmoji reaction={defaultReaction} />
|
||||
<div className="title">{lang('DoubleTapSetting')}</div>
|
||||
</ListItem>
|
||||
)}
|
||||
|
||||
<Checkbox
|
||||
label={lang('SuggestStickers')}
|
||||
checked={shouldSuggestStickers}
|
||||
@ -237,6 +250,7 @@ export default memo(withGlobal<OwnProps>(
|
||||
]),
|
||||
stickerSetIds: global.stickers.added.setIds,
|
||||
stickerSetsById: global.stickers.setsById,
|
||||
defaultReaction: global.appConfig?.defaultReaction,
|
||||
};
|
||||
},
|
||||
)(SettingsGeneral));
|
||||
|
||||
@ -87,6 +87,8 @@ const SettingsHeader: FC<OwnProps> = ({
|
||||
return <h3>{lang('lng_settings_information')}</h3>;
|
||||
case SettingsScreens.General:
|
||||
return <h3>{lang('General')}</h3>;
|
||||
case SettingsScreens.QuickReaction:
|
||||
return <h3>{lang('DoubleTapSetting')}</h3>;
|
||||
case SettingsScreens.Notifications:
|
||||
return <h3>{lang('Notifications')}</h3>;
|
||||
case SettingsScreens.DataStorage:
|
||||
|
||||
65
src/components/left/settings/SettingsQuickReaction.tsx
Normal file
65
src/components/left/settings/SettingsQuickReaction.tsx
Normal file
@ -0,0 +1,65 @@
|
||||
import React, { FC, memo, useCallback } from '../../../lib/teact/teact';
|
||||
import { getDispatch, withGlobal } from '../../../lib/teact/teactn';
|
||||
|
||||
import { SettingsScreens } from '../../../types';
|
||||
import { ApiAvailableReaction } from '../../../api/types';
|
||||
|
||||
import useHistoryBack from '../../../hooks/useHistoryBack';
|
||||
|
||||
import ReactionStaticEmoji from '../../common/ReactionStaticEmoji';
|
||||
import RadioGroup from '../../ui/RadioGroup';
|
||||
|
||||
type OwnProps = {
|
||||
isActive?: boolean;
|
||||
onScreenSelect: (screen: SettingsScreens) => void;
|
||||
onReset: () => void;
|
||||
};
|
||||
|
||||
type StateProps = {
|
||||
availableReactions?: ApiAvailableReaction[];
|
||||
selectedReaction?: string;
|
||||
};
|
||||
|
||||
const SettingsQuickReaction: FC<OwnProps & StateProps> = ({
|
||||
isActive,
|
||||
onReset,
|
||||
onScreenSelect,
|
||||
availableReactions,
|
||||
selectedReaction,
|
||||
}) => {
|
||||
const { setDefaultReaction } = getDispatch();
|
||||
useHistoryBack(isActive, onReset, onScreenSelect, SettingsScreens.General);
|
||||
|
||||
const options = availableReactions?.filter((l) => !l.isInactive).map((l) => {
|
||||
return {
|
||||
label: <><ReactionStaticEmoji reaction={l.reaction} />{l.title}</>,
|
||||
value: l.reaction,
|
||||
};
|
||||
}) || [];
|
||||
|
||||
const handleChange = useCallback((reaction: string) => {
|
||||
setDefaultReaction({ reaction });
|
||||
}, [setDefaultReaction]);
|
||||
|
||||
return (
|
||||
<div className="settings-content settings-item custom-scroll settings-quick-reaction">
|
||||
<RadioGroup
|
||||
name="quick-reaction-settings"
|
||||
options={options}
|
||||
selected={selectedReaction}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(withGlobal<OwnProps>(
|
||||
(global) => {
|
||||
const { availableReactions, appConfig } = global;
|
||||
|
||||
return {
|
||||
availableReactions,
|
||||
selectedReaction: appConfig?.defaultReaction,
|
||||
};
|
||||
},
|
||||
)(SettingsQuickReaction));
|
||||
@ -108,12 +108,14 @@ const Main: FC<StateProps> = ({
|
||||
loadTopInlineBots,
|
||||
loadEmojiKeywords,
|
||||
loadCountryList,
|
||||
loadAvailableReactions,
|
||||
loadStickerSets,
|
||||
loadAddedStickers,
|
||||
loadFavoriteStickers,
|
||||
ensureTimeFormat,
|
||||
openStickerSetShortName,
|
||||
checkVersionNotification,
|
||||
loadAppConfig,
|
||||
} = getDispatch();
|
||||
const isSynced = Boolean(lastSyncTime);
|
||||
|
||||
@ -127,6 +129,8 @@ const Main: FC<StateProps> = ({
|
||||
useEffect(() => {
|
||||
if (lastSyncTime) {
|
||||
updateIsOnline(true);
|
||||
loadAppConfig();
|
||||
loadAvailableReactions();
|
||||
loadAnimatedEmojis();
|
||||
loadNotificationSettings();
|
||||
loadNotificationExceptions();
|
||||
@ -135,7 +139,7 @@ const Main: FC<StateProps> = ({
|
||||
}
|
||||
}, [
|
||||
lastSyncTime, loadAnimatedEmojis, loadEmojiKeywords, loadNotificationExceptions, loadNotificationSettings,
|
||||
loadTopInlineBots, updateIsOnline,
|
||||
loadTopInlineBots, updateIsOnline, loadAvailableReactions, loadAppConfig,
|
||||
]);
|
||||
|
||||
// Language-based API calls
|
||||
|
||||
15
src/components/middle/EmojiInteractionAnimation.async.tsx
Normal file
15
src/components/middle/EmojiInteractionAnimation.async.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
import React, { FC, memo } from '../../lib/teact/teact';
|
||||
import { OwnProps } from './EmojiInteractionAnimation';
|
||||
import { Bundles } from '../../util/moduleLoader';
|
||||
|
||||
import useModuleLoader from '../../hooks/useModuleLoader';
|
||||
|
||||
const EmojiInteractionAnimationAsync: FC<OwnProps> = (props) => {
|
||||
const { emojiInteraction } = props;
|
||||
const EmojiInteractionAnimation = useModuleLoader(Bundles.Extra, 'EmojiInteractionAnimation', !emojiInteraction);
|
||||
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
return EmojiInteractionAnimation ? <EmojiInteractionAnimation {...props} /> : undefined;
|
||||
};
|
||||
|
||||
export default memo(EmojiInteractionAnimationAsync);
|
||||
89
src/components/middle/EmojiInteractionAnimation.scss
Normal file
89
src/components/middle/EmojiInteractionAnimation.scss
Normal file
@ -0,0 +1,89 @@
|
||||
.EmojiInteractionAnimation {
|
||||
--start-x: 0;
|
||||
--start-y: 0;
|
||||
--scale: 0;
|
||||
--end-scale: 0;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 1000;
|
||||
|
||||
@keyframes hide-reaction-reversed {
|
||||
from {
|
||||
transform: translate(100%, -50%) scaleX(-1) scale(1);
|
||||
}
|
||||
|
||||
to {
|
||||
left: var(--end-x, var(--start-x));
|
||||
top: var(--end-y, var(--start-y));
|
||||
transform: translate(50%, 0) scale(var(--end-scale, 0));
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes show-reaction-reversed {
|
||||
from {
|
||||
transform: translate(50%, 0) scaleX(-1) scale(var(--scale, 0));
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translate(100%, -50%) scaleX(-1) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@keyframes hide-reaction {
|
||||
from {
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
|
||||
to {
|
||||
left: var(--end-x, var(--start-x));
|
||||
top: var(--end-y, var(--start-y));
|
||||
transform: translate(0, 0) scale(var(--end-scale, 0));
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes show-reaction {
|
||||
from {
|
||||
transform: translate(0, 0) scale(var(--scale, 0));
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.AnimatedSticker {
|
||||
position: absolute;
|
||||
top: var(--start-y);
|
||||
left: var(--start-x);
|
||||
transform: scale(var(--scale), 0);
|
||||
transform-origin: left top;
|
||||
|
||||
transition: 0.25s cubic-bezier(.29,.81,.27,.99) opacity;
|
||||
}
|
||||
|
||||
&.reversed .AnimatedSticker {
|
||||
transform: scale(var(--scale), 0) scaleX(-1);
|
||||
}
|
||||
|
||||
&.playing .AnimatedSticker {
|
||||
animation: show-reaction forwards 0.25s cubic-bezier(.29,.81,.27,.99);
|
||||
}
|
||||
|
||||
&.reversed.playing .AnimatedSticker {
|
||||
animation: show-reaction-reversed forwards 0.25s cubic-bezier(.29,.81,.27,.99);
|
||||
}
|
||||
|
||||
&.hiding .AnimatedSticker {
|
||||
opacity: 0;
|
||||
animation: hide-reaction forwards 0.25s cubic-bezier(.29,.81,.27,.99);
|
||||
}
|
||||
|
||||
&.reversed.hiding .AnimatedSticker {
|
||||
animation: hide-reaction-reversed forwards 0.25s cubic-bezier(.29,.81,.27,.99);
|
||||
}
|
||||
}
|
||||
122
src/components/middle/EmojiInteractionAnimation.tsx
Normal file
122
src/components/middle/EmojiInteractionAnimation.tsx
Normal file
@ -0,0 +1,122 @@
|
||||
import React, {
|
||||
FC, memo, useCallback, useEffect, useState,
|
||||
} from '../../lib/teact/teact';
|
||||
import { getDispatch, withGlobal } from '../../lib/teact/teactn';
|
||||
|
||||
import { ActiveEmojiInteraction } from '../../global/types';
|
||||
import { ApiMediaFormat } from '../../api/types';
|
||||
|
||||
import useFlag from '../../hooks/useFlag';
|
||||
import useMedia from '../../hooks/useMedia';
|
||||
import buildClassName from '../../util/buildClassName';
|
||||
import {
|
||||
selectAnimatedEmojiEffect,
|
||||
} from '../../modules/selectors';
|
||||
import { REM } from '../common/helpers/mediaDimensions';
|
||||
import getAnimationData, { ANIMATED_STICKERS_PATHS } from '../common/helpers/animatedAssets';
|
||||
|
||||
import AnimatedSticker from '../common/AnimatedSticker';
|
||||
|
||||
import './EmojiInteractionAnimation.scss';
|
||||
|
||||
export type OwnProps = {
|
||||
emojiInteraction: ActiveEmojiInteraction;
|
||||
};
|
||||
|
||||
type StateProps = {
|
||||
effectAnimationId?: string;
|
||||
localEffectAnimation?: string;
|
||||
isReversed?: boolean;
|
||||
};
|
||||
|
||||
const HIDE_ANIMATION_DURATION = 250;
|
||||
const PLAYING_DURATION = 3000;
|
||||
const END_SIZE = 1.125 * REM;
|
||||
const EFFECT_SIZE = 240;
|
||||
|
||||
const EmojiInteractionAnimation: FC<OwnProps & StateProps> = ({
|
||||
emojiInteraction,
|
||||
effectAnimationId,
|
||||
localEffectAnimation,
|
||||
isReversed,
|
||||
}) => {
|
||||
const { stopActiveEmojiInteraction } = getDispatch();
|
||||
|
||||
const [isHiding, startHiding] = useFlag(false);
|
||||
const [isPlaying, startPlaying] = useFlag(false);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
startHiding();
|
||||
setTimeout(() => {
|
||||
stopActiveEmojiInteraction();
|
||||
}, HIDE_ANIMATION_DURATION);
|
||||
}, [startHiding, stopActiveEmojiInteraction]);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('touchstart', stop);
|
||||
document.addEventListener('touchmove', stop);
|
||||
document.addEventListener('mousedown', stop);
|
||||
document.addEventListener('wheel', stop);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('touchstart', stop);
|
||||
document.removeEventListener('touchmove', stop);
|
||||
document.removeEventListener('mousedown', stop);
|
||||
document.removeEventListener('wheel', stop);
|
||||
};
|
||||
}, [stop]);
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(stop, PLAYING_DURATION);
|
||||
}, [stop]);
|
||||
|
||||
const mediaDataEffect = useMedia(`sticker${effectAnimationId}`, !effectAnimationId, ApiMediaFormat.Lottie);
|
||||
|
||||
const [localEffectAnimationData, setLocalEffectAnimationData] = useState<AnyLiteral>();
|
||||
useEffect(() => {
|
||||
if (localEffectAnimation) {
|
||||
getAnimationData(localEffectAnimation as keyof typeof ANIMATED_STICKERS_PATHS).then((data) => {
|
||||
setLocalEffectAnimationData(data);
|
||||
});
|
||||
}
|
||||
}, [localEffectAnimation]);
|
||||
|
||||
const scale = (emojiInteraction.startSize || 0) / EFFECT_SIZE;
|
||||
const endScale = END_SIZE / EFFECT_SIZE;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={buildClassName(
|
||||
'EmojiInteractionAnimation', isHiding && 'hiding', isPlaying && 'playing', isReversed && 'reversed',
|
||||
)}
|
||||
// @ts-ignore teact feature
|
||||
style={`--end-scale: ${endScale}; --scale: ${scale}; --start-x: ${emojiInteraction.x}px;`
|
||||
+ `--start-y: ${emojiInteraction.y}px;${
|
||||
emojiInteraction.endX
|
||||
&& emojiInteraction.endY ? `--end-x: ${emojiInteraction.endX}px; --end-y: ${emojiInteraction.endY}px;` : ''}`}
|
||||
>
|
||||
<AnimatedSticker
|
||||
id={`effect_${effectAnimationId}`}
|
||||
size={EFFECT_SIZE}
|
||||
animationData={(localEffectAnimationData || mediaDataEffect) as AnyLiteral}
|
||||
play={isPlaying}
|
||||
noLoop
|
||||
onLoad={startPlaying}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(withGlobal<OwnProps>(
|
||||
(global, { emojiInteraction }): StateProps => {
|
||||
const animatedEffect = emojiInteraction.animatedEffect !== undefined
|
||||
&& selectAnimatedEmojiEffect(global, emojiInteraction.animatedEffect);
|
||||
return {
|
||||
effectAnimationId: animatedEffect ? animatedEffect.id : undefined,
|
||||
localEffectAnimation: !animatedEffect && emojiInteraction.animatedEffect
|
||||
&& Object.keys(ANIMATED_STICKERS_PATHS).includes(emojiInteraction.animatedEffect)
|
||||
? emojiInteraction.animatedEffect : undefined,
|
||||
isReversed: emojiInteraction.isReversed,
|
||||
};
|
||||
},
|
||||
)(EmojiInteractionAnimation));
|
||||
@ -47,6 +47,7 @@ import fastSmoothScroll, { isAnimatingScroll } from '../../util/fastSmoothScroll
|
||||
import renderText from '../common/helpers/renderText';
|
||||
import useLang from '../../hooks/useLang';
|
||||
import useWindowSize from '../../hooks/useWindowSize';
|
||||
import useInterval from '../../hooks/useInterval';
|
||||
|
||||
import Loading from '../ui/Loading';
|
||||
import MessageListContent from './MessageListContent';
|
||||
@ -92,6 +93,7 @@ type StateProps = {
|
||||
lastSyncTime?: number;
|
||||
};
|
||||
|
||||
const MESSAGE_REACTIONS_POLLING_INTERVAL = 15 * 1000;
|
||||
const BOTTOM_THRESHOLD = 20;
|
||||
const UNREAD_DIVIDER_TOP = 10;
|
||||
const UNREAD_DIVIDER_TOP_WITH_TOOLS = 60;
|
||||
@ -135,7 +137,9 @@ const MessageList: FC<OwnProps & StateProps> = ({
|
||||
lastSyncTime,
|
||||
withBottomShift,
|
||||
}) => {
|
||||
const { loadViewportMessages, setScrollOffset, loadSponsoredMessages } = getDispatch();
|
||||
const {
|
||||
loadViewportMessages, setScrollOffset, loadSponsoredMessages, loadMessageReactions,
|
||||
} = getDispatch();
|
||||
|
||||
// eslint-disable-next-line no-null/no-null
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
@ -204,6 +208,17 @@ const MessageList: FC<OwnProps & StateProps> = ({
|
||||
return groupMessages(orderBy(listedMessages, ['date', 'id']), memoUnreadDividerBeforeIdRef.current);
|
||||
}, [messageIds, messagesById, threadFirstMessageId, threadTopMessageId]);
|
||||
|
||||
useInterval(() => {
|
||||
if (!messageIds || !messagesById) {
|
||||
return;
|
||||
}
|
||||
const ids = messageIds.filter((l) => messagesById[l]?.reactions);
|
||||
|
||||
if (!ids.length) return;
|
||||
|
||||
loadMessageReactions({ chatId, ids });
|
||||
}, MESSAGE_REACTIONS_POLLING_INTERVAL);
|
||||
|
||||
const loadMoreAround = useMemo(() => {
|
||||
if (type !== 'thread') {
|
||||
return undefined;
|
||||
@ -520,6 +535,7 @@ const MessageList: FC<OwnProps & StateProps> = ({
|
||||
isViewportNewest={Boolean(isViewportNewest)}
|
||||
isUnread={Boolean(firstUnreadId)}
|
||||
withUsers={withUsers}
|
||||
areReactionsInMeta={isPrivate}
|
||||
noAvatars={noAvatars}
|
||||
containerRef={containerRef}
|
||||
anchorIdRef={anchorIdRef}
|
||||
|
||||
@ -34,6 +34,7 @@ interface OwnProps {
|
||||
threadId: number;
|
||||
type: MessageListType;
|
||||
isReady: boolean;
|
||||
areReactionsInMeta: boolean;
|
||||
isScrollingRef: { current: boolean | undefined };
|
||||
isScrollPatchNeededRef: { current: boolean | undefined };
|
||||
threadTopMessageId: number | undefined;
|
||||
@ -53,6 +54,7 @@ const MessageListContent: FC<OwnProps> = ({
|
||||
isViewportNewest,
|
||||
isUnread,
|
||||
withUsers,
|
||||
areReactionsInMeta,
|
||||
noAvatars,
|
||||
containerRef,
|
||||
anchorIdRef,
|
||||
@ -188,6 +190,7 @@ const MessageListContent: FC<OwnProps> = ({
|
||||
noAvatars={noAvatars}
|
||||
withAvatar={position.isLastInGroup && withUsers && !isOwn && !(message.id === threadTopMessageId)}
|
||||
withSenderName={position.isFirstInGroup && withUsers && !isOwn}
|
||||
areReactionsInMeta={areReactionsInMeta}
|
||||
threadId={threadId}
|
||||
messageListType={type}
|
||||
noComments={hasLinkedChat === false}
|
||||
|
||||
@ -4,7 +4,11 @@ import React, {
|
||||
import { getDispatch, withGlobal } from '../../lib/teact/teactn';
|
||||
|
||||
import { ApiChatBannedRights, MAIN_THREAD_ID } from '../../api/types';
|
||||
import { MessageListType, MessageList as GlobalMessageList } from '../../global/types';
|
||||
import {
|
||||
MessageListType,
|
||||
MessageList as GlobalMessageList,
|
||||
ActiveEmojiInteraction,
|
||||
} from '../../global/types';
|
||||
import { ThemeKey } from '../../types';
|
||||
|
||||
import {
|
||||
@ -67,6 +71,8 @@ import UnpinAllMessagesModal from '../common/UnpinAllMessagesModal.async';
|
||||
import PaymentModal from '../payment/PaymentModal.async';
|
||||
import ReceiptModal from '../payment/ReceiptModal.async';
|
||||
import SeenByModal from '../common/SeenByModal.async';
|
||||
import EmojiInteractionAnimation from './EmojiInteractionAnimation.async';
|
||||
import ReactorListModal from './ReactorListModal.async';
|
||||
|
||||
import './MiddleColumn.scss';
|
||||
|
||||
@ -94,6 +100,7 @@ type StateProps = {
|
||||
isPaymentModalOpen?: boolean;
|
||||
isReceiptModalOpen?: boolean;
|
||||
isSeenByModalOpen: boolean;
|
||||
isReactorListModalOpen: boolean;
|
||||
animationLevel?: number;
|
||||
shouldSkipHistoryAnimations?: boolean;
|
||||
currentTransitionKey: number;
|
||||
@ -102,6 +109,7 @@ type StateProps = {
|
||||
canSubscribe?: boolean;
|
||||
canStartBot?: boolean;
|
||||
canRestartBot?: boolean;
|
||||
activeEmojiInteraction?: ActiveEmojiInteraction;
|
||||
};
|
||||
|
||||
const CLOSE_ANIMATION_DURATION = IS_SINGLE_COLUMN_LAYOUT ? 450 + ANIMATION_END_DELAY : undefined;
|
||||
@ -134,6 +142,7 @@ const MiddleColumn: FC<StateProps> = ({
|
||||
isPaymentModalOpen,
|
||||
isReceiptModalOpen,
|
||||
isSeenByModalOpen,
|
||||
isReactorListModalOpen,
|
||||
animationLevel,
|
||||
shouldSkipHistoryAnimations,
|
||||
currentTransitionKey,
|
||||
@ -141,6 +150,7 @@ const MiddleColumn: FC<StateProps> = ({
|
||||
canSubscribe,
|
||||
canStartBot,
|
||||
canRestartBot,
|
||||
activeEmojiInteraction,
|
||||
}) => {
|
||||
const {
|
||||
openChat,
|
||||
@ -484,6 +494,7 @@ const MiddleColumn: FC<StateProps> = ({
|
||||
onClose={clearReceipt}
|
||||
/>
|
||||
<SeenByModal isOpen={isSeenByModalOpen} />
|
||||
<ReactorListModal isOpen={isReactorListModalOpen} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@ -507,6 +518,9 @@ const MiddleColumn: FC<StateProps> = ({
|
||||
onUnpin={handleUnpinAllMessages}
|
||||
/>
|
||||
)}
|
||||
{activeEmojiInteraction && (
|
||||
<EmojiInteractionAnimation emojiInteraction={activeEmojiInteraction} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -520,7 +534,7 @@ export default memo(withGlobal(
|
||||
|
||||
const { messageLists } = global.messages;
|
||||
const currentMessageList = selectCurrentMessageList(global);
|
||||
const { isLeftColumnShown, chats: { listIds } } = global;
|
||||
const { isLeftColumnShown, chats: { listIds }, activeEmojiInteraction } = global;
|
||||
|
||||
const state: StateProps = {
|
||||
theme,
|
||||
@ -535,8 +549,10 @@ export default memo(withGlobal(
|
||||
isPaymentModalOpen: global.payment.isPaymentModalOpen,
|
||||
isReceiptModalOpen: Boolean(global.payment.receipt),
|
||||
isSeenByModalOpen: Boolean(global.seenByModal),
|
||||
isReactorListModalOpen: Boolean(global.reactorModal),
|
||||
animationLevel: global.settings.byKey.animationLevel,
|
||||
currentTransitionKey: Math.max(0, global.messages.messageLists.length - 1),
|
||||
activeEmojiInteraction,
|
||||
};
|
||||
|
||||
if (!currentMessageList || !listIds.active) {
|
||||
|
||||
15
src/components/middle/ReactorListModal.async.tsx
Normal file
15
src/components/middle/ReactorListModal.async.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
import React, { FC, memo } from '../../lib/teact/teact';
|
||||
import { OwnProps } from './ReactorListModal';
|
||||
import { Bundles } from '../../util/moduleLoader';
|
||||
|
||||
import useModuleLoader from '../../hooks/useModuleLoader';
|
||||
|
||||
const ReactorListModalAsync: FC<OwnProps> = (props) => {
|
||||
const { isOpen } = props;
|
||||
const ReactorListModal = useModuleLoader(Bundles.Extra, 'ReactorListModal', !isOpen);
|
||||
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
return ReactorListModal ? <ReactorListModal {...props} /> : undefined;
|
||||
};
|
||||
|
||||
export default memo(ReactorListModalAsync);
|
||||
38
src/components/middle/ReactorListModal.scss
Normal file
38
src/components/middle/ReactorListModal.scss
Normal file
@ -0,0 +1,38 @@
|
||||
.ReactorListModal {
|
||||
--color-reaction: var(--color-message-reaction);
|
||||
--hover-color-reaction: var(--color-message-reaction-hover);
|
||||
--accent-color: var(--color-primary);
|
||||
|
||||
.modal-content {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.Reactions {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.reaction-filter-emoji {
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
.reactor-list {
|
||||
max-height: 400px;
|
||||
overflow: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.reactors-list-item {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.reactors-list-item .ListItem-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.reactors-list-emoji {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
}
|
||||
203
src/components/middle/ReactorListModal.tsx
Normal file
203
src/components/middle/ReactorListModal.tsx
Normal file
@ -0,0 +1,203 @@
|
||||
import React, {
|
||||
FC, useCallback, memo, useMemo, useEffect, useState, useRef,
|
||||
} from '../../lib/teact/teact';
|
||||
import { getDispatch, getGlobal, withGlobal } from '../../lib/teact/teactn';
|
||||
|
||||
import { ApiMessage } from '../../api/types';
|
||||
import { LoadMoreDirection } from '../../types';
|
||||
|
||||
import useLang from '../../hooks/useLang';
|
||||
import { selectChatMessage } from '../../modules/selectors';
|
||||
import useInfiniteScroll from '../../hooks/useInfiniteScroll';
|
||||
import { getUserFullName } from '../../modules/helpers';
|
||||
import renderText from '../common/helpers/renderText';
|
||||
import useFlag from '../../hooks/useFlag';
|
||||
import buildClassName from '../../util/buildClassName';
|
||||
import { formatIntegerCompact } from '../../util/textFormat';
|
||||
import { unique } from '../../util/iteratees';
|
||||
|
||||
import InfiniteScroll from '../ui/InfiniteScroll';
|
||||
import Modal from '../ui/Modal';
|
||||
import Button from '../ui/Button';
|
||||
import Avatar from '../common/Avatar';
|
||||
import ListItem from '../ui/ListItem';
|
||||
import ReactionStaticEmoji from '../common/ReactionStaticEmoji';
|
||||
import Loading from '../ui/Loading';
|
||||
|
||||
import './ReactorListModal.scss';
|
||||
|
||||
const MIN_REACTIONS_COUNT_FOR_FILTERS = 10;
|
||||
|
||||
export type OwnProps = {
|
||||
isOpen: boolean;
|
||||
};
|
||||
|
||||
export type StateProps = Pick<ApiMessage, 'reactors' | 'reactions' | 'seenByUserIds'> & {
|
||||
chatId?: string;
|
||||
messageId?: number;
|
||||
};
|
||||
|
||||
const ReactorListModal: FC<OwnProps & StateProps> = ({
|
||||
isOpen,
|
||||
reactors,
|
||||
reactions,
|
||||
chatId,
|
||||
messageId,
|
||||
seenByUserIds,
|
||||
}) => {
|
||||
const {
|
||||
loadReactors,
|
||||
closeReactorListModal,
|
||||
openChat,
|
||||
} = getDispatch();
|
||||
|
||||
// No need for expensive global updates on users, so we avoid them
|
||||
const usersById = getGlobal().users.byId;
|
||||
|
||||
const lang = useLang();
|
||||
const [isClosing, startClosing, stopClosing] = useFlag(false);
|
||||
const [chosenTab, setChosenTab] = useState<string | undefined>(undefined);
|
||||
const canShowFilters = reactors && reactions && reactors.count >= MIN_REACTIONS_COUNT_FOR_FILTERS
|
||||
&& reactions.results.length > 1;
|
||||
const chatIdRef = useRef<string>();
|
||||
|
||||
useEffect(() => {
|
||||
if (isClosing && !isOpen) {
|
||||
stopClosing();
|
||||
setChosenTab(undefined);
|
||||
}
|
||||
}, [isClosing, isOpen, stopClosing]);
|
||||
|
||||
const handleCloseAnimationEnd = useCallback(() => {
|
||||
if (chatIdRef.current) {
|
||||
openChat({ id: chatIdRef.current });
|
||||
}
|
||||
closeReactorListModal();
|
||||
}, [closeReactorListModal, openChat]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
startClosing();
|
||||
}, [startClosing]);
|
||||
|
||||
const handleClick = useCallback((userId: string) => {
|
||||
chatIdRef.current = userId;
|
||||
handleClose();
|
||||
}, [handleClose]);
|
||||
|
||||
const handleLoadMore = useCallback(() => {
|
||||
loadReactors({
|
||||
chatId,
|
||||
messageId,
|
||||
});
|
||||
}, [chatId, loadReactors, messageId]);
|
||||
|
||||
const allReactions = useMemo(() => {
|
||||
return reactors?.reactions ? unique(reactors.reactions.map((l) => l.reaction)) : [];
|
||||
}, [reactors?.reactions]);
|
||||
|
||||
const userIds = useMemo(() => {
|
||||
if (chosenTab) {
|
||||
return reactors?.reactions.filter((l) => l.reaction === chosenTab).map((l) => l.userId);
|
||||
}
|
||||
return unique(reactors?.reactions.map((l) => l.userId).concat(seenByUserIds || []) || []);
|
||||
}, [chosenTab, reactors?.reactions, seenByUserIds]);
|
||||
|
||||
const [viewportIds, getMore] = useInfiniteScroll(
|
||||
handleLoadMore, userIds, reactors && reactors.nextOffset === undefined,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
getMore?.({ direction: LoadMoreDirection.Backwards });
|
||||
}, [getMore]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen && !isClosing}
|
||||
onClose={handleClose}
|
||||
className="ReactorListModal narrow"
|
||||
title={lang('Reactions')}
|
||||
onCloseAnimationEnd={handleCloseAnimationEnd}
|
||||
>
|
||||
{canShowFilters && (
|
||||
<div className="Reactions">
|
||||
<Button
|
||||
className={buildClassName(!chosenTab && 'chosen')}
|
||||
size="tiny"
|
||||
ripple
|
||||
onClick={() => setChosenTab(undefined)}
|
||||
>
|
||||
<i className="icon-reaction-filled" />
|
||||
{reactors?.count && formatIntegerCompact(reactors.count)}
|
||||
</Button>
|
||||
{allReactions.map((reaction) => {
|
||||
const count = reactions?.results.find((l) => l.reaction === reaction)?.count;
|
||||
return (
|
||||
<Button
|
||||
className={buildClassName(chosenTab === reaction && 'chosen')}
|
||||
size="tiny"
|
||||
ripple
|
||||
onClick={() => setChosenTab(reaction)}
|
||||
>
|
||||
<ReactionStaticEmoji reaction={reaction} className="reaction-filter-emoji" />
|
||||
{count && formatIntegerCompact(count)}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div dir={lang.isRtl ? 'rtl' : undefined}>
|
||||
{viewportIds?.length ? (
|
||||
<InfiniteScroll
|
||||
className="reactor-list custom-scroll"
|
||||
items={viewportIds}
|
||||
onLoadMore={getMore}
|
||||
>
|
||||
{viewportIds?.map(
|
||||
(userId) => {
|
||||
const user = usersById[userId];
|
||||
const fullName = getUserFullName(user);
|
||||
const reaction = reactors?.reactions.find((l) => l.userId === userId)?.reaction;
|
||||
return (
|
||||
<ListItem
|
||||
key={userId}
|
||||
className="chat-item-clickable reactors-list-item"
|
||||
onClick={() => handleClick(userId)}
|
||||
>
|
||||
<Avatar user={user} size="medium" />
|
||||
<div className="title">
|
||||
<h3 dir="auto">{fullName && renderText(fullName)}</h3>
|
||||
</div>
|
||||
{reaction && <ReactionStaticEmoji className="reactors-list-emoji" reaction={reaction} />}
|
||||
</ListItem>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</InfiniteScroll>
|
||||
) : <Loading />}
|
||||
</div>
|
||||
<Button
|
||||
className="confirm-dialog-button"
|
||||
isText
|
||||
onClick={closeReactorListModal}
|
||||
>
|
||||
{lang('Close')}
|
||||
</Button>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(withGlobal<OwnProps>(
|
||||
(global): StateProps => {
|
||||
const { chatId, messageId } = global.reactorModal || {};
|
||||
const message = chatId && messageId ? selectChatMessage(global, chatId, messageId) : undefined;
|
||||
|
||||
return {
|
||||
chatId,
|
||||
messageId,
|
||||
reactions: message?.reactions,
|
||||
reactors: message?.reactors,
|
||||
seenByUserIds: message?.seenByUserIds,
|
||||
};
|
||||
},
|
||||
)(ReactorListModal));
|
||||
@ -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) {
|
||||
|
||||
@ -4,7 +4,7 @@ import React, {
|
||||
import { getDispatch, getGlobal, withGlobal } from '../../../lib/teact/teactn';
|
||||
|
||||
import { MessageListType } from '../../../global/types';
|
||||
import { ApiMessage } from '../../../api/types';
|
||||
import { ApiAvailableReaction, ApiMessage } from '../../../api/types';
|
||||
import { IAlbum, IAnchorPosition } from '../../../types';
|
||||
import {
|
||||
selectActiveDownloadIds,
|
||||
@ -13,12 +13,16 @@ import {
|
||||
selectCurrentMessageList,
|
||||
selectIsMessageProtected,
|
||||
} from '../../../modules/selectors';
|
||||
import { isChatGroup, isOwnMessage } from '../../../modules/helpers';
|
||||
import { SEEN_BY_MEMBERS_EXPIRE, SEEN_BY_MEMBERS_CHAT_MAX } from '../../../config';
|
||||
import {
|
||||
isActionMessage, isChatChannel,
|
||||
isChatGroup, isOwnMessage, areReactionsEmpty, isUserId,
|
||||
} from '../../../modules/helpers';
|
||||
import { SEEN_BY_MEMBERS_EXPIRE, SEEN_BY_MEMBERS_CHAT_MAX, SERVICE_NOTIFICATIONS_USER_ID } from '../../../config';
|
||||
import { getDayStartAt } from '../../../util/dateFormat';
|
||||
import { copyTextToClipboard } from '../../../util/clipboard';
|
||||
import useShowTransition from '../../../hooks/useShowTransition';
|
||||
import useFlag from '../../../hooks/useFlag';
|
||||
import { REM } from '../../common/helpers/mediaDimensions';
|
||||
|
||||
import DeleteMessageModal from '../../common/DeleteMessageModal';
|
||||
import ReportMessageModal from '../../common/ReportMessageModal';
|
||||
@ -26,6 +30,8 @@ import PinMessageModal from '../../common/PinMessageModal';
|
||||
import MessageContextMenu from './MessageContextMenu';
|
||||
import CalendarModal from '../../common/CalendarModal';
|
||||
|
||||
const START_SIZE = 2 * REM;
|
||||
|
||||
export type OwnProps = {
|
||||
isOpen: boolean;
|
||||
chatUsername?: string;
|
||||
@ -38,11 +44,15 @@ export type OwnProps = {
|
||||
};
|
||||
|
||||
type StateProps = {
|
||||
availableReactions?: ApiAvailableReaction[];
|
||||
noOptions?: boolean;
|
||||
canSendNow?: boolean;
|
||||
canReschedule?: boolean;
|
||||
canReply?: boolean;
|
||||
canPin?: boolean;
|
||||
canShowReactionsCount?: boolean;
|
||||
canShowReactionList?: boolean;
|
||||
canRemoveReaction?: boolean;
|
||||
canUnpin?: boolean;
|
||||
canDelete?: boolean;
|
||||
canReport?: boolean;
|
||||
@ -51,14 +61,18 @@ type StateProps = {
|
||||
canFaveSticker?: boolean;
|
||||
canUnfaveSticker?: boolean;
|
||||
canCopy?: boolean;
|
||||
isPrivate?: boolean;
|
||||
hasFullInfo?: boolean;
|
||||
canCopyLink?: boolean;
|
||||
canSelect?: boolean;
|
||||
canDownload?: boolean;
|
||||
activeDownloads: number[];
|
||||
canShowSeenBy?: boolean;
|
||||
enabledReactions?: string[];
|
||||
};
|
||||
|
||||
const ContextMenuContainer: FC<OwnProps & StateProps> = ({
|
||||
availableReactions,
|
||||
isOpen,
|
||||
messageListType,
|
||||
chatUsername,
|
||||
@ -69,13 +83,19 @@ const ContextMenuContainer: FC<OwnProps & StateProps> = ({
|
||||
onCloseAnimationEnd,
|
||||
noOptions,
|
||||
canSendNow,
|
||||
hasFullInfo,
|
||||
canReschedule,
|
||||
canReply,
|
||||
canPin,
|
||||
canUnpin,
|
||||
canDelete,
|
||||
canReport,
|
||||
canShowReactionsCount,
|
||||
canShowReactionList,
|
||||
canRemoveReaction,
|
||||
canEdit,
|
||||
enabledReactions,
|
||||
isPrivate,
|
||||
canForward,
|
||||
canFaveSticker,
|
||||
canUnfaveSticker,
|
||||
@ -100,6 +120,10 @@ const ContextMenuContainer: FC<OwnProps & StateProps> = ({
|
||||
cancelMessageMediaDownload,
|
||||
loadSeenBy,
|
||||
openSeenByModal,
|
||||
sendReaction,
|
||||
openReactorListModal,
|
||||
loadFullChat,
|
||||
loadReactors,
|
||||
} = getDispatch();
|
||||
|
||||
const { transitionClassNames } = useShowTransition(isOpen, onCloseAnimationEnd, undefined, false);
|
||||
@ -115,7 +139,26 @@ const ContextMenuContainer: FC<OwnProps & StateProps> = ({
|
||||
}
|
||||
}, [loadSeenBy, isOpen, message.chatId, message.id, canShowSeenBy]);
|
||||
|
||||
useEffect(() => {
|
||||
if (canShowReactionsCount && isOpen) {
|
||||
loadReactors({ chatId: message.chatId, messageId: message.id });
|
||||
}
|
||||
}, [canShowReactionsCount, isOpen, loadReactors, message.chatId, message.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasFullInfo && !isPrivate && isOpen) {
|
||||
loadFullChat({ chatId: message.chatId });
|
||||
}
|
||||
}, [hasFullInfo, isOpen, isPrivate, loadFullChat, message.chatId]);
|
||||
|
||||
const seenByRecentUsers = useMemo(() => {
|
||||
if (message.reactions?.recentReactions?.length) {
|
||||
// No need for expensive global updates on users, so we avoid them
|
||||
const usersById = getGlobal().users.byId;
|
||||
|
||||
return message.reactions?.recentReactions?.slice(0, 3).map(({ userId }) => usersById[userId]).filter(Boolean);
|
||||
}
|
||||
|
||||
if (!message.seenByUserIds) {
|
||||
return undefined;
|
||||
}
|
||||
@ -123,7 +166,7 @@ const ContextMenuContainer: FC<OwnProps & StateProps> = ({
|
||||
// No need for expensive global updates on users, so we avoid them
|
||||
const usersById = getGlobal().users.byId;
|
||||
return message.seenByUserIds?.slice(0, 3).map((id) => usersById[id]).filter(Boolean);
|
||||
}, [message.seenByUserIds]);
|
||||
}, [message.reactions?.recentReactions, message.seenByUserIds]);
|
||||
|
||||
const isDownloading = album ? album.messages.some((msg) => activeDownloads.includes(msg.id))
|
||||
: activeDownloads.includes(message.id);
|
||||
@ -231,6 +274,11 @@ const ContextMenuContainer: FC<OwnProps & StateProps> = ({
|
||||
openSeenByModal({ chatId: message.chatId, messageId: message.id });
|
||||
}, [closeMenu, message.chatId, message.id, openSeenByModal]);
|
||||
|
||||
const handleOpenReactorListModal = useCallback(() => {
|
||||
closeMenu();
|
||||
openReactorListModal({ chatId: message.chatId, messageId: message.id });
|
||||
}, [closeMenu, openReactorListModal, message.chatId, message.id]);
|
||||
|
||||
const handleRescheduleMessage = useCallback((date: Date) => {
|
||||
rescheduleMessage({
|
||||
chatId: message.chatId,
|
||||
@ -255,6 +303,13 @@ const ContextMenuContainer: FC<OwnProps & StateProps> = ({
|
||||
closeMenu();
|
||||
}, [album, message, closeMenu, isDownloading, cancelMessageMediaDownload, downloadMessageMedia]);
|
||||
|
||||
const handleSendReaction = useCallback((reaction: string | undefined, x: number, y: number) => {
|
||||
sendReaction({
|
||||
chatId: message.chatId, messageId: message.id, reaction, x, y, startSize: START_SIZE,
|
||||
});
|
||||
closeMenu();
|
||||
}, [closeMenu, message.chatId, message.id, sendReaction]);
|
||||
|
||||
const reportMessageIds = useMemo(() => (album ? album.messages : [message]).map(({ id }) => id), [album, message]);
|
||||
|
||||
if (noOptions) {
|
||||
@ -269,9 +324,15 @@ const ContextMenuContainer: FC<OwnProps & StateProps> = ({
|
||||
return (
|
||||
<div className={['ContextMenuContainer', transitionClassNames].join(' ')}>
|
||||
<MessageContextMenu
|
||||
availableReactions={availableReactions}
|
||||
message={message}
|
||||
isPrivate={isPrivate}
|
||||
isOpen={isMenuOpen}
|
||||
enabledReactions={enabledReactions}
|
||||
anchor={anchor}
|
||||
canShowReactionsCount={canShowReactionsCount}
|
||||
canShowReactionList={canShowReactionList}
|
||||
canRemoveReaction={canRemoveReaction}
|
||||
canSendNow={canSendNow}
|
||||
canReschedule={canReschedule}
|
||||
canReply={canReply}
|
||||
@ -306,6 +367,8 @@ const ContextMenuContainer: FC<OwnProps & StateProps> = ({
|
||||
onCopyLink={handleCopyLink}
|
||||
onDownload={handleDownloadClick}
|
||||
onShowSeenBy={handleOpenSeenByModal}
|
||||
onSendReaction={handleSendReaction}
|
||||
onShowReactors={handleOpenReactorListModal}
|
||||
/>
|
||||
<DeleteMessageModal
|
||||
isOpen={isDeleteModalOpen}
|
||||
@ -361,15 +424,22 @@ export default memo(withGlobal<OwnProps>(
|
||||
} = (threadId && selectAllowedMessageActions(global, message, threadId)) || {};
|
||||
const isPinned = messageListType === 'pinned';
|
||||
const isScheduled = messageListType === 'scheduled';
|
||||
const isChannel = chat && isChatChannel(chat);
|
||||
const canShowSeenBy = Boolean(chat
|
||||
&& isChatGroup(chat)
|
||||
&& isOwnMessage(message)
|
||||
&& chat.membersCount
|
||||
&& chat.membersCount < SEEN_BY_MEMBERS_CHAT_MAX
|
||||
&& message.date > Date.now() / 1000 - SEEN_BY_MEMBERS_EXPIRE);
|
||||
const isPrivate = chat && isUserId(chat.id);
|
||||
const isAction = isActionMessage(message);
|
||||
const canShowReactionsCount = !isChannel && !isScheduled && !isAction && !isPrivate && message.reactions
|
||||
&& !areReactionsEmpty(message.reactions) && message.reactions.canSeeList;
|
||||
const canRemoveReaction = isPrivate && message.reactions?.results?.some((l) => l.isChosen);
|
||||
const isProtected = selectIsMessageProtected(global, message);
|
||||
|
||||
return {
|
||||
availableReactions: global.availableReactions,
|
||||
noOptions,
|
||||
canSendNow: isScheduled,
|
||||
canReschedule: isScheduled,
|
||||
@ -388,6 +458,12 @@ export default memo(withGlobal<OwnProps>(
|
||||
canDownload: !isProtected && canDownload,
|
||||
activeDownloads,
|
||||
canShowSeenBy,
|
||||
enabledReactions: chat?.fullInfo?.enabledReactions,
|
||||
isPrivate,
|
||||
hasFullInfo: Boolean(chat?.fullInfo),
|
||||
canShowReactionsCount,
|
||||
canShowReactionList: !isAction && !isScheduled && chat?.id !== SERVICE_NOTIFICATIONS_USER_ID,
|
||||
canRemoveReaction,
|
||||
};
|
||||
},
|
||||
)(ContextMenuContainer));
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -8,14 +8,14 @@ import React, {
|
||||
} from '../../../lib/teact/teact';
|
||||
import { getDispatch, withGlobal } from '../../../lib/teact/teactn';
|
||||
|
||||
import { MessageListType } from '../../../global/types';
|
||||
import { ActiveEmojiInteraction, ActiveReaction, MessageListType } from '../../../global/types';
|
||||
import {
|
||||
ApiMessage,
|
||||
ApiMessageOutgoingStatus,
|
||||
ApiUser,
|
||||
ApiChat,
|
||||
ApiSticker,
|
||||
ApiThreadInfo,
|
||||
ApiThreadInfo, ApiAvailableReaction,
|
||||
} from '../../../api/types';
|
||||
import {
|
||||
AudioOrigin, FocusDirection, IAlbum, ISettings,
|
||||
@ -45,7 +45,13 @@ import {
|
||||
selectAllowedMessageActions,
|
||||
selectIsDownloading,
|
||||
selectThreadInfo,
|
||||
selectAnimatedEmojiEffect,
|
||||
selectAnimatedEmojiSound,
|
||||
selectMessageIdsByGroupId,
|
||||
selectLocalAnimatedEmoji,
|
||||
selectIsMessageProtected,
|
||||
selectLocalAnimatedEmojiEffect,
|
||||
selectDefaultReaction,
|
||||
} from '../../../modules/selectors';
|
||||
import {
|
||||
getMessageContent,
|
||||
@ -60,6 +66,7 @@ import {
|
||||
getMessageSingleEmoji,
|
||||
getSenderTitle,
|
||||
getUserColorKey,
|
||||
areReactionsEmpty,
|
||||
} from '../../../modules/helpers';
|
||||
import buildClassName from '../../../util/buildClassName';
|
||||
import useEnsureMessage from '../../../hooks/useEnsureMessage';
|
||||
@ -98,6 +105,9 @@ import Album from './Album';
|
||||
import RoundVideo from './RoundVideo';
|
||||
import InlineButtons from './InlineButtons';
|
||||
import CommentButton from './CommentButton';
|
||||
import Reactions from './Reactions';
|
||||
import ReactionStaticEmoji from '../../common/ReactionStaticEmoji';
|
||||
import LocalAnimatedEmoji from '../../common/LocalAnimatedEmoji';
|
||||
|
||||
import './Message.scss';
|
||||
|
||||
@ -119,6 +129,7 @@ type OwnProps =
|
||||
noAvatars?: boolean;
|
||||
withAvatar?: boolean;
|
||||
withSenderName?: boolean;
|
||||
areReactionsInMeta?: boolean;
|
||||
threadId: number;
|
||||
messageListType: MessageListType;
|
||||
noComments: boolean;
|
||||
@ -136,6 +147,7 @@ type StateProps = {
|
||||
isThreadTop?: boolean;
|
||||
shouldHideReply?: boolean;
|
||||
replyMessage?: ApiMessage;
|
||||
reactionsMessage?: ApiMessage;
|
||||
replyMessageSender?: ApiUser | ApiChat;
|
||||
outgoingStatus?: ApiMessageOutgoingStatus;
|
||||
uploadProgress?: number;
|
||||
@ -153,6 +165,10 @@ type StateProps = {
|
||||
highlight?: string;
|
||||
isSingleEmoji?: boolean;
|
||||
animatedEmoji?: ApiSticker;
|
||||
localSticker?: string;
|
||||
localEffect?: string;
|
||||
animatedEmojiEffect?: ApiSticker;
|
||||
animatedEmojiSoundId?: string;
|
||||
isInSelectMode?: boolean;
|
||||
isSelected?: boolean;
|
||||
isGroupSelected?: boolean;
|
||||
@ -164,10 +180,13 @@ type StateProps = {
|
||||
shouldLoopStickers?: boolean;
|
||||
autoLoadFileMaxSizeMb: number;
|
||||
threadInfo?: ApiThreadInfo;
|
||||
defaultReaction?: string;
|
||||
activeReaction?: ActiveReaction;
|
||||
activeEmojiInteraction?: ActiveEmojiInteraction;
|
||||
availableReactions?: ApiAvailableReaction[];
|
||||
};
|
||||
|
||||
const NBSP = '\u00A0';
|
||||
const GROUP_MESSAGE_HOVER_ATTRIBUTE = 'data-is-document-group-hover';
|
||||
// eslint-disable-next-line max-len
|
||||
const APPENDIX_OWN = { __html: '<svg width="9" height="20" xmlns="http://www.w3.org/2000/svg"><defs><filter x="-50%" y="-14.7%" width="200%" height="141.2%" filterUnits="objectBoundingBox" id="a"><feOffset dy="1" in="SourceAlpha" result="shadowOffsetOuter1"/><feGaussianBlur stdDeviation="1" in="shadowOffsetOuter1" result="shadowBlurOuter1"/><feColorMatrix values="0 0 0 0 0.0621962482 0 0 0 0 0.138574144 0 0 0 0 0.185037364 0 0 0 0.15 0" in="shadowBlurOuter1"/></filter></defs><g fill="none" fill-rule="evenodd"><path d="M6 17H0V0c.193 2.84.876 5.767 2.05 8.782.904 2.325 2.446 4.485 4.625 6.48A1 1 0 016 17z" fill="#000" filter="url(#a)"/><path d="M6 17H0V0c.193 2.84.876 5.767 2.05 8.782.904 2.325 2.446 4.485 4.625 6.48A1 1 0 016 17z" fill="#EEFFDE" class="corner"/></g></svg>' };
|
||||
// eslint-disable-next-line max-len
|
||||
@ -185,6 +204,8 @@ const Message: FC<OwnProps & StateProps> = ({
|
||||
noAvatars,
|
||||
withAvatar,
|
||||
withSenderName,
|
||||
areReactionsInMeta,
|
||||
reactionsMessage,
|
||||
noComments,
|
||||
appearanceOrder,
|
||||
isFirstInGroup,
|
||||
@ -216,10 +237,18 @@ const Message: FC<OwnProps & StateProps> = ({
|
||||
lastSyncTime,
|
||||
highlight,
|
||||
animatedEmoji,
|
||||
localSticker,
|
||||
localEffect,
|
||||
animatedEmojiEffect,
|
||||
animatedEmojiSoundId,
|
||||
isInSelectMode,
|
||||
isSelected,
|
||||
isGroupSelected,
|
||||
threadId,
|
||||
defaultReaction,
|
||||
activeReaction,
|
||||
activeEmojiInteraction,
|
||||
availableReactions,
|
||||
messageListType,
|
||||
isPinnedList,
|
||||
isDownloading,
|
||||
@ -239,6 +268,8 @@ const Message: FC<OwnProps & StateProps> = ({
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
// eslint-disable-next-line no-null/no-null
|
||||
const bottomMarkerRef = useRef<HTMLDivElement>(null);
|
||||
// eslint-disable-next-line no-null/no-null
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const lang = useLang();
|
||||
|
||||
@ -288,6 +319,8 @@ const Message: FC<OwnProps & StateProps> = ({
|
||||
const {
|
||||
text, photo, video, audio, voice, document, sticker, contact, poll, webPage, invoice,
|
||||
} = getMessageContent(message);
|
||||
const hasReactionButtons = !areReactionsInMeta && reactionsMessage?.reactions
|
||||
&& !areReactionsEmpty(reactionsMessage.reactions);
|
||||
const textParts = renderMessageText(message, highlight, isEmojiOnlyMessage(customShape));
|
||||
const isContextMenuShown = contextMenuPosition !== undefined;
|
||||
const signature = (
|
||||
@ -308,6 +341,9 @@ const Message: FC<OwnProps & StateProps> = ({
|
||||
));
|
||||
const avatarPeer = forwardInfo && (isChatWithSelf || isRepliesChat || !sender) ? originSender : sender;
|
||||
const senderPeer = forwardInfo ? originSender : sender;
|
||||
const hasAnimatedEmoji = localSticker || animatedEmoji;
|
||||
const areReactionsOutside = hasReactionButtons
|
||||
&& (asForwarded || customShape || ((photo || video || hasAnimatedEmoji) && !textParts));
|
||||
|
||||
const selectMessage = useCallback((e?: React.MouseEvent<HTMLDivElement, MouseEvent>, groupedId?: string) => {
|
||||
toggleMessageSelection({
|
||||
@ -324,7 +360,12 @@ const Message: FC<OwnProps & StateProps> = ({
|
||||
handleContextMenu,
|
||||
handleDoubleClick,
|
||||
handleContentDoubleClick,
|
||||
handleMouseMove,
|
||||
handleSendQuickReaction,
|
||||
handleMouseLeave,
|
||||
isSwiped,
|
||||
isQuickReactionVisible,
|
||||
handleDocumentGroupMouseEnter,
|
||||
} = useOuterHandlers(
|
||||
selectMessage,
|
||||
ref,
|
||||
@ -335,6 +376,11 @@ const Message: FC<OwnProps & StateProps> = ({
|
||||
Boolean(isProtected),
|
||||
onContextMenu,
|
||||
handleBeforeContextMenu,
|
||||
chatId,
|
||||
isContextMenuShown,
|
||||
contentRef,
|
||||
isOwn,
|
||||
isInDocumentGroup && !isLastInDocumentGroup,
|
||||
);
|
||||
|
||||
const {
|
||||
@ -395,6 +441,7 @@ const Message: FC<OwnProps & StateProps> = ({
|
||||
Boolean(message.inlineButtons) && 'has-inline-buttons',
|
||||
isSwiped && 'is-swiped',
|
||||
transitionClassNames,
|
||||
Boolean(activeReaction) && 'has-active-reaction',
|
||||
);
|
||||
const contentClassName = buildContentClassName(message, {
|
||||
hasReply,
|
||||
@ -410,6 +457,7 @@ const Message: FC<OwnProps & StateProps> = ({
|
||||
threadInfo && (!isInDocumentGroup || isLastInDocumentGroup) && messageListType === 'thread' && !noComments
|
||||
);
|
||||
const withAppendix = contentClassName.includes('has-appendix');
|
||||
const withQuickReaction = !IS_TOUCH_ENV && defaultReaction && (!isInDocumentGroup || isLastInDocumentGroup);
|
||||
|
||||
useEnsureMessage(
|
||||
isRepliesChat && message.replyToChatId ? message.replyToChatId : chatId,
|
||||
@ -474,6 +522,21 @@ const Message: FC<OwnProps & StateProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
function renderMeta(withReactionOffset = false) {
|
||||
return (
|
||||
<MessageMeta
|
||||
message={message}
|
||||
withReactionOffset={withReactionOffset}
|
||||
withReactions={areReactionsInMeta}
|
||||
outgoingStatus={outgoingStatus}
|
||||
signature={signature}
|
||||
onClick={handleMetaClick}
|
||||
activeReaction={activeReaction}
|
||||
availableReactions={availableReactions}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function renderContent() {
|
||||
const className = buildClassName(
|
||||
'content-inner',
|
||||
@ -482,7 +545,7 @@ const Message: FC<OwnProps & StateProps> = ({
|
||||
noMediaCorners && 'no-media-corners',
|
||||
);
|
||||
const hasCustomAppendix = isLastInGroup && !textParts && !asForwarded && !hasThread;
|
||||
const shouldInlineMeta = !webPage && !animatedEmoji && textParts;
|
||||
const shouldInlineMeta = !webPage && !hasAnimatedEmoji && textParts;
|
||||
const textContentClass = buildClassName(
|
||||
'text-content',
|
||||
shouldInlineMeta && 'with-meta',
|
||||
@ -513,10 +576,31 @@ const Message: FC<OwnProps & StateProps> = ({
|
||||
{animatedEmoji && (
|
||||
<AnimatedEmoji
|
||||
size="small"
|
||||
isOwn={isOwn}
|
||||
sticker={animatedEmoji}
|
||||
effect={animatedEmojiEffect}
|
||||
soundId={animatedEmojiSoundId}
|
||||
observeIntersection={observeIntersectionForMedia}
|
||||
lastSyncTime={lastSyncTime}
|
||||
forceLoadPreview={isLocal}
|
||||
messageId={messageId}
|
||||
chatId={chatId}
|
||||
activeEmojiInteraction={activeEmojiInteraction}
|
||||
/>
|
||||
)}
|
||||
{localSticker && (
|
||||
<LocalAnimatedEmoji
|
||||
size="small"
|
||||
isOwn={isOwn}
|
||||
localSticker={localSticker}
|
||||
localEffect={localEffect}
|
||||
soundId={animatedEmojiSoundId}
|
||||
observeIntersection={observeIntersectionForMedia}
|
||||
lastSyncTime={lastSyncTime}
|
||||
forceLoadPreview={isLocal}
|
||||
messageId={messageId}
|
||||
chatId={chatId}
|
||||
activeEmojiInteraction={activeEmojiInteraction}
|
||||
/>
|
||||
)}
|
||||
{isAlbum && (
|
||||
@ -605,19 +689,25 @@ const Message: FC<OwnProps & StateProps> = ({
|
||||
{poll && (
|
||||
<Poll message={message} poll={poll} onSendVote={handleVoteSend} />
|
||||
)}
|
||||
{!animatedEmoji && textParts && (
|
||||
{!hasAnimatedEmoji && textParts && (
|
||||
<p className={textContentClass} dir="auto">
|
||||
{textParts}
|
||||
{shouldInlineMeta && (
|
||||
<MessageMeta
|
||||
message={message}
|
||||
outgoingStatus={outgoingStatus}
|
||||
signature={signature}
|
||||
onClick={handleMetaClick}
|
||||
/>
|
||||
<>
|
||||
{hasReactionButtons && !areReactionsOutside ? (
|
||||
<Reactions
|
||||
activeReaction={activeReaction}
|
||||
message={reactionsMessage!}
|
||||
metaChildren={renderMeta(true)}
|
||||
availableReactions={availableReactions}
|
||||
/>
|
||||
)
|
||||
: renderMeta()}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{webPage && (
|
||||
<WebPage
|
||||
message={message}
|
||||
@ -705,7 +795,8 @@ const Message: FC<OwnProps & StateProps> = ({
|
||||
onContextMenu={handleContextMenu}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onMouseEnter={isInDocumentGroup && !isLastInDocumentGroup ? handleDocumentGroupMouseEnter : undefined}
|
||||
onMouseLeave={isInDocumentGroup && !isLastInDocumentGroup ? handleDocumentGroupMouseLeave : undefined}
|
||||
onMouseMove={withQuickReaction ? handleMouseMove : undefined}
|
||||
onMouseLeave={(withQuickReaction || (isInDocumentGroup && !isLastInDocumentGroup)) ? handleMouseLeave : undefined}
|
||||
>
|
||||
<div
|
||||
ref={bottomMarkerRef}
|
||||
@ -734,6 +825,7 @@ const Message: FC<OwnProps & StateProps> = ({
|
||||
className={buildClassName('message-content-wrapper', contentClassName.includes('text') && 'can-select-text')}
|
||||
>
|
||||
<div
|
||||
ref={contentRef}
|
||||
className={contentClassName}
|
||||
// @ts-ignore
|
||||
style={style}
|
||||
@ -743,13 +835,17 @@ const Message: FC<OwnProps & StateProps> = ({
|
||||
<div className="message-title">{lang('ForwardedMessage')}</div>
|
||||
)}
|
||||
{renderContent()}
|
||||
{(!isInDocumentGroup || isLastInDocumentGroup) && !(!webPage && !animatedEmoji && textParts) && (
|
||||
<MessageMeta
|
||||
message={message}
|
||||
outgoingStatus={outgoingStatus}
|
||||
signature={signature}
|
||||
onClick={handleMetaClick}
|
||||
/>
|
||||
{(!isInDocumentGroup || isLastInDocumentGroup) && !(!webPage && !hasAnimatedEmoji && textParts) && (
|
||||
<>
|
||||
{hasReactionButtons && !areReactionsOutside && (hasAnimatedEmoji || !textParts || webPage) ? (
|
||||
<Reactions
|
||||
activeReaction={activeReaction}
|
||||
message={reactionsMessage!}
|
||||
metaChildren={renderMeta(true)}
|
||||
availableReactions={availableReactions}
|
||||
/>
|
||||
) : renderMeta()}
|
||||
</>
|
||||
)}
|
||||
{canShowActionButton && canForward ? (
|
||||
<Button
|
||||
@ -778,10 +874,26 @@ const Message: FC<OwnProps & StateProps> = ({
|
||||
{withAppendix && (
|
||||
<div className="svg-appendix" dangerouslySetInnerHTML={isOwn ? APPENDIX_OWN : APPENDIX_NOT_OWN} />
|
||||
)}
|
||||
{withQuickReaction && (
|
||||
<div
|
||||
className={buildClassName('quick-reaction', isQuickReactionVisible && !activeReaction && 'visible')}
|
||||
onClick={handleSendQuickReaction}
|
||||
>
|
||||
<ReactionStaticEmoji reaction={defaultReaction!} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{message.inlineButtons && (
|
||||
<InlineButtons message={message} onClick={clickInlineButton} />
|
||||
)}
|
||||
{areReactionsOutside && (
|
||||
<Reactions
|
||||
message={reactionsMessage!}
|
||||
isOutside
|
||||
activeReaction={activeReaction}
|
||||
availableReactions={availableReactions}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{contextMenuPosition && (
|
||||
<ContextMenuContainer
|
||||
@ -799,35 +911,11 @@ const Message: FC<OwnProps & StateProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
function handleDocumentGroupMouseEnter(e: React.MouseEvent<HTMLDivElement>) {
|
||||
const lastGroupElement = getLastElementInDocumentGroup(e.currentTarget);
|
||||
if (lastGroupElement) {
|
||||
lastGroupElement.setAttribute(GROUP_MESSAGE_HOVER_ATTRIBUTE, '');
|
||||
}
|
||||
}
|
||||
|
||||
function handleDocumentGroupMouseLeave(e: React.MouseEvent<HTMLDivElement>) {
|
||||
const lastGroupElement = getLastElementInDocumentGroup(e.currentTarget);
|
||||
if (lastGroupElement) {
|
||||
lastGroupElement.removeAttribute(GROUP_MESSAGE_HOVER_ATTRIBUTE);
|
||||
}
|
||||
}
|
||||
|
||||
function getLastElementInDocumentGroup(element: Element) {
|
||||
let current: Element | null = element;
|
||||
|
||||
do {
|
||||
current = current.nextElementSibling;
|
||||
} while (current && !current.classList.contains('last-in-document-group'));
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
export default memo(withGlobal<OwnProps>(
|
||||
(global, ownProps): StateProps => {
|
||||
const { focusedMessage, forwardMessages, lastSyncTime } = global;
|
||||
const {
|
||||
message, album, withSenderName, withAvatar, threadId, messageListType,
|
||||
message, album, withSenderName, withAvatar, threadId, messageListType, isLastInDocumentGroup,
|
||||
} = ownProps;
|
||||
const {
|
||||
id, chatId, viaBotId, replyToChatId, replyToMessageId, isOutgoing, threadInfo,
|
||||
@ -884,6 +972,16 @@ export default memo(withGlobal<OwnProps>(
|
||||
? selectThreadInfo(global, threadInfo.chatId, threadInfo.threadId) || threadInfo
|
||||
: undefined;
|
||||
|
||||
const firstMessageInGroupId = message.groupedId
|
||||
&& selectMessageIdsByGroupId(global, chatId, message.groupedId)?.[0];
|
||||
const reactionsMessage = firstMessageInGroupId && !album
|
||||
? (isLastInDocumentGroup
|
||||
? selectChatMessage(global, chatId, firstMessageInGroupId)
|
||||
: undefined)
|
||||
: message;
|
||||
|
||||
const localSticker = singleEmoji ? selectLocalAnimatedEmoji(global, singleEmoji) : undefined;
|
||||
|
||||
return {
|
||||
theme: selectTheme(global),
|
||||
chatUsername,
|
||||
@ -898,6 +996,7 @@ export default memo(withGlobal<OwnProps>(
|
||||
isProtected: selectIsMessageProtected(global, message),
|
||||
isFocused,
|
||||
isForwarding,
|
||||
reactionsMessage,
|
||||
isChatWithSelf,
|
||||
isRepliesChat,
|
||||
isChannel,
|
||||
@ -906,6 +1005,10 @@ export default memo(withGlobal<OwnProps>(
|
||||
highlight,
|
||||
isSingleEmoji: Boolean(singleEmoji),
|
||||
animatedEmoji: singleEmoji ? selectAnimatedEmoji(global, singleEmoji) : undefined,
|
||||
animatedEmojiEffect: singleEmoji && isUserId(chatId) ? selectAnimatedEmojiEffect(global, singleEmoji) : undefined,
|
||||
animatedEmojiSoundId: singleEmoji ? selectAnimatedEmojiSound(global, singleEmoji) : undefined,
|
||||
localSticker,
|
||||
localEffect: localSticker && isUserId(chatId) ? selectLocalAnimatedEmojiEffect(localSticker) : undefined,
|
||||
isInSelectMode: selectIsInSelectMode(global),
|
||||
isSelected,
|
||||
isGroupSelected: (
|
||||
@ -922,6 +1025,10 @@ export default memo(withGlobal<OwnProps>(
|
||||
...(isOutgoing && { outgoingStatus: selectOutgoingStatus(global, message, messageListType === 'scheduled') }),
|
||||
...(typeof uploadProgress === 'number' && { uploadProgress }),
|
||||
...(isFocused && { focusDirection, noFocusHighlight, isResizingContainer }),
|
||||
defaultReaction: selectDefaultReaction(global, chatId),
|
||||
activeReaction: global.activeReactions[id],
|
||||
activeEmojiInteraction: global.activeEmojiInteraction,
|
||||
availableReactions: global.availableReactions,
|
||||
};
|
||||
},
|
||||
)(Message));
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -2,31 +2,39 @@ import React, {
|
||||
FC, memo, useCallback, useEffect, useRef,
|
||||
} from '../../../lib/teact/teact';
|
||||
|
||||
import { ApiMessage, ApiUser } from '../../../api/types';
|
||||
import { ApiAvailableReaction, ApiMessage, ApiUser } from '../../../api/types';
|
||||
import { IAnchorPosition } from '../../../types';
|
||||
|
||||
import { getMessageCopyOptions } from './helpers/copyOptions';
|
||||
import { disableScrolling, enableScrolling } from '../../../util/scrollLock';
|
||||
import useContextMenuPosition from '../../../hooks/useContextMenuPosition';
|
||||
import useLang from '../../../hooks/useLang';
|
||||
import buildClassName from '../../../util/buildClassName';
|
||||
import useFlag from '../../../hooks/useFlag';
|
||||
|
||||
import Menu from '../../ui/Menu';
|
||||
import MenuItem from '../../ui/MenuItem';
|
||||
import Avatar from '../../common/Avatar';
|
||||
import ReactionSelector from './ReactionSelector';
|
||||
|
||||
import './MessageContextMenu.scss';
|
||||
|
||||
type OwnProps = {
|
||||
availableReactions?: ApiAvailableReaction[];
|
||||
isOpen: boolean;
|
||||
anchor: IAnchorPosition;
|
||||
message: ApiMessage;
|
||||
canSendNow?: boolean;
|
||||
enabledReactions?: string[];
|
||||
canReschedule?: boolean;
|
||||
canReply?: boolean;
|
||||
canPin?: boolean;
|
||||
canUnpin?: boolean;
|
||||
canDelete?: boolean;
|
||||
canReport?: boolean;
|
||||
canShowReactionsCount?: boolean;
|
||||
canShowReactionList?: boolean;
|
||||
canRemoveReaction?: boolean;
|
||||
canEdit?: boolean;
|
||||
canForward?: boolean;
|
||||
canFaveSticker?: boolean;
|
||||
@ -34,6 +42,7 @@ type OwnProps = {
|
||||
canCopy?: boolean;
|
||||
canCopyLink?: boolean;
|
||||
canSelect?: boolean;
|
||||
isPrivate?: boolean;
|
||||
canDownload?: boolean;
|
||||
isDownloading?: boolean;
|
||||
canShowSeenBy?: boolean;
|
||||
@ -55,13 +64,20 @@ type OwnProps = {
|
||||
onCopyLink?: () => void;
|
||||
onDownload?: () => void;
|
||||
onShowSeenBy?: () => void;
|
||||
onShowReactors?: () => void;
|
||||
onSendReaction: (reaction: string | undefined, x: number, y: number) => void;
|
||||
};
|
||||
|
||||
const SCROLLBAR_WIDTH = 10;
|
||||
const REACTION_BUBBLE_EXTRA_WIDTH = 32;
|
||||
const ANIMATION_DURATION = 200;
|
||||
|
||||
const MessageContextMenu: FC<OwnProps> = ({
|
||||
availableReactions,
|
||||
isOpen,
|
||||
message,
|
||||
isPrivate,
|
||||
enabledReactions,
|
||||
anchor,
|
||||
canSendNow,
|
||||
canReschedule,
|
||||
@ -80,6 +96,9 @@ const MessageContextMenu: FC<OwnProps> = ({
|
||||
canDownload,
|
||||
isDownloading,
|
||||
canShowSeenBy,
|
||||
canShowReactionsCount,
|
||||
canRemoveReaction,
|
||||
canShowReactionList,
|
||||
seenByRecentUsers,
|
||||
onReply,
|
||||
onEdit,
|
||||
@ -98,10 +117,18 @@ const MessageContextMenu: FC<OwnProps> = ({
|
||||
onCopyLink,
|
||||
onDownload,
|
||||
onShowSeenBy,
|
||||
onShowReactors,
|
||||
onSendReaction,
|
||||
}) => {
|
||||
// eslint-disable-next-line no-null/no-null
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
// eslint-disable-next-line no-null/no-null
|
||||
const scrollableRef = useRef<HTMLDivElement>(null);
|
||||
const copyOptions = getMessageCopyOptions(message, onClose, canCopyLink ? onCopyLink : undefined);
|
||||
const noReactions = !isPrivate && !enabledReactions?.length;
|
||||
const withReactions = canShowReactionList && !noReactions;
|
||||
|
||||
const [isReady, markIsReady, unmarkIsReady] = useFlag();
|
||||
|
||||
const getTriggerElement = useCallback(() => {
|
||||
return document.querySelector(`.Transition__slide--active > .MessageList div[data-message-id="${message.id}"]`);
|
||||
@ -117,6 +144,21 @@ const MessageContextMenu: FC<OwnProps> = ({
|
||||
[],
|
||||
);
|
||||
|
||||
const handleRemoveReaction = useCallback(() => {
|
||||
onSendReaction(undefined, 0, 0);
|
||||
}, [onSendReaction]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
unmarkIsReady();
|
||||
return;
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
markIsReady();
|
||||
}, ANIMATION_DURATION);
|
||||
}, [isOpen, markIsReady, unmarkIsReady]);
|
||||
|
||||
const {
|
||||
positionX, positionY, style, menuStyle, withScroll,
|
||||
} = useContextMenuPosition(
|
||||
@ -126,10 +168,11 @@ const MessageContextMenu: FC<OwnProps> = ({
|
||||
getMenuElement,
|
||||
SCROLLBAR_WIDTH,
|
||||
(document.querySelector('.MiddleHeader') as HTMLElement).offsetHeight,
|
||||
withReactions ? REACTION_BUBBLE_EXTRA_WIDTH : undefined,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
disableScrolling(withScroll ? menuRef.current : undefined);
|
||||
disableScrolling(withScroll ? scrollableRef.current : undefined, '.ReactionSelector');
|
||||
|
||||
return enableScrolling;
|
||||
}, [withScroll]);
|
||||
@ -144,51 +187,79 @@ const MessageContextMenu: FC<OwnProps> = ({
|
||||
positionY={positionY}
|
||||
style={style}
|
||||
menuStyle={menuStyle}
|
||||
className="MessageContextMenu fluid"
|
||||
className={buildClassName(
|
||||
'MessageContextMenu', 'fluid', withReactions && 'with-reactions',
|
||||
)}
|
||||
onClose={onClose}
|
||||
onCloseAnimationEnd={onCloseAnimationEnd}
|
||||
>
|
||||
{canSendNow && <MenuItem icon="send-outline" onClick={onSend}>{lang('MessageScheduleSend')}</MenuItem>}
|
||||
{canReschedule && (
|
||||
<MenuItem icon="schedule" onClick={onReschedule}>{lang('MessageScheduleEditTime')}</MenuItem>
|
||||
{canShowReactionList && (
|
||||
<ReactionSelector
|
||||
enabledReactions={enabledReactions}
|
||||
onSendReaction={onSendReaction}
|
||||
isPrivate={isPrivate}
|
||||
availableReactions={availableReactions}
|
||||
isReady={isReady}
|
||||
/>
|
||||
)}
|
||||
{canReply && <MenuItem icon="reply" onClick={onReply}>{lang('Reply')}</MenuItem>}
|
||||
{canEdit && <MenuItem icon="edit" onClick={onEdit}>{lang('Edit')}</MenuItem>}
|
||||
{canFaveSticker && (
|
||||
<MenuItem icon="favorite" onClick={onFaveSticker}>{lang('AddToFavorites')}</MenuItem>
|
||||
)}
|
||||
{canUnfaveSticker && (
|
||||
<MenuItem icon="favorite" onClick={onUnfaveSticker}>{lang('Stickers.RemoveFromFavorites')}</MenuItem>
|
||||
)}
|
||||
{canCopy && copyOptions.map((options) => (
|
||||
<MenuItem key={options.label} icon="copy" onClick={options.handler}>{lang(options.label)}</MenuItem>
|
||||
))}
|
||||
{canPin && <MenuItem icon="pin" onClick={onPin}>{lang('DialogPin')}</MenuItem>}
|
||||
{canUnpin && <MenuItem icon="unpin" onClick={onUnpin}>{lang('DialogUnpin')}</MenuItem>}
|
||||
{canDownload && (
|
||||
<MenuItem icon="download" onClick={onDownload}>
|
||||
{isDownloading ? lang('lng_context_cancel_download') : lang('lng_media_download')}
|
||||
</MenuItem>
|
||||
)}
|
||||
{canForward && <MenuItem icon="forward" onClick={onForward}>{lang('Forward')}</MenuItem>}
|
||||
{canSelect && <MenuItem icon="select" onClick={onSelect}>{lang('Common.Select')}</MenuItem>}
|
||||
{canReport && <MenuItem icon="flag" onClick={onReport}>{lang('lng_context_report_msg')}</MenuItem>}
|
||||
{canShowSeenBy && (
|
||||
<MenuItem icon="group" onClick={onShowSeenBy} disabled={!message.seenByUserIds?.length}>
|
||||
{message.seenByUserIds?.length
|
||||
? lang('Conversation.ContextMenuSeen', message.seenByUserIds.length, 'i')
|
||||
: lang('Conversation.ContextMenuNoViews')}
|
||||
<div className="avatars">
|
||||
{seenByRecentUsers?.map((user) => (
|
||||
<Avatar
|
||||
size="micro"
|
||||
user={user}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</MenuItem>
|
||||
)}
|
||||
{canDelete && <MenuItem destructive icon="delete" onClick={onDelete}>{lang('Delete')}</MenuItem>}
|
||||
|
||||
<div
|
||||
className="scrollable-content custom-scroll"
|
||||
// @ts-ignore teact feature
|
||||
style={menuStyle}
|
||||
ref={scrollableRef}
|
||||
>
|
||||
{canRemoveReaction && <MenuItem icon="reactions" onClick={handleRemoveReaction}>Remove Reaction</MenuItem>}
|
||||
{canSendNow && <MenuItem icon="send-outline" onClick={onSend}>{lang('MessageScheduleSend')}</MenuItem>}
|
||||
{canReschedule && (
|
||||
<MenuItem icon="schedule" onClick={onReschedule}>{lang('MessageScheduleEditTime')}</MenuItem>
|
||||
)}
|
||||
{canReply && <MenuItem icon="reply" onClick={onReply}>{lang('Reply')}</MenuItem>}
|
||||
{canEdit && <MenuItem icon="edit" onClick={onEdit}>{lang('Edit')}</MenuItem>}
|
||||
{canFaveSticker && (
|
||||
<MenuItem icon="favorite" onClick={onFaveSticker}>{lang('AddToFavorites')}</MenuItem>
|
||||
)}
|
||||
{canUnfaveSticker && (
|
||||
<MenuItem icon="favorite" onClick={onUnfaveSticker}>{lang('Stickers.RemoveFromFavorites')}</MenuItem>
|
||||
)}
|
||||
{canCopy && copyOptions.map((options) => (
|
||||
<MenuItem key={options.label} icon="copy" onClick={options.handler}>{lang(options.label)}</MenuItem>
|
||||
))}
|
||||
{canPin && <MenuItem icon="pin" onClick={onPin}>{lang('DialogPin')}</MenuItem>}
|
||||
{canUnpin && <MenuItem icon="unpin" onClick={onUnpin}>{lang('DialogUnpin')}</MenuItem>}
|
||||
{canDownload && (
|
||||
<MenuItem icon="download" onClick={onDownload}>
|
||||
{isDownloading ? lang('lng_context_cancel_download') : lang('lng_media_download')}
|
||||
</MenuItem>
|
||||
)}
|
||||
{canForward && <MenuItem icon="forward" onClick={onForward}>{lang('Forward')}</MenuItem>}
|
||||
{canSelect && <MenuItem icon="select" onClick={onSelect}>{lang('Common.Select')}</MenuItem>}
|
||||
{canReport && <MenuItem icon="flag" onClick={onReport}>{lang('lng_context_report_msg')}</MenuItem>}
|
||||
{(canShowSeenBy || canShowReactionsCount) && (
|
||||
<MenuItem
|
||||
icon={canShowReactionsCount ? 'reactions' : 'group'}
|
||||
onClick={canShowReactionsCount ? onShowReactors : onShowSeenBy}
|
||||
disabled={!canShowReactionsCount && !message.seenByUserIds?.length}
|
||||
>
|
||||
{canShowReactionsCount && message.reactors?.count ? (
|
||||
canShowSeenBy && message.seenByUserIds?.length
|
||||
? lang('Chat.OutgoingContextMixedReactionCount', [message.reactors.count, message.seenByUserIds.length])
|
||||
: lang('Chat.ContextReactionCount', message.reactors.count, 'i'))
|
||||
: (message.seenByUserIds?.length
|
||||
? lang('Conversation.ContextMenuSeen', message.seenByUserIds.length, 'i')
|
||||
: lang('Conversation.ContextMenuNoViews'))}
|
||||
<div className="avatars">
|
||||
{seenByRecentUsers?.map((user) => (
|
||||
<Avatar
|
||||
size="micro"
|
||||
user={user}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</MenuItem>
|
||||
)}
|
||||
{canDelete && <MenuItem destructive icon="delete" onClick={onDelete}>{lang('Delete')}</MenuItem>}
|
||||
</div>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -2,31 +2,42 @@ import React, {
|
||||
FC, memo, useMemo,
|
||||
} from '../../../lib/teact/teact';
|
||||
|
||||
import { ApiMessage, ApiMessageOutgoingStatus } from '../../../api/types';
|
||||
import { ApiAvailableReaction, ApiMessage, ApiMessageOutgoingStatus } from '../../../api/types';
|
||||
import { ActiveReaction } from '../../../global/types';
|
||||
|
||||
import { formatDateTimeToString, formatTime } from '../../../util/dateFormat';
|
||||
import { formatIntegerCompact } from '../../../util/textFormat';
|
||||
|
||||
import MessageOutgoingStatus from '../../common/MessageOutgoingStatus';
|
||||
import renderText from '../../common/helpers/renderText';
|
||||
import useLang from '../../../hooks/useLang';
|
||||
import useFlag from '../../../hooks/useFlag';
|
||||
import buildClassName from '../../../util/buildClassName';
|
||||
|
||||
import MessageOutgoingStatus from '../../common/MessageOutgoingStatus';
|
||||
import ReactionAnimatedEmoji from './ReactionAnimatedEmoji';
|
||||
|
||||
import './MessageMeta.scss';
|
||||
|
||||
type OwnProps = {
|
||||
message: ApiMessage;
|
||||
withReactions?: boolean;
|
||||
withReactionOffset?: boolean;
|
||||
outgoingStatus?: ApiMessageOutgoingStatus;
|
||||
signature?: string;
|
||||
onClick: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
|
||||
activeReaction?: ActiveReaction;
|
||||
availableReactions?: ApiAvailableReaction[];
|
||||
};
|
||||
|
||||
const MessageMeta: FC<OwnProps> = ({
|
||||
message, outgoingStatus, signature, onClick,
|
||||
message, outgoingStatus, signature, onClick, withReactions,
|
||||
activeReaction, withReactionOffset, availableReactions,
|
||||
}) => {
|
||||
const lang = useLang();
|
||||
const [isActivated, markActivated] = useFlag();
|
||||
|
||||
const reactions = withReactions && message.reactions?.results.filter((l) => l.count > 0);
|
||||
|
||||
const title = useMemo(() => {
|
||||
if (!isActivated) return undefined;
|
||||
const createDateTime = formatDateTimeToString(message.date * 1000, lang.code);
|
||||
@ -47,7 +58,19 @@ const MessageMeta: FC<OwnProps> = ({
|
||||
}, [isActivated, lang, message]);
|
||||
|
||||
return (
|
||||
<span className="MessageMeta" dir={lang.isRtl ? 'rtl' : 'ltr'} onClick={onClick}>
|
||||
<span
|
||||
className={buildClassName('MessageMeta', withReactionOffset && 'reactions-offset')}
|
||||
dir={lang.isRtl ? 'rtl' : 'ltr'}
|
||||
onClick={onClick}
|
||||
>
|
||||
{reactions && reactions.map((l) => (
|
||||
<ReactionAnimatedEmoji
|
||||
activeReaction={activeReaction}
|
||||
reaction={l.reaction}
|
||||
isInMeta
|
||||
availableReactions={availableReactions}
|
||||
/>
|
||||
))}
|
||||
{Boolean(message.views) && (
|
||||
<>
|
||||
<span className="message-views">
|
||||
|
||||
50
src/components/middle/message/ReactionAnimatedEmoji.scss
Normal file
50
src/components/middle/message/ReactionAnimatedEmoji.scss
Normal file
@ -0,0 +1,50 @@
|
||||
.ReactionAnimatedEmoji {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&.is-animating {
|
||||
// Fix for redundant scroll on iOS
|
||||
transform: translateZ(0);
|
||||
// Fix for redundant scroll in Firefox
|
||||
contain: layout;
|
||||
}
|
||||
|
||||
.AnimatedSticker {
|
||||
position: fixed;
|
||||
top: -0.375rem;
|
||||
left: -0.375rem;
|
||||
pointer-events: none;
|
||||
|
||||
&.effect {
|
||||
top: -2.5rem;
|
||||
left: -2.5rem;
|
||||
}
|
||||
|
||||
&:not(.open) {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
&.closing {
|
||||
opacity: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
&.in-meta {
|
||||
.AnimatedSticker {
|
||||
top: -0.4375rem;
|
||||
left: -0.4375rem;
|
||||
|
||||
&.effect {
|
||||
top: -2.5625rem;
|
||||
left: -2.5625rem;
|
||||
}
|
||||
|
||||
// Fix for weird positioning in Chrome
|
||||
canvas {
|
||||
position: absolute;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
92
src/components/middle/message/ReactionAnimatedEmoji.tsx
Normal file
92
src/components/middle/message/ReactionAnimatedEmoji.tsx
Normal file
@ -0,0 +1,92 @@
|
||||
import React, { FC, memo, useCallback } from '../../../lib/teact/teact';
|
||||
import { getDispatch } from '../../../lib/teact/teactn';
|
||||
|
||||
import { ActiveReaction } from '../../../global/types';
|
||||
import { ApiAvailableReaction, ApiMediaFormat } from '../../../api/types';
|
||||
|
||||
import buildClassName from '../../../util/buildClassName';
|
||||
import useMedia from '../../../hooks/useMedia';
|
||||
import useShowTransition from '../../../hooks/useShowTransition';
|
||||
import useFlag from '../../../hooks/useFlag';
|
||||
|
||||
import ReactionStaticEmoji from '../../common/ReactionStaticEmoji';
|
||||
import AnimatedSticker from '../../common/AnimatedSticker';
|
||||
|
||||
import './ReactionAnimatedEmoji.scss';
|
||||
|
||||
type OwnProps = {
|
||||
reaction: string;
|
||||
activeReaction?: ActiveReaction;
|
||||
isInMeta?: boolean;
|
||||
availableReactions?: ApiAvailableReaction[];
|
||||
};
|
||||
|
||||
const CENTER_ICON_SIZE = 30;
|
||||
const EFFECT_SIZE = 100;
|
||||
|
||||
const ReactionAnimatedEmoji: FC<OwnProps> = ({
|
||||
reaction,
|
||||
activeReaction,
|
||||
isInMeta,
|
||||
availableReactions,
|
||||
}) => {
|
||||
const { stopActiveReaction } = getDispatch();
|
||||
|
||||
const availableReaction = availableReactions?.find((r) => r.reaction === reaction);
|
||||
const centerIconId = availableReaction?.centerIcon?.id;
|
||||
const effectId = availableReaction?.aroundAnimation?.id;
|
||||
const mediaDataCenterIcon = useMedia(`sticker${centerIconId}`, !centerIconId, ApiMediaFormat.Lottie);
|
||||
const mediaDataEffect = useMedia(`sticker${effectId}`, !effectId, ApiMediaFormat.Lottie);
|
||||
|
||||
const shouldPlay = Boolean(activeReaction?.reaction === reaction && mediaDataCenterIcon && mediaDataEffect);
|
||||
const {
|
||||
shouldRender: shouldRenderAnimation,
|
||||
transitionClassNames: animationClassNames,
|
||||
} = useShowTransition(shouldPlay, undefined, true, 'slow');
|
||||
|
||||
const handleEnded = useCallback(() => {
|
||||
stopActiveReaction({ messageId: activeReaction?.messageId, reaction });
|
||||
}, [activeReaction?.messageId, reaction, stopActiveReaction]);
|
||||
|
||||
const [isAnimationLoaded, markAnimationLoaded, unmarkAnimationLoaded] = useFlag();
|
||||
const shouldRenderStatic = !shouldPlay || !isAnimationLoaded;
|
||||
|
||||
const className = buildClassName(
|
||||
'ReactionAnimatedEmoji',
|
||||
isInMeta && 'in-meta',
|
||||
shouldRenderAnimation && 'is-animating',
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{shouldRenderStatic && <ReactionStaticEmoji reaction={reaction} />}
|
||||
{shouldRenderAnimation && (
|
||||
<>
|
||||
<AnimatedSticker
|
||||
key={centerIconId}
|
||||
id={`reaction_emoji_${centerIconId}`}
|
||||
className={animationClassNames}
|
||||
size={CENTER_ICON_SIZE}
|
||||
animationData={mediaDataCenterIcon as AnyLiteral}
|
||||
play
|
||||
noLoop
|
||||
onLoad={markAnimationLoaded}
|
||||
onEnded={unmarkAnimationLoaded}
|
||||
/>
|
||||
<AnimatedSticker
|
||||
key={effectId}
|
||||
id={`reaction_effect_${effectId}`}
|
||||
className={buildClassName('effect', animationClassNames)}
|
||||
size={EFFECT_SIZE}
|
||||
animationData={mediaDataEffect as AnyLiteral}
|
||||
play
|
||||
noLoop
|
||||
onEnded={handleEnded}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(ReactionAnimatedEmoji);
|
||||
79
src/components/middle/message/ReactionButton.tsx
Normal file
79
src/components/middle/message/ReactionButton.tsx
Normal file
@ -0,0 +1,79 @@
|
||||
import React, {
|
||||
FC, memo, useCallback, useMemo,
|
||||
} from '../../../lib/teact/teact';
|
||||
import { getDispatch, getGlobal } from '../../../lib/teact/teactn';
|
||||
|
||||
import {
|
||||
ApiAvailableReaction, ApiMessage, ApiReactionCount, ApiUser,
|
||||
} from '../../../api/types';
|
||||
import { ActiveReaction } from '../../../global/types';
|
||||
|
||||
import buildClassName from '../../../util/buildClassName';
|
||||
import { formatIntegerCompact } from '../../../util/textFormat';
|
||||
|
||||
import Button from '../../ui/Button';
|
||||
import Avatar from '../../common/Avatar';
|
||||
import ReactionAnimatedEmoji from './ReactionAnimatedEmoji';
|
||||
|
||||
import './Reactions.scss';
|
||||
|
||||
const MAX_REACTORS_AVATARS = 3;
|
||||
|
||||
const ReactionButton: FC<{
|
||||
reaction: ApiReactionCount;
|
||||
message: ApiMessage;
|
||||
activeReaction?: ActiveReaction;
|
||||
availableReactions?: ApiAvailableReaction[];
|
||||
}> = ({
|
||||
reaction,
|
||||
message,
|
||||
activeReaction,
|
||||
availableReactions,
|
||||
}) => {
|
||||
const { sendReaction } = getDispatch();
|
||||
|
||||
const { recentReactions } = message.reactions!;
|
||||
|
||||
const recentReactors = useMemo(() => {
|
||||
if (!recentReactions || reaction.count > MAX_REACTORS_AVATARS) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// No need for expensive global updates on users, so we avoid them
|
||||
const usersById = getGlobal().users.byId;
|
||||
|
||||
return recentReactions
|
||||
.filter((recentReaction) => recentReaction.reaction === reaction.reaction)
|
||||
.map((recentReaction) => usersById[recentReaction.userId])
|
||||
.filter(Boolean) as ApiUser[];
|
||||
}, [reaction, recentReactions]);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
sendReaction({
|
||||
reaction: reaction.isChosen ? undefined : reaction.reaction,
|
||||
chatId: message.chatId,
|
||||
messageId: message.id,
|
||||
});
|
||||
}, [message, reaction, sendReaction]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={buildClassName(reaction.isChosen && 'chosen')}
|
||||
size="tiny"
|
||||
onClick={handleClick}
|
||||
>
|
||||
<ReactionAnimatedEmoji
|
||||
activeReaction={activeReaction}
|
||||
reaction={reaction.reaction}
|
||||
availableReactions={availableReactions}
|
||||
/>
|
||||
{recentReactors?.length ? (
|
||||
<div className="avatars">
|
||||
{recentReactors.map((user) => <Avatar user={user} size="micro" />)}
|
||||
</div>
|
||||
) : formatIntegerCompact(reaction.count)}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(ReactionButton);
|
||||
91
src/components/middle/message/ReactionSelector.scss
Normal file
91
src/components/middle/message/ReactionSelector.scss
Normal file
@ -0,0 +1,91 @@
|
||||
.ReactionSelector {
|
||||
position: absolute;
|
||||
height: 3rem;
|
||||
background: var(--color-background);
|
||||
min-width: 3rem;
|
||||
max-width: calc(100% + 5rem);
|
||||
z-index: 100;
|
||||
border-radius: 3rem;
|
||||
filter: drop-shadow(0 0.25rem 0.125rem var(--color-default-shadow));
|
||||
right: -3rem;
|
||||
top: -3.5rem;
|
||||
|
||||
.bubble-big {
|
||||
position: absolute;
|
||||
display: block;
|
||||
content: "";
|
||||
right: 1.5rem;
|
||||
bottom: -0.5rem;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border-radius: 50%;
|
||||
background: var(--color-background);
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.bubble-small {
|
||||
position: absolute;
|
||||
display: block;
|
||||
content: "";
|
||||
right: 1.25rem;
|
||||
bottom: -1.25rem;
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
background: var(--color-background);
|
||||
}
|
||||
|
||||
body.is-safari & {
|
||||
filter: none;
|
||||
box-shadow: 0 0.25rem 0.125rem var(--color-default-shadow);
|
||||
}
|
||||
|
||||
body.is-safari .bubble-small, body.is-safari .bubble-big {
|
||||
box-shadow: 0 0.25rem 0.125rem var(--color-default-shadow);
|
||||
}
|
||||
|
||||
.items-wrapper {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
border-radius: 3rem;
|
||||
}
|
||||
|
||||
.items {
|
||||
padding: 0 1rem;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow-x: auto;
|
||||
overflow: overlay;
|
||||
overflow-y: hidden;
|
||||
display: flex;
|
||||
cursor: pointer;
|
||||
align-items: center;
|
||||
border-radius: 3rem;
|
||||
}
|
||||
|
||||
.reaction {
|
||||
margin-left: 0.5rem;
|
||||
position: relative;
|
||||
min-width: 2rem;
|
||||
min-height: 2rem;
|
||||
|
||||
&:first-child {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.ReactionStaticEmoji {
|
||||
width: 2rem;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
.AnimatedSticker {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
}
|
||||
122
src/components/middle/message/ReactionSelector.tsx
Normal file
122
src/components/middle/message/ReactionSelector.tsx
Normal file
@ -0,0 +1,122 @@
|
||||
import React, {
|
||||
FC, memo, useLayoutEffect, useRef,
|
||||
} from '../../../lib/teact/teact';
|
||||
|
||||
import { ApiAvailableReaction, ApiMediaFormat } from '../../../api/types';
|
||||
|
||||
import useMedia from '../../../hooks/useMedia';
|
||||
import useHorizontalScroll from '../../../hooks/useHorizontalScroll';
|
||||
import useFlag from '../../../hooks/useFlag';
|
||||
import { getTouchY } from '../../../util/scrollLock';
|
||||
|
||||
import ReactionStaticEmoji from '../../common/ReactionStaticEmoji';
|
||||
import AnimatedSticker from '../../common/AnimatedSticker';
|
||||
|
||||
import './ReactionSelector.scss';
|
||||
|
||||
const REACTION_SIZE = 32;
|
||||
|
||||
type OwnProps = {
|
||||
enabledReactions?: string[];
|
||||
onSendReaction: (reaction: string, x: number, y: number) => void;
|
||||
isPrivate?: boolean;
|
||||
availableReactions?: ApiAvailableReaction[];
|
||||
isReady?: boolean;
|
||||
};
|
||||
|
||||
const AvailableReaction: FC<{
|
||||
reaction: ApiAvailableReaction;
|
||||
isReady?: boolean;
|
||||
onSendReaction: (reaction: string, x: number, y: number) => void;
|
||||
}> = ({ reaction, onSendReaction, isReady }) => {
|
||||
// eslint-disable-next-line no-null/no-null
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [isActivated, activate, deactivate] = useFlag();
|
||||
const mediaData = useMedia(`document${reaction.selectAnimation?.id}`, !isReady, ApiMediaFormat.Lottie);
|
||||
const [isAnimationLoaded, markAnimationLoaded] = useFlag();
|
||||
|
||||
function handleClick() {
|
||||
if (!containerRef.current) return;
|
||||
const { x, y } = containerRef.current.getBoundingClientRect();
|
||||
|
||||
onSendReaction(reaction.reaction, x, y);
|
||||
}
|
||||
|
||||
const shouldRenderPreview = !isAnimationLoaded;
|
||||
const shouldRenderAnimated = mediaData;
|
||||
const shouldPlay = isReady && isActivated;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="reaction"
|
||||
onClick={handleClick}
|
||||
ref={containerRef}
|
||||
onMouseEnter={isReady ? activate : undefined}
|
||||
>
|
||||
{shouldRenderPreview && <ReactionStaticEmoji reaction={reaction.reaction} />}
|
||||
{shouldRenderAnimated && (
|
||||
<AnimatedSticker
|
||||
id={`select_${reaction.reaction}`}
|
||||
animationData={mediaData as AnyLiteral}
|
||||
play={shouldPlay}
|
||||
noLoop
|
||||
onEnded={deactivate}
|
||||
size={REACTION_SIZE}
|
||||
onLoad={markAnimationLoaded}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
const ReactionSelector: FC<OwnProps> = ({
|
||||
availableReactions,
|
||||
enabledReactions,
|
||||
onSendReaction,
|
||||
isPrivate,
|
||||
isReady,
|
||||
}) => {
|
||||
// eslint-disable-next-line no-null/no-null
|
||||
const itemsScrollRef = useRef<HTMLDivElement>(null);
|
||||
const [isHorizontalScrollEnabled, enableHorizontalScroll] = useFlag(false);
|
||||
useHorizontalScroll(itemsScrollRef.current, !isHorizontalScrollEnabled);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
enableHorizontalScroll();
|
||||
}, [enableHorizontalScroll]);
|
||||
|
||||
const handleWheel = (e: React.WheelEvent | React.TouchEvent) => {
|
||||
if (!itemsScrollRef) return;
|
||||
const deltaY = 'deltaY' in e ? e.deltaY : getTouchY(e);
|
||||
|
||||
if (deltaY) {
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
if ((!isPrivate && !enabledReactions?.length) || !availableReactions) return undefined;
|
||||
|
||||
return (
|
||||
<div className="ReactionSelector" onWheelCapture={handleWheel} onTouchMove={handleWheel}>
|
||||
<div className="bubble-big" />
|
||||
<div className="bubble-small" />
|
||||
<div className="items-wrapper">
|
||||
<div className="items no-scrollbar" ref={itemsScrollRef}>
|
||||
{availableReactions?.map((reaction) => {
|
||||
if (reaction.isInactive
|
||||
|| (!isPrivate && (!enabledReactions || !enabledReactions.includes(reaction.reaction)))) return undefined;
|
||||
return (
|
||||
<AvailableReaction
|
||||
key={reaction.reaction}
|
||||
isReady={isReady}
|
||||
onSendReaction={onSendReaction}
|
||||
reaction={reaction}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(ReactionSelector);
|
||||
84
src/components/middle/message/Reactions.scss
Normal file
84
src/components/middle/message/Reactions.scss
Normal file
@ -0,0 +1,84 @@
|
||||
.Reactions {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0.25rem;
|
||||
overflow: visible;
|
||||
|
||||
.Button {
|
||||
--reaction-background: var(--color-reaction);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
height: 1.75rem;
|
||||
white-space: nowrap;
|
||||
width: auto;
|
||||
margin: 0.125rem;
|
||||
padding: 0 0.5rem;
|
||||
border: 2px solid transparent;
|
||||
background-color: var(--reaction-background) !important;
|
||||
border-radius: 1.75rem;
|
||||
font-weight: 500;
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-transform: none;
|
||||
color: var(--accent-color);
|
||||
overflow: visible;
|
||||
|
||||
.ReactionAnimatedEmoji, .icon-reaction-filled {
|
||||
width: 1.125rem;
|
||||
height: 1.125rem;
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
.avatars {
|
||||
display: flex;
|
||||
|
||||
.Avatar {
|
||||
margin: 0 0 0 -0.25rem;
|
||||
border: 0.0625rem solid var(--reaction-background);
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
|
||||
&:first-child {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.chosen {
|
||||
border-color: var(--accent-color);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
--reaction-background: var(--hover-color-reaction);
|
||||
}
|
||||
|
||||
&:first-of-type {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
&:last-of-type {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&.is-outside {
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
|
||||
&.is-outside .Button {
|
||||
--reaction-background: var(--pattern-color);
|
||||
color: white;
|
||||
.theme-dark & {
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
&.chosen {
|
||||
border-color: white;
|
||||
|
||||
.theme-dark & {
|
||||
border-color: var(--accent-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
43
src/components/middle/message/Reactions.tsx
Normal file
43
src/components/middle/message/Reactions.tsx
Normal file
@ -0,0 +1,43 @@
|
||||
import React, { FC, memo } from '../../../lib/teact/teact';
|
||||
|
||||
import { ApiAvailableReaction, ApiMessage } from '../../../api/types';
|
||||
import { ActiveReaction } from '../../../global/types';
|
||||
|
||||
import buildClassName from '../../../util/buildClassName';
|
||||
|
||||
import ReactionButton from './ReactionButton';
|
||||
|
||||
import './Reactions.scss';
|
||||
|
||||
type OwnProps = {
|
||||
message: ApiMessage;
|
||||
isOutside?: boolean;
|
||||
activeReaction?: ActiveReaction;
|
||||
availableReactions?: ApiAvailableReaction[];
|
||||
metaChildren?: React.ReactNode;
|
||||
};
|
||||
|
||||
const Reactions: FC<OwnProps> = ({
|
||||
message,
|
||||
isOutside,
|
||||
activeReaction,
|
||||
availableReactions,
|
||||
metaChildren,
|
||||
}) => {
|
||||
return (
|
||||
<div className={buildClassName('Reactions', isOutside && 'is-outside')}>
|
||||
{message.reactions!.results.map((reaction) => (
|
||||
<ReactionButton
|
||||
key={reaction.reaction}
|
||||
reaction={reaction}
|
||||
message={message}
|
||||
activeReaction={activeReaction}
|
||||
availableReactions={availableReactions}
|
||||
/>
|
||||
))}
|
||||
{metaChildren}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(Reactions);
|
||||
@ -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: "";
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { RefObject } from 'react';
|
||||
import React, { useEffect } from '../../../../lib/teact/teact';
|
||||
import React, { useEffect, useRef } from '../../../../lib/teact/teact';
|
||||
import { getDispatch } from '../../../../lib/teact/teactn';
|
||||
|
||||
import { IS_ANDROID, IS_TOUCH_ENV } from '../../../../util/environment';
|
||||
@ -8,9 +8,14 @@ import { captureEvents, SwipeDirection } from '../../../../util/captureEvents';
|
||||
import useFlag from '../../../../hooks/useFlag';
|
||||
import { preventMessageInputBlur } from '../../helpers/preventMessageInputBlur';
|
||||
import stopEvent from '../../../../util/stopEvent';
|
||||
import { REM } from '../../../common/helpers/mediaDimensions';
|
||||
|
||||
const ANDROID_KEYBOARD_HIDE_DELAY_MS = 350;
|
||||
const SWIPE_ANIMATION_DURATION = 150;
|
||||
const QUICK_REACTION_DOUBLE_TAP_DELAY = 200;
|
||||
const QUICK_REACTION_AREA_WIDTH = 3 * REM;
|
||||
const QUICK_REACTION_AREA_HEIGHT = Number(REM);
|
||||
const GROUP_MESSAGE_HOVER_ATTRIBUTE = 'data-is-document-group-hover';
|
||||
|
||||
export default function useOuterHandlers(
|
||||
selectMessage: (e?: React.MouseEvent<HTMLDivElement, MouseEvent>, groupedId?: string) => void,
|
||||
@ -22,20 +27,53 @@ export default function useOuterHandlers(
|
||||
isProtected: boolean,
|
||||
onContextMenu: (e: React.MouseEvent) => void,
|
||||
handleBeforeContextMenu: (e: React.MouseEvent) => void,
|
||||
chatId: string,
|
||||
isContextMenuShown: boolean,
|
||||
contentRef: RefObject<HTMLDivElement>,
|
||||
isOwn: boolean,
|
||||
shouldHandleMouseLeave: boolean,
|
||||
) {
|
||||
const { setReplyingToId } = getDispatch();
|
||||
const { setReplyingToId, sendDefaultReaction } = getDispatch();
|
||||
|
||||
const [isQuickReactionVisible, markQuickReactionVisible, unmarkQuickReactionVisible] = useFlag();
|
||||
const [isSwiped, markSwiped, unmarkSwiped] = useFlag();
|
||||
const doubleTapTimeoutRef = useRef<NodeJS.Timeout>();
|
||||
|
||||
function handleMouseDown(e: React.MouseEvent<HTMLDivElement, MouseEvent>) {
|
||||
preventMessageInputBlur(e);
|
||||
handleBeforeContextMenu(e);
|
||||
}
|
||||
|
||||
function handleClick(e: React.MouseEvent<HTMLDivElement, MouseEvent>) {
|
||||
if (isInSelectMode) {
|
||||
selectMessage(e);
|
||||
} else if (IS_ANDROID) {
|
||||
function handleMouseMove(e: React.MouseEvent) {
|
||||
const container = contentRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const { clientX, clientY } = e;
|
||||
const {
|
||||
x, width, y, height,
|
||||
} = container.getBoundingClientRect();
|
||||
|
||||
const isVisibleX = Math.abs((isOwn ? (clientX - x) : (x + width - clientX))) < QUICK_REACTION_AREA_WIDTH;
|
||||
const isVisibleY = Math.abs(y + height - clientY) < QUICK_REACTION_AREA_HEIGHT;
|
||||
if (isVisibleX && isVisibleY) {
|
||||
markQuickReactionVisible();
|
||||
} else {
|
||||
unmarkQuickReactionVisible();
|
||||
}
|
||||
}
|
||||
|
||||
function handleSendQuickReaction(e: React.MouseEvent) {
|
||||
const { x, y } = e.currentTarget.getBoundingClientRect();
|
||||
sendDefaultReaction({
|
||||
chatId,
|
||||
messageId,
|
||||
x,
|
||||
y,
|
||||
});
|
||||
}
|
||||
|
||||
function handleTap(e: React.MouseEvent<HTMLDivElement, MouseEvent>) {
|
||||
if (IS_ANDROID) {
|
||||
const target = e.target as HTMLDivElement;
|
||||
if (!target.classList.contains('text-content') && !target.classList.contains('Message')) {
|
||||
return;
|
||||
@ -51,6 +89,38 @@ export default function useOuterHandlers(
|
||||
}
|
||||
}
|
||||
|
||||
function handleDoubleTap(e: React.MouseEvent<HTMLDivElement, MouseEvent>) {
|
||||
const { pageX: x, pageY: y } = e;
|
||||
|
||||
sendDefaultReaction({
|
||||
chatId,
|
||||
messageId,
|
||||
x,
|
||||
y,
|
||||
});
|
||||
}
|
||||
|
||||
function handleClick(e: React.MouseEvent<HTMLDivElement, MouseEvent>) {
|
||||
if (isInSelectMode) {
|
||||
selectMessage(e);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!IS_TOUCH_ENV) return;
|
||||
|
||||
if (doubleTapTimeoutRef.current) {
|
||||
clearInterval(doubleTapTimeoutRef.current);
|
||||
doubleTapTimeoutRef.current = undefined;
|
||||
handleDoubleTap(e);
|
||||
return;
|
||||
}
|
||||
|
||||
doubleTapTimeoutRef.current = setTimeout(() => {
|
||||
doubleTapTimeoutRef.current = undefined;
|
||||
handleTap(e);
|
||||
}, QUICK_REACTION_DOUBLE_TAP_DELAY);
|
||||
}
|
||||
|
||||
function handleContextMenu(e: React.MouseEvent<HTMLDivElement, MouseEvent>) {
|
||||
if (IS_ANDROID) {
|
||||
if ((e.target as HTMLElement).matches('a[href]')) {
|
||||
@ -65,6 +135,8 @@ export default function useOuterHandlers(
|
||||
}
|
||||
|
||||
function handleContainerDoubleClick() {
|
||||
if (IS_TOUCH_ENV) return;
|
||||
|
||||
setReplyingToId({ messageId });
|
||||
}
|
||||
|
||||
@ -73,7 +145,7 @@ export default function useOuterHandlers(
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!IS_TOUCH_ENV || isInSelectMode || !canReply) {
|
||||
if (!IS_TOUCH_ENV || isInSelectMode || !canReply || isContextMenuShown) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@ -104,7 +176,14 @@ export default function useOuterHandlers(
|
||||
startedAt = undefined;
|
||||
},
|
||||
});
|
||||
}, [containerRef, isInSelectMode, messageId, setReplyingToId, markSwiped, unmarkSwiped, canReply]);
|
||||
}, [
|
||||
containerRef, isInSelectMode, messageId, setReplyingToId, markSwiped, unmarkSwiped, canReply, isContextMenuShown,
|
||||
]);
|
||||
|
||||
function handleMouseLeave(e: React.MouseEvent<HTMLDivElement>) {
|
||||
unmarkQuickReactionVisible();
|
||||
if (shouldHandleMouseLeave) handleDocumentGroupMouseLeave(e);
|
||||
}
|
||||
|
||||
return {
|
||||
handleMouseDown: !isInSelectMode ? handleMouseDown : undefined,
|
||||
@ -112,6 +191,35 @@ export default function useOuterHandlers(
|
||||
handleContextMenu: !isInSelectMode ? handleContextMenu : (isProtected ? stopEvent : undefined),
|
||||
handleDoubleClick: !isInSelectMode ? handleContainerDoubleClick : undefined,
|
||||
handleContentDoubleClick: !IS_TOUCH_ENV ? stopPropagation : undefined,
|
||||
handleMouseMove,
|
||||
handleSendQuickReaction,
|
||||
handleMouseLeave,
|
||||
isSwiped,
|
||||
isQuickReactionVisible,
|
||||
handleDocumentGroupMouseEnter,
|
||||
};
|
||||
}
|
||||
|
||||
function handleDocumentGroupMouseEnter(e: React.MouseEvent<HTMLDivElement>) {
|
||||
const lastGroupElement = getLastElementInDocumentGroup(e.currentTarget);
|
||||
if (lastGroupElement) {
|
||||
lastGroupElement.setAttribute(GROUP_MESSAGE_HOVER_ATTRIBUTE, '');
|
||||
}
|
||||
}
|
||||
|
||||
function handleDocumentGroupMouseLeave(e: React.MouseEvent<HTMLDivElement>) {
|
||||
const lastGroupElement = getLastElementInDocumentGroup(e.currentTarget);
|
||||
if (lastGroupElement) {
|
||||
lastGroupElement.removeAttribute(GROUP_MESSAGE_HOVER_ATTRIBUTE);
|
||||
}
|
||||
}
|
||||
|
||||
function getLastElementInDocumentGroup(element: Element) {
|
||||
let current: Element | null = element;
|
||||
|
||||
do {
|
||||
current = current.nextElementSibling;
|
||||
} while (current && !current.classList.contains('last-in-document-group'));
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
@ -123,6 +123,7 @@ const RightColumn: FC<StateProps> = ({
|
||||
case ManagementScreens.ChatAdministrators:
|
||||
case ManagementScreens.ChannelSubscribers:
|
||||
case ManagementScreens.GroupMembers:
|
||||
case ManagementScreens.Reactions:
|
||||
setManagementScreen(ManagementScreens.Initial);
|
||||
break;
|
||||
case ManagementScreens.GroupUserPermissionsCreate:
|
||||
|
||||
@ -17,10 +17,7 @@ import {
|
||||
selectUser,
|
||||
} from '../../modules/selectors';
|
||||
import {
|
||||
getCanAddContact,
|
||||
isChatAdmin,
|
||||
isChatChannel,
|
||||
isUserId,
|
||||
getCanAddContact, isChatAdmin, isChatChannel, isUserId,
|
||||
} from '../../modules/helpers';
|
||||
import useCurrentOrPrev from '../../hooks/useCurrentOrPrev';
|
||||
import useLang from '../../hooks/useLang';
|
||||
@ -84,6 +81,7 @@ enum HeaderContent {
|
||||
GifSearch,
|
||||
PollResults,
|
||||
AddingMembers,
|
||||
ManageReactions,
|
||||
}
|
||||
|
||||
const RightHeader: FC<OwnProps & StateProps> = ({
|
||||
@ -195,6 +193,8 @@ const RightHeader: FC<OwnProps & StateProps> = ({
|
||||
HeaderContent.ManageGroupMembers
|
||||
) : managementScreen === ManagementScreens.GroupAddAdmins ? (
|
||||
HeaderContent.ManageGroupAddAdmins
|
||||
) : managementScreen === ManagementScreens.Reactions ? (
|
||||
HeaderContent.ManageReactions
|
||||
) : undefined // Never reached
|
||||
) : undefined; // When column is closed
|
||||
|
||||
@ -278,6 +278,8 @@ const RightHeader: FC<OwnProps & StateProps> = ({
|
||||
case HeaderContent.MemberList:
|
||||
case HeaderContent.ManageGroupMembers:
|
||||
return <h3>{lang('GroupMembers')}</h3>;
|
||||
case HeaderContent.ManageReactions:
|
||||
return <h3>{lang('Reactions')}</h3>;
|
||||
default:
|
||||
return (
|
||||
<>
|
||||
|
||||
@ -36,6 +36,7 @@ type StateProps = {
|
||||
progress?: ManagementProgress;
|
||||
isSignaturesShown: boolean;
|
||||
canChangeInfo?: boolean;
|
||||
availableReactionsCount?: number;
|
||||
};
|
||||
|
||||
const CHANNEL_TITLE_EMPTY = 'Channel title can\'t be empty';
|
||||
@ -46,6 +47,7 @@ const ManageChannel: FC<OwnProps & StateProps> = ({
|
||||
progress,
|
||||
isSignaturesShown,
|
||||
canChangeInfo,
|
||||
availableReactionsCount,
|
||||
onScreenSelect,
|
||||
onClose,
|
||||
isActive,
|
||||
@ -92,6 +94,10 @@ const ManageChannel: FC<OwnProps & StateProps> = ({
|
||||
onScreenSelect(ManagementScreens.Discussion);
|
||||
}, [onScreenSelect]);
|
||||
|
||||
const handleClickReactions = useCallback(() => {
|
||||
onScreenSelect(ManagementScreens.Reactions);
|
||||
}, [onScreenSelect]);
|
||||
|
||||
const handleClickAdministrators = useCallback(() => {
|
||||
onScreenSelect(ManagementScreens.ChatAdministrators);
|
||||
}, [onScreenSelect]);
|
||||
@ -148,6 +154,8 @@ const ManageChannel: FC<OwnProps & StateProps> = ({
|
||||
openChat({ id: undefined });
|
||||
}, [chat.isCreator, chat.id, closeDeleteDialog, closeManagement, leaveChannel, deleteChannel, openChat]);
|
||||
|
||||
const enabledReactionsCount = chat.fullInfo?.enabledReactions?.length || 0;
|
||||
|
||||
if (chat.isRestricted) {
|
||||
return undefined;
|
||||
}
|
||||
@ -202,6 +210,17 @@ const ManageChannel: FC<OwnProps & StateProps> = ({
|
||||
<span className="title">{lang('ChannelAdministrators')}</span>
|
||||
<span className="subtitle">{adminsCount}</span>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
icon="reactions"
|
||||
multiline
|
||||
onClick={handleClickReactions}
|
||||
disabled={!canChangeInfo}
|
||||
>
|
||||
<span className="title">{lang('Reactions')}</span>
|
||||
<span className="subtitle" dir="auto">
|
||||
{enabledReactionsCount}/{availableReactionsCount}
|
||||
</span>
|
||||
</ListItem>
|
||||
<div className="ListItem no-selection narrow">
|
||||
<Checkbox
|
||||
checked={isSignaturesShown}
|
||||
@ -261,6 +280,7 @@ export default memo(withGlobal<OwnProps>(
|
||||
progress,
|
||||
isSignaturesShown,
|
||||
canChangeInfo: getHasAdminRight(chat, 'changeInfo'),
|
||||
availableReactionsCount: global.availableReactions?.filter((l) => !l.isInactive).length,
|
||||
};
|
||||
},
|
||||
)(ManageChannel));
|
||||
|
||||
@ -40,6 +40,7 @@ type StateProps = {
|
||||
hasLinkedChannel: boolean;
|
||||
canChangeInfo?: boolean;
|
||||
canBanUsers?: boolean;
|
||||
availableReactionsCount?: number;
|
||||
};
|
||||
|
||||
const GROUP_TITLE_EMPTY = 'Group title can\'t be empty';
|
||||
@ -59,6 +60,7 @@ const ManageGroup: FC<OwnProps & StateProps> = ({
|
||||
onScreenSelect,
|
||||
onClose,
|
||||
isActive,
|
||||
availableReactionsCount,
|
||||
}) => {
|
||||
const {
|
||||
togglePreHistoryHidden,
|
||||
@ -100,6 +102,10 @@ const ManageGroup: FC<OwnProps & StateProps> = ({
|
||||
onScreenSelect(ManagementScreens.Discussion);
|
||||
}, [onScreenSelect]);
|
||||
|
||||
const handleClickReactions = useCallback(() => {
|
||||
onScreenSelect(ManagementScreens.Reactions);
|
||||
}, [onScreenSelect]);
|
||||
|
||||
const handleClickPermissions = useCallback(() => {
|
||||
onScreenSelect(ManagementScreens.GroupPermissions);
|
||||
}, [onScreenSelect]);
|
||||
@ -154,6 +160,8 @@ const ManageGroup: FC<OwnProps & StateProps> = ({
|
||||
togglePreHistoryHidden({ chatId: chat.id, isEnabled: !isPreHistoryHidden });
|
||||
}, [chat, togglePreHistoryHidden]);
|
||||
|
||||
const enabledReactionsCount = chat.fullInfo?.enabledReactions?.length || 0;
|
||||
|
||||
const enabledPermissionsCount = useMemo(() => {
|
||||
if (!chat.defaultBannedRights) {
|
||||
return 0;
|
||||
@ -257,6 +265,18 @@ const ManageGroup: FC<OwnProps & StateProps> = ({
|
||||
{enabledPermissionsCount}/{TOTAL_PERMISSIONS_COUNT}
|
||||
</span>
|
||||
</ListItem>
|
||||
|
||||
<ListItem
|
||||
icon="reactions"
|
||||
multiline
|
||||
onClick={handleClickReactions}
|
||||
disabled={!canChangeInfo}
|
||||
>
|
||||
<span className="title">{lang('Reactions')}</span>
|
||||
<span className="subtitle" dir="auto">
|
||||
{enabledReactionsCount}/{availableReactionsCount}
|
||||
</span>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
icon="admin"
|
||||
multiline
|
||||
@ -332,6 +352,7 @@ export default memo(withGlobal<OwnProps>(
|
||||
hasLinkedChannel,
|
||||
canChangeInfo: isBasicGroup ? chat.isCreator : getHasAdminRight(chat, 'changeInfo'),
|
||||
canBanUsers: isBasicGroup ? chat.isCreator : getHasAdminRight(chat, 'banUsers'),
|
||||
availableReactionsCount: global.availableReactions?.filter((l) => !l.isInactive).length,
|
||||
};
|
||||
},
|
||||
)(ManageGroup));
|
||||
|
||||
131
src/components/right/management/ManageReactions.tsx
Normal file
131
src/components/right/management/ManageReactions.tsx
Normal file
@ -0,0 +1,131 @@
|
||||
import React, {
|
||||
FC, memo, useCallback, useEffect, useState,
|
||||
} from '../../../lib/teact/teact';
|
||||
import { getDispatch, withGlobal } from '../../../lib/teact/teactn';
|
||||
|
||||
import { ApiAvailableReaction, ApiChat } from '../../../api/types';
|
||||
|
||||
import { selectChat } from '../../../modules/selectors';
|
||||
import useLang from '../../../hooks/useLang';
|
||||
import useHistoryBack from '../../../hooks/useHistoryBack';
|
||||
|
||||
import ReactionStaticEmoji from '../../common/ReactionStaticEmoji';
|
||||
import Checkbox from '../../ui/Checkbox';
|
||||
import FloatingActionButton from '../../ui/FloatingActionButton';
|
||||
import Spinner from '../../ui/Spinner';
|
||||
|
||||
type OwnProps = {
|
||||
chatId: string;
|
||||
onClose: NoneToVoidFunction;
|
||||
isActive: boolean;
|
||||
};
|
||||
|
||||
type StateProps = {
|
||||
chat?: ApiChat;
|
||||
availableReactions?: ApiAvailableReaction[];
|
||||
enabledReactions?: string[];
|
||||
};
|
||||
|
||||
const ManageReactions: FC<OwnProps & StateProps> = ({
|
||||
availableReactions,
|
||||
enabledReactions,
|
||||
chat,
|
||||
isActive,
|
||||
onClose,
|
||||
}) => {
|
||||
const { setChatEnabledReactions } = getDispatch();
|
||||
|
||||
const lang = useLang();
|
||||
const [isTouched, setIsTouched] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [localEnabledReactions, setLocalEnabledReactions] = useState(enabledReactions);
|
||||
|
||||
useHistoryBack(isActive, onClose);
|
||||
|
||||
const handleSaveReactions = useCallback(() => {
|
||||
if (!chat) return;
|
||||
setIsLoading(true);
|
||||
|
||||
setChatEnabledReactions({
|
||||
chatId: chat.id,
|
||||
enabledReactions: localEnabledReactions,
|
||||
});
|
||||
}, [chat, localEnabledReactions, setChatEnabledReactions]);
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoading(false);
|
||||
setIsTouched(false);
|
||||
setLocalEnabledReactions(enabledReactions || []);
|
||||
}, [enabledReactions]);
|
||||
|
||||
const handleReactionChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (!chat || !availableReactions) return;
|
||||
|
||||
const { name, checked } = e.currentTarget;
|
||||
const newEnabledReactions = name === 'all' ? (checked ? availableReactions.map((l) => l.reaction) : [])
|
||||
: (!checked
|
||||
? localEnabledReactions.filter((l) => l !== name)
|
||||
: [...localEnabledReactions, name]);
|
||||
|
||||
setLocalEnabledReactions(newEnabledReactions);
|
||||
setIsTouched(true);
|
||||
}, [availableReactions, chat, localEnabledReactions]);
|
||||
|
||||
return (
|
||||
<div className="Management">
|
||||
<div className="custom-scroll">
|
||||
<div className="section">
|
||||
<div className="ListItem no-selection">
|
||||
<Checkbox
|
||||
name="all"
|
||||
checked={!localEnabledReactions || localEnabledReactions.length > 0}
|
||||
label={lang('EnableReactions')}
|
||||
onChange={handleReactionChange}
|
||||
/>
|
||||
</div>
|
||||
{availableReactions?.filter((l) => !l.isInactive).map(({ reaction, title }) => (
|
||||
<div className="ListItem no-selection">
|
||||
<Checkbox
|
||||
name={reaction}
|
||||
checked={!localEnabledReactions || localEnabledReactions?.includes(reaction)}
|
||||
disabled={localEnabledReactions?.length === 0}
|
||||
label={(
|
||||
<div className="Reaction">
|
||||
<ReactionStaticEmoji reaction={reaction} />
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
onChange={handleReactionChange}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FloatingActionButton
|
||||
isShown={isTouched}
|
||||
onClick={handleSaveReactions}
|
||||
ariaLabel={lang('Save')}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Spinner color="white" />
|
||||
) : (
|
||||
<i className="icon-check" />
|
||||
)}
|
||||
</FloatingActionButton>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(withGlobal<OwnProps>(
|
||||
(global, { chatId }): StateProps => {
|
||||
const chat = selectChat(global, chatId)!;
|
||||
|
||||
return {
|
||||
enabledReactions: chat.fullInfo?.enabledReactions,
|
||||
availableReactions: global.availableReactions,
|
||||
chat,
|
||||
};
|
||||
},
|
||||
)(ManageReactions));
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -18,6 +18,7 @@ import ManageGroupRecentActions from './ManageGroupRecentActions';
|
||||
import ManageGroupAdminRights from './ManageGroupAdminRights';
|
||||
import ManageGroupMembers from './ManageGroupMembers';
|
||||
import ManageGroupUserPermissionsCreate from './ManageGroupUserPermissionsCreate';
|
||||
import ManageReactions from './ManageReactions';
|
||||
|
||||
export type OwnProps = {
|
||||
chatId: string;
|
||||
@ -239,6 +240,15 @@ const Management: FC<OwnProps & StateProps> = ({
|
||||
onChatMemberSelect={onChatMemberSelect}
|
||||
/>
|
||||
);
|
||||
|
||||
case ManagementScreens.Reactions:
|
||||
return (
|
||||
<ManageReactions
|
||||
chatId={chatId}
|
||||
isActive={isActive}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return undefined; // Never reached
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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']));
|
||||
}
|
||||
|
||||
@ -184,6 +184,7 @@ export const INITIAL_STATE: GlobalState = {
|
||||
},
|
||||
|
||||
twoFaSettings: {},
|
||||
activeReactions: {},
|
||||
|
||||
shouldShowContextMenuHint: true,
|
||||
|
||||
|
||||
@ -23,6 +23,8 @@ import {
|
||||
ApiCountryCode,
|
||||
ApiCountry,
|
||||
ApiGroupCall,
|
||||
ApiAvailableReaction,
|
||||
ApiAppConfig,
|
||||
ApiSponsoredMessage,
|
||||
} from '../api/types';
|
||||
import {
|
||||
@ -61,6 +63,23 @@ export interface MessageList {
|
||||
type: MessageListType;
|
||||
}
|
||||
|
||||
export interface ActiveEmojiInteraction {
|
||||
x: number;
|
||||
y: number;
|
||||
messageId?: number;
|
||||
endX?: number;
|
||||
endY?: number;
|
||||
startSize?: number;
|
||||
reaction?: string;
|
||||
animatedEffect?: string;
|
||||
isReversed?: boolean;
|
||||
}
|
||||
|
||||
export interface ActiveReaction {
|
||||
messageId?: number;
|
||||
reaction?: string;
|
||||
}
|
||||
|
||||
export interface Thread {
|
||||
listedIds?: number[];
|
||||
outlyingIds?: number[];
|
||||
@ -86,6 +105,7 @@ export interface ServiceNotification {
|
||||
}
|
||||
|
||||
export type GlobalState = {
|
||||
appConfig?: ApiAppConfig;
|
||||
isChatInfoShown: boolean;
|
||||
isLeftColumnShown: boolean;
|
||||
isPollModalOpen?: boolean;
|
||||
@ -212,6 +232,11 @@ export type GlobalState = {
|
||||
messageId: number;
|
||||
};
|
||||
|
||||
reactorModal?: {
|
||||
chatId: string;
|
||||
messageId: number;
|
||||
};
|
||||
|
||||
fileUploads: {
|
||||
byMessageLocalId: Record<string, {
|
||||
progress: number;
|
||||
@ -254,6 +279,7 @@ export type GlobalState = {
|
||||
};
|
||||
|
||||
animatedEmojis?: ApiStickerSet;
|
||||
animatedEmojiEffects?: ApiStickerSet;
|
||||
emojiKeywords: Partial<Record<LangCode, EmojiKeywords>>;
|
||||
|
||||
gifs: {
|
||||
@ -305,6 +331,10 @@ export type GlobalState = {
|
||||
globalUserIds?: string[];
|
||||
};
|
||||
|
||||
availableReactions?: ApiAvailableReaction[];
|
||||
activeEmojiInteraction?: ActiveEmojiInteraction;
|
||||
activeReactions: Record<number, ActiveReaction>;
|
||||
|
||||
localTextSearch: {
|
||||
byChatThreadKey: Record<string, {
|
||||
isActive: boolean;
|
||||
@ -483,7 +513,8 @@ export type ActionTypes = (
|
||||
'toggleChatInfo' | 'setIsUiReady' | 'addRecentEmoji' | 'addRecentSticker' | 'toggleLeftColumn' |
|
||||
'toggleSafeLinkModal' | 'openHistoryCalendar' | 'closeHistoryCalendar' | 'disableContextMenuHint' |
|
||||
'setNewChatMembersDialogState' | 'disableHistoryAnimations' | 'setLeftColumnWidth' | 'resetLeftColumnWidth' |
|
||||
'openSeenByModal' | 'closeSeenByModal' |
|
||||
'openSeenByModal' | 'closeSeenByModal' | 'closeReactorListModal' |
|
||||
'openReactorListModal' |
|
||||
// auth
|
||||
'setAuthPhoneNumber' | 'setAuthCode' | 'setAuthPassword' | 'signUp' | 'returnToAuthPhoneNumber' | 'signOut' |
|
||||
'setAuthRememberMe' | 'clearAuthError' | 'uploadProfilePhoto' | 'goToAuthQrCode' | 'clearCache' |
|
||||
@ -495,7 +526,7 @@ export type ActionTypes = (
|
||||
'joinChannel' | 'leaveChannel' | 'deleteChannel' | 'toggleChatPinned' | 'toggleChatArchived' | 'toggleChatUnread' |
|
||||
'loadChatFolders' | 'loadRecommendedChatFolders' | 'editChatFolder' | 'addChatFolder' | 'deleteChatFolder' |
|
||||
'updateChat' | 'toggleSignatures' | 'loadGroupsForDiscussion' | 'linkDiscussionGroup' | 'unlinkDiscussionGroup' |
|
||||
'loadProfilePhotos' | 'loadMoreMembers' | 'setActiveChatFolder' | 'openNextChat' |
|
||||
'loadProfilePhotos' | 'loadMoreMembers' | 'setActiveChatFolder' | 'openNextChat' | 'setChatEnabledReactions' |
|
||||
'addChatMembers' | 'deleteChatMember' | 'openPreviousChat' | 'editChatFolders' | 'toggleIsProtected' |
|
||||
// messages
|
||||
'loadViewportMessages' | 'selectMessage' | 'sendMessage' | 'cancelSendingMessage' | 'pinMessage' | 'deleteMessages' |
|
||||
@ -503,9 +534,12 @@ export type ActionTypes = (
|
||||
'editMessage' | 'deleteHistory' | 'enterMessageSelectMode' | 'toggleMessageSelection' | 'exitMessageSelectMode' |
|
||||
'openTelegramLink' | 'openChatByUsername' | 'requestThreadInfoUpdate' | 'setScrollOffset' | 'unpinAllMessages' |
|
||||
'setReplyingToId' | 'setEditingId' | 'editLastMessage' | 'saveDraft' | 'clearDraft' | 'loadPinnedMessages' |
|
||||
'toggleMessageWebPage' | 'replyToNextMessage' | 'deleteChatUser' | 'deleteChat' |
|
||||
'toggleMessageWebPage' | 'replyToNextMessage' | 'deleteChatUser' | 'deleteChat' | 'sendReaction' |
|
||||
'reportMessages' | 'sendMessageAction' | 'focusNextReply' | 'openChatByInvite' | 'loadSeenBy' |
|
||||
'loadSponsoredMessages' | 'viewSponsoredMessage' | 'loadSendAs' | 'saveDefaultSendAs' |
|
||||
'loadSponsoredMessages' | 'viewSponsoredMessage' | 'loadSendAs' | 'saveDefaultSendAs' | 'loadAvailableReactions' |
|
||||
'stopActiveEmojiInteraction' | 'interactWithAnimatedEmoji' | 'loadReactors' | 'setDefaultReaction' |
|
||||
'sendDefaultReaction' | 'sendEmojiInteraction' | 'sendWatchingEmojiInteraction' | 'loadMessageReactions' |
|
||||
'stopActiveReaction' |
|
||||
// downloads
|
||||
'downloadSelectedMessages' | 'downloadMessageMedia' | 'cancelMessageMediaDownload' |
|
||||
// scheduled messages
|
||||
@ -541,7 +575,7 @@ export type ActionTypes = (
|
||||
'updateWebNotificationSettings' | 'loadLanguages' | 'loadPrivacySettings' | 'setPrivacyVisibility' |
|
||||
'setPrivacySettings' | 'loadNotificationExceptions' | 'setThemeSettings' | 'updateIsOnline' |
|
||||
'loadContentSettings' | 'updateContentSettings' |
|
||||
'loadCountryList' | 'ensureTimeFormat' |
|
||||
'loadCountryList' | 'ensureTimeFormat' | 'loadAppConfig' |
|
||||
// stickers & GIFs
|
||||
'loadStickerSets' | 'loadAddedStickers' | 'loadRecentStickers' | 'loadFavoriteStickers' | 'loadFeaturedStickers' |
|
||||
'loadStickers' | 'setStickerSearchQuery' | 'loadSavedGifs' | 'setGifSearchQuery' | 'searchMoreGifs' |
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user