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) { 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 ( @{seg.value}{needsSpace ? " " : ""} ); } // 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 ( {leadingSpace} {trimmed ? ( , code: ({ ...props }) => , pre: ({ ...props }) =>
,
              blockquote: ({ ...props }) => 
, table: ({ ...props }) => , th: ({ ...props }) =>
, td: ({ ...props }) => , p: ({ ...props }) => , img: ({ src, ...props }) => { const finalSrc = src?.startsWith("https://media") && src.includes(".giphy.com/") ? `/api/v1/gifs/proxy?url=${encodeURIComponent(src)}` : src; return ( ); }, }} > {trimmed} ) : null} {trailingSpace} ); }); } function renderEmbeds(embeds?: { url: string; title?: string; description?: string; image_url?: string; site_name?: string }[]) { if (!embeds || embeds.length === 0) return null; return ( ); } const MessageItem = memo(({ message, memberUsernames, selectMode, isSelected, onAuthorClick, onToggleSelect, onContextMenu, onAddReaction, currentUserId, activeReactionMessageId, setActiveReactionMessageId, members, parentMessage, onJumpToParent, }: { message: any; memberUsernames: Set; 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 (
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 && (
{ e.stopPropagation(); onJumpToParent(parentMessage.id); }} className="cursor-pointer hover:opacity-80 mb-0.5" >
)} {selectMode && ( {isSelected ? '[x]' : '[ ]'} )} {formatTime(message.created_at)}{' '} {message.pinned && [PIN]} { e.stopPropagation(); onAuthorClick(message.author_id); }}> <{members.find((m) => m.id === message.author_id)?.nickname || message.author_username}> {" "} {renderContent(message.content, memberUsernames)} {renderEmbeds(message.embeds)} {message.poll && } {/* Message reactions */} { 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 && ( onAddReaction(message.id, emoji)} onClose={() => setActiveReactionMessageId(null)} /> )}
); }); 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(null); const [showGifPicker, setShowGifPicker] = useState(false); const [showKaomoji, setShowKaomoji] = useState(false); const [showSearch, setShowSearch] = useState(false); const [mentionQuery, setMentionQuery] = useState(null); const [commandQuery, setCommandQuery] = useState(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(null); const bottomRef = useRef(null); const scrollContainerRef = useRef(null); const inputRef = useRef(null); const lastTypingRef = useRef(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() : new Set(), ); 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(null); const [replyToMessage, setReplyToMessage] = useState(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) => { 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 = `![${gif.title || "GIF"}](${gif.images.fixed_height.url})`; try { await sendMessage(activeChannelId, content); setShowGifPicker(false); } catch (err) { setError(err instanceof Error ? err.message : "Failed to send"); } }; return (
{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]"} {activeChannelId && activeChannel?.type === 'text' && (
)}
{selectMode && activeChannelId && activeChannel?.type !== 'forum' && (
{selectedIds.size} selected
)}
{activeChannel && activeChannel.type === 'forum' && activeChannelId ? ( setActiveThread(t)} /> ) : activeChannel && activeChannel.type === 'calendar' && activeChannelId ? ( ) : activeChannel && activeChannel.type === 'docs' && activeChannelId ? ( ) : activeChannel && activeChannel.type === 'list' && activeChannelId ? ( ) : ( <>
{showSearch && activeChannelId && activeChannel && ( setShowSearch(false)} onJump={handleJumpToMessage} /> )} {showPinned && activeChannelId && activeChannel && ( setShowPinned(false)} onJump={handleJumpToMessage} /> )} {showThreads && activeChannelId && activeChannel?.type !== 'forum' && ( setActiveThread(t)} onClose={() => setShowThreads(false)} /> )}
{isLoadingOlder &&

[loading older messages...]

} {isLoading &&

[loading...]

} {!isLoading && messages.length === 0 && (

[no messages in this channel]

)} {messages.map((message) => ( m.id === message.reply_to) : null} onJumpToParent={handleJumpToMessage} /> ))}
{showGifPicker && (
setShowGifPicker(false)} />
)}
{(() => { 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...`; })()}
{replyToMessage && (
setReplyToMessage(null)} />
)} {error && (

ERR: {error}

)} {slowmodeRemaining > 0 && (
SLOWMODE: wait {slowmodeRemaining}s
)}
{">"}
0} /> {mentionQuery !== null && ( )} {commandQuery !== null && ( { setInput('/' + name + ' '); setCommandQuery(null); setDropdownIndex(0); inputRef.current?.focus(); }} /> )}
{showKaomoji && ( setInput((prev) => prev + emoji)} onClose={() => setShowKaomoji(false)} /> )}
{activeThread && ( setActiveThread(null)} /> )} )}
{profileUserId && ( setProfileUserId(null)} /> )} {MenuPortal} {showPollModal && activeChannelId && ( setShowPollModal(false)} /> )}
); }