diff --git a/TODO b/TODO index 3ec0948..e4b9435 100644 --- a/TODO +++ b/TODO @@ -1,5 +1,4 @@ - Add a bunch of tests -- Add packages/hotkeys/ - Full accessability - Add settings saving & update onboarding to use it - Add onboarding page to load one profile during onboarding diff --git a/apps/electron/src/main/main.ts b/apps/electron/src/main/main.ts index 2a0ebfa..083d4d4 100644 --- a/apps/electron/src/main/main.ts +++ b/apps/electron/src/main/main.ts @@ -5,6 +5,7 @@ import { app, BrowserWindow, desktopCapturer, + globalShortcut, ipcMain, session, shell, @@ -13,6 +14,7 @@ import { checkForUpdates } from "./updates.js"; import { ipcChannels, type DesktopCallStatus, + type DesktopGlobalHotkeyBinding, type DesktopScreenShareAudioOutput, type DesktopScreenShareCapabilities, } from "../shared/ipc.js"; @@ -29,6 +31,8 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); const verbose = process.argv.includes("--verbose"); let mainWindow: BrowserWindow | null = null; let selectedScreenShareSourceId: string | null = null; +let globalHotkeyBindings: DesktopGlobalHotkeyBinding[] = []; +let globalHotkeysSuspended = false; app.setName("tensamin"); app.setPath("userData", join(app.getPath("appData"), "tensamin", "electron")); @@ -46,6 +50,13 @@ if ( app.commandLine.appendSwitch("password-store", "gnome-libsecret"); } +if ( + process.platform === "linux" && + process.env.XDG_SESSION_TYPE === "wayland" +) { + app.commandLine.appendSwitch("enable-features", "GlobalShortcutsPortal"); +} + if ( process.platform === "linux" && process.env.XDG_SESSION_TYPE === "wayland" && @@ -242,6 +253,24 @@ function registerIpc() { deleteSecureStorage(key), ); 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) => { if ( 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(); + 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() { const rendererIndex = getRendererIndex(); verboseLog("creating main window", { @@ -367,6 +480,10 @@ app.on("activate", () => { if (BrowserWindow.getAllWindows().length === 0) void createWindow(); }); +app.on("will-quit", () => { + globalShortcut.unregisterAll(); +}); + if (verbose) { process.on("uncaughtException", (error) => { console.error("[tensamin:electron] uncaught exception", error); diff --git a/apps/electron/src/preload/preload.ts b/apps/electron/src/preload/preload.ts index c6b61e4..9f1c932 100644 --- a/apps/electron/src/preload/preload.ts +++ b/apps/electron/src/preload/preload.ts @@ -2,6 +2,7 @@ import { contextBridge, ipcRenderer } from "electron"; import { ipcChannels, type DesktopCallStatus, + type DesktopGlobalHotkeyBinding, type DesktopScreenShareSource, secureStorageLimits, } from "../shared/ipc.js"; @@ -55,6 +56,23 @@ const desktopApi = { 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: { getStatus: () => ipcRenderer.invoke(ipcChannels.getSecureStorageStatus), load: (key: string) => diff --git a/apps/electron/src/shared/ipc.ts b/apps/electron/src/shared/ipc.ts index 0679349..c424265 100644 --- a/apps/electron/src/shared/ipc.ts +++ b/apps/electron/src/shared/ipc.ts @@ -31,6 +31,11 @@ export type DesktopSecureStorageStatus = { backend: string | null; }; +export type DesktopGlobalHotkeyBinding = { + id: string; + accelerator: string; +}; + export const secureStorageLimits = { maxKeyBytes: 256, maxValueBytes: 1024 * 1024, @@ -77,4 +82,7 @@ export const ipcChannels = { saveSecureStorage: "secureStorage:save", deleteSecureStorage: "secureStorage:delete", clearSecureStorage: "secureStorage:clear", + setGlobalHotkeyBindings: "hotkeys:setBindings", + setGlobalHotkeysSuspended: "hotkeys:setSuspended", + globalHotkeyTriggered: "hotkeys:triggered", } as const; diff --git a/apps/web/package.json b/apps/web/package.json index d95d914..e21fea3 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -53,6 +53,7 @@ "@tensamin/cache": "workspace:*", "@tensamin/chat": "workspace:*", "@tensamin/crypto": "workspace:*", + "@tensamin/hotkeys": "workspace:*", "@tensamin/shared": "workspace:*", "@tensamin/settings": "workspace:*", "@tensamin/storage": "workspace:*", diff --git a/apps/web/src/index.tsx b/apps/web/src/index.tsx index 8a7e776..6fbeae6 100644 --- a/apps/web/src/index.tsx +++ b/apps/web/src/index.tsx @@ -49,6 +49,7 @@ import { useStorage } from "@tensamin/storage/context"; import { useLocation, useNavigate } from "@tanstack/react-router"; import { useIsMobile, Toaster, TooltipProvider } from "@methanium/ui"; import { isTauri } from "@tauri-apps/api/core"; +import { HotkeysProvider } from "@tensamin/hotkeys"; const wrapper = document.getElementById("root"); @@ -266,10 +267,12 @@ function RootShell() { /> - - - - + + + + + + diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 2ad8a19..bb41448 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -82,9 +82,7 @@ export default defineConfig({ "use-sync-external-store", "@tanstack/history", "@tanstack/react-router", - "@tanstack/react-store", "@tanstack/router-core", - "@tanstack/store", "@tensamin/crypto", "@tensamin/settings", "@tensamin/storage", @@ -134,6 +132,7 @@ export default defineConfig({ "@tensamin/chat", "@tensamin/crypto", "@tensamin/crypto/context", + "@tensamin/hotkeys", "@tensamin/markdown", "@tensamin/mtp", "@tensamin/notifications", diff --git a/packages/chat/package.json b/packages/chat/package.json index 5e4db63..5be2d97 100644 --- a/packages/chat/package.json +++ b/packages/chat/package.json @@ -22,6 +22,7 @@ "@tanstack/react-router": "^1.0.0", "@tanstack/react-virtual": "^3.0.0", "@tensamin/crypto": "workspace:*", + "@tensamin/hotkeys": "workspace:*", "@tensamin/markdown": "workspace:*", "@tensamin/mtp": "workspace:*", "@tensamin/shared": "workspace:*", diff --git a/packages/chat/src/components/input.tsx b/packages/chat/src/components/input.tsx index 1dc336f..7942b20 100644 --- a/packages/chat/src/components/input.tsx +++ b/packages/chat/src/components/input.tsx @@ -1,4 +1,4 @@ -import Input from "@tensamin/markdown/input"; +import Input, { type InputController } from "@tensamin/markdown/input"; import { Card, CardHeader, @@ -7,7 +7,7 @@ import { PopoverTrigger, } from "@methanium/ui"; 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 { Plus, Laugh, FileVideo } from "lucide-react"; @@ -22,13 +22,17 @@ import GifPicker from "./gifPicker"; import EmojiPicker from "./emojiPicker"; import { useEmojiRanks, useRecordEmojiUse } from "./emojiRanks"; import ReplyBox from "./replyBox"; +import { useHotkey } from "@tensamin/hotkeys"; +import { editLastMessageHotkey } from "../hotkeys"; export default function InputComponent({ value, setValue, + onEditLastMessage, }: { value: string; setValue: (value: string) => void; + onEditLastMessage: () => void; }) { const [invertEnterBehavior, setInvertEnterBehavior] = useState(false); @@ -52,6 +56,58 @@ export default function InputComponent({ width: number; height: number; }>(); + const composerRef = useRef(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(() => { void load("settings.reverse_enter_behavior").then((shouldInvert) => { @@ -195,6 +251,7 @@ export default function InputComponent({ {/* reply */} {replyTo !== undefined && ( setReplyTo(undefined)} @@ -212,6 +269,7 @@ export default function InputComponent({ void; user: User | null; }) { const actuallyFailed = @@ -48,6 +54,7 @@ function MessageComponent({ : message.MessageState === "awaiting" ? "opacity-50" : "opacity-100"; + const messageRef = useRef(null); useEffect(() => { const timeout = window.setTimeout(() => { setHasFadedIn(true); @@ -141,7 +148,6 @@ function MessageComponent({ chatSecret, editMessage, userId, - deleteMessage, removeReaction, replyTo, } = useChat(); @@ -183,8 +189,27 @@ function MessageComponent({ }; }, [chatSecret, getUser, message.ReplyId, ownId, send, userId]); 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 cancelEditing = useCallback(() => { + setEditDraft(message.Content); + onSetEditing(false); + }, [message.Content, onSetEditing]); + useHotkey(cancelMessageEditHotkey, cancelEditing, { + enabled: editing, + target: messageRef, + }); useEffect(() => { if (!editing) { setEditDraft(message.Content); @@ -271,12 +296,14 @@ function MessageComponent({ return (
{/* reply */} {replyMessage && replyUser && ( <>
<> {grouped ? ( -

+

{new Date(message.SendTime).toLocaleString([], { hour: "2-digit", minute: "2-digit", @@ -362,13 +389,17 @@ function MessageComponent({ {editing ? (

{ - submitEditMessage(editDraft); - setEditing(false); + if (!editDraft.trim()) return; + if (editDraft !== message.Content) { + void submitEditMessage(editDraft); + } + onSetEditing(false); }} />
@@ -377,13 +408,12 @@ function MessageComponent({ variant="link" className="text-primary-foreground-alt" onClick={() => { - if (editDraft === message.Content) { - deleteMessage(message.SendTime); - } else { - submitEditMessage(editDraft); + if (!editDraft.trim()) return; + if (editDraft !== message.Content) { + void submitEditMessage(editDraft); } setEditDraft(message.Content); - setEditing(false); + onSetEditing(false); }} > Save @@ -392,19 +422,25 @@ function MessageComponent({ size="xs" variant="link" className="text-muted-foreground" - onClick={() => { - setEditDraft(message.Content); - setEditing(false); - }} + onClick={cancelEditing} > Cancel
- ) : isValidURL ? ( - ) : ( - +
+ {isValidURL ? ( + + ) : ( + + )} + {message.Edited && ( +

+ (edited) +

+ )} +
)} {groupedReactions.length > 0 && (
@@ -445,6 +481,7 @@ function MessageComponent({ export default memo(MessageComponent, (prev, next) => { return ( prev.message.SendTime === next.message.SendTime && + prev.editing === next.editing && prev.message.Content === next.message.Content && prev.message.SenderId === next.message.SenderId && prev.message.ReplyId === next.message.ReplyId && diff --git a/packages/chat/src/components/replyBox.tsx b/packages/chat/src/components/replyBox.tsx index b171457..03df6b0 100644 --- a/packages/chat/src/components/replyBox.tsx +++ b/packages/chat/src/components/replyBox.tsx @@ -28,6 +28,7 @@ function ReplyUser({ user }: { user: User }) { } export default function ReplyBox({ + edited, content, loading = false, onDismiss, @@ -35,6 +36,7 @@ export default function ReplyBox({ userId, variant, }: { + edited?: boolean; content?: string; loading?: boolean; onDismiss?: () => void; @@ -74,8 +76,13 @@ export default function ReplyBox({ /> ) : null} {content !== undefined && ( -
+
+ {edited && ( +

+ (edited) +

+ )}
)} diff --git a/packages/chat/src/context.tsx b/packages/chat/src/context.tsx index 9ecf641..cb56671 100644 --- a/packages/chat/src/context.tsx +++ b/packages/chat/src/context.tsx @@ -927,6 +927,7 @@ export default function Provider({ children }: { children: ReactNode }) { removeReaction, clearLiveMessages, chatSecret: currentChatSecret, + ownId, userId: userIdValue, inputBoxRef, error, @@ -954,6 +955,7 @@ type contextType = { removeReaction: (sendTime: number, reaction: string) => Promise; clearLiveMessages: () => void; chatSecret: Uint8Array | null; + ownId: number; userId: number; inputBoxRef: React.RefObject; error: string; diff --git a/packages/chat/src/hotkeys.ts b/packages/chat/src/hotkeys.ts new file mode 100644 index 0000000..039d1fb --- /dev/null +++ b/packages/chat/src/hotkeys.ts @@ -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", +}); diff --git a/packages/chat/src/screen.tsx b/packages/chat/src/screen.tsx index bdba344..5ae113b 100644 --- a/packages/chat/src/screen.tsx +++ b/packages/chat/src/screen.tsx @@ -85,6 +85,8 @@ export default function Screen() { clearLiveMessages, userId, chatSecret, + ownId, + inputBoxRef, error, errorDescription, } = useChat(); @@ -103,10 +105,25 @@ export default function Screen() { const [didInitialScroll, setDidInitialScroll] = useState(false); const [viewportHeight, setViewportHeight] = useState(0); const [value, setValue] = useState(""); + const [editingMessageId, setEditingMessageId] = useState(null); + const previousEditingMessageIdRef = useRef(null); const hasValidChatUser = Number.isSafeInteger(userId) && userId > 0; const hasChatSecret = chatSecret !== null; + useEffect(() => { + const previousEditingMessageId = previousEditingMessageIdRef.current; + previousEditingMessageIdRef.current = editingMessageId; + if (previousEditingMessageId === null || editingMessageId !== null) return; + + const frame = requestAnimationFrame(() => { + inputBoxRef.current + ?.querySelector(".cm-content") + ?.focus({ preventScroll: true }); + }); + return () => cancelAnimationFrame(frame); + }, [editingMessageId, inputBoxRef]); + const messagesQuery = useInfiniteQuery({ queryKey: ["chat-messages", String(userId), hasChatSecret], initialPageParam: 0, @@ -141,6 +158,7 @@ export default function Screen() { isAtBottomRef.current = true; setDidInitialScroll(false); setLastLiveMessageCount(0); + setEditingMessageId(null); }, [clearLiveMessages, userId]); const historicalMessages = useMemo(() => { @@ -213,6 +231,34 @@ export default function Screen() { const contentHeight = Math.max(totalSize, viewportHeight); 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(() => { const element = scrollRef.current; if (!element || typeof ResizeObserver === "undefined") { @@ -489,8 +535,14 @@ export default function Screen() { loading={null} component={(user) => ( + setEditingMessageId( + editing ? message.SendTime : null, + ) + } user={user} /> )} @@ -504,7 +556,11 @@ export default function Screen() {
- +
)} diff --git a/packages/chat/todo.md b/packages/chat/todo.md index 24f3378..9e33228 100644 --- a/packages/chat/todo.md +++ b/packages/chat/todo.md @@ -1,12 +1,9 @@ - Implement context menu features - Forward - Pin Message - - Reply -- Add default-emoji-hotkey - Placeholder image if media fails to load - Signature verifications via ed25519 key - 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 - Reply jumping - Add emoji picker diff --git a/packages/hotkeys/package.json b/packages/hotkeys/package.json new file mode 100644 index 0000000..cdad472 --- /dev/null +++ b/packages/hotkeys/package.json @@ -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" + } +} diff --git a/packages/hotkeys/src/context.tsx b/packages/hotkeys/src/context.tsx new file mode 100644 index 0000000..c7040cf --- /dev/null +++ b/packages/hotkeys/src/context.tsx @@ -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; + bindingFor: (definition: HotkeyDefinition) => Hotkey | null; + setBinding: (definition: HotkeyDefinition, binding: Hotkey | null) => void; + resetBinding: (definition: HotkeyDefinition) => void; + resetAll: () => void; + globalStatuses: Record; + setRecording: (recording: boolean) => void; + registerGlobalHandler: ( + definition: HotkeyDefinition, + handler: () => void, + ) => () => void; +}; + +const HotkeysContext = createContext( + undefined, +); + +export function HotkeysProvider({ children }: { children: ReactNode }) { + const { load, save } = useStorage(); + const [overrides, setOverrides] = useState>({}); + const [handlersRevision, setHandlersRevision] = useState(0); + const [globalStatuses, setGlobalStatuses] = useState< + Record + >({}); + const handlers = useRef(new Map void>>()); + const overridesRef = useRef>({}); + + 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) => { + 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( + () => ({ + overrides, + bindingFor, + setBinding, + resetBinding, + resetAll, + globalStatuses, + setRecording, + registerGlobalHandler, + }), + [ + bindingFor, + globalStatuses, + overrides, + registerGlobalHandler, + resetAll, + resetBinding, + setBinding, + setRecording, + ], + ); + + return ( + + {children} + + ); +} + +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]); +} diff --git a/packages/hotkeys/src/index.ts b/packages/hotkeys/src/index.ts new file mode 100644 index 0000000..a46ac7c --- /dev/null +++ b/packages/hotkeys/src/index.ts @@ -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"; diff --git a/packages/hotkeys/src/registry.ts b/packages/hotkeys/src/registry.ts new file mode 100644 index 0000000..7d02ea6 --- /dev/null +++ b/packages/hotkeys/src/registry.ts @@ -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(); +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; +} + +export function toElectronAccelerator(hotkey: Hotkey) { + const keyAliases: Record = { + ArrowDown: "Down", + ArrowLeft: "Left", + ArrowRight: "Right", + ArrowUp: "Up", + " ": "Space", + }; + const modifierAliases: Record = { + 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("+"); +} diff --git a/packages/hotkeys/tsconfig.json b/packages/hotkeys/tsconfig.json new file mode 100644 index 0000000..7740474 --- /dev/null +++ b/packages/hotkeys/tsconfig.json @@ -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"] +} diff --git a/packages/markdown/src/input.tsx b/packages/markdown/src/input.tsx index 6b141e0..3a1710e 100644 --- a/packages/markdown/src/input.tsx +++ b/packages/markdown/src/input.tsx @@ -52,6 +52,12 @@ import Emoji, { export const MAX_RENDERED_EMOJI_OPTIONS = 100; +export type InputController = { + focus: () => void; + hasFocus: () => boolean; + insertText: (text: string) => void; +}; + export type InputProps = { ref?: HTMLDivElement; placeholder?: string; @@ -66,6 +72,8 @@ export type InputProps = { className?: string; emojiFrequencies?: Readonly>; onEmojiSelect?: (shortcode: string) => void; + autoFocus?: boolean; + onControllerChange?: (controller: InputController | null) => void; }; type InputStyle = CSSProperties & { @@ -353,8 +361,23 @@ export default function Input(props: InputProps) { state, 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 () => { + props.onControllerChange?.(null); viewRef.current?.destroy(); viewRef.current = undefined; }; diff --git a/packages/settings/package.json b/packages/settings/package.json index feaab6f..5ab6501 100644 --- a/packages/settings/package.json +++ b/packages/settings/package.json @@ -14,6 +14,7 @@ "dependencies": { "@tanstack/react-router": "^1.169.1", "@tensamin/cache": "workspace:*", + "@tensamin/hotkeys": "workspace:*", "@tensamin/markdown": "workspace:*", "@tensamin/mtp": "workspace:*", "@tensamin/shared": "workspace:*", diff --git a/packages/settings/src/manifest.ts b/packages/settings/src/manifest.ts index 5ca92c7..2daf8ff 100644 --- a/packages/settings/src/manifest.ts +++ b/packages/settings/src/manifest.ts @@ -6,6 +6,7 @@ import Licenses from "./pages/licenses"; import Profile from "./pages/profile"; import Security from "./pages/security"; import Theme from "./pages/theme"; +import Hotkeys from "./pages/hotkeys"; export const settingsPages = [ { path: "/", component: Index }, @@ -25,6 +26,12 @@ export const settingsPages = [ { category: "general", path: "call", label: "Call", component: Call }, { category: "application", path: "cache", label: "Cache", component: Cache }, { category: "application", path: "theme", label: "Theme", component: Theme }, + { + category: "application", + path: "hotkeys", + label: "Hotkeys", + component: Hotkeys, + }, { category: "application", path: "licenses", diff --git a/packages/settings/src/pages/hotkeys.tsx b/packages/settings/src/pages/hotkeys.tsx new file mode 100644 index 0000000..3f5cb5f --- /dev/null +++ b/packages/settings/src/pages/hotkeys.tsx @@ -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 ( +
+
+
+

{definition.name}

+ {definition.description && ( +

+ {definition.description} +

+ )} +

+ {definition.global + ? "Global in the Electron desktop app" + : "Active while its screen or component is available"} +

+
+ {binding ? formatForDisplay(binding) : "Unbound"} +
+ {conflicts.length > 0 && ( +

+ Also assigned to {conflicts.join(", ")}. +

+ )} + {definition.global && globalStatuses[definition.id] === "unavailable" && ( +

+ Electron could not register this shortcut. It may be reserved by the + operating system or another application. +

+ )} +
+ + + +
+
+ ); +} + +export default function Page() { + const definitions = useHotkeyDefinitions(); + const { bindingFor, resetAll, setBinding, setRecording } = + useHotkeysContext(); + const [recordingId, setRecordingId] = useState(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 ( +
+
+

+ Click Record, then press the replacement shortcut. Conflicting + shortcuts are allowed and will run together when their scopes overlap. +

+ +
+ {categories.map((category) => ( +
+

{category}

+ {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 ( + { + setRecordingId(definition.id); + setRecording(true); + recorder.startRecording(); + }} + /> + ); + })} +
+ ))} + {definitions.length === 0 && ( +

+ No configurable hotkeys are registered. +

+ )} +
+ ); +} diff --git a/packages/shared/src/data.ts b/packages/shared/src/data.ts index d498df1..f8ca265 100644 --- a/packages/shared/src/data.ts +++ b/packages/shared/src/data.ts @@ -487,6 +487,7 @@ export interface Storage extends SettingsStorageDefaults { height: number; } | null; reactions: Record; + hotkey_overrides: Record; } export const storageDefaults: Storage = { @@ -577,6 +578,7 @@ export const storageDefaults: Storage = { ":fire:": 2, ":white_check_mark:": 1, }, + hotkey_overrides: {}, }; // User Status diff --git a/packages/shared/src/desktopMedia.tsx b/packages/shared/src/desktopMedia.tsx index 32ecb74..75550cd 100644 --- a/packages/shared/src/desktopMedia.tsx +++ b/packages/shared/src/desktopMedia.tsx @@ -38,6 +38,13 @@ type ElectronDesktopApi = { iconDataUrl?: string; }) => Promise; }; + hotkeys?: { + setBindings?: ( + bindings: Array<{ id: string; accelerator: string }>, + ) => Promise>; + setSuspended?: (suspended: boolean) => Promise>; + onTriggered?: (callback: (id: string) => void) => () => void; + }; secureStorage?: { getStatus?: () => Promise<{ available: boolean; backend: string | null }>; load?: (key: string) => Promise; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ab406c7..c140a3a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -250,6 +250,9 @@ importers: "@tensamin/crypto": specifier: workspace:* version: link:../../packages/crypto + "@tensamin/hotkeys": + specifier: workspace:* + version: link:../../packages/hotkeys "@tensamin/markdown": specifier: workspace:* version: link:../../packages/markdown @@ -599,6 +602,9 @@ importers: "@tensamin/crypto": specifier: workspace:* version: link:../crypto + "@tensamin/hotkeys": + specifier: workspace:* + version: link:../hotkeys "@tensamin/markdown": specifier: workspace:* version: link:../markdown @@ -639,6 +645,25 @@ importers: specifier: ^19.2.0 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: dependencies: "@codemirror/autocomplete": @@ -795,6 +820,9 @@ importers: "@tensamin/cache": specifier: workspace:* version: link:../cache + "@tensamin/hotkeys": + specifier: workspace:* + version: link:../hotkeys "@tensamin/markdown": specifier: workspace:* version: link:../markdown @@ -2635,6 +2663,13 @@ packages: } 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": resolution: { @@ -2648,6 +2683,16 @@ packages: 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": resolution: { @@ -2666,6 +2711,15 @@ packages: react: ">=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": resolution: { @@ -9085,6 +9139,10 @@ snapshots: "@tanstack/history@1.162.0": {} + "@tanstack/hotkeys@0.8.0": + dependencies: + "@tanstack/store": 0.11.0 + "@tanstack/pacer@0.21.1": dependencies: "@tanstack/devtools-event-client": 0.4.4 @@ -9092,6 +9150,13 @@ snapshots: "@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)": dependencies: "@tanstack/query-core": 5.101.2 @@ -9106,6 +9171,13 @@ snapshots: 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)": dependencies: "@tanstack/store": 0.9.3