feat(phase3): threads backend + thread panel UI
This commit is contained in:
+119
-115
@@ -3,9 +3,12 @@ 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 ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
|
||||
@@ -16,9 +19,7 @@ function formatTime(iso: string): string {
|
||||
return `${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
|
||||
function renderContent(content: string, memberUsernames: Set<string>) {
|
||||
// Split on @username, preserving mentions; render plain text segments as markdown.
|
||||
const segments: { type: "text" | "mention"; value: string }[] = [];
|
||||
const mentionRe = /@([a-zA-Z0-9_.-]+)/g;
|
||||
let last = 0;
|
||||
@@ -108,6 +109,7 @@ export function ChatArea() {
|
||||
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);
|
||||
@@ -115,6 +117,8 @@ export function ChatArea() {
|
||||
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 bottomRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -124,17 +128,12 @@ export function ChatArea() {
|
||||
const selectedIds = useMessageStore((s) =>
|
||||
activeChannelId ? s.selectedMessageIds[activeChannelId] || new Set<string>() : new Set<string>(),
|
||||
);
|
||||
const canBulkDelete = true; // ponytail: permission enforced server-side; server-side is the source of truth
|
||||
const canBulkDelete = true;
|
||||
|
||||
const channels = activeServerId
|
||||
? channelsByServer[activeServerId] || []
|
||||
: [];
|
||||
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],
|
||||
);
|
||||
const memberUsernames = useMemo(() => new Set(members.map((m) => m.username)), [members]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeChannelId) {
|
||||
@@ -165,8 +164,6 @@ export function ChatArea() {
|
||||
const value = e.target.value;
|
||||
const cursor = e.target.selectionStart ?? value.length;
|
||||
setInput(value);
|
||||
|
||||
// Detect an unfinished mention at cursor position.
|
||||
const beforeCursor = value.slice(0, cursor);
|
||||
const atIndex = beforeCursor.lastIndexOf("@");
|
||||
if (atIndex === -1) {
|
||||
@@ -198,7 +195,7 @@ export function ChatArea() {
|
||||
setInput(next);
|
||||
setMentionQuery(null);
|
||||
requestAnimationFrame(() => {
|
||||
const pos = atIndex + username.length + 2; // @username + space
|
||||
const pos = atIndex + username.length + 2;
|
||||
inputEl.focus();
|
||||
inputEl.setSelectionRange(pos, pos);
|
||||
});
|
||||
@@ -214,7 +211,6 @@ export function ChatArea() {
|
||||
setMentionQuery(null);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Failed to send";
|
||||
// Parse slowmode error from API.
|
||||
try {
|
||||
const parsed = JSON.parse(msg);
|
||||
if (parsed.error === "slowmode" && typeof parsed.retry_after === "number") {
|
||||
@@ -251,6 +247,13 @@ export function ChatArea() {
|
||||
</span>
|
||||
{activeChannelId && (
|
||||
<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'}`}
|
||||
@@ -275,112 +278,113 @@ export function ChatArea() {
|
||||
onClose={() => setShowSearch(false)}
|
||||
/>
|
||||
)}
|
||||
{selectMode && activeChannelId && (
|
||||
<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>
|
||||
{showThreads && activeChannelId && (
|
||||
<ThreadListPanel
|
||||
channelId={activeChannelId}
|
||||
onSelect={(t) => setActiveThread(t)}
|
||||
onClose={() => setShowThreads(false)}
|
||||
/>
|
||||
)}
|
||||
{selectMode && activeChannelId && (
|
||||
<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 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>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-1 min-h-0">
|
||||
<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>
|
||||
)}
|
||||
<span className="text-gb-fg-f">
|
||||
[{formatTime(message.created_at)}]
|
||||
</span>{" "}
|
||||
<span className="text-gb-aqua">
|
||||
<{message.author_username}>
|
||||
</span>{" "}
|
||||
<span className="text-gb-fg">
|
||||
{renderContent(message.content, memberUsernames)}
|
||||
</span>
|
||||
{renderEmbeds(message.embeds)}
|
||||
{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"><{message.author_username}></span>{" "}
|
||||
<span className="text-gb-fg">{renderContent(message.content, memberUsernames)}</span>
|
||||
{renderEmbeds(message.embeds)}
|
||||
</div>
|
||||
))}
|
||||
<div ref={bottomRef} />
|
||||
</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}
|
||||
/>
|
||||
{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>
|
||||
<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>
|
||||
{activeThread && (
|
||||
<ThreadPanel threadId={activeThread.id} threadName={activeThread.name} onClose={() => setActiveThread(null)} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user