diff --git a/src/calendar-app/calendar-app.ts b/src/calendar-app/calendar-app.ts index e96a5bbc81b..a76f0dbb4da 100644 --- a/src/calendar-app/calendar-app.ts +++ b/src/calendar-app/calendar-app.ts @@ -28,7 +28,6 @@ import { SettingsViewAttrs } from "../common/settings/Interfaces.js" import { CalendarSearchView, CalendarSearchViewAttrs } from "./calendar/search/view/CalendarSearchView.js" import { CalendarSettingsView } from "./calendar/settings/CalendarSettingsView.js" import { CalendarSearchViewModel } from "./calendar/search/view/CalendarSearchViewModel.js" - import { AppType } from "../common/misc/ClientConstants.js" assertMainOrNodeBoot() @@ -132,7 +131,6 @@ import("../mail-app/translations/en.js") } styles.init(calendarLocator.themeController) - const { CalendarBottomNav } = await import("./gui/CalendarBottomNav.js") const paths = applicationPaths({ login: makeViewResolver LoginViewModel }>( { @@ -221,7 +219,7 @@ import("../mail-app/translations/en.js") calendar: makeViewResolver< CalendarViewAttrs, CalendarView, - { drawerAttrsFactory: () => DrawerMenuAttrs; header: AppHeaderAttrs; calendarViewModel: CalendarViewModel; bottomNav: () => Children } + { drawerAttrsFactory: () => DrawerMenuAttrs; header: AppHeaderAttrs; calendarViewModel: CalendarViewModel } >( { prepareRoute: async (cache) => { @@ -233,15 +231,13 @@ import("../mail-app/translations/en.js") drawerAttrsFactory, header: await calendarLocator.appHeaderAttrs(), calendarViewModel: await calendarLocator.calendarViewModel(), - bottomNav: () => m(CalendarBottomNav), }, } }, - prepareAttrs: ({ header, calendarViewModel, drawerAttrsFactory, bottomNav }) => ({ + prepareAttrs: ({ header, calendarViewModel, drawerAttrsFactory }) => ({ drawerAttrs: drawerAttrsFactory(), header, calendarViewModel, - bottomNav, }), }, calendarLocator.logins, diff --git a/src/calendar-app/calendar/search/view/CalendarSearchView.ts b/src/calendar-app/calendar/search/view/CalendarSearchView.ts index bde1f2509b7..45a0ca736e4 100644 --- a/src/calendar-app/calendar/search/view/CalendarSearchView.ts +++ b/src/calendar-app/calendar/search/view/CalendarSearchView.ts @@ -18,7 +18,7 @@ import { lang, TranslationKey } from "../../../../common/misc/LanguageViewModel. import { BackgroundColumnLayout } from "../../../../common/gui/BackgroundColumnLayout.js" import { theme } from "../../../../common/gui/theme.js" import { DesktopListToolbar, DesktopViewerToolbar } from "../../../../common/gui/DesktopToolbars.js" -import { SearchListView, CalendarSearchListViewAttrs } from "./SearchListView.js" +import { CalendarSearchListViewAttrs, SearchListView } from "./SearchListView.js" import { isSameId } from "../../../../common/api/common/utils/EntityUtils.js" import { keyManager, Shortcut } from "../../../../common/misc/KeyManager.js" import { EnterMultiselectIconButton } from "../../../../common/gui/EnterMultiselectIconButton.js" @@ -50,15 +50,18 @@ import { MultiselectMode } from "../../../../common/gui/base/List.js" import { ClickHandler } from "../../../../common/gui/base/GuiUtils.js" import { showProgressDialog } from "../../../../common/gui/dialogs/ProgressDialog.js" import { CalendarOperation } from "../../gui/eventeditor-model/CalendarEventModel.js" -import { getEventWithDefaultTimes } from "../../../../common/api/common/utils/CommonCalendarUtils.js" +import { getEventWithDefaultTimes, setNextHalfHour } from "../../../../common/api/common/utils/CommonCalendarUtils.js" import { showNewCalendarEventEditDialog } from "../../gui/eventeditor-view/CalendarEventEditDialog.js" import { getSharedGroupName } from "../../../../common/sharing/GroupUtils.js" import { CalendarInfo } from "../../model/CalendarModel.js" import { Checkbox, CheckboxAttrs } from "../../../../common/gui/base/Checkbox.js" import { MobileActionAttrs, MobileActionBar } from "../../../../common/gui/MobileActionBar.js" -import { assertMainOrNode } from "../../../../common/api/common/Env.js" -import { CalendarBottomNav } from "../../../gui/CalendarBottomNav.js" +import { assertMainOrNode, isApp } from "../../../../common/api/common/Env.js" import { calendarLocator } from "../../../calendarLocator.js" +import { client } from "../../../../common/misc/ClientDetector.js" +import { FloatingActionButton } from "../../../gui/FloatingActionButton.js" +import { CALENDAR_PREFIX } from "../../../../common/misc/RouteChange.js" +import { ButtonColor } from "../../../../common/gui/base/Button.js" assertMainOrNode() @@ -137,6 +140,7 @@ export class CalendarSearchView extends BaseTopLevelView implements TopLevelView desktopToolbar: () => m(DesktopListToolbar, [m(".button-height")]), mobileHeader: () => this.renderMobileListHeader(vnode.attrs.header), columnLayout: this.getResultColumnLayout(), + floatingActionButton: this.renderFab.bind(this), }) }, }, @@ -160,6 +164,19 @@ export class CalendarSearchView extends BaseTopLevelView implements TopLevelView this.viewSlider = new ViewSlider([this.folderColumn, this.resultListColumn, this.resultDetailsColumn]) } + private renderFab(): Children { + if (client.isCalendarApp()) { + return m(FloatingActionButton, { + icon: Icons.Add, + title: "newEvent_action", + colors: ButtonColor.Fab, + action: () => this.createNewEventDialog(), + }) + } + + return null + } + private getResultColumnLayout() { return m(SearchListView, { listModel: this.searchViewModel.listModel, @@ -196,14 +213,6 @@ export class CalendarSearchView extends BaseTopLevelView implements TopLevelView private renderMobileListActionsHeader(header: AppHeaderAttrs) { const rightActions = [] - rightActions.push( - m(EnterMultiselectIconButton, { - clickAction: () => { - this.searchViewModel.listModel?.enterMultiselect() - }, - }), - ) - if (styles.isSingleColumnLayout()) { rightActions.push(this.renderHeaderRightView()) } @@ -259,6 +268,7 @@ export class CalendarSearchView extends BaseTopLevelView implements TopLevelView : !this.getSanitizedPreviewData(selectedEvent).isLoaded() ? null : this.renderEventDetails(selectedEvent), + floatingActionButton: this.renderFab.bind(this), }) } @@ -281,48 +291,49 @@ export class CalendarSearchView extends BaseTopLevelView implements TopLevelView ) } - private renderBottomNav() { - if (!styles.isSingleColumnLayout()) return m(CalendarBottomNav) + private renderSearchResultActions() { + if (this.viewSlider.focusedColumn !== this.resultDetailsColumn) return null - const isInMultiselect = this.searchViewModel.listModel?.state.inMultiselect ?? false - - if (this.viewSlider.focusedColumn === this.resultDetailsColumn) { - const selectedEvent = this.searchViewModel.getSelectedEvents()[0] - if (!selectedEvent) { - this.viewSlider.focus(this.resultListColumn) - return m(MobileActionBar, { actions: [] }) + const selectedEvent = this.searchViewModel.getSelectedEvents()[0] + if (!selectedEvent) { + this.viewSlider.focus(this.resultListColumn) + return m(MobileActionBar, { actions: [] }) + } + const previewModel = this.getSanitizedPreviewData(selectedEvent).getSync() + const actions: Array = [] + if (previewModel) { + if (previewModel.canSendUpdates) { + actions.push({ + icon: BootIcons.Mail, + title: "sendUpdates_label", + action: () => handleSendUpdatesClick(previewModel), + }) } - const previewModel = this.getSanitizedPreviewData(selectedEvent).getSync() - const actions: Array = [] - if (previewModel) { - if (previewModel.canSendUpdates) { - actions.push({ - icon: BootIcons.Mail, - title: "sendUpdates_label", - action: () => handleSendUpdatesClick(previewModel), - }) - } - if (previewModel.canEdit) { - actions.push({ - icon: Icons.Edit, - title: "edit_action", - action: (ev: MouseEvent, receiver: HTMLElement) => handleEventEditButtonClick(previewModel, ev, receiver), - }) - } - if (previewModel.canDelete) { - actions.push({ - icon: Icons.Trash, - title: "delete_action", - action: (ev: MouseEvent, receiver: HTMLElement) => handleEventDeleteButtonClick(previewModel, ev, receiver), - }) - } - } else { - this.getSanitizedPreviewData(selectedEvent).load() + if (previewModel.canEdit) { + actions.push({ + icon: Icons.Edit, + title: "edit_action", + action: (ev: MouseEvent, receiver: HTMLElement) => handleEventEditButtonClick(previewModel, ev, receiver), + }) + } + if (previewModel.canDelete) { + actions.push({ + icon: Icons.Trash, + title: "delete_action", + action: (ev: MouseEvent, receiver: HTMLElement) => handleEventDeleteButtonClick(previewModel, ev, receiver), + }) } - return m(MobileActionBar, { actions }) + } else { + this.getSanitizedPreviewData(selectedEvent).load() } - return m(CalendarBottomNav) + return actions.map((action) => + m(IconButton, { + title: action.title, + icon: action.icon, + click: action.action, + }), + ) } private searchBarPlaceholder() { @@ -340,14 +351,24 @@ export class CalendarSearchView extends BaseTopLevelView implements TopLevelView } private renderHeaderRightView(): Children { - const restriction = this.searchViewModel.getRestriction() - - if (styles.isUsingBottomNavigation()) { + if (styles.isUsingBottomNavigation() && !client.isCalendarApp()) { return m(IconButton, { click: () => this.createNewEventDialog(), title: "newEvent_action", icon: Icons.Add, }) + } else if (client.isCalendarApp()) { + return m.fragment({}, [ + this.renderSearchResultActions(), + m(NavButton, { + label: "calendar_label", + hideLabel: true, + icon: () => BootIcons.Calendar, + href: CALENDAR_PREFIX, + centred: true, + fillSpaceAround: false, + }), + ]) } } @@ -434,7 +455,7 @@ export class CalendarSearchView extends BaseTopLevelView implements TopLevelView } private async createNewEventDialog(): Promise { - const dateToUse = this.searchViewModel.startDate ?? new Date() + const dateToUse = this.searchViewModel.startDate ? setNextHalfHour(new Date(this.searchViewModel.startDate)) : setNextHalfHour(new Date()) // Disallow creation of events when there is no existing calendar const lazyCalendarInfo = this.searchViewModel.getLazyCalendarInfos() @@ -520,7 +541,6 @@ export class CalendarSearchView extends BaseTopLevelView implements TopLevelView }), ...attrs.header, }), - bottomNav: this.renderBottomNav(), }), ) } diff --git a/src/calendar-app/calendar/settings/CalendarSettingsView.ts b/src/calendar-app/calendar/settings/CalendarSettingsView.ts index 8426df90b47..1a0e3a55443 100644 --- a/src/calendar-app/calendar/settings/CalendarSettingsView.ts +++ b/src/calendar-app/calendar/settings/CalendarSettingsView.ts @@ -35,16 +35,16 @@ import { ReferralSettingsViewer } from "../../../common/settings/ReferralSetting import { GroupDetailsView } from "../../../common/settings/groups/GroupDetailsView.js" import { TemplateDetailsViewer } from "../../../mail-app/settings/TemplateDetailsViewer.js" import { KnowledgeBaseSettingsDetailsViewer } from "../../../mail-app/settings/KnowledgeBaseListView.js" -import { NavButtonAttrs, NavButtonColor } from "../../../common/gui/base/NavButton.js" +import { NavButton, NavButtonAttrs, NavButtonColor } from "../../../common/gui/base/NavButton.js" import { CustomerInfoTypeRef, CustomerTypeRef, User } from "../../../common/api/entities/sys/TypeRefs.js" import { Dialog } from "../../../common/gui/base/Dialog.js" import { AboutDialog } from "../../../common/settings/AboutDialog.js" import { SettingsViewAttrs, UpdatableSettingsDetailsViewer, UpdatableSettingsViewer } from "../../../common/settings/Interfaces.js" import { NotificationSettingsViewer } from "./NotificationSettingsViewer.js" import { GlobalSettingsViewer } from "./GlobalSettingsViewer.js" -import { CalendarBottomNav } from "../../gui/CalendarBottomNav.js" import { calendarLocator } from "../../calendarLocator.js" import { locator } from "../../../common/api/main/CommonLocator.js" +import { CALENDAR_PREFIX } from "../../../common/misc/RouteChange.js" assertMainOrNode() @@ -153,7 +153,15 @@ export class CalendarSettingsView extends BaseTopLevelView implements TopLevelVi columnType: "first", title: lang.getMaybeLazy(this._selectedFolder.name), actions: [], - primaryAction: () => null, + primaryAction: () => + m(NavButton, { + label: "calendar_label", + hideLabel: true, + icon: () => BootIcons.Calendar, + href: CALENDAR_PREFIX, + centred: true, + fillSpaceAround: false, + }), }), desktopToolbar: () => null, }), @@ -318,7 +326,6 @@ export class CalendarSettingsView extends BaseTopLevelView implements TopLevelVi header: m(Header, { ...attrs.header, }), - bottomNav: m(CalendarBottomNav), }), ) } diff --git a/src/calendar-app/calendar/settings/SettingsView.ts b/src/calendar-app/calendar/settings/SettingsView.ts deleted file mode 100644 index 5db4c9d4a7b..00000000000 --- a/src/calendar-app/calendar/settings/SettingsView.ts +++ /dev/null @@ -1,510 +0,0 @@ -import m, { Children, Vnode, VnodeDOM } from "mithril" -import stream from "mithril/stream" -import { assertMainOrNode, isApp, isIOSApp } from "../../../common/api/common/Env.js" -import { EntityUpdateData, isUpdateForTypeRef } from "../../../common/api/common/utils/EntityUpdateUtils.js" -import { TopLevelView } from "../../../TopLevelView.js" -import { Header } from "../../../common/gui/Header.js" -import { LoginController } from "../../../common/api/main/LoginController.js" -import { BaseTopLevelView } from "../../../common/gui/BaseTopLevelView.js" -import { ViewSlider } from "../../../common/gui/nav/ViewSlider.js" -import { ColumnType, ViewColumn } from "../../../common/gui/base/ViewColumn.js" -import { SettingsFolder } from "../../../common/settings/SettingsFolder.js" -import { LazyLoaded } from "@tutao/tutanota-utils" -import { FeatureType, GroupType, LegacyPlans } from "../../../common/api/common/TutanotaConstants.js" -import { BootIcons } from "../../../common/gui/base/icons/BootIcons.js" -import { LoginSettingsViewer } from "../../../common/settings/login/LoginSettingsViewer.js" -import { Icons } from "../../../common/gui/base/icons/Icons.js" -import { AppearanceSettingsViewer } from "../../../common/settings/AppearanceSettingsViewer.js" -import { FolderColumnView } from "../../../common/gui/FolderColumnView.js" -import { SidebarSection } from "../../../common/gui/SidebarSection.js" -import { SettingsFolderRow } from "../../../common/settings/SettingsFolderRow.js" -import { size } from "../../../common/gui/size.js" -import { lang } from "../../../common/misc/LanguageViewModel.js" -import { BackgroundColumnLayout } from "../../../common/gui/BackgroundColumnLayout.js" -import { theme } from "../../../common/gui/theme.js" -import { styles } from "../../../common/gui/styles.js" -import { MobileHeader } from "../../../common/gui/MobileHeader.js" -import { getAvailableDomains } from "../../../common/settings/mailaddress/MailAddressesUtils.js" -import { UserListView } from "../../../common/settings/UserListView.js" -import { showUserImportDialog, UserViewer } from "../../../common/settings/UserViewer.js" -import { exportUserCsv } from "../../../common/settings/UserDataExporter.js" -import { GroupListView } from "../../../mail-app/settings/groups/GroupListView.js" -import { WhitelabelSettingsViewer } from "../../../common/settings/whitelabel/WhitelabelSettingsViewer.js" -import { SubscriptionViewer } from "../../../common/subscription/SubscriptionViewer.js" -import { PaymentViewer } from "../../../common/subscription/PaymentViewer.js" -import { ReferralSettingsViewer } from "../../../common/settings/ReferralSettingsViewer.js" -import { GroupDetailsView } from "../../../common/settings/groups/GroupDetailsView.js" -import { TemplateDetailsViewer } from "../../../mail-app/settings/TemplateDetailsViewer.js" -import { KnowledgeBaseSettingsDetailsViewer } from "../../../mail-app/settings/KnowledgeBaseListView.js" -import { NavButtonAttrs, NavButtonColor } from "../../../common/gui/base/NavButton.js" -import { CustomerInfoTypeRef, CustomerTypeRef, User } from "../../../common/api/entities/sys/TypeRefs.js" -import { Dialog } from "../../../common/gui/base/Dialog.js" -import { AboutDialog } from "../../../common/settings/AboutDialog.js" -import { SettingsViewAttrs, UpdatableSettingsDetailsViewer, UpdatableSettingsViewer } from "../../../common/settings/Interfaces.js" -import { NotificationSettingsViewer } from "./NotificationSettingsViewer.js" -import { GlobalSettingsViewer } from "./GlobalSettingsViewer.js" -import { locator } from "../../../common/api/main/CommonLocator.js" -import { CalendarBottomNav } from "../../gui/CalendarBottomNav.js" - -assertMainOrNode() - -export class SettingsView extends BaseTopLevelView implements TopLevelView { - viewSlider: ViewSlider - private readonly _settingsFoldersColumn: ViewColumn - private readonly _settingsColumn: ViewColumn - private readonly _settingsDetailsColumn: ViewColumn - private readonly _userFolders: SettingsFolder[] - private readonly _adminFolders: SettingsFolder[] - private readonly logins: LoginController - private _selectedFolder: SettingsFolder - private _currentViewer: UpdatableSettingsViewer | null = null - private showBusinessSettings: stream = stream(false) - private readonly _targetFolder: string - private readonly _targetRoute: string - detailsViewer: UpdatableSettingsDetailsViewer | null = null // the component for the details column. can be set by settings views - - _customDomains: LazyLoaded - - constructor(vnode: Vnode) { - super() - this.logins = vnode.attrs.logins - this._userFolders = [ - new SettingsFolder( - "login_label", - () => BootIcons.Contacts, - "login", - () => new LoginSettingsViewer(locator.credentialsProvider, isApp() ? locator.systemFacade : null), - undefined, - ), - new SettingsFolder( - "appearanceSettings_label", - () => Icons.Palette, - "appearance", - () => new AppearanceSettingsViewer(), - undefined, - ), - new SettingsFolder( - "notificationSettings_action", - () => Icons.Bell, - "notifications", - () => new NotificationSettingsViewer(), - undefined, - ), - ] - - this._adminFolders = [] - - this._selectedFolder = this._userFolders[0] - - this._settingsFoldersColumn = new ViewColumn( - { - view: () => { - return m(FolderColumnView, { - drawer: vnode.attrs.drawerAttrs, - button: null, - content: m(".flex.flex-grow.col", [ - m( - SidebarSection, - { - name: "userSettings_label", - }, - this._renderSidebarSectionChildren(this._userFolders), - ), - this.logins.isUserLoggedIn() && this.logins.getUserController().isGlobalOrLocalAdmin() - ? m( - SidebarSection, - { - name: "adminSettings_label", - }, - this._renderSidebarSectionChildren(this._adminFolders), - ) - : null, - locator.domainConfigProvider().getCurrentDomainConfig().firstPartyDomain ? this._aboutThisSoftwareLink() : null, - ]), - ariaLabel: "settings_label", - }) - }, - }, - ColumnType.Foreground, - { - minWidth: size.first_col_min_width, - maxWidth: size.first_col_max_width, - headerCenter: () => lang.get("settings_label"), - }, - ) - this._settingsColumn = new ViewColumn( - { - // the CSS improves the situation on devices with notches (no control elements - // are concealed), but there's still room for improvement for scrollbars - view: () => - m(BackgroundColumnLayout, { - backgroundColor: theme.navigation_bg, - columnLayout: m( - ".mlr-safe-inset.fill-absolute.content-bg", - { - class: styles.isUsingBottomNavigation() ? "" : "border-radius-top-left-big", - }, - m(this.getCurrentViewer()), - ), - mobileHeader: () => - m(MobileHeader, { - ...vnode.attrs.header, - backAction: () => this.viewSlider.focusPreviousColumn(), - columnType: "first", - title: lang.getMaybeLazy(this._selectedFolder.name), - actions: [], - primaryAction: () => null, - }), - desktopToolbar: () => null, - }), - }, - ColumnType.Background, - { - minWidth: 400, - maxWidth: 600, - headerCenter: () => lang.getMaybeLazy(this._selectedFolder.name), - }, - ) - this._settingsDetailsColumn = new ViewColumn( - { - view: () => - m(BackgroundColumnLayout, { - backgroundColor: theme.navigation_bg, - columnLayout: m(".mlr-safe-inset.fill-absolute.content-bg", this.detailsViewer ? this.detailsViewer.renderView() : m("")), - mobileHeader: () => - m(MobileHeader, { - ...vnode.attrs.header, - backAction: () => this.viewSlider.focusPreviousColumn(), - columnType: "other", - title: lang.getMaybeLazy(this._selectedFolder.name), - actions: [], - primaryAction: () => null, - }), - desktopToolbar: () => null, - }), - }, - ColumnType.Background, - { - minWidth: 500, - maxWidth: 2400, - headerCenter: () => lang.get("settings_label"), - }, - ) - this.viewSlider = new ViewSlider([this._settingsFoldersColumn, this._settingsColumn, this._settingsDetailsColumn]) - - this._customDomains = new LazyLoaded(async () => { - const domainInfos = await getAvailableDomains(this.logins, true) - return domainInfos.map((info) => info.domain) - }) - - this._customDomains.getAsync().then(() => m.redraw()) - - this._targetFolder = m.route.param("folder") - this._targetRoute = m.route.get() - } - - private async populateAdminFolders() { - await this.updateShowBusinessSettings() - const currentPlanType = await this.logins.getUserController().getPlanType() - const isLegacyPlan = LegacyPlans.includes(currentPlanType) - - if (await this.logins.getUserController().canHaveUsers()) { - this._adminFolders.push( - new SettingsFolder( - "adminUserList_action", - () => BootIcons.Contacts, - "users", - () => - new UserListView( - (viewer) => this.replaceDetailsViewer(viewer), - () => this.focusSettingsDetailsColumn(), - () => !isApp() && this._customDomains.isLoaded() && this._customDomains.getLoaded().length > 0, - () => showUserImportDialog(this._customDomains.getLoaded()), - () => exportUserCsv(locator.entityClient, this.logins, locator.fileController, locator.counterFacade), - ), - undefined, - ), - ) - if (!this.logins.isEnabled(FeatureType.WhitelabelChild)) { - this._adminFolders.push( - new SettingsFolder( - "sharedMailboxes_label", - () => Icons.People, - "groups", - () => - new GroupListView( - (viewer) => this.replaceDetailsViewer(viewer), - () => this.focusSettingsDetailsColumn(), - ), - undefined, - ), - ) - } - } - - if (this.logins.getUserController().isGlobalAdmin()) { - this._adminFolders.push( - new SettingsFolder( - "globalSettings_label", - () => BootIcons.Settings, - "global", - () => new GlobalSettingsViewer(), - undefined, - ), - ) - - if (!this.logins.isEnabled(FeatureType.WhitelabelChild) && !isIOSApp()) { - this._adminFolders.push( - new SettingsFolder( - "whitelabel_label", - () => Icons.Wand, - "whitelabel", - () => new WhitelabelSettingsViewer(locator.entityClient, this.logins), - undefined, - ), - ) - } - } - - if (!this.logins.isEnabled(FeatureType.WhitelabelChild)) { - if (this.logins.getUserController().isGlobalAdmin()) { - this._adminFolders.push( - new SettingsFolder( - "adminSubscription_action", - () => BootIcons.Premium, - "subscription", - () => new SubscriptionViewer(currentPlanType, isIOSApp() ? locator.mobilePaymentsFacade : null, locator.appStorePaymentPicker), - undefined, - ).setIsVisibleHandler(() => !isIOSApp() || !this.logins.getUserController().isFreeAccount()), - ) - - this._adminFolders.push( - new SettingsFolder( - "adminPayment_action", - () => Icons.CreditCard, - "invoice", - () => new PaymentViewer(), - undefined, - ), - ) - - this._adminFolders.push( - new SettingsFolder( - "referralSettings_label", - () => BootIcons.Share, - "referral", - () => new ReferralSettingsViewer(), - undefined, - ).setIsVisibleHandler(() => !this.showBusinessSettings()), - ) - } - } - m.redraw() - } - - private replaceDetailsViewer(viewer: UserViewer | GroupDetailsView | TemplateDetailsViewer | KnowledgeBaseSettingsDetailsViewer | null) { - return (this.detailsViewer = viewer) - } - - oncreate(vnode: Vnode) { - locator.eventController.addEntityListener(this.entityListener) - this.populateAdminFolders().then(() => { - // We have to wait for the folders to be initialized before setting the URL, - // otherwise we won't find the requested folder and will just pick the default folder - const stillAtDefaultUrl = m.route.get() === this._userFolders[0].url - if (stillAtDefaultUrl) { - this.onNewUrl({ folder: this._targetFolder }, this._targetRoute) - } - }) - } - - onremove(vnode: VnodeDOM) { - locator.eventController.removeEntityListener(this.entityListener) - } - - private entityListener = (updates: EntityUpdateData[], eventOwnerGroupId: Id) => { - return this.entityEventsReceived(updates, eventOwnerGroupId) - } - - view({ attrs }: Vnode): Children { - return m( - "#settings.main-view", - m(this.viewSlider, { - header: m(Header, { - ...attrs.header, - }), - bottomNav: m(CalendarBottomNav), - }), - ) - } - - _createSettingsFolderNavButton(folder: SettingsFolder): NavButtonAttrs { - return { - label: folder.name, - icon: folder.icon, - href: folder.url, - colors: NavButtonColor.Nav, - click: () => this.viewSlider.focus(this._settingsColumn), - persistentBackground: true, - } - } - - _renderSidebarSectionChildren(folders: SettingsFolder[]): Children { - return m( - "", - folders - .filter((folder) => folder.isVisible()) - .map((folder) => { - const buttonAttrs = this._createSettingsFolderNavButton(folder) - - return m(SettingsFolderRow, { - mainButtonAttrs: buttonAttrs, - }) - }), - ) - } - - private getCurrentViewer(): UpdatableSettingsViewer { - if (!this._currentViewer) { - this.detailsViewer = null - this._currentViewer = this._selectedFolder.viewerCreator() - } - - return this._currentViewer - } - - /** - * Notifies the current view about changes of the url within its scope. - */ - onNewUrl(args: Record, requestedPath: string) { - if (!args.folder) { - this._setUrl(this._userFolders[0].url) - } else if (args.folder || !m.route.get().startsWith("/settings")) { - // ensure that current viewer will be reinitialized - const folder = this._allSettingsFolders().find((folder) => folder.url === requestedPath) - - if (!folder) { - this._setUrl(this._userFolders[0].url) - } else if (this._selectedFolder.path === folder.path) { - // folder path has not changed - this._selectedFolder = folder // instance of SettingsFolder might have been changed in membership update, so replace this instance - - m.redraw() - } else { - // folder path has changed - // to avoid misleading information, set the url to the folder's url, so the browser url - // is changed to correctly represents the displayed content - this._setUrl(folder.url) - this._selectedFolder = folder - this._currentViewer = null - this.detailsViewer = null - - // make sure the currentViewer is available - this.getCurrentViewer() - - m.redraw() - } - } - } - - _allSettingsFolders(): ReadonlyArray> { - return [...this._userFolders, ...this._adminFolders] - } - - _setUrl(url: string) { - m.route.set(url + location.hash) - } - - _isGlobalOrLocalAdmin(user: User): boolean { - return user.memberships.some((m) => m.groupType === GroupType.Admin || m.groupType === GroupType.LocalAdmin) - } - - focusSettingsDetailsColumn() { - this.viewSlider.focus(this._settingsDetailsColumn) - } - - private async updateShowBusinessSettings() { - this.showBusinessSettings((await this.logins.getUserController().loadCustomer()).businessUse === true) - } - - async entityEventsReceived(updates: ReadonlyArray, eventOwnerGroupId: Id): Promise { - for (const update of updates) { - if (isUpdateForTypeRef(CustomerTypeRef, update)) { - await this.updateShowBusinessSettings() - } else if (this.logins.getUserController().isUpdateForLoggedInUserInstance(update, eventOwnerGroupId)) { - const user = this.logins.getUserController().user - - // the user admin status might have changed - if (!this._isGlobalOrLocalAdmin(user) && this._currentViewer && this._adminFolders.some((f) => f.isActive())) { - this._setUrl(this._userFolders[0].url) - } - m.redraw() - } else if (isUpdateForTypeRef(CustomerInfoTypeRef, update)) { - this._customDomains.reset() - this._adminFolders.length = 0 - // When switching a plan we hide/show certain admin settings. - await this.populateAdminFolders() - - await this._customDomains.getAsync() - m.redraw() - } - } - - await this._currentViewer?.entityEventsReceived(updates) - - await this.detailsViewer?.entityEventsReceived(updates) - } - - getViewSlider(): ViewSlider | null { - return this.viewSlider - } - - _aboutThisSoftwareLink(): Children { - const label = lang.get("about_label") - const versionLabel = `Tutanota v${env.versionNumber}` - return m(".pb.pt-l.flex-no-shrink.flex.col.justify-end", [ - m( - "button.text-center.small.no-text-decoration", - { - style: { - backgroundColor: "transparent", - }, - href: "#", - "aria-label": label, - "aria-description": versionLabel, - "aria-haspopup": "dialog", - onclick: () => { - this.viewSlider.focusNextColumn() - setTimeout(() => { - const dialog = Dialog.showActionDialog({ - title: () => lang.get("about_label"), - child: () => - m(AboutDialog, { - onShowSetupWizard: () => { - dialog.close() - locator.showSetupWizard() - }, - }), - allowOkWithReturn: true, - okAction: (dialog: Dialog) => dialog.close(), - allowCancel: false, - }) - }, 200) - }, - }, - [ - m("", versionLabel), - m( - ".b", - { - style: { - color: theme.navigation_button_selected, - }, - }, - label, - ), - ], - ), - ]) - } -} diff --git a/src/calendar-app/calendar/view/CalendarAgendaView.ts b/src/calendar-app/calendar/view/CalendarAgendaView.ts index c483c2cca38..7863d5b28af 100644 --- a/src/calendar-app/calendar/view/CalendarAgendaView.ts +++ b/src/calendar-app/calendar/view/CalendarAgendaView.ts @@ -27,6 +27,7 @@ import { getIfLargeScroll } from "../../../common/gui/base/GuiUtils.js" import { isKeyPressed } from "../../../common/misc/KeyManager.js" import { Keys } from "../../../common/api/common/TutanotaConstants.js" import { MainCreateButton } from "../../../common/gui/MainCreateButton.js" +import { client } from "../../../common/misc/ClientDetector.js" export type CalendarAgendaViewAttrs = { selectedDate: Date @@ -186,16 +187,18 @@ export class CalendarAgendaView implements Component { icon: BootIcons.Calendar, message: "noEntries_msg", color: theme.list_message_bg, - bottomContent: m(MainCreateButton, { - label: "newEvent_action", - click: (e: MouseEvent) => { - let newDate = new Date(attrs.selectedDate) - attrs.onNewEvent(setNextHalfHour(newDate)) + bottomContent: !client.isCalendarApp() + ? m(MainCreateButton, { + label: "newEvent_action", + click: (e: MouseEvent) => { + let newDate = new Date(attrs.selectedDate) + attrs.onNewEvent(setNextHalfHour(newDate)) - e.preventDefault() - }, - class: "mt-s", - }), + e.preventDefault() + }, + class: "mt-s", + }) + : null, }) } else { return m( diff --git a/src/calendar-app/calendar/view/CalendarMobileHeader.ts b/src/calendar-app/calendar/view/CalendarMobileHeader.ts index e25be0e77ca..dd90352268d 100644 --- a/src/calendar-app/calendar/view/CalendarMobileHeader.ts +++ b/src/calendar-app/calendar/view/CalendarMobileHeader.ts @@ -16,12 +16,16 @@ import { ClickHandler } from "../../../common/gui/base/GuiUtils.js" import { TodayIconButton } from "./TodayIconButton.js" import { ExpanderButton } from "../../../common/gui/base/Expander.js" import { isApp } from "../../../common/api/common/Env.js" +import { BootIcons } from "../../../common/gui/base/icons/BootIcons.js" +import { locator } from "../../../common/api/main/CommonLocator.js" +import { NavButton } from "../../../common/gui/base/NavButton.js" +import { SEARCH_PREFIX } from "../../../common/misc/RouteChange.js" +import { client } from "../../../common/misc/ClientDetector.js" export interface CalendarMobileHeaderAttrs extends AppHeaderAttrs { viewType: CalendarViewType viewSlider: ViewSlider navConfiguration: CalendarNavConfiguration - onCreateEvent: () => unknown onToday: () => unknown onViewTypeSelected: (viewType: CalendarViewType) => unknown onTap?: ClickHandler @@ -58,23 +62,41 @@ export class CalendarMobileHeader implements Component BootIcons.Search, + href: route, + centred: true, + fillSpaceAround: false, + }) + } + + return null + } + + private renderDateNavigation(attrs: CalendarMobileHeaderAttrs) { + if (isApp() || !(styles.isSingleColumnLayout() || styles.isTwoColumnLayout())) { + return null + } + + return m.fragment({}, [attrs.navConfiguration.back, attrs.navConfiguration.forward]) + } + private renderViewSelector(attrs: CalendarMobileHeaderAttrs): Children { return m( IconButton, diff --git a/src/calendar-app/calendar/view/CalendarRow.ts b/src/calendar-app/calendar/view/CalendarRow.ts index d9cac82071f..35901f38a27 100644 --- a/src/calendar-app/calendar/view/CalendarRow.ts +++ b/src/calendar-app/calendar/view/CalendarRow.ts @@ -9,8 +9,9 @@ import { ViewHolder } from "../../../common/gui/base/List.js" import { styles } from "../../../common/gui/styles.js" import { DefaultAnimationTime } from "../../../common/gui/animation/Animations.js" -import { formatEventDuration, getEventColor, getGroupColors } from "../gui/CalendarGuiUtils.js" +import { formatEventDuration, getDisplayEventTitle, getEventColor, getGroupColors } from "../gui/CalendarGuiUtils.js" import { GroupColors } from "./CalendarView.js" +import { lang } from "../../../common/misc/LanguageViewModel.js" export class CalendarRow implements VirtualRow { top: number @@ -31,7 +32,7 @@ export class CalendarRow implements VirtualRow { update(event: CalendarEvent, selected: boolean, isInMultiSelect: boolean): void { this.entity = event - this.summaryDom.innerText = event.summary + this.summaryDom.innerText = getDisplayEventTitle(event.summary) this.calendarIndicatorDom.style.backgroundColor = `#${getEventColor(event, this.colors)}` this.durationDom.innerText = formatEventDuration(this.entity, getTimeZone(), false) diff --git a/src/calendar-app/calendar/view/CalendarView.ts b/src/calendar-app/calendar/view/CalendarView.ts index ab021eaca47..9300f2858d5 100644 --- a/src/calendar-app/calendar/view/CalendarView.ts +++ b/src/calendar-app/calendar/view/CalendarView.ts @@ -12,7 +12,6 @@ import { createGroupSettings } from "../../../common/api/entities/tutanota/TypeR import { defaultCalendarColor, GroupType, Keys, reverse, ShareCapability, TabIndex, TimeFormat, WeekStart } from "../../../common/api/common/TutanotaConstants" import { locator } from "../../../common/api/main/CommonLocator" import { - AlarmInterval, getStartOfTheWeekOffset, getStartOfTheWeekOffsetForUser, getTimeZone, @@ -24,7 +23,7 @@ import { CalendarMonthView } from "./CalendarMonthView" import { DateTime } from "luxon" import { NotFoundError } from "../../../common/api/common/error/RestError" import { CalendarAgendaView, CalendarAgendaViewAttrs } from "./CalendarAgendaView" -import { createDefaultAlarmInfo, GroupInfo } from "../../../common/api/entities/sys/TypeRefs.js" +import { createDefaultAlarmInfo } from "../../../common/api/entities/sys/TypeRefs.js" import { showEditCalendarDialog } from "../gui/EditCalendarDialog.js" import { styles } from "../../../common/gui/styles" import { MultiDayCalendarView } from "./MultiDayCalendarView" @@ -73,6 +72,8 @@ import { DaySelectorSidebar } from "../gui/day-selector/DaySelectorSidebar.js" import { CalendarOperation } from "../gui/eventeditor-model/CalendarEventModel.js" import { DaySelectorPopup } from "../gui/day-selector/DaySelectorPopup.js" import { CalendarEventPreviewViewModel } from "../gui/eventpopup/CalendarEventPreviewViewModel.js" +import { client } from "../../../common/misc/ClientDetector.js" +import { FloatingActionButton } from "../../gui/FloatingActionButton.js" export type GroupColors = Map @@ -80,7 +81,7 @@ export interface CalendarViewAttrs extends TopLevelAttrs { drawerAttrs: DrawerMenuAttrs header: AppHeaderAttrs calendarViewModel: CalendarViewModel - bottomNav: () => Children + bottomNav?: () => Children } const CalendarViewTypeByValue = reverse(CalendarViewType) @@ -210,6 +211,7 @@ export class CalendarView extends BaseTopLevelView implements TopLevelView this.viewModel.setScrollPosition(newPosition), onViewChanged: (vnode) => this.viewModel.setViewParameters(vnode.dom as HTMLElement), }), + floatingActionButton: this.renderFab.bind(this), }) case CalendarViewType.WEEK: @@ -272,6 +275,7 @@ export class CalendarView extends BaseTopLevelView implements TopLevelView this.viewModel.setScrollPosition(newPosition), onViewChanged: (vnode) => this.viewModel.setViewParameters(vnode.dom as HTMLElement), }), + floatingActionButton: this.renderFab.bind(this), }) case CalendarViewType.AGENDA: @@ -315,6 +319,7 @@ export class CalendarView extends BaseTopLevelView implements TopLevelView this.viewModel.setViewParameters(vnode.dom as HTMLElement), onNewEvent: (date) => this.createNewEventDialog(date), } satisfies CalendarAgendaViewAttrs), + floatingActionButton: this.renderFab.bind(this), }) default: @@ -369,6 +374,19 @@ export class CalendarView extends BaseTopLevelView implements TopLevelView this.createNewEventDialog(), + }) + } + + return null + } + private renderDesktopToolbar(): Children { return m(CalendarDesktopToolbar, { navConfig: calendarNavConfiguration(this.currentViewType, this.viewModel.selectedDate(), this.viewModel.weekStart, "detailed", (viewType, next) => @@ -398,7 +416,6 @@ export class CalendarView extends BaseTopLevelView implements TopLevelView this.viewPeriod(viewType, next), ), - onCreateEvent: () => this.createNewEventDialog(), onToday: () => { // in case it has been set, when onToday is called we definitely do not want the time to be ignored this.viewModel.ignoreNextValidTimeSelection = false @@ -838,7 +855,7 @@ export class CalendarView extends BaseTopLevelView implements TopLevelView { - view(vnode: Vnode): Children { - // Using bottom-nav class too to match it inside media queries like @print, otherwise it's not matched - return m("nav.bottom-nav.flex.items-center.z1", [ - locator.logins.isInternalUserLoggedIn() - ? m(NavButton, { - label: "search_label", - icon: () => BootIcons.Search, - href: "/search/calendar", - isSelectedPrefix: SEARCH_PREFIX, - vertical: true, - fontSize, - }) - : null, - locator.logins.isInternalUserLoggedIn() && !locator.logins.isEnabled(FeatureType.DisableCalendar) - ? m(NavButton, { - label: "calendar_label", - icon: () => BootIcons.Calendar, - href: CALENDAR_PREFIX, - vertical: true, - fontSize, - }) - : null, - ]) - } -} diff --git a/src/calendar-app/gui/FloatingActionButton.ts b/src/calendar-app/gui/FloatingActionButton.ts new file mode 100644 index 00000000000..59ca950ddef --- /dev/null +++ b/src/calendar-app/gui/FloatingActionButton.ts @@ -0,0 +1,27 @@ +import m, { Children, Component, Vnode } from "mithril" +import { IconButton } from "../../common/gui/base/IconButton.js" +import { Icons } from "../../common/gui/base/icons/Icons.js" +import { ButtonColor } from "../../common/gui/base/Button.js" +import { BootIcons } from "../../common/gui/base/icons/BootIcons.js" +import type { TranslationText } from "../../common/misc/LanguageViewModel.js" + +export type FloatingActionButtonAttrs = { + title: TranslationText + colors: ButtonColor + icon: Icons | BootIcons + action: () => unknown +} + +export class FloatingActionButton implements Component { + view({ attrs: { title, colors, icon, action } }: Vnode): Children { + return m( + "span.float-action-button.posb-ml.posr-ml.accent-bg", + m(IconButton, { + colors, + icon, + title, + click: action, + }), + ) + } +} diff --git a/src/common/gui/BackgroundColumnLayout.ts b/src/common/gui/BackgroundColumnLayout.ts index fff4487b8c1..a1d0885bc12 100644 --- a/src/common/gui/BackgroundColumnLayout.ts +++ b/src/common/gui/BackgroundColumnLayout.ts @@ -1,11 +1,16 @@ import m, { Children, Component, Vnode } from "mithril" import { styles } from "./styles.js" +import { client } from "../misc/ClientDetector.js" +import { IconButton } from "./base/IconButton.js" +import { Icons } from "./base/icons/Icons.js" +import { isApp } from "../api/common/Env.js" export interface BackgroundColumnLayoutAttrs { mobileHeader: () => Children desktopToolbar: () => Children columnLayout: Children backgroundColor?: string + floatingActionButton?: () => Children } /** @@ -21,7 +26,11 @@ export class BackgroundColumnLayout implements Component unknown onblur?: () => unknown onkeydown?: (event: KeyboardEvent) => unknown + fillSpaceAround?: boolean } export class NavButton implements Component { @@ -85,11 +86,12 @@ export class NavButton implements Component { _getNavButtonClass(a: NavButtonAttrs): string { return ( - "a.nav-button.noselect.flex-no-shrink.items-center.click.plr-button.no-text-decoration.button-height.border-radius" + + "a.nav-button.noselect.items-center.click.plr-button.no-text-decoration.button-height.border-radius" + (a.vertical ? ".col" : "") + (!a.centred ? ".flex-start" : ".flex-center") + (a.disableHoverBackground ? "" : ".state-bg") + - (a.disabled ? ".no-hover" : "") + (a.disabled ? ".no-hover" : "") + + (a.fillSpaceAround ?? true ? ".flex-no-shrink" : "") ) } diff --git a/src/common/gui/main-styles.ts b/src/common/gui/main-styles.ts index 4d755272992..48a3d318a66 100644 --- a/src/common/gui/main-styles.ts +++ b/src/common/gui/main-styles.ts @@ -2546,5 +2546,15 @@ styles.registerStyle("main", () => { ".overflow-auto": { overflow: "auto", }, + ".float-action-button": { + position: "fixed", + "border-radius": "25%", + }, + ".posb-ml": { + bottom: px(size.vpad_ml), + }, + ".posr-ml": { + right: px(size.vpad_ml), + }, } }) diff --git a/src/common/gui/nav/ViewSlider.ts b/src/common/gui/nav/ViewSlider.ts index 9ff7a7d2169..39a7c3a63ad 100644 --- a/src/common/gui/nav/ViewSlider.ts +++ b/src/common/gui/nav/ViewSlider.ts @@ -11,6 +11,7 @@ import { styles } from "../styles.js" import { AriaLandmarks } from "../AriaUtils.js" import { LayerType } from "../../../RootView.js" import { assertMainOrNode } from "../../api/common/Env.js" +import { client } from "../../misc/ClientDetector.js" assertMainOrNode() export type GestureInfo = { @@ -28,7 +29,7 @@ export const gestureInfoFromTouch = (touch: Touch): GestureInfo => ({ interface ViewSliderAttrs { header: Children - bottomNav: Children + bottomNav?: Children } /** @@ -128,7 +129,7 @@ export class ViewSlider implements Component { }), ), ), - styles.isUsingBottomNavigation() ? attrs.bottomNav : null, + styles.isUsingBottomNavigation() && !client.isCalendarApp() ? attrs.bottomNav : null, this.getColumnsForOverlay().map((c) => m(c, {})), this.createModalBackground(), ], diff --git a/src/common/misc/ClientDetector.ts b/src/common/misc/ClientDetector.ts index a7b4298ab88..923bb0a0e27 100644 --- a/src/common/misc/ClientDetector.ts +++ b/src/common/misc/ClientDetector.ts @@ -1,4 +1,4 @@ -import { assertMainOrNodeBoot, Mode } from "../api/common/Env" +import { assertMainOrNodeBoot, isApp, Mode } from "../api/common/Env" import { AppType, BrowserData, BrowserType, DeviceType } from "./ClientConstants" assertMainOrNodeBoot() @@ -390,6 +390,10 @@ export class ClientDetector { compressionStreamSupported(): boolean { return typeof CompressionStream !== "undefined" } + + isCalendarApp() { + return isApp() && this.appType === AppType.Calendar + } } export const client: ClientDetector = new ClientDetector() diff --git a/src/mail-app/search/view/SearchView.ts b/src/mail-app/search/view/SearchView.ts index 20db135c367..d9de8302091 100644 --- a/src/mail-app/search/view/SearchView.ts +++ b/src/mail-app/search/view/SearchView.ts @@ -97,7 +97,7 @@ import { } from "../../../calendar-app/calendar/view/EventDetailsView.js" import { showProgressDialog } from "../../../common/gui/dialogs/ProgressDialog.js" import { CalendarOperation } from "../../../calendar-app/calendar/gui/eventeditor-model/CalendarEventModel.js" -import { getEventWithDefaultTimes } from "../../../common/api/common/utils/CommonCalendarUtils.js" +import { getEventWithDefaultTimes, setNextHalfHour } from "../../../common/api/common/utils/CommonCalendarUtils.js" import { showNewCalendarEventEditDialog } from "../../../calendar-app/calendar/gui/eventeditor-view/CalendarEventEditDialog.js" import { getSharedGroupName } from "../../../common/sharing/GroupUtils.js" import { YEAR_IN_MILLIS } from "@tutao/tutanota-utils/dist/DateUtils.js" @@ -979,7 +979,7 @@ export class SearchView extends BaseTopLevelView implements TopLevelView { - const dateToUse = this.searchViewModel.startDate ?? new Date() + const dateToUse = this.searchViewModel.startDate ? setNextHalfHour(new Date(this.searchViewModel.startDate)) : setNextHalfHour(new Date()) // Disallow creation of events when there is no existing calendar const lazyCalendarInfo = this.searchViewModel.getLazyCalendarInfos() diff --git a/src/mail-app/translations/de.ts b/src/mail-app/translations/de.ts index 32cced80ccc..93eccdaed3a 100644 --- a/src/mail-app/translations/de.ts +++ b/src/mail-app/translations/de.ts @@ -188,6 +188,7 @@ export default { "calendarRepeatStopCondition_label": "Endet", "calendarView_action": "Zur Kalendar-Ansicht wechseln", "calendar_label": "Kalender", + "calendarDefaultReminder_label": "Standarderinnerung vor dem Ereignis", "callNumber_alt": "Diese Telefonnummer anrufen", "cameraUsageDescription_msg": "Die Kamera wird verwendet, um Videos aufzunehmen und diese als Anhang zu versenden.", "cancelContactForm_label": "Abbestellung von Kontaktformular", diff --git a/src/mail-app/translations/de_sie.ts b/src/mail-app/translations/de_sie.ts index d09bbf7e7e9..5b6c548d4fb 100644 --- a/src/mail-app/translations/de_sie.ts +++ b/src/mail-app/translations/de_sie.ts @@ -188,6 +188,7 @@ export default { "calendarRepeatStopCondition_label": "Endet", "calendarView_action": "Zur Kalendar-Ansicht wechseln", "calendar_label": "Kalender", + "calendarDefaultReminder_label": "Standarderinnerung vor dem Ereignis", "callNumber_alt": "Diese Telefonnummer anrufen", "cameraUsageDescription_msg": "Die Kamera wird verwendet, um Videos aufzunehmen und diese als Anhang zu versenden.", "cancelContactForm_label": "Abbestellung von Kontaktformular", diff --git a/src/mail-app/translations/en.ts b/src/mail-app/translations/en.ts index c378c78b259..0143c51b558 100644 --- a/src/mail-app/translations/en.ts +++ b/src/mail-app/translations/en.ts @@ -184,6 +184,7 @@ export default { "calendarRepeatStopCondition_label": "Ends", "calendarView_action": "Switch to the calendar view", "calendar_label": "Calendar", + "calendarDefaultReminder_label": "Default reminder before event", "callNumber_alt": "Call this number", "cameraUsageDescription_msg": "Take a picture or video for adding it as attachment.", "cancelContactForm_label": "Cancel contact form",