diff --git a/components/status/StatusCard.vue b/components/status/StatusCard.vue
index 60208a25..1dc0a380 100644
--- a/components/status/StatusCard.vue
+++ b/components/status/StatusCard.vue
@@ -85,6 +85,7 @@ const showReplyTo = $computed(() => !replyToMain && !directReply)
:class="{ 'hover:bg-active': hover }"
tabindex="0"
focus:outline-none focus-visible:ring="2 primary"
+ aria-roledescription="status-card"
:lang="status.language ?? undefined"
@click="onclick"
@keydown.enter="onclick"
diff --git a/components/status/StatusDetails.vue b/components/status/StatusDetails.vue
index 3e485c64..43839529 100644
--- a/components/status/StatusDetails.vue
+++ b/components/status/StatusDetails.vue
@@ -30,7 +30,7 @@ const isDM = $computed(() => status.visibility === 'direct')
-
+
diff --git a/composables/dialog.ts b/composables/dialog.ts
index 9835d9be..b66a3196 100644
--- a/composables/dialog.ts
+++ b/composables/dialog.ts
@@ -18,6 +18,7 @@ export const isFirstVisit = useLocalStorage(STORAGE_KEY_FIRST_VISIT, !process.mo
export const isSigninDialogOpen = ref(false)
export const isPublishDialogOpen = ref(false)
+export const isKeyboardShortcutsDialogOpen = ref(false)
export const isMediaPreviewOpen = ref(false)
export const isEditHistoryDialogOpen = ref(false)
export const isPreviewHelpOpen = ref(isFirstVisit.value)
@@ -139,3 +140,11 @@ export function openCommandPanel(isCommandMode = false) {
export function closeCommandPanel() {
isCommandPanelOpen.value = false
}
+
+export function toggleKeyboardShortcuts() {
+ isKeyboardShortcutsDialogOpen.value = !isKeyboardShortcutsDialogOpen.value
+}
+
+export function closeKeyboardShortcuts() {
+ isKeyboardShortcutsDialogOpen.value = false
+}
diff --git a/composables/magickeys.ts b/composables/magickeys.ts
new file mode 100644
index 00000000..ab91e031
--- /dev/null
+++ b/composables/magickeys.ts
@@ -0,0 +1,44 @@
+import type { ComputedRef } from 'vue'
+
+// TODO: consider to allow combinations similar to useMagicKeys using proxy?
+// e.g. `const magicSequence = useMagicSequence()`
+// `magicSequence['Shift+Ctrl+A']`
+// `const { Ctrl_A_B } = useMagicSequence()`
+
+/**
+ * source: inspired by https://github.com/vueuse/vueuse/issues/427#issuecomment-815619446
+ * @param keys ordered list of keys making up the sequence
+ */
+export function useMagicSequence(keys: string[]): ComputedRef {
+ const magicKeys = useMagicKeys()
+
+ const success = ref(false)
+ const i = ref(0)
+ let down = false
+
+ watch(
+ () => magicKeys.current,
+ () => {
+ if (magicKeys[keys[i.value]].value && !down) {
+ down = true
+ i.value += 1
+ }
+ else if (i.value > 0 && !magicKeys[keys[i.value - 1]].value && down) {
+ down = false
+ }
+ else {
+ i.value = 0
+ down = false
+ success.value = false
+ }
+ if (i.value >= keys.length && !down) {
+ i.value = 0
+ down = false
+ success.value = true
+ }
+ }, {
+ deep: true,
+ })
+
+ return computed(() => success.value)
+}
diff --git a/locales/en.json b/locales/en.json
index b85fa5d1..d4112eb9 100644
--- a/locales/en.json
+++ b/locales/en.json
@@ -199,6 +199,31 @@
"remove_account": "Remove account from list",
"save": "Save changes"
},
+ "magic_keys": {
+ "dialog_header": "Keyboard shortcuts",
+ "groups": {
+ "actions": {
+ "boost": "Boost",
+ "command_mode": "Command mode",
+ "compose": "Compose",
+ "favourite": "Favourite",
+ "title": "Actions",
+ "zen_mode": "Zen mode"
+ },
+ "media": {
+ "title": "Media"
+ },
+ "navigation": {
+ "go_to_home": "Home",
+ "go_to_notifications": "Notifications",
+ "next_status": "Next status",
+ "previous_status": "Previous status",
+ "shortcut_help": "Shortcut help",
+ "title": "Navigation"
+ }
+ },
+ "sequence_then": "then"
+ },
"menu": {
"block_account": "Block {0}",
"block_domain": "Block domain {0}",
@@ -229,6 +254,9 @@
"unmute_conversation": "Unmute this post",
"unpin_on_profile": "Unpin on profile"
},
+ "modals": {
+ "aria_label_close": "Close"
+ },
"nav": {
"back": "Go back",
"blocked_domains": "Blocked domains",
diff --git a/plugins/scroll-to-top.ts b/plugins/1.scroll-to-top.ts
similarity index 100%
rename from plugins/scroll-to-top.ts
rename to plugins/1.scroll-to-top.ts
diff --git a/plugins/magic-keys.client.ts b/plugins/magic-keys.client.ts
new file mode 100644
index 00000000..97546681
--- /dev/null
+++ b/plugins/magic-keys.client.ts
@@ -0,0 +1,59 @@
+import type { RouteLocationRaw } from 'vue-router'
+import { useMagicSequence } from '~/composables/magickeys'
+
+export default defineNuxtPlugin(({ $scrollToTop }) => {
+ const userSettings = useUserSettings()
+ const keys = useMagicKeys()
+ const router = useRouter()
+
+ // disable shortcuts when focused on inputs (https://vueuse.org/core/usemagickeys/#conditionally-disable)
+ const activeElement = useActiveElement()
+
+ const notUsingInput = computed(() =>
+ activeElement.value?.tagName !== 'INPUT'
+ && activeElement.value?.tagName !== 'TEXTAREA'
+ && !activeElement.value?.isContentEditable,
+ )
+ const isAuthenticated = currentUser.value !== undefined
+
+ const navigateTo = (to: string | RouteLocationRaw) => {
+ closeKeyboardShortcuts()
+ $scrollToTop() // is this really required?
+ router.push(to)
+ }
+
+ whenever(logicAnd(notUsingInput, keys['?']), toggleKeyboardShortcuts)
+ whenever(logicAnd(notUsingInput, keys.z), () => userSettings.value.zenMode = !userSettings.value.zenMode)
+
+ const defaultPublishDialog = () => {
+ const current = keys.current
+ // exclusive 'c' - not apply in combination
+ // TODO: bugfix -> create PR for vueuse, reset `current` ref on window focus|blur
+ if (!current.has('shift') && !current.has('meta') && !current.has('control') && !current.has('alt')) {
+ // TODO: is this the correct way of using openPublishDialog()?
+ openPublishDialog('dialog', getDefaultDraft())
+ }
+ }
+ whenever(logicAnd(isAuthenticated, notUsingInput, keys.c), defaultPublishDialog)
+
+ whenever(logicAnd(notUsingInput, useMagicSequence(['g', 'h'])), () => navigateTo('/home'))
+ whenever(logicAnd(isAuthenticated, notUsingInput, useMagicSequence(['g', 'n'])), () => navigateTo('/notifications'))
+
+ const toggleFavouriteActiveStatus = () => {
+ // TODO: find a better solution than clicking buttons...
+ document
+ .querySelector('[aria-roledescription=status-details]')
+ ?.querySelector('button[aria-label=Favourite]')
+ ?.click()
+ }
+ whenever(logicAnd(isAuthenticated, notUsingInput, keys.f), toggleFavouriteActiveStatus)
+
+ const toggleBoostActiveStatus = () => {
+ // TODO: find a better solution than clicking buttons...
+ document
+ .querySelector('[aria-roledescription=status-details]')
+ ?.querySelector('button[aria-label=Boost]')
+ ?.click()
+ }
+ whenever(logicAnd(isAuthenticated, notUsingInput, keys.b), toggleBoostActiveStatus)
+})
diff --git a/styles/vars.css b/styles/vars.css
index 443e696f..76b35c57 100644
--- a/styles/vars.css
+++ b/styles/vars.css
@@ -1,6 +1,7 @@
:root {
--c-border: #eee;
--c-border-dark: #dccfcf;
+ --c-border-code: #ddd;
--c-danger: #FF3C1B;
--c-danger-active: #B50900;
@@ -33,11 +34,12 @@
--c-primary: var(--c-dark-primary);
--c-primary-active: var(--c-dark-primary-active);
--c-primary-light: var(--c-dark-primary-light);
- --c-primary-fade: var(--c-dark-primary-fade);
+ --c-primary-fade: var(--c-dark-primary-fade);
--c-danger: #FF2810;
--c-danger-active: #E02F00;
-
+
--c-border: #222;
+ --c-border-code: #333;
--c-border-dark: #545251;
--rgb-bg-base: 17, 17, 17;