v3: chatbox improvements (typing indicators, answer to previous messages, backlog days separators, scroll to last message position, visual tweaks)
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 41s
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 41s
This commit is contained in:
@@ -1,4 +1,11 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
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";
|
||||
@@ -12,6 +19,7 @@ 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,
|
||||
@@ -24,17 +32,40 @@ import {
|
||||
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, onEdit, onDelete }: ChatMessageItemProps,
|
||||
{
|
||||
message,
|
||||
currentUser,
|
||||
canModerate,
|
||||
flashed,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onReply,
|
||||
onJumpTo,
|
||||
}: ChatMessageItemProps,
|
||||
) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [editDraft, setEditDraft] = useState(message.body);
|
||||
@@ -61,7 +92,10 @@ function ChatMessageItem(
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="chat-message">
|
||||
<div
|
||||
className={`chat-message${flashed ? " chat-message--flash" : ""}`}
|
||||
data-mid={message.id}
|
||||
>
|
||||
<Avatar
|
||||
userId={message.userId}
|
||||
username={message.authorUsername}
|
||||
@@ -69,6 +103,34 @@ function ChatMessageItem(
|
||||
size={32}
|
||||
/>
|
||||
<div className="chat-message-body">
|
||||
{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}`}
|
||||
@@ -91,8 +153,17 @@ function ChatMessageItem(
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!editing && (canEdit || canDelete) && (
|
||||
{!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"
|
||||
@@ -178,14 +249,38 @@ function ChatMessageItem(
|
||||
);
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -195,12 +290,23 @@ export function ChatModal({ onClose }: { onClose: () => void }) {
|
||||
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(() => {
|
||||
@@ -218,6 +324,12 @@ export function ChatModal({ onClose }: { onClose: () => void }) {
|
||||
.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;
|
||||
@@ -250,12 +362,36 @@ export function ChatModal({ onClose }: { onClose: () => void }) {
|
||||
return () => setChatOpen(false);
|
||||
}, [setChatOpen]);
|
||||
|
||||
// Auto-scroll to the bottom when pinned (initial load + new messages).
|
||||
// Stop announcing "typing" when the chatbox closes, whatever state we're in.
|
||||
useEffect(() => {
|
||||
if (!stickToBottomRef.current) return;
|
||||
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) el.scrollTop = el.scrollHeight;
|
||||
}, [messages]);
|
||||
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;
|
||||
});
|
||||
}, [messages, typingOthers.length]);
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
const el = listRef.current;
|
||||
@@ -296,13 +432,102 @@ export function ChatModal({ onClose }: { onClose: () => void }) {
|
||||
}
|
||||
}, [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);
|
||||
sendChatMessage(body, replyTarget?.id);
|
||||
setDraft("");
|
||||
setReplyTarget(null);
|
||||
stopTyping();
|
||||
stickToBottomRef.current = true;
|
||||
}, [draft, sendChatMessage]);
|
||||
}, [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>
|
||||
@@ -329,24 +554,81 @@ export function ChatModal({ onClose }: { onClose: () => void }) {
|
||||
</p>
|
||||
)
|
||||
: (
|
||||
messages.map((m) => (
|
||||
<ChatMessageItem
|
||||
key={m.id}
|
||||
message={m}
|
||||
currentUser={user}
|
||||
canModerate={canModerate}
|
||||
onEdit={editChatMessage}
|
||||
onDelete={deleteChatMessage}
|
||||
/>
|
||||
))
|
||||
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={setDraft}
|
||||
onChange={handleDraftChange}
|
||||
placeholder={t`Type a message…`}
|
||||
rows={2}
|
||||
autoResize
|
||||
@@ -357,6 +639,10 @@ export function ChatModal({ onClose }: { onClose: () => void }) {
|
||||
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);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user