Files
dumpsterChat/web/src/components/ChatArea.tsx
T
hobokenchicken cca6ea0e37 fix: stop infinite scroll feedback when no more messages
Scroll handler now checks !hasMore to avoid calling fetchOlderMessages
when all messages are loaded. Previously the handler would fire on every
scroll event (since scrollTop stayed < 100), creating a .then() callback
loop that adjusted scroll position repeatedly, causing 'stuck' scrolling.
2026-07-21 09:05:39 -04:00

988 lines
40 KiB
TypeScript

import { useEffect, useRef, useState, useMemo, useCallback, memo } from "react";
import { useMessageStore } from "../stores/message.ts";
import { api } from "../lib/api.ts";
import { useChannelStore } from "../stores/channel.ts";
import { useServerStore } from "../stores/server.ts";
import { useTypingStore } from "../stores/typing.ts";
import { useAuthStore } from "../stores/auth.ts";
import { useMemberStore } from "../stores/member.ts";
import { useThreadStore } from "../stores/thread.ts";
import { type Gif } from "./GiphyPicker";
import { EmojiPicker as KaomojiPicker } from "./EmojiPicker";
import Picker, { Theme } from 'emoji-picker-react';
import { CommandDropdown } from "./CommandDropdown";
import { findCommand, SLASH_COMMANDS } from "../lib/slashCommands";
import { PollDisplay, CreatePollModal } from "./Poll.tsx";
import { MentionDropdown, buildMentionOptions } from "./MentionDropdown";
import { usePermissions } from "../lib/usePermissions.ts";
import { useReadStatesStore } from "../stores/readStates.ts";
import { MessageSearch } from "./MessageSearch";
import { ThreadListPanel } from "./ThreadListPanel.tsx";
import { ThreadPanel } from "./ThreadPanel.tsx";
import { useContextMenu } from "./ContextMenu.tsx";
import { PinnedMessages } from "./PinnedMessages.tsx";
import { FeatureRequestsPanel } from "./FeatureRequestsPanel.tsx";
import { ReactionBar } from "./ReactionBar.tsx";
import { MessageInput } from "./MessageInput.tsx";
import { ReplyBar } from "./ReplyBar.tsx";
import { ExpandableImage } from "./ExpandableImage.tsx";
import { useLayoutStore } from "../stores/layout.ts";
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; special?: boolean }[] = [];
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];
const lower = username.toLowerCase();
if (lower === "everyone" || lower === "channel" || lower === "here" || memberUsernames.has(username)) {
segments.push({
type: "mention",
value: username,
special: lower === "everyone" || lower === "channel" || lower === "here",
});
} 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") {
// ponytail: add trailing space because React collapses whitespace between sibling spans
const nextSeg = segments[idx + 1];
const needsSpace = !nextSeg || (nextSeg.type === "text" && !nextSeg.value.startsWith(" "));
return (
<span
key={idx}
className={seg.special ? "text-gb-orange font-bold bg-gb-orange/15 px-0.5 rounded-sm" : "text-gb-aqua"}
>
@{seg.value}{needsSpace ? " " : ""}
</span>
);
}
// For plain text segments, render through ReactMarkdown but preserve
// leading/trailing spaces that ReactMarkdown would otherwise trim
const leadingSpace = seg.value.match(/^\s+/)?.[0] || "";
const trailingSpace = seg.value.match(/\s+$/)?.[0] || "";
const trimmed = seg.value.slice(leadingSpace.length, seg.value.length - trailingSpace.length);
return (
<span key={idx}>
{leadingSpace}
{trimmed ? (
<ReactMarkdown
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" />,
h1: ({ ...props }) => <h1 {...props} className="text-lg font-bold my-1" />,
h2: ({ ...props }) => <h2 {...props} className="text-base font-bold my-1" />,
h3: ({ ...props }) => <h3 {...props} className="text-sm font-bold my-0.5" />,
p: ({ ...props }) => <span {...props} className="inline" />,
img: ({ src, alt, ...props }) => (
<ExpandableImage
src={src}
alt={alt}
{...props}
/>
),
}}
>
{trimmed}
</ReactMarkdown>
) : null}
{trailingSpace}
</span>
);
});
}
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>
);
}
const MessageItem = memo(({
message,
memberUsernames,
selectMode,
isSelected,
onAuthorClick,
onToggleSelect,
onContextMenu,
onAddReaction,
currentUserId,
activeReactionMessageId,
setActiveReactionMessageId,
activeNativeReactionMessageId,
setActiveNativeReactionMessageId,
members,
parentMessage,
onJumpToParent,
}: {
message: any;
memberUsernames: Set<string>;
selectMode: boolean;
isSelected: boolean;
onAuthorClick: (authorId: string) => void;
onToggleSelect: (messageId: string) => void;
onContextMenu: (e: React.MouseEvent, message: any) => void;
onAddReaction: (messageId: string, emoji: string) => void;
currentUserId?: string;
activeReactionMessageId: string | null;
setActiveReactionMessageId: (id: string | null) => void;
activeNativeReactionMessageId: string | null;
setActiveNativeReactionMessageId: (id: string | null) => void;
members: any[];
parentMessage: any | null;
onJumpToParent: (id: string) => void;
}) => {
return (
<div
id={`msg-${message.id}`}
onClick={() => selectMode && onToggleSelect(message.id)}
onContextMenu={(e) => onContextMenu(e, message)}
className={`break-words ${selectMode ? 'cursor-pointer hover:bg-gb-bg-s' : ''} ${isSelected ? 'bg-gb-bg-s' : ''} relative`}
>
{parentMessage && (
<div
onClick={(e) => {
e.stopPropagation();
onJumpToParent(parentMessage.id);
}}
className="cursor-pointer hover:opacity-80 mb-0.5"
>
<ReplyBar
replyTo={{
id: parentMessage.id,
content: parentMessage.content,
author: {
username: parentMessage.author_username,
}
}}
compact
/>
</div>
)}
{selectMode && (
<span className="text-gb-fg-f mr-2">
{isSelected ? '[x]' : '[ ]'}
</span>
)}
<span className="text-gb-fg-f">{formatTime(message.created_at)}</span>{' '}
{message.pinned && <span className="text-gb-orange font-bold mr-1">[PIN]</span>}
<span className={`${message.author_bot ? 'text-gb-green' : 'text-gb-aqua'} hover:text-gb-orange cursor-pointer`} onClick={(e) => {
e.stopPropagation();
if (!message.author_bot) onAuthorClick(message.author_id);
}}>
&lt;{message.author_bot ? (message.bot_name || message.author_username) : (members.find((m) => m.id === message.author_id)?.nickname || message.author_username)}&gt;
</span>
{message.author_bot && <span className="text-gb-bg bg-gb-green px-0.5 font-mono text-[10px] ml-0.5 align-middle">BOT</span>}
{" "}
<span className="text-gb-fg">{renderContent(message.content, memberUsernames)}</span>
{renderEmbeds(message.embeds)}
{message.poll && <PollDisplay poll={message.poll} channelId={message.channel_id} />}
{/* Message reactions */}
<ReactionBar
reactions={(message.reactions || []).map((r: any) => {
const usernames = r.users.map((uid: string) => {
const m = members.find((member: any) => member.id === uid);
return m ? (m.display_name || m.username) : "unknown user";
});
return {
emoji: r.emoji,
count: r.count,
users: usernames,
reacted: r.users.includes(currentUserId),
};
})}
onToggle={async (emoji, isReacted) => {
if (isReacted) {
await api.delete(`/messages/${message.id}/reactions/${encodeURIComponent(emoji)}`);
} else {
await api.post(`/messages/${message.id}/reactions`, { emoji });
}
}}
/>
{/* Kaomoji picker popover */}
{activeReactionMessageId === message.id && (
<KaomojiPicker
onSelect={(emoji) => onAddReaction(message.id, emoji)}
onClose={() => setActiveReactionMessageId(null)}
/>
)}
{activeNativeReactionMessageId === message.id && (
<div className="absolute z-50">
<Picker
theme={Theme.DARK}
onEmojiClick={(emoji) => {
onAddReaction(message.id, emoji.emoji);
setActiveNativeReactionMessageId(null);
}}
/>
</div>
)}
</div>
);
});
MessageItem.displayName = "MessageItem";
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 hasMore = useMessageStore((s) => activeChannelId ? s.hasMoreByChannel[activeChannelId] !== false : true);
const fetchMessages = useMessageStore((s) => s.fetchMessages);
const fetchOlderMessages = useMessageStore((s) => s.fetchOlderMessages);
const sendMessage = useMessageStore((s) => s.sendMessage);
const typingUsers = useTypingStore((s) => s.typingUsers);
const sendTypingStart = useTypingStore((s) => s.sendTypingStart);
const currentUser = useAuthStore((s) => s.user);
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 [showSearch, setShowSearch] = useState(false);
const [mentionQuery, setMentionQuery] = useState<string | null>(null);
const [commandQuery, setCommandQuery] = useState<string | null>(null);
const [dropdownIndex, setDropdownIndex] = useState(0);
const [showPollModal, setShowPollModal] = useState(false);
// Reset dropdown index when query changes
useEffect(() => { setDropdownIndex(0); }, [mentionQuery, commandQuery]);
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<HTMLTextAreaElement>(null);
const lastTypingRef = useRef<number>(0);
const dropdownRef = useRef({ mentionQuery: null as string | null, commandQuery: null as string | null, dropdownIndex: 0 });
// ponytail: keep ref in sync with state so the stable keydown listener reads current values
dropdownRef.current = { mentionQuery, commandQuery, dropdownIndex };
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] || [] : [];
// Humans only for mentions / nickname lookup (bots live in member list separately).
const humanMembers = useMemo(() => members.filter((m) => !m.is_bot), [members]);
const memberUsernames = useMemo(() => new Set(humanMembers.map((m) => m.username)), [humanMembers]);
const { canMentionEveryone } = usePermissions(activeServerId);
const markRead = useReadStatesStore((s) => s.markRead);
const readStates = useReadStatesStore((s) => s.states);
const [showPinned, setShowPinned] = useState(false);
const [showFeatureRequests, setShowFeatureRequests] = useState(false);
const [activeReactionMessageId, setActiveReactionMessageId] = useState<string | null>(null);
const [activeNativeReactionMessageId, setActiveNativeReactionMessageId] = useState<string | null>(null);
const [replyToMessage, setReplyToMessage] = useState<any | null>(null);
const pinMessage = useMessageStore((s) => s.pinMessage);
const unpinMessage = useMessageStore((s) => s.unpinMessage);
const fetchPinnedMessages = useMessageStore((s) => s.fetchPinnedMessages);
const pinnedMessages = useMessageStore((s) => activeChannelId ? s.pinnedMessagesByChannel[activeChannelId] || [] : []);
const pinnedCount = pinnedMessages.length;
const { showMenu, MenuPortal } = useContextMenu();
const handleJumpToMessage = useCallback((messageId: string) => {
const el = document.getElementById(`msg-${messageId}`);
if (el) {
el.scrollIntoView({ behavior: "smooth", block: "center" });
el.classList.add("terminal-mention");
setTimeout(() => {
el.classList.remove("terminal-mention");
}, 2000);
}
}, []);
const handleAddReaction = useCallback(async (messageId: string, emoji: string) => {
try {
await api.post(`/messages/${messageId}/reactions`, { emoji });
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to add reaction");
}
}, []);
const handleMessageContextMenu = useCallback((e: React.MouseEvent, msg: any) => {
if (selectMode) return;
showMenu(e, [
{
label: "[REPLY]",
onClick: () => {
setReplyToMessage(msg);
}
},
{
label: "[ADD KAOMOJI REACTION]",
onClick: () => {
setActiveReactionMessageId(msg.id);
setActiveNativeReactionMessageId(null);
},
},
{
label: "[ADD EMOJI REACTION]",
onClick: () => {
setActiveNativeReactionMessageId(msg.id);
setActiveReactionMessageId(null);
},
},
{
label: msg.pinned ? "[UNPIN MESSAGE]" : "[PIN MESSAGE]",
onClick: async () => {
try {
if (msg.pinned) {
await unpinMessage(msg.channel_id, msg.id);
} else {
await pinMessage(msg.channel_id, msg.id);
}
} catch (err) {
setError(err instanceof Error ? err.message : "Pin operation failed");
}
}
}
]);
}, [selectMode, showMenu, pinMessage, unpinMessage]);
useEffect(() => {
if (activeChannelId) {
fetchPinnedMessages(activeChannelId);
}
}, [activeChannelId, fetchPinnedMessages]);
const handleToggleSelect = useCallback((messageId: string) => {
if (activeChannelId) {
toggleSelectedMessage(activeChannelId, messageId);
}
}, [activeChannelId, toggleSelectedMessage]);
const handleAuthorClick = useCallback((authorId: string) => {
setProfileUserId(authorId);
}, []);
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 || !hasMore) return;
if (el.scrollTop < 100) {
const prevHeight = el.scrollHeight;
fetchOlderMessages(activeChannelId).then(() => {
requestAnimationFrame(() => {
el.scrollTop = el.scrollHeight - prevHeight;
});
});
}
}, [activeChannelId, isLoadingOlder, hasMore, 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 handlePaste = async (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
const items = e.clipboardData.items;
let imageFile: File | null = null;
for (let i = 0; i < items.length; i++) {
if (items[i].type.startsWith('image/')) {
imageFile = items[i].getAsFile();
break;
}
}
if (imageFile) {
e.preventDefault();
const formData = new FormData();
formData.append('file', imageFile);
try {
const res = await fetch('/api/v1/upload', {
method: 'POST',
body: formData,
credentials: 'include'
});
if (!res.ok) throw new Error('Upload failed');
const data = await res.json();
const imageUrl = data.url;
// Insert into input
const imgMarkdown = `![image](${imageUrl})`;
const inputEl = inputRef.current;
if (inputEl) {
const start = inputEl.selectionStart || 0;
const end = inputEl.selectionEnd || 0;
const newValue = input.slice(0, start) + imgMarkdown + input.slice(end);
setInput(newValue);
setTimeout(() => {
inputEl.focus();
inputEl.setSelectionRange(start + imgMarkdown.length, start + imgMarkdown.length);
}, 0);
} else {
setInput(prev => prev + (prev.length > 0 && !prev.endsWith(' ') ? ' ' : '') + imgMarkdown);
}
} catch (err) {
console.error('Failed to upload image:', err);
}
}
};
const handleMentionSelect = (username: string) => {
const inputEl = inputRef.current;
if (!inputEl) {
setInput((prev) => `${prev}${username} `);
setMentionQuery(null);
return;
}
// Read directly from DOM to avoid stale closure
const currentValue = inputEl.value;
const cursor = inputEl.selectionStart ?? currentValue.length;
const beforeCursor = currentValue.slice(0, cursor);
const atIndex = beforeCursor.lastIndexOf("@");
if (atIndex === -1) return;
const before = currentValue.slice(0, atIndex);
const after = currentValue.slice(cursor);
const separator = after.startsWith(" ") || after === "" ? "" : " ";
const next = `${before}@${username}${separator}${after}`;
setInput(next);
setMentionQuery(null);
setDropdownIndex(0);
// Also set the DOM value immediately so cursor positioning works
inputEl.value = next;
const pos = atIndex + username.length + 2;
inputEl.focus();
inputEl.setSelectionRange(pos, pos);
};
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.ctrlKey && e.key === 'e') return; // handled by MessageInput
const { mentionQuery: mq, commandQuery: cq, dropdownIndex: di } = dropdownRef.current;
const isDropdownOpen = mq !== null || cq !== null;
if (!isDropdownOpen) return;
const itemCount = mq !== null
? buildMentionOptions(mq, humanMembers, canMentionEveryone).length
: cq !== null
? SLASH_COMMANDS.filter((c) => c.name.startsWith(cq.toLowerCase())).slice(0, 8).length
: 0;
if (itemCount === 0) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
setDropdownIndex((prev) => (prev + 1) % itemCount);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setDropdownIndex((prev) => (prev - 1 + itemCount) % itemCount);
} else if (e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
if (mq !== null) {
const options = buildMentionOptions(mq, humanMembers, canMentionEveryone);
const selected = options[di];
if (selected) {
if (selected.kind === "special") {
handleMentionSelect(selected.label);
} else {
handleMentionSelect(selected.member.username);
}
}
} else if (cq !== null) {
const q = cq.toLowerCase();
const filtered = SLASH_COMMANDS.filter((c) => c.name.startsWith(q)).slice(0, 8);
if (filtered[di]) {
const cmd = filtered[di];
setCommandQuery(null);
setDropdownIndex(0);
if (cmd.name === 'poll') {
setShowPollModal(true);
setInput('');
return;
}
if (cmd.name === 'emoji') {
setInput('');
setCommandQuery(null);
setDropdownIndex(0);
return;
}
const inputEl = inputRef.current;
const curVal = inputEl?.value || '';
const args = curVal.startsWith('/' + cmd.name + ' ')
? curVal.slice(cmd.name.length + 2).trim()
: '';
const text = cmd.transform(args, currentUser?.username || 'user');
sendMessage(activeChannelId!, text, replyToMessage?.id);
setInput('');
setReplyToMessage(null);
}
}
} else if (e.key === 'Escape') {
setMentionQuery(null);
setCommandQuery(null);
setDropdownIndex(0);
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [humanMembers, canMentionEveryone, currentUser, activeChannelId, sendMessage, replyToMessage, handleMentionSelect]);
const handleSubmit = useCallback(async () => {
if (mentionQuery !== null || commandQuery !== null) return;
if (!activeChannelId || !input.trim() || slowmodeRemaining > 0) return;
setError(null);
try {
let messageText = input.trim();
if (messageText.startsWith('/')) {
const spaceIdx = messageText.indexOf(' ');
const cmdName = spaceIdx === -1 ? messageText.slice(1) : messageText.slice(1, spaceIdx);
const args = spaceIdx === -1 ? '' : messageText.slice(spaceIdx + 1).trim();
if (cmdName === 'poll') {
setShowPollModal(true);
setInput('');
setCommandQuery(null);
return;
}
if (cmdName === 'emoji') {
setInput('');
setCommandQuery(null);
return;
}
const cmd = findCommand(cmdName);
if (cmd) {
messageText = cmd.transform(args, currentUser?.username || 'user');
}
}
await sendMessage(activeChannelId, messageText, replyToMessage?.id);
setInput("");
setReplyToMessage(null);
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);
}
}, [mentionQuery, commandQuery, activeChannelId, input, slowmodeRemaining, sendMessage, replyToMessage, currentUser]);
const handleGifSelect = async (gif: Gif) => {
if (!activeChannelId) return;
const content = `![${gif.title || "GIF"}](${gif.images.fixed_height.url})`;
try {
await sendMessage(activeChannelId, content);
} 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-2 md:px-3 py-2 text-gb-fg-s flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<button
type="button"
onClick={() => useLayoutStore.getState().setMobileView('sidebar')}
className="md:hidden terminal-button text-xs px-2 py-0.5 shrink-0"
>
</button>
<span className="truncate">
{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>
</div>
{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={() => {
setShowPinned(false);
setShowFeatureRequests(false);
setShowSearch((prev) => !prev);
}}
className={`text-xs font-mono ${showSearch ? 'text-gb-orange' : 'text-gb-fg-f hover:text-gb-orange'}`}
title="Search messages"
>
[SEARCH]
</button>
<button
onClick={() => {
setShowSearch(false);
setShowFeatureRequests(false);
setShowPinned((prev) => !prev);
}}
className={`text-xs font-mono ${showPinned ? 'text-gb-orange' : 'text-gb-fg-f hover:text-gb-orange'}`}
title="Show pinned messages"
>
[PINNED{pinnedCount > 0 ? `:${pinnedCount}` : ''}]
</button>
<button
onClick={() => {
setShowSearch(false);
setShowPinned(false);
setShowFeatureRequests((prev) => !prev);
}}
className={`text-xs font-mono ${showFeatureRequests ? 'text-gb-orange' : 'text-gb-fg-f hover:text-gb-orange'}`}
title="Feature requests"
>
[FEATURES]
</button>
</div>
)}
</div>
{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 min-h-0 relative">
{showSearch && activeChannelId && activeChannel && (
<MessageSearch
channelId={activeChannelId}
channelName={activeChannel.name}
onClose={() => setShowSearch(false)}
onJump={handleJumpToMessage}
/>
)}
{showPinned && activeChannelId && activeChannel && (
<PinnedMessages
channelId={activeChannelId}
channelName={activeChannel.name}
onClose={() => setShowPinned(false)}
onJump={handleJumpToMessage}
/>
)}
{showFeatureRequests && activeServerId && (
<FeatureRequestsPanel
serverId={activeServerId}
channelId={activeChannelId || undefined}
onClose={() => setShowFeatureRequests(false)}
/>
)}
{showThreads && activeChannelId && activeChannel?.type !== 'forum' && (
<ThreadListPanel
channelId={activeChannelId}
onSelect={(t: { id: string; name: string }) => setActiveThread(t)}
onClose={() => setShowThreads(false)}
/>
)}
<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, i) => {
// ponytail: last-read divider — show after the message matching the user's read state
const lastReadId = activeChannelId ? readStates[activeChannelId] : undefined;
const showDivider = lastReadId && message.id === lastReadId && i < messages.length - 1;
return (
<>
<MessageItem
key={message.id}
message={message}
memberUsernames={memberUsernames}
selectMode={selectMode}
isSelected={selectedIds.has(message.id)}
onAuthorClick={handleAuthorClick}
onToggleSelect={handleToggleSelect}
onContextMenu={handleMessageContextMenu}
onAddReaction={handleAddReaction}
currentUserId={currentUser?.id}
activeReactionMessageId={activeReactionMessageId}
setActiveReactionMessageId={setActiveReactionMessageId}
activeNativeReactionMessageId={activeNativeReactionMessageId}
setActiveNativeReactionMessageId={setActiveNativeReactionMessageId}
members={members}
parentMessage={message.reply_to ? messages.find((m) => m.id === message.reply_to) : null}
onJumpToParent={handleJumpToMessage}
/>
{showDivider && (
<div className="flex items-center gap-2 my-1">
<span className="flex-1 border-t border-gb-red"></span>
<span className="text-gb-red text-xs font-bold shrink-0"> NEW </span>
<span className="flex-1 border-t border-gb-red"></span>
</div>
)}
</>
);
})}
<div ref={bottomRef} />
</div>
<div className="px-3 pt-1 text-xs text-gb-fg-f font-mono italic h-5 select-none">
{(() => {
const chTyping = activeChannelId ? (typingUsers[activeChannelId] || []).filter(u => u.userId !== currentUser?.id) : [];
if (chTyping.length === 0) return "\u00A0";
const names = chTyping.map(u => u.username);
return names.length === 1 ? `${names[0]} is typing...` : names.length === 2 ? `${names[0]} and ${names[1]} are typing...` : `${names[0]} and ${names.length - 1} others are typing...`;
})()}
</div>
{replyToMessage && (
<div className="px-3 pb-1">
<ReplyBar
replyTo={{
id: replyToMessage.id,
content: replyToMessage.content,
author: {
username: replyToMessage.author_username,
}
}}
onCancel={() => setReplyToMessage(null)}
/>
</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>
)}
<div className="p-3 relative">
{mentionQuery !== null && (
<MentionDropdown
query={mentionQuery}
members={humanMembers}
selectedIndex={dropdownIndex}
onSelect={handleMentionSelect}
canMentionEveryone={canMentionEveryone}
/>
)}
{commandQuery !== null && (
<CommandDropdown
query={commandQuery}
selectedIndex={dropdownIndex}
onSelect={(name) => {
setInput('/' + name + ' ');
setCommandQuery(null);
setDropdownIndex(0);
inputRef.current?.focus();
}}
/>
)}
<MessageInput
ref={inputRef}
value={input}
onChange={(v) => {
setInput(v);
const cursor = inputRef.current?.selectionStart ?? v.length;
const beforeCursor = v.slice(0, cursor);
if (beforeCursor.startsWith('/') && !beforeCursor.includes(' ')) {
setCommandQuery(beforeCursor.slice(1));
setMentionQuery(null);
} else {
setCommandQuery(null);
const atIndex = beforeCursor.lastIndexOf("@");
if (atIndex === -1 || beforeCursor.slice(atIndex + 1).includes(" ") || beforeCursor.slice(atIndex + 1).includes("\n")) {
setMentionQuery(null);
} else {
setMentionQuery(beforeCursor.slice(atIndex + 1));
}
}
if (activeChannelId && v.length > 0) {
const now = Date.now();
if (now - lastTypingRef.current > 3000) {
sendTypingStart(activeChannelId);
lastTypingRef.current = now;
}
}
}}
onSubmit={handleSubmit}
onPaste={handlePaste}
onGifSelect={handleGifSelect}
disabled={!activeChannelId || slowmodeRemaining > 0}
/>
</div>
</div>
{activeThread && (
<ThreadPanel threadId={activeThread.id} threadName={activeThread.name} onClose={() => setActiveThread(null)} />
)}
</>
)}
</div>
{profileUserId && (
<UserProfileModal userId={profileUserId} onClose={() => setProfileUserId(null)} />
)}
{MenuPortal}
{showPollModal && activeChannelId && (
<CreatePollModal
channelId={activeChannelId}
onClose={() => setShowPollModal(false)}
/>
)}
</div>
);
}