All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 41s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
667 lines
22 KiB
TypeScript
667 lines
22 KiB
TypeScript
import {
|
|
useCallback,
|
|
useEffect,
|
|
useLayoutEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from "react";
|
|
import { t } from "@lingui/core/macro";
|
|
import { Trans } from "@lingui/react/macro";
|
|
import { Link } from "react-router";
|
|
import { Modal } from "./Modal.tsx";
|
|
import { TextEditor, type TextEditorHandle } from "./TextEditor.tsx";
|
|
import { Markdown } from "./Markdown.tsx";
|
|
import { Avatar } from "./Avatar.tsx";
|
|
import { ConfirmModal } from "./ConfirmModal.tsx";
|
|
import { Tooltip } from "./Tooltip.tsx";
|
|
import { useHeightTransition } from "../hooks/useHeightTransition.ts";
|
|
import { useWS } from "../hooks/useWS.ts";
|
|
import { useAuth } from "../hooks/useAuth.ts";
|
|
import { can } from "../utils/permissions.ts";
|
|
import { relativeTime } from "../utils/relativeTime.ts";
|
|
import { i18n } from "../i18n.ts";
|
|
import { API_URL } from "../config/api.ts";
|
|
import {
|
|
type ChatMessage,
|
|
deserializeChatMessage,
|
|
type PublicUser,
|
|
type RawChatMessage,
|
|
} from "../model.ts";
|
|
|
|
// Keep in sync with MAX_CHAT_LENGTH in api/services/chat-service.ts.
|
|
const MAX_CHAT_LENGTH = 2000;
|
|
// Matches the server's default page size; a full page means there may be more.
|
|
const PAGE_SIZE = 50;
|
|
// How long the composer stays "typing" after the last keystroke, and how often
|
|
// we refresh that signal while typing continuously. The refresh interval must
|
|
// stay below the server's TYPING_TTL so a steady typist never flickers out.
|
|
const TYPING_STOP_DELAY = 3_500;
|
|
const TYPING_REFRESH = 3_000;
|
|
|
|
interface ReplyTarget {
|
|
id: string;
|
|
author: string;
|
|
body: string;
|
|
}
|
|
|
|
interface ChatMessageItemProps {
|
|
message: ChatMessage;
|
|
currentUser: PublicUser | null;
|
|
canModerate: boolean;
|
|
flashed: boolean;
|
|
onEdit: (id: string, body: string) => void;
|
|
onDelete: (id: string) => void;
|
|
onReply: (message: ChatMessage) => void;
|
|
onJumpTo: (id: string) => void;
|
|
}
|
|
|
|
function ChatMessageItem(
|
|
{
|
|
message,
|
|
currentUser,
|
|
canModerate,
|
|
flashed,
|
|
onEdit,
|
|
onDelete,
|
|
onReply,
|
|
onJumpTo,
|
|
}: ChatMessageItemProps,
|
|
) {
|
|
const [editing, setEditing] = useState(false);
|
|
const bodyRef = useHeightTransition<HTMLDivElement>(editing);
|
|
const [editDraft, setEditDraft] = useState(message.body);
|
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
|
const editorRef = useRef<TextEditorHandle>(null);
|
|
|
|
// Owners may edit their own messages; moderators may edit any. Deletion is a
|
|
// moderation action only (mirrors api/services/chat-service.ts).
|
|
const isOwn = !!currentUser && currentUser.id === message.userId;
|
|
const canEdit = isOwn || canModerate;
|
|
const canDelete = canModerate;
|
|
|
|
const openEdit = () => {
|
|
setEditDraft(message.body);
|
|
setEditing(true);
|
|
setTimeout(() => editorRef.current?.focus(), 0);
|
|
};
|
|
|
|
const saveEdit = () => {
|
|
const body = editDraft.trim();
|
|
if (!body) return;
|
|
onEdit(message.id, body);
|
|
setEditing(false);
|
|
};
|
|
|
|
return (
|
|
<div
|
|
className={`chat-message${flashed ? " chat-message--flash" : ""}`}
|
|
data-mid={message.id}
|
|
>
|
|
<Avatar
|
|
userId={message.userId}
|
|
username={message.authorUsername}
|
|
hasAvatar={!!message.authorAvatarMime}
|
|
size={32}
|
|
/>
|
|
<div className="chat-message-body" ref={bodyRef}>
|
|
{message.replyToId && (
|
|
// Subtle one-line reference to the replied-to message; clicking jumps
|
|
// to it. Falls back to a muted note when the target was deleted.
|
|
<button
|
|
type="button"
|
|
className="chat-reply-ref"
|
|
onClick={() => onJumpTo(message.replyToId!)}
|
|
disabled={!message.replyToAuthor}
|
|
>
|
|
<span className="chat-reply-ref-mark">↩</span>
|
|
{message.replyToAuthor
|
|
? (
|
|
<>
|
|
<span className="chat-reply-ref-author">
|
|
{message.replyToAuthor}
|
|
</span>
|
|
<span className="chat-reply-ref-text">
|
|
{message.replyToBody}
|
|
</span>
|
|
</>
|
|
)
|
|
: (
|
|
<span className="chat-reply-ref-text">
|
|
<Trans>deleted message</Trans>
|
|
</span>
|
|
)}
|
|
</button>
|
|
)}
|
|
<div className="chat-message-meta">
|
|
<Link
|
|
to={`/users/${message.authorUsername}`}
|
|
className="chat-message-author"
|
|
>
|
|
{message.authorUsername}
|
|
</Link>
|
|
<Tooltip text={message.createdAt.toLocaleString()}>
|
|
<time
|
|
className="chat-message-time"
|
|
dateTime={message.createdAt.toISOString()}
|
|
>
|
|
{relativeTime(message.createdAt)}
|
|
</time>
|
|
</Tooltip>
|
|
{message.updatedAt && (
|
|
<Tooltip text={t`Edited ${message.updatedAt.toLocaleString()}`}>
|
|
<span className="chat-message-edited">
|
|
<Trans>edited {relativeTime(message.updatedAt)}</Trans>
|
|
</span>
|
|
</Tooltip>
|
|
)}
|
|
{!editing && (
|
|
<span className="chat-message-actions">
|
|
<button
|
|
type="button"
|
|
className="chat-icon-btn"
|
|
onClick={() => onReply(message)}
|
|
title={t`Reply`}
|
|
aria-label={t`Reply`}
|
|
>
|
|
↩
|
|
</button>
|
|
{canEdit && (
|
|
<button
|
|
type="button"
|
|
className="chat-icon-btn"
|
|
onClick={openEdit}
|
|
title={t`Edit`}
|
|
aria-label={t`Edit`}
|
|
>
|
|
✎
|
|
</button>
|
|
)}
|
|
{canDelete && (
|
|
<button
|
|
type="button"
|
|
className="chat-icon-btn chat-icon-btn--delete"
|
|
onClick={() => setConfirmDelete(true)}
|
|
title={t`Delete`}
|
|
aria-label={t`Delete`}
|
|
>
|
|
🗑
|
|
</button>
|
|
)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{editing
|
|
? (
|
|
<div className="chat-edit animate-in">
|
|
<TextEditor
|
|
ref={editorRef}
|
|
value={editDraft}
|
|
onChange={setEditDraft}
|
|
rows={2}
|
|
autoResize
|
|
maxLength={MAX_CHAT_LENGTH}
|
|
className="comment-reply-textarea chat-input"
|
|
onSubmit={saveEdit}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter" && !e.shiftKey) {
|
|
e.preventDefault();
|
|
saveEdit();
|
|
} else if (e.key === "Escape") {
|
|
e.preventDefault();
|
|
setEditing(false);
|
|
}
|
|
}}
|
|
/>
|
|
<div className="chat-edit-actions">
|
|
<button
|
|
type="button"
|
|
className="btn-primary"
|
|
onClick={saveEdit}
|
|
disabled={!editDraft.trim()}
|
|
>
|
|
<Trans>Save</Trans>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn-secondary"
|
|
onClick={() => setEditing(false)}
|
|
>
|
|
<Trans>Cancel</Trans>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
: <Markdown className="chat-message-text">{message.body}</Markdown>}
|
|
|
|
{confirmDelete && (
|
|
<ConfirmModal
|
|
message={t`Delete this message?`}
|
|
confirmLabel={t`Delete`}
|
|
onConfirm={() => {
|
|
setConfirmDelete(false);
|
|
onDelete(message.id);
|
|
}}
|
|
onCancel={() => setConfirmDelete(false)}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function isSameDay(a: Date, b: Date): boolean {
|
|
return a.getFullYear() === b.getFullYear() &&
|
|
a.getMonth() === b.getMonth() &&
|
|
a.getDate() === b.getDate();
|
|
}
|
|
|
|
// Human label for a day separator: "Today"/"Yesterday" for the two most recent
|
|
// days, otherwise a localized full date.
|
|
function dayLabel(date: Date): string {
|
|
const now = new Date();
|
|
if (isSameDay(date, now)) return t`Today`;
|
|
const yesterday = new Date(now);
|
|
yesterday.setDate(now.getDate() - 1);
|
|
if (isSameDay(date, yesterday)) return t`Yesterday`;
|
|
return date.toLocaleDateString(i18n.locale, {
|
|
weekday: "long",
|
|
day: "numeric",
|
|
month: "long",
|
|
year: now.getFullYear() === date.getFullYear() ? undefined : "numeric",
|
|
});
|
|
}
|
|
|
|
export function ChatModal({ onClose }: { onClose: () => void }) {
|
|
const {
|
|
chatMessages,
|
|
deletedChatIds,
|
|
typingUsers,
|
|
sendChatMessage,
|
|
editChatMessage,
|
|
deleteChatMessage,
|
|
clearUnreadChat,
|
|
setChatTyping,
|
|
setChatOpen,
|
|
} = useWS();
|
|
const { authFetch, user } = useAuth();
|
|
const canModerate = can(user, "chat:moderate");
|
|
|
|
const [history, setHistory] = useState<ChatMessage[]>([]);
|
|
const [hasMore, setHasMore] = useState(false);
|
|
const [loadingOlder, setLoadingOlder] = useState(false);
|
|
const [draft, setDraft] = useState("");
|
|
const [replyTarget, setReplyTarget] = useState<ReplyTarget | null>(null);
|
|
const [flashId, setFlashId] = useState<string | null>(null);
|
|
|
|
const listRef = useRef<HTMLDivElement>(null);
|
|
const composerRef = useRef<TextEditorHandle>(null);
|
|
// Whether the view is pinned to the bottom — controls auto-scroll on new
|
|
// messages so reading older history isn't yanked away.
|
|
const stickToBottomRef = useRef(true);
|
|
// Set once the first history load has scrolled into place, so the layout
|
|
// effect can force the initial jump to the bottom even before the user has
|
|
// interacted.
|
|
const didInitialScrollRef = useRef(false);
|
|
// Typing-signal bookkeeping: timer that sends "stopped" after a pause, and
|
|
// the timestamp of the last "typing" refresh we sent.
|
|
const typingStopTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const lastTypingSentRef = useRef(0);
|
|
const flashTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
// Focus the composer when the chat opens so you can type straight away.
|
|
useEffect(() => {
|
|
composerRef.current?.focus();
|
|
}, []);
|
|
|
|
// Merge fetched history with the live buffer, drop deleted ones, dedupe by id
|
|
// (live buffer wins so edits show), sort ascending.
|
|
const messages = useMemo(() => {
|
|
const byId = new Map<string, ChatMessage>();
|
|
for (const m of history) byId.set(m.id, m);
|
|
for (const m of chatMessages) byId.set(m.id, m);
|
|
return [...byId.values()]
|
|
.filter((m) => !deletedChatIds.has(m.id))
|
|
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
|
|
}, [history, chatMessages, deletedChatIds]);
|
|
|
|
// Others (not me) currently composing — drives the typing indicator.
|
|
const typingOthers = useMemo(
|
|
() => typingUsers.filter((u) => u.userId !== user?.id),
|
|
[typingUsers, user?.id],
|
|
);
|
|
|
|
// Initial history load.
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
(async () => {
|
|
try {
|
|
const res = await authFetch(`${API_URL}/api/chat?limit=${PAGE_SIZE}`);
|
|
const json = await res.json();
|
|
if (cancelled || !json.success) return;
|
|
const items = (json.data as RawChatMessage[]).map(deserializeChatMessage);
|
|
setHistory(items);
|
|
setHasMore(items.length === PAGE_SIZE);
|
|
} catch {
|
|
// Live buffer still renders; the user can retry by reopening.
|
|
}
|
|
})();
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [authFetch]);
|
|
|
|
// Clear the unread badge on open and as new messages arrive while open.
|
|
useEffect(() => {
|
|
clearUnreadChat();
|
|
}, [chatMessages, clearUnreadChat]);
|
|
|
|
// Tell the server the chatbox is open so it skips redundant mention
|
|
// notifications while the user is reading; clear the flag on close.
|
|
useEffect(() => {
|
|
setChatOpen(true);
|
|
return () => setChatOpen(false);
|
|
}, [setChatOpen]);
|
|
|
|
// Stop announcing "typing" when the chatbox closes, whatever state we're in.
|
|
useEffect(() => {
|
|
return () => {
|
|
if (typingStopTimerRef.current) clearTimeout(typingStopTimerRef.current);
|
|
if (flashTimerRef.current) clearTimeout(flashTimerRef.current);
|
|
if (lastTypingSentRef.current) setChatTyping(false);
|
|
};
|
|
}, [setChatTyping]);
|
|
|
|
// Auto-scroll to the bottom when pinned (initial load + new messages + the
|
|
// typing row appearing). A layout effect runs before paint so there's no
|
|
// visible flash, and a follow-up frame catches late height changes (e.g. an
|
|
// avatar/image that resizes after first paint).
|
|
useLayoutEffect(() => {
|
|
const el = listRef.current;
|
|
if (!el) return;
|
|
// Force the very first positioning to the bottom even though no scrolling
|
|
// has happened yet, then honour the pin afterwards.
|
|
if (!didInitialScrollRef.current) {
|
|
if (messages.length === 0) return;
|
|
didInitialScrollRef.current = true;
|
|
} else if (!stickToBottomRef.current) {
|
|
return;
|
|
}
|
|
el.scrollTop = el.scrollHeight;
|
|
requestAnimationFrame(() => {
|
|
const node = listRef.current;
|
|
if (node && stickToBottomRef.current) node.scrollTop = node.scrollHeight;
|
|
});
|
|
// `replyTarget` is a dependency because the reply bar inserts itself
|
|
// between the list and the composer, shrinking the list: when pinned we
|
|
// must re-anchor to the bottom so the last message isn't hidden behind it.
|
|
}, [messages, typingOthers.length, replyTarget]);
|
|
|
|
const handleScroll = useCallback(() => {
|
|
const el = listRef.current;
|
|
if (!el) return;
|
|
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
|
|
stickToBottomRef.current = distanceFromBottom < 80;
|
|
}, []);
|
|
|
|
const loadOlder = useCallback(async () => {
|
|
const oldest = messages[0];
|
|
if (!oldest || loadingOlder) return;
|
|
setLoadingOlder(true);
|
|
const el = listRef.current;
|
|
const prevHeight = el?.scrollHeight ?? 0;
|
|
try {
|
|
const res = await authFetch(
|
|
`${API_URL}/api/chat?limit=${PAGE_SIZE}&before=${
|
|
encodeURIComponent(oldest.createdAt.toISOString())
|
|
}`,
|
|
);
|
|
const json = await res.json();
|
|
if (!json.success) return;
|
|
const older = (json.data as RawChatMessage[]).map(deserializeChatMessage);
|
|
setHasMore(older.length === PAGE_SIZE);
|
|
if (older.length > 0) {
|
|
stickToBottomRef.current = false;
|
|
setHistory((prev) => [...older, ...prev]);
|
|
// Preserve scroll position after older messages are prepended.
|
|
requestAnimationFrame(() => {
|
|
const node = listRef.current;
|
|
if (node) node.scrollTop = node.scrollHeight - prevHeight;
|
|
});
|
|
}
|
|
} catch {
|
|
// ignore — user can retry
|
|
} finally {
|
|
setLoadingOlder(false);
|
|
}
|
|
}, [authFetch, messages, loadingOlder]);
|
|
|
|
// Announce that the user is composing, throttled, and schedule a "stopped"
|
|
// signal once they pause. Called on every keystroke via the composer.
|
|
const pingTyping = useCallback(() => {
|
|
const now = Date.now();
|
|
if (now - lastTypingSentRef.current > TYPING_REFRESH) {
|
|
lastTypingSentRef.current = now;
|
|
setChatTyping(true);
|
|
}
|
|
if (typingStopTimerRef.current) clearTimeout(typingStopTimerRef.current);
|
|
typingStopTimerRef.current = setTimeout(() => {
|
|
lastTypingSentRef.current = 0;
|
|
setChatTyping(false);
|
|
}, TYPING_STOP_DELAY);
|
|
}, [setChatTyping]);
|
|
|
|
const stopTyping = useCallback(() => {
|
|
if (typingStopTimerRef.current) {
|
|
clearTimeout(typingStopTimerRef.current);
|
|
typingStopTimerRef.current = null;
|
|
}
|
|
if (lastTypingSentRef.current) {
|
|
lastTypingSentRef.current = 0;
|
|
setChatTyping(false);
|
|
}
|
|
}, [setChatTyping]);
|
|
|
|
const handleDraftChange = useCallback((value: string) => {
|
|
setDraft(value);
|
|
if (value.trim()) pingTyping();
|
|
else stopTyping();
|
|
}, [pingTyping, stopTyping]);
|
|
|
|
const startReply = useCallback((message: ChatMessage) => {
|
|
setReplyTarget({
|
|
id: message.id,
|
|
author: message.authorUsername,
|
|
body: message.body,
|
|
});
|
|
composerRef.current?.focus();
|
|
}, []);
|
|
|
|
// Scroll a referenced message into view and flash it briefly. No-op if it's
|
|
// not currently loaded (older history the user hasn't paged in).
|
|
const jumpToMessage = useCallback((id: string) => {
|
|
const node = listRef.current?.querySelector<HTMLElement>(
|
|
`[data-mid="${CSS.escape(id)}"]`,
|
|
);
|
|
if (!node) return;
|
|
stickToBottomRef.current = false;
|
|
node.scrollIntoView({ block: "center", behavior: "smooth" });
|
|
setFlashId(id);
|
|
if (flashTimerRef.current) clearTimeout(flashTimerRef.current);
|
|
flashTimerRef.current = setTimeout(() => setFlashId(null), 1_200);
|
|
}, []);
|
|
|
|
const handleSend = useCallback(() => {
|
|
const body = draft.trim();
|
|
if (!body) return;
|
|
sendChatMessage(body, replyTarget?.id);
|
|
setDraft("");
|
|
setReplyTarget(null);
|
|
stopTyping();
|
|
stickToBottomRef.current = true;
|
|
}, [draft, replyTarget, sendChatMessage, stopTyping]);
|
|
|
|
// Build the rendered stream, inserting a day separator whenever the calendar
|
|
// day changes between consecutive messages.
|
|
const rows = useMemo(() => {
|
|
const out: Array<
|
|
{ kind: "sep"; key: string; label: string } | {
|
|
kind: "msg";
|
|
key: string;
|
|
message: ChatMessage;
|
|
}
|
|
> = [];
|
|
let prev: Date | null = null;
|
|
for (const m of messages) {
|
|
if (!prev || !isSameDay(prev, m.createdAt)) {
|
|
out.push({
|
|
kind: "sep",
|
|
key: `sep-${m.id}`,
|
|
label: dayLabel(m.createdAt),
|
|
});
|
|
}
|
|
out.push({ kind: "msg", key: m.id, message: m });
|
|
prev = m.createdAt;
|
|
}
|
|
return out;
|
|
}, [messages]);
|
|
|
|
const typingLabel = useMemo(() => {
|
|
const names = typingOthers.map((u) => u.username);
|
|
if (names.length === 1) return t`${names[0]} is typing…`;
|
|
if (names.length === 2) return t`${names[0]} and ${names[1]} are typing…`;
|
|
return t`Several people are typing…`;
|
|
}, [typingOthers]);
|
|
|
|
return (
|
|
<Modal title={t`Chat`} onClose={onClose} wide>
|
|
<div className="chat">
|
|
<div className="chat-messages" ref={listRef} onScroll={handleScroll}>
|
|
{hasMore && (
|
|
<div className="chat-load-older">
|
|
<button
|
|
type="button"
|
|
className="btn-secondary"
|
|
onClick={loadOlder}
|
|
disabled={loadingOlder}
|
|
>
|
|
{loadingOlder
|
|
? <Trans>Loading…</Trans>
|
|
: <Trans>Load older messages</Trans>}
|
|
</button>
|
|
</div>
|
|
)}
|
|
{messages.length === 0
|
|
? (
|
|
<p className="chat-empty">
|
|
<Trans>No messages yet. Say hello!</Trans>
|
|
</p>
|
|
)
|
|
: (
|
|
rows.map((row) =>
|
|
row.kind === "sep"
|
|
? (
|
|
<div key={row.key} className="chat-day-sep">
|
|
<span className="chat-day-sep-label">{row.label}</span>
|
|
</div>
|
|
)
|
|
: (
|
|
<ChatMessageItem
|
|
key={row.key}
|
|
message={row.message}
|
|
currentUser={user}
|
|
canModerate={canModerate}
|
|
flashed={flashId === row.message.id}
|
|
onEdit={editChatMessage}
|
|
onDelete={deleteChatMessage}
|
|
onReply={startReply}
|
|
onJumpTo={jumpToMessage}
|
|
/>
|
|
)
|
|
)
|
|
)}
|
|
</div>
|
|
|
|
{typingOthers.length > 0 && (
|
|
<div className="chat-typing" aria-live="polite">
|
|
<span className="chat-typing-avatars">
|
|
{typingOthers.slice(0, 3).map((u) => (
|
|
<span key={u.userId} className="chat-typing-avatar">
|
|
<Avatar
|
|
userId={u.userId}
|
|
username={u.username}
|
|
hasAvatar={u.hasAvatar}
|
|
size={20}
|
|
version={u.avatarVersion}
|
|
/>
|
|
</span>
|
|
))}
|
|
</span>
|
|
<span className="chat-typing-label">{typingLabel}</span>
|
|
<span className="chat-typing-dots" aria-hidden="true">
|
|
<span></span>
|
|
<span></span>
|
|
<span></span>
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{replyTarget && (
|
|
<div className="chat-reply-bar">
|
|
<span className="chat-reply-bar-mark">↩</span>
|
|
<span className="chat-reply-bar-text">
|
|
<Trans>Replying to</Trans>{" "}
|
|
<span className="chat-reply-bar-author">
|
|
{replyTarget.author}
|
|
</span>
|
|
<span className="chat-reply-bar-snippet">{replyTarget.body}</span>
|
|
</span>
|
|
<button
|
|
type="button"
|
|
className="chat-icon-btn chat-reply-bar-cancel"
|
|
onClick={() => setReplyTarget(null)}
|
|
title={t`Cancel reply`}
|
|
aria-label={t`Cancel reply`}
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
<div className="chat-composer">
|
|
<TextEditor
|
|
ref={composerRef}
|
|
value={draft}
|
|
onChange={handleDraftChange}
|
|
placeholder={t`Type a message…`}
|
|
rows={2}
|
|
autoResize
|
|
maxLength={MAX_CHAT_LENGTH}
|
|
className="comment-reply-textarea chat-input"
|
|
onSubmit={handleSend}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter" && !e.shiftKey) {
|
|
e.preventDefault();
|
|
handleSend();
|
|
} else if (e.key === "Escape" && replyTarget) {
|
|
// Cancel the reply first; a second Escape closes the modal.
|
|
e.preventDefault();
|
|
setReplyTarget(null);
|
|
}
|
|
}}
|
|
/>
|
|
<button
|
|
type="button"
|
|
className="btn-primary chat-send-btn"
|
|
onClick={handleSend}
|
|
disabled={!draft.trim()}
|
|
>
|
|
<Trans>Send</Trans>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
);
|
|
}
|