Telegram Web K with changes to work inside I2P
https://web.telegram.i2p/
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
991 lines
35 KiB
991 lines
35 KiB
/* |
|
* https://github.com/morethanwords/tweb |
|
* Copyright (C) 2019-2021 Eduard Kuzmenko |
|
* https://github.com/morethanwords/tweb/blob/master/LICENSE |
|
*/ |
|
|
|
import type { AppMessagesManager, MessagesStorageKey } from "../../lib/appManagers/appMessagesManager"; |
|
import type ChatBubbles from "./bubbles"; |
|
import type ChatInput from "./input"; |
|
import type Chat from "./chat"; |
|
import IS_TOUCH_SUPPORTED from "../../environment/touchSupport"; |
|
import Button from "../button"; |
|
import ButtonIcon from "../buttonIcon"; |
|
import CheckboxField from "../checkboxField"; |
|
import PopupDeleteMessages from "../popups/deleteMessages"; |
|
import PopupForward from "../popups/forward"; |
|
import { toast } from "../toast"; |
|
import SetTransition from "../singleTransition"; |
|
import ListenerSetter from "../../helpers/listenerSetter"; |
|
import PopupSendNow from "../popups/sendNow"; |
|
import appNavigationController, { NavigationItem } from "../appNavigationController"; |
|
import { IS_MOBILE_SAFARI } from "../../environment/userAgent"; |
|
import I18n, { i18n, _i18n } from "../../lib/langPack"; |
|
import findUpClassName from "../../helpers/dom/findUpClassName"; |
|
import blurActiveElement from "../../helpers/dom/blurActiveElement"; |
|
import cancelEvent from "../../helpers/dom/cancelEvent"; |
|
import cancelSelection from "../../helpers/dom/cancelSelection"; |
|
import getSelectedText from "../../helpers/dom/getSelectedText"; |
|
import rootScope from "../../lib/rootScope"; |
|
import replaceContent from "../../helpers/dom/replaceContent"; |
|
import AppSearchSuper from "../appSearchSuper."; |
|
import isInDOM from "../../helpers/dom/isInDOM"; |
|
import { randomLong } from "../../helpers/random"; |
|
import { attachClickEvent, AttachClickOptions } from "../../helpers/dom/clickEvent"; |
|
import findUpAsChild from "../../helpers/dom/findUpAsChild"; |
|
import EventListenerBase from "../../helpers/eventListenerBase"; |
|
import safeAssign from "../../helpers/object/safeAssign"; |
|
import { AppManagers } from "../../lib/appManagers/managers"; |
|
import { attachContextMenuListener } from "../../helpers/dom/attachContextMenuListener"; |
|
import filterUnique from "../../helpers/array/filterUnique"; |
|
import appImManager from "../../lib/appManagers/appImManager"; |
|
|
|
const accumulateMapSet = (map: Map<any, Set<number>>) => { |
|
return [...map.values()].reduce((acc, v) => acc + v.size, 0); |
|
}; |
|
|
|
//const MIN_CLICK_MOVE = 32; // minimum bubble height |
|
|
|
class AppSelection extends EventListenerBase<{ |
|
toggle: (isSelecting: boolean) => void |
|
}> { |
|
public selectedMids: Map<PeerId, Set<number>> = new Map(); |
|
public isSelecting = false; |
|
|
|
public selectedText: string; |
|
|
|
protected listenerSetter: ListenerSetter; |
|
protected isScheduled: boolean; |
|
protected listenElement: HTMLElement; |
|
|
|
protected onToggleSelection: (forwards: boolean, animate: boolean) => void; |
|
protected onUpdateContainer: (cantForward: boolean, cantDelete: boolean, cantSend: boolean) => void; |
|
protected onCancelSelection: () => void; |
|
protected toggleByMid: (peerId: PeerId, 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 verifyTouchLongPress: () => boolean; |
|
protected targetLookupClassName: string; |
|
protected lookupBetweenParentClassName: string; |
|
protected lookupBetweenElementsQuery: string; |
|
|
|
protected doNotAnimate: boolean; |
|
protected managers: AppManagers; |
|
|
|
constructor(options: { |
|
managers: AppManagers, |
|
getElementFromTarget: AppSelection['getElementFromTarget'], |
|
verifyTarget?: AppSelection['verifyTarget'], |
|
verifyMouseMoveTarget?: AppSelection['verifyMouseMoveTarget'], |
|
verifyTouchLongPress?: AppSelection['verifyTouchLongPress'], |
|
targetLookupClassName: string, |
|
lookupBetweenParentClassName: string, |
|
lookupBetweenElementsQuery: string, |
|
isScheduled?: AppSelection['isScheduled'] |
|
}) { |
|
super(false); |
|
|
|
safeAssign(this, options); |
|
|
|
this.navigationType = 'multiselect-' + randomLong() as any; |
|
} |
|
|
|
public attachListeners(listenElement: HTMLElement, listenerSetter: ListenerSetter) { |
|
if(this.listenElement) { |
|
this.listenerSetter.removeAll(); |
|
} |
|
|
|
this.listenElement = listenElement; |
|
this.listenerSetter = listenerSetter; |
|
|
|
if(!listenElement) { |
|
return; |
|
} |
|
|
|
if(IS_TOUCH_SUPPORTED) { |
|
listenerSetter.add(listenElement)('touchend', () => { |
|
if(!this.isSelecting) return; |
|
this.selectedText = getSelectedText(); |
|
}); |
|
|
|
attachContextMenuListener(listenElement, (e) => { |
|
if(this.isSelecting || (this.verifyTouchLongPress && !this.verifyTouchLongPress())) 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 |
|
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); |
|
} |
|
}, listenerSetter); |
|
|
|
return; |
|
} |
|
|
|
listenerSetter.add(listenElement)('mousedown', this.onMouseDown); |
|
} |
|
|
|
private onMouseDown = (e: MouseEvent) => { |
|
//console.log('selection mousedown', e); |
|
const element = findUpClassName(e.target, this.targetLookupClassName); |
|
if(e.button !== 0) { |
|
return; |
|
} |
|
|
|
if(this.verifyTarget && !this.verifyTarget(e, element)) { |
|
return; |
|
} |
|
|
|
const seen: AppSelection['selectedMids'] = new Map(); |
|
let selecting: boolean; |
|
|
|
/* let good = false; |
|
const {x, y} = e; */ |
|
|
|
/* const bubbles = appImManager.bubbles; |
|
for(const mid in bubbles) { |
|
const bubble = bubbles[mid]; |
|
bubble.addEventListener('mouseover', () => { |
|
console.log('mouseover'); |
|
}, {once: true}); |
|
} */ |
|
|
|
let firstTarget = element; |
|
|
|
const processElement = (element: HTMLElement, checkBetween = true) => { |
|
const mid = +element.dataset.mid; |
|
if(!mid || !element.dataset.peerId) return; |
|
const peerId = element.dataset.peerId.toPeerId(); |
|
|
|
if(!isInDOM(firstTarget)) { |
|
firstTarget = element; |
|
} |
|
|
|
let seenSet = seen.get(peerId); |
|
if(!seenSet) { |
|
seen.set(peerId, seenSet = new Set()); |
|
} |
|
|
|
if(seenSet.has(mid)) { |
|
return; |
|
} |
|
|
|
const isSelected = this.isMidSelected(peerId, mid); |
|
if(selecting === undefined) { |
|
//bubblesContainer.classList.add('no-select'); |
|
selecting = !isSelected; |
|
} |
|
|
|
seenSet.add(mid); |
|
|
|
if((selecting && !isSelected) || (!selecting && isSelected)) { |
|
const seenLength = accumulateMapSet(seen); |
|
if(this.toggleByElement && checkBetween) { |
|
if(seenLength < 2) { |
|
if(findUpAsChild(element, firstTarget)) { |
|
firstTarget = element; |
|
} |
|
} |
|
|
|
const elementsBetween = this.getElementsBetween(firstTarget, element); |
|
// console.log(elementsBetween); |
|
if(elementsBetween.length) { |
|
elementsBetween.forEach((element) => { |
|
processElement(element, false); |
|
}); |
|
} |
|
} |
|
|
|
if(!this.selectedMids.size) { |
|
if(seenLength === 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<HTMLElement, true> = new Map(); |
|
let canceledSelection = false; |
|
const onMouseMove = (e: MouseEvent) => { |
|
if(!canceledSelection) { |
|
cancelSelection(); |
|
canceledSelection = true; |
|
} |
|
/* if(!good) { |
|
if(Math.abs(e.x - x) > MIN_CLICK_MOVE || Math.abs(e.y - y) > MIN_CLICK_MOVE) { |
|
good = true; |
|
} else { |
|
return; |
|
} |
|
} */ |
|
|
|
/* if(foundTargets.has(e.target as HTMLElement)) return; |
|
foundTargets.set(e.target as HTMLElement, true); */ |
|
const element = this.getElementFromTarget(e.target as HTMLElement); |
|
if(!element) { |
|
//console.error('found no bubble', e); |
|
return; |
|
} |
|
|
|
if(this.verifyMouseMoveTarget && !this.verifyMouseMoveTarget(e, element, selecting)) { |
|
this.listenerSetter.removeManual(this.listenElement, 'mousemove', onMouseMove); |
|
this.listenerSetter.removeManual(document, 'mouseup', onMouseUp, documentListenerOptions); |
|
return; |
|
} |
|
|
|
processElement(element); |
|
}; |
|
|
|
const onMouseUp = (e: MouseEvent) => { |
|
if(seen.size) { |
|
attachClickEvent(window, cancelEvent, {capture: true, once: true, passive: false}); |
|
} |
|
|
|
this.listenerSetter.removeManual(this.listenElement, 'mousemove', onMouseMove); |
|
//bubblesContainer.classList.remove('no-select'); |
|
|
|
// ! CANCEL USER SELECTION ! |
|
cancelSelection(); |
|
}; |
|
|
|
const documentListenerOptions = {once: true}; |
|
this.listenerSetter.add(this.listenElement)('mousemove', onMouseMove); |
|
this.listenerSetter.add(document)('mouseup', onMouseUp, documentListenerOptions); |
|
}; |
|
|
|
private 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); |
|
|
|
// console.log('getElementsBetween', first, last, slice, firstIndex, lastIndex, isHigher); |
|
|
|
return slice; |
|
}; |
|
|
|
protected isElementShouldBeSelected(element: HTMLElement) { |
|
return this.isMidSelected(element.dataset.peerId.toPeerId(), +element.dataset.mid); |
|
} |
|
|
|
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 false; |
|
} |
|
|
|
const checkboxField = new CheckboxField({ |
|
name: element.dataset.mid, |
|
round: true |
|
}); |
|
|
|
// * if it is a render of new message |
|
if(this.isSelecting) { // ! avoid breaking animation on start |
|
if(this.isElementShouldBeSelected(element)) { |
|
checkboxField.input.checked = true; |
|
element.classList.add('is-selected'); |
|
} |
|
} |
|
|
|
this.appendCheckbox(element, checkboxField); |
|
} else if(hasCheckbox) { |
|
this.getCheckboxInputFromElement(element).parentElement.remove(); |
|
SetTransition(element, 'is-selected', false, 200); |
|
} |
|
|
|
return true; |
|
} |
|
|
|
protected getCheckboxInputFromElement(element: HTMLElement): HTMLInputElement { |
|
return element.firstElementChild?.tagName === 'LABEL' && |
|
element.firstElementChild.firstElementChild as HTMLInputElement; |
|
} |
|
|
|
protected async 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) { |
|
const storageKey: MessagesStorageKey = `${peerId}_${this.isScheduled ? 'scheduled' : 'history'}`; |
|
const r = await this.managers.appMessagesManager.cantForwardDeleteMids(storageKey, Array.from(mids)); |
|
cantForward = r.cantForward; |
|
cantDelete = r.cantDelete; |
|
|
|
if(cantForward && cantDelete) break; |
|
} |
|
|
|
this.onUpdateContainer && this.onUpdateContainer(cantForward, cantDelete, cantSend); |
|
} |
|
|
|
public toggleSelection(toggleCheckboxes = true, forceSelection = false) { |
|
const wasSelecting = this.isSelecting; |
|
const size = this.selectedMids.size; |
|
this.isSelecting = !!size || forceSelection; |
|
|
|
if(wasSelecting === this.isSelecting) return false; |
|
|
|
this.dispatchEvent('toggle', this.isSelecting); |
|
|
|
// const bubblesContainer = this.bubbles.bubblesContainer; |
|
//bubblesContainer.classList.toggle('is-selecting', !!size); |
|
|
|
/* if(bubblesContainer.classList.contains('is-chat-input-hidden')) { |
|
const scrollable = this.appImManager.scrollable; |
|
if(scrollable.isScrolledDown) { |
|
scrollable.scrollTo(scrollable.scrollHeight, 'top', true, true, 200); |
|
} |
|
} */ |
|
|
|
if(!IS_TOUCH_SUPPORTED) { |
|
this.listenElement.classList.toggle('no-select', this.isSelecting); |
|
|
|
if(wasSelecting) { |
|
// ! CANCEL USER SELECTION ! |
|
cancelSelection(); |
|
} |
|
}/* else { |
|
if(!wasSelecting) { |
|
bubblesContainer.classList.add('no-select'); |
|
setTimeout(() => { |
|
cancelSelection(); |
|
bubblesContainer.classList.remove('no-select'); |
|
cancelSelection(); |
|
}, 100); |
|
} |
|
} */ |
|
|
|
blurActiveElement(); |
|
|
|
const forwards = !!size || forceSelection; |
|
this.onToggleSelection && this.onToggleSelection(forwards, !this.doNotAnimate); |
|
|
|
if(!IS_MOBILE_SAFARI) { |
|
if(forwards) { |
|
appNavigationController.pushItem({ |
|
type: this.navigationType, |
|
onPop: () => { |
|
this.cancelSelection(); |
|
} |
|
}); |
|
} else { |
|
appNavigationController.removeByType(this.navigationType); |
|
} |
|
} |
|
|
|
if(forceSelection) { |
|
this.updateContainer(forceSelection); |
|
} |
|
|
|
return true; |
|
} |
|
|
|
public cancelSelection = async(doNotAnimate?: boolean) => { |
|
if(doNotAnimate) this.doNotAnimate = true; |
|
this.onCancelSelection && await this.onCancelSelection(); |
|
this.selectedMids.clear(); |
|
this.toggleSelection(); |
|
cancelSelection(); |
|
if(doNotAnimate) this.doNotAnimate = undefined; |
|
}; |
|
|
|
public cleanup() { |
|
this.doNotAnimate = true; |
|
this.selectedMids.clear(); |
|
this.toggleSelection(false); |
|
this.doNotAnimate = undefined; |
|
} |
|
|
|
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: PeerId, mid: number) { |
|
const set = this.selectedMids.get(peerId); |
|
return set?.has(mid); |
|
} |
|
|
|
public length() { |
|
return accumulateMapSet(this.selectedMids); |
|
} |
|
|
|
protected toggleMid(peerId: PeerId, 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: PeerId, 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, managers: AppManagers) { |
|
super({ |
|
managers, |
|
verifyTarget: (e, target) => !!target && this.isSelecting, |
|
getElementFromTarget: (target) => findUpClassName(target, 'search-super-item'), |
|
targetLookupClassName: 'search-super-item', |
|
lookupBetweenParentClassName: 'tabs-tab', |
|
lookupBetweenElementsQuery: '.search-super-item' |
|
}); |
|
|
|
this.isPrivate = !searchSuper.showSender; |
|
this.attachListeners(searchSuper.container, new ListenerSetter()); |
|
} |
|
|
|
/* 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.toPeerId(); |
|
|
|
if(!this.toggleMid(peerId, mid)) { |
|
return; |
|
} |
|
|
|
this.updateElementSelection(element, this.isMidSelected(peerId, mid)); |
|
}; |
|
|
|
public toggleByMid = (peerId: PeerId, mid: number) => { |
|
const element = this.searchSuper.mediaTab.contentTab.querySelector(`.search-super-item[data-peer-id="${peerId}"][data-mid="${mid}"]`) as HTMLElement; |
|
this.toggleByElement(element); |
|
}; |
|
|
|
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, animate: boolean) => { |
|
SetTransition(this.searchSuper.navScrollableContainer, 'is-selecting', forwards, animate ? 200 : 0, () => { |
|
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(); |
|
|
|
appImManager.setInnerPeer({peerId, lastMsgId: mid}); |
|
}, attachClickOptions); |
|
|
|
this.selectionForwardBtn = ButtonIcon(`forward ${BASE_CLASS}-forward`); |
|
attachClickEvent(this.selectionForwardBtn, () => { |
|
const obj: {[fromPeerId: PeerId]: number[]} = {}; |
|
for(const [fromPeerId, mids] of this.selectedMids) { |
|
obj[fromPeerId] = Array.from(mids).sort((a, b) => a - b); |
|
} |
|
|
|
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; |
|
private selectionLeft: HTMLDivElement; |
|
private selectionRight: HTMLDivElement; |
|
|
|
constructor( |
|
private chat: Chat, |
|
private bubbles: ChatBubbles, |
|
private input: ChatInput, |
|
managers: AppManagers |
|
) { |
|
super({ |
|
managers, |
|
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; |
|
}, |
|
verifyTouchLongPress: () => !this.chat.input.recording, |
|
targetLookupClassName: 'bubble', |
|
lookupBetweenParentClassName: 'bubbles-inner', |
|
lookupBetweenElementsQuery: '.bubble:not(.is-multiple-documents), .grouped-item', |
|
isScheduled: chat.type === 'scheduled' |
|
}); |
|
} |
|
|
|
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) { |
|
if(this.bubbles.skippedMids.has(+mid)) { |
|
continue; |
|
} |
|
|
|
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): Promise<void> => { |
|
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.chat.peerId); |
|
if(set) { |
|
// const mids = await this.chat.getMidsByMid(mid); |
|
const mids = this.getMidsFromGroupContainer(bubble); |
|
mids.forEach((mid) => set.delete(mid)); |
|
} |
|
} |
|
|
|
/* const promises = */this.bubbles.getBubbleGroupedItems(bubble).map(this.toggleByElement); |
|
// await Promise.all(promises); |
|
return; |
|
} |
|
|
|
if(!this.toggleMid(this.chat.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(groupContainer); |
|
|
|
const willChange = isGroupedMidsSelected || isGroupedSelected; |
|
if(willChange) { |
|
this.updateElementSelection(groupContainer, isGroupedMidsSelected); |
|
} |
|
} |
|
|
|
this.updateElementSelection(bubble, this.isMidSelected(this.chat.peerId, mid)); |
|
}; |
|
|
|
protected toggleByMid = async(peerId: PeerId, mid: number) => { |
|
const mounted = await 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)); |
|
} |
|
|
|
protected isGroupedBubbleSelected(bubble: HTMLElement) { |
|
const groupedCheckboxInput = this.getCheckboxInputFromElement(bubble); |
|
return groupedCheckboxInput?.checked; |
|
} |
|
|
|
protected getMidsFromGroupContainer(groupContainer: HTMLElement) { |
|
const elements = this.chat.bubbles.getBubbleGroupedItems(groupContainer); |
|
if(!elements.length) { |
|
elements.push(groupContainer); |
|
} |
|
|
|
return elements.map((element) => +element.dataset.mid); |
|
} |
|
|
|
protected isGroupedMidsSelected(groupContainer: HTMLElement) { |
|
const mids = this.getMidsFromGroupContainer(groupContainer); |
|
const selectedMids = mids.filter((mid) => this.isMidSelected(this.chat.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-outgoing') && |
|
!bubble.classList.contains('bubble-first') && |
|
!bubble.classList.contains('avoid-selection'); |
|
} |
|
|
|
protected onToggleSelection = async(forwards: boolean, animate: boolean) => { |
|
const {needTranslateX, widthFrom, widthTo} = await this.chat.input.center(animate); |
|
|
|
SetTransition(this.listenElement, 'is-selecting', forwards, animate ? 200 : 0, () => { |
|
if(!this.isSelecting) { |
|
this.selectionInputWrapper.remove(); |
|
this.selectionInputWrapper = |
|
this.selectionContainer = |
|
this.selectionSendNowBtn = |
|
this.selectionForwardBtn = |
|
this.selectionDeleteBtn = |
|
this.selectionLeft = |
|
this.selectionRight = |
|
null; |
|
this.selectedText = undefined; |
|
} |
|
|
|
/* fastRaf(() => { |
|
this.bubbles.onScroll(); |
|
}); */ |
|
}); |
|
|
|
//const chatInput = this.appImManager.chatInput; |
|
|
|
const translateButtonsX = widthFrom < widthTo ? undefined : needTranslateX * 2; |
|
if(this.isSelecting) { |
|
if(!this.selectionContainer) { |
|
this.selectionInputWrapper = document.createElement('div'); |
|
this.selectionInputWrapper.classList.add('chat-input-wrapper', 'selection-wrapper'); |
|
|
|
// const background = document.createElement('div'); |
|
// background.classList.add('chat-input-wrapper-background'); |
|
|
|
this.selectionContainer = document.createElement('div'); |
|
this.selectionContainer.classList.add('selection-container'); |
|
|
|
const attachClickOptions: AttachClickOptions = {listenerSetter: this.listenerSetter}; |
|
const btnCancel = ButtonIcon('close', {noRipple: true}); |
|
attachClickEvent(btnCancel, () => this.cancelSelection(), {once: true, listenerSetter: this.listenerSetter}); |
|
|
|
this.selectionCountEl = document.createElement('div'); |
|
this.selectionCountEl.classList.add('selection-container-count'); |
|
|
|
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')); |
|
attachClickEvent(this.selectionSendNowBtn, () => { |
|
new PopupSendNow(this.chat.peerId, [...this.selectedMids.get(this.chat.peerId)], () => { |
|
this.cancelSelection(); |
|
}); |
|
}, attachClickOptions); |
|
} else { |
|
this.selectionForwardBtn = Button('btn-primary btn-transparent text-bold selection-container-forward', {icon: 'forward'}); |
|
this.selectionForwardBtn.append(i18n('Forward')); |
|
attachClickEvent(this.selectionForwardBtn, () => { |
|
const obj: {[fromPeerId: PeerId]: number[]} = {}; |
|
for(const [fromPeerId, mids] of this.selectedMids) { |
|
obj[fromPeerId] = Array.from(mids).sort((a, b) => a - b); |
|
} |
|
|
|
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')); |
|
attachClickEvent(this.selectionDeleteBtn, () => { |
|
new PopupDeleteMessages(this.chat.peerId, [...this.selectedMids.get(this.chat.peerId)], this.chat.type, () => { |
|
this.cancelSelection(); |
|
}); |
|
}, attachClickOptions); |
|
|
|
const left = this.selectionLeft = document.createElement('div'); |
|
left.classList.add('selection-container-left'); |
|
left.append(btnCancel, this.selectionCountEl); |
|
|
|
const right = this.selectionRight = document.createElement('div'); |
|
right.classList.add('selection-container-right'); |
|
right.append(...[ |
|
this.selectionSendNowBtn, |
|
this.selectionForwardBtn, |
|
this.selectionDeleteBtn |
|
].filter(Boolean)) |
|
|
|
if(translateButtonsX !== undefined) { |
|
left.style.transform = `translateX(${-translateButtonsX}px)`; |
|
right.style.transform = `translateX(${translateButtonsX}px)`; |
|
} |
|
|
|
this.selectionContainer.append(left, right); |
|
|
|
// background.style.opacity = '0'; |
|
this.selectionInputWrapper.style.opacity = '0'; |
|
this.selectionInputWrapper.append(/* background, */this.selectionContainer); |
|
this.input.inputContainer.append(this.selectionInputWrapper); |
|
|
|
void this.selectionInputWrapper.offsetLeft; // reflow |
|
// background.style.opacity = ''; |
|
this.selectionInputWrapper.style.opacity = ''; |
|
left.style.transform = ''; |
|
right.style.transform = ''; |
|
} |
|
} else if(this.selectionLeft && translateButtonsX !== undefined) { |
|
this.selectionLeft.style.transform = `translateX(-${translateButtonsX}px)`; |
|
this.selectionRight.style.transform = `translateX(${translateButtonsX}px)`; |
|
} |
|
}; |
|
|
|
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); |
|
}; |
|
|
|
protected onCancelSelection = async() => { |
|
return; |
|
const promises: Promise<HTMLElement>[] = []; |
|
for(const [peerId, mids] of this.selectedMids) { |
|
for(const mid of mids) { |
|
promises.push(this.bubbles.getMountedBubble(mid).then((m) => m?.bubble)); |
|
} |
|
} |
|
|
|
const bubbles = filterUnique((await Promise.all(promises)).filter(Boolean)); |
|
bubbles.forEach((bubble) => { |
|
this.toggleByElement(bubble); |
|
}); |
|
}; |
|
}
|
|
|