Add quick settings

Searcheable and keyboard-friendly way to navigate and invoke various
actions in the app

Close #9520
This commit is contained in:
ivk 2025-08-08 18:47:12 +02:00
parent b1133a83b3
commit 7f124116fb
9 changed files with 333 additions and 14 deletions

View file

@ -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)

View file

@ -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<Key>): boolean {
if (key != null) {
return keys.some((k) => k.code === key.toLowerCase())
export function isKeyPressed(keyFromPress: string | undefined, ...keys: Array<Key>): boolean {
if (keyFromPress != null) {
return keys.some((k) => k.code === keyFromPress.toLowerCase())
}
return false
}

View file

@ -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<QuickActionsModel>; 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",
},
])
}

View file

@ -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<readonly QuickAction[]>
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<void> {
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<readonly QuickAction[]>
getMatchingActions: (query: string) => readonly QuickAction[]
close: () => unknown
}
class QuickActionBar implements Component<Attrs> {
private query = ""
private results: readonly QuickAction[] = []
private selectedIndex: number = 0
private listDom: HTMLElement | null = null
oninit({ attrs: { getInitialActions, getMatchingActions } }: Vnode<Attrs>) {
getInitialActions().then((initialActions) => {
this.results = initialActions
m.redraw()
})
}
view({ attrs: { close, runAction, getMatchingActions } }: Vnode<Attrs>): 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<void> {},
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)
}

View file

@ -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<readonly QuickAction[]> {
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", {}),
},
]
}

View file

@ -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")

View file

@ -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<readonly QuickAction[]> {
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]
})
}

View file

@ -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<QuickActionsModel> = 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")

View file

@ -667,8 +667,10 @@ export class SettingsView extends BaseTopLevelView implements TopLevelView<Setti
if (!args.folder) {
this._setUrl(this._userFolders[0].url)
} else if (args.folder || !m.route.get().startsWith("/settings")) {
// TODO: a bit of a hack, instead of requestedPath we should find the folder by :folder and :id params
const requestedPathWithoutHash = requestedPath.split("#")[0]
// ensure that current viewer will be reinitialized
const folder = this._allSettingsFolders().find((folder) => folder.url === requestedPath)
const folder = this._allSettingsFolders().find((folder) => folder.url === requestedPathWithoutHash)
if (!folder) {
this._setUrl(this._userFolders[0].url)