/*
 * 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');

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' && false) {
    //   this.listenerSetter.add(rootScope)('missed_reactions_element', async({message, changedResults}) => {
    //     if(this.peerId !== message.peerId || !message.reactions || !message.reactions.results.length) {
    //       return;
    //     }
  
    //     const bubble = await this.getBubbleByMessage(message);
    //     if(!bubble) {
    //       return;
    //     }

    //     if(message.grouped_id) {
    //       const grouped = await this.getGroupedBubble(message.grouped_id);
    //       message = grouped.message;
    //     }

    //     this.appendReactionsElementToBubble(bubble, message, changedResults);
    //   });

    //   this.listenerSetter.add(rootScope)('messages_reactions', async(arr) => {
    //     let scrollSaver: ScrollSaver;

    //     const promises = arr.map(async({message, changedResults}) => {
    //       if(this.peerId !== message.peerId) {
    //         return;
    //       }
  
    //       const bubble = await this.getBubbleByMessage(message);
    //       if(!bubble) {
    //         return;
    //       }

    //       if(!scrollSaver) {
    //         scrollSaver = new ScrollSaver(this.scrollable, true);
    //         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 {
    //         rootScope.dispatchEvent('missed_reactions_element', {message, changedResults});
    //       }
    //     });

    //     await Promise.all(promises);

    //     if(scrollSaver) {
    //       scrollSaver.restore();
    //     }
    //   });
    // }

    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);
        });
      }
    });

    // this.listenerSetter.add(rootScope)('messages_views', (arr) => {
    //   fastRaf(() => {
    //     let scrollSaver: ScrollSaver;
    //     for(const {peerId, views, mid} of arr) {
    //       if(this.peerId !== peerId) return;

    //       const bubble = this.bubbles[mid];
    //       if(!bubble) return;
  
    //       const postViewsElements = Array.from(bubble.querySelectorAll('.post-views')) as HTMLElement[];
    //       if(postViewsElements.length) {
    //         const str = formatNumber(views, 1);
    //         let different = false;
    //         postViewsElements.forEach((postViews) => {
    //           if(different || postViews.innerHTML !== str) {
    //             if(!scrollSaver) {
    //               scrollSaver = new ScrollSaver(this.scrollable, true);
    //               scrollSaver.save();
    //             }

    //             different = true;
    //             postViews.innerHTML = 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 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) {
      const avatar = findUpClassName(target, 'user-avatar');
      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'))) {
      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 async getBubbleByMessage(message: Message.message | Message.messageService) {
  //   if(!(message as Message.message).grouped_id) return this.bubbles[message.mid];
  //   const grouped = await this.getGroupedBubble((message as Message.message).grouped_id);
  //   return grouped?.bubble;
  // }

  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 lastDateGroup = this.getLastDateGroup();
    if(lastDateGroup) {
      return lastDateGroup.lastElementChild as HTMLElement;
    }
  }

  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' && whichChild(bubble) === (this.stickyIntersector ? STICKY_OFFSET : 1)/*  && this.chat.setPeerPromise */) {
      const dateGroup = bubble.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 fromUp = maxBubbleId > 0 && (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);
      }

      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[];

    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;
      }
    }
    
    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 = await MessageRender.setTime({
      chatType: this.chat.type, 
      message
    });
    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);
    }

    /* 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 async appendReactionsElementToBubble(bubble: HTMLElement, message: Message.message, changedResults?: ReactionCount[]) {
    if(this.peerId.isUser()/*  || true */) {
      return;
    }

    const reactionsMessage = await this.managers.appMessagesManager.getGroupsFirstMessage(message);
    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 = await MessageRender.setTime({
            chatType: this.chat.type,
            message
          });
        }
        
        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 = new ScrollSaver(this.scrollable, reverse);
    
    if(this.getRenderedLength() && !this.chat.setPeerPromise) {
      const viewportSlice = this.getViewportSlice();
      this.deleteViewportSlice(viewportSlice, true);
    }
    
    scrollSaver.save();
    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) {
    // 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 = new ScrollSaver(this.scrollable, !!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)/*  || true */) {
      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;
}
