(feat): add hotkeys
This commit is contained in:
parent
915568f739
commit
85633a1c81
27 changed files with 1014 additions and 33 deletions
1
TODO
1
TODO
|
|
@ -1,5 +1,4 @@
|
||||||
- Add a bunch of tests
|
- Add a bunch of tests
|
||||||
- Add packages/hotkeys/
|
|
||||||
- Full accessability
|
- Full accessability
|
||||||
- Add settings saving & update onboarding to use it
|
- Add settings saving & update onboarding to use it
|
||||||
- Add onboarding page to load one profile during onboarding
|
- Add onboarding page to load one profile during onboarding
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import {
|
||||||
app,
|
app,
|
||||||
BrowserWindow,
|
BrowserWindow,
|
||||||
desktopCapturer,
|
desktopCapturer,
|
||||||
|
globalShortcut,
|
||||||
ipcMain,
|
ipcMain,
|
||||||
session,
|
session,
|
||||||
shell,
|
shell,
|
||||||
|
|
@ -13,6 +14,7 @@ import { checkForUpdates } from "./updates.js";
|
||||||
import {
|
import {
|
||||||
ipcChannels,
|
ipcChannels,
|
||||||
type DesktopCallStatus,
|
type DesktopCallStatus,
|
||||||
|
type DesktopGlobalHotkeyBinding,
|
||||||
type DesktopScreenShareAudioOutput,
|
type DesktopScreenShareAudioOutput,
|
||||||
type DesktopScreenShareCapabilities,
|
type DesktopScreenShareCapabilities,
|
||||||
} from "../shared/ipc.js";
|
} from "../shared/ipc.js";
|
||||||
|
|
@ -29,6 +31,8 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
const verbose = process.argv.includes("--verbose");
|
const verbose = process.argv.includes("--verbose");
|
||||||
let mainWindow: BrowserWindow | null = null;
|
let mainWindow: BrowserWindow | null = null;
|
||||||
let selectedScreenShareSourceId: string | null = null;
|
let selectedScreenShareSourceId: string | null = null;
|
||||||
|
let globalHotkeyBindings: DesktopGlobalHotkeyBinding[] = [];
|
||||||
|
let globalHotkeysSuspended = false;
|
||||||
|
|
||||||
app.setName("tensamin");
|
app.setName("tensamin");
|
||||||
app.setPath("userData", join(app.getPath("appData"), "tensamin", "electron"));
|
app.setPath("userData", join(app.getPath("appData"), "tensamin", "electron"));
|
||||||
|
|
@ -46,6 +50,13 @@ if (
|
||||||
app.commandLine.appendSwitch("password-store", "gnome-libsecret");
|
app.commandLine.appendSwitch("password-store", "gnome-libsecret");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
process.platform === "linux" &&
|
||||||
|
process.env.XDG_SESSION_TYPE === "wayland"
|
||||||
|
) {
|
||||||
|
app.commandLine.appendSwitch("enable-features", "GlobalShortcutsPortal");
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
process.platform === "linux" &&
|
process.platform === "linux" &&
|
||||||
process.env.XDG_SESSION_TYPE === "wayland" &&
|
process.env.XDG_SESSION_TYPE === "wayland" &&
|
||||||
|
|
@ -242,6 +253,24 @@ function registerIpc() {
|
||||||
deleteSecureStorage(key),
|
deleteSecureStorage(key),
|
||||||
);
|
);
|
||||||
ipcMain.handle(ipcChannels.clearSecureStorage, clearSecureStorage);
|
ipcMain.handle(ipcChannels.clearSecureStorage, clearSecureStorage);
|
||||||
|
ipcMain.handle(
|
||||||
|
ipcChannels.setGlobalHotkeyBindings,
|
||||||
|
(event, bindings: unknown) => {
|
||||||
|
assertTrustedRenderer(event);
|
||||||
|
return setGlobalHotkeyBindings(bindings);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
ipcMain.handle(
|
||||||
|
ipcChannels.setGlobalHotkeysSuspended,
|
||||||
|
(event, suspended: unknown) => {
|
||||||
|
assertTrustedRenderer(event);
|
||||||
|
if (typeof suspended !== "boolean") {
|
||||||
|
throw new Error("Invalid hotkey suspension state.");
|
||||||
|
}
|
||||||
|
globalHotkeysSuspended = suspended;
|
||||||
|
return applyGlobalHotkeyBindings();
|
||||||
|
},
|
||||||
|
);
|
||||||
ipcMain.handle(ipcChannels.setCallStatus, (_event, status: unknown) => {
|
ipcMain.handle(ipcChannels.setCallStatus, (_event, status: unknown) => {
|
||||||
if (
|
if (
|
||||||
typeof status !== "object" ||
|
typeof status !== "object" ||
|
||||||
|
|
@ -282,6 +311,90 @@ function registerIpc() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function assertTrustedRenderer(event: Electron.IpcMainInvokeEvent) {
|
||||||
|
const target = mainWindow;
|
||||||
|
if (
|
||||||
|
!target ||
|
||||||
|
target.isDestroyed() ||
|
||||||
|
event.sender !== target.webContents ||
|
||||||
|
event.senderFrame !== target.webContents.mainFrame
|
||||||
|
) {
|
||||||
|
throw new Error("Untrusted hotkey IPC sender.");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (fileURLToPath(event.senderFrame.url) === getRendererIndex()) return;
|
||||||
|
} catch {
|
||||||
|
// Fall through to the rejection below.
|
||||||
|
}
|
||||||
|
throw new Error("Untrusted hotkey IPC sender.");
|
||||||
|
}
|
||||||
|
|
||||||
|
function validGlobalHotkeyBindings(
|
||||||
|
value: unknown,
|
||||||
|
): value is DesktopGlobalHotkeyBinding[] {
|
||||||
|
return (
|
||||||
|
Array.isArray(value) &&
|
||||||
|
value.length <= 64 &&
|
||||||
|
value.every(
|
||||||
|
(binding) =>
|
||||||
|
binding &&
|
||||||
|
typeof binding === "object" &&
|
||||||
|
typeof (binding as DesktopGlobalHotkeyBinding).id === "string" &&
|
||||||
|
/^[a-z0-9.-]+$/i.test((binding as DesktopGlobalHotkeyBinding).id) &&
|
||||||
|
(binding as DesktopGlobalHotkeyBinding).id.length > 0 &&
|
||||||
|
(binding as DesktopGlobalHotkeyBinding).id.length <= 128 &&
|
||||||
|
typeof (binding as DesktopGlobalHotkeyBinding).accelerator ===
|
||||||
|
"string" &&
|
||||||
|
(binding as DesktopGlobalHotkeyBinding).accelerator.length > 0 &&
|
||||||
|
(binding as DesktopGlobalHotkeyBinding).accelerator.length <= 128,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyGlobalHotkeyBindings() {
|
||||||
|
globalShortcut.unregisterAll();
|
||||||
|
const statuses = Object.fromEntries(
|
||||||
|
globalHotkeyBindings.map(({ id }) => [id, false]),
|
||||||
|
);
|
||||||
|
if (globalHotkeysSuspended) return statuses;
|
||||||
|
|
||||||
|
const grouped = new Map<string, string[]>();
|
||||||
|
for (const { id, accelerator } of globalHotkeyBindings) {
|
||||||
|
const ids = grouped.get(accelerator) ?? [];
|
||||||
|
ids.push(id);
|
||||||
|
grouped.set(accelerator, ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [accelerator, ids] of grouped) {
|
||||||
|
let registered = false;
|
||||||
|
try {
|
||||||
|
registered = globalShortcut.register(accelerator, () => {
|
||||||
|
const target = mainWindow;
|
||||||
|
if (!target || target.isDestroyed()) return;
|
||||||
|
ids.forEach((id) =>
|
||||||
|
target.webContents.send(ipcChannels.globalHotkeyTriggered, id),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to register global hotkey", accelerator, error);
|
||||||
|
}
|
||||||
|
ids.forEach((id) => {
|
||||||
|
statuses[id] = registered;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return statuses;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setGlobalHotkeyBindings(bindings: unknown) {
|
||||||
|
if (!validGlobalHotkeyBindings(bindings)) {
|
||||||
|
throw new Error("Invalid global hotkey bindings.");
|
||||||
|
}
|
||||||
|
globalHotkeyBindings = bindings;
|
||||||
|
return applyGlobalHotkeyBindings();
|
||||||
|
}
|
||||||
|
|
||||||
async function createWindow() {
|
async function createWindow() {
|
||||||
const rendererIndex = getRendererIndex();
|
const rendererIndex = getRendererIndex();
|
||||||
verboseLog("creating main window", {
|
verboseLog("creating main window", {
|
||||||
|
|
@ -367,6 +480,10 @@ app.on("activate", () => {
|
||||||
if (BrowserWindow.getAllWindows().length === 0) void createWindow();
|
if (BrowserWindow.getAllWindows().length === 0) void createWindow();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.on("will-quit", () => {
|
||||||
|
globalShortcut.unregisterAll();
|
||||||
|
});
|
||||||
|
|
||||||
if (verbose) {
|
if (verbose) {
|
||||||
process.on("uncaughtException", (error) => {
|
process.on("uncaughtException", (error) => {
|
||||||
console.error("[tensamin:electron] uncaught exception", error);
|
console.error("[tensamin:electron] uncaught exception", error);
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { contextBridge, ipcRenderer } from "electron";
|
||||||
import {
|
import {
|
||||||
ipcChannels,
|
ipcChannels,
|
||||||
type DesktopCallStatus,
|
type DesktopCallStatus,
|
||||||
|
type DesktopGlobalHotkeyBinding,
|
||||||
type DesktopScreenShareSource,
|
type DesktopScreenShareSource,
|
||||||
secureStorageLimits,
|
secureStorageLimits,
|
||||||
} from "../shared/ipc.js";
|
} from "../shared/ipc.js";
|
||||||
|
|
@ -55,6 +56,23 @@ const desktopApi = {
|
||||||
return ipcRenderer.invoke(ipcChannels.setCallStatus, status);
|
return ipcRenderer.invoke(ipcChannels.setCallStatus, status);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
hotkeys: {
|
||||||
|
setBindings: (bindings: DesktopGlobalHotkeyBinding[]) =>
|
||||||
|
ipcRenderer.invoke(ipcChannels.setGlobalHotkeyBindings, bindings),
|
||||||
|
setSuspended: (suspended: boolean) =>
|
||||||
|
typeof suspended === "boolean"
|
||||||
|
? ipcRenderer.invoke(ipcChannels.setGlobalHotkeysSuspended, suspended)
|
||||||
|
: Promise.reject(new Error("Invalid hotkey suspension state.")),
|
||||||
|
onTriggered: (callback: (id: string) => void) => {
|
||||||
|
const listener = (_event: Electron.IpcRendererEvent, id: unknown) => {
|
||||||
|
if (typeof id === "string") callback(id);
|
||||||
|
};
|
||||||
|
ipcRenderer.on(ipcChannels.globalHotkeyTriggered, listener);
|
||||||
|
return () => {
|
||||||
|
ipcRenderer.removeListener(ipcChannels.globalHotkeyTriggered, listener);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
secureStorage: {
|
secureStorage: {
|
||||||
getStatus: () => ipcRenderer.invoke(ipcChannels.getSecureStorageStatus),
|
getStatus: () => ipcRenderer.invoke(ipcChannels.getSecureStorageStatus),
|
||||||
load: (key: string) =>
|
load: (key: string) =>
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,11 @@ export type DesktopSecureStorageStatus = {
|
||||||
backend: string | null;
|
backend: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type DesktopGlobalHotkeyBinding = {
|
||||||
|
id: string;
|
||||||
|
accelerator: string;
|
||||||
|
};
|
||||||
|
|
||||||
export const secureStorageLimits = {
|
export const secureStorageLimits = {
|
||||||
maxKeyBytes: 256,
|
maxKeyBytes: 256,
|
||||||
maxValueBytes: 1024 * 1024,
|
maxValueBytes: 1024 * 1024,
|
||||||
|
|
@ -77,4 +82,7 @@ export const ipcChannels = {
|
||||||
saveSecureStorage: "secureStorage:save",
|
saveSecureStorage: "secureStorage:save",
|
||||||
deleteSecureStorage: "secureStorage:delete",
|
deleteSecureStorage: "secureStorage:delete",
|
||||||
clearSecureStorage: "secureStorage:clear",
|
clearSecureStorage: "secureStorage:clear",
|
||||||
|
setGlobalHotkeyBindings: "hotkeys:setBindings",
|
||||||
|
setGlobalHotkeysSuspended: "hotkeys:setSuspended",
|
||||||
|
globalHotkeyTriggered: "hotkeys:triggered",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@
|
||||||
"@tensamin/cache": "workspace:*",
|
"@tensamin/cache": "workspace:*",
|
||||||
"@tensamin/chat": "workspace:*",
|
"@tensamin/chat": "workspace:*",
|
||||||
"@tensamin/crypto": "workspace:*",
|
"@tensamin/crypto": "workspace:*",
|
||||||
|
"@tensamin/hotkeys": "workspace:*",
|
||||||
"@tensamin/shared": "workspace:*",
|
"@tensamin/shared": "workspace:*",
|
||||||
"@tensamin/settings": "workspace:*",
|
"@tensamin/settings": "workspace:*",
|
||||||
"@tensamin/storage": "workspace:*",
|
"@tensamin/storage": "workspace:*",
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@ import { useStorage } from "@tensamin/storage/context";
|
||||||
import { useLocation, useNavigate } from "@tanstack/react-router";
|
import { useLocation, useNavigate } from "@tanstack/react-router";
|
||||||
import { useIsMobile, Toaster, TooltipProvider } from "@methanium/ui";
|
import { useIsMobile, Toaster, TooltipProvider } from "@methanium/ui";
|
||||||
import { isTauri } from "@tauri-apps/api/core";
|
import { isTauri } from "@tauri-apps/api/core";
|
||||||
|
import { HotkeysProvider } from "@tensamin/hotkeys";
|
||||||
|
|
||||||
const wrapper = document.getElementById("root");
|
const wrapper = document.getElementById("root");
|
||||||
|
|
||||||
|
|
@ -266,10 +267,12 @@ function RootShell() {
|
||||||
/>
|
/>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Storage>
|
<Storage>
|
||||||
|
<HotkeysProvider>
|
||||||
<ThemeStorageBridge />
|
<ThemeStorageBridge />
|
||||||
<LoginWrapper>
|
<LoginWrapper>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</LoginWrapper>
|
</LoginWrapper>
|
||||||
|
</HotkeysProvider>
|
||||||
</Storage>
|
</Storage>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -82,9 +82,7 @@ export default defineConfig({
|
||||||
"use-sync-external-store",
|
"use-sync-external-store",
|
||||||
"@tanstack/history",
|
"@tanstack/history",
|
||||||
"@tanstack/react-router",
|
"@tanstack/react-router",
|
||||||
"@tanstack/react-store",
|
|
||||||
"@tanstack/router-core",
|
"@tanstack/router-core",
|
||||||
"@tanstack/store",
|
|
||||||
"@tensamin/crypto",
|
"@tensamin/crypto",
|
||||||
"@tensamin/settings",
|
"@tensamin/settings",
|
||||||
"@tensamin/storage",
|
"@tensamin/storage",
|
||||||
|
|
@ -134,6 +132,7 @@ export default defineConfig({
|
||||||
"@tensamin/chat",
|
"@tensamin/chat",
|
||||||
"@tensamin/crypto",
|
"@tensamin/crypto",
|
||||||
"@tensamin/crypto/context",
|
"@tensamin/crypto/context",
|
||||||
|
"@tensamin/hotkeys",
|
||||||
"@tensamin/markdown",
|
"@tensamin/markdown",
|
||||||
"@tensamin/mtp",
|
"@tensamin/mtp",
|
||||||
"@tensamin/notifications",
|
"@tensamin/notifications",
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@
|
||||||
"@tanstack/react-router": "^1.0.0",
|
"@tanstack/react-router": "^1.0.0",
|
||||||
"@tanstack/react-virtual": "^3.0.0",
|
"@tanstack/react-virtual": "^3.0.0",
|
||||||
"@tensamin/crypto": "workspace:*",
|
"@tensamin/crypto": "workspace:*",
|
||||||
|
"@tensamin/hotkeys": "workspace:*",
|
||||||
"@tensamin/markdown": "workspace:*",
|
"@tensamin/markdown": "workspace:*",
|
||||||
"@tensamin/mtp": "workspace:*",
|
"@tensamin/mtp": "workspace:*",
|
||||||
"@tensamin/shared": "workspace:*",
|
"@tensamin/shared": "workspace:*",
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import Input from "@tensamin/markdown/input";
|
import Input, { type InputController } from "@tensamin/markdown/input";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
|
|
@ -7,7 +7,7 @@ import {
|
||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
} from "@methanium/ui";
|
} from "@methanium/ui";
|
||||||
import { useStorage } from "@tensamin/storage/context";
|
import { useStorage } from "@tensamin/storage/context";
|
||||||
import React, { useEffect, useState, useRef } from "react";
|
import React, { useCallback, useEffect, useState, useRef } from "react";
|
||||||
import { Button } from "@methanium/ui";
|
import { Button } from "@methanium/ui";
|
||||||
|
|
||||||
import { Plus, Laugh, FileVideo } from "lucide-react";
|
import { Plus, Laugh, FileVideo } from "lucide-react";
|
||||||
|
|
@ -22,13 +22,17 @@ import GifPicker from "./gifPicker";
|
||||||
import EmojiPicker from "./emojiPicker";
|
import EmojiPicker from "./emojiPicker";
|
||||||
import { useEmojiRanks, useRecordEmojiUse } from "./emojiRanks";
|
import { useEmojiRanks, useRecordEmojiUse } from "./emojiRanks";
|
||||||
import ReplyBox from "./replyBox";
|
import ReplyBox from "./replyBox";
|
||||||
|
import { useHotkey } from "@tensamin/hotkeys";
|
||||||
|
import { editLastMessageHotkey } from "../hotkeys";
|
||||||
|
|
||||||
export default function InputComponent({
|
export default function InputComponent({
|
||||||
value,
|
value,
|
||||||
setValue,
|
setValue,
|
||||||
|
onEditLastMessage,
|
||||||
}: {
|
}: {
|
||||||
value: string;
|
value: string;
|
||||||
setValue: (value: string) => void;
|
setValue: (value: string) => void;
|
||||||
|
onEditLastMessage: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [invertEnterBehavior, setInvertEnterBehavior] = useState(false);
|
const [invertEnterBehavior, setInvertEnterBehavior] = useState(false);
|
||||||
|
|
||||||
|
|
@ -52,6 +56,58 @@ export default function InputComponent({
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
}>();
|
}>();
|
||||||
|
const composerRef = useRef<InputController | null>(null);
|
||||||
|
const setComposer = useCallback((controller: InputController | null) => {
|
||||||
|
composerRef.current = controller;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const frame = requestAnimationFrame(() => composerRef.current?.focus());
|
||||||
|
return () => cancelAnimationFrame(frame);
|
||||||
|
}, [userId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const focusComposerOnType = (event: KeyboardEvent) => {
|
||||||
|
const composer = composerRef.current;
|
||||||
|
const target = event.target;
|
||||||
|
if (
|
||||||
|
!composer ||
|
||||||
|
composer.hasFocus() ||
|
||||||
|
event.defaultPrevented ||
|
||||||
|
event.isComposing ||
|
||||||
|
event.ctrlKey ||
|
||||||
|
event.metaKey ||
|
||||||
|
event.altKey ||
|
||||||
|
event.key.length !== 1
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
target instanceof HTMLElement &&
|
||||||
|
(target.isContentEditable ||
|
||||||
|
target.closest(
|
||||||
|
'button, a[href], input, textarea, select, summary, [contenteditable], [role], [tabindex]:not([tabindex="-1"])',
|
||||||
|
))
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
composer.focus();
|
||||||
|
composer.insertText(event.key);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("keydown", focusComposerOnType, true);
|
||||||
|
return () =>
|
||||||
|
window.removeEventListener("keydown", focusComposerOnType, true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useHotkey(editLastMessageHotkey, onEditLastMessage, {
|
||||||
|
enabled: value.length === 0,
|
||||||
|
ignoreInputs: false,
|
||||||
|
target: inputBoxRef,
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void load("settings.reverse_enter_behavior").then((shouldInvert) => {
|
void load("settings.reverse_enter_behavior").then((shouldInvert) => {
|
||||||
|
|
@ -195,6 +251,7 @@ export default function InputComponent({
|
||||||
{/* reply */}
|
{/* reply */}
|
||||||
{replyTo !== undefined && (
|
{replyTo !== undefined && (
|
||||||
<ReplyBox
|
<ReplyBox
|
||||||
|
edited={replyMessage?.Edited}
|
||||||
content={replyMessage?.Content}
|
content={replyMessage?.Content}
|
||||||
loading={!replyMessage}
|
loading={!replyMessage}
|
||||||
onDismiss={() => setReplyTo(undefined)}
|
onDismiss={() => setReplyTo(undefined)}
|
||||||
|
|
@ -212,6 +269,7 @@ export default function InputComponent({
|
||||||
<CardHeader className="relative p-0 flex flex-col">
|
<CardHeader className="relative p-0 flex flex-col">
|
||||||
<Input
|
<Input
|
||||||
className="w-full"
|
className="w-full"
|
||||||
|
onControllerChange={setComposer}
|
||||||
paddingY="13px"
|
paddingY="13px"
|
||||||
paddingX="13px"
|
paddingX="13px"
|
||||||
placeholder="Send a message..."
|
placeholder="Send a message..."
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import type { RawMessage } from "../values";
|
import type { RawMessage } from "../values";
|
||||||
import Text from "@tensamin/markdown/text";
|
import Text from "@tensamin/markdown/text";
|
||||||
import { AlertTriangle, Check, CheckLine, RefreshCw } from "lucide-react";
|
import { AlertTriangle, Check, CheckLine, RefreshCw } from "lucide-react";
|
||||||
import { memo, useCallback, useEffect, useState } from "react";
|
import { memo, useCallback, useEffect, useRef, useState } from "react";
|
||||||
import type { User } from "@tensamin/user/context";
|
import type { User } from "@tensamin/user/context";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|
@ -24,17 +24,23 @@ import Emoji, { normalizeShortcode } from "@tensamin/markdown/emoji";
|
||||||
import { useRecordEmojiUse } from "./emojiRanks";
|
import { useRecordEmojiUse } from "./emojiRanks";
|
||||||
import { useUser } from "@tensamin/user/context";
|
import { useUser } from "@tensamin/user/context";
|
||||||
import ReplyBox from "./replyBox";
|
import ReplyBox from "./replyBox";
|
||||||
|
import { useHotkey } from "@tensamin/hotkeys";
|
||||||
|
import { cancelMessageEditHotkey } from "../hotkeys";
|
||||||
|
|
||||||
function MessageComponent({
|
function MessageComponent({
|
||||||
|
editing,
|
||||||
grouped,
|
grouped,
|
||||||
message,
|
message,
|
||||||
|
onSetEditing,
|
||||||
user,
|
user,
|
||||||
}: {
|
}: {
|
||||||
|
editing: boolean;
|
||||||
grouped: boolean;
|
grouped: boolean;
|
||||||
message: RawMessage & {
|
message: RawMessage & {
|
||||||
failed?: boolean;
|
failed?: boolean;
|
||||||
decryptionFailed?: boolean;
|
decryptionFailed?: boolean;
|
||||||
};
|
};
|
||||||
|
onSetEditing: (editing: boolean) => void;
|
||||||
user: User | null;
|
user: User | null;
|
||||||
}) {
|
}) {
|
||||||
const actuallyFailed =
|
const actuallyFailed =
|
||||||
|
|
@ -48,6 +54,7 @@ function MessageComponent({
|
||||||
: message.MessageState === "awaiting"
|
: message.MessageState === "awaiting"
|
||||||
? "opacity-50"
|
? "opacity-50"
|
||||||
: "opacity-100";
|
: "opacity-100";
|
||||||
|
const messageRef = useRef<HTMLDivElement>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timeout = window.setTimeout(() => {
|
const timeout = window.setTimeout(() => {
|
||||||
setHasFadedIn(true);
|
setHasFadedIn(true);
|
||||||
|
|
@ -141,7 +148,6 @@ function MessageComponent({
|
||||||
chatSecret,
|
chatSecret,
|
||||||
editMessage,
|
editMessage,
|
||||||
userId,
|
userId,
|
||||||
deleteMessage,
|
|
||||||
removeReaction,
|
removeReaction,
|
||||||
replyTo,
|
replyTo,
|
||||||
} = useChat();
|
} = useChat();
|
||||||
|
|
@ -183,8 +189,27 @@ function MessageComponent({
|
||||||
};
|
};
|
||||||
}, [chatSecret, getUser, message.ReplyId, ownId, send, userId]);
|
}, [chatSecret, getUser, message.ReplyId, ownId, send, userId]);
|
||||||
const recordUse = useRecordEmojiUse();
|
const recordUse = useRecordEmojiUse();
|
||||||
const [editing, setEditing] = useState(false);
|
const editingRef = useRef(editing);
|
||||||
|
const onSetEditingRef = useRef(onSetEditing);
|
||||||
|
useEffect(() => {
|
||||||
|
editingRef.current = editing;
|
||||||
|
onSetEditingRef.current = onSetEditing;
|
||||||
|
}, [editing, onSetEditing]);
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
if (editingRef.current) onSetEditingRef.current(false);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
const [editDraft, setEditDraft] = useState(message.Content);
|
const [editDraft, setEditDraft] = useState(message.Content);
|
||||||
|
const cancelEditing = useCallback(() => {
|
||||||
|
setEditDraft(message.Content);
|
||||||
|
onSetEditing(false);
|
||||||
|
}, [message.Content, onSetEditing]);
|
||||||
|
useHotkey(cancelMessageEditHotkey, cancelEditing, {
|
||||||
|
enabled: editing,
|
||||||
|
target: messageRef,
|
||||||
|
});
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editing) {
|
if (!editing) {
|
||||||
setEditDraft(message.Content);
|
setEditDraft(message.Content);
|
||||||
|
|
@ -271,12 +296,14 @@ function MessageComponent({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
ref={messageRef}
|
||||||
// pt-3 is to get a gap between messages
|
// pt-3 is to get a gap between messages
|
||||||
className={`${grouped ? "" : "pt-3"} w-full flex flex-col gap-1 justify-start items-start transition-opacity duration-150 ${opacityClass}`}
|
className={`${grouped ? "" : "pt-3"} w-full flex flex-col gap-1 justify-start items-start transition-opacity duration-150 ${opacityClass}`}
|
||||||
>
|
>
|
||||||
{/* reply */}
|
{/* reply */}
|
||||||
{replyMessage && replyUser && (
|
{replyMessage && replyUser && (
|
||||||
<ReplyBox
|
<ReplyBox
|
||||||
|
edited={replyMessage.Edited}
|
||||||
content={replyMessage.Content}
|
content={replyMessage.Content}
|
||||||
user={replyUser}
|
user={replyUser}
|
||||||
variant="message"
|
variant="message"
|
||||||
|
|
@ -289,7 +316,7 @@ function MessageComponent({
|
||||||
isOwnMessage={message.SenderId === ownId}
|
isOwnMessage={message.SenderId === ownId}
|
||||||
messageId={message.SendTime}
|
messageId={message.SendTime}
|
||||||
onReact={toggleReaction}
|
onReact={toggleReaction}
|
||||||
onSetEditing={setEditing}
|
onSetEditing={onSetEditing}
|
||||||
>
|
>
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
|
|
@ -305,7 +332,7 @@ function MessageComponent({
|
||||||
>
|
>
|
||||||
<>
|
<>
|
||||||
{grouped ? (
|
{grouped ? (
|
||||||
<p className="w-9 text-xs group-hover:visible invisible text-muted-foreground">
|
<p className="w-9 self-start pt-[5px] text-xs group-hover:visible invisible text-muted-foreground">
|
||||||
{new Date(message.SendTime).toLocaleString([], {
|
{new Date(message.SendTime).toLocaleString([], {
|
||||||
hour: "2-digit",
|
hour: "2-digit",
|
||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
|
|
@ -362,13 +389,17 @@ function MessageComponent({
|
||||||
{editing ? (
|
{editing ? (
|
||||||
<div className="flex w-full flex-col gap-1">
|
<div className="flex w-full flex-col gap-1">
|
||||||
<Input
|
<Input
|
||||||
|
autoFocus
|
||||||
className="w-full"
|
className="w-full"
|
||||||
styled
|
styled
|
||||||
setValue={setEditDraft}
|
setValue={setEditDraft}
|
||||||
value={editDraft}
|
value={editDraft}
|
||||||
onSubmit={() => {
|
onSubmit={() => {
|
||||||
submitEditMessage(editDraft);
|
if (!editDraft.trim()) return;
|
||||||
setEditing(false);
|
if (editDraft !== message.Content) {
|
||||||
|
void submitEditMessage(editDraft);
|
||||||
|
}
|
||||||
|
onSetEditing(false);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
|
|
@ -377,13 +408,12 @@ function MessageComponent({
|
||||||
variant="link"
|
variant="link"
|
||||||
className="text-primary-foreground-alt"
|
className="text-primary-foreground-alt"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (editDraft === message.Content) {
|
if (!editDraft.trim()) return;
|
||||||
deleteMessage(message.SendTime);
|
if (editDraft !== message.Content) {
|
||||||
} else {
|
void submitEditMessage(editDraft);
|
||||||
submitEditMessage(editDraft);
|
|
||||||
}
|
}
|
||||||
setEditDraft(message.Content);
|
setEditDraft(message.Content);
|
||||||
setEditing(false);
|
onSetEditing(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Save
|
Save
|
||||||
|
|
@ -392,20 +422,26 @@ function MessageComponent({
|
||||||
size="xs"
|
size="xs"
|
||||||
variant="link"
|
variant="link"
|
||||||
className="text-muted-foreground"
|
className="text-muted-foreground"
|
||||||
onClick={() => {
|
onClick={cancelEditing}
|
||||||
setEditDraft(message.Content);
|
|
||||||
setEditing(false);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : isValidURL ? (
|
) : (
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{isValidURL ? (
|
||||||
<Media link={message.Content} />
|
<Media link={message.Content} />
|
||||||
) : (
|
) : (
|
||||||
<Text value={message.Content} />
|
<Text value={message.Content} />
|
||||||
)}
|
)}
|
||||||
|
{message.Edited && (
|
||||||
|
<p className="text-xs text-muted-foreground self-center">
|
||||||
|
(edited)
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{groupedReactions.length > 0 && (
|
{groupedReactions.length > 0 && (
|
||||||
<div className="mt-1 flex flex-wrap gap-1 pb-1">
|
<div className="mt-1 flex flex-wrap gap-1 pb-1">
|
||||||
{groupedReactions.map(
|
{groupedReactions.map(
|
||||||
|
|
@ -445,6 +481,7 @@ function MessageComponent({
|
||||||
export default memo(MessageComponent, (prev, next) => {
|
export default memo(MessageComponent, (prev, next) => {
|
||||||
return (
|
return (
|
||||||
prev.message.SendTime === next.message.SendTime &&
|
prev.message.SendTime === next.message.SendTime &&
|
||||||
|
prev.editing === next.editing &&
|
||||||
prev.message.Content === next.message.Content &&
|
prev.message.Content === next.message.Content &&
|
||||||
prev.message.SenderId === next.message.SenderId &&
|
prev.message.SenderId === next.message.SenderId &&
|
||||||
prev.message.ReplyId === next.message.ReplyId &&
|
prev.message.ReplyId === next.message.ReplyId &&
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ function ReplyUser({ user }: { user: User }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ReplyBox({
|
export default function ReplyBox({
|
||||||
|
edited,
|
||||||
content,
|
content,
|
||||||
loading = false,
|
loading = false,
|
||||||
onDismiss,
|
onDismiss,
|
||||||
|
|
@ -35,6 +36,7 @@ export default function ReplyBox({
|
||||||
userId,
|
userId,
|
||||||
variant,
|
variant,
|
||||||
}: {
|
}: {
|
||||||
|
edited?: boolean;
|
||||||
content?: string;
|
content?: string;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
onDismiss?: () => void;
|
onDismiss?: () => void;
|
||||||
|
|
@ -74,8 +76,13 @@ export default function ReplyBox({
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{content !== undefined && (
|
{content !== undefined && (
|
||||||
<div className="h-5.5 min-w-0 flex-1 truncate">
|
<div className="h-5.5 min-w-0 flex-1 truncate flex gap-1">
|
||||||
<Text fontSize="0.88rem" value={content} />
|
<Text fontSize="0.88rem" value={content} />
|
||||||
|
{edited && (
|
||||||
|
<p className="text-xs text-muted-foreground self-center">
|
||||||
|
(edited)
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -927,6 +927,7 @@ export default function Provider({ children }: { children: ReactNode }) {
|
||||||
removeReaction,
|
removeReaction,
|
||||||
clearLiveMessages,
|
clearLiveMessages,
|
||||||
chatSecret: currentChatSecret,
|
chatSecret: currentChatSecret,
|
||||||
|
ownId,
|
||||||
userId: userIdValue,
|
userId: userIdValue,
|
||||||
inputBoxRef,
|
inputBoxRef,
|
||||||
error,
|
error,
|
||||||
|
|
@ -954,6 +955,7 @@ type contextType = {
|
||||||
removeReaction: (sendTime: number, reaction: string) => Promise<void>;
|
removeReaction: (sendTime: number, reaction: string) => Promise<void>;
|
||||||
clearLiveMessages: () => void;
|
clearLiveMessages: () => void;
|
||||||
chatSecret: Uint8Array | null;
|
chatSecret: Uint8Array | null;
|
||||||
|
ownId: number;
|
||||||
userId: number;
|
userId: number;
|
||||||
inputBoxRef: React.RefObject<HTMLDivElement | null>;
|
inputBoxRef: React.RefObject<HTMLDivElement | null>;
|
||||||
error: string;
|
error: string;
|
||||||
|
|
|
||||||
17
packages/chat/src/hotkeys.ts
Normal file
17
packages/chat/src/hotkeys.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import { defineHotkey } from "@tensamin/hotkeys";
|
||||||
|
|
||||||
|
export const editLastMessageHotkey = defineHotkey({
|
||||||
|
id: "chat.edit-last-message",
|
||||||
|
name: "Edit last message",
|
||||||
|
description: "Edit your most recent message when the composer is empty.",
|
||||||
|
category: "Chat",
|
||||||
|
defaultBinding: "ArrowUp",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const cancelMessageEditHotkey = defineHotkey({
|
||||||
|
id: "chat.cancel-message-edit",
|
||||||
|
name: "Cancel message edit",
|
||||||
|
description: "Close the message editor without saving changes.",
|
||||||
|
category: "Chat",
|
||||||
|
defaultBinding: "Escape",
|
||||||
|
});
|
||||||
|
|
@ -85,6 +85,8 @@ export default function Screen() {
|
||||||
clearLiveMessages,
|
clearLiveMessages,
|
||||||
userId,
|
userId,
|
||||||
chatSecret,
|
chatSecret,
|
||||||
|
ownId,
|
||||||
|
inputBoxRef,
|
||||||
error,
|
error,
|
||||||
errorDescription,
|
errorDescription,
|
||||||
} = useChat();
|
} = useChat();
|
||||||
|
|
@ -103,10 +105,25 @@ export default function Screen() {
|
||||||
const [didInitialScroll, setDidInitialScroll] = useState(false);
|
const [didInitialScroll, setDidInitialScroll] = useState(false);
|
||||||
const [viewportHeight, setViewportHeight] = useState(0);
|
const [viewportHeight, setViewportHeight] = useState(0);
|
||||||
const [value, setValue] = useState("");
|
const [value, setValue] = useState("");
|
||||||
|
const [editingMessageId, setEditingMessageId] = useState<number | null>(null);
|
||||||
|
const previousEditingMessageIdRef = useRef<number | null>(null);
|
||||||
|
|
||||||
const hasValidChatUser = Number.isSafeInteger(userId) && userId > 0;
|
const hasValidChatUser = Number.isSafeInteger(userId) && userId > 0;
|
||||||
const hasChatSecret = chatSecret !== null;
|
const hasChatSecret = chatSecret !== null;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const previousEditingMessageId = previousEditingMessageIdRef.current;
|
||||||
|
previousEditingMessageIdRef.current = editingMessageId;
|
||||||
|
if (previousEditingMessageId === null || editingMessageId !== null) return;
|
||||||
|
|
||||||
|
const frame = requestAnimationFrame(() => {
|
||||||
|
inputBoxRef.current
|
||||||
|
?.querySelector<HTMLElement>(".cm-content")
|
||||||
|
?.focus({ preventScroll: true });
|
||||||
|
});
|
||||||
|
return () => cancelAnimationFrame(frame);
|
||||||
|
}, [editingMessageId, inputBoxRef]);
|
||||||
|
|
||||||
const messagesQuery = useInfiniteQuery({
|
const messagesQuery = useInfiniteQuery({
|
||||||
queryKey: ["chat-messages", String(userId), hasChatSecret],
|
queryKey: ["chat-messages", String(userId), hasChatSecret],
|
||||||
initialPageParam: 0,
|
initialPageParam: 0,
|
||||||
|
|
@ -141,6 +158,7 @@ export default function Screen() {
|
||||||
isAtBottomRef.current = true;
|
isAtBottomRef.current = true;
|
||||||
setDidInitialScroll(false);
|
setDidInitialScroll(false);
|
||||||
setLastLiveMessageCount(0);
|
setLastLiveMessageCount(0);
|
||||||
|
setEditingMessageId(null);
|
||||||
}, [clearLiveMessages, userId]);
|
}, [clearLiveMessages, userId]);
|
||||||
|
|
||||||
const historicalMessages = useMemo(() => {
|
const historicalMessages = useMemo(() => {
|
||||||
|
|
@ -213,6 +231,34 @@ export default function Screen() {
|
||||||
const contentHeight = Math.max(totalSize, viewportHeight);
|
const contentHeight = Math.max(totalSize, viewportHeight);
|
||||||
const verticalOffset = Math.max(0, viewportHeight - totalSize);
|
const verticalOffset = Math.max(0, viewportHeight - totalSize);
|
||||||
|
|
||||||
|
const editLastMessage = useCallback(() => {
|
||||||
|
if (editingMessageId !== null) return;
|
||||||
|
const message = [...messages]
|
||||||
|
.reverse()
|
||||||
|
.find(
|
||||||
|
(candidate) =>
|
||||||
|
candidate.SenderId === ownId &&
|
||||||
|
candidate.Content.length > 0 &&
|
||||||
|
candidate.MessageState !== "awaiting" &&
|
||||||
|
!("failed" in candidate && candidate.failed) &&
|
||||||
|
!("decryptionFailed" in candidate && candidate.decryptionFailed),
|
||||||
|
);
|
||||||
|
if (!message) return;
|
||||||
|
|
||||||
|
const chunkIndex = messageChunks.findIndex((chunk) =>
|
||||||
|
chunk.messages.some(
|
||||||
|
(candidate) => candidate.SendTime === message.SendTime,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const chunkIsRendered = virtualizer
|
||||||
|
.getVirtualItems()
|
||||||
|
.some(({ index }) => index === chunkIndex);
|
||||||
|
if (chunkIndex >= 0 && !chunkIsRendered) {
|
||||||
|
virtualizer.scrollToIndex(chunkIndex, { align: "center" });
|
||||||
|
}
|
||||||
|
setEditingMessageId(message.SendTime);
|
||||||
|
}, [editingMessageId, messageChunks, messages, ownId, virtualizer]);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const element = scrollRef.current;
|
const element = scrollRef.current;
|
||||||
if (!element || typeof ResizeObserver === "undefined") {
|
if (!element || typeof ResizeObserver === "undefined") {
|
||||||
|
|
@ -489,8 +535,14 @@ export default function Screen() {
|
||||||
loading={null}
|
loading={null}
|
||||||
component={(user) => (
|
component={(user) => (
|
||||||
<Message
|
<Message
|
||||||
|
editing={editingMessageId === message.SendTime}
|
||||||
grouped={isGrouped}
|
grouped={isGrouped}
|
||||||
message={message}
|
message={message}
|
||||||
|
onSetEditing={(editing) =>
|
||||||
|
setEditingMessageId(
|
||||||
|
editing ? message.SendTime : null,
|
||||||
|
)
|
||||||
|
}
|
||||||
user={user}
|
user={user}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
@ -504,7 +556,11 @@ export default function Screen() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="z-10 shrink-0">
|
<div className="z-10 shrink-0">
|
||||||
<InputComponent setValue={setValue} value={value} />
|
<InputComponent
|
||||||
|
onEditLastMessage={editLastMessage}
|
||||||
|
setValue={setValue}
|
||||||
|
value={value}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,9 @@
|
||||||
- Implement context menu features
|
- Implement context menu features
|
||||||
- Forward
|
- Forward
|
||||||
- Pin Message
|
- Pin Message
|
||||||
- Reply
|
|
||||||
- Add default-emoji-hotkey
|
|
||||||
- Placeholder image if media fails to load
|
- Placeholder image if media fails to load
|
||||||
- Signature verifications via ed25519 key
|
- Signature verifications via ed25519 key
|
||||||
- Confirmation when exiting with text in the input box.
|
- Confirmation when exiting with text in the input box.
|
||||||
- Add arrow up hotkey to edit last message (req: packages/hotkeys)
|
|
||||||
- Drop any unique reactions above 10
|
- Drop any unique reactions above 10
|
||||||
- Reply jumping
|
- Reply jumping
|
||||||
- Add emoji picker
|
- Add emoji picker
|
||||||
|
|
|
||||||
24
packages/hotkeys/package.json
Normal file
24
packages/hotkeys/package.json
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
{
|
||||||
|
"name": "@tensamin/hotkeys",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"format": "pnpm exec prettier --write .",
|
||||||
|
"lint": "eslint src",
|
||||||
|
"test": "vitest run --passWithNoTests",
|
||||||
|
"build": "pnpm run test && tsc -p tsconfig.json --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/react-hotkeys": "^0.10.0",
|
||||||
|
"@tensamin/storage": "workspace:*",
|
||||||
|
"react": "^19.2.0",
|
||||||
|
"react-dom": "^19.2.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"vite": "^8.0.10"
|
||||||
|
}
|
||||||
|
}
|
||||||
252
packages/hotkeys/src/context.tsx
Normal file
252
packages/hotkeys/src/context.tsx
Normal file
|
|
@ -0,0 +1,252 @@
|
||||||
|
import {
|
||||||
|
HotkeysProvider as TanStackHotkeysProvider,
|
||||||
|
useHotkeys as useTanStackHotkeys,
|
||||||
|
type Hotkey,
|
||||||
|
type UseHotkeyOptions,
|
||||||
|
} from "@tanstack/react-hotkeys";
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
type ReactNode,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { useStorage } from "@tensamin/storage/context";
|
||||||
|
import {
|
||||||
|
getHotkeyDefinitions,
|
||||||
|
normalizeHotkeyOverrides,
|
||||||
|
toElectronAccelerator,
|
||||||
|
type HotkeyDefinition,
|
||||||
|
} from "./registry";
|
||||||
|
|
||||||
|
type GlobalRegistrationStatus = "registered" | "unavailable";
|
||||||
|
|
||||||
|
type HotkeysContextValue = {
|
||||||
|
overrides: Record<string, Hotkey | null>;
|
||||||
|
bindingFor: (definition: HotkeyDefinition) => Hotkey | null;
|
||||||
|
setBinding: (definition: HotkeyDefinition, binding: Hotkey | null) => void;
|
||||||
|
resetBinding: (definition: HotkeyDefinition) => void;
|
||||||
|
resetAll: () => void;
|
||||||
|
globalStatuses: Record<string, GlobalRegistrationStatus>;
|
||||||
|
setRecording: (recording: boolean) => void;
|
||||||
|
registerGlobalHandler: (
|
||||||
|
definition: HotkeyDefinition,
|
||||||
|
handler: () => void,
|
||||||
|
) => () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const HotkeysContext = createContext<HotkeysContextValue | undefined>(
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
export function HotkeysProvider({ children }: { children: ReactNode }) {
|
||||||
|
const { load, save } = useStorage();
|
||||||
|
const [overrides, setOverrides] = useState<Record<string, Hotkey | null>>({});
|
||||||
|
const [handlersRevision, setHandlersRevision] = useState(0);
|
||||||
|
const [globalStatuses, setGlobalStatuses] = useState<
|
||||||
|
Record<string, GlobalRegistrationStatus>
|
||||||
|
>({});
|
||||||
|
const handlers = useRef(new Map<string, Set<() => void>>());
|
||||||
|
const overridesRef = useRef<Record<string, Hotkey | null>>({});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
void load("hotkey_overrides")
|
||||||
|
.then((stored) => {
|
||||||
|
if (!active) return;
|
||||||
|
const next = normalizeHotkeyOverrides(stored);
|
||||||
|
overridesRef.current = next;
|
||||||
|
setOverrides(next);
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
console.error("Failed to load hotkey settings", error);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const persist = useCallback(
|
||||||
|
(next: Record<string, Hotkey | null>) => {
|
||||||
|
overridesRef.current = next;
|
||||||
|
setOverrides(next);
|
||||||
|
void save("hotkey_overrides", next).catch((error: unknown) => {
|
||||||
|
console.error("Failed to save hotkey settings", error);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[save],
|
||||||
|
);
|
||||||
|
|
||||||
|
const bindingFor = useCallback(
|
||||||
|
(definition: HotkeyDefinition) =>
|
||||||
|
Object.hasOwn(overrides, definition.id)
|
||||||
|
? (overrides[definition.id] ?? null)
|
||||||
|
: definition.defaultBinding,
|
||||||
|
[overrides],
|
||||||
|
);
|
||||||
|
|
||||||
|
const setBinding = useCallback(
|
||||||
|
(definition: HotkeyDefinition, binding: Hotkey | null) => {
|
||||||
|
const next = { ...overridesRef.current };
|
||||||
|
if (binding === definition.defaultBinding) delete next[definition.id];
|
||||||
|
else next[definition.id] = binding;
|
||||||
|
persist(next);
|
||||||
|
},
|
||||||
|
[persist],
|
||||||
|
);
|
||||||
|
|
||||||
|
const resetBinding = useCallback(
|
||||||
|
(definition: HotkeyDefinition) => {
|
||||||
|
const next = { ...overridesRef.current };
|
||||||
|
delete next[definition.id];
|
||||||
|
persist(next);
|
||||||
|
},
|
||||||
|
[persist],
|
||||||
|
);
|
||||||
|
|
||||||
|
const resetAll = useCallback(() => persist({}), [persist]);
|
||||||
|
|
||||||
|
const registerGlobalHandler = useCallback(
|
||||||
|
(definition: HotkeyDefinition, handler: () => void) => {
|
||||||
|
const current = handlers.current.get(definition.id) ?? new Set();
|
||||||
|
current.add(handler);
|
||||||
|
handlers.current.set(definition.id, current);
|
||||||
|
setHandlersRevision((revision) => revision + 1);
|
||||||
|
return () => {
|
||||||
|
current.delete(handler);
|
||||||
|
if (current.size === 0) handlers.current.delete(definition.id);
|
||||||
|
setHandlersRevision((revision) => revision + 1);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return window.tensaminDesktop?.hotkeys?.onTriggered?.((id) => {
|
||||||
|
handlers.current.get(id)?.forEach((handler) => handler());
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const desktopHotkeys = window.tensaminDesktop?.hotkeys;
|
||||||
|
if (!desktopHotkeys?.setBindings) return;
|
||||||
|
|
||||||
|
const unsupported: string[] = [];
|
||||||
|
const registrations = getHotkeyDefinitions().flatMap((definition) => {
|
||||||
|
if (!definition.global || !handlers.current.has(definition.id)) return [];
|
||||||
|
const binding = bindingFor(definition);
|
||||||
|
const accelerator = binding && toElectronAccelerator(binding);
|
||||||
|
if (binding && !accelerator) unsupported.push(definition.id);
|
||||||
|
return accelerator ? [{ id: definition.id, accelerator }] : [];
|
||||||
|
});
|
||||||
|
let active = true;
|
||||||
|
void desktopHotkeys
|
||||||
|
.setBindings(registrations)
|
||||||
|
.then((statuses) => {
|
||||||
|
if (!active) return;
|
||||||
|
setGlobalStatuses(
|
||||||
|
Object.fromEntries(
|
||||||
|
[
|
||||||
|
...unsupported.map((id) => [id, false] as const),
|
||||||
|
...Object.entries(statuses),
|
||||||
|
].map(([id, registered]) => [
|
||||||
|
id,
|
||||||
|
registered ? "registered" : "unavailable",
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
console.error("Failed to register global hotkeys", error);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, [bindingFor, handlersRevision]);
|
||||||
|
|
||||||
|
const setRecording = useCallback((recording: boolean) => {
|
||||||
|
void window.tensaminDesktop?.hotkeys
|
||||||
|
?.setSuspended?.(recording)
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
console.error("Failed to suspend global hotkeys", error);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value = useMemo<HotkeysContextValue>(
|
||||||
|
() => ({
|
||||||
|
overrides,
|
||||||
|
bindingFor,
|
||||||
|
setBinding,
|
||||||
|
resetBinding,
|
||||||
|
resetAll,
|
||||||
|
globalStatuses,
|
||||||
|
setRecording,
|
||||||
|
registerGlobalHandler,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
bindingFor,
|
||||||
|
globalStatuses,
|
||||||
|
overrides,
|
||||||
|
registerGlobalHandler,
|
||||||
|
resetAll,
|
||||||
|
resetBinding,
|
||||||
|
setBinding,
|
||||||
|
setRecording,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TanStackHotkeysProvider>
|
||||||
|
<HotkeysContext value={value}>{children}</HotkeysContext>
|
||||||
|
</TanStackHotkeysProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useHotkeysContext() {
|
||||||
|
const value = useContext(HotkeysContext);
|
||||||
|
if (!value)
|
||||||
|
throw new Error("useHotkeysContext must be used within HotkeysProvider");
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useHotkey(
|
||||||
|
definition: HotkeyDefinition,
|
||||||
|
callback: () => void,
|
||||||
|
options: UseHotkeyOptions = {},
|
||||||
|
) {
|
||||||
|
const { bindingFor, registerGlobalHandler } = useHotkeysContext();
|
||||||
|
const callbackRef = useRef(callback);
|
||||||
|
useEffect(() => {
|
||||||
|
callbackRef.current = callback;
|
||||||
|
}, [callback]);
|
||||||
|
const binding = bindingFor(definition);
|
||||||
|
const handledByElectron = Boolean(
|
||||||
|
definition.global && window.tensaminDesktop?.hotkeys,
|
||||||
|
);
|
||||||
|
|
||||||
|
useTanStackHotkeys(
|
||||||
|
binding && !handledByElectron && options.enabled !== false
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
hotkey: binding,
|
||||||
|
callback: () => callbackRef.current(),
|
||||||
|
options: {
|
||||||
|
...options,
|
||||||
|
meta: {
|
||||||
|
name: definition.name,
|
||||||
|
description: definition.description,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!definition.global || options.enabled === false || !binding) return;
|
||||||
|
return registerGlobalHandler(definition, () => callbackRef.current());
|
||||||
|
}, [binding, definition, options.enabled, registerGlobalHandler]);
|
||||||
|
}
|
||||||
14
packages/hotkeys/src/index.ts
Normal file
14
packages/hotkeys/src/index.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
export { HotkeysProvider, useHotkey, useHotkeysContext } from "./context";
|
||||||
|
export {
|
||||||
|
defineHotkey,
|
||||||
|
getHotkeyDefinitions,
|
||||||
|
normalizeHotkeyOverrides,
|
||||||
|
toElectronAccelerator,
|
||||||
|
useHotkeyDefinitions,
|
||||||
|
type HotkeyDefinition,
|
||||||
|
} from "./registry";
|
||||||
|
export {
|
||||||
|
formatForDisplay,
|
||||||
|
useHotkeyRecorder,
|
||||||
|
type Hotkey,
|
||||||
|
} from "@tanstack/react-hotkeys";
|
||||||
94
packages/hotkeys/src/registry.ts
Normal file
94
packages/hotkeys/src/registry.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
import { useSyncExternalStore } from "react";
|
||||||
|
import { validateHotkey, type Hotkey } from "@tanstack/react-hotkeys";
|
||||||
|
|
||||||
|
export type HotkeyDefinition = Readonly<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
category: string;
|
||||||
|
defaultBinding: Hotkey;
|
||||||
|
global?: boolean;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
const definitions = new Map<string, HotkeyDefinition>();
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
|
let snapshot: HotkeyDefinition[] = [];
|
||||||
|
|
||||||
|
export function defineHotkey(definition: HotkeyDefinition) {
|
||||||
|
const existing = definitions.get(definition.id);
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
definitions.set(definition.id, Object.freeze({ ...definition }));
|
||||||
|
snapshot = [...definitions.values()];
|
||||||
|
listeners.forEach((listener) => listener());
|
||||||
|
return definitions.get(definition.id)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getHotkeyDefinitions() {
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useHotkeyDefinitions() {
|
||||||
|
return useSyncExternalStore(
|
||||||
|
(listener) => {
|
||||||
|
listeners.add(listener);
|
||||||
|
return () => listeners.delete(listener);
|
||||||
|
},
|
||||||
|
getHotkeyDefinitions,
|
||||||
|
getHotkeyDefinitions,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeHotkeyOverrides(value: unknown) {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
|
||||||
|
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(value).filter(
|
||||||
|
([id, binding]) =>
|
||||||
|
id.length > 0 &&
|
||||||
|
id.length <= 128 &&
|
||||||
|
(binding === null ||
|
||||||
|
(typeof binding === "string" &&
|
||||||
|
binding.length > 0 &&
|
||||||
|
binding.length <= 128 &&
|
||||||
|
validateHotkey(binding).valid)),
|
||||||
|
),
|
||||||
|
) as Record<string, Hotkey | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toElectronAccelerator(hotkey: Hotkey) {
|
||||||
|
const keyAliases: Record<string, string> = {
|
||||||
|
ArrowDown: "Down",
|
||||||
|
ArrowLeft: "Left",
|
||||||
|
ArrowRight: "Right",
|
||||||
|
ArrowUp: "Up",
|
||||||
|
" ": "Space",
|
||||||
|
};
|
||||||
|
const modifierAliases: Record<string, string> = {
|
||||||
|
Alt: "Alt",
|
||||||
|
Control: "Control",
|
||||||
|
Ctrl: "Control",
|
||||||
|
Meta: "Command",
|
||||||
|
Mod: "CommandOrControl",
|
||||||
|
Shift: "Shift",
|
||||||
|
};
|
||||||
|
const parts = hotkey.split("+");
|
||||||
|
if (parts.length === 0) return null;
|
||||||
|
|
||||||
|
const key = parts.at(-1)!;
|
||||||
|
const modifiers = parts.slice(0, -1).map((part) => modifierAliases[part]);
|
||||||
|
if (modifiers.some((part) => !part)) return null;
|
||||||
|
|
||||||
|
const acceleratorKey = keyAliases[key] ?? key;
|
||||||
|
if (
|
||||||
|
!/^[A-Za-z0-9]$/.test(acceleratorKey) &&
|
||||||
|
!keyAliases[key] &&
|
||||||
|
!/^(Backspace|Delete|End|Enter|Escape|F([1-9]|1[0-9]|2[0-4])|Home|PageDown|PageUp|Space|Tab)$/.test(
|
||||||
|
acceleratorKey,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...modifiers, acceleratorKey].join("+");
|
||||||
|
}
|
||||||
13
packages/hotkeys/tsconfig.json
Normal file
13
packages/hotkeys/tsconfig.json
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"types": ["vite/client"]
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
|
|
@ -52,6 +52,12 @@ import Emoji, {
|
||||||
|
|
||||||
export const MAX_RENDERED_EMOJI_OPTIONS = 100;
|
export const MAX_RENDERED_EMOJI_OPTIONS = 100;
|
||||||
|
|
||||||
|
export type InputController = {
|
||||||
|
focus: () => void;
|
||||||
|
hasFocus: () => boolean;
|
||||||
|
insertText: (text: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
export type InputProps = {
|
export type InputProps = {
|
||||||
ref?: HTMLDivElement;
|
ref?: HTMLDivElement;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
|
|
@ -66,6 +72,8 @@ export type InputProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
emojiFrequencies?: Readonly<Record<string, number>>;
|
emojiFrequencies?: Readonly<Record<string, number>>;
|
||||||
onEmojiSelect?: (shortcode: string) => void;
|
onEmojiSelect?: (shortcode: string) => void;
|
||||||
|
autoFocus?: boolean;
|
||||||
|
onControllerChange?: (controller: InputController | null) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
type InputStyle = CSSProperties & {
|
type InputStyle = CSSProperties & {
|
||||||
|
|
@ -353,8 +361,23 @@ export default function Input(props: InputProps) {
|
||||||
state,
|
state,
|
||||||
parent: elementRef.current,
|
parent: elementRef.current,
|
||||||
});
|
});
|
||||||
|
props.onControllerChange?.({
|
||||||
|
focus: () => viewRef.current?.contentDOM.focus({ preventScroll: true }),
|
||||||
|
hasFocus: () => viewRef.current?.hasFocus ?? false,
|
||||||
|
insertText: (text) => {
|
||||||
|
const editor = viewRef.current;
|
||||||
|
if (!editor) return;
|
||||||
|
editor.dispatch({
|
||||||
|
...editor.state.replaceSelection(text),
|
||||||
|
annotations: Transaction.userEvent.of("input.type"),
|
||||||
|
scrollIntoView: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (props.autoFocus) viewRef.current.focus();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
props.onControllerChange?.(null);
|
||||||
viewRef.current?.destroy();
|
viewRef.current?.destroy();
|
||||||
viewRef.current = undefined;
|
viewRef.current = undefined;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-router": "^1.169.1",
|
"@tanstack/react-router": "^1.169.1",
|
||||||
"@tensamin/cache": "workspace:*",
|
"@tensamin/cache": "workspace:*",
|
||||||
|
"@tensamin/hotkeys": "workspace:*",
|
||||||
"@tensamin/markdown": "workspace:*",
|
"@tensamin/markdown": "workspace:*",
|
||||||
"@tensamin/mtp": "workspace:*",
|
"@tensamin/mtp": "workspace:*",
|
||||||
"@tensamin/shared": "workspace:*",
|
"@tensamin/shared": "workspace:*",
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import Licenses from "./pages/licenses";
|
||||||
import Profile from "./pages/profile";
|
import Profile from "./pages/profile";
|
||||||
import Security from "./pages/security";
|
import Security from "./pages/security";
|
||||||
import Theme from "./pages/theme";
|
import Theme from "./pages/theme";
|
||||||
|
import Hotkeys from "./pages/hotkeys";
|
||||||
|
|
||||||
export const settingsPages = [
|
export const settingsPages = [
|
||||||
{ path: "/", component: Index },
|
{ path: "/", component: Index },
|
||||||
|
|
@ -25,6 +26,12 @@ export const settingsPages = [
|
||||||
{ category: "general", path: "call", label: "Call", component: Call },
|
{ category: "general", path: "call", label: "Call", component: Call },
|
||||||
{ category: "application", path: "cache", label: "Cache", component: Cache },
|
{ category: "application", path: "cache", label: "Cache", component: Cache },
|
||||||
{ category: "application", path: "theme", label: "Theme", component: Theme },
|
{ category: "application", path: "theme", label: "Theme", component: Theme },
|
||||||
|
{
|
||||||
|
category: "application",
|
||||||
|
path: "hotkeys",
|
||||||
|
label: "Hotkeys",
|
||||||
|
component: Hotkeys,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
category: "application",
|
category: "application",
|
||||||
path: "licenses",
|
path: "licenses",
|
||||||
|
|
|
||||||
152
packages/settings/src/pages/hotkeys.tsx
Normal file
152
packages/settings/src/pages/hotkeys.tsx
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
import { Button, Kbd } from "@methanium/ui";
|
||||||
|
import {
|
||||||
|
formatForDisplay,
|
||||||
|
useHotkeyDefinitions,
|
||||||
|
useHotkeyRecorder,
|
||||||
|
useHotkeysContext,
|
||||||
|
type HotkeyDefinition,
|
||||||
|
} from "@tensamin/hotkeys";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
function HotkeyRow({
|
||||||
|
definition,
|
||||||
|
conflicts,
|
||||||
|
isRecording,
|
||||||
|
startRecording,
|
||||||
|
}: {
|
||||||
|
definition: HotkeyDefinition;
|
||||||
|
conflicts: string[];
|
||||||
|
isRecording: boolean;
|
||||||
|
startRecording: () => void;
|
||||||
|
}) {
|
||||||
|
const { bindingFor, setBinding, resetBinding, globalStatuses } =
|
||||||
|
useHotkeysContext();
|
||||||
|
const binding = bindingFor(definition);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2 rounded-lg border border-input p-4">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="font-medium">{definition.name}</p>
|
||||||
|
{definition.description && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{definition.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
{definition.global
|
||||||
|
? "Global in the Electron desktop app"
|
||||||
|
: "Active while its screen or component is available"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Kbd>{binding ? formatForDisplay(binding) : "Unbound"}</Kbd>
|
||||||
|
</div>
|
||||||
|
{conflicts.length > 0 && (
|
||||||
|
<p className="text-sm text-amber-600 dark:text-amber-400">
|
||||||
|
Also assigned to {conflicts.join(", ")}.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{definition.global && globalStatuses[definition.id] === "unavailable" && (
|
||||||
|
<p className="text-sm text-destructive">
|
||||||
|
Electron could not register this shortcut. It may be reserved by the
|
||||||
|
operating system or another application.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button variant="outline" onClick={startRecording}>
|
||||||
|
{isRecording ? "Press a shortcut..." : "Record"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
disabled={binding === null}
|
||||||
|
onClick={() => setBinding(definition, null)}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
disabled={binding === definition.defaultBinding}
|
||||||
|
onClick={() => resetBinding(definition)}
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
const definitions = useHotkeyDefinitions();
|
||||||
|
const { bindingFor, resetAll, setBinding, setRecording } =
|
||||||
|
useHotkeysContext();
|
||||||
|
const [recordingId, setRecordingId] = useState<string | null>(null);
|
||||||
|
const categories = [...new Set(definitions.map(({ category }) => category))];
|
||||||
|
const recorder = useHotkeyRecorder({
|
||||||
|
ignoreInputs: false,
|
||||||
|
onRecord: (hotkey) => {
|
||||||
|
const definition = definitions.find(({ id }) => id === recordingId);
|
||||||
|
if (definition) setBinding(definition, hotkey || null);
|
||||||
|
setRecordingId(null);
|
||||||
|
setRecording(false);
|
||||||
|
},
|
||||||
|
onCancel: () => {
|
||||||
|
setRecordingId(null);
|
||||||
|
setRecording(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => () => setRecording(false), [setRecording]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex max-w-3xl flex-col gap-6">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Click Record, then press the replacement shortcut. Conflicting
|
||||||
|
shortcuts are allowed and will run together when their scopes overlap.
|
||||||
|
</p>
|
||||||
|
<Button variant="outline" onClick={resetAll}>
|
||||||
|
Reset All
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{categories.map((category) => (
|
||||||
|
<section className="flex flex-col gap-3" key={category}>
|
||||||
|
<h2 className="text-lg font-semibold">{category}</h2>
|
||||||
|
{definitions
|
||||||
|
.filter((definition) => definition.category === category)
|
||||||
|
.map((definition) => {
|
||||||
|
const binding = bindingFor(definition);
|
||||||
|
const conflicts = binding
|
||||||
|
? definitions
|
||||||
|
.filter(
|
||||||
|
(candidate) =>
|
||||||
|
candidate.id !== definition.id &&
|
||||||
|
bindingFor(candidate) === binding,
|
||||||
|
)
|
||||||
|
.map(({ name }) => name)
|
||||||
|
: [];
|
||||||
|
return (
|
||||||
|
<HotkeyRow
|
||||||
|
conflicts={conflicts}
|
||||||
|
definition={definition}
|
||||||
|
isRecording={
|
||||||
|
recordingId === definition.id && recorder.isRecording
|
||||||
|
}
|
||||||
|
key={definition.id}
|
||||||
|
startRecording={() => {
|
||||||
|
setRecordingId(definition.id);
|
||||||
|
setRecording(true);
|
||||||
|
recorder.startRecording();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
{definitions.length === 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No configurable hotkeys are registered.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -487,6 +487,7 @@ export interface Storage extends SettingsStorageDefaults {
|
||||||
height: number;
|
height: number;
|
||||||
} | null;
|
} | null;
|
||||||
reactions: Record<string, number>;
|
reactions: Record<string, number>;
|
||||||
|
hotkey_overrides: Record<string, string | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const storageDefaults: Storage = {
|
export const storageDefaults: Storage = {
|
||||||
|
|
@ -577,6 +578,7 @@ export const storageDefaults: Storage = {
|
||||||
":fire:": 2,
|
":fire:": 2,
|
||||||
":white_check_mark:": 1,
|
":white_check_mark:": 1,
|
||||||
},
|
},
|
||||||
|
hotkey_overrides: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
// User Status
|
// User Status
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,13 @@ type ElectronDesktopApi = {
|
||||||
iconDataUrl?: string;
|
iconDataUrl?: string;
|
||||||
}) => Promise<void>;
|
}) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
hotkeys?: {
|
||||||
|
setBindings?: (
|
||||||
|
bindings: Array<{ id: string; accelerator: string }>,
|
||||||
|
) => Promise<Record<string, boolean>>;
|
||||||
|
setSuspended?: (suspended: boolean) => Promise<Record<string, boolean>>;
|
||||||
|
onTriggered?: (callback: (id: string) => void) => () => void;
|
||||||
|
};
|
||||||
secureStorage?: {
|
secureStorage?: {
|
||||||
getStatus?: () => Promise<{ available: boolean; backend: string | null }>;
|
getStatus?: () => Promise<{ available: boolean; backend: string | null }>;
|
||||||
load?: (key: string) => Promise<string | null>;
|
load?: (key: string) => Promise<string | null>;
|
||||||
|
|
|
||||||
72
pnpm-lock.yaml
generated
72
pnpm-lock.yaml
generated
|
|
@ -250,6 +250,9 @@ importers:
|
||||||
"@tensamin/crypto":
|
"@tensamin/crypto":
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/crypto
|
version: link:../../packages/crypto
|
||||||
|
"@tensamin/hotkeys":
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/hotkeys
|
||||||
"@tensamin/markdown":
|
"@tensamin/markdown":
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/markdown
|
version: link:../../packages/markdown
|
||||||
|
|
@ -599,6 +602,9 @@ importers:
|
||||||
"@tensamin/crypto":
|
"@tensamin/crypto":
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../crypto
|
version: link:../crypto
|
||||||
|
"@tensamin/hotkeys":
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../hotkeys
|
||||||
"@tensamin/markdown":
|
"@tensamin/markdown":
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../markdown
|
version: link:../markdown
|
||||||
|
|
@ -639,6 +645,25 @@ importers:
|
||||||
specifier: ^19.2.0
|
specifier: ^19.2.0
|
||||||
version: 19.2.7(react@19.2.7)
|
version: 19.2.7(react@19.2.7)
|
||||||
|
|
||||||
|
packages/hotkeys:
|
||||||
|
dependencies:
|
||||||
|
"@tanstack/react-hotkeys":
|
||||||
|
specifier: ^0.10.0
|
||||||
|
version: 0.10.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||||
|
"@tensamin/storage":
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../storage
|
||||||
|
react:
|
||||||
|
specifier: ^19.2.0
|
||||||
|
version: 19.2.7
|
||||||
|
react-dom:
|
||||||
|
specifier: ^19.2.0
|
||||||
|
version: 19.2.7(react@19.2.7)
|
||||||
|
devDependencies:
|
||||||
|
vite:
|
||||||
|
specifier: ^8.0.10
|
||||||
|
version: 8.1.3(@types/node@26.1.0)(esbuild@0.25.12)(jiti@2.7.0)(yaml@2.9.0)
|
||||||
|
|
||||||
packages/markdown:
|
packages/markdown:
|
||||||
dependencies:
|
dependencies:
|
||||||
"@codemirror/autocomplete":
|
"@codemirror/autocomplete":
|
||||||
|
|
@ -795,6 +820,9 @@ importers:
|
||||||
"@tensamin/cache":
|
"@tensamin/cache":
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../cache
|
version: link:../cache
|
||||||
|
"@tensamin/hotkeys":
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../hotkeys
|
||||||
"@tensamin/markdown":
|
"@tensamin/markdown":
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../markdown
|
version: link:../markdown
|
||||||
|
|
@ -2635,6 +2663,13 @@ packages:
|
||||||
}
|
}
|
||||||
engines: { node: ">=20.19" }
|
engines: { node: ">=20.19" }
|
||||||
|
|
||||||
|
"@tanstack/hotkeys@0.8.0":
|
||||||
|
resolution:
|
||||||
|
{
|
||||||
|
integrity: sha512-vqH7X9nb0MTJ/O08++dB5bP9jgj4+BIPOUu/U+6myG86lDsirZSVSobpq5UQpE7nBuk62i8eIYeOhd+OMl/UrA==,
|
||||||
|
}
|
||||||
|
engines: { node: ">=18" }
|
||||||
|
|
||||||
"@tanstack/pacer@0.21.1":
|
"@tanstack/pacer@0.21.1":
|
||||||
resolution:
|
resolution:
|
||||||
{
|
{
|
||||||
|
|
@ -2648,6 +2683,16 @@ packages:
|
||||||
integrity: sha512-hH5MLoJhF7KaIGd7q3xTXGXvslI+GYlM1Z/35aSHHWaCJWB7XvTSHYuV3eM7tw+aE0mT/xMro4M4Q9rCGHT0lw==,
|
integrity: sha512-hH5MLoJhF7KaIGd7q3xTXGXvslI+GYlM1Z/35aSHHWaCJWB7XvTSHYuV3eM7tw+aE0mT/xMro4M4Q9rCGHT0lw==,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
"@tanstack/react-hotkeys@0.10.0":
|
||||||
|
resolution:
|
||||||
|
{
|
||||||
|
integrity: sha512-GwOSndI5j3qBVYTmgP1mYyRTnlxb2MS17cwGlsavSxMQPSnmDf+m3LzMIpRMs+3zzQMjg3cYhHsFYizYlFI2tw==,
|
||||||
|
}
|
||||||
|
engines: { node: ">=18" }
|
||||||
|
peerDependencies:
|
||||||
|
react: ">=16.8"
|
||||||
|
react-dom: ">=16.8"
|
||||||
|
|
||||||
"@tanstack/react-query@5.101.2":
|
"@tanstack/react-query@5.101.2":
|
||||||
resolution:
|
resolution:
|
||||||
{
|
{
|
||||||
|
|
@ -2666,6 +2711,15 @@ packages:
|
||||||
react: ">=18.0.0 || >=19.0.0"
|
react: ">=18.0.0 || >=19.0.0"
|
||||||
react-dom: ">=18.0.0 || >=19.0.0"
|
react-dom: ">=18.0.0 || >=19.0.0"
|
||||||
|
|
||||||
|
"@tanstack/react-store@0.11.0":
|
||||||
|
resolution:
|
||||||
|
{
|
||||||
|
integrity: sha512-tX4YXh3PDkmpvGQWkWqKpzs/MSqbtuwY9dWdWhtV9Q50PmO+jOkUKIWIX4G85dwt7lxdHLXsiaEKPdKmC8F41w==,
|
||||||
|
}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
|
||||||
"@tanstack/react-store@0.9.3":
|
"@tanstack/react-store@0.9.3":
|
||||||
resolution:
|
resolution:
|
||||||
{
|
{
|
||||||
|
|
@ -9085,6 +9139,10 @@ snapshots:
|
||||||
|
|
||||||
"@tanstack/history@1.162.0": {}
|
"@tanstack/history@1.162.0": {}
|
||||||
|
|
||||||
|
"@tanstack/hotkeys@0.8.0":
|
||||||
|
dependencies:
|
||||||
|
"@tanstack/store": 0.11.0
|
||||||
|
|
||||||
"@tanstack/pacer@0.21.1":
|
"@tanstack/pacer@0.21.1":
|
||||||
dependencies:
|
dependencies:
|
||||||
"@tanstack/devtools-event-client": 0.4.4
|
"@tanstack/devtools-event-client": 0.4.4
|
||||||
|
|
@ -9092,6 +9150,13 @@ snapshots:
|
||||||
|
|
||||||
"@tanstack/query-core@5.101.2": {}
|
"@tanstack/query-core@5.101.2": {}
|
||||||
|
|
||||||
|
"@tanstack/react-hotkeys@0.10.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)":
|
||||||
|
dependencies:
|
||||||
|
"@tanstack/hotkeys": 0.8.0
|
||||||
|
"@tanstack/react-store": 0.11.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||||
|
react: 19.2.7
|
||||||
|
react-dom: 19.2.7(react@19.2.7)
|
||||||
|
|
||||||
"@tanstack/react-query@5.101.2(react@19.2.7)":
|
"@tanstack/react-query@5.101.2(react@19.2.7)":
|
||||||
dependencies:
|
dependencies:
|
||||||
"@tanstack/query-core": 5.101.2
|
"@tanstack/query-core": 5.101.2
|
||||||
|
|
@ -9106,6 +9171,13 @@ snapshots:
|
||||||
react: 19.2.7
|
react: 19.2.7
|
||||||
react-dom: 19.2.7(react@19.2.7)
|
react-dom: 19.2.7(react@19.2.7)
|
||||||
|
|
||||||
|
"@tanstack/react-store@0.11.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)":
|
||||||
|
dependencies:
|
||||||
|
"@tanstack/store": 0.11.0
|
||||||
|
react: 19.2.7
|
||||||
|
react-dom: 19.2.7(react@19.2.7)
|
||||||
|
use-sync-external-store: 1.6.0(react@19.2.7)
|
||||||
|
|
||||||
"@tanstack/react-store@0.9.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7)":
|
"@tanstack/react-store@0.9.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7)":
|
||||||
dependencies:
|
dependencies:
|
||||||
"@tanstack/store": 0.9.3
|
"@tanstack/store": 0.9.3
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue