v3: added a chatbox
Some checks failed
Build and Publish Docker Image / build-and-push (push) Has been cancelled
Some checks failed
Build and Publish Docker Image / build-and-push (push) Has been cancelled
This commit is contained in:
375
src/components/ChatModal.tsx
Normal file
375
src/components/ChatModal.tsx
Normal file
@@ -0,0 +1,375 @@
|
||||
import { useCallback, useEffect, 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 { useWS } from "../hooks/useWS.ts";
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
import { can } from "../utils/permissions.ts";
|
||||
import { relativeTime } from "../utils/relativeTime.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;
|
||||
|
||||
interface ChatMessageItemProps {
|
||||
message: ChatMessage;
|
||||
currentUser: PublicUser | null;
|
||||
canModerate: boolean;
|
||||
onEdit: (id: string, body: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}
|
||||
|
||||
function ChatMessageItem(
|
||||
{ message, currentUser, canModerate, onEdit, onDelete }: ChatMessageItemProps,
|
||||
) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
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">
|
||||
<Avatar
|
||||
userId={message.userId}
|
||||
username={message.authorUsername}
|
||||
hasAvatar={!!message.authorAvatarMime}
|
||||
size={32}
|
||||
/>
|
||||
<div className="chat-message-body">
|
||||
<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 && (canEdit || canDelete) && (
|
||||
<span className="chat-message-actions">
|
||||
{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">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChatModal({ onClose }: { onClose: () => void }) {
|
||||
const {
|
||||
chatMessages,
|
||||
deletedChatIds,
|
||||
sendChatMessage,
|
||||
editChatMessage,
|
||||
deleteChatMessage,
|
||||
clearUnreadChat,
|
||||
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 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);
|
||||
|
||||
// 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]);
|
||||
|
||||
// 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]);
|
||||
|
||||
// Auto-scroll to the bottom when pinned (initial load + new messages).
|
||||
useEffect(() => {
|
||||
if (!stickToBottomRef.current) return;
|
||||
const el = listRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
}, [messages]);
|
||||
|
||||
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]);
|
||||
|
||||
const handleSend = useCallback(() => {
|
||||
const body = draft.trim();
|
||||
if (!body) return;
|
||||
sendChatMessage(body);
|
||||
setDraft("");
|
||||
stickToBottomRef.current = true;
|
||||
}, [draft, sendChatMessage]);
|
||||
|
||||
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>
|
||||
)
|
||||
: (
|
||||
messages.map((m) => (
|
||||
<ChatMessageItem
|
||||
key={m.id}
|
||||
message={m}
|
||||
currentUser={user}
|
||||
canModerate={canModerate}
|
||||
onEdit={editChatMessage}
|
||||
onDelete={deleteChatMessage}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="chat-composer">
|
||||
<TextEditor
|
||||
ref={composerRef}
|
||||
value={draft}
|
||||
onChange={setDraft}
|
||||
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();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary chat-send-btn"
|
||||
onClick={handleSend}
|
||||
disabled={!draft.trim()}
|
||||
>
|
||||
<Trans>Send</Trans>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user