Files
dumpsterChat/web/src/components/ChatArea.tsx
T
hobokenchicken 3c7b8278ce fix: client perms, @everyone/@channel, docs, unit tests
- usePermissions ORs current user roles + @everyone only (not all server roles)
- cache myRolesByServer; load on active server; refresh after self role edit
- gate/notify @everyone and @channel; plain @username push; special mention UI
- refresh FEATURE_PARITY (DMs exist; drop stale critical gaps)
- README production deploy notes dumpster.service
- unit tests for permission bits and broadcast mention tokens
2026-07-15 20:56:53 -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 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) 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 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 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>
);
}