import m, { Children, Component, Vnode } from "mithril" import { ConversationItem, ConversationViewModel } from "./ConversationViewModel.js" import { MailViewer } from "./MailViewer.js" import { lang } from "../../../common/misc/LanguageViewModel.js" import { theme } from "../../../common/gui/theme.js" import { Button, ButtonType } from "../../../common/gui/base/Button.js" import { elementIdPart, isSameId } from "../../../common/api/common/utils/EntityUtils.js" import { CollapsedMailView } from "./CollapsedMailView.js" import { MailViewerViewModel } from "./MailViewerViewModel.js" import { px, size } from "../../../common/gui/size.js" import { Keys } from "../../../common/api/common/TutanotaConstants.js" import { keyManager, Shortcut } from "../../../common/misc/KeyManager.js" import { styles } from "../../../common/gui/styles.js" import { responsiveCardHMargin } from "../../../common/gui/cards.js" import { MailTypeRef } from "../../../common/api/entities/tutanota/TypeRefs" import { assertNotNull, isSameTypeRef, ofClass } from "@tutao/tutanota-utils" import { locator } from "../../../common/api/main/CommonLocator" import { UserError } from "../../../common/api/main/UserError" import { showUserError } from "../../../common/misc/ErrorHandlerImpl" import { MailViewerMoreActions } from "./MailViewerUtils" import { MailHeaderActions } from "./MailViewerHeader" export interface ConversationViewerAttrs { viewModel: ConversationViewModel actionableMailViewerViewModel: () => MailViewerViewModel | undefined delayBodyRendering: Promise actions: (mailViewerModel: MailViewerViewModel) => MailHeaderActions moreActions: (mailViewerModel: MailViewerViewModel) => MailViewerMoreActions } const SCROLL_FACTOR = 4 / 5 export const conversationCardMargin = size.hpad_large /** * Displays mails in a conversation */ export class ConversationViewer implements Component { private containerDom: HTMLElement | null = null private didScroll = false /** items from the last render, we need them to calculate the right subject based on the scroll position without the full re-render. */ private lastItems: readonly ConversationItem[] | null = null private readonly shortcuts: Array constructor(vnode: Vnode) { this.view = this.view.bind(this) this.shortcuts = this.setupShortcuts(vnode.attrs.actionableMailViewerViewModel) } private setupShortcuts(viewModel: () => MailViewerViewModel | undefined): Array { const userController = locator.logins.getUserController() const isReplyAndForwardEnabled = () => { const mailViewerViewModel = viewModel() return mailViewerViewModel != null && !mailViewerViewModel.isDraftMail() } const shortcuts: Shortcut[] = [ { key: Keys.PAGE_UP, exec: () => this.scrollUp(), help: "scrollUp_action", }, { key: Keys.PAGE_DOWN, exec: () => this.scrollDown(), help: "scrollDown_action", }, { key: Keys.HOME, exec: () => this.scrollToTop(), help: "scrollToTop_action", }, { key: Keys.END, exec: () => this.scrollToBottom(), help: "scrollToBottom_action", }, { key: Keys.R, exec: () => { assertNotNull(viewModel()).reply(false) }, enabled: isReplyAndForwardEnabled, help: "reply_action", }, { key: Keys.R, shift: true, exec: () => { assertNotNull(viewModel()).reply(true) }, enabled: isReplyAndForwardEnabled, help: "replyAll_action", }, ] if (userController.isInternalUser()) { shortcuts.push({ key: Keys.F, shift: true, enabled: isReplyAndForwardEnabled, exec: () => { assertNotNull(viewModel()).forward().catch(ofClass(UserError, showUserError)) }, help: "forward_action", }) } return shortcuts } oncreate() { keyManager.registerShortcuts(this.shortcuts) } onremove() { keyManager.unregisterShortcuts(this.shortcuts) } view(vnode: Vnode): Children { const { viewModel, delayBodyRendering } = vnode.attrs viewModel.init(delayBodyRendering) this.lastItems = viewModel.conversationItems() this.doScroll(viewModel, this.lastItems) return m(".fill-absolute.nav-bg.flex.col", [ // see comment for .scrollbar-gutter-stable-or-fallback m( ".flex-grow.overflow-y-scroll", { oncreate: (vnode) => { this.containerDom = vnode.dom as HTMLElement }, onremove: () => { console.log("remove container") }, }, this.renderItems(viewModel, this.lastItems, vnode.attrs.actions, vnode.attrs.moreActions), this.renderLoadingState(viewModel), this.renderFooter(), ), ]) } private renderFooter() { // Having more room at the bottom allows the last email so it is (almost) always in the same place on the screen. // We reduce space by 100 for the header of the viewer and a bit more const height = document.body.offsetHeight - (styles.isUsingBottomNavigation() ? size.navbar_height_mobile + size.bottom_nav_bar : size.navbar_height) - 300 return m(".mt-l.noprint", { style: { height: px(height), }, }) } private renderItems( viewModel: ConversationViewModel, entries: readonly ConversationItem[], actions: ConversationViewerAttrs["actions"], moreActions: ConversationViewerAttrs["moreActions"], ): Children { return entries.map((entry, position) => { switch (entry.type_ref.typeId) { case MailTypeRef.typeId: { const mailViewerViewModel = entry.viewModel const isPrimary = mailViewerViewModel === viewModel.primaryViewModel() // only pass in position if we do have an actual conversation position return this.renderViewer( mailViewerViewModel, isPrimary, actions(mailViewerViewModel), moreActions(mailViewerViewModel), viewModel.isFinished() ? position : null, ) } } }) } private renderLoadingState(viewModel: ConversationViewModel): Children { return viewModel.isConnectionLost() ? m( ".center", m(Button, { type: ButtonType.Secondary, label: "retry_action", click: () => viewModel.retry(), }), ) : !viewModel.isFinished() ? m( ".font-weight-600.center.mt-l" + "." + responsiveCardHMargin(), { style: { color: theme.on_surface, }, }, lang.get("loading_msg"), ) : null } private renderViewer( mailViewerViewModel: MailViewerViewModel, isPrimary: boolean, actions: MailHeaderActions, moreActions: MailViewerMoreActions, position: number | null, ): Children { const verificationBanner = null return m( ".mlr-safe-inset", m( ".border-radius-big.rel", { class: responsiveCardHMargin(), key: elementIdPart(mailViewerViewModel.mail.conversationEntry), style: { backgroundColor: theme.surface, marginTop: px(position == null || position === 0 ? 0 : conversationCardMargin), // column resize element takes some space, reduce margin to make the gap smaller marginLeft: styles.isSingleColumnLayout() ? undefined : px(size.hpad_large - size.column_resize_element_width), }, }, mailViewerViewModel.isCollapsed() ? m(CollapsedMailView, { viewModel: mailViewerViewModel, }) : m(MailViewer, { mailViewerViewModel, isPrimary, // we want to expand for the first email like when it's a forwarded email defaultQuoteBehavior: position === 0 ? "expand" : "collapse", moreActions, actions, }), ), ) } private doScroll(viewModel: ConversationViewModel, items: readonly ConversationItem[]) { const containerDom = this.containerDom if (!this.didScroll && containerDom && viewModel.isFinished()) { const conversationId = viewModel.primaryMail.conversationEntry this.didScroll = true // We need to do this at the end of the frame when every change is already applied. // Promise.resolve() schedules a microtask exactly where we need it. // RAF is too long and would flash the wrong frame Promise.resolve().then(() => { // There's a chance that item are not in sync with dom but it's very unlikely, this is the same frame after the last render we used the items // and viewModel is finished. const itemIndex = items.findIndex((e) => isSameTypeRef(e.type_ref, MailTypeRef) && isSameId(e.entryId, conversationId)) // Don't scroll if it's already the first (or if we didn't find it but that would be weird) if (itemIndex > 0) { const childDom = containerDom.childNodes[itemIndex] as HTMLElement const parentTop = containerDom.getBoundingClientRect().top const childTop = childDom.getBoundingClientRect().top const relativeTop = childTop - parentTop const top = relativeTop - conversationCardMargin * 2 - 10 containerDom.scrollTo({ top: top }) } }) } } private scrollUp(): void { if (this.containerDom) { this.containerDom.scrollBy({ top: -this.containerDom.clientHeight * SCROLL_FACTOR, behavior: "smooth" }) } } private scrollDown(): void { if (this.containerDom) { this.containerDom.scrollBy({ top: this.containerDom.clientHeight * SCROLL_FACTOR, behavior: "smooth" }) } } private scrollToTop(): void { if (this.containerDom) { this.containerDom.scrollTo({ top: 0, behavior: "smooth" }) } } private scrollToBottom(): void { if (this.containerDom) { this.containerDom.scrollTo({ top: this.containerDom.scrollHeight - this.containerDom.offsetHeight, behavior: "smooth", }) } } }