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) { 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 ( @{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 }) => , h1: ({ ...props }) =>

, h2: ({ ...props }) =>

, h3: ({ ...props }) =>

, p: ({ ...props }) => , img: ({ src, alt, ...props }) => ( ), }} > {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, activeNativeReactionMessageId, setActiveNativeReactionMessageId, 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; activeNativeReactionMessageId: string | null; setActiveNativeReactionMessageId: (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(); if (!message.author_bot) onAuthorClick(message.author_id); }}> <{message.author_bot ? (message.bot_name || message.author_username) : (members.find((m) => m.id === message.author_id)?.nickname || message.author_username)}> {message.author_bot && BOT} {" "} {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), }; })} 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 && ( onAddReaction(message.id, emoji)} onClose={() => setActiveReactionMessageId(null)} /> )} {activeNativeReactionMessageId === message.id && (
{ onAddReaction(message.id, emoji.emoji); setActiveNativeReactionMessageId(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 [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] || [] : []; // 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(null); const [activeNativeReactionMessageId, setActiveNativeReactionMessageId] = 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 }); } 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) => { 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 (
{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} /> )} {showFeatureRequests && activeServerId && ( setShowFeatureRequests(false)} /> )} {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, 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 ( <> m.id === message.reply_to) : null} onJumpToParent={handleJumpToMessage} /> {showDivider && (
── NEW ──
)} ); })}
{(() => { 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
)}
{mentionQuery !== null && ( )} {commandQuery !== null && ( { setInput('/' + name + ' '); setCommandQuery(null); setDropdownIndex(0); inputRef.current?.focus(); }} /> )} { 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} />
{activeThread && ( setActiveThread(null)} /> )} )}
{profileUserId && ( setProfileUserId(null)} /> )} {MenuPortal} {showPollModal && activeChannelId && ( setShowPollModal(false)} /> )}
); }