/* * https://github.com/morethanwords/tweb * Copyright (C) 2019-2021 Eduard Kuzmenko * https://github.com/morethanwords/tweb/blob/master/LICENSE */ import type {MyDocument} from '../../lib/appManagers/appDocsManager'; import type Chat from './chat'; import IS_TOUCH_SUPPORTED from '../../environment/touchSupport'; import ButtonMenu, {ButtonMenuItemOptions} from '../buttonMenu'; import PopupDeleteMessages from '../popups/deleteMessages'; import PopupForward from '../popups/forward'; import PopupPinMessage from '../popups/unpinMessage'; import {copyTextToClipboard} from '../../helpers/clipboard'; import PopupSendNow from '../popups/sendNow'; import {toast} from '../toast'; import I18n, {i18n, LangPackKey} from '../../lib/langPack'; import findUpClassName from '../../helpers/dom/findUpClassName'; import cancelEvent from '../../helpers/dom/cancelEvent'; import {attachClickEvent, simulateClickEvent} from '../../helpers/dom/clickEvent'; import isSelectionEmpty from '../../helpers/dom/isSelectionEmpty'; import {Message, Poll, Chat as MTChat, MessageMedia, AvailableReaction, MessageEntity, InputStickerSet, StickerSet, Document, Reaction, Photo, SponsoredMessage} from '../../layer'; import PopupReportMessages from '../popups/reportMessages'; import assumeType from '../../helpers/assumeType'; import PopupSponsored from '../popups/sponsored'; import ListenerSetter from '../../helpers/listenerSetter'; import {getMiddleware} from '../../helpers/middleware'; import PeerTitle from '../peerTitle'; import StackedAvatars from '../stackedAvatars'; import {IS_APPLE} from '../../environment/userAgent'; import PopupReactedList from '../popups/reactedList'; import {ChatReactionsMenu, REACTION_CONTAINER_SIZE} from './reactionsMenu'; import getPeerId from '../../lib/appManagers/utils/peers/getPeerId'; import getServerMessageId from '../../lib/appManagers/utils/messageId/getServerMessageId'; import {AppManagers} from '../../lib/appManagers/managers'; import positionMenu, {MenuPositionPadding} from '../../helpers/positionMenu'; import contextMenuController from '../../helpers/contextMenuController'; import {attachContextMenuListener} from '../../helpers/dom/attachContextMenuListener'; import filterAsync from '../../helpers/array/filterAsync'; import appDownloadManager, {DownloadBlob} from '../../lib/appManagers/appDownloadManager'; import {SERVICE_PEER_ID} from '../../lib/mtproto/mtproto_config'; import {MessagesStorageKey, MyMessage} from '../../lib/appManagers/appMessagesManager'; import filterUnique from '../../helpers/array/filterUnique'; import replaceContent from '../../helpers/dom/replaceContent'; import wrapEmojiText from '../../lib/richTextProcessor/wrapEmojiText'; import deferredPromise, {CancellablePromise} from '../../helpers/cancellablePromise'; import PopupStickers from '../popups/stickers'; import getMediaFromMessage from '../../lib/appManagers/utils/messages/getMediaFromMessage'; import canSaveMessageMedia from '../../lib/appManagers/utils/messages/canSaveMessageMedia'; import getAlbumText from '../../lib/appManagers/utils/messages/getAlbumText'; import PopupElement from '../popups'; type ChatContextMenuButton = ButtonMenuItemOptions & { verify: () => boolean | Promise, notDirect?: () => boolean, withSelection?: true, isSponsored?: true, localName?: 'views' | 'emojis' | 'sponsorInfo' | 'sponsorAdditionalInfo' }; export default class ChatContextMenu { private buttons: ChatContextMenuButton[]; private element: HTMLElement; private isSelectable: boolean; private isSelected: boolean; private target: HTMLElement; private isTargetAGroupedItem: boolean; private isTextSelected: boolean; private isAnchorTarget: boolean; private isUsernameTarget: boolean; private isSponsored: boolean; private isOverBubble: boolean; private peerId: PeerId; private mid: number; private message: Message.message | Message.messageService; private sponsoredMessage: SponsoredMessage; private noForwards: boolean; private reactionsMenu: ChatReactionsMenu; private listenerSetter: ListenerSetter; private attachListenerSetter: ListenerSetter; private viewerPeerId: PeerId; private middleware: ReturnType; private canOpenReactedList: boolean; private emojiInputsPromise: CancellablePromise; private albumMessages: Message.message[]; private linkToMessage: Awaited>; private selectedMessagesText: string; private selectedMessages: MyMessage[]; constructor( private chat: Chat, private managers: AppManagers ) { this.listenerSetter = new ListenerSetter(); this.attachListenerSetter = new ListenerSetter(); this.middleware = getMiddleware(); } public attachTo(element: HTMLElement) { this.attachListenerSetter.removeAll(); if(IS_TOUCH_SUPPORTED/* && false */) { attachClickEvent(element, (e) => { if(this.chat.selection.isSelecting) { return; } this.chat.log('touchend', e); const badSelectors = [ '.name', '.peer-title', '.reply', '.document', 'audio-element', 'avatar-element', 'a', '.bubble-beside-button', 'replies-element', '[data-saved-from]:not(.bubble)', 'poll-element', '.attachment', '.reply-markup-button' ]; const good = !(e.target as HTMLElement).closest(badSelectors.join(', ')); if(good) { cancelEvent(e); // onContextMenu((e as TouchEvent).changedTouches[0]); // onContextMenu((e as TouchEvent).changedTouches ? (e as TouchEvent).changedTouches[0] : e as MouseEvent); this.onContextMenu(e); } }, {listenerSetter: this.attachListenerSetter}); } else attachContextMenuListener({ element, callback: this.onContextMenu, listenerSetter: this.attachListenerSetter }); } private onContextMenu = (e: MouseEvent | Touch | TouchEvent) => { let bubble: HTMLElement, contentWrapper: HTMLElement; try { contentWrapper = findUpClassName(e.target, 'bubble-content-wrapper'); bubble = contentWrapper ? contentWrapper.parentElement : findUpClassName(e.target, 'bubble'); } catch(e) {} // ! context menu click by date bubble (there is no pointer-events) if(!bubble || bubble.classList.contains('bubble-first')) return; let element = this.element; if(e instanceof MouseEvent || e.hasOwnProperty('preventDefault')) (e as any).preventDefault(); if(element && element.classList.contains('active')) { return false; } if(e instanceof MouseEvent || e.hasOwnProperty('cancelBubble')) (e as any).cancelBubble = true; let mid = +bubble.dataset.mid; if(!mid) return; const r = async() => { const isSponsored = this.isSponsored = mid < 0; this.isSelectable = this.chat.selection.canSelectBubble(bubble); this.peerId = this.chat.peerId; // this.msgID = msgID; this.target = e.target as HTMLElement; this.isTextSelected = !isSelectionEmpty(); this.isAnchorTarget = this.target.tagName === 'A' && ( (this.target as HTMLAnchorElement).target === '_blank' || this.target.classList.contains('anchor-url') ); this.isUsernameTarget = this.target.tagName === 'A' && this.target.classList.contains('mention'); this.sponsoredMessage = isSponsored ? (bubble as any).message.sponsoredMessage : undefined; const mids = await this.chat.getMidsByMid(mid); // * если открыть контекстное меню для альбома не по бабблу, и последний элемент не выбран, чтобы показать остальные пункты if(this.chat.selection.isSelecting && !contentWrapper) { if(isSponsored) { return; } if(mids.length > 1) { const selectedMid = this.chat.selection.isMidSelected(this.peerId, mid) ? mid : mids.find((mid) => this.chat.selection.isMidSelected(this.peerId, mid)); if(selectedMid) { mid = selectedMid; } } } this.isOverBubble = !!contentWrapper; const groupedItem = findUpClassName(this.target, 'grouped-item'); this.isTargetAGroupedItem = !!groupedItem; if(groupedItem) { this.mid = +groupedItem.dataset.mid; } else { this.mid = mid; } this.isSelected = this.chat.selection.isMidSelected(this.peerId, this.mid); this.message = (bubble as any).message || await this.chat.getMessage(this.mid); this.albumMessages = (this.message as Message.message).grouped_id ? await this.managers.appMessagesManager.getMessagesByAlbum((this.message as Message.message).grouped_id) : undefined; this.noForwards = !isSponsored && !(await this.managers.appMessagesManager.canForward(this.message)); this.viewerPeerId = undefined; this.canOpenReactedList = undefined; this.linkToMessage = await this.getUrlToMessage(); this.selectedMessagesText = await this.getSelectedMessagesText(); this.selectedMessages = this.chat.selection.isSelecting ? await this.chat.selection.getSelectedMessages() : undefined; const initResult = await this.init(); if(!initResult) { return; } element = initResult.element; const {cleanup, destroy, menuPadding, reactionsMenu, reactionsMenuPosition} = initResult; let isReactionsMenuVisible = false; if(reactionsMenu) { const className = 'is-visible'; isReactionsMenuVisible = reactionsMenu.container.classList.contains(className); if(isReactionsMenuVisible) reactionsMenu.container.classList.remove(className); if(reactionsMenuPosition === 'horizontal') { const offsetSize = element[/* reactionsMenuPosition === 'vertical' ? 'offsetHeight' : */'offsetWidth']; // if(reactionsMenu.scrollable.container.scrollWidth > offsetWidth) { const INNER_CONTAINER_PADDING = 8; const visibleLength = (offsetSize - INNER_CONTAINER_PADDING) / REACTION_CONTAINER_SIZE; const nextVisiblePart = visibleLength % 1; const MIN_NEXT_VISIBLE_PART = 0.65; if(nextVisiblePart < MIN_NEXT_VISIBLE_PART) { const minSize = (offsetSize + (MIN_NEXT_VISIBLE_PART - nextVisiblePart) * REACTION_CONTAINER_SIZE) | 0; element.style[/* reactionsMenuPosition === 'vertical' ? 'minHeight' : */'minWidth'] = minSize + 'px'; } // } } } const side: 'left' | 'right' = bubble.classList.contains('is-in') ? 'left' : 'right'; // bubble.parentElement.append(element); // appImManager.log('contextmenu', e, bubble, side); positionMenu((e as TouchEvent).touches ? (e as TouchEvent).touches[0] : e as MouseEvent, element, side, menuPadding); if(reactionsMenu) { reactionsMenu.widthContainer.style.top = element.style.top; reactionsMenu.widthContainer.style.left = element.style.left; reactionsMenu.widthContainer.style.setProperty('--menu-width', element[reactionsMenuPosition === 'vertical' ? 'offsetHeight' : 'offsetWidth'] + 'px'); element.parentElement.append(reactionsMenu.widthContainer); if(isReactionsMenuVisible) void reactionsMenu.container.offsetLeft; // reflow } contextMenuController.openBtnMenu(element, () => { if(reactionsMenu) { reactionsMenu.container.classList.remove('is-visible'); } this.mid = 0; this.peerId = undefined; this.target = null; this.viewerPeerId = undefined; this.canOpenReactedList = undefined; cleanup(); setTimeout(() => { destroy(); }, 300); }); if(isReactionsMenuVisible) { reactionsMenu.container.classList.add('is-visible'); } }; r(); }; public cleanup() { this.listenerSetter.removeAll(); this.reactionsMenu && this.reactionsMenu.cleanup(); this.middleware.clean(); } public destroy() { this.cleanup(); this.attachListenerSetter.removeAll(); } private async filterButtons(buttons: ChatContextMenu['buttons']) { return filterAsync(buttons, async(button) => { let good: boolean; if((this.isSponsored && !button.isSponsored) || (!this.isSponsored && button.isSponsored)) { return false; } // if((appImManager.chatSelection.isSelecting && !button.withSelection) || (button.withSelection && !appImManager.chatSelection.isSelecting)) { if(this.chat.selection.isSelecting && !button.withSelection) { good = false; } else { good = this.isOverBubble || IS_TOUCH_SUPPORTED || true ? await button.verify() : button.notDirect && await button.verify() && button.notDirect(); } return !!good; }); } private setButtons() { const verifyFavoriteSticker = async(toAdd: boolean) => { const doc = ((this.message as Message.message).media as MessageMedia.messageMediaDocument)?.document; if(!(doc as MyDocument)?.sticker) { return false; } const favedStickers = await this.managers.acknowledged.appStickersManager.getFavedStickersStickers(); if(!favedStickers.cached) { return false; } const found = (await favedStickers.result).some((_doc) => _doc.id === doc.id); return toAdd ? !found : found; }; this.buttons = [{ icon: 'send2', text: 'MessageScheduleSend', onClick: this.onSendScheduledClick, verify: () => this.chat.type === 'scheduled' && !this.message.pFlags.is_outgoing }, { icon: 'send2', text: 'Message.Context.Selection.SendNow', onClick: this.onSendScheduledClick, verify: () => this.chat.type === 'scheduled' && this.isSelected && !this.chat.selection.selectionSendNowBtn.hasAttribute('disabled'), notDirect: () => true, withSelection: true }, { icon: 'schedule', text: 'MessageScheduleEditTime', onClick: () => { this.chat.input.scheduleSending(() => { assumeType(this.message); this.managers.appMessagesManager.editMessage(this.message, this.message.message, { scheduleDate: this.chat.input.scheduleDate, entities: this.message.entities }); this.chat.input.onMessageSent(false, false); }, new Date(this.message.date * 1000)); }, verify: () => this.chat.type === 'scheduled' }, { icon: 'reply', text: 'Reply', onClick: this.onReplyClick, verify: async() => await this.chat.canSend() && !this.message.pFlags.is_outgoing && !!this.chat.input.messageInput && this.chat.type !== 'scheduled'/* , cancelEvent: true */ }, { icon: 'favourites', text: 'AddToFavorites', onClick: this.onFaveStickerClick.bind(this, false), verify: () => verifyFavoriteSticker(true) }, { icon: 'favourites', text: 'DeleteFromFavorites', onClick: this.onFaveStickerClick.bind(this, true), verify: () => verifyFavoriteSticker(false) }, { icon: 'edit', text: 'Edit', onClick: this.onEditClick, verify: async() => (await this.managers.appMessagesManager.canEditMessage(this.message, 'text')) && !!this.chat.input.messageInput }, { icon: 'copy', text: 'Copy', onClick: this.onCopyClick, verify: () => !this.noForwards && !!(this.message as Message.message).message && !this.isTextSelected && (!this.isAnchorTarget || (this.message as Message.message).message !== this.target.innerText) }, { icon: 'copy', text: 'Chat.CopySelectedText', onClick: this.onCopyClick, verify: () => !this.noForwards && !!(this.message as Message.message).message && this.isTextSelected }, { icon: 'copy', text: 'Message.Context.Selection.Copy', onClick: this.onCopyClick, verify: async() => { if(!this.isSelected || this.noForwards) { return false; } for(const [peerId, mids] of this.chat.selection.selectedMids) { const storageKey: MessagesStorageKey = `${peerId}_${this.chat.type === 'scheduled' ? 'scheduled' : 'history'}`; for(const mid of mids) { const message = (await this.managers.appMessagesManager.getMessageFromStorage(storageKey, mid)) as Message.message; if(!!message.message) { return true; } } } return false; }, notDirect: () => true, withSelection: true }, { icon: 'copy', text: 'CopyLink', onClick: this.onCopyAnchorLinkClick, verify: () => this.isAnchorTarget, withSelection: true }, { icon: 'copy', text: 'Text.Context.Copy.Username', onClick: () => { copyTextToClipboard(this.target.textContent); }, verify: () => this.isUsernameTarget, withSelection: true }, { icon: 'copy', text: 'Text.Context.Copy.Hashtag', onClick: () => { copyTextToClipboard(this.target.textContent); }, verify: () => this.target.classList.contains('anchor-hashtag'), withSelection: true }, { icon: 'link', text: 'MessageContext.CopyMessageLink1', onClick: this.onCopyLinkClick, verify: async() => await this.managers.appPeersManager.isChannel(this.peerId) && !this.message.pFlags.is_outgoing }, { icon: 'pin', text: 'Message.Context.Pin', onClick: this.onPinClick, verify: async() => !this.message.pFlags.is_outgoing && this.message._ !== 'messageService' && !this.message.pFlags.pinned && await this.managers.appPeersManager.canPinMessage(this.peerId) && this.chat.type !== 'scheduled' }, { icon: 'unpin', text: 'Message.Context.Unpin', onClick: this.onUnpinClick, verify: async() => (this.message as Message.message).pFlags.pinned && await this.managers.appPeersManager.canPinMessage(this.peerId) }, { icon: 'download', text: 'MediaViewer.Context.Download', onClick: () => ChatContextMenu.onDownloadClick(this.message, this.noForwards), verify: () => ChatContextMenu.canDownload(this.message, this.target, this.noForwards) }, { icon: 'checkretract', text: 'Chat.Poll.Unvote', onClick: this.onRetractVote, verify: () => { const poll = (this.message as any).media?.poll as Poll; return poll && poll.chosenIndexes.length && !poll.pFlags.closed && !poll.pFlags.quiz; }/* , cancelEvent: true */ }, { icon: 'stop', text: 'Chat.Poll.Stop', onClick: this.onStopPoll, verify: async() => { const poll = (this.message as any).media?.poll; return await this.managers.appMessagesManager.canEditMessage(this.message, 'poll') && poll && !poll.pFlags.closed && !this.message.pFlags.is_outgoing; }/* , cancelEvent: true */ }, { icon: 'forward', text: 'Forward', onClick: this.onForwardClick, // let forward the message if it's outgoing but not ours (like a changelog) verify: () => !this.noForwards && this.chat.type !== 'scheduled' && (!this.message.pFlags.is_outgoing || this.message.fromId === SERVICE_PEER_ID) && this.message._ !== 'messageService' }, { icon: 'forward', text: 'Message.Context.Selection.Forward', onClick: this.onForwardClick, verify: () => this.chat.selection.selectionForwardBtn && this.isSelected && !this.chat.selection.selectionForwardBtn.hasAttribute('disabled'), notDirect: () => true, withSelection: true }, { icon: 'download', text: 'Message.Context.Selection.Download', onClick: () => ChatContextMenu.onDownloadClick(this.selectedMessages, this.noForwards), verify: () => this.selectedMessages && ChatContextMenu.canDownload(this.selectedMessages, undefined, this.noForwards), withSelection: true }, { icon: 'flag', text: 'ReportChat', onClick: () => { PopupElement.createPopup(PopupReportMessages, this.peerId, [this.mid]); }, verify: async() => !this.message.pFlags.out && this.message._ === 'message' && !this.message.pFlags.is_outgoing && await this.managers.appPeersManager.isChannel(this.peerId), notDirect: () => true, withSelection: true }, { icon: 'select', text: 'Message.Context.Select', onClick: this.onSelectClick, verify: () => !(this.message as Message.messageService).action && !this.isSelected && this.isSelectable, notDirect: () => true, withSelection: true }, { icon: 'select', text: 'Message.Context.Selection.Clear', onClick: this.onClearSelectionClick, verify: () => this.isSelected, notDirect: () => true, withSelection: true }, { onClick: () => { if(this.viewerPeerId && false) { this.chat.appImManager.setInnerPeer({ peerId: this.viewerPeerId }); } else if(this.canOpenReactedList) { PopupElement.createPopup(PopupReactedList, this.message as Message.message); } else { return false; } }, verify: async() => !this.peerId.isUser() && (!!(this.message as Message.message).reactions?.recent_reactions?.length || await this.managers.appMessagesManager.canViewMessageReadParticipants(this.message)), notDirect: () => true, localName: 'views' }, { icon: 'delete danger', text: 'Delete', onClick: this.onDeleteClick, verify: async() => this.managers.appMessagesManager.canDeleteMessage(this.message) }, { icon: 'delete danger', text: 'Message.Context.Selection.Delete', onClick: this.onDeleteClick, verify: () => this.isSelected && !this.chat.selection.selectionDeleteBtn.hasAttribute('disabled'), notDirect: () => true, withSelection: true }, { icon: 'info', text: 'Chat.Message.Sponsored.What', onClick: () => { PopupElement.createPopup(PopupSponsored); }, verify: () => this.isSponsored, isSponsored: true }, { icon: 'copy', text: 'Copy', onClick: () => copyTextToClipboard(this.sponsoredMessage.message), verify: () => this.isSponsored, isSponsored: true }, { // icon: 'smile', text: 'Loading', onClick: () => { this.emojiInputsPromise.then((inputs) => { PopupElement.createPopup(PopupStickers, inputs, true).show(); }); }, verify: () => !!this.getUniqueCustomEmojisFromMessage().length, notDirect: () => true, localName: 'emojis' }, { regularText: this.sponsoredMessage?.sponsor_info ? wrapEmojiText(this.sponsoredMessage.sponsor_info) : undefined, separator: true, multiline: true, onClick: () => copyTextToClipboard(this.sponsoredMessage.sponsor_info), verify: () => !!this.sponsoredMessage.sponsor_info, isSponsored: true }, { regularText: this.sponsoredMessage?.additional_info ? wrapEmojiText(this.sponsoredMessage.additional_info) : undefined, separator: true, multiline: true, onClick: () => copyTextToClipboard(this.sponsoredMessage.additional_info), verify: () => !!this.sponsoredMessage.additional_info, isSponsored: true }]; } public static canDownload(message: MyMessage | MyMessage[], withTarget?: HTMLElement, noForwards?: boolean): boolean { if(Array.isArray(message)) { return message.some((message) => ChatContextMenu.canDownload(message, withTarget, noForwards)); } if(!canSaveMessageMedia(message) || noForwards) { return false; } const isPhoto: boolean = !!((message as Message.message).media as MessageMedia.messageMediaPhoto)?.photo; let isGoodType = false if(isPhoto) { isGoodType = true; } else { const doc: MyDocument = ((message as Message.message).media as MessageMedia.messageMediaDocument)?.document as any; if(!doc) return false; // isGoodType = doc.type && (['gif', 'video', 'audio', 'voice', 'sticker'] as MyDocument['type'][]).includes(doc.type) isGoodType = true; } let hasTarget = !withTarget || !!IS_TOUCH_SUPPORTED; if(isGoodType && withTarget) { hasTarget ||= !!(findUpClassName(withTarget, 'document') || findUpClassName(withTarget, 'audio') || findUpClassName(withTarget, 'media-sticker-wrapper') || findUpClassName(withTarget, 'media-photo') || findUpClassName(withTarget, 'media-video')); } return isGoodType && hasTarget; } private getMessageWithText() { return (this.albumMessages && getAlbumText(this.albumMessages)) || this.message; } private getUniqueCustomEmojisFromMessage() { const docIds: DocId[] = []; const message = this.getMessageWithText(); const entities = (message as Message.message).entities; if(entities) { const filtered = entities.filter((entity) => entity._ === 'messageEntityCustomEmoji') as MessageEntity.messageEntityCustomEmoji[]; docIds.push(...filtered.map((entity) => entity.document_id)); } const reactions = (message as Message.message).reactions; if(reactions) { const results = reactions.results.filter((reactionCount) => reactionCount.reaction._ === 'reactionCustomEmoji'); docIds.push(...results.map((reactionCount) => (reactionCount.reaction as Reaction.reactionCustomEmoji).document_id)); } return filterUnique(docIds); } private async init() { this.cleanup(); this.setButtons(); const filteredButtons = await this.filterButtons(this.buttons); if(!filteredButtons.length) { return; } const element = this.element = await ButtonMenu({ buttons: filteredButtons, listenerSetter: this.listenerSetter }); element.id = 'bubble-contextmenu'; element.classList.add('contextmenu'); const viewsButton = filteredButtons.find((button) => button.localName === 'views'); if(viewsButton) { const reactions = (this.message as Message.message).reactions; const recentReactions = reactions?.recent_reactions; const isViewingReactions = !!recentReactions?.length; const participantsCount = await this.managers.appMessagesManager.canViewMessageReadParticipants(this.message) ? ((await this.managers.appPeersManager.getPeer(this.peerId)) as MTChat.chat).participants_count : undefined; const reactedLength = reactions ? reactions.results.reduce((acc, r) => acc + r.count, 0) : undefined; viewsButton.element.classList.add('tgico-' + (isViewingReactions ? 'reactions' : 'checks')); const i18nElem = new I18n.IntlElement({ key: isViewingReactions ? ( participantsCount === undefined ? 'Chat.Context.ReactedFast' : 'Chat.Context.Reacted' ) : 'NobodyViewed', args: isViewingReactions ? ( participantsCount === undefined ? [reactedLength] : [participantsCount, participantsCount] ) : undefined, element: viewsButton.textElement }); let fakeText: HTMLElement; if(isViewingReactions) { if(participantsCount === undefined) { fakeText = i18n('Chat.Context.ReactedFast', [reactedLength]); } else { fakeText = i18n( recentReactions.length === participantsCount ? 'Chat.Context.ReactedFast' : 'Chat.Context.Reacted', [recentReactions.length, participantsCount] ); } } else { fakeText = i18n('Loading'); } fakeText.classList.add('btn-menu-item-text-fake'); viewsButton.element.append(fakeText); const AVATAR_SIZE = 22; const MAX_AVATARS = 3; const PADDING_PER_AVATAR = 1.125; i18nElem.element.style.visibility = 'hidden'; i18nElem.element.style.paddingRight = isViewingReactions ? PADDING_PER_AVATAR * Math.min(MAX_AVATARS, recentReactions.length) + 'rem' : '1rem'; const middleware = this.middleware.get(); this.managers.appMessagesManager.getMessageReactionsListAndReadParticipants(this.message as Message.message).then((result) => { if(!middleware()) { return; } fakeText?.remove(); const reactions = result.combined; const reactedLength = participantsCount === undefined ? result.reactionsCount : ( isViewingReactions ? reactions.filter((reaction) => reaction.reaction).length : reactions.length ); let fakeElem: HTMLElement; if(reactions.length === 1) { fakeElem = new PeerTitle({ peerId: reactions[0].peerId, onlyFirstName: true, dialog: false }).element; if(!isViewingReactions || result.readParticipantDates.length <= 1) { this.viewerPeerId = reactions[0].peerId; } } else if(isViewingReactions) { const isFull = reactedLength === reactions.length || participantsCount === undefined; fakeElem = i18n( isFull ? 'Chat.Context.ReactedFast' : 'Chat.Context.Reacted', isFull ? [reactedLength] : [reactedLength, reactions.length] ); } else { if(!reactions.length) { i18nElem.element.style.visibility = ''; } else { fakeElem = i18n('MessageSeen', [reactions.length]); } } if(fakeElem) { fakeElem.style.paddingRight = PADDING_PER_AVATAR * Math.min(MAX_AVATARS, reactedLength) + 'rem'; fakeElem.classList.add('btn-menu-item-text-fake'); viewsButton.element.append(fakeElem); } if(reactions.length) { const avatars = new StackedAvatars({avatarSize: AVATAR_SIZE}); avatars.render(recentReactions ? recentReactions.map((r) => getPeerId(r.peer_id)) : reactions.map((reaction) => reaction.peerId)); viewsButton.element.append(avatars.container); // if(reactions.length > 1) { // if(isViewingReactions) { this.canOpenReactedList = true; // } } }); } let menuPadding: MenuPositionPadding; let reactionsMenu: ChatReactionsMenu; let reactionsMenuPosition: 'horizontal' | 'vertical'; if( this.message._ === 'message' && !this.chat.selection.isSelecting && !this.message.pFlags.is_outgoing && !this.message.pFlags.is_scheduled && !this.message.pFlags.local ) { reactionsMenuPosition = (IS_APPLE || IS_TOUCH_SUPPORTED)/* && false */ ? 'horizontal' : 'vertical'; reactionsMenu = this.reactionsMenu = new ChatReactionsMenu(this.managers, reactionsMenuPosition, this.middleware); reactionsMenu.init(await this.managers.appMessagesManager.getGroupsFirstMessage(this.message)); // element.prepend(reactionsMenu.widthContainer); const size = 36; const margin = 8; const totalSize = size + margin; const paddingLeft = 0, paddingRight = 0; if(reactionsMenuPosition === 'vertical') { menuPadding = { top: paddingLeft, // bottom: 36, // positionMenu will detect it itself somehow left: totalSize }; } else { menuPadding = { top: totalSize, right: paddingRight, left: paddingLeft }; } } const emojisButton = filteredButtons.find((button) => button.localName === 'emojis'); if(emojisButton) { emojisButton.element.classList.add('is-multiline'); emojisButton.element.parentElement.insertBefore(document.createElement('hr'), emojisButton.element); const setPadding = () => { menuPadding ??= {}; menuPadding.bottom = 24; }; const docIds = this.getUniqueCustomEmojisFromMessage(); const inputsPromise = this.emojiInputsPromise = deferredPromise(); await this.managers.appEmojiManager.getCachedCustomEmojiDocuments(docIds).then(async(docs) => { const p = async(docs: Document.document[]) => { const s: Map = new Map(); docs.forEach((doc) => { if(!doc || s.has(doc.stickerSetInput.id)) { return; } s.set(doc.stickerSetInput.id, doc.stickerSetInput); }); const inputs = [...s.values()]; inputsPromise.resolve(inputs); if(s.size === 1) { const result = await this.managers.acknowledged.appStickersManager.getStickerSet(inputs[0]); const promise = result.result.then((set) => { const el = i18n('MessageContainsEmojiPack', [wrapEmojiText(set.set.title)]); replaceContent(emojisButton.textElement, el); }); return result.cached ? promise : (setPadding(), undefined); } replaceContent(emojisButton.textElement, i18n('MessageContainsEmojiPacks', [s.size])); }; if(docs.some((doc) => !doc)) { setPadding(); this.managers.appEmojiManager.getCustomEmojiDocuments(docIds).then(p); } else { return p(docs); } }); // emojisButton.element.append(i18n('Loading')); } this.chat.container.append(element); return { element, cleanup: () => { this.cleanup(); reactionsMenu && reactionsMenu.cleanup(); }, destroy: () => { element.remove(); reactionsMenu && reactionsMenu.widthContainer.remove(); }, menuPadding, reactionsMenu, reactionsMenuPosition }; } private async getUrlToMessage() { if(this.peerId.isUser()) { return; } let threadMessage: Message.message; const {peerId, mid} = this; const threadId = this.chat.threadId; if(this.chat.type === 'discussion') { threadMessage = (await this.managers.appMessagesManager.getMessageByPeer(peerId, threadId)) as Message.message; } const username = await this.managers.appPeersManager.getPeerUsername(threadMessage ? threadMessage.fromId : peerId); const msgId = getServerMessageId(mid); let url = 'https://t.me/'; if(username) { url += username; if(threadMessage) url += `/${getServerMessageId(threadMessage.fwd_from.channel_post)}?comment=${msgId}`; else if(threadId) url += `/${getServerMessageId(threadId)}/${msgId}`; else url += '/' + msgId; } else { url += 'c/' + peerId.toChatId(); if(threadMessage) url += `/${msgId}?thread=${getServerMessageId(threadMessage.mid)}`; else if(threadId) url += `/${getServerMessageId(threadId)}/${msgId}`; else url += '/' + msgId; } return {url, isPrivate: !username}; } private async getSelectedMessagesText() { if(!isSelectionEmpty()) { return ''; } const mids = this.chat.selection.isSelecting ? [...this.chat.selection.selectedMids.get(this.peerId)].sort((a, b) => a - b) : [this.mid]; const parts: string[] = await Promise.all(mids.map(async(mid) => { const message = (await this.chat.getMessage(mid)) as Message.message; return message?.message ? message.message + '\n' : ''; })); return parts.join(''); } private onSendScheduledClick = async() => { if(this.chat.selection.isSelecting) { simulateClickEvent(this.chat.selection.selectionSendNowBtn); } else { PopupElement.createPopup(PopupSendNow, this.peerId, await this.chat.getMidsByMid(this.mid)); } }; private onReplyClick = () => { this.chat.input.initMessageReply(this.mid); }; private onFaveStickerClick = (unfave?: boolean) => { const docId = ((this.message as Message.message).media as MessageMedia.messageMediaDocument).document.id; this.managers.appStickersManager.faveSticker(docId, unfave); }; private onEditClick = () => { const message = this.getMessageWithText(); this.chat.input.initMessageEditing(this.isTargetAGroupedItem ? this.mid : message.mid); }; private onCopyClick = async() => { if(isSelectionEmpty()) { copyTextToClipboard(this.selectedMessagesText); } else { document.execCommand('copy'); // cancelSelection(); } }; private onCopyAnchorLinkClick = () => { copyTextToClipboard((this.target as HTMLAnchorElement).href); }; private onCopyLinkClick = () => { const {url, isPrivate} = this.linkToMessage; const key: LangPackKey = isPrivate ? 'LinkCopiedPrivateInfo' : 'LinkCopied'; toast(I18n.format(key, true)); copyTextToClipboard(url); }; private onPinClick = () => { PopupElement.createPopup(PopupPinMessage, this.peerId, this.mid); }; private onUnpinClick = () => { PopupElement.createPopup(PopupPinMessage, this.peerId, this.mid, true); }; private onRetractVote = () => { this.managers.appPollsManager.sendVote(this.message as Message.message, []); }; private onStopPoll = () => { this.managers.appPollsManager.stopPoll(this.message as Message.message); }; private onForwardClick = async() => { if(this.chat.selection.isSelecting) { simulateClickEvent(this.chat.selection.selectionForwardBtn); } else { const peerId = this.peerId; const mids = this.isTargetAGroupedItem ? [this.mid] : await this.chat.getMidsByMid(this.mid); PopupForward.create({ [peerId]: mids }); } }; private onSelectClick = () => { this.chat.selection.toggleByElement(findUpClassName(this.target, 'grouped-item') || findUpClassName(this.target, 'bubble')); }; private onClearSelectionClick = () => { this.chat.selection.cancelSelection(); }; private onDeleteClick = async() => { if(this.chat.selection.isSelecting) { simulateClickEvent(this.chat.selection.selectionDeleteBtn); } else { PopupElement.createPopup(PopupDeleteMessages, this.peerId, this.isTargetAGroupedItem ? [this.mid] : await this.chat.getMidsByMid(this.mid), this.chat.type); } }; public static onDownloadClick(messages: MyMessage | MyMessage[], noForwards?: boolean): DownloadBlob | DownloadBlob[] { if(Array.isArray(messages)) { return messages.map((message) => { return this.onDownloadClick(message) as any; }); } if(!this.canDownload(messages, undefined, noForwards)) { return; } return appDownloadManager.downloadToDisc({media: getMediaFromMessage(messages, true)}); }; }