aad0eab716
Both ChatArea and DMChat now detect when you scroll near the top and fetch the next 50 older messages. Scroll position is preserved so you don't lose your place. Works for both server channels and DM conversations.
453 lines
19 KiB
TypeScript
453 lines
19 KiB
TypeScript
import { useEffect, useRef, useState, useMemo, useCallback } 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 { useReadStatesStore } from "../stores/readStates.ts";
|
|
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 { ListView } from "./ListView.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 day = date.getDate().toString().padStart(2, "0");
|
|
const month = (date.getMonth() + 1).toString().padStart(2, "0");
|
|
const year = date.getFullYear();
|
|
const hours = date.getHours().toString().padStart(2, "0");
|
|
const minutes = date.getMinutes().toString().padStart(2, "0");
|
|
return `[${day}.${month}.${year} @ ${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 isLoadingOlder = useMessageStore((s) => s.isLoadingOlder);
|
|
const fetchMessages = useMessageStore((s) => s.fetchMessages);
|
|
const fetchOlderMessages = useMessageStore((s) => s.fetchOlderMessages);
|
|
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 scrollContainerRef = 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]);
|
|
const markRead = useReadStatesStore((s) => s.markRead);
|
|
|
|
useEffect(() => {
|
|
if (activeChannelId) {
|
|
fetchMessages(activeChannelId);
|
|
setError(null);
|
|
}
|
|
}, [activeChannelId, fetchMessages]);
|
|
|
|
useEffect(() => {
|
|
if (!activeChannelId || messages.length === 0 || isLoading) return;
|
|
const lastMsg = messages[messages.length - 1];
|
|
if (lastMsg?.id) {
|
|
markRead(activeChannelId, lastMsg.id);
|
|
}
|
|
}, [activeChannelId, messages.length, isLoading]);
|
|
|
|
const handleScroll = useCallback(() => {
|
|
const el = scrollContainerRef.current;
|
|
if (!el || !activeChannelId || isLoadingOlder) return;
|
|
if (el.scrollTop < 100) {
|
|
const prevHeight = el.scrollHeight;
|
|
fetchOlderMessages(activeChannelId).then(() => {
|
|
// ponytail: maintain scroll position after prepending older messages
|
|
requestAnimationFrame(() => {
|
|
el.scrollTop = el.scrollHeight - prevHeight;
|
|
});
|
|
});
|
|
}
|
|
}, [activeChannelId, isLoadingOlder, fetchOlderMessages]);
|
|
|
|
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 = ``;
|
|
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.type === 'list'
|
|
? `☑ ${activeChannel.name}`
|
|
: `# ${activeChannel.name}`
|
|
: activeChannelId
|
|
? `#${activeChannelId}`
|
|
: "[NO CHANNEL SELECTED]"}
|
|
</span>
|
|
{activeChannelId && activeChannel?.type === 'text' && (
|
|
<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} />
|
|
) : activeChannel && activeChannel.type === 'list' && activeChannelId ? (
|
|
<ListView channelId={activeChannelId} channelName={activeChannel.name} />
|
|
) : (
|
|
<>
|
|
<div className="flex-1 flex flex-col min-w-0">
|
|
<div ref={scrollContainerRef} onScroll={handleScroll} className="flex-1 overflow-y-auto p-3 space-y-1 font-mono text-sm">
|
|
{isLoadingOlder && <p className="text-center text-gb-fg-f text-xs">[loading older messages...]</p>}
|
|
{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)}>
|
|
<{message.author_username}>
|
|
</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>
|
|
);
|
|
}
|