40f8d193ff
- /poll command opens creation modal (2-10 options) - PollDisplay with vote bars, percentages, live WS updates - Backend: polls/poll_options/poll_votes tables, Create/Get/Vote endpoints - attachPolls enriches message list responses - POLL_UPDATE broadcast on vote for real-time sync
804 lines
32 KiB
TypeScript
804 lines
32 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 { GiphyPicker, type Gif } from "./GiphyPicker";
|
|
import { CommandDropdown } from "./CommandDropdown";
|
|
import { findCommand } from "../lib/slashCommands";
|
|
import { PollDisplay, CreatePollModal } from "./Poll.tsx";
|
|
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 { useContextMenu } from "./ContextMenu.tsx";
|
|
import { PinnedMessages } from "./PinnedMessages.tsx";
|
|
import { ReactionBar } from "./ReactionBar.tsx";
|
|
import { EmojiPicker } from "./EmojiPicker.tsx";
|
|
import { ReplyBar } from "./ReplyBar.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") {
|
|
// 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="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" />,
|
|
p: ({ ...props }) => <span {...props} className="inline" />,
|
|
img: ({ src, ...props }) => {
|
|
const finalSrc = src?.startsWith("https://media") && src.includes(".giphy.com/")
|
|
? `/api/v1/gifs/proxy?url=${encodeURIComponent(src)}`
|
|
: src;
|
|
return (
|
|
<img
|
|
{...props}
|
|
src={finalSrc}
|
|
className="max-w-[240px] max-h-[240px] object-contain rounded my-1 block"
|
|
loading="lazy"
|
|
/>
|
|
);
|
|
},
|
|
}}
|
|
>
|
|
{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,
|
|
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;
|
|
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="text-gb-aqua hover:text-gb-orange cursor-pointer" onClick={(e) => {
|
|
e.stopPropagation();
|
|
onAuthorClick(message.author_id);
|
|
}}>
|
|
<{members.find((m) => m.id === message.author_id)?.nickname || message.author_username}>
|
|
</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
|
|
messageId={message.id}
|
|
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),
|
|
};
|
|
})}
|
|
onRefresh={() => {}}
|
|
/>
|
|
|
|
{/* Kaomoji picker popover */}
|
|
{activeReactionMessageId === message.id && (
|
|
<EmojiPicker
|
|
onSelect={(emoji) => onAddReaction(message.id, emoji)}
|
|
onClose={() => setActiveReactionMessageId(null)}
|
|
/>
|
|
)}
|
|
</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 [showGifPicker, setShowGifPicker] = useState(false);
|
|
const [showKaomoji, setShowKaomoji] = useState(false);
|
|
const [showSearch, setShowSearch] = useState(false);
|
|
const [mentionQuery, setMentionQuery] = useState<string | null>(null);
|
|
const [commandQuery, setCommandQuery] = useState<string | null>(null);
|
|
const [showPollModal, setShowPollModal] = useState(false);
|
|
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 lastTypingRef = useRef<number>(0);
|
|
|
|
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);
|
|
|
|
const [showPinned, setShowPinned] = useState(false);
|
|
const [activeReactionMessageId, setActiveReactionMessageId] = 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 });
|
|
setActiveReactionMessageId(null);
|
|
} 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 REACTION]",
|
|
onClick: () => {
|
|
setActiveReactionMessageId(msg.id);
|
|
}
|
|
},
|
|
{
|
|
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 handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const value = e.target.value;
|
|
const cursor = e.target.selectionStart ?? value.length;
|
|
setInput(value);
|
|
|
|
// ponytail: throttle typing indicator to once per 3s
|
|
if (activeChannelId && value.length > 0) {
|
|
const now = Date.now();
|
|
if (now - lastTypingRef.current > 3000) {
|
|
sendTypingStart(activeChannelId);
|
|
lastTypingRef.current = now;
|
|
}
|
|
}
|
|
|
|
const beforeCursor = value.slice(0, cursor);
|
|
// Slash command detection (only at start of input)
|
|
if (beforeCursor.startsWith('/') && !beforeCursor.includes(' ')) {
|
|
setCommandQuery(beforeCursor.slice(1));
|
|
setMentionQuery(null);
|
|
return;
|
|
}
|
|
setCommandQuery(null);
|
|
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;
|
|
}
|
|
// 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);
|
|
// 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);
|
|
};
|
|
|
|
const handleSubmit = async (event: React.FormEvent) => {
|
|
event.preventDefault();
|
|
if (!activeChannelId || !input.trim() || slowmodeRemaining > 0) return;
|
|
setError(null);
|
|
try {
|
|
let messageText = input.trim();
|
|
// Slash command transform
|
|
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();
|
|
// /poll opens the poll creation modal
|
|
if (cmdName === 'poll') {
|
|
setShowPollModal(true);
|
|
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);
|
|
}
|
|
};
|
|
|
|
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={() => {
|
|
setShowPinned(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);
|
|
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>
|
|
</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}
|
|
/>
|
|
)}
|
|
{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) => (
|
|
<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}
|
|
members={members}
|
|
parentMessage={message.reply_to ? messages.find((m) => m.id === message.reply_to) : null}
|
|
onJumpToParent={handleJumpToMessage}
|
|
/>
|
|
))}
|
|
<div ref={bottomRef} />
|
|
</div>
|
|
{showGifPicker && (
|
|
<div className="px-3 pb-1">
|
|
<GiphyPicker onSelect={handleGifSelect} onClose={() => setShowGifPicker(false)} />
|
|
</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>
|
|
)}
|
|
<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}
|
|
onKeyDown={(e) => {
|
|
if (e.ctrlKey && e.key === 'e') {
|
|
e.preventDefault();
|
|
setShowKaomoji((prev) => !prev);
|
|
}
|
|
}}
|
|
/>
|
|
{mentionQuery !== null && (
|
|
<MentionDropdown query={mentionQuery} members={members} onSelect={handleMentionSelect} />
|
|
)}
|
|
{commandQuery !== null && (
|
|
<CommandDropdown
|
|
query={commandQuery}
|
|
onSelect={(name) => {
|
|
setInput('/' + name + ' ');
|
|
setCommandQuery(null);
|
|
inputRef.current?.focus();
|
|
}}
|
|
/>
|
|
)}
|
|
</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>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowKaomoji((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 kaomoji picker (Ctrl+E)"
|
|
>
|
|
[☺]
|
|
</button>
|
|
{showKaomoji && (
|
|
<EmojiPicker
|
|
onSelect={(emoji) => setInput((prev) => prev + emoji)}
|
|
onClose={() => setShowKaomoji(false)}
|
|
/>
|
|
)}
|
|
</form>
|
|
</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>
|
|
);
|
|
}
|