From c2604ec14dceb62f659348b9fbf65ea47505f9ba Mon Sep 17 00:00:00 2001 From: Eduard Kuzmenko Date: Sun, 12 Sep 2021 08:12:39 +0300 Subject: [PATCH] Search multiselect Fix keyboard inputs on iOS Alt+ArrowUp/ArrowDown shortcut --- src/components/appMediaViewer.ts | 4 +- src/components/appSearchSuper..ts | 238 ++++- src/components/chat/bubbles.ts | 23 +- src/components/chat/contextMenu.ts | 101 +- src/components/chat/input.ts | 90 +- src/components/chat/replyContainer.ts | 2 +- src/components/chat/selection.ts | 978 +++++++++++++----- src/components/chat/topbar.ts | 8 +- src/components/popups/forward.ts | 9 +- src/components/rangeSelector.ts | 2 + src/components/row.ts | 17 +- src/components/sendingStatus.ts | 86 ++ .../sidebarRight/tabs/sharedMedia.ts | 20 +- src/components/singleTransition.ts | 4 + src/components/transition.ts | 123 ++- src/components/wrappers.ts | 3 +- src/config/app.ts | 2 +- src/helpers/dom/clickEvent.ts | 12 +- .../dom/fixSafariStickyInputFocusing.ts | 2 +- src/lang.ts | 1 + src/lib/appManagers/appDialogsManager.ts | 29 +- src/lib/appManagers/appImManager.ts | 21 +- src/lib/appManagers/appMessagesManager.ts | 2 +- src/lib/rootScope.ts | 1 + src/scss/components/_global.scss | 5 + src/scss/partials/_button.scss | 2 +- src/scss/partials/_chat.scss | 5 - src/scss/partials/_chatBubble.scss | 47 +- src/scss/partials/_chatlist.scss | 29 +- src/scss/partials/_checkbox.scss | 16 +- src/scss/partials/_ckin.scss | 14 +- src/scss/partials/_document.scss | 4 + src/scss/partials/_profile.scss | 4 +- src/scss/partials/_rightSidebar.scss | 225 +++- src/scss/partials/_row.scss | 4 + src/scss/partials/_transition.scss | 34 +- src/scss/style.scss | 26 +- 37 files changed, 1590 insertions(+), 603 deletions(-) create mode 100644 src/components/sendingStatus.ts diff --git a/src/components/appMediaViewer.ts b/src/components/appMediaViewer.ts index 83718de8..8a742df3 100644 --- a/src/components/appMediaViewer.ts +++ b/src/components/appMediaViewer.ts @@ -1684,7 +1684,9 @@ export default class AppMediaViewer extends AppMediaViewerBase<'caption', 'delet onForwardClick = () => { if(this.currentMessageId) { //appSidebarRight.forwardTab.open([this.currentMessageId]); - new PopupForward(this.currentPeerId, [this.currentMessageId], () => { + new PopupForward({ + [this.currentPeerId]: [this.currentMessageId] + }, () => { return this.close(); }); } diff --git a/src/components/appSearchSuper..ts b/src/components/appSearchSuper..ts index 1bd9f87a..634a7ea5 100644 --- a/src/components/appSearchSuper..ts +++ b/src/components/appSearchSuper..ts @@ -21,7 +21,7 @@ import AppMediaViewer from "./appMediaViewer"; import { SearchGroup, SearchGroupType } from "./appSearch"; import { horizontalMenu } from "./horizontalMenu"; import LazyLoadQueue from "./lazyLoadQueue"; -import { putPreloader } from "./misc"; +import { attachContextMenuListener, openBtnMenu, positionMenu, putPreloader } from "./misc"; import { ripple } from "./ripple"; import Scrollable, { ScrollableX } from "./scrollable"; import { wrapDocument, wrapPhoto, wrapVideo } from "./wrappers"; @@ -42,6 +42,14 @@ import { isTouchSupported } from "../helpers/touchSupport"; import handleTabSwipe from "../helpers/dom/handleTabSwipe"; import windowSize from "../helpers/windowSize"; import { formatPhoneNumber } from "../helpers/formatPhoneNumber"; +import ButtonMenu, { ButtonMenuItemOptions } from "./buttonMenu"; +import PopupForward from "./popups/forward"; +import PopupDeleteMessages from "./popups/deleteMessages"; +import Row from "./row"; +import htmlToDocumentFragment from "../helpers/dom/htmlToDocumentFragment"; +import { SearchSelection } from "./chat/selection"; +import { cancelEvent } from "../helpers/dom/cancelEvent"; +import { attachClickEvent, simulateClickEvent } from "../helpers/dom/clickEvent"; //const testScroll = false; @@ -69,6 +77,149 @@ export type SearchSuperMediaTab = { scroll?: {scrollTop: number, scrollHeight: number} }; +class SearchContextMenu { + private buttons: (ButtonMenuItemOptions & {verify?: () => boolean, withSelection?: true})[]; + private element: HTMLElement; + private target: HTMLElement; + private peerId: number; + private mid: number; + private isSelected: boolean; + + constructor( + private attachTo: HTMLElement, + private searchSuper: AppSearchSuper + ) { + const onContextMenu = (e: MouseEvent) => { + if(this.init) { + this.init(); + this.init = null; + } + + let item: HTMLElement; + try { + item = findUpClassName(e.target, 'search-super-item'); + } catch(e) {} + + if(!item) return; + + if(e instanceof MouseEvent) e.preventDefault(); + if(this.element.classList.contains('active')) { + return false; + } + if(e instanceof MouseEvent) e.cancelBubble = true; + + this.target = item; + this.peerId = +item.dataset.peerId; + this.mid = +item.dataset.mid; + this.isSelected = searchSuper.selection.isMidSelected(this.peerId, this.mid); + + this.buttons.forEach(button => { + let good: boolean; + + if(this.isSelected && !button.withSelection) { + good = false; + } else { + good = button.verify ? button.verify() : true; + } + + button.element.classList.toggle('hide', !good); + }); + + item.classList.add('menu-open'); + + positionMenu(e, this.element); + openBtnMenu(this.element, () => { + item.classList.remove('menu-open'); + }); + }; + + if(isTouchSupported) { + + } else { + attachContextMenuListener(attachTo, onContextMenu as any); + } + } + + private init() { + this.buttons = [{ + icon: 'forward', + text: 'Forward', + onClick: this.onForwardClick + }, { + icon: 'forward', + text: 'Message.Context.Selection.Forward', + onClick: this.onForwardClick, + verify: () => this.isSelected && + !this.searchSuper.selection.selectionForwardBtn.classList.contains('hide'), + withSelection: true + }, { + icon: 'message', + text: 'Message.Context.Goto', + onClick: this.onGotoClick, + withSelection: true + }, { + icon: 'select', + text: 'Message.Context.Select', + onClick: this.onSelectClick + }, { + icon: 'select', + text: 'Message.Context.Selection.Clear', + onClick: this.onClearSelectionClick, + verify: () => this.isSelected, + withSelection: true + }, { + icon: 'delete danger', + text: 'Delete', + onClick: this.onDeleteClick, + verify: () => appMessagesManager.canDeleteMessage(appMessagesManager.getMessageByPeer(this.peerId, this.mid)) + }, { + icon: 'delete danger', + text: 'Message.Context.Selection.Delete', + onClick: this.onDeleteClick, + verify: () => this.isSelected && !this.searchSuper.selection.selectionDeleteBtn.classList.contains('hide'), + withSelection: true + }]; + + this.element = ButtonMenu(this.buttons); + this.element.classList.add('search-contextmenu', 'contextmenu'); + document.getElementById('page-chats').append(this.element); + } + + private onGotoClick = () => { + rootScope.dispatchEvent('history_focus', { + peerId: this.peerId, + mid: this.mid, + threadId: this.searchSuper.searchContext.threadId + }); + }; + + private onForwardClick = () => { + if(this.searchSuper.selection.isSelecting) { + simulateClickEvent(this.searchSuper.selection.selectionForwardBtn); + } else { + new PopupForward({ + [this.peerId]: [this.mid] + }); + } + }; + + private onSelectClick = () => { + this.searchSuper.selection.toggleByElement(this.target); + }; + + private onClearSelectionClick = () => { + this.searchSuper.selection.cancelSelection(); + }; + + private onDeleteClick = () => { + if(this.searchSuper.selection.isSelecting) { + simulateClickEvent(this.searchSuper.selection.selectionDeleteBtn); + } else { + new PopupDeleteMessages(this.peerId, [this.mid], 'chat'); + } + }; +} + export default class AppSearchSuper { public tabs: {[t in SearchSuperType]: HTMLDivElement} = {} as any; @@ -76,8 +227,9 @@ export default class AppSearchSuper { public container: HTMLElement; public nav: HTMLElement; - private navScrollableContainer: HTMLDivElement; - private tabsContainer: HTMLElement; + public navScrollableContainer: HTMLDivElement; + public tabsContainer: HTMLElement; + public navScrollable: ScrollableX; private tabsMenu: HTMLElement; private prevTabId = -1; @@ -88,7 +240,7 @@ export default class AppSearchSuper { public usedFromHistory: Partial<{[type in SearchSuperType]: number}> = {}; public urlsToRevoke: string[] = []; - private searchContext: SearchSuperContext; + public searchContext: SearchSuperContext; public loadMutex: Promise = Promise.resolve(); private nextRates: Partial<{[type in SearchSuperType]: number}> = {}; @@ -127,16 +279,23 @@ export default class AppSearchSuper { public onChangeTab?: (mediaTab: SearchSuperMediaTab) => void; public showSender? = false; + private searchContextMenu: SearchContextMenu; + public selection: SearchSelection; + constructor(options: Pick) { safeAssign(this, options); this.container = document.createElement('div'); this.container.classList.add('search-super'); + this.searchContextMenu = new SearchContextMenu(this.container, this); + this.selection = new SearchSelection(this, appMessagesManager); + const navScrollableContainer = this.navScrollableContainer = document.createElement('div'); navScrollableContainer.classList.add('search-super-tabs-scrollable', 'menu-horizontal-scrollable', 'sticky'); - const navScrollable = new ScrollableX(navScrollableContainer); + const navScrollable = this.navScrollable = new ScrollableX(navScrollableContainer); + navScrollable.container.classList.add('search-super-nav-scrollable'); const nav = this.nav = document.createElement('nav'); nav.classList.add('search-super-tabs', 'menu-horizontal-div'); @@ -284,6 +443,13 @@ export default class AppSearchSuper { this.onTransitionEnd(); }, undefined, navScrollable); + + attachClickEvent(this.tabsContainer, (e) => { + if(this.selection.isSelecting) { + cancelEvent(e); + this.selection.toggleByElement(findUpClassName(e.target, 'search-super-item')); + } + }, {capture: true, passive: false}); const onMediaClick = (className: string, targetClassName: string, inputFilter: MyInputMessagesFilter, e: MouseEvent) => { const target = findUpClassName(e.target as HTMLDivElement, className); @@ -314,8 +480,20 @@ export default class AppSearchSuper { .openMedia(message, targets[idx].element, 0, false, targets.slice(0, idx), targets.slice(idx + 1)); }; - this.tabs.inputMessagesFilterPhotoVideo.addEventListener('click', onMediaClick.bind(null, 'grid-item', 'grid-item', 'inputMessagesFilterPhotoVideo')); - this.tabs.inputMessagesFilterDocument.addEventListener('click', onMediaClick.bind(null, 'document-with-thumb', 'media-container', 'inputMessagesFilterDocument')); + attachClickEvent(this.tabs.inputMessagesFilterPhotoVideo, onMediaClick.bind(null, 'grid-item', 'grid-item', 'inputMessagesFilterPhotoVideo')); + attachClickEvent(this.tabs.inputMessagesFilterDocument, onMediaClick.bind(null, 'document-with-thumb', 'media-container', 'inputMessagesFilterDocument')); + + attachClickEvent(this.tabs.inputMessagesFilterUrl, (e) => { + const target = e.target as HTMLElement; + if(target.tagName === 'A') { + return; + } + + try { + const a = findUpClassName(target, 'row').querySelector('.anchor-url:last-child') as HTMLAnchorElement; + a.click(); + } catch(err) {} + }); this.mediaTab = this.mediaTabs[0]; @@ -593,7 +771,7 @@ export default class AppSearchSuper { let div = document.createElement('div'); let previewDiv = document.createElement('div'); - previewDiv.classList.add('preview'); + previewDiv.classList.add('preview', 'row-media'); //this.log('wrapping webpage', webpage); @@ -618,7 +796,19 @@ export default class AppSearchSuper { let title = webpage.rTitle || ''; let subtitle = webpage.rDescription || ''; - let url = RichTextProcessor.wrapRichText(webpage.url || ''); + + const subtitleFragment = htmlToDocumentFragment(subtitle); + const aFragment = htmlToDocumentFragment(RichTextProcessor.wrapRichText(webpage.url || '')); + const a = aFragment.firstElementChild; + if(a instanceof HTMLAnchorElement) { + a.innerText = decodeURIComponent(a.href); + } + + if(subtitleFragment.firstChild) { + subtitleFragment.append('\n'); + } + + subtitleFragment.append(a); if(!title) { //title = new URL(webpage.url).hostname; @@ -632,18 +822,32 @@ export default class AppSearchSuper { titleAdditionHTML = `
${formatDateAccordingToToday(new Date(message.date * 1000))}
`; } + const row = new Row({ + title, + titleRight: titleAdditionHTML, + subtitle: subtitleFragment, + havePadding: true, + clickable: true, + noRipple: true + }); + + /* const mediaDiv = document.createElement('div'); + mediaDiv.classList.add('row-media'); */ + + row.container.append(previewDiv); + + /* ripple(div); div.append(previewDiv); div.insertAdjacentHTML('beforeend', `
${title}${titleAdditionHTML}
${subtitle}
${url}
${sender} - `); + `); */ - if(div.innerText.trim().length) { - elemsToAppend.push({element: div, message}); + if(row.container.innerText.trim().length) { + elemsToAppend.push({element: row.container, message}); } - } break; @@ -675,6 +879,10 @@ export default class AppSearchSuper { element.dataset.mid = '' + message.mid; element.dataset.peerId = '' + message.peerId; monthContainer.items[method](element); + + if(this.selection.isSelecting) { + this.selection.toggleElementCheckbox(element, true); + } }); } @@ -1245,6 +1453,10 @@ export default class AppSearchSuper { this.usedFromHistory[mediaTab.inputFilter] = -1; }); + if(this.selection.isSelecting) { + this.selection.cancelSelection(); + } + // * must go to first tab (это костыль) /* const membersTab = this.mediaTabsMap.get('members'); if(membersTab) { diff --git a/src/components/chat/bubbles.ts b/src/components/chat/bubbles.ts index 2bcc4ed7..6e2819ff 100644 --- a/src/components/chat/bubbles.ts +++ b/src/components/chat/bubbles.ts @@ -439,6 +439,7 @@ export default class ChatBubbles { }); }); + // attachClickEvent(this.bubblesContainer, this.onBubblesClick, {listenerSetter: this.listenerSetter}); this.listenerSetter.add(this.bubblesContainer)('click', this.onBubblesClick/* , {capture: true, passive: false} */); if(DEBUG) { @@ -865,7 +866,7 @@ export default class ChatBubbles { } if(!isTouchSupported && findUpClassName(target, 'time')) { - this.chat.selection.toggleByBubble(bubble); + this.chat.selection.toggleByElement(bubble); return; } @@ -884,7 +885,7 @@ export default class ChatBubbles { } //this.chatSelection.toggleByBubble(bubble); - this.chat.selection.toggleByBubble(findUpClassName(target, 'grouped-item') || bubble); + this.chat.selection.toggleByElement(findUpClassName(target, 'grouped-item') || bubble); return; } @@ -1076,7 +1077,9 @@ export default class ChatBubbles { } else if(target.classList.contains('forward')) { const mid = +bubble.dataset.mid; const message = this.appMessagesManager.getMessageByPeer(this.peerId, mid); - new PopupForward(this.peerId, this.appMessagesManager.getMidsByMessage(message)); + new PopupForward({ + [this.peerId]: this.appMessagesManager.getMidsByMessage(message) + }); //appSidebarRight.forwardTab.open([mid]); return; } @@ -1390,7 +1393,7 @@ export default class ChatBubbles { }); if(permanent && this.chat.selection.isSelecting) { - this.chat.selection.deleteSelectedMids(mids); + this.chat.selection.deleteSelectedMids(this.peerId, mids); } animationIntersector.checkAnimations(false, CHAT_ANIMATION_GROUP); @@ -2213,6 +2216,7 @@ export default class ChatBubbles { // ! reset due to album edit or delete item this.bubbles[+message.mid] = bubble; bubble.dataset.mid = message.mid; + bubble.dataset.peerId = '' + message.peerId; bubble.dataset.timestamp = message.date; const loadPromises: Promise[] = []; @@ -2281,7 +2285,7 @@ export default class ChatBubbles { }); let canHaveTail = true; - + let isStandaloneMedia = false; let needToSetHTML = true; if(totalEntities && !messageMedia) { let emojiEntities = totalEntities.filter((e) => e._ === 'messageEntityEmoji'); @@ -2308,6 +2312,7 @@ export default class ChatBubbles { } bubble.classList.add('is-message-empty', 'emoji-big'); + isStandaloneMedia = true; canHaveTail = false; needToSetHTML = false; } @@ -2391,7 +2396,9 @@ export default class ChatBubbles { } return new Promise((resolve, reject) => { - new PopupForward(this.peerId, [], (peerId) => { + new PopupForward({ + [this.peerId]: [] + }, (peerId) => { resolve(peerId); }, () => { reject(); @@ -2468,8 +2475,6 @@ export default class ChatBubbles { const isOut = our && (!message.fwd_from || this.peerId !== rootScope.myId); let nameContainer: HTMLElement = bubbleContainer; - let isStandaloneMedia = false; - // media if(messageMedia/* && messageMedia._ === 'messageMediaPhoto' */) { let attachmentDiv = document.createElement('div'); @@ -2856,7 +2861,7 @@ export default class ChatBubbles { } if(this.chat.selection.isSelecting) { - this.chat.selection.toggleBubbleCheckbox(bubble, true); + this.chat.selection.toggleElementCheckbox(bubble, true); } let savedFrom = ''; diff --git a/src/components/chat/contextMenu.ts b/src/components/chat/contextMenu.ts index 9ead8433..cc528028 100644 --- a/src/components/chat/contextMenu.ts +++ b/src/components/chat/contextMenu.ts @@ -19,11 +19,10 @@ 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 I18n, { LangPackKey } from "../../lib/langPack"; import findUpClassName from "../../helpers/dom/findUpClassName"; import { cancelEvent } from "../../helpers/dom/cancelEvent"; -import cancelSelection from "../../helpers/dom/cancelSelection"; -import { attachClickEvent } from "../../helpers/dom/clickEvent"; +import { attachClickEvent, simulateClickEvent } from "../../helpers/dom/clickEvent"; import isSelectionEmpty from "../../helpers/dom/isSelectionEmpty"; import { Message } from "../../layer"; import PopupReportMessages from "../popups/reportMessages"; @@ -33,6 +32,7 @@ export default class ChatContextMenu { private element: HTMLElement; private isSelectable: boolean; + private isSelected: boolean; private target: HTMLElement; private isTargetAGroupedItem: boolean; private isTextSelected: boolean; @@ -75,17 +75,6 @@ export default class ChatContextMenu { let mid = +bubble.dataset.mid; if(!mid) return; - // * если открыть контекстное меню для альбома не по бабблу, и последний элемент не выбран, чтобы показать остальные пункты - if(chat.selection.isSelecting && !contentWrapper) { - const mids = this.chat.getMidsByMid(mid); - if(mids.length > 1) { - const selectedMid = chat.selection.selectedMids.has(mid) ? mid : mids.find(mid => chat.selection.selectedMids.has(mid)); - if(selectedMid) { - mid = selectedMid; - } - } - } - this.isSelectable = this.chat.selection.canSelectBubble(bubble); this.peerId = this.chat.peerId; //this.msgID = msgID; @@ -97,6 +86,19 @@ export default class ChatContextMenu { ); this.isUsernameTarget = this.target.tagName === 'A' && this.target.classList.contains('mention'); + // * если открыть контекстное меню для альбома не по бабблу, и последний элемент не выбран, чтобы показать остальные пункты + if(chat.selection.isSelecting && !contentWrapper) { + const mids = this.chat.getMidsByMid(mid); + 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; + } + } + } + const groupedItem = findUpClassName(this.target, 'grouped-item'); this.isTargetAGroupedItem = !!groupedItem; if(groupedItem) { @@ -105,6 +107,7 @@ export default class ChatContextMenu { this.mid = mid; } + this.isSelected = this.chat.selection.isMidSelected(this.peerId, this.mid); this.message = this.chat.getMessage(this.mid); this.buttons.forEach(button => { @@ -147,29 +150,10 @@ export default class ChatContextMenu { if(good) { cancelEvent(e); //onContextMenu((e as TouchEvent).changedTouches[0]); - onContextMenu((e as TouchEvent).changedTouches ? (e as TouchEvent).changedTouches[0] : e as MouseEvent); + // onContextMenu((e as TouchEvent).changedTouches ? (e as TouchEvent).changedTouches[0] : e as MouseEvent); + onContextMenu(e); } }, {listenerSetter: this.chat.bubbles.listenerSetter}); - - attachContextMenuListener(attachTo, (e) => { - if(chat.selection.isSelecting) return; - - // * these two lines will fix instant text selection on iOS Safari - document.body.classList.add('no-select'); // * need no-select on body because chat-input transforms in channels - attachTo.addEventListener('touchend', (e) => { - cancelEvent(e); // ! this one will fix propagation to document loader button, etc - document.body.classList.remove('no-select'); - - //this.chat.bubbles.onBubblesClick(e); - }, {once: true, capture: true}); - - cancelSelection(); - //cancelEvent(e as any); - const bubble = findUpClassName(e.target, 'grouped-item') || findUpClassName(e.target, 'bubble'); - if(bubble) { - chat.selection.toggleByBubble(bubble); - } - }, this.chat.bubbles.listenerSetter); } else attachContextMenuListener(attachTo, onContextMenu, this.chat.bubbles.listenerSetter); } @@ -183,7 +167,7 @@ export default class ChatContextMenu { icon: 'send2', text: 'Message.Context.Selection.SendNow', onClick: this.onSendScheduledClick, - verify: () => this.chat.type === 'scheduled' && this.chat.selection.selectedMids.has(this.mid) && !this.chat.selection.selectionSendNowBtn.hasAttribute('disabled'), + verify: () => this.chat.type === 'scheduled' && this.isSelected && !this.chat.selection.selectionSendNowBtn.hasAttribute('disabled'), notDirect: () => true, withSelection: true }, { @@ -228,7 +212,21 @@ export default class ChatContextMenu { icon: 'copy', text: 'Message.Context.Selection.Copy', onClick: this.onCopyClick, - verify: () => this.chat.selection.selectedMids.has(this.mid) && !![...this.chat.selection.selectedMids].find(mid => !!this.chat.getMessage(mid).message), + verify: () => { + if(!this.isSelected) { + return false; + } + + for(const [peerId, mids] of this.chat.selection.selectedMids) { + for(const mid of mids) { + if(!!this.appMessagesManager.getMessageByPeer(peerId, mid).message) { + return true; + } + } + } + + return false; + }, notDirect: () => true, withSelection: true }, { @@ -319,7 +317,7 @@ export default class ChatContextMenu { text: 'Message.Context.Selection.Forward', onClick: this.onForwardClick, verify: () => this.chat.selection.selectionForwardBtn && - this.chat.selection.selectedMids.has(this.mid) && + this.isSelected && !this.chat.selection.selectionForwardBtn.hasAttribute('disabled'), notDirect: () => true, withSelection: true @@ -336,14 +334,14 @@ export default class ChatContextMenu { icon: 'select', text: 'Message.Context.Select', onClick: this.onSelectClick, - verify: () => !this.message.action && !this.chat.selection.selectedMids.has(this.mid) && this.isSelectable, + verify: () => !this.message.action && !this.isSelected && this.isSelectable, notDirect: () => true, withSelection: true }, { icon: 'select', text: 'Message.Context.Selection.Clear', onClick: this.onClearSelectionClick, - verify: () => this.chat.selection.selectedMids.has(this.mid), + verify: () => this.isSelected, notDirect: () => true, withSelection: true }, { @@ -355,7 +353,7 @@ export default class ChatContextMenu { icon: 'delete danger', text: 'Message.Context.Selection.Delete', onClick: this.onDeleteClick, - verify: () => this.chat.selection.selectedMids.has(this.mid) && !this.chat.selection.selectionDeleteBtn.hasAttribute('disabled'), + verify: () => this.isSelected && !this.chat.selection.selectionDeleteBtn.hasAttribute('disabled'), notDirect: () => true, withSelection: true }]; @@ -364,11 +362,11 @@ export default class ChatContextMenu { this.element.id = 'bubble-contextmenu'; this.element.classList.add('contextmenu'); this.chat.container.append(this.element); - }; + } private onSendScheduledClick = () => { if(this.chat.selection.isSelecting) { - this.chat.selection.selectionSendNowBtn.click(); + simulateClickEvent(this.chat.selection.selectionSendNowBtn); } else { new PopupSendNow(this.peerId, this.chat.getMidsByMid(this.mid)); } @@ -384,11 +382,15 @@ export default class ChatContextMenu { private onCopyClick = () => { if(isSelectionEmpty()) { - const mids = this.chat.selection.isSelecting ? [...this.chat.selection.selectedMids].sort((a, b) => a - b) : [this.mid]; + const mids = this.chat.selection.isSelecting ? + [...this.chat.selection.selectedMids.get(this.peerId)].sort((a, b) => a - b) : + [this.mid]; + const str = mids.reduce((acc, mid) => { const message = this.chat.getMessage(mid); return acc + (message?.message ? message.message + '\n' : ''); }, '').trim(); + copyTextToClipboard(str); } else { document.execCommand('copy'); @@ -443,14 +445,17 @@ export default class ChatContextMenu { private onForwardClick = () => { if(this.chat.selection.isSelecting) { - this.chat.selection.selectionForwardBtn.click(); + simulateClickEvent(this.chat.selection.selectionForwardBtn); } else { - new PopupForward(this.peerId, this.isTargetAGroupedItem ? [this.mid] : this.chat.getMidsByMid(this.mid)); + const mids = this.isTargetAGroupedItem ? [this.mid] : this.chat.getMidsByMid(this.mid); + new PopupForward({ + [this.peerId]: mids + }); } }; private onSelectClick = () => { - this.chat.selection.toggleByBubble(findUpClassName(this.target, 'grouped-item') || findUpClassName(this.target, 'bubble')); + this.chat.selection.toggleByElement(findUpClassName(this.target, 'grouped-item') || findUpClassName(this.target, 'bubble')); }; private onClearSelectionClick = () => { @@ -459,7 +464,7 @@ export default class ChatContextMenu { private onDeleteClick = () => { if(this.chat.selection.isSelecting) { - this.chat.selection.selectionDeleteBtn.click(); + simulateClickEvent(this.chat.selection.selectionDeleteBtn); } else { new PopupDeleteMessages(this.peerId, this.isTargetAGroupedItem ? [this.mid] : this.chat.getMidsByMid(this.mid), this.chat.type); } diff --git a/src/components/chat/input.ts b/src/components/chat/input.ts index 3f171a30..764b5f2f 100644 --- a/src/components/chat/input.ts +++ b/src/components/chat/input.ts @@ -77,6 +77,7 @@ import PeerTitle from '../peerTitle'; import { fastRaf } from '../../helpers/schedulers'; import PopupDeleteMessages from '../popups/deleteMessages'; import fixSafariStickyInputFocusing, { IS_STICKY_INPUT_BUGGED } from '../../helpers/dom/fixSafariStickyInputFocusing'; +import { copy } from '../../helpers/object'; const RECORD_MIN_TIME = 500; const POSTING_MEDIA_NOT_ALLOWED = 'Posting media content isn\'t allowed in this group.'; @@ -117,8 +118,7 @@ export default class ChatInput { private getWebPagePromise: Promise; private willSendWebPage: WebPage = null; - private forwardingMids: number[] = []; - private forwardingFromPeerId: number = 0; + private forwarding: {[fromPeerId: number]: number[]}; public replyToMsgId: number; public editMsgId: number; private noWebPage: true; @@ -1402,7 +1402,7 @@ export default class ChatInput { private onBtnSendClick = (e: Event) => { cancelEvent(e); - if(!this.recorder || this.recording || !this.isInputEmpty() || this.forwardingMids.length || this.editMsgId) { + if(!this.recorder || this.recording || !this.isInputEmpty() || this.forwarding || this.editMsgId) { if(this.recording) { if((Date.now() - this.recordStartTime) < RECORD_MIN_TIME) { this.onCancelRecordClick(); @@ -1526,13 +1526,11 @@ export default class ChatInput { if(this.helperWaitingForward) return; this.helperWaitingForward = true; - const fromId = this.forwardingFromPeerId; - const mids = this.forwardingMids.slice(); const helperFunc = this.helperFunc; this.clearHelper(); this.updateSendBtn(); let selected = false; - new PopupForward(fromId, mids, () => { + new PopupForward(copy(this.forwarding), () => { selected = true; }, () => { this.helperWaitingForward = false; @@ -1593,7 +1591,7 @@ export default class ChatInput { const isInputEmpty = this.isInputEmpty(); if(this.editMsgId) icon = 'edit'; - else if(!this.recorder || this.recording || !isInputEmpty || this.forwardingMids.length) icon = this.chat.type === 'scheduled' ? 'schedule' : 'send'; + else if(!this.recorder || this.recording || !isInputEmpty || this.forwarding) icon = this.chat.type === 'scheduled' ? 'schedule' : 'send'; else icon = 'record'; ['send', 'record', 'edit', 'schedule'].forEach(i => { @@ -1673,17 +1671,18 @@ export default class ChatInput { } // * wait for sendText set messageId for invokeAfterMsg - if(this.forwardingMids.length) { - const mids = this.forwardingMids.slice(); - const fromPeerId = this.forwardingFromPeerId; + if(this.forwarding) { + const forwarding = copy(this.forwarding); const peerId = this.chat.peerId; const silent = this.sendSilent; const scheduleDate = this.scheduleDate; setTimeout(() => { - this.appMessagesManager.forwardMessages(peerId, fromPeerId, mids, { - silent, - scheduleDate: scheduleDate - }); + for(const fromPeerId in forwarding) { + this.appMessagesManager.forwardMessages(peerId, +fromPeerId, forwarding[fromPeerId], { + silent, + scheduleDate: scheduleDate + }); + } }, 0); } @@ -1751,17 +1750,26 @@ export default class ChatInput { f(); } - public initMessagesForward(fromPeerId: number, mids: number[]) { + public initMessagesForward(fromPeerIdsMids: {[fromPeerId: number]: number[]}) { const f = () => { //const peerTitles: string[] - const smth: Set = new Set(mids.map(mid => { - const message = this.appMessagesManager.getMessageByPeer(fromPeerId, mid); - if(message.fwd_from && message.fwd_from.from_name && !message.fromId && !message.fwdFromId) { - return message.fwd_from.from_name; - } else { - return message.fromId; - } - })); + const fromPeerIds = Object.keys(fromPeerIdsMids).map(str => +str); + const smth: Set = new Set(); + let length = 0; + + fromPeerIds.forEach(fromPeerId => { + const mids = fromPeerIdsMids[fromPeerId]; + mids.forEach(mid => { + const message = this.appMessagesManager.getMessageByPeer(fromPeerId, mid); + if(message.fwd_from?.from_name && !message.fromId && !message.fwdFromId) { + smth.add(message.fwd_from.from_name); + } else { + smth.add(message.fromId); + } + }); + + length += mids.length; + }); const onlyFirstName = smth.size > 2; const peerTitles = [...smth].map(smth => { @@ -1776,26 +1784,31 @@ export default class ChatInput { } else { title.append(peerTitles[0], i18n('AndOther', [peerTitles.length - 1])); } - - const firstMessage = this.appMessagesManager.getMessageByPeer(fromPeerId, mids[0]); - - let usingFullAlbum = !!firstMessage.grouped_id; - if(firstMessage.grouped_id) { - const albumMids = this.appMessagesManager.getMidsByMessage(firstMessage); - if(albumMids.length !== mids.length || albumMids.find(mid => !mids.includes(mid))) { - usingFullAlbum = false; + + let firstMessage: any, usingFullAlbum: boolean; + if(fromPeerIds.length === 1) { + const fromPeerId = fromPeerIds[0]; + const mids = fromPeerIdsMids[fromPeerId]; + firstMessage = this.appMessagesManager.getMessageByPeer(fromPeerId, mids[0]); + + usingFullAlbum = !!firstMessage.grouped_id; + if(usingFullAlbum) { + const albumMids = this.appMessagesManager.getMidsByMessage(firstMessage); + if(albumMids.length !== length || albumMids.find(mid => !mids.includes(mid))) { + usingFullAlbum = false; + } } } - - const replyFragment = this.appMessagesManager.wrapMessageForReply(firstMessage, undefined, mids); - if(usingFullAlbum || mids.length === 1) { + + if(usingFullAlbum || length === 1) { + const mids = fromPeerIdsMids[fromPeerIds[0]]; + const replyFragment = this.appMessagesManager.wrapMessageForReply(firstMessage, undefined, mids); this.setTopInfo('forward', f, title, replyFragment); } else { - this.setTopInfo('forward', f, title, i18n('ForwardedMessageCount', [mids.length])); + this.setTopInfo('forward', f, title, i18n('ForwardedMessageCount', [length])); } - this.forwardingMids = mids.slice(); - this.forwardingFromPeerId = fromPeerId; + this.forwarding = fromPeerIdsMids; }; f(); @@ -1845,8 +1858,7 @@ export default class ChatInput { } this.replyToMsgId = undefined; - this.forwardingMids.length = 0; - this.forwardingFromPeerId = 0; + this.forwarding = undefined; this.editMsgId = undefined; this.helperType = this.helperFunc = undefined; diff --git a/src/components/chat/replyContainer.ts b/src/components/chat/replyContainer.ts index fc9c2676..1fe835d7 100644 --- a/src/components/chat/replyContainer.ts +++ b/src/components/chat/replyContainer.ts @@ -52,7 +52,7 @@ export function wrapReplyDivAndCaption(options: { media = media.webpage; } - if(media.photo || (media.document && ['video', 'sticker', 'gif', 'round'].indexOf(media.document.type) !== -1)) { + if(media.photo || (media.document && ['video', 'sticker', 'gif', 'round', 'photo'].indexOf(media.document.type) !== -1)) { middleware = appImManager.chat.bubbles.getMiddleware(); const lazyLoadQueue = appImManager.chat.bubbles.lazyLoadQueue; diff --git a/src/components/chat/selection.ts b/src/components/chat/selection.ts index b633c2b6..19ff5e70 100644 --- a/src/components/chat/selection.ts +++ b/src/components/chat/selection.ts @@ -18,7 +18,7 @@ import { toast } from "../toast"; import SetTransition from "../singleTransition"; import ListenerSetter from "../../helpers/listenerSetter"; import PopupSendNow from "../popups/sendNow"; -import appNavigationController from "../appNavigationController"; +import appNavigationController, { NavigationItem } from "../appNavigationController"; import { isMobileSafari } from "../../helpers/userAgent"; import I18n, { i18n, _i18n } from "../../lib/langPack"; import findUpClassName from "../../helpers/dom/findUpClassName"; @@ -27,53 +27,130 @@ import { cancelEvent } from "../../helpers/dom/cancelEvent"; import cancelSelection from "../../helpers/dom/cancelSelection"; import getSelectedText from "../../helpers/dom/getSelectedText"; import rootScope from "../../lib/rootScope"; +import { safeAssign } from "../../helpers/object"; +import { fastRaf } from "../../helpers/schedulers"; +import replaceContent from "../../helpers/dom/replaceContent"; +import AppSearchSuper from "../appSearchSuper."; +import isInDOM from "../../helpers/dom/isInDOM"; +import { randomLong } from "../../helpers/random"; +import { attachContextMenuListener } from "../misc"; +import { attachClickEvent, AttachClickOptions } from "../../helpers/dom/clickEvent"; + +const accumulateMapSet = (map: Map>) => { + return [...map.values()].reduce((acc, v) => acc + v.size, 0); +}; //const MIN_CLICK_MOVE = 32; // minimum bubble height -export default class ChatSelection { - public selectedMids: Set = new Set(); +class AppSelection { + public selectedMids: Map> = new Map(); public isSelecting = false; - private selectionInputWrapper: HTMLElement; - private selectionContainer: HTMLElement; - private selectionCountEl: HTMLElement; - public selectionSendNowBtn: HTMLElement; - public selectionForwardBtn: HTMLElement; - public selectionDeleteBtn: HTMLElement; - public selectedText: string; - private listenerSetter: ListenerSetter; - - constructor(private chat: Chat, private bubbles: ChatBubbles, private input: ChatInput, private appMessagesManager: AppMessagesManager) { - const bubblesContainer = bubbles.bubblesContainer; - this.listenerSetter = bubbles.listenerSetter; + protected listenerSetter: ListenerSetter; + protected appMessagesManager: AppMessagesManager; + protected listenElement: HTMLElement; + + protected onToggleSelection: (forwards: boolean) => void; + protected onUpdateContainer: (cantForward: boolean, cantDelete: boolean, cantSend: boolean) => void; + protected onCancelSelection: () => void; + protected toggleByMid: (peerId: number, mid: number) => void; + protected toggleByElement: (bubble: HTMLElement) => void; + + protected navigationType: NavigationItem['type']; + + protected getElementFromTarget: (target: HTMLElement) => HTMLElement; + protected verifyTarget: (e: MouseEvent, target: HTMLElement) => boolean; + protected verifyMouseMoveTarget: (e: MouseEvent, element: HTMLElement, selecting: boolean) => boolean; + protected targetLookupClassName: string; + protected lookupBetweenParentClassName: string; + protected lookupBetweenElementsQuery: string; + + constructor(options: { + appMessagesManager: AppMessagesManager, + listenElement: HTMLElement, + listenerSetter: ListenerSetter, + getElementFromTarget: AppSelection['getElementFromTarget'], + verifyTarget?: AppSelection['verifyTarget'], + verifyMouseMoveTarget?: AppSelection['verifyMouseMoveTarget'], + targetLookupClassName: string, + lookupBetweenParentClassName: string, + lookupBetweenElementsQuery: string + }) { + safeAssign(this, options); + + this.navigationType = 'multiselect-' + randomLong() as any; if(isTouchSupported) { - this.listenerSetter.add(bubblesContainer)('touchend', (e) => { + this.listenerSetter.add(this.listenElement)('touchend', () => { if(!this.isSelecting) return; this.selectedText = getSelectedText(); }); + + attachContextMenuListener(this.listenElement, (e) => { + if(this.isSelecting) return; + + // * these two lines will fix instant text selection on iOS Safari + document.body.classList.add('no-select'); // * need no-select on body because chat-input transforms in channels + this.listenElement.addEventListener('touchend', (e) => { + cancelEvent(e); // ! this one will fix propagation to document loader button, etc + document.body.classList.remove('no-select'); + + //this.chat.bubbles.onBubblesClick(e); + }, {once: true, capture: true}); + + cancelSelection(); + //cancelEvent(e as any); + const element = this.getElementFromTarget(e.target as HTMLElement); + if(element) { + this.toggleByElement(element); + } + }, this.listenerSetter); + return; } - this.listenerSetter.add(bubblesContainer)('mousedown', (e) => { + const getElementsBetween = (first: HTMLElement, last: HTMLElement) => { + if(first === last) { + return []; + } + + const firstRect = first.getBoundingClientRect(); + const lastRect = last.getBoundingClientRect(); + const difference = (firstRect.top - lastRect.top) || (firstRect.left - lastRect.left); + const isHigher = difference < 0; + + const parent = findUpClassName(first, this.lookupBetweenParentClassName); + if(!parent) { + return []; + } + + const elements = Array.from(parent.querySelectorAll(this.lookupBetweenElementsQuery)) as HTMLElement[]; + let firstIndex = elements.indexOf(first); + let lastIndex = elements.indexOf(last); + + if(!isHigher) { + [lastIndex, firstIndex] = [firstIndex, lastIndex]; + } + + const slice = elements.slice(firstIndex + 1, lastIndex); + + return slice; + }; + + this.listenerSetter.add(this.listenElement)('mousedown', (e) => { //console.log('selection mousedown', e); - const bubble = findUpClassName(e.target, 'bubble'); - // LEFT BUTTON - // проверка внизу нужна для того, чтобы не активировать селект если target потомок .bubble - if(e.button !== 0 - || ( - !this.selectedMids.size - && !(e.target as HTMLElement).classList.contains('bubble') - && !(e.target as HTMLElement).classList.contains('document-selection') - && bubble - ) - ) { + const element = findUpClassName(e.target, this.targetLookupClassName); + if(e.button !== 0) { + return; + } + + if(this.verifyTarget && !this.verifyTarget(e, element)) { return; } - const seen: Set = new Set(); + const seen: Map> = new Map(); let selecting: boolean; /* let good = false; @@ -87,6 +164,56 @@ export default class ChatSelection { }, {once: true}); } */ + let firstTarget = element; + + const processElement = (element: HTMLElement, checkBetween = true) => { + const mid = +element.dataset.mid; + const peerId = +element.dataset.peerId; + if(!mid || !peerId) return; + + if(!isInDOM(firstTarget)) { + firstTarget = element; + } + + let seenSet = seen.get(peerId); + if(!seenSet) { + seen.set(peerId, seenSet = new Set()); + } + + if(!seenSet.has(mid)) { + const isSelected = this.isMidSelected(peerId, mid); + if(selecting === undefined) { + //bubblesContainer.classList.add('no-select'); + selecting = !isSelected; + } + + seenSet.add(mid); + + if((selecting && !isSelected) || (!selecting && isSelected)) { + if(this.toggleByElement && checkBetween) { + const elementsBetween = getElementsBetween(firstTarget, element); + if(elementsBetween.length) { + elementsBetween.forEach(element => { + processElement(element, false); + }); + } + } + + if(!this.selectedMids.size) { + if(accumulateMapSet(seen) === 2 && this.toggleByMid) { + for(const [peerId, mids] of seen) { + for(const mid of mids) { + this.toggleByMid(peerId, mid); + } + } + } + } else if(this.toggleByElement) { + this.toggleByElement(element); + } + } + } + }; + //const foundTargets: Map = new Map(); let canceledSelection = false; const onMouseMove = (e: MouseEvent) => { @@ -104,57 +231,27 @@ export default class ChatSelection { /* if(foundTargets.has(e.target as HTMLElement)) return; foundTargets.set(e.target as HTMLElement, true); */ - const bubble = findUpClassName(e.target, 'grouped-item') || findUpClassName(e.target, 'bubble'); - if(!bubble) { + const element = this.getElementFromTarget(e.target as HTMLElement); + if(!element) { //console.error('found no bubble', e); return; } - const mid = +bubble.dataset.mid; - if(!mid) return; - - // * cancel selecting if selecting message text - if(e.target !== bubble && !(e.target as HTMLElement).classList.contains('document-selection') && selecting === undefined && !this.selectedMids.size) { - this.listenerSetter.removeManual(bubblesContainer, 'mousemove', onMouseMove); + if(this.verifyMouseMoveTarget && !this.verifyMouseMoveTarget(e, element, selecting)) { + this.listenerSetter.removeManual(this.listenElement, 'mousemove', onMouseMove); this.listenerSetter.removeManual(document, 'mouseup', onMouseUp, documentListenerOptions); return; } - if(!seen.has(mid)) { - const isBubbleSelected = this.selectedMids.has(mid); - if(selecting === undefined) { - //bubblesContainer.classList.add('no-select'); - selecting = !isBubbleSelected; - } - - seen.add(mid); - - if((selecting && !isBubbleSelected) || (!selecting && isBubbleSelected)) { - if(!this.selectedMids.size) { - if(seen.size === 2) { - [...seen].forEach(mid => { - const mounted = this.bubbles.getMountedBubble(mid); - if(mounted) { - this.toggleByBubble(mounted.bubble); - } - }) - } - } else { - this.toggleByBubble(bubble); - } - } - } - //console.log('onMouseMove', target); + processElement(element); }; const onMouseUp = (e: MouseEvent) => { if(seen.size) { - window.addEventListener('click', (e) => { - cancelEvent(e); - }, {capture: true, once: true, passive: false}); + attachClickEvent(window, cancelEvent, {capture: true, once: true, passive: false}); } - this.listenerSetter.removeManual(bubblesContainer, 'mousemove', onMouseMove); + this.listenerSetter.removeManual(this.listenElement, 'mousemove', onMouseMove); //bubblesContainer.classList.remove('no-select'); // ! CANCEL USER SELECTION ! @@ -162,107 +259,93 @@ export default class ChatSelection { }; const documentListenerOptions = {once: true}; - this.listenerSetter.add(bubblesContainer)('mousemove', onMouseMove); + this.listenerSetter.add(this.listenElement)('mousemove', onMouseMove); this.listenerSetter.add(document)('mouseup', onMouseUp, documentListenerOptions); }); } - public toggleBubbleCheckbox(bubble: HTMLElement, show: boolean) { - if(!this.canSelectBubble(bubble)) return; + protected isElementShouldBeSelected(element: HTMLElement) { + return this.isMidSelected(+element.dataset.peerId, +element.dataset.mid); + } - const hasCheckbox = !!this.getCheckboxInputFromBubble(bubble); - const isGrouped = bubble.classList.contains('is-grouped'); + protected appendCheckbox(element: HTMLElement, checkboxField: CheckboxField) { + element.prepend(checkboxField.label); + } + + public toggleElementCheckbox(element: HTMLElement, show: boolean) { + const hasCheckbox = !!this.getCheckboxInputFromElement(element); if(show) { - if(hasCheckbox) return; + if(hasCheckbox) { + return false; + } const checkboxField = new CheckboxField({ - name: bubble.dataset.mid, + name: element.dataset.mid, round: true }); - checkboxField.label.classList.add('bubble-select-checkbox'); - + // * if it is a render of new message if(this.isSelecting) { // ! avoid breaking animation on start - const mid = +bubble.dataset.mid; - if(this.selectedMids.has(mid) && (!isGrouped || this.isGroupedMidsSelected(mid))) { + if(this.isElementShouldBeSelected(element)) { checkboxField.input.checked = true; - bubble.classList.add('is-selected'); + element.classList.add('is-selected'); } } - - if(bubble.classList.contains('document-container')) { - bubble.querySelector('.document, audio-element').append(checkboxField.label); - } else { - bubble.prepend(checkboxField.label); - } + + this.appendCheckbox(element, checkboxField); } else if(hasCheckbox) { - this.getCheckboxInputFromBubble(bubble).parentElement.remove(); + this.getCheckboxInputFromElement(element).parentElement.remove(); } - if(isGrouped) { - this.bubbles.getBubbleGroupedItems(bubble).forEach(item => this.toggleBubbleCheckbox(item, show)); - } + return true; } - private getCheckboxInputFromBubble(bubble: HTMLElement): HTMLInputElement { - /* let perf = performance.now(); - let checkbox = bubble.firstElementChild.tagName === 'LABEL' && bubble.firstElementChild.firstElementChild as HTMLInputElement; - console.log('getCheckboxInputFromBubble firstElementChild time:', performance.now() - perf); - - perf = performance.now(); - checkbox = bubble.querySelector('label input'); - console.log('getCheckboxInputFromBubble querySelector time:', performance.now() - perf); */ - /* let perf = performance.now(); - let contains = bubble.classList.contains('document-container'); - console.log('getCheckboxInputFromBubble classList time:', performance.now() - perf); - - perf = performance.now(); - contains = bubble.className.includes('document-container'); - console.log('getCheckboxInputFromBubble className time:', performance.now() - perf); */ - - return bubble.classList.contains('document-container') ? - bubble.querySelector('label input') : - bubble.firstElementChild.tagName === 'LABEL' && bubble.firstElementChild.firstElementChild as HTMLInputElement; + protected getCheckboxInputFromElement(element: HTMLElement): HTMLInputElement { + return element.firstElementChild?.tagName === 'LABEL' && + element.firstElementChild.firstElementChild as HTMLInputElement; } - private updateContainer(forceSelection = false) { - if(!this.selectedMids.size && !forceSelection) return; - this.selectionCountEl.textContent = ''; - this.selectionCountEl.append(i18n('messages', [this.selectedMids.size])); - - let cantForward = !this.selectedMids.size, cantDelete = !this.selectedMids.size, cantSend = !this.selectedMids.size; - for(const mid of this.selectedMids.values()) { - const message = this.appMessagesManager.getMessageByPeer(this.bubbles.peerId, mid); - if(!cantForward) { - if(message.action) { - cantForward = true; + protected updateContainer(forceSelection = false) { + const size = this.selectedMids.size; + if(!size && !forceSelection) return; + + let cantForward = !size, + cantDelete = !size, + cantSend = !size; + for(const [peerId, mids] of this.selectedMids) { + for(const mid of mids) { + const message = this.appMessagesManager.getMessageByPeer(peerId, mid); + if(!cantForward) { + if(message.action) { + cantForward = true; + } } - } - - - if(!cantDelete) { - const canDelete = this.appMessagesManager.canDeleteMessage(this.chat.getMessage(mid)); - if(!canDelete) { - cantDelete = true; + + if(!cantDelete) { + const canDelete = this.appMessagesManager.canDeleteMessage(message); + if(!canDelete) { + cantDelete = true; + } } + + if(cantForward && cantDelete) break; } if(cantForward && cantDelete) break; } - - this.selectionSendNowBtn && this.selectionSendNowBtn.toggleAttribute('disabled', cantSend); - this.selectionForwardBtn && this.selectionForwardBtn.toggleAttribute('disabled', cantForward); - this.selectionDeleteBtn.toggleAttribute('disabled', cantDelete); + + this.onUpdateContainer && this.onUpdateContainer(cantForward, cantDelete, cantSend); } public toggleSelection(toggleCheckboxes = true, forceSelection = false) { const wasSelecting = this.isSelecting; - this.isSelecting = this.selectedMids.size > 0 || forceSelection; + const size = this.selectedMids.size; + this.isSelecting = !!size || forceSelection; - if(wasSelecting === this.isSelecting) return; + if(wasSelecting === this.isSelecting) return false; - const bubblesContainer = this.bubbles.bubblesContainer; - //bubblesContainer.classList.toggle('is-selecting', !!this.selectedMids.size); + // const bubblesContainer = this.bubbles.bubblesContainer; + //bubblesContainer.classList.toggle('is-selecting', !!size); /* if(bubblesContainer.classList.contains('is-chat-input-hidden')) { const scrollable = this.appImManager.scrollable; @@ -272,7 +355,7 @@ export default class ChatSelection { } */ if(!isTouchSupported) { - bubblesContainer.classList.toggle('no-select', this.isSelecting); + this.listenElement.classList.toggle('no-select', this.isSelecting); if(wasSelecting) { // ! CANCEL USER SELECTION ! @@ -291,8 +374,432 @@ export default class ChatSelection { blurActiveElement(); + const forwards = !!size || forceSelection; + this.onToggleSelection && this.onToggleSelection(forwards); + + if(!isMobileSafari) { + if(forwards) { + appNavigationController.pushItem({ + type: this.navigationType, + onPop: () => { + this.cancelSelection(); + } + }); + } else { + appNavigationController.removeByType(this.navigationType); + } + } + + if(forceSelection) { + this.updateContainer(forceSelection); + } + + return true; + } + + public cancelSelection = () => { + this.onCancelSelection && this.onCancelSelection(); + this.selectedMids.clear(); + this.toggleSelection(); + cancelSelection(); + }; + + public cleanup() { + this.selectedMids.clear(); + this.toggleSelection(false); + } + + protected updateElementSelection(element: HTMLElement, isSelected: boolean) { + this.toggleElementCheckbox(element, true); + const input = this.getCheckboxInputFromElement(element); + input.checked = isSelected; + + this.toggleSelection(); + this.updateContainer(); + SetTransition(element, 'is-selected', isSelected, 200); + } + + public isMidSelected(peerId: number, mid: number) { + const set = this.selectedMids.get(peerId); + return set?.has(mid); + } + + public length() { + return accumulateMapSet(this.selectedMids); + } + + protected toggleMid(peerId: number, mid: number, unselect?: boolean) { + let set = this.selectedMids.get(peerId); + if(unselect || (unselect === undefined && set?.has(mid))) { + if(set) { + set.delete(mid); + + if(!set.size) { + this.selectedMids.delete(peerId); + } + } + } else { + const diff = rootScope.config.forwarded_count_max - this.length() - 1; + if(diff < 0) { + toast(I18n.format('Chat.Selection.LimitToast', true)); + return false; + /* const it = this.selectedMids.values(); + do { + const mid = it.next().value; + const mounted = this.appImManager.getMountedBubble(mid); + if(mounted) { + this.toggleByBubble(mounted.bubble); + } else { + const mids = this.appMessagesManager.getMidsByMid(mid); + for(const mid of mids) { + this.selectedMids.delete(mid); + } + } + } while(this.selectedMids.size > MAX_SELECTION_LENGTH); */ + } + + if(!set) { + set = new Set(); + this.selectedMids.set(peerId, set); + } + + set.add(mid); + } + + return true; + } + + /** + * ! Call this method only to handle deleted messages + */ + public deleteSelectedMids(peerId: number, mids: number[]) { + const set = this.selectedMids.get(peerId); + if(!set) { + return; + } + + mids.forEach(mid => { + set.delete(mid); + }); + + if(!set.size) { + this.selectedMids.delete(peerId); + } + + this.updateContainer(); + this.toggleSelection(); + } +} + +export class SearchSelection extends AppSelection { + protected selectionContainer: HTMLElement; + protected selectionCountEl: HTMLElement; + public selectionForwardBtn: HTMLElement; + public selectionDeleteBtn: HTMLElement; + public selectionGotoBtn: HTMLElement; + + private isPrivate: boolean; + + constructor(private searchSuper: AppSearchSuper, appMessagesManager: AppMessagesManager) { + super({ + appMessagesManager, + listenElement: searchSuper.container, + listenerSetter: new ListenerSetter(), + verifyTarget: (e, target) => !!target, + getElementFromTarget: (target) => findUpClassName(target, 'search-super-item'), + targetLookupClassName: 'search-super-item', + lookupBetweenParentClassName: 'tabs-tab', + lookupBetweenElementsQuery: '.search-super-item' + }); + + this.isPrivate = !searchSuper.showSender; + } + + /* public appendCheckbox(element: HTMLElement, checkboxField: CheckboxField) { + checkboxField.label.classList.add('bubble-select-checkbox'); + + if(element.classList.contains('document') || element.tagName === 'AUDIO-ELEMENT') { + element.querySelector('.document, audio-element').append(checkboxField.label); + } else { + super.appendCheckbox(bubble, checkboxField); + } + } */ + + public toggleSelection(toggleCheckboxes = true, forceSelection = false) { + const ret = super.toggleSelection(toggleCheckboxes, forceSelection); + + if(ret && toggleCheckboxes) { + const elements = Array.from(this.searchSuper.tabsContainer.querySelectorAll('.search-super-item')) as HTMLElement[]; + elements.forEach(element => { + this.toggleElementCheckbox(element, this.isSelecting); + }); + } + + return ret; + } + + public toggleByElement = (element: HTMLElement) => { + const mid = +element.dataset.mid; + const peerId = +element.dataset.peerId; + + if(!this.toggleMid(peerId, mid)) { + return; + } + + this.updateElementSelection(element, this.isMidSelected(peerId, mid)); + }; + + protected onUpdateContainer = (cantForward: boolean, cantDelete: boolean, cantSend: boolean) => { + const length = this.length(); + replaceContent(this.selectionCountEl, i18n('messages', [length])); + this.selectionGotoBtn.classList.toggle('hide', length !== 1); + this.selectionForwardBtn.classList.toggle('hide', cantForward); + this.selectionDeleteBtn && this.selectionDeleteBtn.classList.toggle('hide', cantDelete); + }; + + protected onToggleSelection = (forwards: boolean) => { + SetTransition(this.searchSuper.navScrollableContainer, 'is-selecting', forwards, 200, () => { + if(!this.isSelecting) { + this.selectionContainer.remove(); + this.selectionContainer = + this.selectionForwardBtn = + this.selectionDeleteBtn = + null; + this.selectedText = undefined; + } + }); + + SetTransition(this.searchSuper.container, 'is-selecting', forwards, 200); + + if(this.isSelecting) { + if(!this.selectionContainer) { + const BASE_CLASS = 'search-super-selection'; + this.selectionContainer = document.createElement('div'); + this.selectionContainer.classList.add(BASE_CLASS + '-container'); + + const btnCancel = ButtonIcon(`close ${BASE_CLASS}-cancel`, {noRipple: true}); + this.listenerSetter.add(btnCancel)('click', this.cancelSelection, {once: true}); + + this.selectionCountEl = document.createElement('div'); + this.selectionCountEl.classList.add(BASE_CLASS + '-count'); + + this.selectionGotoBtn = ButtonIcon(`message ${BASE_CLASS}-goto`); + + const attachClickOptions: AttachClickOptions = {listenerSetter: this.listenerSetter}; + attachClickEvent(this.selectionGotoBtn, () => { + const peerId = [...this.selectedMids.keys()][0]; + const mid = [...this.selectedMids.get(peerId)][0]; + this.cancelSelection(); + + rootScope.dispatchEvent('history_focus', { + peerId, + mid + }); + }, attachClickOptions); + + this.selectionForwardBtn = ButtonIcon(`forward ${BASE_CLASS}-forward`); + attachClickEvent(this.selectionForwardBtn, () => { + const obj: {[fromPeerId: number]: number[]} = {}; + for(const [fromPeerId, mids] of this.selectedMids) { + obj[fromPeerId] = Array.from(mids); + } + + new PopupForward(obj, () => { + this.cancelSelection(); + }); + }, attachClickOptions); + + if(this.isPrivate) { + this.selectionDeleteBtn = ButtonIcon(`delete danger ${BASE_CLASS}-delete`); + attachClickEvent(this.selectionDeleteBtn, () => { + const peerId = [...this.selectedMids.keys()][0]; + new PopupDeleteMessages(peerId, [...this.selectedMids.get(peerId)], 'chat', () => { + this.cancelSelection(); + }); + }, attachClickOptions); + } + + this.selectionContainer.append(...[ + btnCancel, + this.selectionCountEl, + this.selectionGotoBtn, + this.selectionForwardBtn, + this.selectionDeleteBtn + ].filter(Boolean)); + + const transitionElement = this.selectionContainer; + transitionElement.style.opacity = '0'; + this.searchSuper.navScrollableContainer.append(transitionElement); + + void transitionElement.offsetLeft; // reflow + transitionElement.style.opacity = ''; + } + } + }; +} + +export default class ChatSelection extends AppSelection { + protected selectionInputWrapper: HTMLElement; + protected selectionContainer: HTMLElement; + protected selectionCountEl: HTMLElement; + public selectionSendNowBtn: HTMLElement; + public selectionForwardBtn: HTMLElement; + public selectionDeleteBtn: HTMLElement; + + constructor(private chat: Chat, private bubbles: ChatBubbles, private input: ChatInput, appMessagesManager: AppMessagesManager) { + super({ + appMessagesManager, + listenElement: bubbles.bubblesContainer, + listenerSetter: bubbles.listenerSetter, + getElementFromTarget: (target) => findUpClassName(target, 'grouped-item') || findUpClassName(target, 'bubble'), + verifyTarget: (e, target) => { + // LEFT BUTTON + // проверка внизу нужна для того, чтобы не активировать селект если target потомок .bubble + const bad = !this.selectedMids.size + && !(e.target as HTMLElement).classList.contains('bubble') + && !(e.target as HTMLElement).classList.contains('document-selection') + && target; + + return !bad; + }, + verifyMouseMoveTarget: (e, element, selecting) => { + const bad = e.target !== element && + !(e.target as HTMLElement).classList.contains('document-selection') && + selecting === undefined && + !this.selectedMids.size; + return !bad; + }, + targetLookupClassName: 'bubble', + lookupBetweenParentClassName: 'bubbles-inner', + lookupBetweenElementsQuery: '.bubble:not(.is-multiple-documents), .grouped-item' + }); + } + + public appendCheckbox(bubble: HTMLElement, checkboxField: CheckboxField) { + checkboxField.label.classList.add('bubble-select-checkbox'); + + if(bubble.classList.contains('document-container')) { + bubble.querySelector('.document, audio-element').append(checkboxField.label); + } else { + super.appendCheckbox(bubble, checkboxField); + } + } + + public toggleSelection(toggleCheckboxes = true, forceSelection = false) { + const ret = super.toggleSelection(toggleCheckboxes, forceSelection); + + if(ret && toggleCheckboxes) { + for(const mid in this.bubbles.bubbles) { + const bubble = this.bubbles.bubbles[mid]; + this.toggleElementCheckbox(bubble, this.isSelecting); + } + } + + return ret; + } + + public toggleElementCheckbox(bubble: HTMLElement, show: boolean) { + if(!this.canSelectBubble(bubble)) return; + + const ret = super.toggleElementCheckbox(bubble, show); + if(ret) { + const isGrouped = bubble.classList.contains('is-grouped'); + if(isGrouped) { + this.bubbles.getBubbleGroupedItems(bubble).forEach(item => this.toggleElementCheckbox(item, show)); + } + } + + return ret; + } + + public toggleByElement = (bubble: HTMLElement) => { + if(!this.canSelectBubble(bubble)) return; + + const mid = +bubble.dataset.mid; + + const isGrouped = bubble.classList.contains('is-grouped'); + if(isGrouped) { + if(!this.isGroupedBubbleSelected(bubble)) { + const set = this.selectedMids.get(this.bubbles.peerId); + if(set) { + const mids = this.chat.getMidsByMid(mid); + mids.forEach(mid => set.delete(mid)); + } + } + + this.bubbles.getBubbleGroupedItems(bubble).forEach(this.toggleByElement); + return; + } + + if(!this.toggleMid(this.bubbles.peerId, mid)) { + return; + } + + const isGroupedItem = bubble.classList.contains('grouped-item'); + if(isGroupedItem) { + const groupContainer = findUpClassName(bubble, 'bubble'); + const isGroupedSelected = this.isGroupedBubbleSelected(groupContainer); + const isGroupedMidsSelected = this.isGroupedMidsSelected(mid); + + const willChange = isGroupedMidsSelected || isGroupedSelected; + if(willChange) { + this.updateElementSelection(groupContainer, isGroupedMidsSelected); + } + } + + this.updateElementSelection(bubble, this.isMidSelected(this.bubbles.peerId, mid)); + }; + + protected toggleByMid = (peerId: number, mid: number) => { + const mounted = this.bubbles.getMountedBubble(mid); + if(mounted) { + this.toggleByElement(mounted.bubble); + } + }; + + public isElementShouldBeSelected(element: HTMLElement) { + const isGrouped = element.classList.contains('is-grouped'); + return super.isElementShouldBeSelected(element) && (!isGrouped || this.isGroupedMidsSelected(+element.dataset.mid)); + } + + protected isGroupedBubbleSelected(bubble: HTMLElement) { + const groupedCheckboxInput = this.getCheckboxInputFromElement(bubble); + return groupedCheckboxInput?.checked; + } + + protected isGroupedMidsSelected(mid: number) { + const mids = this.chat.getMidsByMid(mid); + const selectedMids = mids.filter(mid => this.isMidSelected(this.bubbles.peerId, mid)); + return mids.length === selectedMids.length; + } + + protected getCheckboxInputFromElement(bubble: HTMLElement) { + /* let perf = performance.now(); + let checkbox = bubble.firstElementChild.tagName === 'LABEL' && bubble.firstElementChild.firstElementChild as HTMLInputElement; + console.log('getCheckboxInputFromBubble firstElementChild time:', performance.now() - perf); + + perf = performance.now(); + checkbox = bubble.querySelector('label input'); + console.log('getCheckboxInputFromBubble querySelector time:', performance.now() - perf); */ + /* let perf = performance.now(); + let contains = bubble.classList.contains('document-container'); + console.log('getCheckboxInputFromBubble classList time:', performance.now() - perf); + + perf = performance.now(); + contains = bubble.className.includes('document-container'); + console.log('getCheckboxInputFromBubble className time:', performance.now() - perf); */ + + return bubble.classList.contains('document-container') ? + bubble.querySelector('label input') as HTMLInputElement : + super.getCheckboxInputFromElement(bubble); + } + + public canSelectBubble(bubble: HTMLElement) { + return !bubble.classList.contains('service') && !bubble.classList.contains('is-sending') && !bubble.classList.contains('bubble-first'); + } + + protected onToggleSelection = (forwards: boolean) => { let transform = '', borderRadius = ''; - const forwards = !!this.selectedMids.size || forceSelection; if(forwards) { const p = this.input.rowsWrapper.parentElement; const fakeSelectionWrapper = p.querySelector('.fake-selection-wrapper'); @@ -319,31 +826,23 @@ export default class ChatSelection { SetTransition(this.input.rowsWrapper, 'is-centering', forwards, 200); this.input.rowsWrapper.style.transform = transform; this.input.rowsWrapper.style.borderRadius = borderRadius; - SetTransition(bubblesContainer, 'is-selecting', forwards, 200, () => { + SetTransition(this.listenElement, 'is-selecting', forwards, 200, () => { if(!this.isSelecting) { this.selectionInputWrapper.remove(); - this.selectionInputWrapper = this.selectionContainer = this.selectionSendNowBtn = this.selectionForwardBtn = this.selectionDeleteBtn = null; + this.selectionInputWrapper = + this.selectionContainer = + this.selectionSendNowBtn = + this.selectionForwardBtn = + this.selectionDeleteBtn = + null; this.selectedText = undefined; } - window.requestAnimationFrame(() => { + fastRaf(() => { this.bubbles.onScroll(); }); }); - if(!isMobileSafari) { - if(forwards) { - appNavigationController.pushItem({ - type: 'multiselect', - onPop: () => { - this.cancelSelection(); - } - }); - } else { - appNavigationController.removeByType('multiselect'); - } - } - //const chatInput = this.appImManager.chatInput; if(this.isSelecting) { @@ -354,8 +853,9 @@ export default class ChatSelection { this.selectionContainer = document.createElement('div'); this.selectionContainer.classList.add('selection-container'); + const attachClickOptions: AttachClickOptions = {listenerSetter: this.listenerSetter}; const btnCancel = ButtonIcon('close', {noRipple: true}); - this.listenerSetter.add(btnCancel)('click', this.cancelSelection, {once: true}); + attachClickEvent(btnCancel, this.cancelSelection, {once: true, listenerSetter: this.listenerSetter}); this.selectionCountEl = document.createElement('div'); this.selectionCountEl.classList.add('selection-container-count'); @@ -363,30 +863,41 @@ export default class ChatSelection { if(this.chat.type === 'scheduled') { this.selectionSendNowBtn = Button('btn-primary btn-transparent btn-short text-bold selection-container-send', {icon: 'send2'}); this.selectionSendNowBtn.append(i18n('MessageScheduleSend')); - this.listenerSetter.add(this.selectionSendNowBtn)('click', () => { - new PopupSendNow(this.bubbles.peerId, [...this.selectedMids], () => { + attachClickEvent(this.selectionSendNowBtn, () => { + new PopupSendNow(this.bubbles.peerId, [...this.selectedMids.get(this.bubbles.peerId)], () => { this.cancelSelection(); - }) - }); + }); + }, attachClickOptions); } else { this.selectionForwardBtn = Button('btn-primary btn-transparent text-bold selection-container-forward', {icon: 'forward'}); this.selectionForwardBtn.append(i18n('Forward')); - this.listenerSetter.add(this.selectionForwardBtn)('click', () => { - new PopupForward(this.bubbles.peerId, [...this.selectedMids], () => { + attachClickEvent(this.selectionForwardBtn, () => { + const obj: {[fromPeerId: number]: number[]} = {}; + for(const [fromPeerId, mids] of this.selectedMids) { + obj[fromPeerId] = Array.from(mids); + } + + new PopupForward(obj, () => { this.cancelSelection(); }); - }); + }, attachClickOptions); } this.selectionDeleteBtn = Button('btn-primary btn-transparent danger text-bold selection-container-delete', {icon: 'delete'}); this.selectionDeleteBtn.append(i18n('Delete')); - this.listenerSetter.add(this.selectionDeleteBtn)('click', () => { - new PopupDeleteMessages(this.bubbles.peerId, [...this.selectedMids], this.chat.type, () => { + attachClickEvent(this.selectionDeleteBtn, () => { + new PopupDeleteMessages(this.bubbles.peerId, [...this.selectedMids.get(this.bubbles.peerId)], this.chat.type, () => { this.cancelSelection(); }); - }); + }, attachClickOptions); - this.selectionContainer.append(...[btnCancel, this.selectionCountEl, this.selectionSendNowBtn, this.selectionForwardBtn, this.selectionDeleteBtn].filter(Boolean)); + this.selectionContainer.append(...[ + btnCancel, + this.selectionCountEl, + this.selectionSendNowBtn, + this.selectionForwardBtn, + this.selectionDeleteBtn + ].filter(Boolean)); this.selectionInputWrapper.style.opacity = '0'; this.selectionInputWrapper.append(this.selectionContainer); @@ -396,133 +907,28 @@ export default class ChatSelection { this.selectionInputWrapper.style.opacity = ''; } } - - if(toggleCheckboxes) { - for(const mid in this.bubbles.bubbles) { - const bubble = this.bubbles.bubbles[mid]; - this.toggleBubbleCheckbox(bubble, this.isSelecting); - } - } - - if(forceSelection) { - this.updateContainer(forceSelection); - } - } - - public cancelSelection = () => { - for(const mid of this.selectedMids) { - const mounted = this.bubbles.getMountedBubble(mid); - if(mounted) { - //this.toggleByBubble(mounted.message.grouped_id ? mounted.bubble.querySelector(`.grouped-item[data-mid="${mid}"]`) : mounted.bubble); - this.toggleByBubble(mounted.bubble); - } - /* const bubble = this.appImManager.bubbles[mid]; - if(bubble) { - this.toggleByBubble(bubble); - } */ - } - - this.selectedMids.clear(); - this.toggleSelection(); - cancelSelection(); }; - public cleanup() { - this.selectedMids.clear(); - this.toggleSelection(false); - } - - private updateBubbleSelection(bubble: HTMLElement, isSelected: boolean) { - this.toggleBubbleCheckbox(bubble, true); - const input = this.getCheckboxInputFromBubble(bubble); - input.checked = isSelected; - - this.toggleSelection(); - this.updateContainer(); - SetTransition(bubble, 'is-selected', isSelected, 200); - } - - private isGroupedBubbleSelected(bubble: HTMLElement) { - const groupedCheckboxInput = this.getCheckboxInputFromBubble(bubble); - return groupedCheckboxInput?.checked; - } - - private isGroupedMidsSelected(mid: number) { - const mids = this.chat.getMidsByMid(mid); - const selectedMids = mids.filter(mid => this.selectedMids.has(mid)); - return mids.length === selectedMids.length; - } - - public toggleByBubble = (bubble: HTMLElement) => { - if(!this.canSelectBubble(bubble)) return; - - const mid = +bubble.dataset.mid; - - const isGrouped = bubble.classList.contains('is-grouped'); - if(isGrouped) { - if(!this.isGroupedBubbleSelected(bubble)) { - const mids = this.chat.getMidsByMid(mid); - mids.forEach(mid => this.selectedMids.delete(mid)); - } - - this.bubbles.getBubbleGroupedItems(bubble).forEach(this.toggleByBubble); - return; - } - - const found = this.selectedMids.has(mid); - if(found) { - this.selectedMids.delete(mid); - } else { - const diff = rootScope.config.forwarded_count_max - this.selectedMids.size - 1; - if(diff < 0) { - toast(I18n.format('Chat.Selection.LimitToast', true)); - return; - /* const it = this.selectedMids.values(); - do { - const mid = it.next().value; - const mounted = this.appImManager.getMountedBubble(mid); - if(mounted) { - this.toggleByBubble(mounted.bubble); - } else { - const mids = this.appMessagesManager.getMidsByMid(mid); - for(const mid of mids) { - this.selectedMids.delete(mid); - } - } - } while(this.selectedMids.size > MAX_SELECTION_LENGTH); */ - } - - this.selectedMids.add(mid); - } - - const isGroupedItem = bubble.classList.contains('grouped-item'); - if(isGroupedItem) { - const groupContainer = findUpClassName(bubble, 'bubble'); - const isGroupedSelected = this.isGroupedBubbleSelected(groupContainer); - const isGroupedMidsSelected = this.isGroupedMidsSelected(mid); + protected onUpdateContainer = (cantForward: boolean, cantDelete: boolean, cantSend: boolean) => { + replaceContent(this.selectionCountEl, i18n('messages', [this.length()])); + this.selectionSendNowBtn && this.selectionSendNowBtn.toggleAttribute('disabled', cantSend); + this.selectionForwardBtn && this.selectionForwardBtn.toggleAttribute('disabled', cantForward); + this.selectionDeleteBtn.toggleAttribute('disabled', cantDelete); + }; - const willChange = isGroupedMidsSelected || isGroupedSelected; - if(willChange) { - this.updateBubbleSelection(groupContainer, isGroupedMidsSelected); + protected onCancelSelection = () => { + for(const [peerId, mids] of this.selectedMids) { + for(const mid of mids) { + const mounted = this.bubbles.getMountedBubble(mid); + if(mounted) { + //this.toggleByBubble(mounted.message.grouped_id ? mounted.bubble.querySelector(`.grouped-item[data-mid="${mid}"]`) : mounted.bubble); + this.toggleByElement(mounted.bubble); + } + /* const bubble = this.appImManager.bubbles[mid]; + if(bubble) { + this.toggleByBubble(bubble); + } */ } } - - this.updateBubbleSelection(bubble, !found); }; - - /** - * ! Call this method only to handle deleted messages - */ - public deleteSelectedMids(mids: number[]) { - mids.forEach(mid => { - this.selectedMids.delete(mid); - }); - - this.updateContainer(); - this.toggleSelection(); - } - - public canSelectBubble(bubble: HTMLElement) { - return !bubble.classList.contains('service') && !bubble.classList.contains('is-sending') && !bubble.classList.contains('bubble-first'); - } } diff --git a/src/components/chat/topbar.ts b/src/components/chat/topbar.ts index e0583e0c..bef6d67d 100644 --- a/src/components/chat/topbar.ts +++ b/src/components/chat/topbar.ts @@ -249,13 +249,13 @@ export default class ChatTopbar { return; } - const original = selection.toggleByBubble.bind(selection); - selection.toggleByBubble = (bubble) => { + const original = selection.toggleByElement.bind(selection); + selection.toggleByElement = (bubble) => { appStateManager.pushToState('chatContextMenuHintWasShown', true); toast(i18n('Chat.Menu.Hint')); - selection.toggleByBubble = original; - selection.toggleByBubble(bubble); + selection.toggleByElement = original; + selection.toggleByElement(bubble); }; }); }, diff --git a/src/components/popups/forward.ts b/src/components/popups/forward.ts index 3e941d95..cd2b6d3b 100644 --- a/src/components/popups/forward.ts +++ b/src/components/popups/forward.ts @@ -8,7 +8,12 @@ import appImManager from "../../lib/appManagers/appImManager"; import PopupPickUser from "./pickUser"; export default class PopupForward extends PopupPickUser { - constructor(fromPeerId: number, mids: number[], onSelect?: (peerId: number) => Promise | void, onClose?: () => void, overrideOnSelect = false) { + constructor( + peerIdMids: {[fromPeerId: number]: number[]}, + onSelect?: (peerId: number) => Promise | void, + onClose?: () => void, + overrideOnSelect = false + ) { super({ peerTypes: ['dialogs', 'contacts'], onSelect: overrideOnSelect ? onSelect : async(peerId) => { @@ -20,7 +25,7 @@ export default class PopupForward extends PopupPickUser { } appImManager.setInnerPeer(peerId); - appImManager.chat.input.initMessagesForward(fromPeerId, mids.slice()); + appImManager.chat.input.initMessagesForward(peerIdMids); }, onClose, placeholder: 'ShareModal.Search.ForwardPlaceholder', diff --git a/src/components/rangeSelector.ts b/src/components/rangeSelector.ts index 6cd6d6a2..57f92951 100644 --- a/src/components/rangeSelector.ts +++ b/src/components/rangeSelector.ts @@ -73,11 +73,13 @@ export default class RangeSelector { this.rect = this.container.getBoundingClientRect(); this.mousedown = true; this.scrub(event); + this.container.classList.add('is-focused'); this.events?.onMouseDown && this.events.onMouseDown(event); }; protected onMouseUp = (event: GrabEvent) => { this.mousedown = false; + this.container.classList.remove('is-focused'); this.events?.onMouseUp && this.events.onMouseUp(event); }; diff --git a/src/components/row.ts b/src/components/row.ts index 95e4d0f9..ff326a6a 100644 --- a/src/components/row.ts +++ b/src/components/row.ts @@ -11,6 +11,7 @@ import { SliderSuperTab } from "./slider"; import RadioForm from "./radioForm"; import { i18n, LangPackKey } from "../lib/langPack"; import replaceContent from "../helpers/dom/replaceContent"; +import setInnerHTML from "../helpers/dom/setInnerHTML"; export default class Row { public container: HTMLElement; @@ -24,7 +25,7 @@ export default class Row { constructor(options: Partial<{ icon: string, - subtitle: string, + subtitle: string | HTMLElement | DocumentFragment, subtitleLangKey: LangPackKey, subtitleLangArgs: any[], radioField: Row['radioField'], @@ -35,7 +36,8 @@ export default class Row { titleRight: string | HTMLElement, clickable: boolean | ((e: Event) => void), navigationTab: SliderSuperTab, - havePadding: boolean + havePadding: boolean, + noRipple: boolean }> = {}) { this.container = document.createElement(options.radioField || options.checkboxField ? 'label' : 'div'); this.container.classList.add('row'); @@ -44,7 +46,11 @@ export default class Row { this.subtitle.classList.add('row-subtitle'); this.subtitle.setAttribute('dir', 'auto'); if(options.subtitle) { - this.subtitle.innerHTML = options.subtitle; + if(typeof(options.subtitle) === 'string') { + setInnerHTML(this.subtitle, options.subtitle); + } else { + this.subtitle.append(options.subtitle); + } } else if(options.subtitleLangKey) { this.subtitle.append(i18n(options.subtitleLangKey, options.subtitleLangArgs)); } @@ -137,7 +143,10 @@ export default class Row { } this.container.classList.add('row-clickable', 'hover-effect'); - ripple(this.container, undefined, undefined, true); + + if(!options.noRipple) { + ripple(this.container, undefined, undefined, true); + } /* if(options.radioField || options.checkboxField) { this.container.prepend(this.container.lastElementChild); diff --git a/src/components/sendingStatus.ts b/src/components/sendingStatus.ts new file mode 100644 index 00000000..14558ab2 --- /dev/null +++ b/src/components/sendingStatus.ts @@ -0,0 +1,86 @@ +/* + * https://github.com/morethanwords/tweb + * Copyright (C) 2019-2021 Eduard Kuzmenko + * https://github.com/morethanwords/tweb/blob/master/LICENSE + */ + +import { Message } from "../layer"; +/* import findUpClassName from "../helpers/dom/findUpClassName"; +import rootScope from "../lib/rootScope"; +import Transition from "./transition"; */ + +export enum SENDING_STATUS { + Error = -1, + Pending, + Sent, + Read +} + +export function getSendingStatus(message: Message.message | Message.messageService) { + return message.pFlags.is_outgoing ? + SENDING_STATUS.Pending : ( + message.pFlags.unread ? + SENDING_STATUS.Sent : + SENDING_STATUS.Read + ); +} + +export function setSendingStatus( + container: HTMLElement, + message?: Message.message | Message.messageService, + disableAnimationIfRippleFound?: boolean +) { + let className: 'check' | 'checks' | 'sending'; + if(message?.pFlags.out) { + if(message.pFlags.is_outgoing) { + className = 'sending'; + } else if(message.pFlags.unread) { + className = 'check'; + } else { + className = 'checks'; + } + } + + if(!className) { + container.textContent = ''; + return; + } + + const iconClassName = 'tgico-' + className; + const lastElement = container.lastElementChild as HTMLElement; + if(lastElement && lastElement.classList.contains(iconClassName)) { + return; + } + + const element = document.createElement('i'); + element.classList.add('sending-status-icon', /* 'transition-item', */ iconClassName); + container.append(element); + + if(lastElement) { + lastElement.remove(); + } + + /* if(!lastElement) { + element.classList.add('active'); + return; + } + + const select = Transition(container, undefined, 350, () => { + lastElement.remove(); + }, false, true, false); + + let animate = rootScope.settings.animationsEnabled && className !== 'sending' && !lastElement.classList.contains('tgico-sending'); + if(disableAnimationIfRippleFound && animate) { + const parent = findUpClassName(container, 'rp'); + if(parent.querySelector('.c-ripple__circle') || parent.matches(':hover')) { + animate = false; + } + } + + select(element, animate, lastElement); */ + + /* SetTransition(lastElement, 'is-visible', false, 350, () => { + // lastElement.remove(); + }, 2); + SetTransition(element, 'is-visible', true, 350, undefined, 2); */ +} diff --git a/src/components/sidebarRight/tabs/sharedMedia.ts b/src/components/sidebarRight/tabs/sharedMedia.ts index a2d96dbd..c8cfb00a 100644 --- a/src/components/sidebarRight/tabs/sharedMedia.ts +++ b/src/components/sidebarRight/tabs/sharedMedia.ts @@ -245,7 +245,8 @@ class PeerProfileAvatars { const rect = this.container.getBoundingClientRect(); - const e = (_e as TouchEvent).touches ? (_e as TouchEvent).touches[0] : _e as MouseEvent; + // const e = (_e as TouchEvent).touches ? (_e as TouchEvent).touches[0] : _e as MouseEvent; + const e = _e; const x = e.pageX; const clickX = x - rect.left; @@ -1055,16 +1056,15 @@ export default class AppSharedMediaTab extends SliderSuperTab { const inputFilter = mediaTab.inputFilter; const filtered = this.searchSuper.filterMessagesByType(mids.map(mid => appMessagesManager.getMessageByPeer(peerId, mid)), inputFilter); if(filtered.length) { - if(this.historiesStorage[peerId][inputFilter]) { - this.historiesStorage[peerId][inputFilter].unshift(...filtered.map(message => ({mid: message.mid, peerId: message.peerId}))); + const history = this.historiesStorage[peerId][inputFilter]; + if(history) { + history.unshift(...filtered.map(message => ({mid: message.mid, peerId: message.peerId}))); } if(this.peerId === peerId && this.searchSuper.usedFromHistory[inputFilter] !== -1) { this.searchSuper.usedFromHistory[inputFilter] += filtered.length; this.searchSuper.performSearchResult(filtered, mediaTab, false); } - - break; } } } @@ -1078,17 +1078,21 @@ export default class AppSharedMediaTab extends SliderSuperTab { for(const type of this.searchSuper.mediaTabs) { const inputFilter = type.inputFilter; - if(!this.historiesStorage[peerId][inputFilter]) continue; - const history = this.historiesStorage[peerId][inputFilter]; + if(!history) continue; + const idx = history.findIndex(m => m.mid === mid); if(idx !== -1) { history.splice(idx, 1); if(this.peerId === peerId) { const container = this.searchSuper.tabs[inputFilter]; - const div = container.querySelector(`div[data-mid="${mid}"][data-peer-id="${peerId}"]`); + const div = container.querySelector(`div[data-mid="${mid}"][data-peer-id="${peerId}"]`) as HTMLElement; if(div) { + if(this.searchSuper.selection.isSelecting) { + this.searchSuper.selection.toggleByElement(div); + } + div.remove(); } diff --git a/src/components/singleTransition.ts b/src/components/singleTransition.ts index 6aad59a3..50f38555 100644 --- a/src/components/singleTransition.ts +++ b/src/components/singleTransition.ts @@ -26,6 +26,10 @@ const SetTransition = ( } } + // if(forwards && className && element.classList.contains(className) && !element.classList.contains('animating')) { + // return; + // } + if(useRafs && rootScope.settings.animationsEnabled && duration) { element.dataset.raf = '' + window.requestAnimationFrame(() => { delete element.dataset.raf; diff --git a/src/components/transition.ts b/src/components/transition.ts index ad6fc153..fa1651f6 100644 --- a/src/components/transition.ts +++ b/src/components/transition.ts @@ -10,6 +10,7 @@ import { dispatchHeavyAnimationEvent } from "../hooks/useHeavyAnimationCheck"; import whichChild from "../helpers/dom/whichChild"; import findUpClassName from "../helpers/dom/findUpClassName"; import { isSafari } from "../helpers/userAgent"; +import { cancelEvent } from "../helpers/dom/cancelEvent"; function slideNavigation(tabContent: HTMLElement, prevTabContent: HTMLElement, toRight: boolean) { const width = prevTabContent.getBoundingClientRect().width; @@ -80,7 +81,13 @@ function slideTabs(tabContent: HTMLElement, prevTabContent: HTMLElement, toRight }; } -export const TransitionSlider = (content: HTMLElement, type: 'tabs' | 'navigation' | 'zoom-fade' | 'slide-fade' | 'none'/* | 'counter' */, transitionTime: number, onTransitionEnd?: (id: number) => void, isHeavy = true) => { +export const TransitionSlider = ( + content: HTMLElement, + type: 'tabs' | 'navigation' | 'zoom-fade' | 'slide-fade' | 'none'/* | 'counter' */, + transitionTime: number, + onTransitionEnd?: (id: number) => void, + isHeavy = true +) => { let animationFunction: TransitionFunction = null; switch(type) { @@ -101,62 +108,94 @@ export const TransitionSlider = (content: HTMLElement, type: 'tabs' | 'navigatio type TransitionFunction = (tabContent: HTMLElement, prevTabContent: HTMLElement, toRight: boolean) => void | (() => void); -const Transition = (content: HTMLElement, animationFunction: TransitionFunction, transitionTime: number, onTransitionEnd?: (id: number) => void, isHeavy = true) => { +const Transition = ( + content: HTMLElement, + animationFunction: TransitionFunction, + transitionTime: number, + onTransitionEnd?: (id: number) => void, + isHeavy = true, + once = false, + withAnimationListener = true +) => { const onTransitionEndCallbacks: Map = new Map(); let animationDeferred: CancellablePromise; - let animationStarted = 0; + // let animationStarted = 0; let from: HTMLElement = null; - // TODO: check for transition type (transform, etc) using by animationFunction - content.addEventListener(animationFunction ? 'transitionend' : 'animationend', (e) => { - if((e.target as HTMLElement).parentElement !== content) { - return; - } - - //console.log('Transition: transitionend', /* content, */ e, selectTab.prevId, performance.now() - animationStarted); + if(withAnimationListener) { + const listenerName = animationFunction ? 'transitionend' : 'animationend'; - const callback = onTransitionEndCallbacks.get(e.target as HTMLElement); - if(callback) callback(); - - if(e.target !== from) { - return; - } - - if(!animationDeferred && isHeavy) return; - - if(animationDeferred) { - animationDeferred.resolve(); - animationDeferred = undefined; - } + const onEndEvent = (e: TransitionEvent | AnimationEvent) => { + cancelEvent(e); + + if((e.target as HTMLElement).parentElement !== content) { + return; + } + + //console.log('Transition: transitionend', /* content, */ e, selectTab.prevId, performance.now() - animationStarted); + + const callback = onTransitionEndCallbacks.get(e.target as HTMLElement); + if(callback) callback(); + + if(e.target !== from) { + return; + } + + if(!animationDeferred && isHeavy) return; + + if(animationDeferred) { + animationDeferred.resolve(); + animationDeferred = undefined; + } + + if(onTransitionEnd) { + onTransitionEnd(selectTab.prevId()); + } + + content.classList.remove('animating', 'backwards', 'disable-hover'); + + if(once) { + content.removeEventListener(listenerName, onEndEvent/* , {capture: false} */); + from = animationDeferred = undefined; + onTransitionEndCallbacks.clear(); + } + }; + + // TODO: check for transition type (transform, etc) using by animationFunction + content.addEventListener(listenerName, onEndEvent/* , {passive: true, capture: false} */); + } - if(onTransitionEnd) { - onTransitionEnd(selectTab.prevId()); + function selectTab(id: number | HTMLElement, animate = true, overrideFrom?: typeof from) { + if(overrideFrom) { + from = overrideFrom; } - content.classList.remove('animating', 'backwards', 'disable-hover'); - }); - - function selectTab(id: number | HTMLElement, animate = true) { - const self = selectTab; - if(id instanceof HTMLElement) { id = whichChild(id); } - const prevId = self.prevId(); + const prevId = selectTab.prevId(); if(id === prevId) return false; //console.log('selectTab id:', id); - const _from = from; const to = content.children[id] as HTMLElement; if(!rootScope.settings.animationsEnabled || prevId === -1) { animate = false; } + if(!withAnimationListener) { + const timeout = content.dataset.timeout; + if(timeout !== undefined) { + clearTimeout(+timeout); + } + + delete content.dataset.timeout; + } + if(!animate) { - if(_from) _from.classList.remove('active', 'to', 'from'); + if(from) from.classList.remove('active', 'to', 'from'); if(to) { to.classList.remove('to', 'from'); to.classList.add('active'); @@ -170,12 +209,21 @@ const Transition = (content: HTMLElement, animationFunction: TransitionFunction, return; } + if(!withAnimationListener) { + content.dataset.timeout = '' + window.setTimeout(() => { + to.classList.remove('to'); + from && from.classList.remove('from'); + content.classList.remove('animating', 'backwards', 'disable-hover'); + delete content.dataset.timeout; + }, transitionTime); + } + if(from) { from.classList.remove('to'); from.classList.add('from'); } - content.classList.add('animating', 'disable-hover'); + content.classList.add('animating'/* , 'disable-hover' */); const toRight = prevId < id; content.classList.toggle('backwards', !toRight); @@ -200,7 +248,8 @@ const Transition = (content: HTMLElement, animationFunction: TransitionFunction, }); } - if(_from/* && false */) { + if(from/* && false */) { + const _from = from; const callback = () => { _from.classList.remove('active', 'from'); @@ -223,7 +272,7 @@ const Transition = (content: HTMLElement, animationFunction: TransitionFunction, if(isHeavy) { if(!animationDeferred) { animationDeferred = deferredPromise(); - animationStarted = performance.now(); + // animationStarted = performance.now(); } dispatchHeavyAnimationEvent(animationDeferred, transitionTime * 2); diff --git a/src/components/wrappers.ts b/src/components/wrappers.ts index ab07eb3b..3920516a 100644 --- a/src/components/wrappers.ts +++ b/src/components/wrappers.ts @@ -550,7 +550,7 @@ export function wrapDocument({message, withTime, fontWeight, voiceAsMusic, showS icoDiv.classList.add('document-ico'); const cacheContext = appDownloadManager.getCacheContext(doc); - if(doc.thumbs?.length || (message.pFlags.is_outgoing && cacheContext.url && doc.type === 'photo')) { + if((doc.thumbs?.length || (message.pFlags.is_outgoing && cacheContext.url && doc.type === 'photo')) && doc.mime_type !== 'image/gif') { docDiv.classList.add('document-with-thumb'); let imgs: HTMLImageElement[] = []; @@ -1564,6 +1564,7 @@ export function wrapGroupedDocuments({albumMustBeRenderedFull, message, bubble, const container = document.createElement('div'); container.classList.add('document-container'); container.dataset.mid = '' + mid; + container.dataset.peerId = '' + message.peerId; const wrapper = document.createElement('div'); wrapper.classList.add('document-wrapper'); diff --git a/src/config/app.ts b/src/config/app.ts index 69817088..3b8c6320 100644 --- a/src/config/app.ts +++ b/src/config/app.ts @@ -16,7 +16,7 @@ export const MAIN_DOMAIN = 'web.telegram.org'; const App = { id: 1025907, hash: '452b0359b988148995f22ff0f4229750', - version: '0.8.2', + version: '0.8.3', langPackVersion: '0.3.3', langPack: 'macos', langPackCode: 'en', diff --git a/src/helpers/dom/clickEvent.ts b/src/helpers/dom/clickEvent.ts index f95ed546..03bbe063 100644 --- a/src/helpers/dom/clickEvent.ts +++ b/src/helpers/dom/clickEvent.ts @@ -8,9 +8,9 @@ import type ListenerSetter from "../listenerSetter"; import { isTouchSupported } from "../touchSupport"; import simulateEvent from "./dispatchEvent"; -export const CLICK_EVENT_NAME: 'mousedown' | 'touchend' | 'click' = (isTouchSupported ? 'mousedown' : 'click') as any; +export const CLICK_EVENT_NAME: 'mousedown' /* | 'touchend' */ | 'click' = (isTouchSupported ? 'mousedown' : 'click') as any; export type AttachClickOptions = AddEventListenerOptions & Partial<{listenerSetter: ListenerSetter, touchMouseDown: true}>; -export function attachClickEvent(elem: HTMLElement, callback: (e: TouchEvent | MouseEvent) => void, options: AttachClickOptions = {}) { +export function attachClickEvent(elem: HTMLElement | Window, callback: (e: /* TouchEvent | */MouseEvent) => void, options: AttachClickOptions = {}) { const add = options.listenerSetter ? options.listenerSetter.add(elem) : elem.addEventListener.bind(elem); // const remove = options.listenerSetter ? options.listenerSetter.removeManual.bind(options.listenerSetter, elem) : elem.removeEventListener.bind(elem); @@ -46,11 +46,11 @@ export function attachClickEvent(elem: HTMLElement, callback: (e: TouchEvent | M } export function detachClickEvent(elem: HTMLElement, callback: (e: TouchEvent | MouseEvent) => void, options?: AddEventListenerOptions) { - if(CLICK_EVENT_NAME === 'touchend') { - elem.removeEventListener('touchstart', callback, options); - } else { + // if(CLICK_EVENT_NAME === 'touchend') { + // elem.removeEventListener('touchstart', callback, options); + // } else { elem.removeEventListener(CLICK_EVENT_NAME, callback, options); - } + // } } export function simulateClickEvent(elem: HTMLElement) { diff --git a/src/helpers/dom/fixSafariStickyInputFocusing.ts b/src/helpers/dom/fixSafariStickyInputFocusing.ts index d863aa57..12a2cafd 100644 --- a/src/helpers/dom/fixSafariStickyInputFocusing.ts +++ b/src/helpers/dom/fixSafariStickyInputFocusing.ts @@ -56,7 +56,7 @@ if(IS_STICKY_INPUT_BUGGED) { // let hasFocus = false; let lastFocusOutTimeStamp = 0; document.addEventListener('focusin', (e) => { - if((e.timeStamp - lastFocusOutTimeStamp) < 50/* && document.activeElement === input */) { + if(!(e.target as HTMLElement).classList.contains('is-sticky-input-bugged') || (e.timeStamp - lastFocusOutTimeStamp) < 50/* && document.activeElement === input */) { return; } diff --git a/src/lang.ts b/src/lang.ts index ac9dfaa0..7d772b2f 100644 --- a/src/lang.ts +++ b/src/lang.ts @@ -793,6 +793,7 @@ const lang = { "Message.Context.Select": "Select", "Message.Context.Pin": "Pin", "Message.Context.Unpin": "Unpin", + "Message.Context.Goto": "Show Message", "MessageContext.CopyMessageLink1": "Copy Message Link", "NewPoll.Anonymous": "Anonymous Voting", "NewPoll.Explanation.Placeholder": "Add a Comment (Optional)", diff --git a/src/lib/appManagers/appDialogsManager.ts b/src/lib/appManagers/appDialogsManager.ts index 0b5c178f..4028a2a8 100644 --- a/src/lib/appManagers/appDialogsManager.ts +++ b/src/lib/appManagers/appDialogsManager.ts @@ -22,7 +22,7 @@ import rootScope from "../rootScope"; import apiUpdatesManager from "./apiUpdatesManager"; import appPeersManager from './appPeersManager'; import appImManager from "./appImManager"; -import appMessagesManager, { Dialog } from "./appMessagesManager"; +import appMessagesManager, { Dialog, MyMessage } from "./appMessagesManager"; import appStateManager, { State } from "./appStateManager"; import appUsersManager from "./appUsersManager"; import Button from "../../components/button"; @@ -51,6 +51,7 @@ import windowSize from "../../helpers/windowSize"; import isInDOM from "../../helpers/dom/isInDOM"; import appPhotosManager, { MyPhoto } from "./appPhotosManager"; import { MyDocument } from "./appDocsManager"; +import { setSendingStatus } from "../../components/sendingStatus"; export type DialogDom = { avatarEl: AvatarElement, @@ -440,6 +441,7 @@ export class AppDialogsManager { public setFilterId(filterId: number) { this.filterId = filterId; this.indexKey = appMessagesManager.dialogsStorage ? appMessagesManager.dialogsStorage.getDialogIndexKey(this.filterId) : 'index'; + rootScope.filterId = filterId; } private async onStateLoaded(state: State) { @@ -1433,22 +1435,15 @@ export class AppDialogsManager { } } - const lastMessage = dialog.draft?._ === 'draftMessage' ? - dialog.draft : - appMessagesManager.getMessageByPeer(dialog.peerId, dialog.top_message); - if(!lastMessage.deleted && lastMessage.pFlags.out && lastMessage.peerId !== rootScope.myId/* && - dialog.read_outbox_max_id */) { // maybe comment, 06.20.2020 - const isUnread = !!lastMessage.pFlags?.unread - /* && dialog.read_outbox_max_id !== 0 */; // maybe uncomment, 31.01.2020 - - if(isUnread) { - dom.statusSpan.classList.remove('tgico-checks'); - dom.statusSpan.classList.add('tgico-check'); - } else { - dom.statusSpan.classList.remove('tgico-check'); - dom.statusSpan.classList.add('tgico-checks'); + let setStatusMessage: MyMessage; + if(dialog.draft?._ !== 'draftMessage') { + const lastMessage: MyMessage = appMessagesManager.getMessageByPeer(dialog.peerId, dialog.top_message); + if(!lastMessage.deleted && lastMessage.pFlags.out && lastMessage.peerId !== rootScope.myId) { + setStatusMessage = lastMessage; } - } else dom.statusSpan.classList.remove('tgico-check', 'tgico-checks'); + } + + setSendingStatus(dom.statusSpan, setStatusMessage, true); const filter = appMessagesManager.filtersStorage.getFilter(this.filterId); let isPinned: boolean; @@ -1672,7 +1667,7 @@ export class AppDialogsManager { li.dataset.peerId = '' + peerId; const statusSpan = document.createElement('span'); - statusSpan.classList.add('message-status'); + statusSpan.classList.add('message-status', 'sending-status'/* , 'transition', 'reveal' */); const lastTimeSpan = document.createElement('span'); lastTimeSpan.classList.add('message-time'); diff --git a/src/lib/appManagers/appImManager.ts b/src/lib/appManagers/appImManager.ts index 18b6efd8..76c4e9b1 100644 --- a/src/lib/appManagers/appImManager.ts +++ b/src/lib/appManagers/appImManager.ts @@ -806,6 +806,16 @@ export class AppImManager { if(e.code === 'KeyC' && (e.ctrlKey || e.metaKey) && target.tagName !== 'INPUT') { return; + } else if(e.altKey && (e.code === 'ArrowUp' || e.code === 'ArrowDown') && rootScope.peerId) { + const folder = appMessagesManager.dialogsStorage.getFolder(rootScope.filterId, true); + const idx = folder.findIndex(dialog => dialog.peerId === rootScope.peerId); + if(idx !== -1) { + const nextIndex = e.code === 'ArrowUp' ? idx - 1 : idx + 1; + const nextDialog = folder[nextIndex]; + if(nextDialog) { + this.setPeer(nextDialog.peerId); + } + } } else if(e.code === 'ArrowUp') { if(!chat.input.editMsgId && chat.input.isInputEmpty()) { const historyStorage = appMessagesManager.getHistoryStorage(chat.peerId, chat.threadId); @@ -854,23 +864,18 @@ export class AppImManager { document.body.addEventListener('keydown', onKeyDown); - rootScope.addEventListener('history_multiappend', (e) => { - const msgIdsByPeer = e; - + rootScope.addEventListener('history_multiappend', (msgIdsByPeer) => { for(const peerId in msgIdsByPeer) { appSidebarRight.sharedMediaTab.renderNewMessages(+peerId, Array.from(msgIdsByPeer[peerId])); } }); - rootScope.addEventListener('history_delete', (e) => { - const {peerId, msgs} = e; - + rootScope.addEventListener('history_delete', ({peerId, msgs}) => { appSidebarRight.sharedMediaTab.deleteDeletedMessages(peerId, Array.from(msgs)); }); // Calls when message successfully sent and we have an id - rootScope.addEventListener('message_sent', (e) => { - const {storage, tempId, mid} = e; + rootScope.addEventListener('message_sent', ({storage, tempId, mid}) => { const message = appMessagesManager.getMessageFromStorage(storage, mid); appSidebarRight.sharedMediaTab.renderNewMessages(message.peerId, [mid]); }); diff --git a/src/lib/appManagers/appMessagesManager.ts b/src/lib/appManagers/appMessagesManager.ts index 0b8d1dbf..782e87a4 100644 --- a/src/lib/appManagers/appMessagesManager.ts +++ b/src/lib/appManagers/appMessagesManager.ts @@ -2171,7 +2171,7 @@ export class AppMessagesManager { //return Object.keys(this.groupedMessagesStorage[grouped_id]).map(id => +id).sort((a, b) => a - b); } - public getMidsByMessage(message: any) { + public getMidsByMessage(message: Message.message) { if(message?.grouped_id) return this.getMidsByAlbum(message.grouped_id); else return [message.mid]; } diff --git a/src/lib/rootScope.ts b/src/lib/rootScope.ts index da39bcc0..da616c0e 100644 --- a/src/lib/rootScope.ts +++ b/src/lib/rootScope.ts @@ -147,6 +147,7 @@ export class RootScope extends EventListenerBase<{ public connectionStatus: {[name: string]: ConnectionStatusChange} = {}; public settings: State['settings']; public peerId = 0; + public filterId = 0; public systemTheme: Theme['name']; public config: Partial = { forwarded_count_max: 100, diff --git a/src/scss/components/_global.scss b/src/scss/components/_global.scss index 2a9ea8cb..b5f65245 100644 --- a/src/scss/components/_global.scss +++ b/src/scss/components/_global.scss @@ -155,6 +155,11 @@ Utility Classes border-radius: 0 !important; } +.disable-hover/* , +.disable-hover * */ { + pointer-events: none !important; +} + /* .flex-grow { flex-grow: 1; } diff --git a/src/scss/partials/_button.scss b/src/scss/partials/_button.scss index 59bc3e2a..96c91e0c 100644 --- a/src/scss/partials/_button.scss +++ b/src/scss/partials/_button.scss @@ -212,7 +212,7 @@ right: 0; top: 0; bottom: 0; - z-index: 1; + z-index: 3; cursor: default; user-select: none; //background-color: rgba(0, 0, 0, .2); diff --git a/src/scss/partials/_chat.scss b/src/scss/partials/_chat.scss index f841f38b..55192876 100644 --- a/src/scss/partials/_chat.scss +++ b/src/scss/partials/_chat.scss @@ -1147,11 +1147,6 @@ $chat-helper-size: 36px; @include respond-to(handhelds) { padding: 0 $chat-padding-handhelds; - - html.is-ios & { - -webkit-user-select: none; - -webkit-touch-callout: none; - } } &.is-chat { diff --git a/src/scss/partials/_chatBubble.scss b/src/scss/partials/_chatBubble.scss index 9fcda266..98b974b2 100644 --- a/src/scss/partials/_chatBubble.scss +++ b/src/scss/partials/_chatBubble.scss @@ -221,7 +221,8 @@ $bubble-margin: .25rem; // ! hide context menu for media on android .bubbles.is-selecting & { img, - video { + video, + a { pointer-events: none; } } @@ -1031,7 +1032,7 @@ $bubble-margin: .25rem; &-title, &-subtitle, i { - color: #fff; + color: #fff !important; } &-border { @@ -1412,31 +1413,31 @@ $bubble-margin: .25rem; position: absolute; /* position: relative; width: max-content; */ - bottom: .1rem; - right: .2rem; - border-radius: 12px; + bottom: .1875rem; + right: .1875rem; + border-radius: .75rem; background-color: var(--message-time-background); - padding: 0 .2rem; + padding: 0 .3125rem; z-index: 2; .time { margin-left: 0; - color: #fff; - visibility: visible; display: flex; align-items: center; - padding: 0 2.5px; - line-height: 18px; - pointer-events: all; // show title + padding: 0; + margin: 0; white-space: nowrap; height: 18px; - &:after { + .inner { + right: unset; + bottom: unset; color: #fff; - } + margin: inherit; - .inner { - display: none; + &:after { + color: #fff; + } } } } @@ -2102,6 +2103,10 @@ $bubble-margin: .25rem; .reply, .name { right: calc(100% + 10px); } + + .message { + right: 0; + } } &:not(.just-media) { @@ -2117,14 +2122,6 @@ $bubble-margin: .25rem; } } - &.sticker, - &.round, - &.emoji-big { - .message { - right: 0; - } - } - .quote:before { background-color: var(--message-out-primary-color); } @@ -2152,10 +2149,6 @@ $bubble-margin: .25rem; } } - &.is-message-empty .time .inner { - color: var(--message-out-primary-color); - } - /* &.is-message-empty .time:after { margin-bottom: 1px; } */ diff --git a/src/scss/partials/_chatlist.scss b/src/scss/partials/_chatlist.scss index eddb3c9c..282fe6d0 100644 --- a/src/scss/partials/_chatlist.scss +++ b/src/scss/partials/_chatlist.scss @@ -109,6 +109,7 @@ ul.chatlist { } */ li { + --background: unset; //height: var(--height); height: 72px; //max-height: var(--height); @@ -124,12 +125,14 @@ ul.chatlist { padding-right: 8.5px; padding-left: 8.5px; */ overflow: hidden; + background: var(--background); @include respond-to(handhelds) { border-radius: 0; } - @include hover-background-effect(); + //@include hover-background-effect(); + @include hover(gray, --background, false); &.is-muted { .user-title { @@ -183,13 +186,13 @@ ul.chatlist { } */ &.menu-open { - background: var(--light-secondary-text-color); + --background: var(--light-secondary-text-color); } @include respond-to(not-handhelds) { &.active { + --background: var(--primary-color) !important; //background: var(--light-secondary-text-color); - background: var(--primary-color) !important; .user-caption, .tgico-chatspinned:before, @@ -405,25 +408,25 @@ ul.chatlist { } .message-status { - margin-right: .1rem; - //margin-top: .3rem; - margin-top: -.3rem; + margin-right: 0.125rem; display: inline-block; vertical-align: middle; - - &[class*=" tgico-"] { - color: var(--chatlist-status-color); - font-size: 1.25rem; - } + color: var(--chatlist-status-color); + line-height: 1; + width: 1.25rem; + height: 1.25rem; + font-size: 1.25rem; + position: relative; + margin-top: -.0625rem; &:before { vertical-align: middle; } } - .message-time { + /* .message-time { vertical-align: middle; - } + } */ .tgico-chatspinned { background: transparent; diff --git a/src/scss/partials/_checkbox.scss b/src/scss/partials/_checkbox.scss index c9986a4d..4ba5dd82 100644 --- a/src/scss/partials/_checkbox.scss +++ b/src/scss/partials/_checkbox.scss @@ -56,10 +56,12 @@ } &-background { - top: -15%; + // it is needed for circle scale animation + top: -15%; right: -15%; bottom: -15%; left: -15%; + background-color: var(--primary-color); transform: scale(1); border-radius: 50%; @@ -138,6 +140,14 @@ .checkbox-box { border-radius: 50%; + overflow: auto; + + &-background { + top: 0; + right: 0; + bottom: 0; + left: 0; + } &-border { border: 2px solid var(--secondary-color); @@ -147,6 +157,10 @@ &-check { --offset: calc(var(--size) - (var(--size) / 2 + .125rem)); } + + html.is-safari & { + -webkit-mask-image: none; + } } } diff --git a/src/scss/partials/_ckin.scss b/src/scss/partials/_ckin.scss index fcd8fd64..267f2a07 100644 --- a/src/scss/partials/_ckin.scss +++ b/src/scss/partials/_ckin.scss @@ -287,6 +287,9 @@ video::-webkit-media-controls-enclosure { margin: 0; outline: none; caret-color: var(--color); + position: absolute; + top: -.5rem; + bottom: -.5rem; &:focus { outline: none; @@ -348,17 +351,24 @@ video::-webkit-media-controls-enclosure { position: absolute; right: 0; top: 50%; - transform: translate(calc(var(--thumb-size) / 2), -50%); + transform: translate(calc(var(--thumb-size) / 2), -50%) scale(1); + + @include animation-level(2) { + transition: transform .125s ease-in-out; + } } } } + &.is-focused .progress-line__filled:not(.progress-line__loaded):after { + transform: translate(calc(var(--thumb-size) / 2), -50%) scale(1.25); + } + &__loaded, &:before { opacity: .3; background-color: var(--secondary-color); } - &__seek, &__filled, &__loaded { border-radius: var(--border-radius); diff --git a/src/scss/partials/_document.scss b/src/scss/partials/_document.scss index 56fe535b..700317aa 100644 --- a/src/scss/partials/_document.scss +++ b/src/scss/partials/_document.scss @@ -10,6 +10,10 @@ padding-left: 4.25rem; height: 70px; + .media-photo { + border-radius: inherit; + } + &-ico { background-color: var(--background-color); border-radius: $border-radius; diff --git a/src/scss/partials/_profile.scss b/src/scss/partials/_profile.scss index 1ccd1e48..a1fc13eb 100644 --- a/src/scss/partials/_profile.scss +++ b/src/scss/partials/_profile.scss @@ -194,7 +194,7 @@ position: relative; width: 100%; - .checkbox-field { + /* .checkbox-field { margin: 0; padding: 0; margin-left: -54px; @@ -202,7 +202,7 @@ .checkbox-caption { padding-left: 54px; - } + } */ &-wrapper { flex: 1 1 auto; diff --git a/src/scss/partials/_rightSidebar.scss b/src/scss/partials/_rightSidebar.scss index ab742edf..24727617 100644 --- a/src/scss/partials/_rightSidebar.scss +++ b/src/scss/partials/_rightSidebar.scss @@ -310,7 +310,9 @@ display: none; } - .document-name, .audio-title, .title { + .document-name, + .audio-title, + .title { display: flex; justify-content: space-between; } @@ -347,6 +349,11 @@ overflow: hidden; } + .checkbox-field { + right: .25rem; + top: .25rem; + } + /* span.video-play { background-color: var(--message-time-background); color: #fff; @@ -357,6 +364,37 @@ } */ } + .checkbox { + &-box { + box-shadow: 0px 0px 3px 0px rgb(0 0 0 / 40%); + + &-border { + border-color: var(--message-checkbox-border-color); + } + + &-background { + background-color: var(--message-checkbox-color); + } + } + + &-field { + position: absolute; + z-index: 2; + margin: 0; + } + } + + .document, + .audio { + .checkbox-field { + top: 50%; + left: 0; + margin-left: 2rem; + margin-top: 1rem; + transform: translateY(-50%); + } + } + &-content-media &-month { &-items { width: 100%; @@ -385,7 +423,8 @@ //height: 54px; height: calc(48px + 1.5rem); - &-ico, &-download { + &-ico, + &-download { width: 48px; height: 48px; border-radius: 5px !important; @@ -412,73 +451,87 @@ .search-super-item { display: flex; flex-direction: column; - margin-top: 20px; - margin-left: 5px; - padding-bottom: 2px; - //padding-bottom: 10px; + padding-left: 4.4375rem; position: relative; - padding-left: 60px; overflow: hidden; - //min-height: 48px; - min-height: 58px; - - .preview { - height: 3rem; - width: 3rem; - border-radius: .375rem; - overflow: hidden; - position: absolute; - left: 0; - top: 0; - - &.empty { - display: flex; - align-items: center; - justify-content: center; - font-size: 2rem; - color: #fff; - text-transform: uppercase; - background-color: var(--primary-color); - } + min-height: 4.375rem; + cursor: pointer; + justify-content: flex-start; + } - .media-photo { - object-fit: cover; - width: 100%; - height: 100%; - } + .row-media { + height: 3rem; + width: 3rem; + border-radius: .375rem; + overflow: hidden; + position: absolute; + left: .6875rem; + + &.empty { + display: flex; + align-items: center; + justify-content: center; + font-size: 2rem; + color: #fff; + text-transform: uppercase; + background-color: var(--primary-color); } + } - .url { - white-space: nowrap; - text-overflow: ellipsis; - overflow: hidden; - font-size: 14px; - margin-top: -1px; + /* .anchor-url { + &:before { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + content: " "; + cursor: pointer; } + } */ + + .media-photo { + object-fit: cover; + width: 100%; + height: 100%; + border-radius: inherit; } - .title { - font-size: 16px; - margin-top: 2px; + .row-title { + margin-top: .1875rem; } - .subtitle { - font-size: 14px; - max-width: 310px; + .row-subtitle { + overflow: hidden; + white-space: pre-wrap; + text-overflow: ellipsis; + word-break: break-word; &.sender { - margin-top: 2px; + margin-top: .125rem; } } - .search-super-month-items { - padding: 0 24px 15px 15px; - - @include respond-to(handhelds) { - padding: 0 16px 15px 7px; + .sent-time { + margin: 1px 0 0; + } + + .checkbox-field { + padding: 0 !important; + margin: 2rem 0 0 -1.75rem !important; + } + + @include respond-to(not-handhelds) { + .search-super-month-items { + margin: .5625rem; } } + @include respond-to(handhelds) { + .search-super-month-name { + padding: .875rem 1rem; + } + } } &-content-music, &-content-voice { @@ -528,6 +581,76 @@ } } } + + &-tabs-scrollable { + .search-super-nav-scrollable { + opacity: 1; + } + + .search-super-nav-scrollable, + .search-super-selection-container { + @include animation-level(2) { + transition: opacity .2s ease-in-out; + } + } + + &.is-selecting { + &:not(.backwards) { + .search-super-nav-scrollable { + opacity: 0; + } + + .search-super-selection-container { + opacity: 1; + } + } + } + } + + &.is-selecting { + a { + pointer-events: none; + } + + .row { + &:not(.menu-open) { + background-color: transparent !important; + } + } + } + + &-selection { + &-container { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 1rem; + opacity: 0; + + .btn-icon + .btn-icon { + margin-left: .5rem; + } + + @include respond-to(handhelds) { + padding: 0 .5rem; + } + } + + &-count { + flex-grow: 1; + font-weight: 500; + color: var(--primary-text-color); + white-space: nowrap; + text-transform: capitalize; + margin-left: 1.5rem; + } + } } #search-container { diff --git a/src/scss/partials/_row.scss b/src/scss/partials/_row.scss index 7c6c078e..921416af 100644 --- a/src/scss/partials/_row.scss +++ b/src/scss/partials/_row.scss @@ -115,4 +115,8 @@ margin: 0 !important; left: .5rem; } + + &.menu-open { + background-color: var(--light-secondary-text-color); + } } diff --git a/src/scss/partials/_transition.scss b/src/scss/partials/_transition.scss index 2503c264..44f1f59e 100644 --- a/src/scss/partials/_transition.scss +++ b/src/scss/partials/_transition.scss @@ -1,14 +1,7 @@ -/* - * https://github.com/morethanwords/tweb - * Copyright (C) 2019-2021 Eduard Kuzmenko - * https://github.com/morethanwords/tweb/blob/master/LICENSE - */ +// * Jolly Cobra's transition .transition { - --easeOutSine: cubic-bezier(.39, .575, .565, 1); - --easeInSine: cubic-bezier(.47, 0, .745, .715); - - .transition-item { + > .transition-item { position: absolute; top: 0; left: 0; @@ -71,6 +64,8 @@ * slide-fade */ &.slide-fade { + --easeOutSine: cubic-bezier(.39, .575, .565, 1); + --easeInSine: cubic-bezier(.47, 0, .745, .715); position: relative; > .from { @@ -117,6 +112,18 @@ } } } + + /* &.reveal { + > .to { + clip-path: inset(0 100% 0 0); + } + + &.animating { + > .to { + animation: reveal-in 350ms ease-in; + } + } + } */ } /* @@ -188,6 +195,15 @@ } } + /* @keyframes reveal-in { + 0% { + clip-path: inset(0 100% 0 0); + } + 100% { + clip-path: inset(0 0 0 0); + } +} */ + /* .zoom-fade { transition: .15s ease-in-out opacity, .15s ease-in-out transform; transform: scale3d(1.1, 1.1, 1); diff --git a/src/scss/style.scss b/src/scss/style.scss index 368e1aa6..78cef0c7 100644 --- a/src/scss/style.scss +++ b/src/scss/style.scss @@ -406,6 +406,10 @@ html.is-ios { //&, body { position: fixed; // fix iOS fullscreen scroll //} + + // disable image longtapping + -webkit-user-select: none; + -webkit-touch-callout: none; } @supports(padding: unquote('max(0px)')) { @@ -490,11 +494,6 @@ input, textarea, button, select, a, div { //} } -.disable-hover/* , -.disable-hover * */ { - pointer-events: none !important; -} - @include respond-to(not-handhelds) { .only-handhelds { display: none !important; @@ -1359,3 +1358,20 @@ middle-ellipsis-element { content: "W"; } } + +.sending-status { + &:empty { + display: none; + } + + /* &.animating { + .sending-status-icon { + background: var(--background); + } + } */ + + &-icon { + position: absolute; + line-height: 1 !important; + } +}