import m, { Children, Component, Vnode, VnodeDOM } from "mithril" import { Attachment } from "../mailFunctionality/SendMailModel.js" import { Button, ButtonType } from "./base/Button.js" import { Icons } from "./base/icons/Icons.js" import { formatStorageSize } from "../misc/Formatter.js" import { defer, DeferredObject, noOp, Thunk } from "@tutao/tutanota-utils" import { modal, ModalComponent } from "./base/Modal.js" import { focusNext, focusPrevious, Shortcut } from "../misc/KeyManager.js" import { PosRect } from "./base/Dropdown.js" import { Keys } from "../api/common/TutanotaConstants.js" import { px } from "./size.js" import { AllIcons, Icon } from "./base/Icon.js" import { theme } from "./theme.js" import { animations, height, opacity, transform, TransformEnum, width } from "./animation/Animations.js" import { ease } from "./animation/Easing.js" import { getFileBaseName, getFileExtension, isTutanotaFile } from "../api/common/utils/FileUtils.js" import { getSafeAreaInsetBottom } from "./HtmlUtils.js" import { hasError } from "../api/common/utils/ErrorUtils.js" import { BubbleButton, bubbleButtonHeight, bubbleButtonPadding } from "./base/buttons/BubbleButton.js" import { BootIcons } from "./base/icons/BootIcons.js" import { CALENDAR_MIME_TYPE, MAIL_MIME_TYPES, VCARD_MIME_TYPES } from "../file/FileController.js" import { lang } from "../misc/LanguageViewModel.js" export enum AttachmentType { GENERIC, CONTACT, CALENDAR, MAIL, } export type AttachmentBubbleAttrs = { attachment: Attachment download: Thunk | null open: Thunk | null remove: Thunk | null fileImport: Thunk | null type: AttachmentType } export class AttachmentBubble implements Component { private dom: HTMLElement | null = null view(vnode: Vnode): Children { const { attachment } = vnode.attrs if (isTutanotaFile(attachment) && hasError(attachment)) { return m(BubbleButton, { label: "emptyString_msg", text: "corrupted_msg", icon: Icons.Warning, onclick: noOp, }) } else { const extension = getFileExtension(attachment.name) const rest = getFileBaseName(attachment.name) return m( BubbleButton, { label: lang.makeTranslation("attachment_name", attachment.name), text: lang.makeTranslation("attachment_base_name", rest), icon: getAttachmentIcon(vnode.attrs.type), onclick: () => { showAttachmentDetailsPopup(this.dom!, vnode.attrs).then(() => this.dom?.focus()) }, }, `${extension}, ${formatStorageSize(Number(attachment.size))}`, ) } } oncreate(vnode: VnodeDOM): void { this.dom = vnode.dom as HTMLElement } } async function showAttachmentDetailsPopup(dom: HTMLElement, attrs: AttachmentBubbleAttrs): Promise { const parentRect = dom.getBoundingClientRect() const panel = new AttachmentDetailsPopup(parentRect, parentRect.width, attrs) panel.show() return panel.deferAfterClose } function getAttachmentIcon(type: AttachmentType): AllIcons { switch (type) { case AttachmentType.CONTACT: return Icons.People case AttachmentType.CALENDAR: return BootIcons.Calendar default: return Icons.Attachment } } export function getAttachmentType(mimeType: string) { if (Object.values(VCARD_MIME_TYPES).includes(mimeType)) { return AttachmentType.CONTACT } else if (mimeType === CALENDAR_MIME_TYPE) { return AttachmentType.CALENDAR } else if (Object.values(MAIL_MIME_TYPES).includes(mimeType)) { return AttachmentType.MAIL } return AttachmentType.GENERIC } export class AttachmentDetailsPopup implements ModalComponent { private readonly _shortcuts: Array = [] private domContent: HTMLElement | null = null private domPanel: HTMLElement | null = null private closeDefer: DeferredObject = defer() private focusedBeforeShown: HTMLElement | null = null get deferAfterClose(): Promise { return this.closeDefer.promise } constructor( private readonly targetRect: PosRect, private readonly targetWidth: number, private readonly attrs: AttachmentBubbleAttrs, ) { this._shortcuts.push({ key: Keys.ESC, exec: () => this.onClose(), help: "close_alt", }) this._shortcuts.push({ key: Keys.TAB, shift: true, exec: () => (this.domContent ? focusPrevious(this.domContent) : false), help: "selectPrevious_action", }) this._shortcuts.push({ key: Keys.TAB, shift: false, exec: () => (this.domContent ? focusNext(this.domContent) : false), help: "selectNext_action", }) if (attrs.open) { this._shortcuts.push({ key: Keys.O, exec: () => this.thenClose(attrs.open), help: "open_action", }) } if (attrs.download) { this._shortcuts.push({ key: Keys.D, exec: () => this.thenClose(attrs.download), help: "download_action", }) } if (attrs.remove) { this._shortcuts.push({ key: Keys.DELETE, exec: () => this.thenClose(attrs.remove), help: "remove_action", }) } if (attrs.fileImport) { this._shortcuts.push({ key: Keys.I, exec: () => this.thenClose(attrs.fileImport), help: "import_action", }) } this.view = this.view.bind(this) } view(): Children { return m( ".abs.bubble-color.border-radius.overflow-hidden.flex.flex-column", { class: bubbleButtonPadding(), style: { width: px(this.targetWidth), // see hack description below. if #5587 persists, we might try visibility: hidden instead? opacity: "0", }, oncreate: (vnode) => { this.domPanel = vnode.dom as HTMLElement // This is a hack to get "natural" view size but render it invisibly first and then show the panel with inferred size. // also focus the first tabbable element in the content after the panel opens. deferToNextFrame(() => this.animatePanel().then(() => this.domContent && focusNext(this.domContent))) }, onclick: () => this.onClose(), }, this.renderContent(), ) } private renderContent(): Children { // We are trying to make some contents look like the attachment button to make the transition look smooth. // It is somewhat harder as it looks different with mobile layout. const { remove, open, download, attachment, fileImport, type } = this.attrs return m( ".flex.mb-8.pr-12", { oncreate: (vnode) => (this.domContent = vnode.dom as HTMLElement), }, [ m(Icon, { icon: getAttachmentIcon(type), class: "pr-4 flex items-center", style: { fill: theme.on_surface, "background-color": "initial", minHeight: px(bubbleButtonHeight()), }, }), // flex-grow to cover the bubble in case it's bigger than our content m( ".flex.col.flex-grow", { style: { minHeight: px(bubbleButtonHeight()), // set line-height to position the text like in the button lineHeight: px(bubbleButtonHeight()), }, }, m(".span.break-all.smaller", attachment.name), // bottom info is inside the same column as file text to align them m(".flex.row.justify-between.items-center", [ m("span.smaller", `${formatStorageSize(Number(attachment.size))}`), m(".flex.no-wrap", [ remove ? m(Button, { type: ButtonType.Secondary, label: "remove_action", click: () => this.thenClose(remove) }) : null, fileImport ? m(Button, { type: ButtonType.Secondary, label: "import_action", click: () => this.thenClose(fileImport), }) : null, open ? m(Button, { type: ButtonType.Secondary, label: "open_action", click: () => this.thenClose(open) }) : null, download ? m(Button, { type: ButtonType.Secondary, label: "download_action", click: () => this.thenClose(download) }) : null, ]), ]), ), ], ) } private thenClose(action: Thunk | null): void { action?.() this.onClose() } private async animatePanel(): Promise { const { targetRect, domPanel, domContent } = this if (domPanel == null || domContent == null) return const initialHeight = bubbleButtonHeight() // there is a possibility that we get 0 here in some circumstances, but it's unclear when. // might have something to do with the opacity: 0 hack above or because the original 24ms // delay was not enough. https://github.com/tutao/tutanota/issues/5587 // 85 is a value that fits for a single line of attachment name, but looks weird for more while // keeping the buttons accessible. // if the reports continue, we can ask for logs. const targetHeight = domContent.offsetHeight === 0 ? 85 : domContent.offsetHeight if (domContent.offsetHeight === 0) { console.log( "got offsetHeight 0, panel contains content:", domPanel.contains(domContent), "content style:", domContent.style, "panel style:", domPanel.style, ) } // for very short attachment bubbles, we need to set a min width so the buttons fit. const targetWidth = Math.max(targetRect.width, 300) domPanel.style.width = px(targetRect.width) domPanel.style.height = px(initialHeight) // add half the difference between .button height of 44px and 30px for pixel-perfect positioning domPanel.style.top = px(targetRect.top) //Verify if the attachment bubble is going to overflow the screen //if yes, invert the side of the margin and discount the bubble width if (targetRect.left + targetWidth > window.innerWidth) { domPanel.style.right = px(24) } else { domPanel.style.left = px(targetRect.left) } const mutations = [opacity(0, 1, true), height(initialHeight, targetHeight)] if (targetRect.width !== targetWidth) { mutations.push(width(targetRect.width, targetWidth)) } // space below the panel after it fully extends minus a bit. const spaceBelow = window.innerHeight - getSafeAreaInsetBottom() - targetRect.top - targetHeight - initialHeight if (spaceBelow < 0) { mutations.push(transform(TransformEnum.TranslateY, 0, spaceBelow)) } await animations.add(domPanel, mutations, { easing: ease.out, }) } show() { this.focusedBeforeShown = document.activeElement as HTMLElement modal.displayUnique(this, true) } backgroundClick(e: MouseEvent): void { modal.remove(this) } async hideAnimation(): Promise { if (this.domPanel == null) return const startHeight = this.domPanel.offsetHeight const startWidth = this.domPanel.offsetWidth await animations.add(this.domPanel, [height(startHeight, 30), width(startWidth, this.targetWidth), opacity(1, 0, false)], { easing: ease.out, }) } onClose(): void { modal.remove(this) this.closeDefer.resolve() } shortcuts(): Shortcut[] { return this._shortcuts } popState(e: Event): boolean { modal.remove(this) return false } callingElement(): HTMLElement | null { return this.focusedBeforeShown } } /** try and execute stuff after the next rendering frame */ const deferToNextFrame = (fn: Thunk) => { window.requestAnimationFrame(() => { window.requestAnimationFrame(fn) }) }