v3: added a chatbox
Some checks failed
Build and Publish Docker Image / build-and-push (push) Has been cancelled

This commit is contained in:
khannurien
2026-06-29 20:25:10 +00:00
parent f171027673
commit 6c4f410d9b
35 changed files with 1800 additions and 152 deletions

View File

@@ -5,6 +5,7 @@ import { Trans } from "@lingui/react/macro";
import { useAuth } from "../hooks/useAuth.ts";
import { useWS } from "../hooks/useWS.ts";
import { NotificationBell } from "./NotificationBell.tsx";
import { ChatButton } from "./ChatButton.tsx";
import { UserMenu } from "./UserMenu.tsx";
import { SearchBar } from "./SearchBar.tsx";
import { SITE_EMOJI, SITE_NAME } from "../hooks/useDocumentTitle.ts";
@@ -91,6 +92,7 @@ export function AppHeader(
<UserMenu user={user} />
</div>
<ChatButton />
<NotificationBell />
<button
type="button"

View File

@@ -0,0 +1,27 @@
import { t } from "@lingui/core/macro";
import { useWS } from "../hooks/useWS.ts";
import { useChat } from "../hooks/useChat.ts";
/** Header chat button with an unread-message badge. Opens the app-wide chat. */
export function ChatButton() {
const { unreadChatCount } = useWS();
const { openChat } = useChat();
return (
<button
type="button"
className="chat-button"
onClick={openChat}
aria-label={unreadChatCount > 0
? t`Chat (${unreadChatCount} unread)`
: t`Chat`}
>
<span className="chat-button-icon">💬</span>
{unreadChatCount > 0 && (
<span className="notification-badge">
{unreadChatCount > 99 ? "99+" : unreadChatCount}
</span>
)}
</button>
);
}

View File

@@ -0,0 +1,74 @@
import { useEffect, useState } from "react";
import { useLocation } from "react-router";
import { t } from "@lingui/core/macro";
import { useAuth } from "../hooks/useAuth.ts";
import { useWS } from "../hooks/useWS.ts";
import { useChat } from "../hooks/useChat.ts";
/**
* Floating chat button shown once the header has scrolled out of view, mirroring
* {@link DumpFab}. Opens the single app-wide chat modal (owned by ChatProvider).
* Only rendered for signed-in users.
*/
export function ChatFab() {
const { user } = useAuth();
const { unreadChatCount } = useWS();
const { openChat } = useChat();
const location = useLocation();
const [headerHidden, setHeaderHidden] = useState(false);
// Reveal the button once the header's bottom edge scrolls above the viewport.
// Re-reading the header on every tick keeps this correct across navigation
// and lazily-mounted pages, where the header element is replaced.
useEffect(() => {
if (!user) return;
const update = () => {
const header = document.querySelector(".app-header");
setHeaderHidden(!!header && header.getBoundingClientRect().bottom <= 0);
};
update();
globalThis.addEventListener("scroll", update, { passive: true });
globalThis.addEventListener("resize", update);
const ro = new ResizeObserver(update);
ro.observe(document.body);
return () => {
globalThis.removeEventListener("scroll", update);
globalThis.removeEventListener("resize", update);
ro.disconnect();
};
}, [user, location.pathname]);
if (!user) return null;
return (
<button
type="button"
className={`chat-fab${headerHidden ? " chat-fab--visible" : ""}`}
aria-label={unreadChatCount > 0
? t`Chat (${unreadChatCount} unread)`
: t`Chat`}
title={t`Chat`}
aria-hidden={!headerHidden}
tabIndex={headerHidden ? 0 : -1}
onClick={openChat}
>
<svg
className="chat-fab-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M21 11.5a8.38 8.38 0 0 1-8.5 8.5 8.5 8.5 0 0 1-3.8-.9L3 21l1.9-5.7a8.5 8.5 0 0 1-.9-3.8A8.38 8.38 0 0 1 12.5 3a8.38 8.38 0 0 1 8.5 8.5z" />
</svg>
{unreadChatCount > 0 && (
<span className="notification-badge">
{unreadChatCount > 99 ? "99+" : unreadChatCount}
</span>
)}
</button>
);
}

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