tweb/src/components/chat/bubbles.ts

5464 lines
190 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* https://github.com/morethanwords/tweb
* Copyright (C) 2019-2021 Eduard Kuzmenko
* https://github.com/morethanwords/tweb/blob/master/LICENSE
*/
import type { AppImManager, ChatSavedPosition } from "../../lib/appManagers/appImManager";
import type { HistoryResult, MyMessage } from "../../lib/appManagers/appMessagesManager";
import type { MyDocument } from "../../lib/appManagers/appDocsManager";
import type Chat from "./chat";
import { CHAT_ANIMATION_GROUP } from "../../lib/appManagers/appImManager";
import IS_TOUCH_SUPPORTED from "../../environment/touchSupport";
import { logger } from "../../lib/logger";
import rootScope from "../../lib/rootScope";
import BubbleGroups from "./bubbleGroups";
import PopupDatePicker from "../popups/datePicker";
import PopupForward from "../popups/forward";
import PopupStickers from "../popups/stickers";
import ProgressivePreloader from "../preloader";
import Scrollable, { SliceSides } from "../scrollable";
import StickyIntersector from "../stickyIntersector";
import animationIntersector from "../animationIntersector";
import mediaSizes from "../../helpers/mediaSizes";
import { IS_ANDROID, IS_APPLE, IS_MOBILE, IS_SAFARI } from "../../environment/userAgent";
import I18n, { FormatterArguments, i18n, langPack, LangPackKey, UNSUPPORTED_LANG_PACK_KEY, _i18n } from "../../lib/langPack";
import AvatarElement from "../avatar";
import ripple from "../ripple";
import { wrapAlbum, wrapPhoto, wrapVideo, wrapDocument, wrapSticker, wrapPoll, wrapGroupedDocuments } from "../wrappers";
import { MessageRender } from "./messageRender";
import LazyLoadQueue from "../lazyLoadQueue";
import ListenerSetter from "../../helpers/listenerSetter";
import PollElement from "../poll";
import AudioElement from "../audio";
import { ChatInvite, Document, Message, MessageEntity, MessageMedia, MessageReplyHeader, Photo, PhotoSize, ReactionCount, ReplyMarkup, SponsoredMessage, Update, User, WebPage } from "../../layer";
import { NULL_PEER_ID, REPLIES_PEER_ID } from "../../lib/mtproto/mtproto_config";
import { FocusDirection, ScrollStartCallbackDimensions } from "../../helpers/fastSmoothScroll";
import useHeavyAnimationCheck, { getHeavyAnimationPromise, dispatchHeavyAnimationEvent, interruptHeavyAnimation } from "../../hooks/useHeavyAnimationCheck";
import { fastRaf, fastRafPromise } from "../../helpers/schedulers";
import deferredPromise from "../../helpers/cancellablePromise";
import RepliesElement from "./replies";
import DEBUG from "../../config/debug";
import { SliceEnd } from "../../helpers/slicedArray";
import PeerTitle from "../peerTitle";
import findUpClassName from "../../helpers/dom/findUpClassName";
import findUpTag from "../../helpers/dom/findUpTag";
import { toast, toastNew } from "../toast";
import { getElementByPoint } from "../../helpers/dom/getElementByPoint";
import { getMiddleware } from "../../helpers/middleware";
import cancelEvent from "../../helpers/dom/cancelEvent";
import { attachClickEvent, simulateClickEvent } from "../../helpers/dom/clickEvent";
import htmlToDocumentFragment from "../../helpers/dom/htmlToDocumentFragment";
import positionElementByIndex from "../../helpers/dom/positionElementByIndex";
import reflowScrollableElement from "../../helpers/dom/reflowScrollableElement";
import replaceContent from "../../helpers/dom/replaceContent";
import setInnerHTML from "../../helpers/dom/setInnerHTML";
import whichChild from "../../helpers/dom/whichChild";
import { cancelAnimationByKey } from "../../helpers/animation";
import assumeType from "../../helpers/assumeType";
import debounce, { DebounceReturnType } from "../../helpers/schedulers/debounce";
import { SEND_WHEN_ONLINE_TIMESTAMP } from "../../lib/mtproto/constants";
import windowSize from "../../helpers/windowSize";
import { formatPhoneNumber } from "../../helpers/formatPhoneNumber";
import AppMediaViewer from "../appMediaViewer";
import SetTransition from "../singleTransition";
import handleHorizontalSwipe from "../../helpers/dom/handleHorizontalSwipe";
import findUpAttribute from "../../helpers/dom/findUpAttribute";
import findUpAsChild from "../../helpers/dom/findUpAsChild";
import formatCallDuration from "../../helpers/formatCallDuration";
import IS_CALL_SUPPORTED from "../../environment/callSupport";
import Button from "../button";
import { CallType } from "../../lib/calls/types";
import getVisibleRect from "../../helpers/dom/getVisibleRect";
import PopupJoinChatInvite from "../popups/joinChatInvite";
import { InternalLink, INTERNAL_LINK_TYPE } from "../../lib/appManagers/internalLink";
import ReactionsElement, { REACTIONS_ELEMENTS } from "./reactions";
import type ReactionElement from "./reaction";
import RLottiePlayer from "../../lib/rlottie/rlottiePlayer";
import pause from "../../helpers/schedulers/pause";
import ScrollSaver from "../../helpers/scrollSaver";
import getObjectKeysAndSort from "../../helpers/object/getObjectKeysAndSort";
import forEachReverse from "../../helpers/array/forEachReverse";
import formatNumber from "../../helpers/number/formatNumber";
import getViewportSlice from "../../helpers/dom/getViewportSlice";
import SuperIntersectionObserver from "../../helpers/dom/superIntersectionObserver";
import generateFakeIcon from "../generateFakeIcon";
import copyFromElement from "../../helpers/dom/copyFromElement";
import PopupElement from "../popups";
import setAttachmentSize from "../../helpers/setAttachmentSize";
import wrapWebPageDescription from "../wrappers/webPageDescription";
import wrapWebPageTitle from "../wrappers/webPageTitle";
import wrapEmojiText from "../../lib/richTextProcessor/wrapEmojiText";
import wrapRichText from "../../lib/richTextProcessor/wrapRichText";
import wrapMessageActionTextNew from "../wrappers/messageActionTextNew";
import isMentionUnread from "../../lib/appManagers/utils/messages/isMentionUnread";
import getMediaFromMessage from "../../lib/appManagers/utils/messages/getMediaFromMessage";
import getPeerColorById from "../../lib/appManagers/utils/peers/getPeerColorById";
import getPeerId from "../../lib/appManagers/utils/peers/getPeerId";
import getServerMessageId from "../../lib/appManagers/utils/messageId/getServerMessageId";
import generateMessageId from "../../lib/appManagers/utils/messageId/generateMessageId";
import { AppManagers } from "../../lib/appManagers/managers";
import filterAsync from "../../helpers/array/filterAsync";
import { Awaited } from "../../types";
import idleController from "../../helpers/idleController";
import overlayCounter from "../../helpers/overlayCounter";
import { cancelContextMenuOpening } from "../../helpers/dom/attachContextMenuListener";
import contextMenuController from "../../helpers/contextMenuController";
import { AckedResult } from "../../lib/mtproto/superMessagePort";
import middlewarePromise from "../../helpers/middlewarePromise";
import findAndSplice from "../../helpers/array/findAndSplice";
import { EmoticonsDropdown } from "../emoticonsDropdown";
import indexOfAndSplice from "../../helpers/array/indexOfAndSplice";
import noop from "../../helpers/noop";
const USE_MEDIA_TAILS = false;
const IGNORE_ACTIONS: Set<Message.messageService['action']['_']> = new Set([
'messageActionHistoryClear',
'messageActionChatCreate'/* ,
'messageActionChannelMigrateFrom' */
]);
export const SERVICE_AS_REGULAR: Set<Message.messageService['action']['_']> = new Set();
if(IS_CALL_SUPPORTED) {
SERVICE_AS_REGULAR.add('messageActionPhoneCall');
}
const TEST_SCROLL_TIMES: number = undefined;
let TEST_SCROLL = TEST_SCROLL_TIMES;
let queueId = 0;
type GenerateLocalMessageType<IsService> = IsService extends true ? Message.messageService : Message.message;
const SPONSORED_MESSAGE_ID_OFFSET = 1;
export const STICKY_OFFSET = 3;
const SCROLLED_DOWN_THRESHOLD = 300;
const PEER_CHANGED_ERROR = new Error('peer changed');
const DO_NOT_SLICE_VIEWPORT = false;
const DO_NOT_SLICE_VIEWPORT_ON_RENDER = false;
const DO_NOT_UPDATE_MESSAGE_VIEWS = false;
const DO_NOT_UPDATE_MESSAGE_REACTIONS = false;
const DO_NOT_UPDATE_MESSAGE_REPLY = false;
type Bubble = {
bubble: HTMLElement,
mids: Set<number>,
groupedId?: string
};
type MyHistoryResult = HistoryResult | {history: number[]};
function getMainMidForGrouped(mids: number[]) {
return Math.max(...mids);
}
export default class ChatBubbles {
public container: HTMLDivElement;
private chatInner: HTMLDivElement;
public scrollable: Scrollable;
private getHistoryTopPromise: Promise<boolean>;
private getHistoryBottomPromise: Promise<boolean>;
//public messagesCount: number = -1;
private unreadOut = new Set<number>();
public needUpdate: {replyToPeerId: PeerId, replyMid: number, mid: number}[] = []; // if need wrapSingleMessage
public bubbles: {[mid: string]: HTMLElement} = {};
public skippedMids: Set<number> = new Set();
public bubblesNewByGroupedId: {[groupId: string]: Bubble} = {};
public bubblesNew: {[mid: string]: Bubble} = {};
private dateMessages: {[timestamp: number]: {
div: HTMLElement,
firstTimestamp: number,
container: HTMLElement,
timeout?: number
}} = {};
private scrolledDown = true;
private isScrollingTimeout = 0;
private stickyIntersector: StickyIntersector;
private unreaded: Map<HTMLElement, number> = new Map();
private unreadedSeen: Set<number> = new Set();
private readPromise: Promise<void>;
private bubbleGroups: BubbleGroups;
private preloader: ProgressivePreloader = null;
public messagesQueuePromise: Promise<void> = null;
private messagesQueue: ReturnType<ChatBubbles['safeRenderMessage']>[] = [];
// private messagesQueueOnRender: () => void = null;
private messagesQueueOnRenderAdditional: () => void = null;
private firstUnreadBubble: HTMLElement = null;
private attachedUnreadBubble: boolean;
public lazyLoadQueue: LazyLoadQueue;
private middleware = getMiddleware();
private log: ReturnType<typeof logger>;
public listenerSetter: ListenerSetter;
private replyFollowHistory: number[] = [];
private isHeavyAnimationInProgress = false;
private scrollingToBubble: HTMLElement;
private isFirstLoad = true;
private needReflowScroll: boolean;
private fetchNewPromise: Promise<void>;
private passEntities: Partial<{
[_ in MessageEntity['_']]: boolean
}> = {};
private onAnimateLadder: () => Promise<any> | void;
// private ladderDeferred: CancellablePromise<void>;
private resolveLadderAnimation: () => Promise<any>;
private emptyPlaceholderBubble: HTMLElement;
private viewsMids: Set<number> = new Set();
private sendViewCountersDebounced: () => Promise<void>;
private isTopPaddingSet = false;
private getSponsoredMessagePromise: Promise<void>;
private previousStickyDate: HTMLElement;
private sponsoredMessage: SponsoredMessage.sponsoredMessage;
private hoverBubble: HTMLElement;
private hoverReaction: HTMLElement;
private sliceViewportDebounced: DebounceReturnType<ChatBubbles['sliceViewport']>;
private resizeObserver: ResizeObserver;
private willScrollOnLoad: boolean;
private observer: SuperIntersectionObserver;
private renderingMessages: Set<number> = new Set();
private setPeerCached: boolean;
private attachPlaceholderOnRender: () => void;
private bubblesToEject: Set<HTMLElement> = new Set();
private updatePlaceholderPosition: () => void;
private setPeerOptions: {lastMsgId: number; topMessage: number;};
// private reactions: Map<number, ReactionsElement>;
constructor(
private chat: Chat,
private managers: AppManagers
) {
this.log = this.chat.log;
//this.chat.log.error('Bubbles construction');
this.listenerSetter = new ListenerSetter();
this.constructBubbles();
// * constructor end
this.bubbleGroups = new BubbleGroups(this.chat);
this.preloader = new ProgressivePreloader({
cancelable: false
});
this.lazyLoadQueue = new LazyLoadQueue();
this.lazyLoadQueue.queueId = ++queueId;
// this.reactions = new Map();
// * events
// will call when sent for update pos
this.listenerSetter.add(rootScope)('history_update', ({storageKey, peerId, mid, message, sequential}) => {
if(this.chat.messagesStorageKey !== storageKey) {
return;
}
const bubble = this.bubbles[mid];
if(!bubble) return;
const item = this.bubbleGroups.getItemByBubble(bubble);
if(!item) return; // probably a group item
const unmountIfFound = !sequential || this.bubbleGroups.getLastGroup() !== item.group;
if(unmountIfFound) {
const groupIndex = this.bubbleGroups.groups.indexOf(item.group);
this.bubbleGroups.removeAndUnmountBubble(bubble);
if(!item.group.items.length) { // group has collapsed, next message can have higher mid so have to reposition them too
const nearbyGroups = this.bubbleGroups.groups.slice(0, groupIndex + 1);
for(let length = nearbyGroups.length, i = length - 2; i >= 0; --i) {
const group = nearbyGroups[i];
const groupItems = group.items;
const nextGroup = nearbyGroups[i + 1];
const nextGroupItems = nextGroup.items;
let _break = false, moved = false;
for(let j = groupItems.length - 1; j >= 0; --j) {
const groupItem = groupItems[j];
const possibleIndex = this.bubbleGroups.findIndexForItemInItems(groupItem, nextGroupItems);
if(possibleIndex === -1) {
_break = true;
break;
}
this.bubbleGroups.removeAndUnmountBubble(groupItem.bubble);
this.bubbleGroups.addItemToGroup(groupItem, nextGroup);
moved = true;
}
if(moved) {
nextGroup.mount();
}
if(_break) {
break;
}
}
}
const {group} = this.setBubblePosition(bubble, message, unmountIfFound);
group.mount();
if(this.scrollingToBubble) {
this.scrollToEnd();
}
}/* else {
this.bubbleGroups.changeBubbleMid(bubble, mid);
} */
});
this.listenerSetter.add(rootScope)('dialog_flush', ({peerId}) => {
if(this.peerId === peerId) {
this.deleteMessagesByIds(Object.keys(this.bubbles).map((m) => +m));
}
});
// Calls when message successfully sent and we have an id
this.listenerSetter.add(rootScope)('message_sent', async(e) => {
const {storageKey, tempId, tempMessage, mid, message} = e;
// ! can't use peerId to validate here, because id can be the same in 'scheduled' and 'chat' types
if(this.chat.messagesStorageKey !== storageKey) {
return;
}
const bubbles = this.bubbles;
const _bubble = bubbles[tempId];
if(_bubble) {
const bubble = bubbles[tempId];
bubbles[mid] = bubble;
delete bubbles[tempId];
fastRaf(() => {
const mid = +bubble.dataset.mid;
if(bubbles[mid] === bubble && bubble.classList.contains('is-outgoing')) {
bubble.classList.remove('is-sending', 'is-outgoing');
bubble.classList.add((this.peerId === rootScope.myId && this.chat.type !== 'scheduled') || !this.unreadOut.has(mid) ? 'is-read' : 'is-sent');
}
});
bubble.dataset.mid = '' + mid;
}
if(this.unreadOut.has(tempId)) {
this.unreadOut.delete(tempId);
this.unreadOut.add(mid);
}
// * check timing of scheduled message
if(this.chat.type === 'scheduled') {
const timestamp = Date.now() / 1000 | 0;
const maxTimestamp = tempMessage.date - 10;
if(timestamp >= maxTimestamp) {
this.deleteMessagesByIds([mid]);
}
}
let messages: (Message.message | Message.messageService)[], tempIds: number[];
const groupedId = (message as Message.message).grouped_id;
if(groupedId) {
const mids = await this.managers.appMessagesManager.getMidsByMessage(message);
if(!mids.length || getMainMidForGrouped(mids) !== mid || bubbles[mid] !== _bubble) {
return;
}
messages = await Promise.all(mids.map((mid) => this.chat.getMessage(mid)));
if(bubbles[mid] !== _bubble) {
return;
}
tempIds = (Array.from(_bubble.querySelectorAll('.grouped-item')) as HTMLElement[]).map((el) => +el.dataset.mid);
} else {
messages = [message];
tempIds = [tempId];
}
const reactionsElements = Array.from(_bubble.querySelectorAll('reactions-element')) as ReactionsElement[];
if(reactionsElements.length) {
reactionsElements.forEach((reactionsElement) => {
reactionsElement.changeMessage(message as Message.message);
});
}
(messages as Message.message[]).forEach((message, idx) => {
if(!message) {
return;
}
const tempId = tempIds[idx];
const mid = message.mid;
const bubble: HTMLElement = _bubble.querySelector(`.document-container[data-mid="${mid}"]`) || _bubble;
if(message._ !== 'message') {
return;
}
if(message.replies) {
const repliesElement = _bubble.querySelector('replies-element') as RepliesElement;
if(repliesElement) {
repliesElement.message = message;
repliesElement.init();
}
}
const media = message.media ?? {} as MessageMedia.messageMediaEmpty;
const doc = (media as MessageMedia.messageMediaDocument).document as Document.document;
const poll = (media as MessageMedia.messageMediaPoll).poll;
const webPage = (media as MessageMedia.messageMediaWebPage).webpage as WebPage.webPage;
if(doc) {
const div = bubble.querySelector(`.document-container[data-mid="${tempId}"] .document`);
if(div) {
const container = findUpClassName(div, 'document-container');
if(!tempMessage.media?.document?.thumbs?.length && doc.thumbs?.length) {
getHeavyAnimationPromise().then(async() => {
const timeSpan = div.querySelector('.time');
const newDiv = await wrapDocument({message});
div.replaceWith(newDiv);
if(timeSpan) {
newDiv.querySelector('.document-size').append(timeSpan);
}
});
}
if(container) {
container.dataset.mid = '' + mid;
}
}
const element = bubble.querySelector(`audio-element[data-mid="${tempId}"], .document[data-doc-id="${tempId}"], .media-round[data-mid="${tempId}"]`) as HTMLElement;
if(element) {
if(element instanceof AudioElement || element.classList.contains('media-round')) {
element.dataset.mid = '' + message.mid;
delete element.dataset.isOutgoing;
(element as any).message = message;
(element as any).onLoad(true);
} else {
element.dataset.docId = '' + doc.id;
}
}
} else if(poll) {
const pollElement = bubble.querySelector('poll-element') as PollElement;
if(pollElement) {
pollElement.message = message;
pollElement.setAttribute('poll-id', '' + poll.id);
pollElement.setAttribute('message-id', '' + mid);
}
} else if(webPage && !bubble.querySelector('.web')) {
getHeavyAnimationPromise().then(() => {
this.safeRenderMessage(message, true, bubble);
this.scrollToBubbleIfLast(bubble);
});
}
// set new mids to album items for mediaViewer
if(groupedId) {
const item = (bubble.querySelector(`.grouped-item[data-mid="${tempId}"]`) as HTMLElement) || bubble; // * it can be .document-container
if(item) {
item.dataset.mid = '' + mid;
}
}
});
});
this.listenerSetter.add(rootScope)('message_edit', ({storageKey, message}) => {
if(storageKey !== this.chat.messagesStorageKey) return;
const bubble = this.bubbles[message.mid];
if(!bubble) return;
this.safeRenderMessage(message, true, bubble);
});
this.listenerSetter.add(rootScope)('album_edit', ({peerId, messages, deletedMids}) => {
if(peerId !== this.peerId) return;
const mids = messages.map(({mid}) => mid);
const oldMids = mids.concat(Array.from(deletedMids));
const wasMainMid = getMainMidForGrouped(oldMids);
const bubble = this.bubbles[wasMainMid];
if(!bubble) {
return;
}
const mainMid = getMainMidForGrouped(mids);
const message = messages.find((message) => message.mid === mainMid);
this.safeRenderMessage(message, true, bubble);
});
if(this.chat.type !== 'scheduled' && !DO_NOT_UPDATE_MESSAGE_REACTIONS/* && false */) {
this.listenerSetter.add(rootScope)('messages_reactions', async(arr) => {
let scrollSaver: ScrollSaver;
const a = arr.map(async({message, changedResults}) => {
if(this.peerId !== message.peerId) {
return;
}
const result = await this.getMountedBubble(message.mid, message);
if(!result) {
return;
}
return {bubble: result.bubble, message, changedResults};
});
let top: number;
(await Promise.all(a)).filter(Boolean).forEach(({bubble, message, changedResults}) => {
if(!scrollSaver) {
scrollSaver = this.createScrollSaver(false);
scrollSaver.save();
}
const key = message.peerId + '_' + message.mid;
const set = REACTIONS_ELEMENTS.get(key);
if(set) {
for(const element of set) {
element.update(message, changedResults);
}
} else if(!message.reactions || !message.reactions.results.length) {
return;
} else {
this.appendReactionsElementToBubble(bubble, message, message, changedResults);
}
});
if(scrollSaver) {
scrollSaver.restore();
}
});
}
!DO_NOT_UPDATE_MESSAGE_REPLY && this.listenerSetter.add(rootScope)('messages_downloaded', async({peerId, mids}) => {
const middleware = this.getMiddleware();
await getHeavyAnimationPromise();
if(!middleware()) return;
(mids as number[]).forEach((mid) => {
const needUpdate = this.needUpdate;
const filtered: typeof needUpdate[0][] = [];
forEachReverse(this.needUpdate, (obj, idx) => {
if(obj.replyMid === mid && obj.replyToPeerId === peerId) {
this.needUpdate.splice(idx, 1)[0];
filtered.push(obj);
}
});
filtered.forEach(async({mid, replyMid, replyToPeerId}) => {
const bubble = this.bubbles[mid];
if(!bubble) return;
const message = (await this.chat.getMessage(mid)) as Message.message;
MessageRender.setReply({
chat: this.chat,
bubble,
message
});
});
});
});
attachClickEvent(this.scrollable.container, this.onBubblesClick, {listenerSetter: this.listenerSetter});
// this.listenerSetter.add(this.bubblesContainer)('click', this.onBubblesClick/* , {capture: true, passive: false} */);
this.listenerSetter.add(this.scrollable.container)('mousedown', (e) => {
if(e.button !== 0) return;
const code: HTMLElement = findUpTag(e.target, 'CODE');
if(code) {
cancelEvent(e);
copyFromElement(code);
toastNew({langPackKey: 'TextCopied'});
return;
}
});
/* if(false) */this.stickyIntersector = new StickyIntersector(this.scrollable.container, (stuck, target) => {
for(const timestamp in this.dateMessages) {
const dateMessage = this.dateMessages[timestamp];
if(dateMessage.container === target) {
const dateBubble = dateMessage.div;
// dateMessage.container.classList.add('has-sticky-dates');
// SetTransition(dateBubble, 'kek', stuck, this.previousStickyDate ? 300 : 0);
// if(this.previousStickyDate) {
// dateBubble.classList.add('kek');
// }
dateBubble.classList.toggle('is-sticky', stuck);
if(stuck) {
this.previousStickyDate = dateBubble;
}
break;
}
}
if(this.previousStickyDate) {
// fastRaf(() => {
// this.bubblesContainer.classList.add('has-sticky-dates');
// });
}
});
if(!IS_SAFARI) {
this.sliceViewportDebounced = debounce(this.sliceViewport.bind(this), 3000, false, true);
}
let middleware: ReturnType<ChatBubbles['getMiddleware']>;
useHeavyAnimationCheck(() => {
this.isHeavyAnimationInProgress = true;
this.lazyLoadQueue.lock();
middleware = this.getMiddleware();
// if(this.sliceViewportDebounced) {
// this.sliceViewportDebounced.clearTimeout();
// }
}, () => {
this.isHeavyAnimationInProgress = false;
if(middleware && middleware()) {
this.lazyLoadQueue.unlock();
this.lazyLoadQueue.refresh();
// if(this.sliceViewportDebounced) {
// this.sliceViewportDebounced();
// }
}
middleware = null;
}, this.listenerSetter);
}
private constructBubbles() {
const container = this.container = document.createElement('div');
container.classList.add('bubbles', 'scrolled-down');
const chatInner = this.chatInner = document.createElement('div');
chatInner.classList.add('bubbles-inner');
this.setScroll();
container.append(this.scrollable.container);
}
public attachContainerListeners() {
const container = this.container;
this.chat.contextMenu.attachTo(container);
this.chat.selection.attachListeners(container, new ListenerSetter());
if(DEBUG) {
this.listenerSetter.add(container)('dblclick', async(e) => {
const bubble = findUpClassName(e.target, 'grouped-item') || findUpClassName(e.target, 'bubble');
if(bubble) {
const mid = +bubble.dataset.mid
this.log('debug message:', await this.chat.getMessage(mid));
this.highlightBubble(bubble);
}
});
}
if(!IS_MOBILE && this.chat.type !== 'pinned') {
this.listenerSetter.add(container)('dblclick', async(e) => {
if(this.chat.selection.isSelecting ||
!(await this.chat.canSend())) {
return;
}
const target = e.target as HTMLElement;
const bubble = target.classList.contains('bubble') ?
target :
(target.classList.contains('document-selection') ? target.parentElement : null);
if(bubble && !bubble.classList.contains('bubble-first')) {
const mid = +bubble.dataset.mid;
const message = await this.chat.getMessage(mid);
if(message.pFlags.is_outgoing) {
return;
}
this.chat.input.initMessageReply(mid);
}
});
}
if(IS_TOUCH_SUPPORTED) {
const className = 'is-gesturing-reply';
const MAX = 64;
const replyAfter = MAX * .75;
let shouldReply = false;
let target: HTMLElement;
let icon: HTMLElement;
handleHorizontalSwipe({
element: container,
verifyTouchTarget: async(e) => {
if(this.chat.selection.isSelecting || !(await this.chat.canSend())) {
return false;
}
// cancelEvent(e);
target = findUpClassName(e.target, 'bubble');
if(target) {
SetTransition(target, className, true, 250);
void target.offsetLeft; // reflow
if(!icon) {
icon = document.createElement('span');
icon.classList.add('tgico-reply_filled', 'bubble-gesture-reply-icon');
} else {
icon.classList.remove('is-visible');
icon.style.opacity = '';
}
target/* .querySelector('.bubble-content') */.append(icon);
}
return !!target;
},
onSwipe: (xDiff, yDiff) => {
shouldReply = xDiff >= replyAfter;
if(shouldReply && !icon.classList.contains('is-visible')) {
icon.classList.add('is-visible');
}
icon.style.opacity = '' + Math.min(1, xDiff / replyAfter);
const x = -Math.max(0, Math.min(MAX, xDiff));
target.style.transform = `translateX(${x}px)`;
cancelContextMenuOpening();
},
onReset: () => {
const _target = target;
SetTransition(_target, className, false, 250, () => {
if(icon.parentElement === _target) {
icon.classList.remove('is-visible');
icon.remove();
}
});
fastRaf(() => {
_target.style.transform = ``;
if(shouldReply) {
const {mid} = _target.dataset;
this.chat.input.initMessageReply(+mid);
shouldReply = false;
}
});
},
listenerOptions: {capture: true}
});
}
}
public constructPeerHelpers() {
// will call when message is sent (only 1)
this.listenerSetter.add(rootScope)('history_append', async({storageKey, mid}) => {
if(storageKey !== this.chat.messagesStorageKey) return;
if(!this.scrollable.loadedAll.bottom) {
this.chat.setMessageId();
} else {
this.renderNewMessagesByIds([mid], true);
}
if(rootScope.settings.animationsEnabled) {
const gradientRenderer = this.chat.gradientRenderer;
if(gradientRenderer) {
gradientRenderer.toNextPosition();
}
}
});
this.listenerSetter.add(rootScope)('history_multiappend', (msgIdsByPeer) => {
if(!(this.peerId in msgIdsByPeer)) return;
const msgIds = Array.from(msgIdsByPeer[this.peerId]).slice().sort((a, b) => b - a);
this.renderNewMessagesByIds(msgIds);
});
this.listenerSetter.add(rootScope)('history_delete', ({peerId, msgs}) => {
if(peerId === this.peerId) {
this.deleteMessagesByIds(Array.from(msgs));
}
});
this.listenerSetter.add(rootScope)('dialog_unread', ({peerId}) => {
if(peerId === this.peerId) {
this.chat.input.setUnreadCount();
getHeavyAnimationPromise().then(() => {
this.updateUnreadByDialog();
});
}
});
this.listenerSetter.add(rootScope)('dialogs_multiupdate', (dialogs) => {
if(dialogs[this.peerId]) {
this.chat.input.setUnreadCount();
}
});
this.listenerSetter.add(rootScope)('dialog_notify_settings', (dialog) => {
if(this.peerId === dialog.peerId) {
this.chat.input.setUnreadCount();
}
});
this.listenerSetter.add(rootScope)('chat_update', async(chatId) => {
if(this.peerId === chatId.toPeerId(true)) {
const hadRights = this.chatInner.classList.contains('has-rights');
const hasRights = await this.chat.canSend();
if(hadRights !== hasRights) {
const callbacks = await Promise.all([
this.finishPeerChange(),
this.chat.input.finishPeerChange(),
]);
callbacks.forEach((callback) => callback());
}
}
});
this.listenerSetter.add(rootScope)('settings_updated', async({key}) => {
if(key === 'settings.emoji.big') {
const middleware = this.getMiddleware();
const mids = getObjectKeysAndSort(this.bubbles, 'desc');
const m = mids.map(async(mid) => {
const bubble = this.bubbles[mid];
if(bubble.classList.contains('can-have-big-emoji')) {
return {bubble, message: await this.chat.getMessage(mid)};
}
});
const awaited = await Promise.all(m);
if(!middleware()) {
return;
}
awaited.forEach(({bubble, message}) => {
if(this.bubbles[message.mid] !== bubble) {
return;
}
this.safeRenderMessage(message, true, bubble);
});
}
});
!DO_NOT_UPDATE_MESSAGE_VIEWS && this.listenerSetter.add(rootScope)('messages_views', (arr) => {
fastRaf(() => {
let scrollSaver: ScrollSaver;
for(const {peerId, views, mid} of arr) {
if(this.peerId !== peerId) continue;
const bubble = this.bubbles[mid];
if(!bubble) continue;
const postViewsElements = Array.from(bubble.querySelectorAll('.post-views')) as HTMLElement[];
if(!postViewsElements.length) continue;
const str = formatNumber(views, 1);
let different = false;
postViewsElements.forEach((postViews) => {
if(different || postViews.textContent !== str) {
if(!scrollSaver) {
scrollSaver = this.createScrollSaver(true);
scrollSaver.save();
}
different = true;
postViews.textContent = str;
}
});
}
if(scrollSaver) {
scrollSaver.restore();
}
});
});
this.observer = new SuperIntersectionObserver({root: this.scrollable.container});
this.listenerSetter.add(this.chat.appImManager)('chat_changing', ({to}) => {
const freeze = to !== this.chat;
const cb = () => {
this.observer.toggleObservingNew(freeze);
};
if(!freeze) {
setTimeout(() => {
cb();
}, 400);
} else {
cb();
}
});
this.sendViewCountersDebounced = debounce(() => {
const mids = [...this.viewsMids];
this.viewsMids.clear();
this.managers.appMessagesManager.incrementMessageViews(this.peerId, mids);
}, 1000, false, true);
}
private get peerId() {
return this.chat.peerId;
}
private createScrollSaver(reverse = true) {
const scrollSaver = new ScrollSaver(this.scrollable, '.bubbles-group .bubble', reverse);
return scrollSaver;
}
private unreadedObserverCallback = (entry: IntersectionObserverEntry) => {
if(entry.isIntersecting) {
const target = entry.target as HTMLElement;
const mid = this.unreaded.get(target as HTMLElement);
this.onUnreadedInViewport(target, mid);
}
};
private viewsObserverCallback = (entry: IntersectionObserverEntry) => {
if(entry.isIntersecting) {
const mid = +(entry.target as HTMLElement).dataset.mid;
this.observer.unobserve(entry.target, this.viewsObserverCallback);
if(mid) {
this.viewsMids.add(mid);
this.sendViewCountersDebounced();
} else {
const {sponsoredMessage} = this;
if(sponsoredMessage && sponsoredMessage.random_id) {
delete sponsoredMessage.random_id;
this.managers.appChatsManager.viewSponsoredMessage(this.peerId.toChatId(), sponsoredMessage.random_id);
}
}
}
};
private createResizeObserver() {
if(!('ResizeObserver' in window) || this.resizeObserver) {
return;
}
const container = this.scrollable.container;
let wasHeight = 0/* container.offsetHeight */;
let resizing = false;
let skip = false;
let scrolled = 0;
let part = 0;
let rAF = 0;
// let skipNext = true;
const onResizeEnd = () => {
const height = container.offsetHeight;
const isScrolledDown = this.scrollable.isScrolledDown;
if(height !== wasHeight && (!skip || !isScrolledDown)) { // * fix opening keyboard while ESG is active, offsetHeight will change right between 'start' and this first frame
part += wasHeight - height;
}
/* if(DEBUG) {
this.log('resize end', scrolled, part, this.scrollable.scrollTop, height, wasHeight, this.scrollable.isScrolledDown);
} */
if(part) {
this.scrollable.setScrollTopSilently(this.scrollable.scrollTop + Math.round(part));
}
wasHeight = height;
scrolled = 0;
rAF = 0;
part = 0;
resizing = false;
skip = false;
};
const setEndRAF = (single: boolean) => {
if(rAF) window.cancelAnimationFrame(rAF);
rAF = window.requestAnimationFrame(single ? onResizeEnd : () => {
rAF = window.requestAnimationFrame(onResizeEnd);
//this.log('resize after RAF', part);
});
};
const processEntries: ResizeObserverCallback = (entries) => {
/* if(skipNext) {
skipNext = false;
return;
} */
if(skip) {
setEndRAF(false);
return;
}
const entry = entries[0];
const height = entry.contentRect.height;/* Math.ceil(entry.contentRect.height); */
if(!wasHeight) {
wasHeight = height;
return;
}
const realDiff = wasHeight - height;
let diff = realDiff + part;
const _part = diff % 1;
diff -= _part;
if(!resizing) {
resizing = true;
/* if(DEBUG) {
this.log('resize start', realDiff, this.scrollable.scrollTop, this.scrollable.container.offsetHeight, this.scrollable.isScrolledDown);
} */
if(realDiff < 0 && this.scrollable.isScrolledDown) {
//if(isSafari) { // * fix opening keyboard while ESG is active
part = -realDiff;
//}
skip = true;
setEndRAF(false);
return;
}
}
scrolled += diff;
/* if(DEBUG) {
this.log('resize', wasHeight - height, diff, this.scrollable.container.offsetHeight, this.scrollable.isScrolledDown, height, wasHeight);
} */
if(diff) {
const needScrollTop = this.scrollable.scrollTop + diff;
this.scrollable.setScrollTopSilently(needScrollTop);
}
setEndRAF(false);
part = _part;
wasHeight = height;
};
const resizeObserver = this.resizeObserver = new ResizeObserver(processEntries);
resizeObserver.observe(container);
}
private destroyResizeObserver() {
const resizeObserver = this.resizeObserver;
if(!resizeObserver) {
return;
}
resizeObserver.disconnect();
this.resizeObserver = undefined;
}
private onBubblesMouseMove = async(e: MouseEvent) => {
const content = findUpClassName(e.target, 'bubble-content');
if(content && !this.chat.selection.isSelecting) {
const bubble = findUpClassName(content, 'bubble');
if(!this.chat.selection.canSelectBubble(bubble)) {
this.unhoverPrevious();
return;
}
let {hoverBubble, hoverReaction} = this;
if(bubble === hoverBubble) {
return;
}
this.unhoverPrevious();
hoverBubble = this.hoverBubble = bubble;
hoverReaction = this.hoverReaction;
// hoverReaction = contentWrapper.querySelector('.bubble-hover-reaction');
if(!hoverReaction) {
hoverReaction = this.hoverReaction = document.createElement('div');
hoverReaction.classList.add('bubble-hover-reaction');
const stickerWrapper = document.createElement('div');
stickerWrapper.classList.add('bubble-hover-reaction-sticker');
hoverReaction.append(stickerWrapper);
content.append(hoverReaction);
let message = (await this.chat.getMessage(+bubble.dataset.mid)) as Message.message;
message = await this.managers.appMessagesManager.getGroupsFirstMessage(message);
const middleware = this.getMiddleware(() => this.hoverReaction === hoverReaction);
Promise.all([
this.managers.appReactionsManager.getAvailableReactionsByMessage(message),
pause(400)
]).then(([availableReactions]) => {
const availableReaction = availableReactions[0];
if(!availableReaction) {
hoverReaction.remove();
return;
}
wrapSticker({
div: stickerWrapper,
doc: availableReaction.select_animation,
width: 18,
height: 18,
needUpscale: true,
middleware,
group: CHAT_ANIMATION_GROUP,
withThumb: false,
needFadeIn: false
}).then(({render}) => render).then((player) => {
assumeType<RLottiePlayer>(player);
if(!middleware()) {
return;
}
player.addEventListener('firstFrame', () => {
if(!middleware()) {
// debugger;
return;
}
hoverReaction.dataset.loaded = '1';
this.setHoverVisible(hoverReaction, true);
}, {once: true});
attachClickEvent(hoverReaction, (e) => {
cancelEvent(e); // cancel triggering selection
this.managers.appReactionsManager.sendReaction(message, availableReaction.reaction);
this.unhoverPrevious();
}, {listenerSetter: this.listenerSetter});
});
});
} else if(hoverReaction.dataset.loaded) {
this.setHoverVisible(hoverReaction, true);
}
} else {
this.unhoverPrevious();
}
};
public setReactionsHoverListeners() {
this.listenerSetter.add(contextMenuController)('toggle', this.unhoverPrevious);
this.listenerSetter.add(overlayCounter)('change', this.unhoverPrevious);
this.listenerSetter.add(this.chat.selection)('toggle', this.unhoverPrevious);
this.listenerSetter.add(this.container)('mousemove', this.onBubblesMouseMove);
}
private setHoverVisible(hoverReaction: HTMLElement, visible: boolean) {
SetTransition(hoverReaction, 'is-visible', visible, 200, visible ? undefined : () => {
hoverReaction.remove();
}, 2);
}
private unhoverPrevious = () => {
const {hoverBubble, hoverReaction} = this;
if(hoverBubble) {
this.setHoverVisible(hoverReaction, false);
this.hoverBubble = undefined;
this.hoverReaction = undefined;
}
};
public setStickyDateManually() {
return;
const timestamps = Object.keys(this.dateMessages).map((k) => +k).sort((a, b) => b - a);
let lastVisible: HTMLElement;
// if(this.chatInner.classList.contains('is-scrolling')) {
const {scrollTop} = this.scrollable.container;
const isOverflown = scrollTop > 0;
if(isOverflown) {
for(const timestamp of timestamps) {
const dateMessage = this.dateMessages[timestamp];
const visibleRect = getVisibleRect(dateMessage.container, this.scrollable.container);
if(visibleRect && visibleRect.overflow.top) {
lastVisible = dateMessage.div;
} else if(lastVisible) {
break;
}
}
}
// }
if(lastVisible === this.previousStickyDate) {
return;
}
if(lastVisible) {
const needReflow = /* !!this.chat.setPeerPromise || */!this.previousStickyDate;
if(needReflow) {
lastVisible.classList.add('no-transition');
}
lastVisible.classList.add('is-sticky');
if(needReflow) {
void lastVisible.offsetLeft; // reflow
lastVisible.classList.remove('no-transition');
}
}
if(this.previousStickyDate && this.previousStickyDate !== lastVisible) {
this.previousStickyDate.classList.remove('is-sticky');
}
this.previousStickyDate = lastVisible;
}
public getRenderedLength() {
return Object.keys(this.bubbles).length - this.skippedMids.size;
}
private onUnreadedInViewport(target: HTMLElement, mid: number) {
this.unreadedSeen.add(mid);
this.observer.unobserve(target, this.unreadedObserverCallback);
this.unreaded.delete(target);
this.readUnreaded();
}
private readUnreaded() {
if(this.readPromise) return;
const middleware = this.getMiddleware();
this.readPromise = idleController.idle.focusPromise.then(async() => {
if(!middleware()) return;
let maxId = Math.max(...Array.from(this.unreadedSeen));
// ? if message with maxId is not rendered ?
if(this.scrollable.loadedAll.bottom) {
const bubblesMaxId = Math.max(...Object.keys(this.bubbles).map((i) => +i));
if(maxId >= bubblesMaxId) {
maxId = Math.max((await this.chat.getHistoryMaxId()) || 0, maxId);
}
}
this.unreaded.forEach((mid, target) => {
if(mid <= maxId) {
this.onUnreadedInViewport(target, mid);
}
});
const readContents: number[] = [];
for(const mid of this.unreadedSeen) {
const message: MyMessage = await this.chat.getMessage(mid);
if(isMentionUnread(message)) {
readContents.push(mid);
}
}
this.managers.appMessagesManager.readMessages(this.peerId, readContents);
this.unreadedSeen.clear();
if(DEBUG) {
this.log('will readHistory by maxId:', maxId);
}
// return;
return this.managers.appMessagesManager.readHistory(this.peerId, maxId, this.chat.threadId).catch((err: any) => {
this.log.error('readHistory err:', err);
this.managers.appMessagesManager.readHistory(this.peerId, maxId, this.chat.threadId);
}).finally(() => {
if(!middleware()) return;
this.readPromise = undefined;
if(this.unreadedSeen.size) {
this.readUnreaded();
}
});
});
}
public constructPinnedHelpers() {
this.listenerSetter.add(rootScope)('peer_pinned_messages', (e) => {
const {peerId, mids, pinned} = e;
if(peerId !== this.peerId) return;
if(mids) {
if(!pinned) {
this.deleteMessagesByIds(mids);
}
}
});
}
public constructScheduledHelpers() {
const onUpdate = async() => {
this.chat.topbar.setTitle((await this.managers.appMessagesManager.getScheduledMessagesStorage(this.peerId)).size);
};
this.listenerSetter.add(rootScope)('scheduled_new', ({peerId, mid}) => {
if(peerId !== this.peerId) return;
this.renderNewMessagesByIds([mid]);
onUpdate();
});
this.listenerSetter.add(rootScope)('scheduled_delete', ({peerId, mids}) => {
if(peerId !== this.peerId) return;
this.deleteMessagesByIds(mids);
onUpdate();
});
}
public onBubblesClick = async(e: Event) => {
let target = e.target as HTMLElement;
let bubble: HTMLElement = null;
try {
bubble = findUpClassName(target, 'bubble');
} catch(err) {}
if(!bubble && !this.chat.selection.isSelecting) {
const avatar = findUpClassName(target, 'user-avatar');
if(!avatar) {
return;
}
const peerId = avatar.dataset.peerId.toPeerId();
if(peerId !== NULL_PEER_ID) {
this.chat.appImManager.setInnerPeer({peerId});
} else {
toast(I18n.format('HidAccount', true));
}
return;
}
if(bubble.classList.contains('is-date') && findUpClassName(target, 'bubble-content')) {
if(bubble.classList.contains('is-sticky') && !this.chatInner.classList.contains('is-scrolling')) {
return;
}
for(const timestamp in this.dateMessages) {
const d = this.dateMessages[timestamp];
if(d.div === bubble) {
PopupElement.createPopup(PopupDatePicker, new Date(+timestamp), this.onDatePick).show();
break;
}
}
return;
}
if(!IS_TOUCH_SUPPORTED && findUpClassName(target, 'time')) {
this.chat.selection.toggleByElement(bubble);
return;
}
// ! Trusted - due to audio autoclick
if(this.chat.selection.isSelecting && e.isTrusted) {
if(bubble.classList.contains('service') && bubble.dataset.mid === undefined) {
return;
}
cancelEvent(e);
//console.log('bubble click', e);
if(IS_TOUCH_SUPPORTED && this.chat.selection.selectedText) {
this.chat.selection.selectedText = undefined;
return;
}
//this.chatSelection.toggleByBubble(bubble);
this.chat.selection.toggleByElement(findUpClassName(target, 'grouped-item') || bubble);
return;
}
const contactDiv: HTMLElement = findUpClassName(target, 'contact');
if(contactDiv) {
this.chat.appImManager.setInnerPeer({
peerId: contactDiv.dataset.peerId.toPeerId()
});
return;
}
const callDiv: HTMLElement = findUpClassName(target, 'bubble-call');
if(callDiv) {
this.chat.appImManager.callUser(this.peerId.toUserId(), callDiv.dataset.type as any);
return;
}
const spoiler: HTMLElement = findUpClassName(target, 'spoiler');
if(spoiler) {
const messageDiv = findUpClassName(spoiler, 'message');
const className = 'is-spoiler-visible';
const isVisible = messageDiv.classList.contains(className);
if(!isVisible) {
cancelEvent(e);
}
const duration = 400 / 2;
const showDuration = 5000;
const useRafs = !isVisible ? 2 : 0;
if(useRafs) {
messageDiv.classList.add('will-change');
}
const spoilerTimeout = messageDiv.dataset.spoilerTimeout;
if(spoilerTimeout !== null) {
clearTimeout(+spoilerTimeout);
delete messageDiv.dataset.spoilerTimeout;
}
SetTransition(messageDiv, className, true, duration, () => {
messageDiv.dataset.spoilerTimeout = '' + window.setTimeout(() => {
SetTransition(messageDiv, className, false, duration, () => {
messageDiv.classList.remove('will-change');
delete messageDiv.dataset.spoilerTimeout;
});
}, showDuration);
}, useRafs);
return;
}
const reactionElement = findUpTag(target, 'REACTION-ELEMENT') as ReactionElement;
if(reactionElement) {
cancelEvent(e);
if(reactionElement.classList.contains('is-inactive')) {
return;
}
const reactionsElement = reactionElement.parentElement as ReactionsElement;
const reactionCount = reactionsElement.getReactionCount(reactionElement);
const message = reactionsElement.getMessage();
this.managers.appReactionsManager.sendReaction(message, reactionCount.reaction);
return;
}
const commentsDiv: HTMLElement = findUpClassName(target, 'replies');
if(commentsDiv) {
const bubbleMid = +bubble.dataset.mid;
if(this.peerId === REPLIES_PEER_ID) {
const message = await this.chat.getMessage(bubbleMid) as Message.message;
const peerId = getPeerId(message.reply_to.reply_to_peer_id);
const threadId = message.reply_to.reply_to_top_id;
const lastMsgId = message.fwd_from.saved_from_msg_id;
this.chat.appImManager.openThread(peerId, lastMsgId, threadId);
} else {
const message1 = await this.chat.getMessage(bubbleMid);
const message = await this.managers.appMessagesManager.getMessageWithReplies(message1 as Message.message);
const replies = message.replies;
if(replies) {
this.managers.appMessagesManager.getDiscussionMessage(this.peerId, message.mid).then((message) => {
this.chat.appImManager.setInnerPeer({
peerId: replies.channel_id.toPeerId(true),
type: 'discussion',
threadId: (message as MyMessage).mid
});
});
}
}
return;
}
const via = findUpClassName(target, 'is-via');
if(via) {
const el = via.querySelector('.peer-title') as HTMLElement;
if(target === el || findUpAsChild(target, el)) {
const message = el.innerText + ' ';
this.managers.appDraftsManager.setDraft(this.peerId, this.chat.threadId, message);
cancelEvent(e);
return;
}
}
const nameDiv = findUpClassName(target, 'peer-title') || findUpTag(target, 'AVATAR-ELEMENT') || findUpAttribute(target, 'data-saved-from');
if(nameDiv && nameDiv !== bubble) {
target = nameDiv || target;
const peerIdStr = target.dataset.peerId || target.getAttribute('peer') || (target as AvatarElement).peerId;
const savedFrom = target.dataset.savedFrom;
if(typeof(peerIdStr) === 'string' || savedFrom) {
if(savedFrom) {
const [peerId, mid] = savedFrom.split('_');
this.chat.appImManager.setInnerPeer({
peerId: peerId.toPeerId(),
lastMsgId: +mid
});
} else {
const peerId = peerIdStr.toPeerId();
if(peerId !== NULL_PEER_ID) {
this.chat.appImManager.setInnerPeer({peerId});
} else {
toast(I18n.format('HidAccount', true));
}
}
}
return;
}
//this.log('chatInner click:', target);
// const isVideoComponentElement = target.tagName === 'SPAN' && findUpClassName(target, 'media-container');
/* if(isVideoComponentElement) {
const video = target.parentElement.querySelector('video') as HTMLElement;
if(video) {
video.click(); // hot-fix for time and play button
return;
}
} */
if(bubble.classList.contains('sticker') && target.parentElement.classList.contains('attachment')) {
const messageId = +bubble.dataset.mid;
const message = await this.chat.getMessage(messageId);
const doc = ((message as Message.message).media as MessageMedia.messageMediaDocument)?.document as Document.document;
if(doc?.stickerSetInput) {
new PopupStickers(doc.stickerSetInput).show();
}
return;
}
const documentDiv = findUpClassName(target, 'document-with-thumb');
if((target.tagName === 'IMG' && !target.classList.contains('emoji') && !target.classList.contains('document-thumb'))
|| target.classList.contains('album-item')
// || isVideoComponentElement
|| (target.tagName === 'VIDEO' && !bubble.classList.contains('round'))
|| (documentDiv && !documentDiv.querySelector('.preloader-container'))
|| target.classList.contains('canvas-thumbnail')) {
const groupedItem = findUpClassName(target, 'album-item') || findUpClassName(target, 'document-container');
const messageId = +(groupedItem || bubble).dataset.mid;
const message = await this.chat.getMessage(messageId);
if(!message) {
this.log.warn('no message by messageId:', messageId);
return;
}
const preloader = (groupedItem || bubble).querySelector<HTMLElement>('.preloader-container');
if(preloader) {
simulateClickEvent(preloader);
cancelEvent(e);
return;
}
const SINGLE_MEDIA_CLASSNAME = 'webpage';
const isSingleMedia = bubble.classList.contains(SINGLE_MEDIA_CLASSNAME);
const f = documentDiv ? (media: any) => {
return AppMediaViewer.isMediaCompatibleForDocumentViewer(media);
} : (media: any) => {
return media._ === 'photo' || ['video', 'gif'].includes(media.type);
};
const targets: {element: HTMLElement, mid: number, peerId: PeerId}[] = [];
const ids = isSingleMedia ? [messageId] : (await Promise.all(Object.keys(this.bubbles).map((k) => +k).map(async(mid) => {
/* if(isSingleMedia && !this.bubbles[id].classList.contains(SINGLE_MEDIA_CLASSNAME)) {
return false;
} */
//if(!this.scrollable.visibleElements.find((e) => e.element === this.bubbles[id])) return false;
const message = await this.chat.getMessage(mid);
const media = getMediaFromMessage(message);
return media && f(media) && mid;
}))).filter(Boolean).sort((a, b) => a - b);
ids.forEach((id) => {
let selector: string;
if(documentDiv) {
selector = '.document-container';
} else {
const withTail = this.bubbles[id].classList.contains('with-media-tail');
selector = '.album-item video, .album-item img, .preview video, .preview img, ';
if(withTail) {
selector += '.bubble__media-container';
} else {
selector += '.attachment video, .attachment img';
}
}
const elements = Array.from(this.bubbles[id].querySelectorAll(selector)) as HTMLElement[];
const parents: Set<HTMLElement> = new Set();
if(documentDiv) {
elements.forEach((element) => {
targets.push({
element: element.querySelector('.document-ico'),
mid: +element.dataset.mid,
peerId: this.peerId
});
});
} else {
const hasAspecter = !!this.bubbles[id].querySelector('.media-container-aspecter');
elements.forEach((element) => {
if(hasAspecter && !findUpClassName(element, 'media-container-aspecter')) return;
let albumItem = findUpClassName(element, 'album-item');
const parent = albumItem || element.parentElement;
if(parents.has(parent)) return;
parents.add(parent);
targets.push({
element,
mid: albumItem ? +albumItem.dataset.mid : id,
peerId: this.peerId
});
});
}
});
targets.sort((a, b) => a.mid - b.mid);
let idx = targets.findIndex((t) => t.mid === messageId);
if(DEBUG) {
this.log('open mediaViewer single with ids:', ids, idx, targets);
}
if(!targets[idx]) {
this.log('no target for media viewer!', target);
return;
}
new AppMediaViewer()
.setSearchContext({
threadId: this.chat.threadId,
peerId: this.peerId,
inputFilter: {_: documentDiv ? 'inputMessagesFilterDocument' : 'inputMessagesFilterPhotoVideo'},
useSearch: this.chat.type !== 'scheduled' && !isSingleMedia,
isScheduled: this.chat.type === 'scheduled'
})
.openMedia(message, targets[idx].element, 0, true, targets.slice(0, idx), targets.slice(idx + 1));
cancelEvent(e);
//appMediaViewer.openMedia(message, target as HTMLImageElement);
return;
}
if(['IMG', 'DIV', 'SPAN'/* , 'A' */].indexOf(target.tagName) === -1) target = findUpTag(target, 'DIV');
if(['DIV', 'SPAN'].indexOf(target.tagName) !== -1/* || target.tagName === 'A' */) {
if(target.classList.contains('goto-original')) {
const savedFrom = bubble.dataset.savedFrom;
const [peerId, mid] = savedFrom.split('_');
////this.log('savedFrom', peerId, msgID);
this.chat.appImManager.setInnerPeer({
peerId: peerId.toPeerId(),
lastMsgId: +mid
});
return;
} else if(target.classList.contains('forward')) {
const mid = +bubble.dataset.mid;
const message = await this.managers.appMessagesManager.getMessageByPeer(this.peerId, mid);
new PopupForward({
[this.peerId]: await this.managers.appMessagesManager.getMidsByMessage(message)
});
//appSidebarRight.forwardTab.open([mid]);
return;
}
let isReplyClick = false;
try {
isReplyClick = !!findUpClassName(e.target, 'reply');
} catch(err) {}
if(isReplyClick && bubble.classList.contains('is-reply')/* || bubble.classList.contains('forwarded') */) {
const bubbleMid = +bubble.dataset.mid;
this.replyFollowHistory.push(bubbleMid);
const message = (await this.chat.getMessage(bubbleMid)) as Message.message;
const replyToPeerId = message.reply_to.reply_to_peer_id ? getPeerId(message.reply_to.reply_to_peer_id) : this.peerId;
const replyToMid = message.reply_to.reply_to_msg_id;
this.chat.appImManager.setInnerPeer({
peerId: replyToPeerId,
lastMsgId: replyToMid,
type: this.chat.type,
threadId: this.chat.threadId
});
/* if(this.chat.type === 'discussion') {
this.chat.appImManager.setMessageId(, originalMessageId);
} else {
this.chat.appImManager.setInnerPeer(this.peerId, originalMessageId);
} */
//this.chat.setMessageId(, originalMessageId);
}
}
//console.log('chatInner click', e);
};
public async onGoDownClick() {
if(!this.replyFollowHistory.length) {
this.chat.setMessageId(/* , dialog.top_message */);
// const dialog = this.appMessagesManager.getDialogByPeerId(this.peerId)[0];
// if(dialog) {
// this.chat.setPeer(this.peerId/* , dialog.top_message */);
// } else {
// this.log('will scroll down 3');
// this.scroll.scrollTop = this.scroll.scrollHeight;
// }
return;
}
const middleware = this.getMiddleware();
const slice = this.replyFollowHistory.slice();
const messages = await Promise.all(slice.map((mid) => this.chat.getMessage(mid)));
if(!middleware()) return;
slice.forEach((mid, idx) => {
const message = messages[idx];
const bubble = this.bubbles[mid];
let bad = true;
if(bubble) {
const rect = bubble.getBoundingClientRect();
bad = (windowSize.height / 2) > rect.top;
} else if(message) {
bad = false;
}
if(bad) {
this.replyFollowHistory.splice(this.replyFollowHistory.indexOf(mid), 1);
}
});
this.replyFollowHistory.sort((a, b) => b - a);
const mid = this.replyFollowHistory.pop();
this.chat.setMessageId(mid);
}
public getBubbleByPoint(verticalSide: 'top' | 'bottom') {
let element = getElementByPoint(this.scrollable.container, verticalSide, 'center');
/* if(element) {
if(element.classList.contains('bubbles-date-group')) {
const children = Array.from(element.children) as HTMLElement[];
if(verticalSide === 'top') {
element = children[this.stickyIntersector ? 2 : 1];
} else {
element = children[children.length - 1];
}
} else {
element = findUpClassName(element, 'bubble');
if(element && element.classList.contains('is-date')) {
element = element.nextElementSibling as HTMLElement;
}
}
} */
if(element) element = findUpClassName(element, 'bubble');
return element;
}
public async getGroupedBubble(groupId: string) {
const mids = await this.managers.appMessagesManager.getMidsByAlbum(groupId);
for(const mid of mids) {
if(this.bubbles[mid] && !this.skippedMids.has(mid)) {
// const maxId = Math.max(...mids); // * because in scheduled album can be rendered by lowest mid during sending
return {
bubble: this.bubbles[mid],
mid: mid,
// message: await this.chat.getMessage(maxId) as Message.message
};
}
}
}
public getBubbleGroupedItems(bubble: HTMLElement) {
return Array.from(bubble.querySelectorAll('.grouped-item')) as HTMLElement[];
}
public async getMountedBubble(mid: number, message?: Message.message | Message.messageService) {
if(message === undefined) {
message = await this.chat.getMessage(mid);
}
if(!message) {
return;
}
const groupedId = (message as Message.message).grouped_id;
if(groupedId) {
const a = await this.getGroupedBubble(groupedId);
if(a) {
a.bubble = a.bubble.querySelector(`.document-container[data-mid="${mid}"]`) || a.bubble;
return a;
}
}
const bubble = this.bubbles[mid];
if(!bubble) return;
return {bubble, mid};
}
private findNextMountedBubbleByMsgId(mid: number, prev?: boolean) {
const mids = getObjectKeysAndSort(this.bubbles, prev ? 'desc' : 'asc');
let filterCallback: (_mid: number) => boolean;
if(prev) filterCallback = (_mid) => _mid < mid;
else filterCallback = (_mid) => mid < _mid;
const foundMid = mids.find((_mid) => {
if(!filterCallback(_mid)) return false;
return !!this.bubbles[_mid]?.parentElement;
});
return this.bubbles[foundMid];
}
public loadMoreHistory(top: boolean, justLoad = false) {
// return;
//this.log('loadMoreHistory', top);
if(
!this.peerId ||
/* TEST_SCROLL || */
this.chat.setPeerPromise ||
this.isHeavyAnimationInProgress ||
(top && (this.getHistoryTopPromise || this.scrollable.loadedAll.top)) ||
(!top && (this.getHistoryBottomPromise || this.scrollable.loadedAll.bottom))
) {
return;
}
// warning, если иды только отрицательные то вниз не попадёт (хотя мб и так не попадёт)
// some messages can have negative id (such as sponsored message)
const history = Object.keys(this.bubbles)
.map((id) => +id)
.filter((id) => id > 0 && !this.skippedMids.has(id))
.sort((a, b) => a - b);
if(!history.length) return;
if(top) {
if(DEBUG) {
this.log('Will load more (up) history by id:', history[0], 'maxId:', history[history.length - 1], justLoad/* , history */);
}
this.getHistory1(history[0], true, undefined, undefined, justLoad);
} else {
//let dialog = this.appMessagesManager.getDialogByPeerId(this.peerId)[0];
// const historyMaxId = await this.chat.getHistoryMaxId();
// // if scroll down after search
// if(history.indexOf(historyMaxId) !== -1) {
// this.setLoaded('bottom', true);
// return;
// }
if(DEBUG) {
this.log('Will load more (down) history by id:', history[history.length - 1], justLoad/* , history */);
}
this.getHistory1(history[history.length - 1], false, true, undefined, justLoad);
}
}
public onScroll = (ignoreHeavyAnimation?: boolean, scrollDimensions?: ScrollStartCallbackDimensions) => {
//return;
if(this.isHeavyAnimationInProgress) {
if(this.sliceViewportDebounced) {
this.sliceViewportDebounced.clearTimeout();
}
// * В таком случае, кнопка не будет моргать если чат в самом низу, и правильно отработает случай написания нового сообщения и проскролла вниз
if(this.scrolledDown && !ignoreHeavyAnimation) {
return;
}
} else {
if(this.chat.topbar.pinnedMessage) {
this.chat.topbar.pinnedMessage.setCorrectIndexThrottled(this.scrollable.lastScrollDirection);
}
if(this.sliceViewportDebounced) {
this.sliceViewportDebounced();
}
this.setStickyDateManually();
}
//lottieLoader.checkAnimations(false, 'chat');
if(scrollDimensions && scrollDimensions.distanceToEnd < SCROLLED_DOWN_THRESHOLD && this.scrolledDown) {
return;
}
const distanceToEnd = scrollDimensions?.distanceToEnd ?? this.scrollable.getDistanceToEnd();
if(/* !IS_TOUCH_SUPPORTED && */(this.scrollable.lastScrollDirection !== 0 && distanceToEnd > 0) || scrollDimensions) {
// if(/* !IS_TOUCH_SUPPORTED && */(this.scrollable.lastScrollDirection !== 0 || scrollDimensions) && distanceToEnd > 0) {
if(this.isScrollingTimeout) {
clearTimeout(this.isScrollingTimeout);
} else if(!this.chatInner.classList.contains('is-scrolling')) {
this.chatInner.classList.add('is-scrolling');
}
this.isScrollingTimeout = window.setTimeout(() => {
this.chatInner.classList.remove('is-scrolling');
this.isScrollingTimeout = 0;
}, 1350 + (scrollDimensions?.duration ?? 0));
}
if(distanceToEnd < SCROLLED_DOWN_THRESHOLD && (this.scrollable.loadedAll.bottom || this.chat.setPeerPromise || !this.peerId)) {
this.container.classList.add('scrolled-down');
this.scrolledDown = true;
} else if(this.container.classList.contains('scrolled-down')) {
this.container.classList.remove('scrolled-down');
this.scrolledDown = false;
}
};
public setScroll() {
if(this.scrollable) {
this.destroyScrollable();
}
this.scrollable = new Scrollable(null, 'IM', /* 10300 */300);
this.setLoaded('top', false, false);
this.setLoaded('bottom', false, false);
this.scrollable.container.append(this.chatInner);
/* const getScrollOffset = () => {
//return Math.round(Math.max(300, appPhotosManager.windowH / 1.5));
return 300;
};
window.addEventListener('resize', () => {
this.scrollable.onScrollOffset = getScrollOffset();
});
this.scrollable = new Scrollable(this.bubblesContainer, 'y', 'IM', this.chatInner, getScrollOffset()); */
this.scrollable.onAdditionalScroll = this.onScroll;
this.scrollable.onScrolledTop = () => this.loadMoreHistory(true);
this.scrollable.onScrolledBottom = () => this.loadMoreHistory(false);
//this.scrollable.attachSentinels(undefined, 300);
if(IS_TOUCH_SUPPORTED && false) {
this.scrollable.container.addEventListener('touchmove', () => {
if(this.isScrollingTimeout) {
clearTimeout(this.isScrollingTimeout);
} else if(!this.chatInner.classList.contains('is-scrolling')) {
this.chatInner.classList.add('is-scrolling');
}
}, {passive: true});
this.scrollable.container.addEventListener('touchend', () => {
if(!this.chatInner.classList.contains('is-scrolling')) {
return;
}
if(this.isScrollingTimeout) {
clearTimeout(this.isScrollingTimeout);
}
this.isScrollingTimeout = window.setTimeout(() => {
this.chatInner.classList.remove('is-scrolling');
this.isScrollingTimeout = 0;
}, 1350);
}, {passive: true});
}
}
public async updateUnreadByDialog() {
const historyStorage = await this.chat.getHistoryStorage();
const maxId = this.peerId === rootScope.myId ? historyStorage.readMaxId : historyStorage.readOutboxMaxId;
///////this.log('updateUnreadByDialog', maxId, dialog, this.unreadOut);
for(const msgId of this.unreadOut) {
if(msgId > 0 && msgId <= maxId) {
const bubble = this.bubbles[msgId];
if(bubble) {
this.unreadOut.delete(msgId);
if(bubble.classList.contains('is-outgoing')) {
continue;
}
bubble.classList.remove('is-sent', 'is-sending', 'is-outgoing'); // is-sending can be when there are bulk of updates (e.g. sending command to Stickers bot)
bubble.classList.add('is-read');
}
}
}
}
public deleteMessagesByIds(mids: number[], permanent = true, ignoreOnScroll?: boolean) {
let deleted = false;
mids.forEach((mid) => {
const bubble = this.bubbles[mid];
if(!bubble) return;
deleted = true;
/* const mounted = this.getMountedBubble(mid);
if(!mounted) return; */
delete this.bubbles[mid];
this.skippedMids.delete(mid);
if(this.firstUnreadBubble === bubble) {
this.firstUnreadBubble = null;
}
this.bubbleGroups.removeAndUnmountBubble(bubble);
if(this.observer) {
this.observer.unobserve(bubble, this.unreadedObserverCallback);
this.unreaded.delete(bubble);
this.observer.unobserve(bubble, this.viewsObserverCallback);
this.viewsMids.delete(mid);
}
if(this.emptyPlaceholderBubble === bubble) {
this.emptyPlaceholderBubble = undefined;
}
// this.reactions.delete(mid);
});
if(!deleted) {
return;
}
this.scrollable.ignoreNextScrollEvent();
if(permanent && this.chat.selection.isSelecting) {
this.chat.selection.deleteSelectedMids(this.peerId, mids);
}
animationIntersector.checkAnimations(false, CHAT_ANIMATION_GROUP);
this.deleteEmptyDateGroups();
if(!ignoreOnScroll) {
this.onScroll();
}
}
private setTopPadding(middleware = this.getMiddleware()) {
let isPaddingNeeded = false;
let setPaddingTo: HTMLElement;
if(!this.isTopPaddingSet) {
const {clientHeight, scrollHeight} = this.scrollable.container;
isPaddingNeeded = clientHeight === scrollHeight;
/* const firstEl = this.chatInner.firstElementChild as HTMLElement;
if(this.chatInner.firstElementChild) {
const visibleRect = getVisibleRect(firstEl, this.scrollable.container);
isPaddingNeeded = !visibleRect.overflow.top && (visibleRect.rect.top - firstEl.offsetTop) !== this.scrollable.container.getBoundingClientRect().top;
} else {
isPaddingNeeded = true;
} */
if(isPaddingNeeded) {
/* const add = clientHeight - scrollHeight;
this.chatInner.style.paddingTop = add + 'px';
this.scrollable.scrollTop += add; */
setPaddingTo = this.chatInner;
setPaddingTo.style.paddingTop = clientHeight + 'px';
this.scrollable.setScrollTopSilently(scrollHeight);
this.isTopPaddingSet = true;
}
}
return {
isPaddingNeeded,
unsetPadding: isPaddingNeeded ? () => {
if(middleware() && isPaddingNeeded) {
setPaddingTo.style.paddingTop = '';
this.isTopPaddingSet = false;
}
} : undefined
};
}
public async renderNewMessagesByIds(mids: number[], scrolledDown?: boolean) {
if(!this.scrollable.loadedAll.bottom) { // seems search active or sliced
//this.log('renderNewMessagesByIds: seems search is active, skipping render:', mids);
const setPeerPromise = this.chat.setPeerPromise;
if(setPeerPromise) {
const middleware = this.getMiddleware();
setPeerPromise.then(() => {
if(!middleware()) return;
this.renderNewMessagesByIds(mids);
});
}
return;
}
if(this.chat.threadId) {
mids = await filterAsync(mids, async(mid) => {
const message = await this.chat.getMessage(mid);
const replyTo = message.reply_to as MessageReplyHeader;
return replyTo && (replyTo.reply_to_top_id || replyTo.reply_to_msg_id) === this.chat.threadId;
});
}
mids = mids.filter((mid) => !this.bubbles[mid]);
// ! should scroll even without new messages
/* if(!mids.length) {
return;
} */
if(!scrolledDown) {
scrolledDown = this.scrolledDown && (
!this.scrollingToBubble ||
this.scrollingToBubble === this.getLastBubble() ||
this.scrollingToBubble === this.chatInner
);
}
const middleware = this.getMiddleware();
const {isPaddingNeeded, unsetPadding} = this.setTopPadding(middleware);
const promise = this.performHistoryResult({history: mids}, false);
if(scrolledDown) {
promise.then(() => {
if(!middleware()) return;
//this.log('renderNewMessagesByIDs: messagesQueuePromise after', this.scrollable.isScrolledDown);
//this.scrollable.scrollTo(this.scrollable.scrollHeight, 'top', true, true, 5000);
//const bubble = this.bubbles[Math.max(...mids)];
let bubble: HTMLElement;
if(this.chat.type === 'scheduled') {
bubble = this.bubbles[Math.max(...mids)];
}
const promise = bubble ? this.scrollToBubbleEnd(bubble) : this.scrollToEnd();
if(isPaddingNeeded) {
// it will be called only once even if was set multiple times (that won't happen)
promise.then(unsetPadding);
}
//this.scrollable.scrollIntoViewNew(this.chatInner, 'end');
/* setTimeout(() => {
this.log('messagesQueuePromise afterafter:', this.chatInner.childElementCount, this.scrollable.scrollHeight);
}, 10); */
});
}
}
public getLastBubble() {
const group = this.bubbleGroups.getLastGroup();
return group?.lastItem?.bubble;
}
public scrollToBubble(
element: HTMLElement,
position: ScrollLogicalPosition,
forceDirection?: FocusDirection,
forceDuration?: number
) {
const bubble = findUpClassName(element, 'bubble');
let fallbackToElementStartWhenCentering: HTMLElement;
// * if it's a start, then scroll to start of the group
if(bubble && position !== 'end') {
const item = this.bubbleGroups.getItemByBubble(bubble);
if(item.group.firstItem === item && whichChild(item.group.container) === (this.stickyIntersector ? STICKY_OFFSET : 1)) {
const dateGroup = item.group.container.parentElement;
// if(whichChild(dateGroup) === 0) {
fallbackToElementStartWhenCentering = dateGroup;
// position = 'start';
// element = dateGroup;
// }
}
}
// const isLastBubble = this.getLastBubble() === bubble;
/* if(isLastBubble) {
element = this.getLastDateGroup();
} */
let margin = 4; // * 4 = .25rem
/* if(isLastBubble && this.chat.type === 'chat' && this.bubblesContainer.classList.contains('is-chat-input-hidden')) {
margin = 20;
} */
const isChangingHeight = (this.chat.input.messageInput && this.chat.input.messageInput.classList.contains('is-changing-height')) || this.chat.container.classList.contains('is-toggling-helper');
const promise = this.scrollable.scrollIntoViewNew({
element,
position,
margin,
forceDirection,
forceDuration,
axis: 'y',
getNormalSize: isChangingHeight ? ({rect}) => {
// return rect.height;
let height = windowSize.height;
// height -= this.chat.topbar.container.getBoundingClientRect().height;
height -= this.container.offsetTop;
height -= mediaSizes.isMobile || windowSize.height < 570 ? 58 : 78;
return height;
/* const rowsWrapperHeight = this.chat.input.rowsWrapper.getBoundingClientRect().height;
const diff = rowsWrapperHeight - 54;
return rect.height + diff; */
} : undefined,
fallbackToElementStartWhenCentering,
startCallback: (dimensions) => {
// this.onScroll(true, this.scrolledDown && dimensions.distanceToEnd <= SCROLLED_DOWN_THRESHOLD ? undefined : dimensions);
this.onScroll(true, dimensions);
}
});
// fix flickering date when opening unread chat and focusing message
if(forceDirection === FocusDirection.Static) {
this.scrollable.lastScrollPosition = this.scrollable.scrollTop;
}
return promise;
}
public scrollToEnd() {
return this.scrollToBubbleEnd(this.chatInner);
}
public async scrollToBubbleEnd(bubble: HTMLElement) {
/* if(DEBUG) {
this.log('scrollToNewLastBubble: will scroll into view:', bubble);
} */
if(bubble) {
this.scrollingToBubble = bubble;
const middleware = this.getMiddleware();
await this.scrollToBubble(bubble, 'end', undefined, undefined);
if(!middleware()) return;
this.scrollingToBubble = undefined;
}
}
// ! can't get it by chatInner.lastElementChild because placeholder can be the last...
// private getLastDateGroup() {
// let lastTime = 0, lastElem: HTMLElement;
// for(const i in this.dateMessages) {
// const dateMessage = this.dateMessages[i];
// if(dateMessage.firstTimestamp > lastTime) {
// lastElem = dateMessage.container;
// lastTime = dateMessage.firstTimestamp;
// }
// }
// return lastElem;
// }
public async scrollToBubbleIfLast(bubble: HTMLElement) {
if(this.getLastBubble() === bubble) {
// return this.scrollToBubbleEnd(bubble);
return this.scrollToEnd();
}
}
public highlightBubble(element: HTMLElement) {
const datasetKey = 'highlightTimeout';
if(element.dataset[datasetKey]) {
clearTimeout(+element.dataset[datasetKey]);
element.classList.remove('is-highlighted');
void element.offsetWidth; // reflow
}
element.classList.add('is-highlighted');
element.dataset[datasetKey] = '' + setTimeout(() => {
element.classList.remove('is-highlighted');
delete element.dataset[datasetKey];
}, 2000);
}
private createDateBubble(timestamp: number, date: Date = new Date(timestamp * 1000)) {
let dateElement: HTMLElement;
const today = new Date();
today.setHours(0, 0, 0, 0);
const isScheduled = this.chat.type === 'scheduled';
if(today.getTime() === date.getTime()) {
dateElement = i18n(isScheduled ? 'Chat.Date.ScheduledForToday' : 'Date.Today');
} else if(isScheduled && timestamp === SEND_WHEN_ONLINE_TIMESTAMP) {
dateElement = i18n('MessageScheduledUntilOnline');
} else {
const options: Intl.DateTimeFormatOptions = {
day: 'numeric',
month: 'long'
};
if(date.getFullYear() !== today.getFullYear()) {
options.year = 'numeric';
}
dateElement = new I18n.IntlDateElement({
date,
options
}).element;
if(isScheduled) {
dateElement = i18n('Chat.Date.ScheduledFor', [dateElement]);
}
}
const bubble = document.createElement('div');
bubble.className = 'bubble service is-date';
const bubbleContent = document.createElement('div');
bubbleContent.classList.add('bubble-content');
const serviceMsg = document.createElement('div');
serviceMsg.classList.add('service-msg');
serviceMsg.append(dateElement);
bubbleContent.append(serviceMsg);
bubble.append(bubbleContent);
return bubble;
}
public getDateForDateContainer(timestamp: number) {
const date = new Date(timestamp * 1000);
date.setHours(0, 0, 0);
return {date, dateTimestamp: date.getTime()};
}
public getDateContainerByTimestamp(timestamp: number) {
const {date, dateTimestamp} = this.getDateForDateContainer(timestamp);
if(!this.dateMessages[dateTimestamp]) {
const bubble = this.createDateBubble(timestamp, date);
// bubble.classList.add('is-sticky');
const fakeBubble = this.createDateBubble(timestamp, date);
fakeBubble.classList.add('is-fake');
const container = document.createElement('section');
container.className = 'bubbles-date-group';
container.append(bubble, fakeBubble);
this.dateMessages[dateTimestamp] = {
div: bubble,
container,
firstTimestamp: date.getTime()
};
const haveTimestamps = getObjectKeysAndSort(this.dateMessages, 'asc');
let i = 0, length = haveTimestamps.length, insertBefore: HTMLElement; // there can be 'first bubble' (e.g. bot description) so can't insert by index
for(; i < haveTimestamps.length; ++i) {
const t = haveTimestamps[i];
insertBefore = this.dateMessages[t].container;
if(dateTimestamp < t) {
break;
}
}
if(i === length && insertBefore) {
insertBefore = insertBefore.nextElementSibling as HTMLElement;
}
if(!insertBefore) {
this.chatInner.append(container);
} else {
this.chatInner.insertBefore(container, insertBefore);
}
if(this.stickyIntersector) {
this.stickyIntersector.observeStickyHeaderChanges(container);
}
if(this.chatInner.parentElement) {
this.container.classList.add('has-groups');
}
}
return this.dateMessages[dateTimestamp];
}
private destroyScrollable() {
this.scrollable.removeListeners();
this.scrollable.onScrolledTop = this.scrollable.onScrolledBottom = this.scrollable.onAdditionalScroll = null;
}
public destroy() {
//this.chat.log.error('Bubbles destroying');
this.destroyScrollable();
this.listenerSetter.removeAll();
this.lazyLoadQueue.clear();
this.observer && this.observer.disconnect();
this.stickyIntersector && this.stickyIntersector.disconnect();
delete this.lazyLoadQueue;
this.observer && delete this.observer;
this.stickyIntersector && delete this.stickyIntersector;
}
public cleanup(bubblesToo = false) {
this.bubbles = {}; // clean it before so sponsored message won't be deleted faster on peer changing
////console.time('appImManager cleanup');
this.setLoaded('top', false, false);
this.setLoaded('bottom', false, false);
// cancel scroll
cancelAnimationByKey(this.scrollable.container);
// do not wait ending of previous scale animation
interruptHeavyAnimation();
if(TEST_SCROLL !== undefined) {
TEST_SCROLL = TEST_SCROLL_TIMES;
}
this.skippedMids.clear();
this.dateMessages = {};
this.bubbleGroups.cleanup();
this.unreadOut.clear();
this.needUpdate.length = 0;
this.lazyLoadQueue.clear();
// clear messages
if(bubblesToo) {
this.scrollable.container.textContent = '';
this.cleanupPlaceholders();
}
this.firstUnreadBubble = null;
this.attachedUnreadBubble = false;
this.messagesQueue.length = 0;
this.messagesQueuePromise = null;
this.getHistoryTopPromise = this.getHistoryBottomPromise = undefined;
this.fetchNewPromise = undefined;
this.getSponsoredMessagePromise = undefined;
if(this.stickyIntersector) {
this.stickyIntersector.disconnect();
}
if(this.observer) {
this.observer.disconnect();
this.unreaded.clear();
this.unreadedSeen.clear();
this.readPromise = undefined;
this.viewsMids.clear();
}
this.middleware.clean();
this.onAnimateLadder = undefined;
this.resolveLadderAnimation = undefined;
this.attachPlaceholderOnRender = undefined;
this.emptyPlaceholderBubble = undefined;
this.sponsoredMessage = undefined;
this.previousStickyDate = undefined;
this.scrollingToBubble = undefined;
////console.timeEnd('appImManager cleanup');
this.isTopPaddingSet = false;
this.renderingMessages.clear();
this.bubblesToEject.clear();
// this.reactions.clear();
if(this.isScrollingTimeout) {
clearTimeout(this.isScrollingTimeout);
this.isScrollingTimeout = 0;
}
this.container.classList.remove('has-sticky-dates');
this.scrollable.cancelMeasure();
}
private cleanupPlaceholders(bubble = this.emptyPlaceholderBubble) {
if(bubble) {
bubble.remove();
if(this.emptyPlaceholderBubble === bubble) {
this.emptyPlaceholderBubble = undefined;
}
}
}
public async setPeer(samePeer: boolean, peerId: PeerId, lastMsgId?: number, startParam?: string): Promise<{cached?: boolean, promise: Chat['setPeerPromise']}> {
if(!peerId) {
this.cleanup(true);
this.preloader.detach();
return null;
}
const perf = performance.now();
const log = this.log.bindPrefix('setPeer');
log.warn('start');
/* if(samePeer && this.chat.setPeerPromise) {
return {cached: true, promise: this.chat.setPeerPromise};
} */
const chatType = this.chat.type;
if(chatType === 'scheduled' || this.chat.isRestricted) {
lastMsgId = 0;
}
const historyStorage = await this.chat.getHistoryStorage();
let topMessage = chatType === 'pinned' ? await this.managers.appMessagesManager.getPinnedMessagesMaxId(peerId) : historyStorage.maxId ?? 0;
const isTarget = lastMsgId !== undefined;
// * this one will fix topMessage for null message in history (e.g. channel comments with only 1 comment and it is a topMessage)
/* if(chatType !== 'pinned' && topMessage && !historyStorage.history.slice.includes(topMessage)) {
topMessage = 0;
} */
let followingUnread: boolean;
let readMaxId = 0, savedPosition: ReturnType<AppImManager['getChatSavedPosition']>, overrideAdditionMsgId: number;
if(!isTarget) {
if(!samePeer) {
savedPosition = this.chat.appImManager.getChatSavedPosition(this.chat);
}
if(savedPosition) {
} else if(topMessage) {
readMaxId = await this.managers.appMessagesManager.getReadMaxIdIfUnread(peerId, this.chat.threadId);
const dialog = await this.managers.appMessagesManager.getDialogOnly(peerId);
if(/* dialog.unread_count */readMaxId && !samePeer && (!dialog || dialog.unread_count !== 1)) {
const foundSlice = historyStorage.history.findSliceOffset(readMaxId);
if(foundSlice && foundSlice.slice.isEnd(SliceEnd.Bottom)) {
overrideAdditionMsgId = foundSlice.slice[foundSlice.offset - 25] || foundSlice.slice[0] || readMaxId;
}
followingUnread = !isTarget;
lastMsgId = readMaxId;
} else {
lastMsgId = topMessage;
//lastMsgID = topMessage;
}
}
}
const isJump = lastMsgId !== topMessage/* && overrideAdditionMsgId === undefined */;
if(samePeer) {
const mounted = await this.getMountedBubble(lastMsgId);
if(mounted) {
if(isTarget) {
this.scrollToBubble(mounted.bubble, 'center');
this.highlightBubble(mounted.bubble);
this.chat.dispatchEvent('setPeer', lastMsgId, false);
} else if(topMessage && !isJump) {
//log('will scroll down', this.scroll.scrollTop, this.scroll.scrollHeight);
// scrollable.setScrollTopSilently(scrollable.scrollHeight);
this.scrollToEnd();
this.chat.dispatchEvent('setPeer', lastMsgId, true);
}
if(startParam !== undefined) {
this.chat.input.setStartParam(startParam);
}
return null;
}
} else {
if(this.peerId) { // * set new queue id if new peer (setting not from 0)
this.lazyLoadQueue.queueId = ++queueId;
this.managers.apiFileManager.setQueueId(this.chat.bubbles.lazyLoadQueue.queueId);
}
this.replyFollowHistory.length = 0;
this.passEntities = {
messageEntityBotCommand: await this.managers.appPeersManager.isAnyGroup(peerId) || await this.managers.appUsersManager.isBot(peerId)
};
}
if(DEBUG) {
log('setPeer peerId:', peerId, historyStorage, lastMsgId, topMessage);
}
// add last message, bc in getHistory will load < max_id
const additionMsgId = overrideAdditionMsgId ?? (isJump || chatType === 'scheduled' || this.chat.isRestricted ? 0 : topMessage);
let maxBubbleId = 0;
if(samePeer) {
let el = this.getBubbleByPoint('bottom'); // ! this may not work if being called when chat is hidden
//this.chat.log('[PM]: setCorrectIndex: get last element perf:', performance.now() - perf, el);
if(el) {
maxBubbleId = +el.dataset.mid;
}
if(maxBubbleId <= 0) {
maxBubbleId = Math.max(...Object.keys(this.bubbles).map((mid) => +mid));
}
} else {
this.isFirstLoad = true;
this.destroyResizeObserver();
}
// const oldContainer = this.container;
const oldChatInner = this.chatInner;
const oldPlaceholderBubble = this.emptyPlaceholderBubble;
this.cleanup();
// this.constructBubbles();
// const container = this.container;
// const chatInner = this.chatInner;/* = document.createElement('div'); */
const chatInner = this.chatInner = document.createElement('div');
if(samePeer) {
chatInner.className = oldChatInner.className;
chatInner.classList.remove('disable-hover', 'is-scrolling');
} else {
chatInner.classList.add('bubbles-inner');
}
this.lazyLoadQueue.lock();
// const haveToScrollToBubble = (topMessage && (isJump || samePeer)) || isTarget;
const haveToScrollToBubble = samePeer || (topMessage && isJump) || isTarget;
const fromUp = maxBubbleId > 0 && (!lastMsgId || maxBubbleId < lastMsgId || lastMsgId < 0);
const scrollFromDown = !fromUp && samePeer;
const scrollFromUp = !scrollFromDown && fromUp/* && (samePeer || forwardingUnread) */;
this.willScrollOnLoad = scrollFromDown || scrollFromUp;
this.setPeerOptions = {
lastMsgId,
topMessage
};
let result: Awaited<ReturnType<ChatBubbles['getHistory']>>;
if(!savedPosition) {
result = await this.getHistory1(lastMsgId, true, isJump, additionMsgId);
} else {
result = {
promise: getHeavyAnimationPromise().then(() => {
return this.performHistoryResult({history: savedPosition.mids}, true);
}) as any,
cached: true,
waitPromise: Promise.resolve()
};
}
this.setPeerCached = result.cached;
log.warn('got history');// warning
const {promise, cached} = result;
const middleware = this.getMiddleware();
if(!cached && !samePeer) {
await this.chat.finishPeerChange(isTarget, isJump, lastMsgId, startParam);
if(!middleware()) throw PEER_CHANGED_ERROR;
this.scrollable.container.textContent = '';
// oldContainer.textContent = '';
//oldChatInner.remove();
this.preloader.attach(this.container);
}
/* this.ladderDeferred && this.ladderDeferred.resolve();
this.ladderDeferred = deferredPromise<void>(); */
animationIntersector.lockGroup(CHAT_ANIMATION_GROUP);
const setPeerPromise = promise.then(async() => {
log.warn('promise fulfilled');
let mountedByLastMsgId = haveToScrollToBubble ? await (lastMsgId ? this.getMountedBubble(lastMsgId) : {bubble: this.getLastBubble()}) : undefined;
if(cached && !samePeer) {
log.warn('finishing peer change');
await this.chat.finishPeerChange(isTarget, isJump, lastMsgId, startParam); // * костыль
if(!middleware()) throw PEER_CHANGED_ERROR;
log.warn('finished peer change');
}
this.preloader.detach();
if(this.resolveLadderAnimation) {
this.resolveLadderAnimation();
this.resolveLadderAnimation = undefined;
}
this.setPeerCached = undefined;
// this.ladderDeferred.resolve();
const scrollable = this.scrollable;
scrollable.lastScrollDirection = 0;
scrollable.lastScrollPosition = 0;
replaceContent(scrollable.container, chatInner);
// this.chat.topbar.container.nextElementSibling.replaceWith(container);
if(oldPlaceholderBubble) {
this.cleanupPlaceholders(oldPlaceholderBubble);
}
if(this.attachPlaceholderOnRender) {
this.attachPlaceholderOnRender();
}
this.container.classList.toggle('has-groups', !!Object.keys(this.dateMessages).length);
log.warn('mounted chat', this.chatInner === chatInner, this.chatInner.parentElement, performance.now() - perf);
animationIntersector.unlockGroup(CHAT_ANIMATION_GROUP);
animationIntersector.checkAnimations(false, CHAT_ANIMATION_GROUP/* , true */);
//fastRaf(() => {
this.lazyLoadQueue.unlock();
//});
//if(dialog && lastMsgID && lastMsgID !== topMessage && (this.bubbles[lastMsgID] || this.firstUnreadBubble)) {
if(savedPosition) {
scrollable.setScrollTopSilently(savedPosition.top);
/* const mountedByLastMsgId = this.getMountedBubble(lastMsgId);
let bubble: HTMLElement = mountedByLastMsgId?.bubble;
if(!bubble?.parentElement) {
bubble = this.findNextMountedBubbleByMsgId(lastMsgId);
}
if(bubble) {
const top = bubble.getBoundingClientRect().top;
const distance = savedPosition.top - top;
scrollable.scrollTop += distance;
} */
} else if(haveToScrollToBubble) {
let unsetPadding: () => void;
if(scrollFromDown) {
scrollable.setScrollTopSilently(99999);
} else if(scrollFromUp) {
const set = this.setTopPadding();
if(set.isPaddingNeeded) {
unsetPadding = set.unsetPadding;
}
scrollable.setScrollTopSilently(0);
}
// const mountedByLastMsgId = lastMsgId ? this.getMountedBubble(lastMsgId) : {bubble: this.getLastBubble()};
let bubble: HTMLElement = (followingUnread && this.firstUnreadBubble) || mountedByLastMsgId?.bubble;
if(!bubble?.parentElement) {
bubble = this.findNextMountedBubbleByMsgId(lastMsgId, false) || this.findNextMountedBubbleByMsgId(lastMsgId, true);
}
let promise: Promise<void>;
// ! sometimes there can be no bubble
if(bubble) {
const lastBubble = this.getLastBubble();
const position: ScrollLogicalPosition = followingUnread ? 'start' : (!isJump && !isTarget && lastBubble === bubble ? 'end' : 'center');
if(position === 'end' && lastBubble === bubble && samePeer) {
promise = this.scrollToEnd();
} else {
promise = this.scrollToBubble(bubble, position, !samePeer ? FocusDirection.Static : undefined);
}
if(!followingUnread && isTarget) {
this.highlightBubble(bubble);
}
}
if(unsetPadding) {
(promise || Promise.resolve()).then(() => {
unsetPadding();
});
}
} else {
scrollable.setScrollTopSilently(99999);
}
// if(!cached) {
this.onRenderScrollSet();
// }
this.onScroll();
const afterSetPromise = Promise.all([setPeerPromise, getHeavyAnimationPromise()]);
afterSetPromise.then(() => { // check whether list isn't full
scrollable.checkForTriggers();
// if(cached) {
// this.onRenderScrollSet();
// }
});
this.chat.dispatchEvent('setPeer', lastMsgId, !isJump);
this.setFetchReactionsInterval(afterSetPromise);
this.setFetchHistoryInterval({
afterSetPromise,
lastMsgId,
samePeer,
savedPosition,
topMessage
});
log('scrolledAllDown:', scrollable.loadedAll.bottom);
//if(!this.unreaded.length && dialog) { // lol
if(scrollable.loadedAll.bottom && topMessage && !this.unreaded.size) { // lol
this.onScrolledAllDown();
}
if(chatType === 'chat') {
const dialog = await this.managers.appMessagesManager.getDialogOnly(peerId);
if(dialog?.pFlags.unread_mark) {
this.managers.appMessagesManager.markDialogUnread(peerId, true);
}
}
//this.chatInner.classList.remove('disable-hover', 'is-scrolling'); // warning, performance!
}).catch((err) => {
log.error('getHistory promise error:', err);
if(!middleware()) {
this.preloader.detach();
}
throw err;
});
return {cached, promise: setPeerPromise};
}
private async setFetchReactionsInterval(afterSetPromise: Promise<any>) {
const middleware = this.getMiddleware();
const needReactionsInterval = await this.managers.appPeersManager.isChannel(this.peerId);
if(needReactionsInterval) {
const fetchReactions = async() => {
if(!middleware()) return;
const mids: number[] = [];
for(const mid in this.bubbles) {
let message = await this.chat.getMessage(+mid);
if(message?._ !== 'message') {
continue;
}
message = await this.managers.appMessagesManager.getGroupsFirstMessage(message);
mids.push(message.mid);
}
const promise = mids.length ? this.managers.appReactionsManager.getMessagesReactions(this.peerId, mids) : Promise.resolve();
promise.then(() => {
setTimeout(fetchReactions, 10e3);
});
};
Promise.all([afterSetPromise, getHeavyAnimationPromise(), pause(500)]).then(() => {
fetchReactions();
});
}
}
private async setFetchHistoryInterval({
lastMsgId,
topMessage,
afterSetPromise,
savedPosition,
samePeer
}: {
lastMsgId: number,
topMessage: number,
afterSetPromise: Promise<any>,
savedPosition: ChatSavedPosition,
samePeer: boolean
}) {
const middleware = this.getMiddleware();
const peerId = this.peerId;
const needFetchInterval = await this.managers.appMessagesManager.isFetchIntervalNeeded(peerId);
const needFetchNew = savedPosition || needFetchInterval;
if(!needFetchNew) {
return;
}
await afterSetPromise;
if(!middleware()) {
return;
}
this.setLoaded('bottom', false);
this.scrollable.checkForTriggers();
if(!needFetchInterval) {
return;
}
const f = () => {
this.fetchNewPromise = new Promise<void>(async(resolve) => {
if(!middleware() || !(await this.managers.appMessagesManager.isFetchIntervalNeeded(peerId))) {
resolve();
return;
}
this.managers.appMessagesManager.getNewHistory(peerId, this.chat.threadId).then((result) => {
if(!middleware() || !result) {
resolve();
return;
}
const {isBottomEnd} = result;
if(this.scrollable.loadedAll.bottom && this.scrollable.loadedAll.bottom !== isBottomEnd) {
this.setLoaded('bottom', isBottomEnd);
this.onScroll();
}
setTimeout(f, 30e3);
resolve();
});
}).finally(() => {
this.fetchNewPromise = undefined;
});
};
if(samePeer) {
setTimeout(f, 30e3);
} else {
f();
}
}
public async onScrolledAllDown() {
if(this.chat.type === 'chat' || this.chat.type === 'discussion') {
const historyMaxId = await this.chat.getHistoryMaxId();
this.managers.appMessagesManager.readHistory(this.peerId, historyMaxId, this.chat.threadId, true);
}
}
public async finishPeerChange() {
const [isChannel, canWrite, isAnyGroup] = await Promise.all([
this.managers.appPeersManager.isChannel(this.peerId),
this.chat.canSend(),
this.chat.isAnyGroup
]);
return () => {
this.chatInner.classList.toggle('has-rights', canWrite);
this.container.classList.toggle('is-chat-input-hidden', !canWrite);
this.chatInner.classList.toggle('is-chat', isAnyGroup);
this.chatInner.classList.toggle('is-channel', isChannel);
this.createResizeObserver();
};
}
public renderMessagesQueue(options: ChatBubbles['messagesQueue'][0]) {
this.messagesQueue.push(options);
return this.setMessagesQueuePromise();
}
public setMessagesQueuePromise() {
if(!this.messagesQueue.length) return Promise.resolve();
if(this.messagesQueuePromise) {
return this.messagesQueuePromise;
}
const middleware = this.getMiddleware();
const log = this.log.bindPrefix('queue');
const possibleError = PEER_CHANGED_ERROR;
const m = middlewarePromise(middleware, possibleError);
const promise = this.messagesQueuePromise = m(pause(0)).then(async() => {
const renderQueue = this.messagesQueue.slice();
this.messagesQueue.length = 0;
const renderQueuePromises = renderQueue.map((promise) => {
const perf = performance.now();
promise.then((details) => {
log('render message time', performance.now() - perf, details);
});
return promise;
});
const loadQueue = (await m(Promise.all(renderQueuePromises))).filter(Boolean);
log.warn('messages rendered');
const groups: Set<BubbleGroups['groups'][0]> = new Set();
const promises = loadQueue.reduce((acc, details) => {
if(!details || this.bubbles[details.message.mid] !== details.bubble) {
return acc; // message was deleted during rendering
}
const perf = performance.now();
const promises = details.promises.slice();
const timePromises = promises.map(async(promise) => (await promise, performance.now() - perf));
Promise.all(timePromises).then((times) => {
log.groupCollapsed('media message time', performance.now() - perf, details, times);
times.forEach((time, idx) => {
log('media message time', time, idx, promises[idx]);
});
log.groupEnd();
});
if(details.updatePosition) {
const res = this.setBubblePosition(details.bubble, details.message, false);
if(res) {
groups.add(res.group);
if(details.needAvatar) {
details.promises.push(res.group.createAvatar(details.message));
}
}
}
acc.push(...details.promises);
return acc;
}, [] as Promise<any>[]);
// promises.push(pause(200));
// * это нужно для того, чтобы если захочет подгрузить reply или какое-либо сообщение, то скролл не прервался
// * если добавить этот промис - в таком случае нужно сделать, чтобы скроллило к последнему сообщению после рендера
// promises.push(getHeavyAnimationPromise());
log.warn('media promises to call', promises, loadQueue, this.isHeavyAnimationInProgress);
await m(Promise.all([...promises, this.setUnreadDelimiter()])); // не нашёл места лучше
await m(fastRafPromise()); // have to be the last
log.warn('media promises end');
this.prepareToSaveScroll(loadQueue[0] && loadQueue[0].reverse);
// if(this.messagesQueueOnRender) {
// this.messagesQueueOnRender();
// }
if(this.messagesQueueOnRenderAdditional) {
this.messagesQueueOnRenderAdditional();
}
this.ejectBubbles();
if(this.chat.selection.isSelecting) {
loadQueue.forEach(({message, bubble}) => {
if(this.bubbles[message.mid] !== bubble) { // maybe message was deleted during rendering
return;
}
this.chat.selection.toggleElementCheckbox(bubble, true);
});
}
// const sortedGroups = Array.from(groups).sort((a, b) => a.firstTimestamp - b.firstTimestamp);
// for(const group of sortedGroups) {
for(const group of groups) {
group.mount();
}
if(this.updatePlaceholderPosition) {
this.updatePlaceholderPosition();
}
// this.setStickyDateManually();
}).finally(() => {
if(this.messagesQueuePromise === promise) {
this.messagesQueuePromise = null;
if(this.messagesQueue.length) {
this.setMessagesQueuePromise();
}
}
});
return promise;
}
private ejectBubbles() {
for(const bubble of this.bubblesToEject) {
bubble.remove();
// this.bubbleGroups.removeAndUnmountBubble(bubble);
}
}
public setBubblePosition(bubble: HTMLElement, message: Message.message | Message.messageService, unmountIfFound: boolean) {
if(message.pFlags.local) {
this.chatInner[(message as Message.message).pFlags.sponsored ? 'append' : 'prepend'](bubble);
return;
}
const result = this.bubbleGroups.addBubble(bubble, message, unmountIfFound);
return result;
}
public getMiddleware(additionalCallback?: () => boolean) {
return this.middleware.get(additionalCallback);
}
private async safeRenderMessage(
message: Message.message | Message.messageService,
reverse?: boolean,
bubble?: HTMLElement,
updatePosition = true,
processResult?: (result: ReturnType<ChatBubbles['renderMessage']>, bubble: HTMLElement) => typeof result
) {
if(!message || this.renderingMessages.has(message.mid) || (this.bubbles[message.mid] && !bubble)) {
return;
}
const middleware = this.getMiddleware();
let result: Awaited<ReturnType<ChatBubbles['renderMessage']>> & {updatePosition: typeof updatePosition};
try {
this.renderingMessages.add(message.mid);
// const groupedId = (message as Message.message).grouped_id;
const newBubble = document.createElement('div');
// const bubbleNew: Bubble = this.bubblesNew[message.mid] ??= {
// bubble: newBubble,
// mids: new Set(),
// groupedId
// };
// bubbleNew.mids.add(message.mid);
if(bubble) {
this.skippedMids.delete(message.mid);
this.bubblesToEject.add(bubble);
this.bubbleGroups.changeBubbleByBubble(bubble, newBubble);
}
bubble = this.bubbles[message.mid] = newBubble;
let originalPromise = this.renderMessage(message, reverse, bubble);
if(processResult) {
originalPromise = processResult(originalPromise, bubble);
}
const promise = originalPromise.then((r) => ((r && middleware() ? {...r, updatePosition} : undefined) as typeof result));
this.renderMessagesQueue(promise.catch(() => undefined));
result = await promise;
if(!middleware()) {
return;
}
if(!result) {
this.skippedMids.add(+message.mid);
}
} catch(err) {
this.log.error('renderMessage error:', err);
}
if(!middleware()) {
return;
}
this.renderingMessages.delete(message.mid);
return result;
}
// reverse means top
private async renderMessage(
message: Message.message | Message.messageService,
reverse = false,
bubble: HTMLElement
) {
// if(DEBUG) {
// this.log('message to render:', message);
// }
// if(!bubble && this.bubbles[message.mid]) {
// return;
// }
const isMessage = message._ === 'message';
const groupedId = isMessage && message.grouped_id;
let albumMids: number[], reactionsMessage: Message.message;
const albumMustBeRenderedFull = this.chat.type !== 'pinned';
if(groupedId && albumMustBeRenderedFull) { // will render only last album's message
albumMids = await this.managers.appMessagesManager.getMidsByAlbum(groupedId);
const mainMid = getMainMidForGrouped(albumMids);
if(message.mid !== mainMid) {
return;
}
}
if(isMessage) {
reactionsMessage = groupedId ? await this.managers.appMessagesManager.getGroupsFirstMessage(message) : message;
}
const peerId = this.peerId;
// * can't use 'message.pFlags.out' here because this check will be used to define side of message (left-right)
const our = message.fromId === rootScope.myId || (message.pFlags.out && await this.managers.appPeersManager.isMegagroup(peerId));
const messageDiv = document.createElement('div');
messageDiv.classList.add('message');
//messageDiv.innerText = message.message;
let bubbleContainer: HTMLDivElement;
let contentWrapper: HTMLElement;
// bubble
// if(!bubble) {
contentWrapper = document.createElement('div');
contentWrapper.classList.add('bubble-content-wrapper');
bubbleContainer = document.createElement('div');
bubbleContainer.classList.add('bubble-content');
// bubble = document.createElement('div');
bubble.classList.add('bubble');
contentWrapper.appendChild(bubbleContainer);
bubble.appendChild(contentWrapper);
if(!our && !message.pFlags.out && this.observer) {
//this.log('not our message', message, message.pFlags.unread);
const isUnread = message.pFlags.unread ||
isMentionUnread(message)/* ||
(this.historyStorage.readMaxId !== undefined && this.historyStorage.readMaxId < message.mid) */;
if(isUnread) {
this.observer.observe(bubble, this.unreadedObserverCallback);
this.unreaded.set(bubble, message.mid);
}
}
// } else {
// const save = ['is-highlighted', 'is-group-first', 'is-group-last'];
// const wasClassNames = bubble.className.split(' ');
// const classNames = ['bubble'].concat(save.filter((c) => wasClassNames.includes(c)));
// bubble.className = classNames.join(' ');
// contentWrapper = bubble.lastElementChild as HTMLElement;
// if(!contentWrapper.classList.contains('bubble-content-wrapper')) {
// contentWrapper = bubble.querySelector('.bubble-content-wrapper');
// }
// bubbleContainer = contentWrapper.firstElementChild as HTMLDivElement;
// bubbleContainer.innerHTML = '';
// bubbleContainer.style.cssText = '';
// contentWrapper.innerHTML = '';
// contentWrapper.appendChild(bubbleContainer);
// //bubbleContainer.style.marginBottom = '';
// const transitionDelay = contentWrapper.style.transitionDelay;
// contentWrapper.style.cssText = '';
// contentWrapper.style.transitionDelay = transitionDelay;
// if(bubble === this.firstUnreadBubble) {
// bubble.classList.add('is-first-unread');
// }
// // * Нужно очистить прошлую информацию, полезно если удалить последний элемент из альбома в ПОСЛЕДНЕМ БАББЛЕ ГРУППЫ (видно по аватару)
// const originalMid = +bubble.dataset.mid;
// const sameMid = +message.mid === originalMid;
// /* if(updatePosition) {
// bubble.remove(); // * for positionElementByIndex
// } */
// ! WARNING
// if(!sameMid) {
// delete this.bubbles[originalMid];
// this.skippedMids.delete(originalMid);
// }
// //bubble.innerHTML = '';
// }
// ! 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<any>[] = [];
const ret = {
bubble,
promises: loadPromises,
message,
reverse,
needAvatar: undefined as boolean,
isOut: undefined as boolean
};
if(message._ === 'messageService' && (!message.action || !SERVICE_AS_REGULAR.has(message.action._))) {
const action = message.action;
if(action) {
const _ = action._;
if(IGNORE_ACTIONS.has(_) || (langPack.hasOwnProperty(_) && !langPack[_])) {
return;
}
}
bubble.className = 'bubble service';
bubbleContainer.innerHTML = '';
const s = document.createElement('div');
s.classList.add('service-msg');
if(action) {
if(action._ === 'messageActionChannelMigrateFrom') {
s.append(i18n('ChatMigration.From', [new PeerTitle({peerId: action.chat_id.toPeerId(true)}).element]));
} else if(action._ === 'messageActionChatMigrateTo') {
s.append(i18n('ChatMigration.To', [new PeerTitle({peerId: action.channel_id.toPeerId(true)}).element]));
} else {
s.append(await wrapMessageActionTextNew(message));
}
}
bubbleContainer.append(s);
if(message.pFlags.is_single) { // * Ignore 'Discussion started'
bubble.classList.add('is-group-last');
}
return ret;
}
let messageMedia: MessageMedia = isMessage && message.media;
let messageMessage: string, totalEntities: MessageEntity[];
if(isMessage) {
if((messageMedia as MessageMedia.messageMediaDocument)?.document &&
!['video', 'gif'].includes(((messageMedia as MessageMedia.messageMediaDocument).document as MyDocument).type)) {
// * just filter these cases for documents caption
} else if(groupedId && albumMustBeRenderedFull) {
const t = await this.managers.appMessagesManager.getAlbumText(groupedId);
messageMessage = t.message;
//totalEntities = t.entities;
totalEntities = t.totalEntities;
} else if(((messageMedia as MessageMedia.messageMediaDocument)?.document as MyDocument)?.type !== 'sticker') {
messageMessage = message.message;
//totalEntities = message.entities;
totalEntities = message.totalEntities;
}
} else {
if(message.action._ === 'messageActionPhoneCall') {
messageMedia = {
_: 'messageMediaCall',
action: message.action
};
}
}
/* let richText = wrapRichText(messageMessage, {
entities: totalEntities
}); */
let richText = wrapRichText(messageMessage, {
entities: totalEntities,
passEntities: this.passEntities
});
let canHaveTail = true;
let isStandaloneMedia = false;
let needToSetHTML = true;
if(totalEntities && !messageMedia) {
let emojiEntities = totalEntities.filter((e) => e._ === 'messageEntityEmoji');
let strLength = messageMessage.length;
let emojiStrLength = emojiEntities.reduce((acc, curr) => acc + curr.length, 0);
if(emojiStrLength === strLength && emojiEntities.length <= 3 && totalEntities.length === emojiEntities.length) {
if(rootScope.settings.emoji.big) {
let sticker = await this.managers.appStickersManager.getAnimatedEmojiSticker(messageMessage);
if(emojiEntities.length === 1 && !messageMedia && sticker) {
messageMedia = {
_: 'messageMediaDocument',
document: sticker
};
} else {
let attachmentDiv = document.createElement('div');
attachmentDiv.classList.add('attachment');
setInnerHTML(attachmentDiv, richText);
bubble.classList.add('emoji-' + emojiEntities.length + 'x');
bubbleContainer.append(attachmentDiv);
}
bubble.classList.add('is-message-empty', 'emoji-big');
isStandaloneMedia = true;
canHaveTail = false;
needToSetHTML = false;
}
bubble.classList.add('can-have-big-emoji');
}
/* if(strLength === emojiStrLength) {
messageDiv.classList.add('emoji-only');
messageDiv.classList.add('message-empty');
} */
}
if(needToSetHTML) {
setInnerHTML(messageDiv, richText);
}
const timeSpan = MessageRender.setTime({
chatType: this.chat.type,
message,
reactionsMessage
});
messageDiv.append(timeSpan);
bubbleContainer.prepend(messageDiv);
//bubble.prepend(timeSpan, messageDiv); // that's bad
if(isMessage && message.views) {
bubble.classList.add('channel-post');
if(!message.fwd_from?.saved_from_msg_id && this.chat.type !== 'pinned') {
const forward = document.createElement('div');
forward.classList.add('bubble-beside-button', 'forward', 'tgico-forward_filled');
bubbleContainer.prepend(forward);
bubble.classList.add('with-beside-button');
}
if(!message.pFlags.is_outgoing && this.observer) {
this.observer.observe(bubble, this.viewsObserverCallback);
}
}
const replyMarkup = isMessage && message.reply_markup;
if(replyMarkup && replyMarkup._ === 'replyInlineMarkup' && replyMarkup.rows && replyMarkup.rows.length) {
const rows = replyMarkup.rows;
const containerDiv = document.createElement('div');
containerDiv.classList.add('reply-markup');
rows.forEach((row) => {
const buttons = row.buttons;
if(!buttons || !buttons.length) return;
const rowDiv = document.createElement('div');
rowDiv.classList.add('reply-markup-row');
buttons.forEach((button) => {
const text = wrapRichText(button.text, {noLinks: true, noLinebreaks: true});
let buttonEl: HTMLButtonElement | HTMLAnchorElement;
switch(button._) {
case 'keyboardButtonUrl': {
const r = wrapRichText(' ', {
entities: [{
_: 'messageEntityTextUrl',
length: 1,
offset: 0,
url: button.url
}]
});
buttonEl = htmlToDocumentFragment(r).firstElementChild as HTMLAnchorElement;
buttonEl.classList.add('is-link', 'tgico');
break;
}
case 'keyboardButtonSwitchInline': {
buttonEl = document.createElement('button');
buttonEl.classList.add('is-switch-inline', 'tgico');
attachClickEvent(buttonEl, (e) => {
cancelEvent(e);
const botId = message.viaBotId || message.fromId;
let promise: Promise<PeerId>;
if(button.pFlags.same_peer) promise = Promise.resolve(this.peerId);
else promise = this.managers.appInlineBotsManager.checkSwitchReturn(botId).then((peerId) => {
if(peerId) {
return peerId;
}
return new Promise<PeerId>((resolve, reject) => {
const popup = new PopupForward({
[this.peerId]: []
}, (peerId) => {
resolve(peerId);
}, true);
popup.addEventListener('close', () => {
reject();
});
});
});
promise.then((peerId) => {
const threadId = this.peerId === peerId ? this.chat.threadId : undefined;
this.managers.appInlineBotsManager.switchInlineQuery(peerId, threadId, botId, button.query);
});
});
break;
}
default: {
buttonEl = document.createElement('button');
break;
}
}
buttonEl.classList.add('reply-markup-button', 'rp');
if(typeof(text) === 'string') {
buttonEl.insertAdjacentHTML('beforeend', text);
} else {
buttonEl.append(text);
}
ripple(buttonEl);
rowDiv.append(buttonEl);
});
containerDiv.append(rowDiv);
});
attachClickEvent(containerDiv, (e) => {
let target = e.target as HTMLElement;
if(!target.classList.contains('reply-markup-button')) target = findUpClassName(target, 'reply-markup-button');
if(!target || target.classList.contains('is-link') || target.classList.contains('is-switch-inline')) return;
cancelEvent(e);
const column = whichChild(target);
const row = rows[whichChild(target.parentElement)];
if(!row.buttons || !row.buttons[column]) {
this.log.warn('no such button', row, column, message);
return;
}
const button = row.buttons[column];
this.managers.appInlineBotsManager.callbackButtonClick(this.peerId, message.mid, button).then((callbackAnswer) => {
if(typeof callbackAnswer.message === 'string' && callbackAnswer.message.length) {
toast(wrapRichText(callbackAnswer.message, {noLinks: true, noLinebreaks: true}));
}
//console.log('callbackButtonClick callbackAnswer:', callbackAnswer);
});
});
canHaveTail = false;
bubble.classList.add('with-reply-markup');
contentWrapper.append(containerDiv);
}
const isOutgoing = message.pFlags.is_outgoing/* && this.peerId !== rootScope.myId */;
if(our) {
if(message.pFlags.unread || isOutgoing) this.unreadOut.add(message.mid);
let status = '';
if(isOutgoing) status = 'is-sending';
else status = message.pFlags.unread || (message as Message.message).pFlags.is_scheduled ? 'is-sent' : 'is-read';
bubble.classList.add(status);
}
if(isOutgoing) {
bubble.classList.add('is-outgoing');
}
const messageWithReplies = isMessage && await this.managers.appMessagesManager.getMessageWithCommentReplies(message);
const withReplies = !!messageWithReplies && message.mid > 0;
if(withReplies) {
bubble.classList.add('with-replies');
}
const fwdFrom = isMessage && message.fwd_from;
const fwdFromId = isMessage && message.fwdFromId;
const isOut = ret.isOut = our && (!fwdFrom || this.peerId !== rootScope.myId);
let nameContainer: HTMLElement = bubbleContainer;
const canHideNameIfMedia = !message.viaBotId && (message.fromId === rootScope.myId || !message.pFlags.out);
// media
if(messageMedia/* && messageMedia._ === 'messageMediaPhoto' */) {
let attachmentDiv = document.createElement('div');
attachmentDiv.classList.add('attachment');
if(!messageMessage) {
bubble.classList.add('is-message-empty');
}
let processingWebPage = false;
/* if(isMessage) */switch(messageMedia._) {
case 'messageMediaPhoto': {
const photo = messageMedia.photo;
////////this.log('messageMediaPhoto', photo);
if(!messageMessage) {
canHaveTail = false;
}
if(canHideNameIfMedia) {
bubble.classList.add('hide-name');
}
bubble.classList.add('photo');
if(albumMustBeRenderedFull && groupedId && albumMids.length !== 1) {
bubble.classList.add('is-album', 'is-grouped');
const promise = wrapAlbum({
groupId: groupedId,
attachmentDiv,
middleware: this.getMiddleware(),
isOut: our,
lazyLoadQueue: this.lazyLoadQueue,
chat: this.chat,
loadPromises,
autoDownload: this.chat.autoDownload,
});
loadPromises.push(promise);
break;
}
const withTail = !IS_ANDROID && canHaveTail && !withReplies && USE_MEDIA_TAILS;
if(withTail) bubble.classList.add('with-media-tail');
wrapPhoto({
photo: photo as Photo.photo,
message,
container: attachmentDiv,
withTail,
isOut,
lazyLoadQueue: this.lazyLoadQueue,
middleware: this.getMiddleware(),
loadPromises,
autoDownloadSize: this.chat.autoDownload.photo,
});
break;
}
case 'messageMediaWebPage': {
processingWebPage = true;
let webpage: WebPage = messageMedia.webpage;
////////this.log('messageMediaWebPage', webpage);
if(webpage._ !== 'webPage') {
break;
}
bubble.classList.add('webpage');
let box = document.createElement('div');
box.classList.add('web');
let quote = document.createElement('div');
quote.classList.add('quote');
let previewResizer: HTMLDivElement, preview: HTMLDivElement;
const photo: Photo.photo = webpage.photo as any;
if(photo || webpage.document) {
previewResizer = document.createElement('div');
previewResizer.classList.add('preview-resizer');
preview = document.createElement('div');
preview.classList.add('preview');
previewResizer.append(preview);
}
let quoteTextDiv = document.createElement('div');
quoteTextDiv.classList.add('quote-text');
const doc = webpage.document as MyDocument;
if(doc) {
if(doc.type === 'gif' || doc.type === 'video' || doc.type === 'round') {
//if(doc.size <= 20e6) {
const mediaSize = doc.type === 'round' ? mediaSizes.active.round : mediaSizes.active.webpage;
if(doc.type === 'round') {
bubble.classList.add('round');
preview.classList.add('is-round');
} else {
bubble.classList.add('video');
}
wrapVideo({
doc,
container: preview,
message: message as Message.message,
boxWidth: mediaSize.width,
boxHeight: mediaSize.height,
lazyLoadQueue: this.lazyLoadQueue,
middleware: this.getMiddleware(),
isOut,
group: CHAT_ANIMATION_GROUP,
loadPromises,
autoDownload: this.chat.autoDownload,
});
//}
} else {
const docDiv = await wrapDocument({
message: message as Message.message,
autoDownloadSize: this.chat.autoDownload.file,
lazyLoadQueue: this.lazyLoadQueue,
loadPromises,
sizeType: 'documentName',
searchContext: {
useSearch: false,
peerId: this.peerId,
inputFilter: {
_: 'inputMessagesFilterEmpty'
}
}
});
preview.append(docDiv);
preview.classList.add('preview-with-document');
quoteTextDiv.classList.add('has-document');
//messageDiv.classList.add((webpage.type || 'document') + '-message');
//doc = null;
}
}
if(previewResizer) {
quoteTextDiv.append(previewResizer);
}
let t: HTMLElement;
if(webpage.site_name) {
const html = wrapRichText(webpage.url);
const a: HTMLAnchorElement = htmlToDocumentFragment(html).firstElementChild as any;
a.classList.add('webpage-name');
const strong = document.createElement('strong');
setInnerHTML(strong, wrapEmojiText(webpage.site_name));
a.textContent = '';
a.append(strong);
quoteTextDiv.append(a);
t = a;
}
const title = wrapWebPageTitle(webpage);
if(title.textContent) {
let titleDiv = document.createElement('div');
titleDiv.classList.add('title');
const strong = document.createElement('strong');
setInnerHTML(strong, title);
titleDiv.append(strong);
quoteTextDiv.append(titleDiv);
t = titleDiv;
}
const description = wrapWebPageDescription(webpage);
if(description.textContent) {
let textDiv = document.createElement('div');
textDiv.classList.add('text');
setInnerHTML(textDiv, description);
quoteTextDiv.append(textDiv);
t = textDiv;
}
/* if(t) {
t.append(timeSpan);
} else {
box.classList.add('no-text');
} */
quote.append(quoteTextDiv);
if(photo && !doc) {
bubble.classList.add('photo');
const size: PhotoSize.photoSize = photo.sizes[photo.sizes.length - 1] as any;
let isSquare = false;
if(size.w === size.h && t) {
bubble.classList.add('is-square-photo');
isSquare = true;
setAttachmentSize(photo, preview, 48, 48, false);
/* if(t) {
t.append(timeSpan);
} */
} else if(size.h > size.w) {
bubble.classList.add('is-vertical-photo');
}
wrapPhoto({
photo,
message,
container: preview,
boxWidth: isSquare ? 0 : mediaSizes.active.webpage.width,
boxHeight: isSquare ? 0 : mediaSizes.active.webpage.height,
isOut,
lazyLoadQueue: this.lazyLoadQueue,
middleware: this.getMiddleware(),
loadPromises,
withoutPreloader: isSquare,
autoDownloadSize: this.chat.autoDownload.photo,
});
}
box.append(quote);
//bubble.prepend(box);
// if(timeSpan.parentElement === messageDiv) {
messageDiv.insertBefore(box, timeSpan);
// } else {
// messageDiv.append(box);
// }
//this.log('night running', bubble.scrollHeight);
break;
}
case 'messageMediaDocument': {
const doc = messageMedia.document as MyDocument;
//this.log('messageMediaDocument', doc, bubble);
if(doc.sticker/* && doc.size <= 1e6 */) {
bubble.classList.add('sticker');
canHaveTail = false;
isStandaloneMedia = true;
if(doc.animated) {
bubble.classList.add('sticker-animated');
}
const sizes = mediaSizes.active;
const size = bubble.classList.contains('emoji-big') ? sizes.emojiSticker : (doc.animated ? sizes.animatedSticker : sizes.staticSticker);
setAttachmentSize(doc, attachmentDiv, size.width, size.height);
//let preloader = new ProgressivePreloader(attachmentDiv, false);
bubbleContainer.style.minWidth = attachmentDiv.style.width;
bubbleContainer.style.minHeight = attachmentDiv.style.height;
//appPhotosManager.setAttachmentSize(doc, bubble);
wrapSticker({
doc,
div: attachmentDiv,
middleware: this.getMiddleware(),
lazyLoadQueue: this.lazyLoadQueue,
group: CHAT_ANIMATION_GROUP,
//play: !!message.pending || !multipleRender,
play: true,
loop: true,
emoji: bubble.classList.contains('emoji-big') ? messageMessage : undefined,
withThumb: true,
loadPromises
});
} else if(doc.type === 'video' || doc.type === 'gif' || doc.type === 'round'/* && doc.size <= 20e6 */) {
//this.log('never get free 2', doc);
const isRound = doc.type === 'round';
if(isRound) {
isStandaloneMedia = true;
}
if(isRound || !messageMessage) {
canHaveTail = false;
}
if(canHideNameIfMedia) {
bubble.classList.add('hide-name');
}
bubble.classList.add(isRound ? 'round' : 'video');
if(albumMustBeRenderedFull && groupedId && albumMids.length !== 1) {
bubble.classList.add('is-album', 'is-grouped');
await wrapAlbum({
groupId: groupedId,
attachmentDiv,
middleware: this.getMiddleware(),
isOut: our,
lazyLoadQueue: this.lazyLoadQueue,
chat: this.chat,
loadPromises,
autoDownload: this.chat.autoDownload,
});
} else {
const withTail = !IS_ANDROID && !IS_APPLE && !isRound && canHaveTail && !withReplies && USE_MEDIA_TAILS;
if(withTail) bubble.classList.add('with-media-tail');
wrapVideo({
doc,
container: attachmentDiv,
message: message as Message.message,
boxWidth: mediaSizes.active.regular.width,
boxHeight: mediaSizes.active.regular.height,
withTail,
isOut,
lazyLoadQueue: this.lazyLoadQueue,
middleware: this.getMiddleware(),
group: CHAT_ANIMATION_GROUP,
loadPromises,
autoDownload: this.chat.autoDownload,
searchContext: isRound ? {
peerId: this.peerId,
inputFilter: {_: 'inputMessagesFilterRoundVoice'},
threadId: this.chat.threadId,
useSearch: !(message as Message.message).pFlags.is_scheduled,
isScheduled: (message as Message.message).pFlags.is_scheduled
} : undefined,
});
}
} else {
const newNameContainer = await wrapGroupedDocuments({
albumMustBeRenderedFull,
message,
bubble,
messageDiv,
chat: this.chat,
loadPromises,
autoDownloadSize: this.chat.autoDownload.file,
lazyLoadQueue: this.lazyLoadQueue,
searchContext: doc.type === 'voice' || doc.type === 'audio' ? {
peerId: this.peerId,
inputFilter: {_: doc.type === 'voice' ? 'inputMessagesFilterRoundVoice' : 'inputMessagesFilterMusic'},
threadId: this.chat.threadId,
useSearch: !(message as Message.message).pFlags.is_scheduled,
isScheduled: (message as Message.message).pFlags.is_scheduled
} : undefined,
sizeType: 'documentName'
});
if(newNameContainer) {
nameContainer = newNameContainer;
}
const lastContainer = messageDiv.lastElementChild.querySelector('.document-message, .document-size, .audio');
// lastContainer && lastContainer.append(timeSpan.cloneNode(true));
lastContainer && lastContainer.append(timeSpan);
bubble.classList.remove('is-message-empty');
messageDiv.classList.add((!(['photo', 'pdf'] as MyDocument['type'][]).includes(doc.type) ? doc.type || 'document' : 'document') + '-message');
processingWebPage = true;
}
break;
}
case 'messageMediaCall': {
const action = messageMedia.action;
const div = document.createElement('div');
div.classList.add('bubble-call', action.pFlags.video ? 'tgico-videocamera' : 'tgico-phone');
const type: CallType = action.pFlags.video ? 'video' : 'voice';
div.dataset.type = type;
const title = document.createElement('div');
title.classList.add('bubble-call-title');
_i18n(title, isOut ?
(action.pFlags.video ? 'CallMessageVideoOutgoing' : 'CallMessageOutgoing') :
(action.pFlags.video ? 'CallMessageVideoIncoming' : 'CallMessageIncoming'));
const subtitle = document.createElement('div');
subtitle.classList.add('bubble-call-subtitle');
if(action.duration !== undefined) {
subtitle.append(formatCallDuration(action.duration));
} else {
let langPackKey: LangPackKey;
switch(action.reason._) {
case 'phoneCallDiscardReasonBusy':
langPackKey = 'Call.StatusBusy';
break;
case 'phoneCallDiscardReasonMissed':
langPackKey = 'Chat.Service.Call.Missed';
break;
// case 'phoneCallDiscardReasonHangup':
default:
langPackKey = 'Chat.Service.Call.Cancelled';
break;
}
subtitle.classList.add('is-reason');
_i18n(subtitle, langPackKey);
}
subtitle.classList.add('tgico', 'arrow-' + (action.duration !== undefined ? 'green' : 'red'));
div.append(title, subtitle);
processingWebPage = true;
bubble.classList.remove('is-message-empty');
messageDiv.classList.add('call-message');
messageDiv.append(div);
break;
}
case 'messageMediaContact': {
//this.log('wrapping contact', message);
const contact = messageMedia;
const contactDiv = document.createElement('div');
contactDiv.classList.add('contact');
contactDiv.dataset.peerId = '' + contact.user_id;
processingWebPage = true;
const contactDetails = document.createElement('div');
contactDetails.className = 'contact-details';
const contactNameDiv = document.createElement('div');
contactNameDiv.className = 'contact-name';
contactNameDiv.append(
wrapEmojiText([
contact.first_name,
contact.last_name
].filter(Boolean).join(' '))
);
const contactNumberDiv = document.createElement('div');
contactNumberDiv.className = 'contact-number';
contactNumberDiv.textContent = contact.phone_number ? '+' + formatPhoneNumber(contact.phone_number).formatted : 'Unknown phone number';
contactDiv.append(contactDetails);
contactDetails.append(contactNameDiv, contactNumberDiv);
const avatarElem = new AvatarElement();
avatarElem.updateWithOptions({
lazyLoadQueue: this.lazyLoadQueue,
peerId: contact.user_id.toPeerId()
});
avatarElem.classList.add('contact-avatar', 'avatar-54');
contactDiv.prepend(avatarElem);
bubble.classList.remove('is-message-empty');
messageDiv.classList.add('contact-message');
messageDiv.append(contactDiv);
break;
}
case 'messageMediaPoll': {
bubble.classList.remove('is-message-empty');
const pollElement = wrapPoll(message);
messageDiv.prepend(pollElement);
messageDiv.classList.add('poll-message');
break;
}
default:
bubble.classList.remove('is-message-empty');
messageDiv.append(i18n(UNSUPPORTED_LANG_PACK_KEY), timeSpan);
this.log.warn('unrecognized media type:', messageMedia._, message);
break;
}
if(!processingWebPage) {
bubbleContainer.append(attachmentDiv);
}
/* if(bubble.classList.contains('is-message-empty') && (bubble.classList.contains('photo') || bubble.classList.contains('video'))) {
bubble.classList.add('no-tail');
if(!bubble.classList.contains('with-media-tail')) {
bubble.classList.add('use-border-radius');
}
} */
}
if(isStandaloneMedia) {
bubble.classList.add('just-media');
}
let savedFrom = '';
// const needName = ((peerId.isAnyChat() && (peerId !== message.fromId || our)) && message.fromId !== rootScope.myId) || message.viaBotId;
const needName = (message.fromId !== rootScope.myId && await this.managers.appPeersManager.isAnyGroup(peerId)) || message.viaBotId || (message as Message.message).pFlags.sponsored;
if(needName || fwdFrom || message.reply_to_mid) { // chat
let title: HTMLElement | DocumentFragment;
let titleVia: typeof title;
const isForwardFromChannel = message.from_id && message.from_id._ === 'peerChannel' && message.fromId === fwdFromId;
let isHidden = fwdFrom && !fwdFrom.from_id;
if(message.viaBotId) {
titleVia = document.createElement('span');
titleVia.innerText = '@' + (await this.managers.appUsersManager.getUser(message.viaBotId)).username;
titleVia.classList.add('peer-title');
bubble.classList.add('must-have-name');
}
if(isHidden) {
///////this.log('message to render hidden', message);
title = document.createElement('span');
setInnerHTML(title, wrapEmojiText(fwdFrom.from_name));
title.classList.add('peer-title');
//title = fwdFrom.from_name;
bubble.classList.add('hidden-profile');
} else {
title = new PeerTitle({peerId: fwdFromId || message.fromId}).element;
}
if(message.reply_to_mid && message.reply_to_mid !== this.chat.threadId && isMessage) {
await MessageRender.setReply({
chat: this.chat,
bubble,
bubbleContainer,
message
});
}
//this.log(title);
let nameDiv: HTMLElement;
if((fwdFromId || fwdFrom)) {
if(this.peerId !== rootScope.myId && !isForwardFromChannel) {
bubble.classList.add('forwarded');
}
if(message.savedFrom) {
savedFrom = message.savedFrom;
title.dataset.savedFrom = savedFrom;
}
nameDiv = document.createElement('div');
title.dataset.peerId = '' + fwdFromId;
if((this.peerId === rootScope.myId || this.peerId === REPLIES_PEER_ID || isForwardFromChannel) && !isStandaloneMedia) {
nameDiv.style.color = getPeerColorById(fwdFromId, false);
nameDiv.append(title);
} else {
/* const fromTitle = message.fromId === this.myID || appPeersManager.isBroadcast(fwdFromId || message.fromId) ? '' : `<div class="name" data-peer-id="${message.fromId}" style="color: ${appPeersManager.getPeerColorByID(message.fromId, false)};">${appPeersManager.getPeerTitle(message.fromId)}</div>`;
nameDiv.innerHTML = fromTitle + 'Forwarded from ' + title; */
const args: FormatterArguments = [title];
if(isStandaloneMedia) {
args.unshift(document.createElement('br'));
}
nameDiv.append(i18n('ForwardedFrom', [args]));
}
} else if(!message.viaBotId) {
if(!isStandaloneMedia && needName) {
nameDiv = document.createElement('div');
nameDiv.append(title);
const peer = await this.managers.appPeersManager.getPeer(message.fromId);
const pFlags = (peer as User.user)?.pFlags;
if(pFlags && (pFlags.scam || pFlags.fake)) {
nameDiv.append(generateFakeIcon(pFlags.scam));
}
if(!our) {
nameDiv.style.color = getPeerColorById(message.fromId, false);
}
nameDiv.dataset.peerId = '' + message.fromId;
} else /* if(!message.reply_to_mid) */ {
bubble.classList.add('hide-name');
}
}
if(message.viaBotId) {
if(!nameDiv) {
nameDiv = document.createElement('div');
} else {
nameDiv.append(' ');
}
const span = document.createElement('span');
span.append(i18n('ViaBot'), ' ', titleVia);
span.classList.add('is-via');
nameDiv.append(span);
}
if(nameDiv) {
nameDiv.classList.add('name');
nameContainer.append(nameDiv);
}
ret.needAvatar = this.chat.isAnyGroup && !isOut;
} else {
bubble.classList.add('hide-name');
}
if(this.chat.type === 'pinned') {
savedFrom = `${this.chat.peerId}_${message.mid}`;
}
const isThreadStarter = messageWithReplies && messageWithReplies.mid === this.chat.threadId;
if(isThreadStarter) {
bubble.classList.add('is-thread-starter', 'is-group-last');
}
if(savedFrom && (this.chat.type === 'pinned' || fwdFrom.saved_from_msg_id) && this.peerId !== REPLIES_PEER_ID) {
const goto = document.createElement('div');
goto.classList.add('bubble-beside-button', 'goto-original', 'tgico-arrow_next');
bubbleContainer.append(goto);
bubble.dataset.savedFrom = savedFrom;
bubble.classList.add('with-beside-button');
}
bubble.classList.add(isOut ? 'is-out' : 'is-in');
if(withReplies) {
const isFooter = MessageRender.renderReplies({
bubble,
bubbleContainer,
message: messageWithReplies,
messageDiv,
loadPromises,
lazyLoadQueue: this.lazyLoadQueue
});
if(isFooter) {
canHaveTail = true;
}
}
if(isMessage) {
this.appendReactionsElementToBubble(bubble, message, reactionsMessage);
}
/* if(isMessage) {
const reactionHover = document.createElement('div');
reactionHover.classList.add('bubble-reaction-hover');
contentWrapper.append(reactionHover);
} */
if(canHaveTail) {
bubble.classList.add('can-have-tail');
bubbleContainer.append(generateTail());
}
return ret;
}
private appendReactionsElementToBubble(bubble: HTMLElement, message: Message.message, reactionsMessage: Message.message, changedResults?: ReactionCount[]) {
if(this.peerId.isUser()/* || true */) {
return;
}
if(!reactionsMessage?.reactions || !reactionsMessage.reactions.results.length) {
return;
}
// message = this.appMessagesManager.getMessageWithReactions(message);
const reactionsElement = new ReactionsElement();
reactionsElement.init(reactionsMessage, 'block');
reactionsElement.render(changedResults);
if(bubble.classList.contains('is-message-empty')) {
bubble.querySelector('.bubble-content-wrapper').append(reactionsElement);
} else {
const messageDiv = bubble.querySelector('.message');
if(bubble.classList.contains('is-multiple-documents')) {
const documentContainer = messageDiv.lastElementChild as HTMLElement;
let documentMessageDiv = documentContainer.querySelector('.document-message');
let timeSpan: HTMLElement = documentMessageDiv && documentMessageDiv.querySelector('.time');
if(!timeSpan) {
timeSpan = MessageRender.setTime({
chatType: this.chat.type,
message,
reactionsMessage
});
}
reactionsElement.append(timeSpan);
if(!documentMessageDiv) {
documentMessageDiv = document.createElement('div');
documentMessageDiv.classList.add('document-message');
documentContainer.querySelector('.document-wrapper').prepend(documentMessageDiv);
}
documentMessageDiv.append(reactionsElement);
} else {
const timeSpan = Array.from(bubble.querySelectorAll('.time')).pop();
reactionsElement.append(timeSpan);
messageDiv.append(reactionsElement);
}
}
}
private prepareToSaveScroll(reverse?: boolean) {
const isMounted = !!this.chatInner.parentElement;
if(!isMounted) {
return;
}
this.log.warn('onRender');
const scrollSaver = this.createScrollSaver(reverse);
scrollSaver.save(); // * let's save scroll position by point before the slicing, not after
if(this.getRenderedLength() && !this.chat.setPeerPromise) {
const viewportSlice = this.getViewportSlice();
this.deleteViewportSlice(viewportSlice, true);
}
// scrollSaver.save(); // ! slicing will corrupt scroll position
// const saved = scrollSaver.getSaved();
// const hadScroll = saved.scrollHeight !== saved.clientHeight;
(this.messagesQueuePromise || Promise.resolve()).then(() => {
// scrollSaver.restore(_history.length === 1 && !reverse ? false : true);
scrollSaver.restore(reverse);
this.onRenderScrollSet(scrollSaver.getSaved());
});
}
public async performHistoryResult(historyResult: HistoryResult | {history: (Message.message | Message.messageService | number)[]}, reverse: boolean) {
const log = this.log.bindPrefix('perform-' + (Math.random() * 1000 | 0));
log.warn('start', this.chatInner.parentElement);
let history = historyResult.history;
history = history.slice(); // need
if(this.needReflowScroll) {
reflowScrollableElement(this.scrollable.container);
this.needReflowScroll = false;
}
const cb = (message: Message.message | Message.messageService) => {
if(!message) {
return;
} else if(message.pFlags.local) {
return this.processLocalMessageRender(message);
} else {
return this.safeRenderMessage(message, reverse);
}
};
const messages = await Promise.all(history.map((mid) => {
return typeof(mid) === 'number' ? this.chat.getMessage(mid) : mid;
}));
const setLoadedPromises: Promise<any>[] = [];
if(!this.scrollable.loadedAll['bottom'] || !this.scrollable.loadedAll['top']) {
let isEnd = (historyResult as HistoryResult).isEnd;
if(!isEnd) {
const historyStorage = await this.chat.getHistoryStorage();
const firstSlice = historyStorage.history.first;
const lastSlice = historyStorage.history.last;
isEnd = {top: false, bottom: false, both: false};
if(firstSlice.isEnd(SliceEnd.Bottom) && (!firstSlice.length || history.includes(firstSlice[0]))) {
isEnd.bottom = true;
}
if(lastSlice.isEnd(SliceEnd.Top) && (!lastSlice.length || history.includes(lastSlice[lastSlice.length - 1]))) {
isEnd.top = true;
}
}
if(!isEnd.bottom && this.setPeerOptions) {
const {lastMsgId, topMessage} = this.setPeerOptions;
this.setPeerOptions = undefined;
if(!lastMsgId || this.bubbles[topMessage] || lastMsgId === topMessage) {
isEnd.bottom = true;
}
}
if(isEnd.top) setLoadedPromises.push(this.setLoaded('top', true));
if(isEnd.bottom) setLoadedPromises.push(this.setLoaded('bottom', true));
}
await Promise.all(setLoadedPromises);
// ! it is important to insert bubbles to group reversed way
const length = history.length, promises: Promise<any>[] = [];
if(reverse) for(let i = 0; i < length; ++i) promises.push(cb(messages[i]));
else for(let i = length - 1; i >= 0; --i) promises.push(cb(messages[i]));
// cannot combine them into one promise
await Promise.all(promises);
await this.messagesQueuePromise;
if(this.scrollable.loadedAll.top && this.messagesQueueOnRenderAdditional) {
this.messagesQueueOnRenderAdditional();
if(this.messagesQueueOnRenderAdditional) {
this.messagesQueueOnRenderAdditional();
}
}
log.warn('performHistoryResult end');
}
private onRenderScrollSet(state?: {scrollHeight: number, clientHeight: number}) {
const className = 'has-sticky-dates';
if(!this.container.classList.contains(className)) {
const isLoading = !this.preloader.detached;
if(isLoading ||
(
state ??= {
scrollHeight: this.scrollable.scrollHeight,
clientHeight: this.scrollable.container.clientHeight
},
state.scrollHeight !== state.clientHeight
)
) {
/* for(const timestamp in this.dateMessages) {
const dateMessage = this.dateMessages[timestamp];
dateMessage.div.classList.add('is-sticky');
} */
const middleware = this.getMiddleware();
const callback = () => {
if(!middleware()) return;
this.container.classList.add(className);
};
if(this.willScrollOnLoad) {
callback();
} else {
setTimeout(callback, 600);
}
return;
}
}
this.willScrollOnLoad = undefined;
}
public onDatePick = (timestamp: number) => {
const peerId = this.peerId;
this.managers.appMessagesManager.requestHistory(peerId, 0, 2, -1, timestamp, this.chat.threadId).then((history) => {
if(!history?.messages?.length) {
this.log.error('no history!');
return;
} else if(this.peerId !== peerId) {
return;
}
this.chat.setMessageId((history.messages[0] as MyMessage).mid);
//console.log('got history date:', history);
});
};
public requestHistory(maxId: number, loadCount: number, backLimit: number) {
//const middleware = this.getMiddleware();
if(this.chat.type === 'chat' || this.chat.type === 'discussion') {
return this.managers.acknowledged.appMessagesManager.getHistory(this.peerId, maxId, loadCount, backLimit, this.chat.threadId);
} else if(this.chat.type === 'pinned') {
return this.managers.acknowledged.appMessagesManager.getSearch({
peerId: this.peerId,
inputFilter: {_: 'inputMessagesFilterPinned'},
maxId,
limit: loadCount,
backLimit
}).then((ackedResult) => {
return {
cached: ackedResult.cached,
result: Promise.resolve(ackedResult.result).then((value) => {
return {history: value.history.map((m) => m.mid)};
})
};
});
} else if(this.chat.type === 'scheduled') {
return this.managers.acknowledged.appMessagesManager.getScheduledMessages(this.peerId).then((ackedResult) => {
// this.setLoaded('top', true);
// this.setLoaded('bottom', true);
return {
cached: ackedResult.cached,
result: Promise.resolve(ackedResult.result).then((mids) => ({history: mids.slice().reverse()}))
};
});
}
}
private async animateAsLadder(additionMsgId: number, additionMsgIds: number[], isAdditionRender: boolean, backLimit: number, maxId: number) {
/* const middleware = this.getMiddleware();
await this.ladderDeferred; */
const log = this.log.bindPrefix('ladder');
if(this.chat.setPeerPromise && !this.resolveLadderAnimation) {
log.warn('will be delayed');
// @ts-ignore
this.resolveLadderAnimation = this.animateAsLadder.bind(this, additionMsgId, additionMsgIds, isAdditionRender, backLimit, maxId);
return;
}
/* if(!middleware()) {
return;
} */
if(!Object.keys(this.bubbles).length) {
log.warn('no bubbles');
return;
}
let sortedMids = getObjectKeysAndSort(this.bubbles, 'desc');
if(isAdditionRender && additionMsgIds.length) {
sortedMids = sortedMids.filter((mid) => !additionMsgIds.includes(mid));
}
let targetMid: number;
if(backLimit) {
targetMid = maxId || Math.max(...sortedMids); // * on discussion enter
} else {
if(additionMsgId) {
targetMid = additionMsgId;
} else { // * if maxId === 0
targetMid = Math.max(...sortedMids);
}
}
const topIds = sortedMids.slice(sortedMids.findIndex((mid) => targetMid > mid));
const middleIds = isAdditionRender ? [] : [targetMid];
const bottomIds = isAdditionRender ? [] : sortedMids.slice(0, sortedMids.findIndex((mid) => targetMid >= mid)).reverse();
if(DEBUG) {
log('targeting mid:', targetMid, maxId, additionMsgId,
topIds.map((m) => getServerMessageId(m)),
bottomIds.map((m) => getServerMessageId(m)));
}
const setBubbles: HTMLElement[] = [];
this.chatInner.classList.add('zoom-fading');
const delay = isAdditionRender ? 10 : 40;
const offsetIndex = isAdditionRender ? 0 : 1;
const animateAsLadder = (mids: number[], offsetIndex = 0) => {
const animationPromise = deferredPromise<void>();
let lastMsDelay = 0;
mids.forEach((mid, idx) => {
const bubble = this.bubbles[mid];
if(!bubble || this.skippedMids.has(mid)) {
log.warn('no bubble by mid:', mid);
return;
}
lastMsDelay = ((idx + offsetIndex) || 0.1) * delay;
//lastMsDelay = (idx + offsetIndex) * delay;
//lastMsDelay = (idx || 0.1) * 1000;
const contentWrapper = bubble.lastElementChild as HTMLElement;
const elementsToAnimate: HTMLElement[] = [contentWrapper];
const item = this.bubbleGroups.getItemByBubble(bubble);
if(item && item.group.avatar && item.group.lastItem === item) {
elementsToAnimate.push(item.group.avatar);
}
elementsToAnimate.forEach((element) => {
element.classList.add('zoom-fade', 'can-zoom-fade');
element.style.transitionDelay = lastMsDelay + 'ms';
});
if(idx === (mids.length - 1)) {
const onTransitionEnd = (e: TransitionEvent) => {
if(e.target !== contentWrapper) {
return;
}
animationPromise.resolve();
contentWrapper.removeEventListener('transitionend', onTransitionEnd);
};
contentWrapper.addEventListener('transitionend', onTransitionEnd);
}
setBubbles.push(...elementsToAnimate);
});
if(!mids.length) {
animationPromise.resolve();
}
return {lastMsDelay, animationPromise};
};
const topRes = animateAsLadder(topIds, offsetIndex);
const middleRes = animateAsLadder(middleIds);
const bottomRes = animateAsLadder(bottomIds, offsetIndex);
const promises = [topRes.animationPromise, middleRes.animationPromise, bottomRes.animationPromise];
const delays: number[] = [topRes.lastMsDelay, middleRes.lastMsDelay, bottomRes.lastMsDelay];
if(this.onAnimateLadder) {
await this.onAnimateLadder();
}
fastRaf(() => {
this.setStickyDateManually(); // ! maybe it's not efficient
setBubbles.forEach((element) => {
element.classList.remove('zoom-fade');
});
});
let promise: Promise<any>;
if(topIds.length || middleIds.length || bottomIds.length) {
promise = Promise.all(promises);
dispatchHeavyAnimationEvent(promise, Math.max(...delays) + 200) // * 200 - transition time
.then(() => {
fastRaf(() => {
setBubbles.forEach((element) => {
element.style.transitionDelay = '';
element.classList.remove('can-zoom-fade');
});
this.chatInner.classList.remove('zoom-fading');
});
// ! в хроме, каким-то образом из-за zoom-fade класса начинает прыгать скролл при подгрузке сообщений вверх,
// ! т.е. скролл не ставится, так же, как в сафари при translateZ на блок выше scrollable
// if(!IS_SAFARI) {
// this.needReflowScroll = true;
// }
});
}
return promise;
}
private async renderEmptyPlaceholder(
type: 'group' | 'saved' | 'noMessages' | 'noScheduledMessages' | 'greeting' | 'restricted',
bubble: HTMLElement,
message: any,
elements: (Node | string)[]
) {
const BASE_CLASS = 'empty-bubble-placeholder';
bubble.classList.add(BASE_CLASS, BASE_CLASS + '-' + type);
let title: HTMLElement;
if(type === 'group') title = i18n('GroupEmptyTitle1');
else if(type === 'saved') title = i18n('ChatYourSelfTitle');
else if(type === 'noMessages' || type === 'greeting') title = i18n('NoMessages');
else if(type === 'noScheduledMessages') title = i18n('NoScheduledMessages');
else if(type === 'restricted') {
title = document.createElement('span');
title.innerText = await this.managers.appPeersManager.getRestrictionReasonText(this.peerId);
}
title.classList.add('center', BASE_CLASS + '-title');
elements.push(title);
let listElements: HTMLElement[];
if(type === 'group') {
elements.push(i18n('GroupEmptyTitle2'));
listElements = [
i18n('GroupDescription1'),
i18n('GroupDescription2'),
i18n('GroupDescription3'),
i18n('GroupDescription4')
];
} else if(type === 'saved') {
listElements = [
i18n('ChatYourSelfDescription1'),
i18n('ChatYourSelfDescription2'),
i18n('ChatYourSelfDescription3'),
i18n('ChatYourSelfDescription4')
];
} else if(type === 'greeting') {
const subtitle = i18n('NoMessagesGreetingsDescription');
subtitle.classList.add('center', BASE_CLASS + '-subtitle');
// findAndSplice(this.messagesQueue, q => q.bubble === bubble);
const stickerDiv = document.createElement('div');
stickerDiv.classList.add(BASE_CLASS + '-sticker');
const middleware = this.getMiddleware();
await this.managers.appStickersManager.getGreetingSticker().then(async(doc) => {
if(!middleware()) return;
const loadPromises: Promise<any>[] = [];
await wrapSticker({
doc,
// doc: appDocsManager.getDoc("5431607541660389336"), // cubigator mockup
div: stickerDiv,
middleware,
lazyLoadQueue: this.lazyLoadQueue,
group: CHAT_ANIMATION_GROUP,
//play: !!message.pending || !multipleRender,
play: true,
loop: true,
withThumb: true,
loadPromises
});
attachClickEvent(stickerDiv, (e) => {
cancelEvent(e);
EmoticonsDropdown.onMediaClick({target: e.target});
});
return Promise.all(loadPromises);
});
// this.renderMessagesQueue({
// message,
// bubble,
// reverse: false,
// promises: [loadPromise]
// });
elements.push(subtitle, stickerDiv);
}
if(listElements) {
elements.push(
...listElements.map((elem) => {
const span = document.createElement('span');
span.classList.add(BASE_CLASS + '-list-item');
span.append(elem);
return span;
})
);
if(type === 'group') {
listElements.forEach((elem) => {
const i = document.createElement('span');
i.classList.add('tgico-check');
elem.prepend(i);
});
} else if(type === 'saved') {
listElements.forEach((elem) => {
const i = document.createElement('span');
i.classList.add(BASE_CLASS + '-list-bullet');
i.innerText = '•';
elem.prepend(i);
});
}
}
if(elements.length > 1) {
bubble.classList.add('has-description');
}
elements.forEach((element: any) => element.classList.add(BASE_CLASS + '-line'));
}
private async processLocalMessageRender(message: Message.message | Message.messageService, animate?: boolean) {
const isSponsored = !!(message as Message.message).pFlags.sponsored;
const middleware = this.getMiddleware();
const m = middlewarePromise(middleware);
return this.safeRenderMessage(message, isSponsored ? false : true, undefined, false, async(result) => {
const {bubble} = await m(result);
if(!bubble) {
return result;
}
bubble.classList.add('is-group-last', 'is-group-first');
const updatePosition = () => {
if(this.updatePlaceholderPosition === updatePosition) {
this.updatePlaceholderPosition = undefined;
}
appendTo[method](bubble);
};
if(!isSponsored) {
bubble.classList.add('bubble-first');
bubble.classList.remove('can-have-tail', 'is-in');
}
const elements: (Node | string)[] = [];
const isBot = await m(this.managers.appPeersManager.isBot(this.peerId));
let renderPromise: Promise<any>, appendTo = this.container, method: 'append' | 'prepend' = 'append';
if(this.chat.isRestricted) {
renderPromise = this.renderEmptyPlaceholder('restricted', bubble, message, elements);
} else if(isSponsored) {
let text: LangPackKey, mid: number, startParam: string, callback: () => void;
bubble.classList.add('avoid-selection');
const sponsoredMessage = this.sponsoredMessage = (message as Message.message).sponsoredMessage;
const peerId = getPeerId(sponsoredMessage.from_id);
// const peer = this.appPeersManager.getPeer(peerId);
if(sponsoredMessage.channel_post) {
text = 'OpenChannelPost';
mid = generateMessageId(sponsoredMessage.channel_post);
} else if(sponsoredMessage.start_param || isBot) {
text = 'Chat.Message.ViewBot';
startParam = sponsoredMessage.start_param;
} else {
text = await this.managers.appPeersManager.isAnyGroup(peerId) ? 'Chat.Message.ViewGroup' : 'Chat.Message.ViewChannel';
}
if(sponsoredMessage.chat_invite) {
callback = () => {
new PopupJoinChatInvite(sponsoredMessage.chat_invite_hash, sponsoredMessage.chat_invite as ChatInvite.chatInvite);
};
} else if(sponsoredMessage.chat_invite_hash) {
callback = () => {
const link: InternalLink = {
_: INTERNAL_LINK_TYPE.JOIN_CHAT,
invite: sponsoredMessage.chat_invite_hash
};
this.chat.appImManager.processInternalLink(link);
};
} else {
callback = () => {
rootScope.dispatchEvent('history_focus', {
peerId,
mid,
startParam
});
};
}
const button = Button('btn-primary btn-primary-transparent bubble-view-button', {
text
});
this.observer.observe(button, this.viewsObserverCallback);
if(callback) {
attachClickEvent(button, callback);
}
bubble.querySelector('.bubble-content').prepend(button);
return result;
} else if(isBot && message._ === 'message') {
const b = document.createElement('b');
b.append(i18n('BotInfoTitle'));
elements.push(b, '\n\n');
appendTo = this.chatInner;
method = 'prepend';
} else if(await m(this.managers.appPeersManager.isAnyGroup(this.peerId)) && (await m(this.managers.appPeersManager.getPeer(this.peerId))).pFlags.creator) {
renderPromise = this.renderEmptyPlaceholder('group', bubble, message, elements);
} else if(this.chat.type === 'scheduled') {
renderPromise = this.renderEmptyPlaceholder('noScheduledMessages', bubble, message, elements);
} else if(rootScope.myId === this.peerId) {
renderPromise = this.renderEmptyPlaceholder('saved', bubble, message, elements);
} else if(this.peerId.isUser() && !isBot && await m(this.chat.canSend()) && this.chat.type === 'chat') {
renderPromise = this.renderEmptyPlaceholder('greeting', bubble, message, elements);
} else {
renderPromise = this.renderEmptyPlaceholder('noMessages', bubble, message, elements);
}
if(renderPromise) {
await renderPromise;
}
if(elements.length) {
const messageDiv = bubble.querySelector('.message, .service-msg');
messageDiv.prepend(...elements);
}
const isWaitingForAnimation = !!this.messagesQueueOnRenderAdditional;
const noTransition = this.setPeerCached && !isWaitingForAnimation;
if(noTransition) {
const setOn = bubble.firstElementChild;
setOn.classList.add('no-transition');
if(this.chat.setPeerPromise) {
this.chat.setPeerPromise.catch(noop).finally(() => {
setOn.classList.remove('no-transition');
});
}
}
if(animate === undefined && !noTransition) {
animate = true;
}
if(isWaitingForAnimation || animate) {
this.updatePlaceholderPosition = updatePosition;
this.onAnimateLadder = () => {
// appendTo[method](bubble);
this.onAnimateLadder = undefined;
// need raf here because animation won't fire if this message is single
if(!this.messagesQueuePromise) {
return fastRafPromise();
}
};
} else if(this.chat.setPeerPromise) {
this.attachPlaceholderOnRender = () => {
this.attachPlaceholderOnRender = undefined;
updatePosition();
// appendTo[method](bubble);
};
} else {
this.updatePlaceholderPosition = updatePosition;
// appendTo[method](bubble);
}
if(!isWaitingForAnimation && animate) {
await m(getHeavyAnimationPromise());
const additionMsgIds = getObjectKeysAndSort(this.bubbles);
indexOfAndSplice(additionMsgIds, message.mid);
this.animateAsLadder(message.mid, additionMsgIds, false, 0, 0);
}
// if(!isSponsored) {
this.emptyPlaceholderBubble = bubble;
// }
return result;
});
}
private generateLocalMessageId(addOffset = 0) {
// const INCREMENT = 0x10;
let offset = (this.chat.type === 'scheduled' ? -1 : 0) + addOffset;
// offset = generateMessageId(offset);
// id: -Math.abs(+this.peerId * INCREMENT + offset),
const id = -Math.abs(offset);
const mid = -Math.abs(generateMessageId(id));
return {id, mid};
}
private async generateLocalFirstMessage<T extends boolean>(service?: T, fill?: (message: GenerateLocalMessageType<T>) => void, addOffset = 0): Promise<GenerateLocalMessageType<T>> {
const {id, mid} = this.generateLocalMessageId(addOffset);
let message: Omit<Message.message | Message.messageService, 'message'> & {message?: string} = {
_: service ? 'messageService' : 'message',
date: 0,
id,
mid,
peer_id: await this.managers.appPeersManager.getOutputPeer(this.peerId),
pFlags: {
local: true
}
};
if(!service) {
message.message = '';
}/* else {
(message as Message.messageService).action = {} as any;
} */
assumeType<GenerateLocalMessageType<T>>(message);
fill && fill(message);
const savedMessages = await this.managers.appMessagesManager.saveMessages([message], {storage: new Map() as any});
message = savedMessages[0];
message.mid = mid;
return message as any;
}
public getViewportSlice() {
// this.log.trace('viewport slice');
return getViewportSlice({
overflowElement: this.scrollable.container,
selector: '.bubbles-date-group .bubble:not(.is-date)',
extraSize: Math.max(700, windowSize.height) * 2
});
}
public deleteViewportSlice(slice: ReturnType<ChatBubbles['getViewportSlice']>, ignoreScrollSaving?: boolean) {
if(DO_NOT_SLICE_VIEWPORT_ON_RENDER) {
return;
}
const {invisibleTop, invisibleBottom} = slice;
const invisible = invisibleTop.concat(invisibleBottom);
if(!invisible.length) {
return;
}
if(invisibleTop.length) {
this.setLoaded('top', false);
this.getHistoryTopPromise = undefined;
}
if(invisibleBottom.length) {
this.setLoaded('bottom', false);
this.getHistoryBottomPromise = undefined;
}
const mids = invisible.map(({element}) => +element.dataset.mid);
let scrollSaver: ScrollSaver;
if(!!invisibleTop.length !== !!invisibleBottom.length && !ignoreScrollSaving) {
scrollSaver = this.createScrollSaver(!!invisibleTop.length);
scrollSaver.save();
}
this.deleteMessagesByIds(mids, false, true);
if(scrollSaver) {
scrollSaver.restore();
} else if(invisibleTop.length) {
this.scrollable.lastScrollPosition = this.scrollable.scrollTop;
}
}
public sliceViewport(ignoreHeavyAnimation?: boolean) {
// Safari cannot reset the scroll.
if(IS_SAFARI || (this.isHeavyAnimationInProgress && !ignoreHeavyAnimation) || DO_NOT_SLICE_VIEWPORT) {
return;
}
// const scrollSaver = new ScrollSaver(this.scrollable, true);
// scrollSaver.save();
const slice = this.getViewportSlice();
// if(IS_SAFARI) slice.invisibleTop = [];
this.deleteViewportSlice(slice);
// scrollSaver.restore();
}
private async setLoaded(side: SliceSides, value: boolean, checkPlaceholders = true) {
const willChange = this.scrollable.loadedAll[side] !== value;
if(!willChange) {
return;
}
const log = this.log.bindPrefix('setLoaded');
log('change', side, value);
this.scrollable.loadedAll[side] = value;
// return;
if(!checkPlaceholders) {
return;
}
if(!this.chat.isRestricted) {
if(side === 'bottom' && await this.managers.appPeersManager.isBroadcast(this.peerId)/* && false */) {
this.toggleSponsoredMessage(value);
}
if(side === 'top' && value && await this.managers.appPeersManager.isBot(this.peerId)) {
return this.renderBotPlaceholder();
}
}
return this.checkIfEmptyPlaceholderNeeded();
}
private async toggleSponsoredMessage(value: boolean) {
const _log = this.log.bindPrefix('sponsored');
_log('checking');
const {mid} = this.generateLocalMessageId(SPONSORED_MESSAGE_ID_OFFSET);
if(value) {
const middleware = this.getMiddleware(() => {
return this.scrollable.loadedAll.bottom && !this.bubbles[mid] && this.getSponsoredMessagePromise === promise;
});
const promise = this.getSponsoredMessagePromise = this.managers.appChatsManager.getSponsoredMessage(this.peerId.toChatId())
.then(async(sponsoredMessages) => {
const sponsoredMessage = sponsoredMessages.messages[0];
if(!sponsoredMessage) {
_log('no message');
return;
}
const messagePromise = this.generateLocalFirstMessage(false, (message) => {
message.message = sponsoredMessage.message;
message.from_id = sponsoredMessage.from_id;
message.entities = sponsoredMessage.entities;
message.pFlags.sponsored = true;
message.sponsoredMessage = sponsoredMessage;
}, SPONSORED_MESSAGE_ID_OFFSET);
return Promise.all([
messagePromise,
this.getHistoryTopPromise, // wait for top load and execute rendering after or with it
this.messagesQueuePromise
]).then(([message]) => {
if(!middleware()) return;
// this.processLocalMessageRender(message);
_log('rendering', message);
const promise = this.performHistoryResult({history: [message]}, false);
});
}).finally(() => {
this.getSponsoredMessagePromise = undefined;
});
} else {
_log('clearing rendered', mid);
this.deleteMessagesByIds([mid]);
this.getSponsoredMessagePromise = undefined;
}
}
private async renderBotPlaceholder() {
const _log = this.log.bindPrefix('bot placeholder');
const middleware = this.getMiddleware();
const result = await this.managers.acknowledged.appProfileManager.getProfile(this.peerId.toUserId());
_log('getting profile, cached:', result.cached);
const processPromise = result.result.then(async(userFull) => {
if(!middleware()) {
return;
}
if(!userFull.bot_info?.description) {
_log.warn('no description');
return this.checkIfEmptyPlaceholderNeeded();
}
const message = await this.generateLocalFirstMessage(false, (message) => {
message.message = userFull.bot_info.description;
});
if(!middleware()) {
return;
}
_log('rendering');
const renderPromise = this.processLocalMessageRender(message, !result.cached).then(() => {
_log('done');
});
return {renderPromise};
});
if(!result.cached) {
return;
}
return processPromise;
}
public async checkIfEmptyPlaceholderNeeded() {
if(this.scrollable.loadedAll.top &&
this.scrollable.loadedAll.bottom &&
this.emptyPlaceholderBubble === undefined &&
(
this.chat.isRestricted ||
!(await this.chat.getHistoryStorage()).count ||
(
!Object.keys(this.bubbles).length ||
// ! WARNING ! ! ! ! ! ! REPLACE LINE ABOVE WITH THESE
Object.keys(this.bubbles).length &&
!this.getRenderedLength()
) ||
(this.chat.type === 'scheduled' && !Object.keys(this.bubbles).length)
)
) {
this.log('inject empty peer placeholder');
const message = await this.generateLocalFirstMessage(true);
return {renderPromise: this.processLocalMessageRender(message)};
}
}
public getHistory1(maxId?: number, reverse?: boolean, isBackLimit?: boolean, additionMsgId?: number, justLoad?: boolean) {
const middleware = this.getMiddleware(justLoad ? undefined : () => {
return (reverse ? this.getHistoryTopPromise : this.getHistoryBottomPromise) === waitPromise;
});
const result = this.getHistory(maxId, reverse, isBackLimit, additionMsgId, justLoad, middleware);
const waitPromise = result.then((res) => res && (res.waitPromise || res.promise));
(reverse ? this.getHistoryTopPromise = waitPromise : this.getHistoryBottomPromise = waitPromise);
waitPromise.then(() => {
if(!middleware()) {
return;
}
(reverse ? this.getHistoryTopPromise = undefined : this.getHistoryBottomPromise = undefined);
if(!justLoad) {
// preload more
//if(!isFirstMessageRender) {
if(this.chat.type === 'chat'/* || this.chat.type === 'discussion' */) {
/* const storage = this.appMessagesManager.getHistoryStorage(peerId, this.chat.threadId);
const isMaxIdInHistory = storage.history.indexOf(maxId) !== -1;
if(isMaxIdInHistory || true) { // * otherwise it is a search or jump */
setTimeout(() => {
if(reverse) {
this.loadMoreHistory(true, true);
} else {
this.loadMoreHistory(false, true);
}
}, 0);
//}
}
//}
}
});
return result;
}
/**
* Load and render history
* @param maxId max message id
* @param reverse 'true' means up
* @param isBackLimit is search
* @param additionMsgId for the last message
* @param justLoad do not render
*/
public async getHistory(
maxId = 0,
reverse = false,
isBackLimit = false,
additionMsgId = 0,
justLoad = false,
middleware?: () => boolean
): Promise<{cached: boolean, promise: Promise<void>, waitPromise: Promise<any>}> {
const peerId = this.peerId;
const isBroadcast = await this.managers.appPeersManager.isBroadcast(peerId);
//console.time('appImManager call getHistory');
const pageCount = Math.min(30, windowSize.height / 40/* * 1.25 */ | 0);
//const loadCount = Object.keys(this.bubbles).length > 0 ? 50 : pageCount;
const realLoadCount = isBroadcast ? 20 : (Object.keys(this.bubbles).length > 0 ? Math.max(35, pageCount) : pageCount);
//const realLoadCount = pageCount;//const realLoadCount = 50;
let loadCount = realLoadCount;
/* if(TEST_SCROLL) {
//loadCount = 1;
if(Object.keys(this.bubbles).length > 0)
return {cached: false, promise: Promise.resolve(true)};
} */
if(TEST_SCROLL !== undefined) {
if(TEST_SCROLL) {
if(Object.keys(this.bubbles).length > 0) {
--TEST_SCROLL;
}
} else {
return {cached: false, promise: Promise.resolve(), waitPromise: Promise.resolve()};
}
}
////console.time('render history total');
let backLimit = 0;
if(isBackLimit) {
backLimit = loadCount;
if(!reverse) { // if not jump
loadCount = 0;
//maxId = this.appMessagesManager.incrementMessageId(maxId, 1);
}
}
let additionMsgIds: number[];
if(additionMsgId && !isBackLimit) {
if(this.chat.type === 'pinned') {
additionMsgIds = [additionMsgId];
} else {
const historyStorage = await this.chat.getHistoryStorage();
const slice = historyStorage.history.slice;
if(slice.length < loadCount && !slice.isEnd(SliceEnd.Both)) {
additionMsgIds = slice.slice();
// * filter last album, because we don't know is it the last item
for(let i = additionMsgIds.length - 1; i >= 0; --i) {
const message = await this.chat.getMessage(additionMsgIds[i]);
if((message as Message.message).grouped_id) additionMsgIds.splice(i, 1);
else break;
}
maxId = additionMsgIds[additionMsgIds.length - 1] || maxId;
}
}
}
/* const result = additionMsgID ?
{history: [additionMsgID]} :
appMessagesManager.getHistory(this.peerId, maxId, loadCount, backLimit); */
let result: AckedResult<MyHistoryResult> = await this.requestHistory(maxId, loadCount, backLimit) as any;
let resultPromise: typeof result['result'];
//const isFirstMessageRender = !!additionMsgID && result.cached && !appMessagesManager.getMessage(additionMsgID).grouped_id;
const isAdditionRender = additionMsgIds?.length && !result.cached;
const isFirstMessageRender = (this.isFirstLoad && backLimit && !result.cached) || isAdditionRender;
if(isAdditionRender) {
resultPromise = result.result;
result = {
cached: true,
result: Promise.resolve({history: additionMsgIds})
};
//additionMsgID = 0;
}
this.isFirstLoad = false;
const processResult = async(historyResult: Awaited<typeof result['result']>) => {
if((historyResult as HistoryResult).isEnd?.top) {
if(this.chat.type === 'discussion') { // * inject discussion start
const serviceStartMessageId = await this.managers.appMessagesManager.getThreadServiceMessageId(this.peerId, this.chat.threadId);
if(serviceStartMessageId) historyResult.history.push(serviceStartMessageId);
const mids = await this.chat.getMidsByMid(this.chat.threadId);
historyResult.history.push(...mids.reverse());
}
// synchronize bot placeholder appearance
await this.managers.appProfileManager.getProfileByPeerId(peerId);
// await this.setLoaded('top', true);
}
};
const sup = (historyResult: Awaited<typeof result['result']>) => {
return getHeavyAnimationPromise().then(() => {
return processResult(historyResult);
}).then(() => {
if(!isAdditionRender && additionMsgId) {
historyResult.history.unshift(additionMsgId);
}
return this.performHistoryResult(historyResult, reverse);
});
};
const processPromise = (_promise: typeof result['result']) => {
const promise = Promise.resolve(_promise).then((result) => {
if(middleware && !middleware()) {
throw PEER_CHANGED_ERROR;
}
if(justLoad) {
// нужно делать из-за ранней прогрузки
this.scrollable.onScroll();
// fastRaf(() => {
// this.scrollable.checkForTriggers();
// });
return;
}
return sup(result);
}, (err) => {
this.log.error('getHistory error:', err);
throw err;
});
return promise;
};
let promise: Promise<void>, cached: boolean;
if(!result.cached) {
cached = false;
promise = processPromise(result.result);
} else if(justLoad) {
// нужно делать из-за ранней прогрузки
this.scrollable.onScroll();
return null;
} else {
cached = true;
promise = sup(await result.result);
}
const waitPromise = isAdditionRender ? processPromise(resultPromise) : promise;
if(isFirstMessageRender && rootScope.settings.animationsEnabled/* && false */) {
let times = isAdditionRender ? 2 : 1;
this.messagesQueueOnRenderAdditional = () => {
this.log('messagesQueueOnRenderAdditional');
if(--times) return;
this.messagesQueueOnRenderAdditional = undefined;
const promise = this.animateAsLadder(additionMsgId, additionMsgIds, isAdditionRender, backLimit, maxId);
promise.then(() => {
setTimeout(() => { // preload messages
this.loadMoreHistory(reverse, true);
}, 0);
});
};
} else {
this.messagesQueueOnRenderAdditional = undefined;
}
if(justLoad) {
return null;
}
return {cached, promise, waitPromise};
}
public async setUnreadDelimiter() {
if(!(this.chat.type === 'chat' || this.chat.type === 'discussion')) {
return;
}
if(this.attachedUnreadBubble) {
return;
}
const historyMaxId = await this.chat.getHistoryMaxId();
let readMaxId = await this.managers.appMessagesManager.getReadMaxIdIfUnread(this.peerId, this.chat.threadId);
if(!readMaxId) return;
readMaxId = Object.keys(this.bubbles)
.filter((mid) => !this.bubbles[mid].classList.contains('is-out'))
.map((i) => +i)
.sort((a, b) => a - b)
.find((i) => i > readMaxId);
if(readMaxId && this.bubbles[readMaxId]) {
let bubble = this.bubbles[readMaxId];
if(this.firstUnreadBubble && this.firstUnreadBubble !== bubble) {
this.firstUnreadBubble.classList.remove('is-first-unread');
this.firstUnreadBubble = null;
}
if(readMaxId !== historyMaxId) {
bubble.classList.add('is-first-unread');
}
this.firstUnreadBubble = bubble;
this.attachedUnreadBubble = true;
}
}
public deleteEmptyDateGroups() {
const mustBeCount = this.stickyIntersector ? STICKY_OFFSET : 1;
let deleted = false;
for(const i in this.dateMessages) {
const dateMessage = this.dateMessages[i];
if(dateMessage.container.childElementCount === mustBeCount) { // only date div + sentinel div
dateMessage.container.remove();
if(this.stickyIntersector) {
this.stickyIntersector.unobserve(dateMessage.container, dateMessage.div);
}
delete this.dateMessages[i];
deleted = true;
// * no sense in it
/* if(dateMessage.div === this.previousStickyDate) {
this.previousStickyDate = undefined;
} */
}
}
if(!deleted) {
return;
}
if(!Object.keys(this.dateMessages).length) {
this.container.classList.remove('has-groups');
}
this.checkIfEmptyPlaceholderNeeded();
this.setStickyDateManually();
}
}
export function generateTail() {
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttributeNS(null, 'viewBox', '0 0 11 20');
svg.setAttributeNS(null, 'width', '11');
svg.setAttributeNS(null, 'height', '20');
svg.classList.add('bubble-tail');
const use = document.createElementNS('http://www.w3.org/2000/svg', 'use');
use.setAttributeNS(null, 'href', '#message-tail-filled');
svg.append(use);
return svg;
}