(feat): add hotkeys
This commit is contained in:
parent
915568f739
commit
85633a1c81
27 changed files with 1014 additions and 33 deletions
|
|
@ -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..."
|
||||
|
|
|
|||
|
|
@ -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 &&
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
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,
|
||||
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>
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
Loading…
Reference in a new issue