diff --git a/README.md b/README.md index ad22d44..caaaa2b 100644 --- a/README.md +++ b/README.md @@ -39,14 +39,14 @@ pnpm build pnpm inject ``` -Restart Discord. The plugin is on by default; you can find it as **WaylandKeybinds** in Vencord's plugin list. +Restart Discord. The plugin is on by default; you can find it as **WaylandKeybinds** in Vencord's plugin list. Its info panel shows whether the fix is active, how many keybinds GNOME has registered, and a button that opens Discord's Keybinds page. > [!IMPORTANT] > Keep the folder name `waylandKeybinds.discordDesktop`. The suffix limits the plugin to the Discord desktop app, the only place where Discord's native keybind module exists. ## Use -1. Open Discord **User Settings → Keybinds → Add a Keybind**. +1. Open Discord **User Settings → System → Keybinds** and click **Add a Keybind**. 2. Pick an action (Toggle Mute, Toggle Deafen, Push to Talk…) and press your combination. 3. GNOME asks once to allow the shortcut. Click **Add**. diff --git a/about.tsx b/about.tsx new file mode 100644 index 0000000..633045e --- /dev/null +++ b/about.tsx @@ -0,0 +1,86 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Evandro + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { Button } from "@components/Button"; +import { findByPropsLazy } from "@webpack"; +import { i18n, SettingsRouter, useState, useStateFromStores } from "@webpack/common"; + +import { pickCopy } from "./copy"; +import { KeybindsStore, portalActive } from "./discord"; + +function locale(): string { + try { + return i18n.intl.currentLocale ?? document.documentElement.lang; + } catch { + return document.documentElement.lang; + } +} + +// the panel renders inside the plugin modal, so the top of Discord's modal stack at mount is that modal +const ModalStack = findByPropsLazy("getOpenModalKeys", "closeModal"); + +function topModalKey(): string | undefined { + try { + return [...ModalStack.getOpenModalKeys()].at(-1); + } catch { + return undefined; + } +} + +function openKeybinds(pluginModalKey: string | undefined) { + // settings ignore navigation while another modal sits on top of them + if (pluginModalKey) ModalStack.closeModal(pluginModalKey); + SettingsRouter.openUserSettings("system_custom_keybinds_category"); +} + +function StatusIcon({ active }: { active: boolean; }) { + return ( + + ); +} + +export function AboutPanel() { + const copy = pickCopy(locale()); + const active = portalActive(); + const [modalKey] = useState(topModalKey); + const registered = useStateFromStores([KeybindsStore], () => + Object.keys(KeybindsStore.getUserAgnosticState() ?? {}) + .filter(id => KeybindsStore.getWaylandPortalShortcutState(id)?.bound).length + ); + + if (!active) { + return ( +

+ + {copy.inactive} +

+ ); + } + + return ( +
+

+ + {copy.active(registered)} +

+

{copy.title}

+

{copy.lead}

+ {/* list-style is removed for the numbered badges, so keep the list role for screen readers */} +
    + {copy.steps.map(([before, bold, after], i) =>
  1. {before}{bold && {bold}}{after}
  2. )} +
+
+ +

{copy.tip}

+
+
+ ); +} diff --git a/copy.ts b/copy.ts new file mode 100644 index 0000000..e0fe557 --- /dev/null +++ b/copy.ts @@ -0,0 +1,79 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Evandro + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +// Labels in bold quote Discord's own strings for each locale, so users find them on screen +export interface Copy { + active(registered: number): string; + inactive: string; + title: string; + lead: string; + // [before, bold, after]: plain data, because JSX can't run before Vencord has loaded React + steps: [string, string, string][]; + open: string; + tip: string; +} + +export const EN: Copy = { + active: n => n === 0 + ? "Active on this system · no keybinds yet" + : `Active on this system · ${n} ${n === 1 ? "keybind" : "keybinds"} registered`, + inactive: "Not needed on this system: the fix only acts on Linux with Wayland.", + title: "Your Discord keybinds now work in every app", + lead: "Set them up the same way as on Windows, on Discord's own Keybinds page.", + steps: [ + ["Open ", "Keybinds", " under System in Discord's User Settings."], + ["Click ", "Add a Keybind", ", choose an action such as Toggle Mute, Toggle Deafen or Push to Talk, and record your keys."], + ["When your system asks to add the shortcut, ", "allow it", "."] + ], + open: "Open Keybinds", + tip: "Push to Talk is most reliable on a single key without modifiers, such as Pause or F13." +}; + +export const PT: Copy = { + active: n => n === 0 + ? "Ativo neste sistema · nenhum atalho ainda" + : `Ativo neste sistema · ${n} ${n === 1 ? "atalho registrado" : "atalhos registrados"}`, + inactive: "Não é necessário neste sistema: a correção só atua no Linux com Wayland.", + title: "Seus atalhos do Discord agora funcionam em qualquer aplicativo", + lead: "Configure do mesmo jeito que no Windows, na própria página de atalhos do Discord.", + steps: [ + ["Abra ", "Atalhos do teclado", " em Sistema, nas Configurações de usuário."], + ["Clique em ", "Adicionar atalho", ", escolha a ação (Ativar/desativar microfone, Ativar/desativar áudio, Aperte para Falar…) e grave as teclas."], + ["Quando o sistema pedir para adicionar o atalho, ", "permita", "."] + ], + open: "Abrir Atalhos do teclado", + tip: "No Aperte para Falar, prefira uma tecla única sem modificadores, como Pause ou F13." +}; + +// Spain and Latin American Spanish name the voice actions differently in Discord +function spanish(mute: string, deafen: string): Copy { + return { + active: n => n === 0 + ? "Activo en este sistema · todavía no hay atajos" + : `Activo en este sistema · ${n} ${n === 1 ? "atajo registrado" : "atajos registrados"}`, + inactive: "No hace falta en este sistema: la corrección solo actúa en Linux con Wayland.", + title: "Tus atajos de Discord ahora funcionan en cualquier aplicación", + lead: "Configúralos igual que en Windows, en la propia página de atajos de Discord.", + steps: [ + ["Abre ", "Atajos de teclado", " en Sistema, dentro de los Ajustes de usuario."], + ["Haz clic en ", "Añadir un atajo de teclado", `, elige la acción (${mute}, ${deafen}, Pulsar para hablar…) y graba las teclas.`], + ["Cuando el sistema pida añadir el atajo, ", "permítelo", "."] + ], + open: "Abrir Atajos de teclado", + tip: "Para Pulsar para hablar, mejor usa una sola tecla sin modificadores, como Pause o F13." + }; +} + +export const ES_ES = spanish("Alternar silencio", "Alternar ensordecimiento"); +export const ES_419 = spanish("Activar o desactivar silencio", "Activar o desactivar ensordecer"); + +export function pickCopy(locale: string): Copy { + const lang = locale.toLowerCase(); + if (lang.startsWith("pt")) return PT; + if (lang === "es-419") return ES_419; + if (lang.startsWith("es")) return ES_ES; + return EN; +} diff --git a/discord.ts b/discord.ts new file mode 100644 index 0000000..94be29f --- /dev/null +++ b/discord.ts @@ -0,0 +1,24 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Evandro + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { IS_LINUX } from "@utils/constants"; +import { findStoreLazy } from "@webpack"; + +export type Shortcut = [number, number, ...unknown[]][]; + +export const KeybindsStore = findStoreLazy("KeybindsStore"); + +export function discordUtils() { + return DiscordNative.nativeModules.requireModule("discord_utils"); +} + +export function portalActive(): boolean { + return IS_LINUX && discordUtils().isWaylandGlobalShortcutsAvailable?.() === true; +} + +export function portalTrigger(shortcut: Shortcut): string { + return discordUtils().getWaylandPortalTrigger?.(shortcut.map(([type, code]) => [type, code])) ?? ""; +} diff --git a/index.ts b/index.ts index 76747da..4926016 100644 --- a/index.ts +++ b/index.ts @@ -4,15 +4,14 @@ * SPDX-License-Identifier: GPL-3.0-or-later */ -import { IS_LINUX } from "@utils/constants"; import { Logger } from "@utils/Logger"; import definePlugin from "@utils/types"; -import { findStoreLazy } from "@webpack"; +import { AboutPanel } from "./about"; +import { KeybindsStore, portalActive, portalTrigger, Shortcut } from "./discord"; import { adjustOptions, Callback, createDispatcher, Dispatcher, fixPortalState, KeyEventOptions, planRegistration, PortalState, Scheduler } from "./logic"; import { PATCHES } from "./patches"; - -type Shortcut = [number, number, ...unknown[]][]; +import managedStyle from "./style.css?managed"; // Discord's own native keybind bridge (the object the KeybindsStore registers through) interface Bridge { @@ -28,28 +27,21 @@ const scheduler: Scheduler = { }; const logger = new Logger("WaylandKeybinds"); -const KeybindsStore = findStoreLazy("KeybindsStore"); - -function discordUtils() { - return DiscordNative.nativeModules.requireModule("discord_utils"); -} - -function portalTrigger(shortcut: Shortcut): string { - return discordUtils().getWaylandPortalTrigger?.(shortcut.map(([type, code]) => [type, code])) ?? ""; -} export default definePlugin({ name: "WaylandKeybinds", - description: "Makes Discord's global keybinds reliable on GNOME Wayland: toggles (mute, deafen) fire on key press and push-to-talk recovers from key releases GNOME drops", + description: "Makes Discord's keybinds work in every app on Linux Wayland: set them up in Discord's Keybinds page as usual and allow the system prompt", authors: [{ name: "Evandro", id: 281463271758888960n }], // only changes behaviour when Discord registers keybinds through the Wayland GlobalShortcuts portal enabledByDefault: true, patches: PATCHES, + managedStyle, + settingsAboutComponent: AboutPanel, wrapRegistration(bridge: Bridge, id: number, shortcut: Shortcut, callback: Callback, options: KeyEventOptions): [Callback, KeyEventOptions] { try { - const portalActive = IS_LINUX && discordUtils().isWaylandGlobalShortcutsAvailable?.() === true; - const mode = planRegistration(options, portalActive, portalActive && portalTrigger(shortcut) !== ""); + const active = portalActive(); + const mode = planRegistration(options, active, active && portalTrigger(shortcut) !== ""); const adjusted = adjustOptions(options, mode); dispatchers.get(id)?.cancel(); const dispatcher = createDispatcher(callback, mode, () => { diff --git a/logic.test.ts b/logic.test.ts index 4770d66..8618a51 100644 --- a/logic.test.ts +++ b/logic.test.ts @@ -8,6 +8,7 @@ import assert from "node:assert/strict"; import { describe, test } from "node:test"; +import { EN, ES_419, ES_ES, pickCopy, PT } from "./copy"; import { adjustOptions, createDispatcher, ECHO_MS, FIRST_PROBE_MS, fixPortalState, normalizeTrigger, planRegistration, PROBE_WINDOW_MS } from "./logic"; function recorder() { @@ -241,3 +242,50 @@ describe("fixPortalState (V(G)=5)", () => { assert.equal(fixPortalState(gnome, "CTRL+ALT+F12"), gnome); }); }); + +describe("pickCopy (V(G)=4)", () => { + test("P1 português → PT", () => { + assert.equal(pickCopy("pt-BR"), PT); + }); + test("P2 espanhol da América Latina → ES_419", () => { + assert.equal(pickCopy("es-419"), ES_419); + }); + test("P3 espanhol da Espanha → ES_ES", () => { + assert.equal(pickCopy("es-ES"), ES_ES); + }); + test("P4 inglês → EN", () => { + assert.equal(pickCopy("en-US"), EN); + }); + test("borda: idioma fora dos três → EN", () => { + assert.equal(pickCopy("fr"), EN); + assert.equal(pickCopy("ja"), EN); + }); + test("borda: idioma vazio → EN", () => { + assert.equal(pickCopy(""), EN); + }); + test("borda: maiúsculas e minúsculas não importam", () => { + assert.equal(pickCopy("PT-br"), PT); + assert.equal(pickCopy("ES-419"), ES_419); + }); +}); + +describe("textos do painel", () => { + test("botão citado no passo 2 é o rótulo oficial do Discord em cada idioma", () => { + assert.equal(EN.steps[1][1], "Add a Keybind"); + assert.equal(PT.steps[1][1], "Adicionar atalho"); + assert.equal(ES_ES.steps[1][1], "Añadir un atajo de teclado"); + assert.equal(ES_419.steps[1][1], "Añadir un atajo de teclado"); + }); + test("as variantes de espanhol usam os nomes de ação de cada região", () => { + assert.match(ES_ES.steps[1][2], /Alternar silencio, Alternar ensordecimiento/); + assert.match(ES_419.steps[1][2], /Activar o desactivar silencio, Activar o desactivar ensordecer/); + }); + test("plural do status", () => { + for (const copy of [EN, PT, ES_ES]) { + assert.notEqual(copy.active(0), copy.active(1)); + assert.notEqual(copy.active(1).replace("1", "2"), copy.active(2)); + } + assert.equal(ES_ES.active(1), "Activo en este sistema · 1 atajo registrado"); + assert.equal(ES_ES.active(2), "Activo en este sistema · 2 atajos registrados"); + }); +}); diff --git a/style.css b/style.css new file mode 100644 index 0000000..2950a34 --- /dev/null +++ b/style.css @@ -0,0 +1,104 @@ +.vc-wk-panel { + display: grid; + gap: 12px; + padding: 18px 20px 20px; + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md, 12px); + background: var(--background-mod-subtle); + color: var(--text-default); +} + +.vc-wk-status { + display: flex; + align-items: center; + gap: 8px; + margin: 0; + font-size: 14px; + line-height: 20px; + color: var(--text-muted); +} + +.vc-wk-status-icon { + flex: none; + color: var(--interactive-icon-default); +} + +.vc-wk-status-on { + font-weight: 600; + color: var(--text-default); +} + +.vc-wk-status-on .vc-wk-status-icon { + color: var(--status-positive); +} + +.vc-wk-title { + margin: 2px 0 0; + font-family: var(--font-display); + font-size: 20px; + font-weight: 700; + line-height: 26px; + color: var(--text-strong); + text-wrap: balance; +} + +.vc-wk-lead { + margin: -4px 0 0; + max-width: 62ch; + font-size: 15px; + line-height: 22px; +} + +.vc-wk-steps { + display: grid; + gap: 10px; + margin: 6px 0 0; + padding: 0; + list-style: none; + counter-reset: vc-wk-step; +} + +.vc-wk-steps li { + display: grid; + grid-template-columns: 24px 1fr; + gap: 12px; + max-width: 66ch; + font-size: 15px; + line-height: 22px; + counter-increment: vc-wk-step; +} + +.vc-wk-steps li::before { + content: counter(vc-wk-step); + display: grid; + place-items: center; + width: 24px; + height: 24px; + margin-top: -1px; + border-radius: 50%; + background: var(--background-mod-strong); + color: var(--text-strong); + font-size: 13px; + font-weight: 700; + font-variant-numeric: tabular-nums; +} + +.vc-wk-steps b { + color: var(--text-strong); +} + +.vc-wk-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px 16px; + margin-top: 8px; +} + +.vc-wk-tip { + flex: 1 1 240px; + margin: 0; + font-size: 13px; + line-height: 18px; + color: var(--text-muted); +}