From 672c0f5f4159682b60b441255ca244410d93afb7 Mon Sep 17 00:00:00 2001 From: "Shane A. Stillwell" Date: Fri, 13 Feb 2026 21:39:16 -0700 Subject: [PATCH] added keyboard shortcuts for quick navigation Keyboard shortcuts added: * j / ArrowDown - Navigate to next message * k / ArrowUp - Navigate to previous message * Escape / u - Return to inbox/search (MessageView only) --- package-lock.json | 101 ++++++++++-------- package.json | 1 + .../composables/useMessageListKeyboardNav.js | 54 ++++++++++ .../composables/useMessageViewKeyboardNav.js | 39 +++++++ server/ui-src/utils/keyboard.js | 10 ++ server/ui-src/views/MailboxView.vue | 5 + server/ui-src/views/MessageView.vue | 5 + server/ui-src/views/SearchView.vue | 5 + 8 files changed, 175 insertions(+), 45 deletions(-) create mode 100644 server/ui-src/composables/useMessageListKeyboardNav.js create mode 100644 server/ui-src/composables/useMessageViewKeyboardNav.js create mode 100644 server/ui-src/utils/keyboard.js diff --git a/package-lock.json b/package-lock.json index 169df59ea..84ab7ac29 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "mailpit", "version": "0.0.0", "dependencies": { + "@vueuse/core": "^14.2.1", "axios": "^1.13.5", "bootstrap": "^5.2.0", "bootstrap-icons": "^1.9.1", @@ -112,8 +113,7 @@ "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.11.0.tgz", "integrity": "sha512-sBXGT13cpmPR5BMgHE6UEEfEaShh5Ror6rfN3yEK5si7QVrtZg8LEPQb0VVhiLRUslD2yLnXtnRzG035J/mZXQ==", "dev": true, - "license": "(Apache-2.0 AND BSD-3-Clause)", - "peer": true + "license": "(Apache-2.0 AND BSD-3-Clause)" }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.3", @@ -1110,6 +1110,7 @@ "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", "license": "MIT", + "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/popperjs" @@ -1683,18 +1684,6 @@ "node": "^18 || ^20 || >= 21" } }, - "node_modules/@swagger-api/apidom-parser-adapter-yaml-1-2/node_modules/tree-sitter": { - "version": "0.22.4", - "resolved": "https://registry.npmjs.org/tree-sitter/-/tree-sitter-0.22.4.tgz", - "integrity": "sha512-usbHZP9/oxNsUY65MQUsduGRqDHQOou1cagUSwjhoSYAmSahjQDAVsh9s+SlZkn8X8+O1FULRGwHu7AFP3kjzg==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "dependencies": { - "node-addon-api": "^8.3.0", - "node-gyp-build": "^4.8.4" - } - }, "node_modules/@swagger-api/apidom-reference": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/@swagger-api/apidom-reference/-/apidom-reference-1.4.0.tgz", @@ -1830,6 +1819,12 @@ "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", "license": "MIT" }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.21", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", + "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==", + "license": "MIT" + }, "node_modules/@vue/compiler-core": { "version": "3.5.28", "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.28.tgz", @@ -1858,6 +1853,7 @@ "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.28.tgz", "integrity": "sha512-6TnKMiNkd6u6VeVDhZn/07KhEZuBSn43Wd2No5zaP5s3xm8IqFTHBj84HJah4UepSUJTro5SoqqlOY22FKY96g==", "license": "MIT", + "peer": true, "dependencies": { "@babel/parser": "^7.29.0", "@vue/compiler-core": "3.5.28", @@ -1936,12 +1932,51 @@ "integrity": "sha512-cfWa1fCGBxrvaHRhvV3Is0MgmrbSCxYTXCSCau2I0a1Xw1N1pHAvkWCiXPRAqjvToILvguNyEwjevUqAuBQWvQ==", "license": "MIT" }, + "node_modules/@vueuse/core": { + "version": "14.2.1", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.2.1.tgz", + "integrity": "sha512-3vwDzV+GDUNpdegRY6kzpLm4Igptq+GA0QkJ3W61Iv27YWwW/ufSlOfgQIpN6FZRMG0mkaz4gglJRtq5SeJyIQ==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.21", + "@vueuse/metadata": "14.2.1", + "@vueuse/shared": "14.2.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/@vueuse/metadata": { + "version": "14.2.1", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-14.2.1.tgz", + "integrity": "sha512-1ButlVtj5Sb/HDtIy1HFr1VqCP4G6Ypqt5MAo0lCgjokrk2mvQKsK2uuy0vqu/Ks+sHfuHo0B9Y9jn9xKdjZsw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "14.2.1", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-14.2.1.tgz", + "integrity": "sha512-shTJncjV9JTI4oVNyF1FQonetYAiTBd+Qj7cY89SWbXSkx7gyhrgtEdF2ZAVWS1S3SHlaROO6F2IesJxQEkZBw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2226,8 +2261,7 @@ "resolved": "https://registry.npmjs.org/colorjs.io/-/colorjs.io-0.5.2.tgz", "integrity": "sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/combined-stream": { "version": "1.0.8", @@ -2456,6 +2490,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -2539,6 +2574,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3683,6 +3719,7 @@ "resolved": "https://registry.npmjs.org/ramda/-/ramda-0.30.1.tgz", "integrity": "sha512-tEF5I22zJnuclswcZMc8bDIrwRHRzf+NqVEmqg50ShAZMP7MWeR/RGDthfM/p+BlqvF2fXAzpn8i+SJcYD3alw==", "license": "MIT", + "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/ramda" @@ -3805,7 +3842,6 @@ "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "^2.1.0" } @@ -3837,7 +3873,6 @@ "integrity": "sha512-eKzFy13Nk+IRHhlAwP3sfuv+PzOrvzUkwJK2hdoCKYcWGSdmwFpeGpWmyewdw8EgBnsKaSBtgf/0b2K635ecSA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@bufbuild/protobuf": "^2.5.0", "colorjs.io": "^0.5.0", @@ -3887,7 +3922,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "sass": "1.97.3" } @@ -3905,7 +3939,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=14.0.0" } @@ -3923,7 +3956,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=14.0.0" } @@ -3941,7 +3973,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=14.0.0" } @@ -3959,7 +3990,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=14.0.0" } @@ -3977,7 +4007,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=14.0.0" } @@ -3995,7 +4024,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=14.0.0" } @@ -4013,7 +4041,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=14.0.0" } @@ -4031,7 +4058,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=14.0.0" } @@ -4049,7 +4075,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=14.0.0" } @@ -4067,7 +4092,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=14.0.0" } @@ -4085,7 +4109,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=14.0.0" } @@ -4103,7 +4126,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=14.0.0" } @@ -4121,7 +4143,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=14.0.0" } @@ -4139,7 +4160,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=14.0.0" } @@ -4157,7 +4177,6 @@ "!linux", "!win32" ], - "peer": true, "dependencies": { "sass": "1.97.3" } @@ -4175,7 +4194,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=14.0.0" } @@ -4193,7 +4211,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=14.0.0" } @@ -4204,7 +4221,6 @@ "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -4341,7 +4357,6 @@ "integrity": "sha512-8lD+t2KrrScJ/7KXCSyfhT3/hRq78rC0wBFqNJXv3mZyn6hW2ypM05JmlSvtqRbeq6jqA94oHbxAr2vYsJ8vDA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "sync-message-port": "^1.0.0" }, @@ -4355,7 +4370,6 @@ "integrity": "sha512-gAQ9qrUN/UCypHtGFbbe7Rc/f9bzO88IwrG8TDo/aMKAApKyD6E3W4Cm0EfhfBb6Z6SKt59tTCTfD+n1xmAvMg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=16.0.0" } @@ -4373,7 +4387,6 @@ "hasInstallScript": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "node-addon-api": "^8.0.0", "node-gyp-build": "^4.8.0" @@ -4415,7 +4428,6 @@ "integrity": "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==", "license": "MIT", "optional": true, - "peer": true, "engines": { "node": "^18 || ^20 || >= 21" } @@ -4437,8 +4449,7 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "dev": true, - "license": "0BSD", - "peer": true + "license": "0BSD" }, "node_modules/type-check": { "version": "0.4.0", @@ -4490,14 +4501,14 @@ "resolved": "https://registry.npmjs.org/varint/-/varint-6.0.0.tgz", "integrity": "sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/vue": { "version": "3.5.28", "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.28.tgz", "integrity": "sha512-BRdrNfeoccSoIZeIhyPBfvWSLFP4q8J3u8Ju8Ug5vu3LdD+yTM13Sg4sKtljxozbnuMu1NB1X5HBHRYUzFocKg==", "license": "MIT", + "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.28", "@vue/compiler-sfc": "3.5.28", diff --git a/package.json b/package.json index 9b3f859ec..ad2668044 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "lint-fix": "eslint --fix && prettier --write ." }, "dependencies": { + "@vueuse/core": "^14.2.1", "axios": "^1.13.5", "bootstrap": "^5.2.0", "bootstrap-icons": "^1.9.1", diff --git a/server/ui-src/composables/useMessageListKeyboardNav.js b/server/ui-src/composables/useMessageListKeyboardNav.js new file mode 100644 index 000000000..3b18200a4 --- /dev/null +++ b/server/ui-src/composables/useMessageListKeyboardNav.js @@ -0,0 +1,54 @@ +import { getCurrentInstance } from "vue"; +import { onKeyStroke } from "@vueuse/core"; +import { mailbox } from "../stores/mailbox"; +import { isInputFocused } from "../utils/keyboard"; + +/** + * Keyboard navigation for message list views (MailboxView, SearchView). + * Handles j/k and arrow keys to navigate through the message list. + */ +export function useMessageListKeyboardNav() { + const instance = getCurrentInstance(); + + // Navigate to next message (j or ArrowDown) + onKeyStroke(["j", "ArrowDown"], (e) => { + if (isInputFocused()) return; + e.preventDefault(); + const messages = mailbox.messages; + if (!messages || !messages.length) return; + + // Find current index based on lastMessage or start at -1 + let currentIndex = -1; + if (mailbox.lastMessage) { + currentIndex = messages.findIndex((m) => m.ID === mailbox.lastMessage); + } + + const nextIndex = currentIndex + 1; + if (nextIndex < messages.length) { + const nextMessage = messages[nextIndex]; + mailbox.lastMessage = nextMessage.ID; + instance.proxy.$router.push("/view/" + nextMessage.ID); + } + }); + + // Navigate to previous message (k or ArrowUp) + onKeyStroke(["k", "ArrowUp"], (e) => { + if (isInputFocused()) return; + e.preventDefault(); + const messages = mailbox.messages; + if (!messages || !messages.length) return; + + // Find current index based on lastMessage + let currentIndex = messages.length; + if (mailbox.lastMessage) { + currentIndex = messages.findIndex((m) => m.ID === mailbox.lastMessage); + } + + const prevIndex = currentIndex - 1; + if (prevIndex >= 0) { + const prevMessage = messages[prevIndex]; + mailbox.lastMessage = prevMessage.ID; + instance.proxy.$router.push("/view/" + prevMessage.ID); + } + }); +} diff --git a/server/ui-src/composables/useMessageViewKeyboardNav.js b/server/ui-src/composables/useMessageViewKeyboardNav.js new file mode 100644 index 000000000..5c113d221 --- /dev/null +++ b/server/ui-src/composables/useMessageViewKeyboardNav.js @@ -0,0 +1,39 @@ +import { getCurrentInstance } from "vue"; +import { onKeyStroke } from "@vueuse/core"; +import { isInputFocused } from "../utils/keyboard"; + +/** + * Keyboard navigation for message detail view (MessageView). + * Handles j/k and arrow keys to navigate between messages, + * and Escape/u to go back to the list. + */ +export function useMessageViewKeyboardNav() { + const instance = getCurrentInstance(); + + // Navigate to next message (j or ArrowDown) + onKeyStroke(["j", "ArrowDown"], (e) => { + if (isInputFocused()) return; + const nextID = instance.proxy.nextID; + if (nextID) { + e.preventDefault(); + instance.proxy.$router.push("/view/" + nextID); + } + }); + + // Navigate to previous message (k or ArrowUp) + onKeyStroke(["k", "ArrowUp"], (e) => { + if (isInputFocused()) return; + const previousID = instance.proxy.previousID; + if (previousID) { + e.preventDefault(); + instance.proxy.$router.push("/view/" + previousID); + } + }); + + // Go back to inbox/search (Escape or u) + onKeyStroke(["Escape", "u"], (e) => { + if (isInputFocused()) return; + e.preventDefault(); + instance.proxy.goBack(); + }); +} diff --git a/server/ui-src/utils/keyboard.js b/server/ui-src/utils/keyboard.js new file mode 100644 index 000000000..bea3dcd61 --- /dev/null +++ b/server/ui-src/utils/keyboard.js @@ -0,0 +1,10 @@ +/** + * Check if user is currently focused on an input element. + * Used to prevent keyboard shortcuts from triggering while typing. + */ +export function isInputFocused() { + const el = document.activeElement; + if (!el) return false; + const tag = el.tagName.toLowerCase(); + return tag === "input" || tag === "textarea" || tag === "select" || el.isContentEditable; +} diff --git a/server/ui-src/views/MailboxView.vue b/server/ui-src/views/MailboxView.vue index 7bed109bb..8b5f4a427 100644 --- a/server/ui-src/views/MailboxView.vue +++ b/server/ui-src/views/MailboxView.vue @@ -10,6 +10,7 @@ import Pagination from "../components/NavPagination.vue"; import SearchForm from "../components/SearchForm.vue"; import { mailbox } from "../stores/mailbox"; import { pagination } from "../stores/pagination"; +import { useMessageListKeyboardNav } from "../composables/useMessageListKeyboardNav"; export default { components: { @@ -27,6 +28,10 @@ export default { // global event bus to handle message status changes inject: ["eventBus"], + setup() { + useMessageListKeyboardNav(); + }, + data() { return { mailbox, diff --git a/server/ui-src/views/MessageView.vue b/server/ui-src/views/MessageView.vue index 7840885cc..cf311ec51 100644 --- a/server/ui-src/views/MessageView.vue +++ b/server/ui-src/views/MessageView.vue @@ -8,6 +8,7 @@ import Screenshot from "../components/message/MessageScreenshot.vue"; import { mailbox } from "../stores/mailbox"; import { pagination } from "../stores/pagination"; import dayjs from "dayjs"; +import { useMessageViewKeyboardNav } from "../composables/useMessageViewKeyboardNav"; export default { components: { @@ -23,6 +24,10 @@ export default { // global event bus to handle message status changes inject: ["eventBus"], + setup() { + useMessageViewKeyboardNav(); + }, + data() { return { mailbox, diff --git a/server/ui-src/views/SearchView.vue b/server/ui-src/views/SearchView.vue index 447ae1656..d8ba422a4 100644 --- a/server/ui-src/views/SearchView.vue +++ b/server/ui-src/views/SearchView.vue @@ -10,6 +10,7 @@ import Pagination from "../components/NavPagination.vue"; import SearchForm from "../components/SearchForm.vue"; import { mailbox } from "../stores/mailbox"; import { pagination } from "../stores/pagination"; +import { useMessageListKeyboardNav } from "../composables/useMessageListKeyboardNav"; export default { components: { @@ -27,6 +28,10 @@ export default { // global event bus to handle message status changes inject: ["eventBus"], + setup() { + useMessageListKeyboardNav(); + }, + data() { return { mailbox,