mirror of
https://github.com/tutao/tutanota.git
synced 2025-10-19 07:53:47 +00:00
Add quick settings
Searcheable and keyboard-friendly way to navigate and invoke various actions in the app Close #9520
This commit is contained in:
parent
b1133a83b3
commit
7f124116fb
9 changed files with 333 additions and 14 deletions
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
},
|
||||
])
|
||||
}
|
||||
|
|
204
src/common/misc/QuickActionBar.ts
Normal file
204
src/common/misc/QuickActionBar.ts
Normal 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)
|
||||
}
|
36
src/common/settings/SettingsQuickActions.ts
Normal file
36
src/common/settings/SettingsQuickActions.ts
Normal 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", {}),
|
||||
},
|
||||
]
|
||||
}
|
|
@ -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")
|
||||
|
||||
|
|
48
src/mail-app/mail/model/MailQuickActions.ts
Normal file
48
src/mail-app/mail/model/MailQuickActions.ts
Normal 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]
|
||||
})
|
||||
}
|
|
@ -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")
|
||||
|
|
|
@ -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)
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue