tutanota/src/mail-app/mail/view/ConversationViewer.ts

295 lines
9.4 KiB
TypeScript

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<unknown>
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<ConversationViewerAttrs> {
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<Shortcut>
constructor(vnode: Vnode<ConversationViewerAttrs>) {
this.view = this.view.bind(this)
this.shortcuts = this.setupShortcuts(vnode.attrs.actionableMailViewerViewModel)
}
private setupShortcuts(viewModel: () => MailViewerViewModel | undefined): Array<Shortcut> {
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<ConversationViewerAttrs>): 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",
})
}
}
}