Files
fx-test/fluxer_app/src/lib/KeybindManager.ts
Hampus Kraft 2f557eda8c initial commit
2026-01-01 21:05:54 +00:00

979 lines
30 KiB
TypeScript

/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {I18n} from '@lingui/core';
import {msg} from '@lingui/core/macro';
import CombokeysImport from 'combokeys';
import {autorun} from 'mobx';
import React from 'react';
import * as CallActionCreators from '~/actions/CallActionCreators';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import {modal} from '~/actions/ModalActionCreators';
import * as NavigationActionCreators from '~/actions/NavigationActionCreators';
import * as ReadStateActionCreators from '~/actions/ReadStateActionCreators';
import * as VoiceStateActionCreators from '~/actions/VoiceStateActionCreators';
import {ChannelTypes, JumpTypes, ME} from '~/Constants';
import {AddGuildModal} from '~/components/modals/AddGuildModal';
import {ConfirmModal} from '~/components/modals/ConfirmModal';
import {CreateDMModal} from '~/components/modals/CreateDMModal';
import {UserSettingsModal} from '~/components/modals/UserSettingsModal';
import {Routes} from '~/Routes';
import AccessibilityStore from '~/stores/AccessibilityStore';
import ChannelStore from '~/stores/ChannelStore';
import GuildListStore from '~/stores/GuildListStore';
import GuildStore from '~/stores/GuildStore';
import InboxStore from '~/stores/InboxStore';
import KeybindStore, {type KeybindAction, type KeybindConfig, type KeyCombo} from '~/stores/KeybindStore';
import KeyboardModeStore from '~/stores/KeyboardModeStore';
import QuickSwitcherStore from '~/stores/QuickSwitcherStore';
import ReadStateStore from '~/stores/ReadStateStore';
import RecentMentionsStore from '~/stores/RecentMentionsStore';
import SavedMessagesStore from '~/stores/SavedMessagesStore';
import SelectedChannelStore from '~/stores/SelectedChannelStore';
import SelectedGuildStore from '~/stores/SelectedGuildStore';
import MediaEngineStore from '~/stores/voice/MediaEngineFacade';
import {goToMessage} from '~/utils/MessageNavigator';
import {checkNativePermission} from '~/utils/NativePermissions';
import {getElectronAPI, isNativeMacOS} from '~/utils/NativeUtils';
import * as RouterUtils from '~/utils/RouterUtils';
import {jsKeyToUiohookKeycode} from '~/utils/UiohookKeycodes';
import {ComponentDispatch} from './ComponentDispatch';
interface CombokeysInstance {
bind: (
key: string,
callback: (event?: KeyboardEvent | undefined) => void,
action?: 'keydown' | 'keyup' | 'keypress',
) => void;
unbind: (key: string, action?: 'keydown' | 'keyup' | 'keypress') => void;
detach: () => void;
reset: () => void;
stopCallback: () => boolean;
}
type CombokeysConstructor = new (element?: HTMLElement) => CombokeysInstance;
const Combokeys = CombokeysImport as unknown as CombokeysConstructor;
type ShortcutSource = 'local' | 'global';
type KeybindHandler = (payload: {type: 'press' | 'release'; source: ShortcutSource}) => void;
const NON_TEXT_INPUT_TYPES = new Set([
'button',
'checkbox',
'radio',
'range',
'color',
'file',
'image',
'submit',
'reset',
]);
const isEditableElement = (target: EventTarget | null): target is HTMLElement => {
if (!(target instanceof HTMLElement)) return false;
if (target.isContentEditable) return true;
const tagName = target.tagName;
if (tagName === 'TEXTAREA') return true;
if (tagName === 'INPUT') {
const type = ((target as HTMLInputElement).type || '').toLowerCase();
return !NON_TEXT_INPUT_TYPES.has(type);
}
return false;
};
const isAltOnlyArrowCombo = (combo: KeyCombo): boolean => {
if (!combo.alt || combo.ctrlOrMeta || combo.ctrl || combo.meta) return false;
const key = combo.code ?? combo.key;
return key === 'ArrowLeft' || key === 'ArrowRight' || key === 'ArrowUp' || key === 'ArrowDown';
};
const comboToShortcutString = (combo: KeyCombo): string => {
const parts: Array<string> = [];
if (combo.ctrl) {
parts.push('Control');
} else if (combo.ctrlOrMeta) {
parts.push('CommandOrControl');
}
if (combo.meta) parts.push('Super');
if (combo.shift) parts.push('Shift');
if (combo.alt) parts.push('Alt');
const rawKey = combo.code ?? combo.key;
const key = rawKey === ' ' ? 'Space' : rawKey;
parts.push(key && key.length === 1 ? key.toUpperCase() : (key ?? ''));
return parts.filter(Boolean).join('+');
};
const keyFromComboForCombokeys = (combo: KeyCombo): string | null => {
const raw = combo.code ?? combo.key;
if (!raw) return null;
if (raw === ' ') return 'space';
if (raw.length === 1) {
return raw.toLowerCase();
}
if (/^Key[A-Z]$/.test(raw)) {
return raw.slice(3).toLowerCase();
}
if (/^Digit[0-9]$/.test(raw)) {
return raw.slice(5);
}
switch (raw) {
case 'Space':
case 'Spacebar':
return 'space';
case 'Escape':
case 'Esc':
return 'esc';
case 'Enter':
return 'enter';
case 'Tab':
return 'tab';
case 'Backspace':
return 'backspace';
case 'ArrowUp':
case 'Up':
return 'up';
case 'ArrowDown':
case 'Down':
return 'down';
case 'ArrowLeft':
case 'Left':
return 'left';
case 'ArrowRight':
case 'Right':
return 'right';
default:
return raw.toLowerCase();
}
};
const comboToCombokeysString = (combo: KeyCombo): string | null => {
const parts: Array<string> = [];
if (combo.ctrl) {
parts.push('ctrl');
} else if (combo.ctrlOrMeta) {
parts.push('mod');
}
if (combo.meta) parts.push('meta');
if (combo.shift) parts.push('shift');
if (combo.alt) parts.push('alt');
const key = keyFromComboForCombokeys(combo);
if (!key) return null;
parts.push(key);
return parts.join('+');
};
class KeybindManager {
private handlers = new Map<KeybindAction, KeybindHandler>();
private initialized = false;
private globalShortcutsEnabled = false;
private suspended = false;
private disposers: Array<() => void> = [];
private combokeys: CombokeysInstance | null = null;
private accessibilityStatus: 'unknown' | 'granted' | 'denied' = 'unknown';
private pttReleaseTimer: ReturnType<typeof setTimeout> | null = null;
private globalShortcutUnsubscribe: (() => void) | null = null;
private globalKeyHookUnsubscribes: Array<() => void> = [];
private globalKeyHookStarted = false;
private pttKeycode: number | null = null;
private pttMouseButton: number | null = null;
private get currentChannelId(): string | null {
return SelectedChannelStore.currentChannelId;
}
private get currentGuildId(): string | null {
return SelectedGuildStore.selectedGuildId;
}
private navigateToChannel(guildId: string | null, channelId: string): void {
const channel = ChannelStore.getChannel(channelId);
const effectiveGuildId = guildId ?? channel?.guildId ?? null;
if (channel?.guildId) {
RouterUtils.transitionTo(Routes.guildChannel(channel.guildId, channelId));
NavigationActionCreators.selectGuild(channel.guildId);
NavigationActionCreators.selectChannel(channel.guildId, channelId);
return;
}
if (channel && !channel.guildId) {
RouterUtils.transitionTo(Routes.dmChannel(channelId));
NavigationActionCreators.selectChannel(ME, channelId);
return;
}
if (effectiveGuildId) {
RouterUtils.transitionTo(Routes.guildChannel(effectiveGuildId, channelId));
NavigationActionCreators.selectGuild(effectiveGuildId);
NavigationActionCreators.selectChannel(effectiveGuildId, channelId);
}
}
private get activeKeybinds(): Array<KeybindConfig & {combo: KeyCombo}> {
return KeybindStore.getAll().filter(({combo}) => combo.enabled ?? true);
}
private get activeGlobalKeybinds(): Array<KeybindConfig & {combo: KeyCombo}> {
return this.activeKeybinds.filter(
(k) => k.allowGlobal && (k.combo.global ?? false) && ((k.combo.key ?? '') !== '' || (k.combo.code ?? '') !== ''),
);
}
private getOrderedGuilds(): Array<ReturnType<typeof GuildStore.getGuilds>[number]> {
if (GuildListStore.guilds.length > 0) {
return GuildListStore.guilds;
}
return GuildStore.getGuilds();
}
private getFirstSelectableChannelId(guildId: string): string | undefined {
const channels = ChannelStore.getGuildChannels(guildId);
const selectableChannel = channels.find((c) => c.type !== ChannelTypes.GUILD_CATEGORY);
return selectableChannel?.id;
}
private ensureCombokeys(): CombokeysInstance | null {
if (!this.combokeys && Combokeys) {
this.combokeys = new Combokeys(document.documentElement);
this.combokeys.stopCallback = () => false;
}
return this.combokeys;
}
private async checkInputMonitoringPermission(): Promise<boolean> {
if (!isNativeMacOS()) return true;
if (this.accessibilityStatus === 'granted') return true;
const result = await checkNativePermission('input-monitoring');
if (result === 'granted') {
this.accessibilityStatus = 'granted';
return true;
}
if (result === 'denied') {
this.accessibilityStatus = 'denied';
} else {
this.accessibilityStatus = 'unknown';
}
return false;
}
async init(i18n: I18n) {
if (this.initialized) return;
this.initialized = true;
this.ensureCombokeys();
this.registerDefaultHandlers(i18n);
this.refreshLocalShortcuts();
this.disposers.push(
autorun(() => {
this.refreshLocalShortcuts();
}),
);
this.disposers.push(
autorun(() => {
void this.refreshGlobalShortcuts();
}),
);
this.disposers.push(
autorun(() => {
MediaEngineStore.handlePushToTalkModeChange();
}),
);
this.disposers.push(
autorun(() => {
void this.refreshGlobalKeyHook();
}),
);
await this.refreshGlobalShortcuts();
await this.refreshGlobalKeyHook();
}
private async refreshGlobalKeyHook(): Promise<void> {
const electronApi = getElectronAPI();
if (!electronApi?.globalKeyHookStart) return;
const pttKeybind = KeybindStore.getByAction('push_to_talk');
const isPttEnabled = KeybindStore.isPushToTalkEnabled();
const hasPttKeybind = !!(pttKeybind.combo.key || pttKeybind.combo.code);
const shouldUseGlobalHook = isPttEnabled && hasPttKeybind && (pttKeybind.combo.global ?? false);
if (shouldUseGlobalHook) {
const started = await this.startGlobalKeyHook();
if (started) {
this.pttKeycode = jsKeyToUiohookKeycode(pttKeybind.combo.code ?? pttKeybind.combo.key);
this.pttMouseButton = null;
}
} else {
this.stopGlobalKeyHook();
this.pttKeycode = null;
this.pttMouseButton = null;
}
}
async reapplyGlobalShortcuts() {
if (!this.initialized) return;
await this.refreshGlobalShortcuts();
}
destroy() {
if (!this.initialized) return;
this.initialized = false;
this.disposers.forEach((dispose) => dispose());
this.disposers = [];
if (this.globalShortcutUnsubscribe) {
this.globalShortcutUnsubscribe();
this.globalShortcutUnsubscribe = null;
}
this.stopGlobalKeyHook();
const electronApi = getElectronAPI();
if (electronApi) {
void electronApi.unregisterAllGlobalShortcuts?.().catch(() => {});
}
this.handlers.clear();
this.combokeys?.detach();
this.combokeys = null;
}
async startGlobalKeyHook(): Promise<boolean> {
const electronApi = getElectronAPI();
if (!electronApi?.globalKeyHookStart) return false;
if (this.globalKeyHookStarted) return true;
if (!(await this.checkInputMonitoringPermission())) {
return false;
}
const started = await electronApi.globalKeyHookStart();
if (!started) return false;
this.globalKeyHookStarted = true;
const keyEventUnsub = electronApi.onGlobalKeyEvent?.((event) => {
this.handleGlobalKeyEvent(event);
});
if (keyEventUnsub) this.globalKeyHookUnsubscribes.push(keyEventUnsub);
const mouseEventUnsub = electronApi.onGlobalMouseEvent?.((event) => {
this.handleGlobalMouseEvent(event);
});
if (mouseEventUnsub) this.globalKeyHookUnsubscribes.push(mouseEventUnsub);
return true;
}
stopGlobalKeyHook(): void {
const electronApi = getElectronAPI();
this.globalKeyHookUnsubscribes.forEach((unsub) => unsub());
this.globalKeyHookUnsubscribes = [];
if (electronApi?.globalKeyHookStop && this.globalKeyHookStarted) {
void electronApi.globalKeyHookStop();
}
this.globalKeyHookStarted = false;
}
private handleGlobalKeyEvent(event: {type: 'keydown' | 'keyup'; keycode: number; keyName: string}): void {
if (this.pttKeycode !== null && event.keycode === this.pttKeycode) {
const handler = this.handlers.get('push_to_talk');
if (handler) {
handler({
type: event.type === 'keydown' ? 'press' : 'release',
source: 'global',
});
}
}
}
private handleGlobalMouseEvent(event: {type: 'mousedown' | 'mouseup'; button: number}): void {
if (this.pttMouseButton !== null && event.button === this.pttMouseButton) {
const handler = this.handlers.get('push_to_talk');
if (handler) {
handler({
type: event.type === 'mousedown' ? 'press' : 'release',
source: 'global',
});
}
}
}
setPttKeybind(keycode: number | null, mouseButton: number | null): void {
this.pttKeycode = keycode;
this.pttMouseButton = mouseButton;
}
suspend(): void {
this.suspended = true;
this.combokeys?.reset();
}
resume(): void {
this.suspended = false;
this.refreshLocalShortcuts();
}
register(action: KeybindAction, handler: KeybindHandler) {
this.handlers.set(action, handler);
}
private registerDefaultHandlers(i18n: I18n) {
this.register('quick_switcher', ({type}) => {
if (type !== 'press') return;
if (QuickSwitcherStore.getIsOpen()) QuickSwitcherStore.hide();
else QuickSwitcherStore.show();
});
this.register('toggle_hotkeys', ({type}) => {
if (type !== 'press') return;
ModalActionCreators.push(modal(() => React.createElement(UserSettingsModal, {initialTab: 'keybinds'})));
ComponentDispatch.dispatch('USER_SETTINGS_TAB_SELECT', {tab: 'keybinds'});
});
this.register('get_help', ({type}) => {
if (type !== 'press') return;
window.open(Routes.help(), '_blank', 'noopener');
});
this.register('search', ({type}) => {
if (type !== 'press') return;
ComponentDispatch.dispatch('MESSAGE_SEARCH_OPEN');
});
this.register('toggle_mute', ({type}) => {
if (type !== 'press') return;
const connectedGuildId = MediaEngineStore.guildId;
const voiceState = MediaEngineStore.getVoiceState(connectedGuildId);
const isGuildMuted = voiceState?.mute ?? false;
if (isGuildMuted) {
ModalActionCreators.push(
modal(() =>
React.createElement(ConfirmModal, {
title: i18n._(msg`Community Muted`),
description: i18n._(msg`You cannot unmute yourself because you have been muted by a moderator.`),
primaryText: i18n._(msg`Okay`),
primaryVariant: 'primary',
secondaryText: false,
onPrimary: () => {},
}),
),
);
return;
}
void VoiceStateActionCreators.toggleSelfMute(null);
});
this.register('toggle_deafen', ({type}) => {
if (type !== 'press') return;
const connectedGuildId = MediaEngineStore.guildId;
const voiceState = MediaEngineStore.getVoiceState(connectedGuildId);
const isGuildDeafened = voiceState?.deaf ?? false;
if (isGuildDeafened) {
ModalActionCreators.push(
modal(() =>
React.createElement(ConfirmModal, {
title: i18n._(msg`Community Deafened`),
description: i18n._(msg`You cannot undeafen yourself because you have been deafened by a moderator.`),
primaryText: i18n._(msg`Okay`),
primaryVariant: 'primary',
secondaryText: false,
onPrimary: () => {},
}),
),
);
return;
}
void VoiceStateActionCreators.toggleSelfDeaf(null);
});
this.register('toggle_video', ({type}) => {
if (type !== 'press') return;
void MediaEngineStore.toggleCameraFromKeybind();
});
this.register('toggle_screen_share', ({type}) => {
if (type !== 'press') return;
void MediaEngineStore.toggleScreenShareFromKeybind();
});
this.register('toggle_settings', ({type}) => {
if (type !== 'press') return;
ModalActionCreators.push(modal(() => React.createElement(UserSettingsModal)));
});
this.register('toggle_push_to_talk_mode', ({type}) => {
if (type !== 'press') return;
KeybindStore.setTransmitMode(KeybindStore.isPushToTalkEnabled() ? 'voice_activity' : 'push_to_talk');
MediaEngineStore.handlePushToTalkModeChange();
});
this.register('push_to_talk', ({type}) => {
if (type === 'press') {
if (this.pttReleaseTimer) {
clearTimeout(this.pttReleaseTimer);
this.pttReleaseTimer = null;
}
const shouldUnmute = KeybindStore.handlePushToTalkPress();
if (shouldUnmute) {
MediaEngineStore.applyPushToTalkHold(true);
}
} else {
const shouldMute = KeybindStore.handlePushToTalkRelease();
if (shouldMute) {
const delay = KeybindStore.pushToTalkReleaseDelay;
this.pttReleaseTimer = setTimeout(() => {
this.pttReleaseTimer = null;
MediaEngineStore.applyPushToTalkHold(false);
}, delay);
}
}
});
this.register('scroll_chat_up', ({type}) => {
if (type !== 'press') return;
ComponentDispatch.dispatch('SCROLL_PAGE_UP');
});
this.register('scroll_chat_down', ({type}) => {
if (type !== 'press') return;
ComponentDispatch.dispatch('SCROLL_PAGE_DOWN');
});
this.register('jump_to_oldest_unread', ({type}) => {
if (type !== 'press') return;
const channelId = this.currentChannelId;
if (!channelId) return;
const targetId = ReadStateStore.getOldestUnreadMessageId(channelId);
if (!targetId) return;
goToMessage(channelId, targetId, {jumpType: JumpTypes.ANIMATED});
});
this.register('mark_channel_read', ({type}) => {
if (type !== 'press') return;
const channelId = this.currentChannelId;
if (!channelId) return;
if (ReadStateStore.hasUnread(channelId)) {
ReadStateActionCreators.ack(channelId, true, true);
}
});
this.register('mark_server_read', ({type}) => {
if (type !== 'press') return;
const guildId = this.currentGuildId;
if (!guildId) return;
const channels = ChannelStore.getGuildChannels(guildId);
const channelIds = channels
.filter((channel) => ReadStateStore.hasUnread(channel.id))
.map((channel) => channel.id);
if (channelIds.length > 0) {
void ReadStateActionCreators.bulkAckChannels(channelIds);
}
});
this.register('mark_top_inbox_read', ({type}) => {
if (type !== 'press') return;
const inboxTab = InboxStore.selectedTab;
if (inboxTab === 'bookmarks') {
const savedMessages = SavedMessagesStore.savedMessages;
const unreadBookmark = savedMessages.find((message) => {
return ReadStateStore.hasUnread(message.channelId);
});
if (unreadBookmark) {
ReadStateActionCreators.ack(unreadBookmark.channelId, true, true);
}
} else {
const mentions = RecentMentionsStore.recentMentions;
const unreadMention = mentions.find((message) => {
return ReadStateStore.hasUnread(message.channelId);
});
if (unreadMention) {
ReadStateActionCreators.ack(unreadMention.channelId, true, true);
}
}
});
this.register('navigate_history_back', ({type}) => {
if (type !== 'press') return;
const history = RouterUtils.getHistory();
if (history?.go) history.go(-1);
});
this.register('navigate_history_forward', ({type}) => {
if (type !== 'press') return;
const history = RouterUtils.getHistory();
if (history?.go) history.go(1);
});
this.register('navigate_to_current_call', ({type}) => {
if (type !== 'press') return;
const channelId = MediaEngineStore.channelId;
const guildId = MediaEngineStore.guildId;
if (!channelId) return;
this.navigateToChannel(guildId, channelId);
});
this.register('navigate_last_server_or_dm', ({type}) => {
if (type !== 'press') return;
const lastGuild = SelectedGuildStore.lastSelectedGuildId;
const dmChannel = SelectedChannelStore.selectedChannelIds.get(ME);
if (lastGuild) {
const channelId = SelectedChannelStore.selectedChannelIds.get(lastGuild);
if (channelId) {
this.navigateToChannel(lastGuild, channelId);
return;
}
}
if (dmChannel) {
this.navigateToChannel(ME, dmChannel);
}
});
this.register('navigate_channel_next', ({type}) => {
if (type !== 'press') return;
const guildId = this.currentGuildId;
if (!guildId) return;
const channels = ChannelStore.getGuildChannels(guildId);
const current = this.currentChannelId;
if (!channels.length || !current) return;
const idx = channels.findIndex((c) => c.id === current);
const next = channels[(idx + 1) % channels.length];
this.navigateToChannel(guildId, next.id);
});
this.register('navigate_channel_previous', ({type}) => {
if (type !== 'press') return;
const guildId = this.currentGuildId;
if (!guildId) return;
const channels = ChannelStore.getGuildChannels(guildId);
const current = this.currentChannelId;
if (!channels.length || !current) return;
const idx = channels.findIndex((c) => c.id === current);
const prev = channels[(idx - 1 + channels.length) % channels.length];
this.navigateToChannel(guildId, prev.id);
});
this.register('navigate_server_next', ({type}) => {
if (type !== 'press') return;
const guilds = this.getOrderedGuilds();
if (!guilds.length) return;
const currentId = this.currentGuildId ?? guilds[0].id;
const idx = guilds.findIndex((g) => g.id === currentId);
const safeIdx = idx === -1 ? 0 : idx;
const next = guilds[(safeIdx + 1) % guilds.length];
const channelId =
SelectedChannelStore.selectedChannelIds.get(next.id) ?? this.getFirstSelectableChannelId(next.id);
if (!channelId) return;
this.navigateToChannel(next.id, channelId);
});
this.register('navigate_server_previous', ({type}) => {
if (type !== 'press') return;
const guilds = this.getOrderedGuilds();
if (!guilds.length) return;
const currentId = this.currentGuildId ?? guilds[0].id;
const idx = guilds.findIndex((g) => g.id === currentId);
const safeIdx = idx === -1 ? 0 : idx;
const prev = guilds[(safeIdx - 1 + guilds.length) % guilds.length];
const channelId =
SelectedChannelStore.selectedChannelIds.get(prev.id) ?? this.getFirstSelectableChannelId(prev.id);
if (!channelId) return;
this.navigateToChannel(prev.id, channelId);
});
this.register('navigate_unread_channel_next', ({type}) => {
if (type !== 'press') return;
const guildId = this.currentGuildId;
if (!guildId) return;
const unread = ChannelStore.getGuildChannels(guildId).filter((c) => ReadStateStore.hasUnread(c.id));
if (!unread.length) return;
const current = this.currentChannelId;
const idx = unread.findIndex((c) => c.id === current);
const next = unread[(idx + 1) % unread.length];
this.navigateToChannel(guildId, next.id);
});
this.register('navigate_unread_channel_previous', ({type}) => {
if (type !== 'press') return;
const guildId = this.currentGuildId;
if (!guildId) return;
const unread = ChannelStore.getGuildChannels(guildId).filter((c) => ReadStateStore.hasUnread(c.id));
if (!unread.length) return;
const current = this.currentChannelId;
const idx = unread.findIndex((c) => c.id === current);
const prev = unread[(idx - 1 + unread.length) % unread.length];
this.navigateToChannel(guildId, prev.id);
});
this.register('navigate_unread_mentions_next', ({type}) => {
if (type !== 'press') return;
const guildId = this.currentGuildId;
if (!guildId) return;
const unread = ChannelStore.getGuildChannels(guildId).filter((c) => ReadStateStore.getMentionCount(c.id) > 0);
if (!unread.length) return;
const current = this.currentChannelId;
const idx = unread.findIndex((c) => c.id === current);
const next = unread[(idx + 1) % unread.length];
this.navigateToChannel(guildId, next.id);
});
this.register('navigate_unread_mentions_previous', ({type}) => {
if (type !== 'press') return;
const guildId = this.currentGuildId;
if (!guildId) return;
const unread = ChannelStore.getGuildChannels(guildId).filter((c) => ReadStateStore.getMentionCount(c.id) > 0);
if (!unread.length) return;
const current = this.currentChannelId;
const idx = unread.findIndex((c) => c.id === current);
const prev = unread[(idx - 1 + unread.length) % unread.length];
this.navigateToChannel(guildId, prev.id);
});
this.register('return_previous_text_channel', ({type}) => {
if (type !== 'press') return;
const current = this.currentChannelId;
const prevVisit = SelectedChannelStore.recentChannelVisits.find((visit) => visit.channelId !== current);
if (!prevVisit) return;
this.navigateToChannel(prevVisit.guildId, prevVisit.channelId);
});
this.register('return_previous_text_channel_alt', ({type}) => {
if (type !== 'press') return;
const visits = SelectedChannelStore.recentChannelVisits;
if (visits.length < 2) return;
const target = visits[1];
this.navigateToChannel(target.guildId, target.channelId);
});
this.register('return_connected_audio_channel', ({type}) => {
if (type !== 'press') return;
const channelId = MediaEngineStore.channelId;
if (!channelId) return;
this.navigateToChannel(MediaEngineStore.guildId, channelId);
});
this.register('return_connected_audio_channel_alt', ({type}) => {
if (type !== 'press') return;
const channelId = MediaEngineStore.channelId;
if (!channelId) return;
this.navigateToChannel(MediaEngineStore.guildId, channelId);
});
this.register('start_pm_call', ({type}) => {
if (type !== 'press') return;
const channelId = this.currentChannelId;
if (!channelId) return;
const channel = ChannelStore.getChannel(channelId);
if (!channel || channel.guildId) return;
CallActionCreators.startCall(channelId);
});
this.register('toggle_pins_popout', ({type}) => {
if (type !== 'press') return;
ComponentDispatch.dispatch('CHANNEL_PINS_OPEN');
});
this.register('toggle_mentions_popout', ({type}) => {
if (type !== 'press') return;
ComponentDispatch.dispatch('INBOX_OPEN');
});
this.register('toggle_channel_member_list', ({type}) => {
if (type !== 'press') return;
ComponentDispatch.dispatch('CHANNEL_MEMBER_LIST_TOGGLE');
});
this.register('create_or_join_server', ({type}) => {
if (type !== 'press') return;
ModalActionCreators.push(modal(() => React.createElement(AddGuildModal)));
});
this.register('create_private_group', ({type}) => {
if (type !== 'press') return;
ModalActionCreators.push(modal(() => React.createElement(CreateDMModal)));
});
this.register('focus_text_area', ({type}) => {
if (type !== 'press') return;
const channelId = this.currentChannelId;
if (!channelId) return;
ComponentDispatch.dispatch('FOCUS_TEXTAREA', {channelId});
});
this.register('upload_file', ({type}) => {
if (type !== 'press') return;
const channelId = this.currentChannelId;
if (!channelId) return;
ComponentDispatch.dispatch('TEXTAREA_UPLOAD_FILE', {channelId});
});
this.register('zoom_in', ({type}) => {
if (type !== 'press') return;
void AccessibilityStore.adjustZoom(0.1);
});
this.register('zoom_out', ({type}) => {
if (type !== 'press') return;
void AccessibilityStore.adjustZoom(-0.1);
});
this.register('zoom_reset', ({type}) => {
if (type !== 'press') return;
AccessibilityStore.updateSettings({zoomLevel: 1.0});
});
}
private async refreshGlobalShortcuts() {
const electronApi = getElectronAPI();
if (!electronApi) return;
const keybinds = this.activeGlobalKeybinds;
try {
await electronApi.unregisterAllGlobalShortcuts?.();
} catch (error) {
console.error('Failed to unregister global shortcuts', error);
}
if (!keybinds.length) {
this.globalShortcutsEnabled = false;
return;
}
if (!(await this.checkInputMonitoringPermission())) {
return;
}
if (!this.globalShortcutUnsubscribe) {
this.globalShortcutUnsubscribe =
electronApi.onGlobalShortcut?.((id: string) => {
const keybind = keybinds.find((k) => comboToShortcutString(k.combo) === id);
if (!keybind) return;
const handler = this.handlers.get(keybind.action);
if (!handler) return;
handler({
type: 'press',
source: 'global',
});
}) ?? null;
}
const shortcuts = keybinds
.map((k) => ({entry: k, shortcut: comboToShortcutString(k.combo)}))
.filter((s): s is {entry: KeybindConfig & {combo: KeyCombo}; shortcut: string} => !!s.shortcut);
if (!shortcuts.length) return;
for (const {shortcut} of shortcuts) {
try {
await electronApi.registerGlobalShortcut?.(shortcut, shortcut);
} catch (error) {
console.error(`Failed to register global shortcut ${shortcut}`, error);
}
}
this.globalShortcutsEnabled = true;
}
private refreshLocalShortcuts() {
if (!this.combokeys || this.suspended) return;
this.combokeys.reset();
this.activeKeybinds.forEach((entry) => this.bindLocalShortcut(entry));
}
private bindLocalShortcut(entry: KeybindConfig & {combo: KeyCombo}) {
const {combo, action} = entry;
const handler = this.handlers.get(action);
if (!handler) return;
const shortcut = comboToCombokeysString(combo);
if (!shortcut) return;
const hasModifier = !!(combo.ctrl || combo.ctrlOrMeta || combo.alt || combo.meta);
const ignoreInEditable = isAltOnlyArrowCombo(combo);
const shouldIgnoreEvent = (event: KeyboardEvent): boolean => {
const target = event.target ?? null;
if (!isEditableElement(target)) return false;
if (!hasModifier) return true;
return ignoreInEditable;
};
const wrapHandler = (type: 'press' | 'release') => (event?: KeyboardEvent) => {
if (!event) return;
if (shouldIgnoreEvent(event)) return;
if (this.globalShortcutsEnabled && (combo.global ?? false)) {
return;
}
if (action === 'focus_text_area' && KeyboardModeStore.keyboardModeEnabled) {
return;
}
handler({type, source: 'local'});
if (type === 'press') event.preventDefault();
};
const combokeys = this.ensureCombokeys();
if (combokeys) {
combokeys.bind(shortcut, wrapHandler('press'), 'keydown');
combokeys.bind(shortcut, wrapHandler('release'), 'keyup');
}
}
}
export default new KeybindManager();