From 7f124116fb208472b47f20545e1f8f0b8666e0fe Mon Sep 17 00:00:00 2001 From: ivk Date: Fri, 8 Aug 2025 18:47:12 +0200 Subject: [PATCH] Add quick settings Searcheable and keyboard-friendly way to navigate and invoke various actions in the app Close #9520 --- src/calendar-app/calendar-app.ts | 3 +- src/common/misc/KeyManager.ts | 8 +- src/common/misc/NavShortcuts.ts | 25 ++- src/common/misc/QuickActionBar.ts | 204 ++++++++++++++++++++ src/common/settings/SettingsQuickActions.ts | 36 ++++ src/mail-app/app.ts | 13 +- src/mail-app/mail/model/MailQuickActions.ts | 48 +++++ src/mail-app/mailLocator.ts | 6 + src/mail-app/settings/SettingsView.ts | 4 +- 9 files changed, 333 insertions(+), 14 deletions(-) create mode 100644 src/common/misc/QuickActionBar.ts create mode 100644 src/common/settings/SettingsQuickActions.ts create mode 100644 src/mail-app/mail/model/MailQuickActions.ts diff --git a/src/calendar-app/calendar-app.ts b/src/calendar-app/calendar-app.ts index b1116ffaea..a22aa9daa9 100644 --- a/src/calendar-app/calendar-app.ts +++ b/src/calendar-app/calendar-app.ts @@ -81,7 +81,8 @@ import("../mail-app/translations/en.js") initCommonLocator(calendarLocator) const { setupNavShortcuts } = await import("../common/misc/NavShortcuts.js") - setupNavShortcuts() + // FIXME + // setupNavShortcuts() // this needs to stay after client.init windowFacade.init(calendarLocator.logins, calendarLocator.connectivityModel, null) diff --git a/src/common/misc/KeyManager.ts b/src/common/misc/KeyManager.ts index 3205c29aff..b550622296 100644 --- a/src/common/misc/KeyManager.ts +++ b/src/common/misc/KeyManager.ts @@ -301,12 +301,12 @@ class KeyManager { /** * - * @param key The key to be checked, should correspond to KeyEvent.key + * @param keyFromPress The key to be checked, should correspond to KeyEvent.key * @param keys Keys to be checked against, type of Keys */ -export function isKeyPressed(key: string | undefined, ...keys: Array): boolean { - if (key != null) { - return keys.some((k) => k.code === key.toLowerCase()) +export function isKeyPressed(keyFromPress: string | undefined, ...keys: Array): boolean { + if (keyFromPress != null) { + return keys.some((k) => k.code === keyFromPress.toLowerCase()) } return false } diff --git a/src/common/misc/NavShortcuts.ts b/src/common/misc/NavShortcuts.ts index bc9003725c..3a8b474513 100644 --- a/src/common/misc/NavShortcuts.ts +++ b/src/common/misc/NavShortcuts.ts @@ -1,32 +1,33 @@ import { keyManager } from "./KeyManager.js" import { FeatureType, Keys } from "../api/common/TutanotaConstants.js" -import { locator } from "../api/main/CommonLocator.js" import m from "mithril" import { CALENDAR_PREFIX, CONTACTS_PREFIX, LogoutUrl, MAIL_PREFIX, SETTINGS_PREFIX } from "./RouteChange.js" +import { QuickActionsModel, showQuickActionBar } from "./QuickActionBar" +import { LoginController } from "../api/main/LoginController" -export function setupNavShortcuts() { +export function setupNavShortcuts({ quickActionsModel, logins }: { quickActionsModel: () => Promise; logins: LoginController }) { keyManager.registerShortcuts([ { key: Keys.M, - enabled: () => locator.logins.isUserLoggedIn(), + enabled: () => logins.isUserLoggedIn(), exec: () => m.route.set(MAIL_PREFIX), help: "mailView_action", }, { key: Keys.C, - enabled: () => locator.logins.isInternalUserLoggedIn() && !locator.logins.isEnabled(FeatureType.DisableContacts), + enabled: () => logins.isInternalUserLoggedIn() && !logins.isEnabled(FeatureType.DisableContacts), exec: () => m.route.set(CONTACTS_PREFIX), help: "contactView_action", }, { key: Keys.O, - enabled: () => locator.logins.isInternalUserLoggedIn(), + enabled: () => logins.isInternalUserLoggedIn(), exec: () => m.route.set(CALENDAR_PREFIX), help: "calendarView_action", }, { key: Keys.S, - enabled: () => locator.logins.isInternalUserLoggedIn(), + enabled: () => logins.isInternalUserLoggedIn(), exec: () => m.route.set(SETTINGS_PREFIX), help: "settingsView_action", }, @@ -34,9 +35,19 @@ export function setupNavShortcuts() { key: Keys.L, shift: true, ctrlOrCmd: true, - enabled: () => locator.logins.isUserLoggedIn(), + enabled: () => logins.isUserLoggedIn(), exec: (key) => m.route.set(LogoutUrl), help: "switchAccount_action", }, + { + key: Keys.K, + shift: true, + ctrlOrCmd: true, + exec: () => { + quickActionsModel().then(showQuickActionBar) + }, + // FIXME + help: "search_label", + }, ]) } diff --git a/src/common/misc/QuickActionBar.ts b/src/common/misc/QuickActionBar.ts new file mode 100644 index 0000000000..8f10acc37a --- /dev/null +++ b/src/common/misc/QuickActionBar.ts @@ -0,0 +1,204 @@ +import m, { Children, Component, Vnode, VnodeDOM } from "mithril" +import { px, size } from "../gui/size" +import { TextField } from "../gui/base/TextField" +import { modal } from "../gui/base/Modal" +import { isKeyPressed, Shortcut } from "./KeyManager" +import { lastIndex, remove } from "@tutao/tutanota-utils" +import { Keys } from "../api/common/TutanotaConstants" +import { highlightTextInQueryAsChildren } from "../gui/TextHighlightViewUtils" +import { theme } from "../gui/theme" + +export interface QuickAction { + readonly description: string + readonly exec: () => unknown +} + +type LazyActionProvider = () => Promise + +export class QuickActionsModel { + private readonly _lastRunActions: QuickAction[] = [] + private actions: readonly QuickAction[] = [] + private readonly providers: LazyActionProvider[] = [] + + register(actionProvider: LazyActionProvider) { + this.providers.push(actionProvider) + } + + async updateActions(): Promise { + const result: QuickAction[] = [] + for (const actionProvider of this.providers) { + const actions = await actionProvider() + result.push(...actions) + } + this.actions = result + } + + runAction(action: QuickAction) { + remove(this._lastRunActions, action) + this._lastRunActions.unshift(action) + action.exec() + } + + lastActions(): readonly QuickAction[] { + return this._lastRunActions + } + + getMatchingActions(query: string): readonly QuickAction[] { + const lowerQuery = query.toLowerCase() + return this.actions.filter((pr) => pr.description.toLowerCase().includes(lowerQuery)) + } +} + +interface Attrs { + runAction: (action: QuickAction) => unknown + getInitialActions: () => Promise + getMatchingActions: (query: string) => readonly QuickAction[] + close: () => unknown +} + +class QuickActionBar implements Component { + private query = "" + private results: readonly QuickAction[] = [] + private selectedIndex: number = 0 + private listDom: HTMLElement | null = null + + oninit({ attrs: { getInitialActions, getMatchingActions } }: Vnode) { + getInitialActions().then((initialActions) => { + this.results = initialActions + m.redraw() + }) + } + + view({ attrs: { close, runAction, getMatchingActions } }: Vnode): Children { + return m( + ".flex.col", + { + style: { + maxWidth: "50vw", + maxHeight: "80vh", + background: "white", + borderRadius: px(size.border_radius_large), + margin: "10vh auto", + padding: px(size.hpad), + }, + }, + [ + m(TextField, { + label: "action_label", + value: this.query, + class: "flex-no-grow-no-shrink-auto", + oninput: (newValue) => { + this.query = newValue + this.results = getMatchingActions(newValue) + this.selectedIndex = 0 + }, + onDomInputCreated: (dom) => { + setTimeout(() => dom.focus(), 32) + }, + onReturnKeyPressed: () => { + const firstResult = this.results.at(this.selectedIndex) + if (firstResult) { + runAction(firstResult) + } + close() + }, + keyHandler: (keyPress) => { + if (isKeyPressed(keyPress.key, Keys.ESC)) { + close() + } else if (isKeyPressed(keyPress.key, Keys.UP)) { + this.selectedIndex = Math.max(0, this.selectedIndex - 1) + this.scrollToIndex() + return false + } else if (isKeyPressed(keyPress.key, Keys.DOWN)) { + this.selectedIndex = Math.min(lastIndex(this.results), this.selectedIndex + 1) + this.scrollToIndex() + return false + } + return true + }, + }), + m( + ".flex.col.ul.mt-s.scroll.flex-grow", + { + style: { + "list-style": "none", + gap: "4px", + }, + oncreate: (vnode: VnodeDOM) => { + this.listDom = vnode.dom as HTMLElement + }, + }, + this.results.map((result, index) => + m( + "li.border-radius-small.plr-s.click", + { + style: { + padding: "4px", + backgroundColor: index === this.selectedIndex ? theme.state_bg_hover : undefined, + }, + onclick: () => { + const action = this.results.at(index) + if (action) { + runAction(action) + close() + } + }, + }, + highlightTextInQueryAsChildren(result.description, [{ token: this.query, exact: false }]), + ), + ), + ), + ], + ) + } + + private scrollToIndex() { + if (this.listDom) { + const child = this.listDom.children.item(this.selectedIndex) + child?.scrollIntoView() + } + } +} + +export function showQuickActionBar(model: QuickActionsModel) { + const activeElement = document.activeElement + const modalComponent = { + view: () => { + return m(QuickActionBar, { + getInitialActions: async () => { + await model.updateActions() + return model.lastActions().concat(model.getMatchingActions("")) + }, + getMatchingActions: (query) => model.getMatchingActions(query), + runAction: (action) => model.runAction(action), + close: () => modal.remove(modalComponent), + } satisfies Attrs) + }, + async hideAnimation(): Promise {}, + + onClose(): void {}, + + shortcuts(): Shortcut[] { + return [] + }, + + backgroundClick(e: MouseEvent): void { + modal.remove(modalComponent) + }, + + /** + * will be called by the main modal if no other component above this one blocked the event (previous components returned true) + * return false if the event was handled and lower components shouldn't be notified, true otherwise + * @param e + */ + popState(e: Event): boolean { + return true + }, + + // The element that was interacted with to show the modal. + callingElement(): HTMLElement | null { + return activeElement as HTMLElement | null + }, + } + modal.display(modalComponent) +} diff --git a/src/common/settings/SettingsQuickActions.ts b/src/common/settings/SettingsQuickActions.ts new file mode 100644 index 0000000000..88c0a1faa2 --- /dev/null +++ b/src/common/settings/SettingsQuickActions.ts @@ -0,0 +1,36 @@ +import { QuickAction } from "../misc/QuickActionBar" +import { lang } from "../misc/LanguageViewModel" +import { Router } from "../gui/ScopedRouter" + +export async function quickSettingsActions(router: Router): Promise { + return [ + { + description: `${lang.get("settings_label")} ${lang.get("login_label")}`, + exec: () => router.routeTo("/settings/login", {}), + }, + { + description: `${lang.get("settings_label")} ${lang.get("email_label")}`, + exec: () => router.routeTo("/settings/mail", {}), + }, + { + description: `${lang.get("settings_label")} ${lang.get("email_label")} ${lang.get("defaultSenderMailAddress_label")}`, + exec: () => router.routeTo("/settings/mail#defaultSender", {}), + }, + { + description: `${lang.get("settings_label")} ${lang.get("appearanceSettings_label")}`, + exec: () => router.routeTo("/settings/appearance", {}), + }, + { + description: `${lang.get("settings_label")} ${lang.get("appearanceSettings_label")} ${lang.get("language_label")}`, + exec: () => router.routeTo("/settings/appearance#label", {}), + }, + { + description: `${lang.get("settings_label")} ${lang.get("appearanceSettings_label")} ${lang.get("switchColorTheme_action")}`, + exec: () => router.routeTo("/settings/appearance#colorTheme", {}), + }, + { + description: `${lang.get("settings_label")} ${lang.get("appearanceSettings_label")} ${lang.get("weekScrollTime_label")}`, + exec: () => router.routeTo("/settings/appearance#weekScrollTime", {}), + }, + ] +} diff --git a/src/mail-app/app.ts b/src/mail-app/app.ts index 17ba08e3e2..ad795a3e5c 100644 --- a/src/mail-app/app.ts +++ b/src/mail-app/app.ts @@ -120,7 +120,18 @@ import("./translations/en.js") initCommonLocator(mailLocator) const { setupNavShortcuts } = await import("../common/misc/NavShortcuts.js") - setupNavShortcuts() + setupNavShortcuts({ quickActionsModel: () => mailLocator.quickActionsModel(), logins: mailLocator.logins }) + + mailLocator.quickActionsModel().then((model) => { + model.register(async () => { + const { quickMailActions } = await import("./mail/model/MailQuickActions.js") + return quickMailActions(mailLocator.mailboxModel, mailLocator.mailModel, mailLocator.logins, mailLocator.throttledRouter()) + }) + model.register(async () => { + const { quickSettingsActions } = await import("../common/settings/SettingsQuickActions.js") + return quickSettingsActions(mailLocator.throttledRouter()) + }) + }) const { BottomNav } = await import("./gui/BottomNav.js") diff --git a/src/mail-app/mail/model/MailQuickActions.ts b/src/mail-app/mail/model/MailQuickActions.ts new file mode 100644 index 0000000000..bd53095d30 --- /dev/null +++ b/src/mail-app/mail/model/MailQuickActions.ts @@ -0,0 +1,48 @@ +import { MailModel } from "./MailModel" +import { QuickAction } from "../../../common/misc/QuickActionBar" +import { MailboxDetail, MailboxModel } from "../../../common/mailFunctionality/MailboxModel" +import { getMailboxName } from "../../../common/mailFunctionality/SharedMailUtils" +import { LoginController } from "../../../common/api/main/LoginController" +import { getFolderName } from "./MailUtils" +import { Router } from "../../../common/gui/ScopedRouter" +import { getElementId } from "../../../common/api/common/utils/EntityUtils" +import { lang } from "../../../common/misc/LanguageViewModel" + +export async function quickMailActions( + mailboxModel: MailboxModel, + mailModel: MailModel, + loginController: LoginController, + router: Router, +): Promise { + const mailboxDetails: MailboxDetail[] = await mailboxModel.getMailboxDetails() + return mailboxDetails.flatMap((mailboxDetail) => { + const mailboxName = getMailboxName(loginController, mailboxDetail) + + const newEmailAction: QuickAction = { + description: `${mailboxName} ${lang.get("newMail_action")}`, + exec: async () => { + const { newMailEditor } = await import("../editor/MailEditor") + const dialog = await newMailEditor(mailboxDetail) + dialog.show() + }, + } + + const fs = mailModel.getFolderSystemByGroupId(mailboxDetail.mailGroup._id) + + let folderActions: readonly QuickAction[] + if (fs == null) { + folderActions = [] + } else { + folderActions = fs.getIndentedList().map(({ folder }) => { + return { + description: `${mailboxName} ${getFolderName(folder)}`, + // TODO: this is not ideal as this will forget the selected mail in that folder. We could pull it + // up from somewhere. + exec: () => router.routeTo("/mail/:folder", { folder: getElementId(folder) }), + } + }) + } + + return [newEmailAction, ...folderActions] + }) +} diff --git a/src/mail-app/mailLocator.ts b/src/mail-app/mailLocator.ts index 13c31f9532..cb11216372 100644 --- a/src/mail-app/mailLocator.ts +++ b/src/mail-app/mailLocator.ts @@ -154,6 +154,7 @@ import { WhitelabelThemeGenerator } from "../common/gui/WhitelabelThemeGenerator import { UndoModel } from "./UndoModel" import { GroupSettingsModel } from "../common/sharing/model/GroupSettingsModel" import { AutosaveFacade } from "../common/api/worker/facades/lazy/AutosaveFacade" +import { QuickActionsModel } from "../common/misc/QuickActionBar" assertMainOrNode() @@ -348,6 +349,11 @@ class MailLocator implements CommonLocator { : noOp, } + readonly quickActionsModel: lazyAsync = lazyMemoized(async () => { + const { QuickActionsModel } = await import("../common/misc/QuickActionBar.js") + return new QuickActionsModel() + }) + readonly contactViewModel = lazyMemoized(async () => { const { ContactViewModel } = await import("../mail-app/contacts/view/ContactViewModel.js") const router = new ScopedRouter(this.throttledRouter(), "/contact") diff --git a/src/mail-app/settings/SettingsView.ts b/src/mail-app/settings/SettingsView.ts index 6d7ae9f082..76bbccae30 100644 --- a/src/mail-app/settings/SettingsView.ts +++ b/src/mail-app/settings/SettingsView.ts @@ -667,8 +667,10 @@ export class SettingsView extends BaseTopLevelView implements TopLevelView folder.url === requestedPath) + const folder = this._allSettingsFolders().find((folder) => folder.url === requestedPathWithoutHash) if (!folder) { this._setUrl(this._userFolders[0].url)