4ed2e5bfd0
Moved keydown handler from inline React prop to useEffect with addEventListener on the input ref. Uses a ref to track latest dropdown state, avoiding stale closure issues that prevented arrow keys from working. Added stopPropagation on Enter to prevent form submission race. Guard in handleSubmit to bail when a dropdown is open.
888 lines
35 KiB
TypeScript
888 lines
35 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, SLASH_COMMANDS } 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 [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<HTMLInputElement>(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] || [] : [];
|
|
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);
|
|
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);
|
|
};
|
|
|
|
// ponytail: stable keydown handler via addEventListener avoids stale closure issues
|
|
useEffect(() => {
|
|
const el = inputRef.current;
|
|
if (!el) return;
|
|
const handler = (e: KeyboardEvent) => {
|
|
if (e.ctrlKey && e.key === 'e') {
|
|
e.preventDefault();
|
|
setShowKaomoji((prev) => !prev);
|
|
return;
|
|
}
|
|
|
|
const { mentionQuery: mq, commandQuery: cq, dropdownIndex: di } = dropdownRef.current;
|
|
const isDropdownOpen = mq !== null || cq !== null;
|
|
if (!isDropdownOpen) return;
|
|
|
|
const itemCount = mq !== null
|
|
? members.filter((m) =>
|
|
m.username.toLowerCase().includes(mq.toLowerCase()) ||
|
|
m.display_name?.toLowerCase().includes(mq.toLowerCase())
|
|
).slice(0, 6).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 q = mq.toLowerCase();
|
|
const filtered = members.filter((m) =>
|
|
m.username.toLowerCase().includes(q) ||
|
|
m.display_name?.toLowerCase().includes(q)
|
|
).slice(0, 6);
|
|
if (filtered[di]) {
|
|
handleMentionSelect(filtered[di].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;
|
|
}
|
|
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);
|
|
}
|
|
};
|
|
el.addEventListener('keydown', handler);
|
|
return () => el.removeEventListener('keydown', handler);
|
|
}, [members, currentUser, activeChannelId, sendMessage, replyToMessage, handleMentionSelect]);
|
|
|
|
const handleSubmit = async (event: React.FormEvent) => {
|
|
event.preventDefault();
|
|
// ponytail: don't submit when a dropdown is handling keyboard input
|
|
if (mentionQuery !== null || commandQuery !== null) return;
|
|
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}
|
|
/>
|
|
{mentionQuery !== null && (
|
|
<MentionDropdown query={mentionQuery} members={members} selectedIndex={dropdownIndex} onSelect={handleMentionSelect} />
|
|
)}
|
|
{commandQuery !== null && (
|
|
<CommandDropdown
|
|
query={commandQuery}
|
|
selectedIndex={dropdownIndex}
|
|
onSelect={(name) => {
|
|
setInput('/' + name + ' ');
|
|
setCommandQuery(null);
|
|
setDropdownIndex(0);
|
|
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>
|
|
);
|
|
}
|