Files
dumpsterChat/web/src/components/ChatArea.tsx
T

417 lines
17 KiB
TypeScript

import { useEffect, useRef, useState, useMemo } from "react";
import { useMessageStore } from "../stores/message.ts";
import { useChannelStore } from "../stores/channel.ts";
import { useServerStore } from "../stores/server.ts";
import { useMemberStore } from "../stores/member.ts";
import { useThreadStore } from "../stores/thread.ts";
import { GiphyPicker, type Gif } from "./GiphyPicker";
import { MentionDropdown } from "./MentionDropdown";
import { MessageSearch } from "./MessageSearch";
import { ThreadListPanel } from "./ThreadListPanel.tsx";
import { ThreadPanel } from "./ThreadPanel.tsx";
import { ForumView } from "./ForumView.tsx";
import { CalendarView } from "./CalendarView.tsx";
import { DocsView } from "./DocsView.tsx";
import { UserProfileModal } from "./UserProfileModal.tsx";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
function formatTime(iso: string): string {
const date = new Date(iso);
const hours = date.getHours().toString().padStart(2, "0");
const minutes = date.getMinutes().toString().padStart(2, "0");
return `${hours}:${minutes}`;
}
function renderContent(content: string, memberUsernames: Set<string>) {
const segments: { type: "text" | "mention"; value: string }[] = [];
const mentionRe = /@([a-zA-Z0-9_.-]+)/g;
let last = 0;
let match: RegExpExecArray | null;
while ((match = mentionRe.exec(content)) !== null) {
if (match.index > last) {
segments.push({ type: "text", value: content.slice(last, match.index) });
}
const username = match[1];
if (memberUsernames.has(username)) {
segments.push({ type: "mention", value: username });
} else {
segments.push({ type: "text", value: match[0] });
}
last = mentionRe.lastIndex;
}
if (last < content.length) {
segments.push({ type: "text", value: content.slice(last) });
}
return segments.map((seg, idx) => {
if (seg.type === "mention") {
return (
<span key={idx} className="text-gb-aqua">
@{seg.value}
</span>
);
}
return (
<ReactMarkdown
key={idx}
remarkPlugins={[remarkGfm]}
components={{
a: ({ ...props }) => <a {...props} className="text-gb-aqua hover:underline" target="_blank" rel="noreferrer" />,
code: ({ ...props }) => <code {...props} className="bg-gb-bg-t px-1 rounded text-gb-fg-s" />,
pre: ({ ...props }) => <pre {...props} className="bg-gb-bg-s p-2 my-1 overflow-x-auto text-gb-fg-s" />,
blockquote: ({ ...props }) => <blockquote {...props} className="border-l-2 border-gb-orange pl-2 my-1 text-gb-fg-s" />,
table: ({ ...props }) => <table {...props} className="border-collapse border border-gb-bg-t my-1" />,
th: ({ ...props }) => <th {...props} className="border border-gb-bg-t px-2 py-1 bg-gb-bg-s" />,
td: ({ ...props }) => <td {...props} className="border border-gb-bg-t px-2 py-1" />,
p: ({ ...props }) => <span {...props} className="inline" />,
}}
>
{seg.value}
</ReactMarkdown>
);
});
}
function renderEmbeds(embeds?: { url: string; title?: string; description?: string; image_url?: string; site_name?: string }[]) {
if (!embeds || embeds.length === 0) return null;
return (
<div className="mt-1 space-y-1">
{embeds.map((embed) => (
<a
key={embed.url}
href={embed.url}
target="_blank"
rel="noreferrer"
className="block bg-gb-bg-s border border-gb-bg-t p-2 hover:border-gb-fg-t transition-colors"
>
{embed.site_name && <div className="text-gb-fg-f text-[10px] uppercase">{embed.site_name}</div>}
{embed.title && <div className="text-gb-fg text-xs font-bold truncate">{embed.title}</div>}
{embed.description && <div className="text-gb-fg-s text-xs line-clamp-2">{embed.description}</div>}
{embed.image_url && (
<img
src={embed.image_url}
alt=""
className="mt-1 max-h-24 object-cover border border-gb-bg-t"
loading="lazy"
/>
)}
</a>
))}
</div>
);
}
export function ChatArea() {
const activeChannelId = useChannelStore((s) => s.activeChannelId);
const channelsByServer = useChannelStore((s) => s.channelsByServer);
const activeServerId = useServerStore((s) => s.activeServerId);
const messages = useMessageStore((s) =>
activeChannelId ? s.messagesByChannel[activeChannelId] || [] : [],
);
const isLoading = useMessageStore((s) => s.isLoading);
const fetchMessages = useMessageStore((s) => s.fetchMessages);
const sendMessage = useMessageStore((s) => s.sendMessage);
const membersByServer = useMemberStore((s) => s.membersByServer);
const threads = useThreadStore((s) => activeChannelId ? s.threadsByParent[activeChannelId] || [] : []);
const [input, setInput] = useState("");
const [error, setError] = useState<string | null>(null);
const [showGifPicker, setShowGifPicker] = useState(false);
const [showSearch, setShowSearch] = useState(false);
const [mentionQuery, setMentionQuery] = useState<string | null>(null);
const [slowmodeRemaining, setSlowmodeRemaining] = useState(0);
const [selectMode, setSelectMode] = useState(false);
const [showThreads, setShowThreads] = useState(false);
const [activeThread, setActiveThread] = useState<{ id: string; name: string } | null>(null);
const [profileUserId, setProfileUserId] = useState<string | null>(null);
const bottomRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const toggleSelectedMessage = useMessageStore((s) => s.toggleSelectedMessage);
const clearSelectedMessages = useMessageStore((s) => s.clearSelectedMessages);
const bulkDeleteMessages = useMessageStore((s) => s.bulkDeleteMessages);
const selectedIds = useMessageStore((s) =>
activeChannelId ? s.selectedMessageIds[activeChannelId] || new Set<string>() : new Set<string>(),
);
const canBulkDelete = true;
const channels = activeServerId ? channelsByServer[activeServerId] || [] : [];
const activeChannel = channels.find((c) => c.id === activeChannelId);
const members = activeServerId ? membersByServer[activeServerId] || [] : [];
const memberUsernames = useMemo(() => new Set(members.map((m) => m.username)), [members]);
useEffect(() => {
if (activeChannelId) {
fetchMessages(activeChannelId);
setError(null);
}
}, [activeChannelId, fetchMessages]);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "auto" });
}, [messages]);
useEffect(() => {
if (slowmodeRemaining <= 0) return;
const t = setInterval(() => {
setSlowmodeRemaining((r) => {
if (r <= 1) {
clearInterval(t);
return 0;
}
return r - 1;
});
}, 1000);
return () => clearInterval(t);
}, [slowmodeRemaining]);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
const cursor = e.target.selectionStart ?? value.length;
setInput(value);
const beforeCursor = value.slice(0, cursor);
const atIndex = beforeCursor.lastIndexOf("@");
if (atIndex === -1) {
setMentionQuery(null);
return;
}
const between = beforeCursor.slice(atIndex + 1);
if (between.includes(" ") || between.includes("\n")) {
setMentionQuery(null);
return;
}
setMentionQuery(between);
};
const handleMentionSelect = (username: string) => {
const inputEl = inputRef.current;
if (!inputEl) {
setInput((prev) => `${prev}${username} `);
setMentionQuery(null);
return;
}
const cursor = inputEl.selectionStart ?? input.length;
const beforeCursor = input.slice(0, cursor);
const atIndex = beforeCursor.lastIndexOf("@");
if (atIndex === -1) return;
const before = input.slice(0, atIndex);
const after = input.slice(cursor);
const next = `${before}@${username} ${after}`;
setInput(next);
setMentionQuery(null);
requestAnimationFrame(() => {
const pos = atIndex + username.length + 2;
inputEl.focus();
inputEl.setSelectionRange(pos, pos);
});
};
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
if (!activeChannelId || !input.trim() || slowmodeRemaining > 0) return;
setError(null);
try {
await sendMessage(activeChannelId, input.trim());
setInput("");
setMentionQuery(null);
} catch (err) {
const msg = err instanceof Error ? err.message : "Failed to send";
try {
const parsed = JSON.parse(msg);
if (parsed.error === "slowmode" && typeof parsed.retry_after === "number") {
setSlowmodeRemaining(parsed.retry_after);
return;
}
} catch {
// not json
}
setError(msg);
}
};
const handleGifSelect = async (gif: Gif) => {
if (!activeChannelId) return;
const content = `![${gif.title || "GIF"}](${gif.url})`;
try {
await sendMessage(activeChannelId, content);
setShowGifPicker(false);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to send");
}
};
return (
<div className="flex flex-col h-full bg-gb-bg">
<div className="terminal-border border-t-0 border-x-0 px-3 py-2 text-gb-fg-s flex items-center justify-between">
<span>
{activeChannel
? activeChannel.type === 'forum'
? `${activeChannel.name}`
: activeChannel.type === 'calendar'
? `${activeChannel.name}`
: activeChannel.type === 'docs'
? `${activeChannel.name}`
: `# ${activeChannel.name}`
: activeChannelId
? `#${activeChannelId}`
: "[NO CHANNEL SELECTED]"}
</span>
{activeChannelId && activeChannel?.type !== 'forum' && (
<div className="flex items-center gap-2">
<button
onClick={() => setShowThreads((prev) => !prev)}
className={`text-xs font-mono ${showThreads ? 'text-gb-orange' : 'text-gb-fg-f hover:text-gb-orange'}`}
title="Toggle threads panel"
>
[THREADS{threads.length > 0 ? `:${threads.length}` : ''}]
</button>
<button
onClick={() => setSelectMode((prev) => !prev)}
className={`text-xs font-mono ${selectMode ? 'text-gb-orange' : 'text-gb-fg-f hover:text-gb-orange'}`}
title="Toggle bulk delete selection"
>
[SELECT]
</button>
<button
onClick={() => setShowSearch((prev) => !prev)}
className="text-xs text-gb-fg-f hover:text-gb-orange font-mono"
title="Search messages"
>
[SEARCH]
</button>
</div>
)}
</div>
{showSearch && activeChannelId && activeChannel && (
<MessageSearch
channelId={activeChannelId}
channelName={activeChannel.name}
onClose={() => setShowSearch(false)}
/>
)}
{showThreads && activeChannelId && activeChannel?.type !== 'forum' && (
<ThreadListPanel
channelId={activeChannelId}
onSelect={(t: { id: string; name: string }) => setActiveThread(t)}
onClose={() => setShowThreads(false)}
/>
)}
{selectMode && activeChannelId && activeChannel?.type !== 'forum' && (
<div className="px-3 py-1 text-xs font-mono text-gb-fg-f flex items-center justify-between bg-gb-bg-s border-b border-gb-bg-t">
<span>{selectedIds.size} selected</span>
<div className="flex items-center gap-2">
<button
onClick={() => clearSelectedMessages(activeChannelId)}
className="text-gb-fg-f hover:text-gb-orange"
>
[CLEAR]
</button>
<button
onClick={async () => {
if (!activeChannelId || selectedIds.size === 0) return;
if (!confirm(`Delete ${selectedIds.size} messages?`)) return;
try {
await bulkDeleteMessages(activeChannelId, Array.from(selectedIds));
} catch (err) {
setError(err instanceof Error ? err.message : 'Bulk delete failed');
}
}}
disabled={selectedIds.size === 0 || !canBulkDelete}
className="text-gb-red hover:text-gb-orange disabled:opacity-50"
>
[DELETE]
</button>
</div>
</div>
)}
<div className="flex flex-1 min-h-0">
{activeChannel && activeChannel.type === 'forum' && activeChannelId ? (
<ForumView channelId={activeChannelId} channelName={activeChannel.name} onOpenThread={(t) => setActiveThread(t)} />
) : activeChannel && activeChannel.type === 'calendar' && activeChannelId ? (
<CalendarView channelId={activeChannelId} channelName={activeChannel.name} />
) : activeChannel && activeChannel.type === 'docs' && activeChannelId ? (
<DocsView channelId={activeChannelId} channelName={activeChannel.name} />
) : (
<>
<div className="flex-1 flex flex-col min-w-0">
<div className="flex-1 overflow-y-auto p-3 space-y-1 font-mono text-sm">
{isLoading && <p className="text-gb-fg-f">[loading...]</p>}
{!isLoading && messages.length === 0 && (
<p className="text-gb-fg-f">[no messages in this channel]</p>
)}
{messages.map((message) => (
<div
key={message.id}
onClick={() => selectMode && activeChannelId && toggleSelectedMessage(activeChannelId, message.id)}
className={`break-words ${selectMode ? 'cursor-pointer hover:bg-gb-bg-s' : ''} ${selectedIds.has(message.id) ? 'bg-gb-bg-s' : ''}`}
>
{selectMode && activeChannelId && (
<span className="text-gb-fg-f mr-2">
{selectedIds.has(message.id) ? '[x]' : '[ ]'}
</span>
)}
<span className="text-gb-fg-f">[{formatTime(message.created_at)}]</span>{" "}
<span className="text-gb-aqua hover:text-gb-orange cursor-pointer" onClick={() => setProfileUserId(message.author_id)}>
&lt;{message.author_username}&gt;
</span>{" "}
<span className="text-gb-fg">{renderContent(message.content, memberUsernames)}</span>
{renderEmbeds(message.embeds)}
</div>
))}
<div ref={bottomRef} />
</div>
{showGifPicker && (
<div className="px-3 pb-1">
<GiphyPicker onSelect={handleGifSelect} onClose={() => setShowGifPicker(false)} />
</div>
)}
{error && (
<div className="px-3 pb-1">
<p className="text-gb-red text-xs font-mono">ERR: {error}</p>
</div>
)}
{slowmodeRemaining > 0 && (
<div className="px-3 pt-1 text-xs text-gb-fg-f font-mono">
SLOWMODE: wait {slowmodeRemaining}s
</div>
)}
<form onSubmit={handleSubmit} className="p-3 flex items-center gap-2 relative">
<span className="text-gb-fg-f select-none shrink-0">{">"}</span>
<div className="flex-1 min-w-0 relative">
<input
ref={inputRef}
type="text"
value={input}
onChange={handleInputChange}
placeholder="type a message..."
className="terminal-input w-full"
disabled={!activeChannelId || slowmodeRemaining > 0}
/>
{mentionQuery !== null && (
<MentionDropdown query={mentionQuery} members={members} onSelect={handleMentionSelect} />
)}
</div>
<button
type="button"
onClick={() => setShowGifPicker((prev) => !prev)}
disabled={!activeChannelId}
className="text-gb-fg-f hover:text-gb-orange font-mono text-sm select-none disabled:opacity-50 disabled:cursor-not-allowed shrink-0"
title="Toggle GIF picker"
>
[GIF]
</button>
</form>
</div>
{activeThread && (
<ThreadPanel threadId={activeThread.id} threadName={activeThread.name} onClose={() => setActiveThread(null)} />
)}
</>
)}
</div>
{profileUserId && (
<UserProfileModal userId={profileUserId} onClose={() => setProfileUserId(null)} />
)}
</div>
);
}