2023-02-21 17:09:14 +01:00
|
|
|
import m, { Children, Component, Vnode } from "mithril"
|
2023-03-29 16:29:16 +02:00
|
|
|
import { ConversationItem, ConversationViewModel } from "./ConversationViewModel.js"
|
2023-01-09 17:41:13 +01:00
|
|
|
import { MailViewer } from "./MailViewer.js"
|
2024-07-01 17:56:41 +02:00
|
|
|
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"
|
2023-01-09 17:41:13 +01:00
|
|
|
import { CollapsedMailView } from "./CollapsedMailView.js"
|
|
|
|
import { MailViewerViewModel } from "./MailViewerViewModel.js"
|
2024-07-01 17:56:41 +02:00
|
|
|
import { px, size } from "../../../common/gui/size.js"
|
2025-08-25 11:55:21 +02:00
|
|
|
import { Keys } from "../../../common/api/common/TutanotaConstants.js"
|
2024-07-01 17:56:41 +02:00
|
|
|
import { keyManager, Shortcut } from "../../../common/misc/KeyManager.js"
|
|
|
|
import { styles } from "../../../common/gui/styles.js"
|
|
|
|
import { responsiveCardHMargin } from "../../../common/gui/cards.js"
|
2025-03-10 16:19:11 +01:00
|
|
|
import { MailTypeRef } from "../../../common/api/entities/tutanota/TypeRefs"
|
2025-05-12 18:17:53 +02:00
|
|
|
import { assertNotNull, isSameTypeRef, ofClass } from "@tutao/tutanota-utils"
|
2025-02-07 13:37:37 +01:00
|
|
|
import { locator } from "../../../common/api/main/CommonLocator"
|
|
|
|
import { UserError } from "../../../common/api/main/UserError"
|
|
|
|
import { showUserError } from "../../../common/misc/ErrorHandlerImpl"
|
2025-05-12 18:17:53 +02:00
|
|
|
import { MailViewerMoreActions } from "./MailViewerUtils"
|
|
|
|
import { MailHeaderActions } from "./MailViewerHeader"
|
2023-01-09 17:41:13 +01:00
|
|
|
|
|
|
|
export interface ConversationViewerAttrs {
|
|
|
|
viewModel: ConversationViewModel
|
2025-02-07 13:37:37 +01:00
|
|
|
actionableMailViewerViewModel: () => MailViewerViewModel | undefined
|
2023-05-17 16:49:56 +02:00
|
|
|
delayBodyRendering: Promise<unknown>
|
2025-05-12 18:17:53 +02:00
|
|
|
actions: (mailViewerModel: MailViewerViewModel) => MailHeaderActions
|
|
|
|
moreActions: (mailViewerModel: MailViewerViewModel) => MailViewerMoreActions
|
2023-01-09 17:41:13 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
const SCROLL_FACTOR = 4 / 5
|
|
|
|
|
2023-04-21 16:58:36 +02:00
|
|
|
export const conversationCardMargin = size.hpad_large
|
2023-02-14 16:16:26 +01:00
|
|
|
|
2023-01-09 17:41:13 +01:00
|
|
|
/**
|
|
|
|
* 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
|
2025-02-07 13:37:37 +01:00
|
|
|
private readonly shortcuts: Array<Shortcut>
|
2023-01-09 17:41:13 +01:00
|
|
|
|
2025-02-07 13:37:37 +01:00
|
|
|
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 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: () => !viewModel()?.isDraftMail(),
|
|
|
|
help: "reply_action",
|
|
|
|
},
|
|
|
|
{
|
|
|
|
key: Keys.R,
|
|
|
|
shift: true,
|
|
|
|
exec: () => {
|
|
|
|
assertNotNull(viewModel()).reply(true)
|
|
|
|
},
|
|
|
|
enabled: () => !viewModel()?.isDraftMail(),
|
|
|
|
help: "replyAll_action",
|
|
|
|
},
|
|
|
|
]
|
|
|
|
if (userController.isInternalUser()) {
|
|
|
|
shortcuts.push({
|
|
|
|
key: Keys.F,
|
|
|
|
shift: true,
|
|
|
|
enabled: () => !viewModel()?.isDraftMail(),
|
|
|
|
exec: () => {
|
|
|
|
assertNotNull(viewModel()).forward().catch(ofClass(UserError, showUserError))
|
|
|
|
},
|
|
|
|
help: "forward_action",
|
|
|
|
})
|
|
|
|
}
|
|
|
|
return shortcuts
|
|
|
|
}
|
2023-01-09 17:41:13 +01:00
|
|
|
|
|
|
|
oncreate() {
|
|
|
|
keyManager.registerShortcuts(this.shortcuts)
|
|
|
|
}
|
|
|
|
|
|
|
|
onremove() {
|
|
|
|
keyManager.unregisterShortcuts(this.shortcuts)
|
|
|
|
}
|
|
|
|
|
|
|
|
view(vnode: Vnode<ConversationViewerAttrs>): Children {
|
2023-05-17 16:49:56 +02:00
|
|
|
const { viewModel, delayBodyRendering } = vnode.attrs
|
|
|
|
|
|
|
|
viewModel.init(delayBodyRendering)
|
|
|
|
|
2023-01-09 17:41:13 +01:00
|
|
|
this.lastItems = viewModel.conversationItems()
|
|
|
|
this.doScroll(viewModel, this.lastItems)
|
|
|
|
|
2023-03-29 16:29:16 +02:00
|
|
|
return m(".fill-absolute.nav-bg.flex.col", [
|
2023-07-04 17:45:49 +02:00
|
|
|
// see comment for .scrollbar-gutter-stable-or-fallback
|
2023-01-09 17:41:13 +01:00
|
|
|
m(
|
2023-07-04 17:45:49 +02:00
|
|
|
".flex-grow.overflow-y-scroll",
|
2023-01-09 17:41:13 +01:00
|
|
|
{
|
|
|
|
oncreate: (vnode) => {
|
|
|
|
this.containerDom = vnode.dom as HTMLElement
|
|
|
|
},
|
|
|
|
onremove: () => {
|
|
|
|
console.log("remove container")
|
|
|
|
},
|
|
|
|
},
|
2025-05-12 18:17:53 +02:00
|
|
|
this.renderItems(viewModel, this.lastItems, vnode.attrs.actions, vnode.attrs.moreActions),
|
2023-01-09 17:41:13 +01:00
|
|
|
this.renderLoadingState(viewModel),
|
2023-02-15 16:38:14 +01:00
|
|
|
this.renderFooter(),
|
2023-01-09 17:41:13 +01:00
|
|
|
),
|
|
|
|
])
|
|
|
|
}
|
|
|
|
|
2023-02-15 16:38:14 +01:00
|
|
|
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 =
|
2023-04-19 11:30:06 +02:00
|
|
|
document.body.offsetHeight - (styles.isUsingBottomNavigation() ? size.navbar_height_mobile + size.bottom_nav_bar : size.navbar_height) - 300
|
2023-11-23 12:51:47 +01:00
|
|
|
return m(".mt-l.noprint", {
|
2023-02-15 16:38:14 +01:00
|
|
|
style: {
|
|
|
|
height: px(height),
|
|
|
|
},
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
2025-05-12 18:17:53 +02:00
|
|
|
private renderItems(
|
|
|
|
viewModel: ConversationViewModel,
|
|
|
|
entries: readonly ConversationItem[],
|
|
|
|
actions: ConversationViewerAttrs["actions"],
|
|
|
|
moreActions: ConversationViewerAttrs["moreActions"],
|
|
|
|
): Children {
|
2023-01-09 17:41:13 +01:00
|
|
|
return entries.map((entry, position) => {
|
2025-03-10 16:19:11 +01:00
|
|
|
switch (entry.type_ref.typeId) {
|
|
|
|
case MailTypeRef.typeId: {
|
2025-07-24 13:12:19 +02:00
|
|
|
const mailViewerViewModel = entry.viewModel
|
|
|
|
const isPrimary = mailViewerViewModel === viewModel.primaryViewModel()
|
2023-01-09 17:41:13 +01:00
|
|
|
// only pass in position if we do have an actual conversation position
|
2025-05-12 18:17:53 +02:00
|
|
|
return this.renderViewer(
|
2025-07-24 13:12:19 +02:00
|
|
|
mailViewerViewModel,
|
2025-05-12 18:17:53 +02:00
|
|
|
isPrimary,
|
2025-07-24 13:12:19 +02:00
|
|
|
actions(mailViewerViewModel),
|
|
|
|
moreActions(mailViewerViewModel),
|
2025-05-12 18:17:53 +02:00
|
|
|
viewModel.isFinished() ? position : null,
|
|
|
|
)
|
2023-01-09 17:41:13 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
|
|
|
private renderLoadingState(viewModel: ConversationViewModel): Children {
|
|
|
|
return viewModel.isConnectionLost()
|
|
|
|
? m(
|
|
|
|
".center",
|
|
|
|
m(Button, {
|
|
|
|
type: ButtonType.Secondary,
|
|
|
|
label: "retry_action",
|
|
|
|
click: () => viewModel.retry(),
|
|
|
|
}),
|
2025-07-07 11:51:45 +02:00
|
|
|
)
|
2023-01-09 17:41:13 +01:00
|
|
|
: !viewModel.isFinished()
|
2025-07-07 11:51:45 +02:00
|
|
|
? m(
|
|
|
|
".font-weight-600.center.mt-l" + "." + responsiveCardHMargin(),
|
|
|
|
{
|
|
|
|
style: {
|
|
|
|
color: theme.content_button,
|
|
|
|
},
|
2023-01-09 17:41:13 +01:00
|
|
|
},
|
2025-07-07 11:51:45 +02:00
|
|
|
lang.get("loading_msg"),
|
|
|
|
)
|
|
|
|
: null
|
2023-01-09 17:41:13 +01:00
|
|
|
}
|
|
|
|
|
2025-05-12 18:17:53 +02:00
|
|
|
private renderViewer(
|
2025-07-17 17:47:14 +02:00
|
|
|
mailViewerViewModel: MailViewerViewModel,
|
2025-05-12 18:17:53 +02:00
|
|
|
isPrimary: boolean,
|
|
|
|
actions: MailHeaderActions,
|
|
|
|
moreActions: MailViewerMoreActions,
|
|
|
|
position: number | null,
|
|
|
|
): Children {
|
2025-07-24 13:12:19 +02:00
|
|
|
const verificationBanner = null
|
|
|
|
|
2023-01-09 17:41:13 +01:00
|
|
|
return m(
|
2023-05-03 14:35:54 +02:00
|
|
|
".mlr-safe-inset",
|
|
|
|
m(
|
|
|
|
".border-radius-big.rel",
|
|
|
|
{
|
2023-04-21 16:58:36 +02:00
|
|
|
class: responsiveCardHMargin(),
|
2025-07-17 17:47:14 +02:00
|
|
|
key: elementIdPart(mailViewerViewModel.mail.conversationEntry),
|
2023-05-03 14:35:54 +02:00
|
|
|
style: {
|
|
|
|
backgroundColor: theme.content_bg,
|
2023-04-21 16:58:36 +02:00
|
|
|
marginTop: px(position == null || position === 0 ? 0 : conversationCardMargin),
|
2023-05-03 14:35:54 +02:00
|
|
|
},
|
2023-01-09 17:41:13 +01:00
|
|
|
},
|
2025-07-17 17:47:14 +02:00
|
|
|
mailViewerViewModel.isCollapsed()
|
2023-05-03 14:35:54 +02:00
|
|
|
? m(CollapsedMailView, {
|
2025-07-17 17:47:14 +02:00
|
|
|
viewModel: mailViewerViewModel,
|
2025-07-07 11:51:45 +02:00
|
|
|
})
|
2023-05-03 14:35:54 +02:00
|
|
|
: m(MailViewer, {
|
2025-07-24 13:12:19 +02:00
|
|
|
mailViewerViewModel,
|
|
|
|
isPrimary,
|
2023-04-21 16:58:36 +02:00
|
|
|
// we want to expand for the first email like when it's a forwarded email
|
|
|
|
defaultQuoteBehavior: position === 0 ? "expand" : "collapse",
|
2025-07-24 13:12:19 +02:00
|
|
|
moreActions,
|
|
|
|
actions,
|
2025-07-07 11:51:45 +02:00
|
|
|
}),
|
2023-05-03 14:35:54 +02:00
|
|
|
),
|
2023-01-09 17:41:13 +01:00
|
|
|
)
|
|
|
|
}
|
|
|
|
|
|
|
|
private doScroll(viewModel: ConversationViewModel, items: readonly ConversationItem[]) {
|
|
|
|
const containerDom = this.containerDom
|
2023-02-20 17:25:29 +01:00
|
|
|
if (!this.didScroll && containerDom && viewModel.isFinished()) {
|
2023-01-09 17:41:13 +01:00
|
|
|
const conversationId = viewModel.primaryMail.conversationEntry
|
|
|
|
|
|
|
|
this.didScroll = true
|
2023-02-20 17:25:29 +01:00
|
|
|
// 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
|
2023-01-09 17:41:13 +01:00
|
|
|
// and viewModel is finished.
|
2025-03-10 16:19:11 +01:00
|
|
|
const itemIndex = items.findIndex((e) => isSameTypeRef(e.type_ref, MailTypeRef) && isSameId(e.entryId, conversationId))
|
2023-01-09 17:41:13 +01:00
|
|
|
// Don't scroll if it's already the first (or if we didn't find it but that would be weird)
|
2023-04-21 16:58:36 +02:00
|
|
|
if (itemIndex > 0) {
|
2023-03-29 16:29:16 +02:00
|
|
|
const childDom = containerDom.childNodes[itemIndex] as HTMLElement
|
|
|
|
const parentTop = containerDom.getBoundingClientRect().top
|
|
|
|
const childTop = childDom.getBoundingClientRect().top
|
|
|
|
const relativeTop = childTop - parentTop
|
2023-04-21 16:58:36 +02:00
|
|
|
const top = relativeTop - conversationCardMargin * 2 - 10
|
|
|
|
containerDom.scrollTo({ top: top })
|
2023-01-09 17:41:13 +01:00
|
|
|
}
|
|
|
|
})
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
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) {
|
2025-02-07 13:37:37 +01:00
|
|
|
this.containerDom.scrollTo({
|
|
|
|
top: this.containerDom.scrollHeight - this.containerDom.offsetHeight,
|
|
|
|
behavior: "smooth",
|
|
|
|
})
|
2023-01-09 17:41:13 +01:00
|
|
|
}
|
|
|
|
}
|
2023-04-19 10:29:37 +02:00
|
|
|
}
|