(feat): add hotkeys
Some checks failed
/ build-web (push) Successful in 7m53s
/ build-desktop (linux) (push) Successful in 12m24s
/ release (push) Has been cancelled
/ build-mobile (push) Has been cancelled

This commit is contained in:
Alois 2026-08-02 01:10:05 +02:00
commit 85633a1c81
Signed by: alois
SSH key fingerprint: SHA256:GBzT2DXvAuGV9XIV5W3WrzVpjU54FThmxHXdbz95J24
27 changed files with 1014 additions and 33 deletions

View file

@ -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<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(() => {
void load("settings.reverse_enter_behavior").then((shouldInvert) => {
@ -195,6 +251,7 @@ export default function InputComponent({
{/* reply */}
{replyTo !== undefined && (
<ReplyBox
edited={replyMessage?.Edited}
content={replyMessage?.Content}
loading={!replyMessage}
onDismiss={() => setReplyTo(undefined)}
@ -212,6 +269,7 @@ export default function InputComponent({
<CardHeader className="relative p-0 flex flex-col">
<Input
className="w-full"
onControllerChange={setComposer}
paddingY="13px"
paddingX="13px"
placeholder="Send a message..."

View file

@ -1,7 +1,7 @@
import type { RawMessage } from "../values";
import Text from "@tensamin/markdown/text";
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 {
@ -24,17 +24,23 @@ import Emoji, { normalizeShortcode } from "@tensamin/markdown/emoji";
import { useRecordEmojiUse } from "./emojiRanks";
import { useUser } from "@tensamin/user/context";
import ReplyBox from "./replyBox";
import { useHotkey } from "@tensamin/hotkeys";
import { cancelMessageEditHotkey } from "../hotkeys";
function MessageComponent({
editing,
grouped,
message,
onSetEditing,
user,
}: {
editing: boolean;
grouped: boolean;
message: RawMessage & {
failed?: boolean;
decryptionFailed?: boolean;
};
onSetEditing: (editing: boolean) => void;
user: User | null;
}) {
const actuallyFailed =
@ -48,6 +54,7 @@ function MessageComponent({
: message.MessageState === "awaiting"
? "opacity-50"
: "opacity-100";
const messageRef = useRef<HTMLDivElement>(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 (
<div
ref={messageRef}
// 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}`}
>
{/* reply */}
{replyMessage && replyUser && (
<ReplyBox
edited={replyMessage.Edited}
content={replyMessage.Content}
user={replyUser}
variant="message"
@ -289,7 +316,7 @@ function MessageComponent({
isOwnMessage={message.SenderId === ownId}
messageId={message.SendTime}
onReact={toggleReaction}
onSetEditing={setEditing}
onSetEditing={onSetEditing}
>
<>
<div
@ -305,7 +332,7 @@ function MessageComponent({
>
<>
{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([], {
hour: "2-digit",
minute: "2-digit",
@ -362,13 +389,17 @@ function MessageComponent({
{editing ? (
<div className="flex w-full flex-col gap-1">
<Input
autoFocus
className="w-full"
styled
setValue={setEditDraft}
value={editDraft}
onSubmit={() => {
submitEditMessage(editDraft);
setEditing(false);
if (!editDraft.trim()) return;
if (editDraft !== message.Content) {
void submitEditMessage(editDraft);
}
onSetEditing(false);
}}
/>
<div className="flex gap-1">
@ -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
</Button>
</div>
</div>
) : isValidURL ? (
<Media link={message.Content} />
) : (
<Text value={message.Content} />
<div className="flex gap-1">
{isValidURL ? (
<Media link={message.Content} />
) : (
<Text value={message.Content} />
)}
{message.Edited && (
<p className="text-xs text-muted-foreground self-center">
(edited)
</p>
)}
</div>
)}
{groupedReactions.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1 pb-1">
@ -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 &&

View file

@ -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 && (
<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} />
{edited && (
<p className="text-xs text-muted-foreground self-center">
(edited)
</p>
)}
</div>
)}
</>

View file

@ -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<void>;
clearLiveMessages: () => void;
chatSecret: Uint8Array | null;
ownId: number;
userId: number;
inputBoxRef: React.RefObject<HTMLDivElement | null>;
error: string;

View 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",
});

View file

@ -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<number | null>(null);
const previousEditingMessageIdRef = useRef<number | null>(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<HTMLElement>(".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) => (
<Message
editing={editingMessageId === message.SendTime}
grouped={isGrouped}
message={message}
onSetEditing={(editing) =>
setEditingMessageId(
editing ? message.SendTime : null,
)
}
user={user}
/>
)}
@ -504,7 +556,11 @@ export default function Screen() {
</div>
</div>
<div className="z-10 shrink-0">
<InputComponent setValue={setValue} value={value} />
<InputComponent
onEditLastMessage={editLastMessage}
setValue={setValue}
value={value}
/>
</div>
</>
)}