[READ-ONLY] Mirror of https://github.com/aaronateataco/ermine. A stoat web client erminechat.vercel.app
0

Configure Feed

Select the types of activity you want to include in your feed.

Update App.jsx

authored by

Fascinating Pistachio and committed by
GitHub
(Feb 14, 2026, 7:01 PM UTC) c610d6c0 b1300ae2

+644 -1391
+644 -1391
src/App.jsx
··· 1 - import React, { useEffect, useMemo, useRef, useState } from 'react'; 1 + import React, { useEffect, useMemo, useRef, useState, useCallback, memo } from 'react'; 2 2 import { 3 3 Activity, 4 4 AlertCircle, ··· 19 19 User, 20 20 Users, 21 21 X, 22 + Trash2, 23 + Edit2, 24 + Save, 25 + MoreVertical, 26 + Compass 22 27 } from 'lucide-react'; 23 28 29 + // --- Twemoji Utilities --- 30 + const TWEMOJI_BASE = 'https://cdnjs.cloudflare.com/ajax/libs/twemoji/14.0.2/72x72/'; 31 + 32 + const toCodePoint = (unicodeSurrogates, sep) => { 33 + const r = []; 34 + let c = 0; 35 + let p = 0; 36 + let i = 0; 37 + while (i < unicodeSurrogates.length) { 38 + c = unicodeSurrogates.charCodeAt(i++); 39 + if (p) { 40 + r.push((0x10000 + ((p - 0xd800) << 10) + (c - 0xdc00)).toString(16)); 41 + p = 0; 42 + } else if (0xd800 <= c && c <= 0xdbff) { 43 + p = c; 44 + } else { 45 + r.push(c.toString(16)); 46 + } 47 + } 48 + return r.join(sep || '-'); 49 + }; 50 + 51 + const renderTwemoji = (text, className = "inline-block w-5 h-5 align-bottom") => { 52 + if (!text) return null; 53 + const parts = text.split(/([\uD800-\uDBFF][\uDC00-\uDFFF])/g); 54 + return parts.map((part, index) => { 55 + if (/[\uD800-\uDBFF][\uDC00-\uDFFF]/.test(part)) { 56 + const codePoint = toCodePoint(part); 57 + return ( 58 + <img 59 + key={index} 60 + src={`${TWEMOJI_BASE}${codePoint}.png`} 61 + alt={part} 62 + className={className} 63 + draggable={false} 64 + onError={(e) => { e.currentTarget.style.display = 'none'; }} 65 + /> 66 + ); 67 + } 68 + return part; 69 + }); 70 + }; 71 + 72 + // --- Config & Helpers --- 24 73 const getRuntimeConfig = () => { 25 74 const runtime = typeof window !== 'undefined' ? window.__ERMINE_CONFIG__ || {} : {}; 26 - const env = typeof import.meta !== 'undefined' ? import.meta.env || {} : {}; 27 - 28 75 return { 29 - apiUrl: runtime.apiUrl || env.VITE_STOAT_API_URL || 'https://api.stoat.chat', 30 - wsUrl: runtime.wsUrl || env.VITE_STOAT_WS_URL || 'wss://stoat.chat/events', 31 - cdnUrl: runtime.cdnUrl || env.VITE_STOAT_CDN_URL || 'https://cdn.stoatusercontent.com', 76 + apiUrl: runtime.apiUrl || 'https://api.stoat.chat', 77 + wsUrl: runtime.wsUrl || 'wss://stoat.chat/events', 78 + cdnUrl: runtime.cdnUrl || 'https://cdn.stoatusercontent.com', 32 79 }; 33 80 }; 34 81 ··· 43 90 44 91 const getCookie = (name) => { 45 92 if (typeof document === 'undefined') return null; 46 - const value = document.cookie 47 - .split('; ') 48 - .find((entry) => entry.startsWith(`${name}=`)); 49 - if (!value) return null; 50 - return decodeURIComponent(value.slice(name.length + 1)); 93 + const value = document.cookie.split('; ').find((entry) => entry.startsWith(`${name}=`)); 94 + return value ? decodeURIComponent(value.slice(name.length + 1)) : null; 51 95 }; 52 96 53 97 const setCookie = (name, value, maxAgeSeconds = 60 * 60 * 24 * 30) => { ··· 74 118 return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); 75 119 }; 76 120 77 - const getAvatarUrl = (user, cdnUrl) => { 78 - if (!user?.avatar?._id) return null; 79 - return `${cdnUrl}/avatars/${user.avatar._id}`; 80 - }; 81 - 82 - const getIconUrl = (server, cdnUrl) => { 83 - if (!server?.icon?._id) return null; 84 - return `${cdnUrl}/icons/${server.icon._id}`; 85 - }; 86 - 87 - 121 + const getAvatarUrl = (user, cdnUrl) => user?.avatar?._id ? `${cdnUrl}/avatars/${user.avatar._id}` : null; 122 + const getIconUrl = (server, cdnUrl) => server?.icon?._id ? `${cdnUrl}/icons/${server.icon._id}` : null; 88 123 const getBannerUrl = (user, cdnUrl) => { 89 124 const banner = user?.profile?.background || user?.banner; 90 - if (!banner?._id) return null; 91 - return `${cdnUrl}/backgrounds/${banner._id}`; 125 + const bannerId = typeof banner === 'string' ? banner : banner?._id; 126 + return bannerId ? `${cdnUrl}/backgrounds/${bannerId}/original` : null; 92 127 }; 93 - 94 128 const getJoinedAt = (entry) => entry?.joined_at || entry?.joinedAt || entry?.created_at || entry?.createdAt || null; 95 - 96 129 const toDateLabel = (value) => { 97 130 if (!value) return 'Unknown'; 98 131 const date = new Date(value); 99 - if (Number.isNaN(date.getTime())) return 'Unknown'; 100 - return date.toLocaleDateString([], { year: 'numeric', month: 'short', day: 'numeric' }); 132 + return Number.isNaN(date.getTime()) ? 'Unknown' : date.toLocaleDateString([], { year: 'numeric', month: 'short', day: 'numeric' }); 101 133 }; 102 134 135 + // --- Shared Components --- 103 136 137 + const Avatar = ({ user, cdnUrl, size = 'md', animateOnHover = false, alwaysAnimate = false }) => { 138 + const sizeMap = { 139 + sm: 'h-8 w-8 text-xs', 140 + md: 'h-10 w-10 text-sm', 141 + lg: 'h-12 w-12 text-base', 142 + }; 143 + const initials = (user?.username || '?').slice(0, 2).toUpperCase(); 144 + const [isHovered, setIsHovered] = useState(false); 145 + const src = (() => { 146 + if (!user?.avatar?._id) return null; 147 + return (alwaysAnimate || (animateOnHover && isHovered)) ? `${cdnUrl}/avatars/${user.avatar._id}/original` : `${cdnUrl}/avatars/${user.avatar._id}`; 148 + })(); 149 + 150 + return ( 151 + <div 152 + className={`grid shrink-0 place-items-center overflow-hidden rounded-full bg-[#313338] font-semibold text-gray-200 ${sizeMap[size]}`} 153 + onMouseEnter={() => setIsHovered(true)} 154 + onMouseLeave={() => setIsHovered(false)} 155 + > 156 + {src ? <img alt={user?.username || 'User avatar'} className="block h-full w-full object-cover object-center" src={src} /> : initials} 157 + </div> 158 + ); 159 + }; 160 + 161 + class AppErrorBoundary extends React.Component { 162 + constructor(props) { 163 + super(props); 164 + this.state = { hasError: false }; 165 + } 166 + static getDerivedStateFromError() { return { hasError: true }; } 167 + componentDidCatch(error) { console.error('Ermine crashed:', error); } 168 + render() { 169 + if (this.state.hasError) { 170 + return ( 171 + <div className="grid min-h-screen place-items-center bg-[#1e1f22] p-6 text-gray-100"> 172 + <div className="w-full max-w-lg rounded-2xl border border-[#202225] bg-[#2b2d31] p-8 text-center shadow-2xl"> 173 + <h1 className="mt-3 text-2xl font-bold text-white">Looks like Ermine crashed.</h1> 174 + <p className="mt-2 text-sm text-gray-300">Try reloading the app.</p> 175 + <button className="mt-6 rounded-md bg-[#5865f2] px-4 py-2 text-sm font-semibold text-white hover:bg-[#4956d8]" onClick={() => window.location.reload()} type="button">Reload Ermine</button> 176 + </div> 177 + </div> 178 + ); 179 + } 180 + return this.props.children; 181 + } 182 + } 183 + 184 + const Modal = ({ title, onClose, children }) => ( 185 + <div className="fixed inset-0 z-50 grid place-items-center bg-black/70 p-4 backdrop-blur-sm"> 186 + <div className="w-full max-w-md overflow-hidden rounded-xl border border-[#202225] bg-[#2b2d31] shadow-2xl"> 187 + <div className="flex items-center justify-between border-b border-[#202225] px-4 py-3"> 188 + <h3 className="text-sm font-bold text-white">{title}</h3> 189 + <button className="rounded p-1 text-gray-400 hover:bg-[#3a3d42] hover:text-white" onClick={onClose}><X size={16} /></button> 190 + </div> 191 + <div className="space-y-3 p-4">{children}</div> 192 + </div> 193 + </div> 194 + ); 195 + 196 + const VirtualList = ({ items, renderItem, itemHeight, className }) => { 197 + const containerRef = useRef(null); 198 + const [scrollTop, setScrollTop] = useState(0); 199 + const [viewportHeight, setViewportHeight] = useState(0); 200 + const requestRef = useRef(); 201 + 202 + useEffect(() => { 203 + const container = containerRef.current; 204 + if (!container) return; 205 + const handleResize = () => setViewportHeight(container.clientHeight); 206 + const handleScroll = () => { 207 + if (requestRef.current) return; 208 + requestRef.current = requestAnimationFrame(() => { 209 + setScrollTop(container.scrollTop); 210 + requestRef.current = null; 211 + }); 212 + }; 213 + setViewportHeight(container.clientHeight); 214 + window.addEventListener('resize', handleResize); 215 + container.addEventListener('scroll', handleScroll); 216 + return () => { 217 + window.removeEventListener('resize', handleResize); 218 + container.removeEventListener('scroll', handleScroll); 219 + if (requestRef.current) cancelAnimationFrame(requestRef.current); 220 + }; 221 + }, []); 222 + 223 + const totalHeight = items.length * itemHeight; 224 + const startIndex = Math.max(0, Math.floor(scrollTop / itemHeight) - 5); 225 + const endIndex = Math.min(items.length, Math.ceil((scrollTop + viewportHeight) / itemHeight) + 5); 226 + const visibleItems = items.slice(startIndex, endIndex).map((item, index) => ({ item, index: startIndex + index, offsetTop: (startIndex + index) * itemHeight })); 227 + 228 + return ( 229 + <div ref={containerRef} className={className} style={{ position: 'relative', overflowY: 'auto' }}> 230 + <div style={{ height: totalHeight, width: '100%' }}> 231 + {visibleItems.map(({ item, index, offsetTop }) => ( 232 + <div key={item.key || index} style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: itemHeight, transform: `translateY(${offsetTop}px)` }}> 233 + {renderItem(item)} 234 + </div> 235 + ))} 236 + </div> 237 + </div> 238 + ); 239 + }; 240 + 241 + // --- Logic Helpers --- 104 242 const LinkAnchor = ({ href, children, onRequestOpenLink, className = 'text-[#8ea1ff] underline hover:text-[#bdc3ff]' }) => ( 105 - <a 106 - className={className} 107 - href={href} 108 - onClick={(event) => { 109 - event.preventDefault(); 110 - if (onRequestOpenLink) onRequestOpenLink(href); 111 - }} 112 - rel="noreferrer" 113 - target="_blank" 114 - > 115 - {children} 116 - </a> 243 + <a className={className} href={href} onClick={(e) => { e.preventDefault(); if (onRequestOpenLink) onRequestOpenLink(href); }} rel="noreferrer" target="_blank">{children}</a> 117 244 ); 118 245 119 246 const renderLinksInText = (value, keyPrefix = 'link', onRequestOpenLink = null) => { 120 247 if (!value) return null; 121 - const parts = value.split(/(https?:\/\/[^\s]+)/gi); 122 - return parts.map((part, index) => { 123 - if (!part) return null; 124 - if (/^https?:\/\//i.test(part)) { 125 - return <LinkAnchor href={part} key={`${keyPrefix}-${index}`} onRequestOpenLink={onRequestOpenLink}>{part}</LinkAnchor>; 126 - } 248 + return value.split(/(https?:\/\/[^\s]+)/gi).map((part, index) => { 249 + if (/^https?:\/\//i.test(part)) return <LinkAnchor href={part} key={`${keyPrefix}-${index}`} onRequestOpenLink={onRequestOpenLink}>{part}</LinkAnchor>; 127 250 return <React.Fragment key={`${keyPrefix}-${index}`}>{part}</React.Fragment>; 128 251 }); 129 252 }; ··· 131 254 const renderMarkdownInline = (text, keyPrefix = 'md', onRequestOpenLink = null) => { 132 255 if (!text) return null; 133 256 const tokens = text.split(/(`[^`]+`|\*\*[^*]+\*\*|\*[^*]+\*|__[^_]+__|~~[^~]+~~|\[[^\]]+\]\([^\)]+\))/g); 134 - 135 257 return tokens.map((token, index) => { 136 - if (!token) return null; 137 - 138 - if (/^`[^`]+`$/.test(token)) { 139 - return <code className="rounded bg-[#232428] px-1 py-0.5 text-xs text-[#f2f3f5]" key={`${keyPrefix}-${index}`}>{token.slice(1, -1)}</code>; 140 - } 141 - 142 - if (/^\*\*[^*]+\*\*$/.test(token)) { 143 - return <strong key={`${keyPrefix}-${index}`}>{token.slice(2, -2)}</strong>; 144 - } 145 - 146 - if (/^\*[^*]+\*$/.test(token)) { 147 - return <em key={`${keyPrefix}-${index}`}>{token.slice(1, -1)}</em>; 148 - } 149 - 150 - if (/^__[^_]+__$/.test(token)) { 151 - return <span className="underline" key={`${keyPrefix}-${index}`}>{token.slice(2, -2)}</span>; 152 - } 153 - 154 - if (/^~~[^~]+~~$/.test(token)) { 155 - return <span className="line-through" key={`${keyPrefix}-${index}`}>{token.slice(2, -2)}</span>; 156 - } 157 - 258 + if (/^`[^`]+`$/.test(token)) return <code key={`${keyPrefix}-${index}`} className="rounded bg-[#232428] px-1 py-0.5 text-xs text-[#f2f3f5]">{token.slice(1, -1)}</code>; 259 + if (/^\*\*[^*]+\*\*$/.test(token)) return <strong key={`${keyPrefix}-${index}`}>{token.slice(2, -2)}</strong>; 260 + if (/^\*[^*]+\*$/.test(token)) return <em key={`${keyPrefix}-${index}`}>{token.slice(1, -1)}</em>; 261 + if (/^__[^_]+__$/.test(token)) return <span key={`${keyPrefix}-${index}`} className="underline">{token.slice(2, -2)}</span>; 262 + if (/^~~[^~]+~~$/.test(token)) return <span key={`${keyPrefix}-${index}`} className="line-through">{token.slice(2, -2)}</span>; 158 263 const markdownLink = token.match(/^\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)$/i); 159 - if (markdownLink) { 160 - return <LinkAnchor href={markdownLink[2]} key={`${keyPrefix}-${index}`} onRequestOpenLink={onRequestOpenLink}>{markdownLink[1]}</LinkAnchor>; 161 - } 162 - 264 + if (markdownLink) return <LinkAnchor href={markdownLink[2]} key={`${keyPrefix}-${index}`} onRequestOpenLink={onRequestOpenLink}>{markdownLink[1]}</LinkAnchor>; 163 265 return <React.Fragment key={`${keyPrefix}-${index}`}>{renderLinksInText(token, `${keyPrefix}-plain-${index}`, onRequestOpenLink)}</React.Fragment>; 164 266 }); 165 267 }; 166 268 167 - const EMOJI_SHORTCODES = { 168 - smile: '😄', 169 - grin: '😁', 170 - joy: '😂', 171 - rofl: '🤣', 172 - wink: '😉', 173 - heart: '❤️', 174 - thumbs_up: '👍', 175 - thumbs_down: '👎', 176 - fire: '🔥', 177 - sob: '😭', 178 - thinking: '🤔', 179 - tada: '🎉', 180 - eyes: '👀', 181 - }; 182 - 269 + const EMOJI_SHORTCODES = { smile: '😄', grin: '😁', joy: '😂', rofl: '🤣', wink: '😉', heart: '❤️', thumbs_up: '👍', thumbs_down: '👎', fire: '🔥', sob: '😭', thinking: '🤔', tada: '🎉', eyes: '👀' }; 183 270 const QUICK_EMOJIS = ['👍', '❤️', '😂', '🔥', '😭', '🎉', '😮', '👀', '🤔']; 184 - const EXTENDED_EMOJIS = [ 185 - ...QUICK_EMOJIS, 186 - '😄', '😁', '🤣', '😉', '🙏', '👏', '✨', '🎯', '💯', '🤝', '😅', '😎', '🥳', '✅', '❌', '🎵', '📌', '🚀', '🫡', '🤌', 187 - ]; 271 + const EXTENDED_EMOJIS = [...QUICK_EMOJIS, '😄', '😁', '🤣', '😉', '🙏', '👏', '✨', '🎯', '💯', '🤝', '😅', '😎', '🥳', '✅', '❌', '🎵', '📌', '🚀', '🫡', '🤌']; 188 272 const MEMBER_RENDER_LIMIT = 250; 189 273 const MEMBER_HYDRATE_CHUNK = 400; 190 274 191 275 const toReactionEntries = (reactions) => { 192 276 if (!reactions || typeof reactions !== 'object') return []; 193 - return Object.entries(reactions) 194 - .map(([emoji, userIds]) => ({ emoji, userIds: Array.isArray(userIds) ? userIds : [] })) 195 - .filter((entry) => entry.userIds.length > 0); 277 + return Object.entries(reactions).map(([emoji, userIds]) => ({ emoji, userIds: Array.isArray(userIds) ? userIds : [] })).filter(e => e.userIds.length > 0); 196 278 }; 197 - 198 279 199 280 const getReplyIdFromMessage = (message) => { 200 281 const firstReply = Array.isArray(message?.replies) ? message.replies[0] : null; 201 - if (!firstReply) return null; 202 - return typeof firstReply === 'string' ? firstReply : firstReply.id || firstReply._id || null; 282 + return typeof firstReply === 'string' ? firstReply : firstReply?.id || firstReply?._id || null; 203 283 }; 204 - 205 284 206 285 const isCustomEmojiToken = (value) => /^:[A-Z0-9]{26}:$/i.test(value || ''); 207 - 208 286 const getCustomEmojiId = (token) => token?.slice(1, -1); 209 - 210 287 const resolveCustomEmojiMeta = (token, customEmojiById) => { 211 288 if (!isCustomEmojiToken(token)) return null; 212 289 const emojiId = getCustomEmojiId(token); 213 - const mapped = customEmojiById?.[emojiId]; 214 - return mapped || { id: emojiId, name: emojiId, serverName: null, isPrivate: false, unresolved: true }; 290 + return customEmojiById?.[emojiId] || { id: emojiId, name: emojiId, serverName: null, isPrivate: false, unresolved: true }; 215 291 }; 216 292 217 293 const renderEmojiVisual = (token, customEmoji, cdnUrl) => { 218 294 const emojiId = customEmoji?.id || (isCustomEmojiToken(token) ? getCustomEmojiId(token) : null); 219 - if (emojiId) { 220 - return <img alt={customEmoji?.name || token} className="inline h-5 w-5 align-text-bottom" src={`${cdnUrl}/emojis/${emojiId}`} />; 221 - } 295 + if (emojiId) return <img alt={customEmoji?.name || token} className="inline h-5 w-5 align-text-bottom" src={`${cdnUrl}/emojis/${emojiId}`} />; 296 + if (Object.values(EMOJI_SHORTCODES).includes(token) || EXTENDED_EMOJIS.includes(token) || !token.startsWith(':')) return renderTwemoji(token); 222 297 return token; 223 298 }; 224 299 225 - 226 - const isImageLike = (filename = '', contentType = '') => { 227 - if (typeof contentType === 'string' && contentType.startsWith('image/')) return true; 228 - return /\.(png|jpe?g|webp|gif|bmp|avif|svg)$/i.test(filename || ''); 229 - }; 230 - 300 + const isImageLike = (filename = '', contentType = '') => (typeof contentType === 'string' && contentType.startsWith('image/')) || /\.(png|jpe?g|webp|gif|bmp|avif|svg)$/i.test(filename || ''); 231 301 const getAttachmentData = (attachment, cdnUrl, index = 0) => { 232 302 const attachmentId = typeof attachment === 'string' ? attachment : attachment?._id || attachment?.id; 233 303 const filename = attachment?.filename || attachment?.name || `attachment-${index + 1}`; 234 304 const contentType = attachment?.content_type || attachment?.contentType || attachment?.metadata?.type || ''; 235 305 const url = attachmentId ? `${cdnUrl}/attachments/${attachmentId}` : null; 236 306 const image = isImageLike(filename, contentType); 237 - 238 307 return { attachmentId, filename, url, image }; 239 308 }; 240 309 241 - 242 310 const extractUrls = (value = '') => (value.match(/https?:\/\/[^\s]+/gi) || []); 243 - 244 - const isEmbeddableGifLink = (url) => { 245 - if (!url) return false; 246 - if (/^https?:\/\/media\.tenor\.com\//i.test(url)) return true; 247 - return /\.(gif)(\?.*)?$/i.test(url); 248 - }; 311 + const isEmbeddableGifLink = (url) => url && (/^https?:\/\/media\.tenor\.com\//i.test(url) || /\.(gif)(\?.*)?$/i.test(url)); 249 312 250 313 const renderMessageContent = (content, users, channels, onUserClick, customEmojiById, cdnUrl, onRequestOpenLink) => { 251 314 if (!content) return null; 252 - 253 315 const parts = content.split(/(<@!?[A-Za-z0-9]+>|<#[A-Za-z0-9]+>|:[A-Za-z0-9_+-]+:)/g); 254 - 255 316 return parts.map((part, index) => { 256 - if (!part) return null; 257 - 258 317 const userMention = part.match(/^<@!?([A-Za-z0-9]+)>$/); 259 318 if (userMention) { 260 319 const userId = userMention[1]; 261 320 const user = users[userId]; 262 - return ( 263 - <button 264 - className="mx-0.5 inline rounded bg-[#5865f2]/25 px-1 text-[#bdc3ff] hover:bg-[#5865f2]/35" 265 - key={`${part}-${index}`} 266 - onClick={() => onUserClick(user || { _id: userId, username: 'Unknown user' }, userId)} 267 - type="button" 268 - > 269 - @{user?.username || 'unknown'} 270 - </button> 271 - ); 321 + return <button className="mx-0.5 inline rounded bg-[#5865f2]/25 px-1 text-[#bdc3ff] hover:bg-[#5865f2]/35" key={`${part}-${index}`} onClick={() => onUserClick(user || { _id: userId, username: 'Unknown user' }, userId)}>@{user?.username || 'unknown'}</button>; 272 322 } 273 - 274 323 const channelMention = part.match(/^<#([A-Za-z0-9]+)>$/); 275 - if (channelMention) { 276 - const channelId = channelMention[1]; 277 - return ( 278 - <span className="mx-0.5 inline rounded bg-[#3f4249] px-1 text-gray-100" key={`${part}-${index}`}> 279 - #{channels[channelId]?.name || 'unknown-channel'} 280 - </span> 281 - ); 282 - } 283 - 324 + if (channelMention) return <span className="mx-0.5 inline rounded bg-[#3f4249] px-1 text-gray-100" key={`${part}-${index}`}>#{channels[channelMention[1]]?.name || 'unknown-channel'}</span>; 284 325 const emoji = part.match(/^:([a-z0-9_+-]+):$/i); 285 326 if (emoji) { 286 327 if (isCustomEmojiToken(part)) { 287 328 const customEmoji = resolveCustomEmojiMeta(part, customEmojiById); 288 - const source = customEmoji?.unresolved ? 'Custom emoji (external/unavailable metadata)' : customEmoji?.isPrivate ? 'Private server emoji' : customEmoji?.serverName ? `Server emoji from ${customEmoji.serverName}` : 'Custom emoji'; 289 - return ( 290 - <span className="mx-0.5 inline-flex items-center" key={`${part}-${index}`} title={`${customEmoji?.name || part} · ${source}`}> 291 - {renderEmojiVisual(part, customEmoji, cdnUrl)} 292 - </span> 293 - ); 329 + return <span className="mx-0.5 inline-flex items-center" key={`${part}-${index}`} title={`${customEmoji?.name || part}`}>{renderEmojiVisual(part, customEmoji, cdnUrl)}</span>; 294 330 } 295 - return EMOJI_SHORTCODES[emoji[1].toLowerCase()] || part; 331 + return renderTwemoji(EMOJI_SHORTCODES[emoji[1].toLowerCase()] || part); 296 332 } 297 - 298 - return <React.Fragment key={`${part}-${index}`}>{renderMarkdownInline(part, `md-${index}`, onRequestOpenLink)}</React.Fragment>; 333 + const textElement = renderMarkdownInline(part, `md-${index}`, onRequestOpenLink); 334 + return typeof textElement === 'string' ? <React.Fragment key={`${part}-${index}`}>{renderTwemoji(textElement)}</React.Fragment> : <React.Fragment key={`${part}-${index}`}>{textElement}</React.Fragment>; 299 335 }); 300 336 }; 301 337 302 - const Avatar = ({ user, cdnUrl, size = 'md', animateOnHover = false, alwaysAnimate = false }) => { 303 - const sizeMap = { 304 - sm: 'h-8 w-8 text-xs', 305 - md: 'h-10 w-10 text-sm', 306 - lg: 'h-12 w-12 text-base', 307 - }; 308 - 309 - const initials = (user?.username || '?').slice(0, 2).toUpperCase(); 310 - const [isHovered, setIsHovered] = useState(false); 311 - const src = (() => { 312 - if (!user?.avatar?._id) return null; 313 - const wantsAnimated = alwaysAnimate || (animateOnHover && isHovered); 314 - return wantsAnimated ? `${cdnUrl}/avatars/${user.avatar._id}/original` : `${cdnUrl}/avatars/${user.avatar._id}`; 315 - })(); 316 - 317 - return ( 318 - <div 319 - className={`grid shrink-0 place-items-center overflow-hidden rounded-full bg-[#313338] font-semibold text-gray-200 ${sizeMap[size]}`} 320 - onMouseEnter={() => setIsHovered(true)} 321 - onMouseLeave={() => setIsHovered(false)} 322 - > 323 - {src ? <img alt={user?.username || 'User avatar'} className="block h-full w-full object-cover object-center" src={src} /> : initials} 324 - </div> 325 - ); 326 - }; 327 - 328 338 const withoutClearedFields = (base, clear = []) => { 329 339 if (!clear?.length) return base; 330 340 const next = { ...base }; 331 - clear.forEach((field) => { 332 - const key = field?.[0]?.toLowerCase() + field?.slice(1); 333 - if (key) delete next[key]; 334 - }); 341 + clear.forEach((field) => { const key = field?.[0]?.toLowerCase() + field?.slice(1); if (key) delete next[key]; }); 335 342 return next; 336 343 }; 337 344 338 - const Modal = ({ title, onClose, children }) => ( 339 - <div className="fixed inset-0 z-50 grid place-items-center bg-black/70 p-4 backdrop-blur-sm"> 340 - <div className="w-full max-w-md overflow-hidden rounded-xl border border-[#202225] bg-[#2b2d31] shadow-2xl"> 341 - <div className="flex items-center justify-between border-b border-[#202225] px-4 py-3"> 342 - <h3 className="text-sm font-bold text-white">{title}</h3> 343 - <button className="rounded p-1 text-gray-400 hover:bg-[#3a3d42] hover:text-white" onClick={onClose}> 344 - <X size={16} /> 345 - </button> 346 - </div> 347 - <div className="space-y-3 p-4">{children}</div> 348 - </div> 349 - </div> 350 - ); 351 - 352 - 353 - class AppErrorBoundary extends React.Component { 354 - constructor(props) { 355 - super(props); 356 - this.state = { hasError: false }; 357 - } 358 - 359 - static getDerivedStateFromError() { 360 - return { hasError: true }; 361 - } 345 + const uniqueMessages = (list) => { 346 + const seen = new Set(); 347 + return list.filter((item) => { if (!item?._id || seen.has(item._id)) return false; seen.add(item._id); return true; }); 348 + }; 362 349 363 - componentDidCatch(error) { 364 - console.error('Ermine crashed:', error); 365 - } 350 + const sortRoles = (rolesMap) => !rolesMap ? [] : Object.entries(rolesMap).map(([id, role]) => ({ id, ...role })).sort((a, b) => (b.rank || 0) - (a.rank || 0)); 351 + const getMemberHighestRole = (member, sortedRoles) => { 352 + if (!member.roles?.length) return null; 353 + for (const role of sortedRoles) { if (member.roles.includes(role.id)) return role; } 354 + return null; 355 + }; 356 + const getMemberHoistedRole = (member, sortedRoles) => { 357 + if (!member.roles?.length) return null; 358 + for (const role of sortedRoles) { if (member.roles.includes(role.id) && role.hoist) return role; } 359 + return null; 360 + }; 361 + const organizeMembers = (members, rolesMap, users) => { 362 + const sortedRoles = sortRoles(rolesMap); 363 + const groups = {}; 364 + const onlineKey = 'Online'; 365 + const offlineKey = 'Offline'; 366 + sortedRoles.filter(r => r.hoist).forEach(role => { groups[role.id] = { id: role.id, name: role.name, color: role.colour, rank: role.rank, members: [] }; }); 367 + groups[onlineKey] = { id: onlineKey, name: 'Online', color: null, rank: -1, members: [] }; 368 + groups[offlineKey] = { id: offlineKey, name: 'Offline', color: null, rank: -2, members: [] }; 366 369 367 - render() { 368 - if (this.state.hasError) { 369 - return ( 370 - <div className="grid min-h-screen place-items-center bg-[#1e1f22] p-6 text-gray-100"> 371 - <div className="w-full max-w-lg rounded-2xl border border-[#202225] bg-[#2b2d31] p-8 text-center shadow-2xl"> 372 - <p className="text-xs font-semibold uppercase tracking-[0.2em] text-[#949ba4]">Well, this is awkward</p> 373 - <h1 className="mt-3 text-2xl font-bold text-white">Looks like Ermine crashed.</h1> 374 - <p className="mt-2 text-sm text-gray-300">Try reloading the app. If it keeps happening, relog and reconnect to stoat.chat.</p> 375 - <button 376 - className="mt-6 rounded-md bg-[#5865f2] px-4 py-2 text-sm font-semibold text-white hover:bg-[#4956d8]" 377 - onClick={() => window.location.reload()} 378 - type="button" 379 - > 380 - Reload Ermine 381 - </button> 382 - </div> 383 - </div> 384 - ); 370 + members.forEach(member => { 371 + const user = users[member._id.user]; 372 + if (!user) return; 373 + const hoistedRole = getMemberHoistedRole(member, sortedRoles); 374 + const highestRole = getMemberHighestRole(member, sortedRoles); 375 + const isOffline = !user.status || user.status.presence === 'Invisible' || user.status.presence === 'Offline'; 376 + const enrichedMember = { ...member, user, color: highestRole?.colour || null }; 377 + 378 + if (hoistedRole && !isOffline) { 379 + if (!groups[hoistedRole.id]) groups[hoistedRole.id] = { id: hoistedRole.id, name: hoistedRole.name, color: hoistedRole.colour, rank: hoistedRole.rank, members: [] }; 380 + groups[hoistedRole.id].members.push(enrichedMember); 381 + } else { 382 + groups[isOffline ? offlineKey : onlineKey].members.push(enrichedMember); 385 383 } 384 + }); 386 385 387 - return this.props.children; 388 - } 389 - } 386 + const flatList = []; 387 + const sortedGroups = Object.values(groups).filter(g => g.members.length > 0).sort((a, b) => b.rank - a.rank); 388 + sortedGroups.forEach(group => { 389 + flatList.push({ type: 'header', key: `header-${group.id}`, name: group.name, count: group.members.length, color: group.color }); 390 + const sortedMembers = group.members.sort((a, b) => a.user.username.localeCompare(b.user.username)); 391 + sortedMembers.forEach(m => flatList.push({ type: 'member', key: m._id.user, data: m })); 392 + }); 393 + return flatList; 394 + }; 390 395 391 - const Message = ({ message, users, channels, me, onUserClick, cdnUrl, onToggleReaction, onReply, replyTarget, onJumpToMessage, registerMessageRef, customEmojiById, reactionOptions, onRequestOpenLink }) => { 396 + // --- Message Component --- 397 + const Message = memo(({ 398 + message, users, channels, me, onUserClick, cdnUrl, 399 + onToggleReaction, onReply, replyTarget, onJumpToMessage, 400 + registerMessageRef, customEmojiById, reactionOptions, onRequestOpenLink, 401 + onEditMessage, onDeleteMessage, replyMessageMap 402 + }) => { 392 403 const authorId = typeof message.author === 'string' ? message.author : message.author?._id; 393 404 const author = users[authorId] || (typeof message.author === 'object' ? message.author : null) || { username: 'Unknown user' }; 394 405 const mine = me === authorId; 395 406 const messageReactions = toReactionEntries(message.reactions); 396 - const replyPreview = message.replyMessage; 407 + 408 + const replyId = getReplyIdFromMessage(message); 409 + const fullReplyMessage = replyId ? (replyMessageMap[replyId] || (message.replyMessage?._id === replyId ? message.replyMessage : null)) : null; 410 + const replyAuthorId = fullReplyMessage ? (typeof fullReplyMessage.author === 'string' ? fullReplyMessage.author : fullReplyMessage.author?._id) : message.replyMessage?.authorId; 411 + const replyUser = replyAuthorId ? users[replyAuthorId] : message.replyMessage?.authorUser; 412 + 397 413 const [showReactionPicker, setShowReactionPicker] = useState(false); 398 414 const [isMessageHovered, setIsMessageHovered] = useState(false); 399 415 const embeddedGifUrls = useMemo(() => extractUrls(message.content || '').filter(isEmbeddableGifLink), [message.content]); 400 416 const [resolvedGifUrls, setResolvedGifUrls] = useState({}); 417 + const [isEditing, setIsEditing] = useState(false); 418 + const [editContent, setEditContent] = useState(''); 401 419 402 420 useEffect(() => { 403 421 let cancelled = false; 404 - 405 422 const resolveTenorUrls = async () => { 406 423 const tenorLinks = embeddedGifUrls.filter((url) => /^https?:\/\/tenor\.com\//i.test(url)); 407 424 if (!tenorLinks.length) return; 408 - 409 - const results = await Promise.all( 410 - tenorLinks.map(async (url) => { 411 - try { 412 - const response = await fetch(`https://tenor.com/oembed?url=${encodeURIComponent(url)}`); 413 - if (!response.ok) return [url, url]; 414 - const data = await response.json(); 415 - return [url, data?.url || data?.thumbnail_url || url]; 416 - } catch { 417 - return [url, url]; 418 - } 419 - }), 420 - ); 421 - 422 - if (!cancelled) { 423 - setResolvedGifUrls((prev) => ({ ...prev, ...Object.fromEntries(results) })); 424 - } 425 + const results = await Promise.all(tenorLinks.map(async (url) => { 426 + try { 427 + const response = await fetch(`https://tenor.com/oembed?url=${encodeURIComponent(url)}`); 428 + if (!response.ok) return [url, url]; 429 + const data = await response.json(); 430 + return [url, data?.url || data?.thumbnail_url || url]; 431 + } catch { return [url, url]; } 432 + })); 433 + if (!cancelled) setResolvedGifUrls((prev) => ({ ...prev, ...Object.fromEntries(results) })); 425 434 }; 426 - 427 435 void resolveTenorUrls(); 428 - return () => { 429 - cancelled = true; 430 - }; 436 + return () => { cancelled = true; }; 431 437 }, [embeddedGifUrls]); 432 438 433 439 const contentWithoutEmbeddedGifLinks = useMemo(() => { 434 440 let next = message.content || ''; 435 - embeddedGifUrls.forEach((url) => { 436 - next = next.split(url).join(''); 437 - }); 441 + embeddedGifUrls.forEach((url) => { next = next.split(url).join(''); }); 438 442 return next.trim(); 439 443 }, [embeddedGifUrls, message.content]); 444 + 445 + const startEditing = () => { setEditContent(message.content || ''); setIsEditing(true); setShowReactionPicker(false); }; 446 + const saveEdit = () => { if (editContent.trim() !== message.content) onEditMessage(message._id, editContent); setIsEditing(false); }; 447 + const cancelEdit = () => { setIsEditing(false); setEditContent(''); }; 440 448 441 449 return ( 442 - <article className="group flex gap-3 rounded px-4 py-2 hover:bg-[#2e3035]" onMouseEnter={() => setIsMessageHovered(true)} onMouseLeave={() => setIsMessageHovered(false)} ref={(node) => registerMessageRef(message._id, node)}> 450 + <article 451 + className={`group relative flex gap-3 px-4 py-1 hover:bg-[#2e3035] ${replyTarget === message._id ? 'bg-[#3a3f66]/20' : ''}`} 452 + onMouseEnter={() => setIsMessageHovered(true)} 453 + onMouseLeave={() => setIsMessageHovered(false)} 454 + ref={(node) => registerMessageRef(message._id, node)} 455 + > 443 456 <button className="mt-0.5" onClick={() => onUserClick(author, authorId)} type="button"> 444 457 <Avatar alwaysAnimate={isMessageHovered} cdnUrl={cdnUrl} user={author} /> 445 458 </button> 446 459 <div className="min-w-0 flex-1"> 447 - {replyPreview ? ( 448 - <button 449 - className="mb-1 flex max-w-full items-center gap-1 text-xs text-gray-400 hover:text-gray-200" 450 - onClick={() => onJumpToMessage(replyPreview._id)} 451 - type="button" 452 - > 460 + {replyId ? ( 461 + <button className="mb-1 flex max-w-full items-center gap-1 text-xs text-gray-400 hover:text-gray-200" onClick={() => onJumpToMessage(replyId)} type="button"> 453 462 <Reply size={12} /> 454 - <span className="truncate">Replying to {replyPreview.authorUser?.username || 'Unknown user'}: {replyPreview.content || 'Attachment / embed'}</span> 463 + <span className="truncate">Replying to {replyUser?.username || 'Unknown user'}: {fullReplyMessage?.content || message.replyMessage?.content || 'Attachment / embed'}</span> 455 464 </button> 456 465 ) : null} 457 - <div className="flex items-baseline gap-2"> 458 - <button className="text-sm font-semibold text-white hover:underline" onClick={() => onUserClick(author, authorId)} type="button"> 459 - {author.username} 460 - </button> 461 - {mine && <span className="rounded bg-[#5865f2]/20 px-1.5 py-0.5 text-[10px] font-semibold text-[#bdc3ff]">YOU</span>} 462 - <time className="text-[11px] text-gray-500">{toTime(message.createdAt)}</time> 466 + 467 + <div className="flex items-baseline justify-between"> 468 + <div className="flex items-baseline gap-2"> 469 + <button className="text-sm font-semibold text-white hover:underline" onClick={() => onUserClick(author, authorId)} type="button" style={{ color: message.author?.color }}>{author.username}</button> 470 + {mine && <span className="rounded bg-[#5865f2]/20 px-1.5 py-0.5 text-[10px] font-semibold text-[#bdc3ff]">YOU</span>} 471 + <time className="text-[11px] text-gray-500">{toTime(message.createdAt)}</time> 472 + {message.edited && <span className="text-[10px] text-gray-500">(edited)</span>} 473 + </div> 463 474 </div> 464 - {contentWithoutEmbeddedGifLinks ? ( 465 - <p className="whitespace-pre-wrap break-words text-sm text-gray-200">{renderMessageContent(contentWithoutEmbeddedGifLinks, users, channels, onUserClick, customEmojiById, cdnUrl, onRequestOpenLink)}</p> 466 - ) : null} 475 + 476 + {isEditing ? ( 477 + <div className="mt-1 bg-[#383a40] p-2 rounded"> 478 + <input className="w-full bg-transparent text-gray-200 text-sm focus:outline-none mb-2" value={editContent} onChange={(e) => setEditContent(e.target.value)} autoFocus onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) saveEdit(); if (e.key === 'Escape') cancelEdit(); }} /> 479 + <div className="text-xs text-gray-400 flex gap-2"><span className="flex-1">escape to cancel • enter to save</span><button onClick={cancelEdit} className="hover:underline text-[#ed4245]">cancel</button><button onClick={saveEdit} className="hover:underline text-[#5865f2]">save</button></div> 480 + </div> 481 + ) : ( 482 + <> 483 + {contentWithoutEmbeddedGifLinks ? <p className="whitespace-pre-wrap break-words text-sm text-gray-200">{renderMessageContent(contentWithoutEmbeddedGifLinks, users, channels, onUserClick, customEmojiById, cdnUrl, onRequestOpenLink)}</p> : null} 484 + </> 485 + )} 486 + 467 487 {embeddedGifUrls.length ? ( 468 488 <div className="mt-1.5 flex flex-wrap gap-2"> 469 - {embeddedGifUrls.slice(0, 3).map((url) => { 489 + {embeddedGifUrls.slice(0, 3).map((url, idx) => { 470 490 const renderUrl = resolvedGifUrls[url] || url; 471 - return ( 472 - <button className="block overflow-hidden rounded border border-[#3a3d42]" key={url} onClick={() => onRequestOpenLink(url)} title={url} type="button"> 473 - <img alt="Embedded GIF" className="max-h-52 max-w-[320px] object-contain" src={renderUrl} /> 474 - </button> 475 - ); 491 + return <button className="block overflow-hidden rounded border border-[#3a3d42]" key={`${url}-${idx}`} onClick={() => onRequestOpenLink(url)} title={url} type="button"><img alt="Embedded GIF" className="max-h-52 max-w-[320px] object-contain" src={renderUrl} /></button>; 476 492 })} 477 493 </div> 478 494 ) : null} ··· 481 497 {message.attachments.map((attachment, index) => { 482 498 const { attachmentId, filename, url, image } = getAttachmentData(attachment, cdnUrl, index); 483 499 if (!attachmentId || !url) return null; 484 - 485 - if (image) { 486 - return ( 487 - <a className="block overflow-hidden rounded border border-[#3a3d42]" href={url} key={attachmentId} rel="noreferrer" target="_blank" title={filename}> 488 - <img alt={filename} className="max-h-52 max-w-[320px] object-contain" src={url} /> 489 - </a> 490 - ); 491 - } 492 - 493 - return ( 494 - <a 495 - className="rounded bg-[#232428] px-2 py-1 text-xs text-[#bdc3ff] hover:bg-[#2f3136]" 496 - href={url} 497 - key={attachmentId} 498 - rel="noreferrer" 499 - target="_blank" 500 - > 501 - 📎 {filename} 502 - </a> 503 - ); 500 + if (image) return <a className="block overflow-hidden rounded border border-[#3a3d42]" href={url} key={attachmentId} rel="noreferrer" target="_blank" title={filename}><img alt={filename} className="max-h-52 max-w-[320px] object-contain" src={url} /></a>; 501 + return <a className="rounded bg-[#232428] px-2 py-1 text-xs text-[#bdc3ff] hover:bg-[#2f3136]" href={url} key={attachmentId} rel="noreferrer" target="_blank">📎 {filename}</a>; 504 502 })} 505 503 </div> 506 504 ) : null} ··· 508 506 {messageReactions.map(({ emoji, userIds }) => { 509 507 const reacted = userIds.includes(me); 510 508 const customEmoji = resolveCustomEmojiMeta(emoji, customEmojiById); 511 - const source = customEmoji?.unresolved ? 'Custom emoji (external/unavailable metadata)' : customEmoji?.isPrivate ? 'Private server emoji' : customEmoji?.serverName ? `Server emoji from ${customEmoji.serverName}` : 'Emoji'; 512 509 return ( 510 + <button className={`rounded-full border px-2 py-0.5 text-xs flex items-center gap-1 ${reacted ? 'border-[#5865f2] bg-[#5865f2]/20 text-[#d7ddff]' : 'border-[#4c4f56] bg-[#2b2d31] text-gray-200 hover:bg-[#35373c]'}`} key={emoji} onClick={() => onToggleReaction(message, emoji, reacted)} title={`${customEmoji?.name || emoji}`} type="button"> 511 + {renderEmojiVisual(emoji, customEmoji, cdnUrl)} {userIds.length} 512 + </button> 513 + ); 514 + })} 515 + </div> 516 + </div> 517 + 518 + <div className={`absolute right-4 -top-2 z-10 ${isMessageHovered || showReactionPicker ? 'opacity-100' : 'opacity-0'} transition-opacity duration-100`}> 519 + <div className="flex items-center bg-[#313338] border border-[#2f3237] rounded shadow-sm"> 520 + <button className="p-1.5 text-gray-400 hover:text-gray-200 hover:bg-[#404249] rounded-l" onClick={() => onReply(message)} title="Reply"><Reply size={14} /></button> 521 + <button className="p-1.5 text-gray-400 hover:text-gray-200 hover:bg-[#404249]" onClick={() => setShowReactionPicker(!showReactionPicker)} title="Add Reaction"><Smile size={14} /></button> 522 + {mine && ( 523 + <> 524 + <button className="p-1.5 text-gray-400 hover:text-gray-200 hover:bg-[#404249]" onClick={startEditing} title="Edit"><Edit2 size={14} /></button> 525 + <button className="p-1.5 text-red-400 hover:text-red-200 hover:bg-[#404249] rounded-r" onClick={() => onDeleteMessage(message._id)} title="Delete"><Trash2 size={14} /></button> 526 + </> 527 + )} 528 + </div> 529 + </div> 530 + 531 + {/* Reaction Picker Overlay */} 532 + {showReactionPicker && ( 533 + <div className="absolute right-0 bottom-8 z-30 w-72 rounded-lg border border-[#4c4f56] bg-[#232428] p-2 shadow-2xl"> 534 + <p className="mb-1 px-1 text-[10px] uppercase tracking-wide text-gray-500">Add reaction</p> 535 + <div className="grid max-h-44 grid-cols-8 gap-1 overflow-y-auto"> 536 + {reactionOptions.map((option) => ( 513 537 <button 514 - className={`rounded-full border px-2 py-0.5 text-xs ${reacted ? 'border-[#5865f2] bg-[#5865f2]/20 text-[#d7ddff]' : 'border-[#4c4f56] bg-[#2b2d31] text-gray-200 hover:bg-[#35373c]'}`} 515 - key={emoji} 516 - onClick={() => onToggleReaction(message, emoji, reacted)} 517 - title={`${customEmoji?.name || emoji} · ${source}`} 538 + className="grid h-8 place-items-center rounded hover:bg-[#3a3d42]" 539 + key={option.value} 540 + onClick={() => { 541 + onToggleReaction(message, option.value, message.reactions?.[option.value]?.includes(me)); 542 + setShowReactionPicker(false); 543 + }} 544 + title={option.title} 518 545 type="button" 519 546 > 520 - {renderEmojiVisual(emoji, customEmoji, cdnUrl)} {userIds.length} 547 + {option.custom ? renderEmojiVisual(option.value, option.custom, cdnUrl) : renderTwemoji(option.label, "w-5 h-5")} 521 548 </button> 522 - ); 523 - })} 524 - <button className="rounded-full border border-[#4c4f56] px-2 py-0.5 text-xs text-gray-300 hover:bg-[#35373c]" onClick={() => onReply(message)} type="button"> 525 - <Reply size={12} className="inline" /> Reply 526 - </button> 527 - <div className="relative"> 528 - <button className="rounded-full border border-[#4c4f56] px-2 py-0.5 text-xs text-gray-300 hover:bg-[#35373c]" onClick={() => setShowReactionPicker((prev) => !prev)} type="button"> 529 - + React 530 - </button> 531 - {showReactionPicker ? ( 532 - <div className="absolute bottom-7 left-0 z-30 w-72 rounded-lg border border-[#4c4f56] bg-[#232428] p-2 shadow-2xl"> 533 - <p className="mb-1 px-1 text-[10px] uppercase tracking-wide text-gray-500">Add reaction</p> 534 - <div className="grid max-h-44 grid-cols-8 gap-1 overflow-y-auto"> 535 - {reactionOptions.map((option) => ( 536 - <button 537 - className="grid h-8 place-items-center rounded hover:bg-[#3a3d42]" 538 - key={option.value} 539 - onClick={() => { 540 - onToggleReaction(message, option.value, message.reactions?.[option.value]?.includes(me)); 541 - setShowReactionPicker(false); 542 - }} 543 - title={option.title} 544 - type="button" 545 - > 546 - {option.custom ? renderEmojiVisual(option.value, option.custom, cdnUrl) : option.label} 547 - </button> 548 - ))} 549 - </div> 550 - </div> 551 - ) : null} 549 + ))} 552 550 </div> 553 - {replyTarget === message._id ? <span className="text-[11px] text-[#bdc3ff]">Reply target</span> : null} 554 551 </div> 555 - </div> 552 + )} 556 553 </article> 557 554 ); 558 - }; 555 + }); 559 556 557 + // --- AppShell --- 560 558 function AppShell() { 561 559 const [view, setView] = useState('loading'); 562 560 const [status, setStatus] = useState('disconnected'); 563 561 const [config, setConfig] = useState({ apiUrl: DEFAULT_API_URL, wsUrl: DEFAULT_WS_URL, cdnUrl: DEFAULT_CDN_URL }); 564 562 const [auth, setAuth] = useState({ token: null, userId: null }); 565 563 564 + const [peekUser, setPeekUser] = useState(null); 565 + const openUserProfile = useCallback((user, fallbackId = null) => { 566 + const stableId = user?._id || fallbackId || 'unknown-user'; 567 + setPeekUser({ 568 + ...user, 569 + _id: stableId, 570 + username: user?.username || 'Unknown user', 571 + discriminator: user?.discriminator || '0000', 572 + }); 573 + }, []); 574 + 566 575 const [servers, setServers] = useState({}); 567 576 const [channels, setChannels] = useState({}); 568 577 const [users, setUsers] = useState({}); ··· 576 585 const [showAdvanced, setShowAdvanced] = useState(false); 577 586 const [activeModal, setActiveModal] = useState(null); 578 587 const [createServerName, setCreateServerName] = useState(''); 579 - const [peekUser, setPeekUser] = useState(null); 580 588 const [isMembersLoading, setIsMembersLoading] = useState(false); 581 589 const [replyingTo, setReplyingTo] = useState(null); 582 590 const [showEmojiPicker, setShowEmojiPicker] = useState(false); ··· 589 597 const [isSavingStatus, setIsSavingStatus] = useState(false); 590 598 const [isAccountHovered, setIsAccountHovered] = useState(false); 591 599 const [privacyAcknowledged, setPrivacyAcknowledged] = useState(false); 600 + 601 + const [editServerName, setEditServerName] = useState(''); 602 + const [isUpdatingServer, setIsUpdatingServer] = useState(false); 592 603 593 604 const [loginMode, setLoginMode] = useState('credentials'); 594 605 const [email, setEmail] = useState(''); ··· 629 640 [channels], 630 641 ); 631 642 632 - const currentMessages = useMemo(() => messages[selectedChannelId] || [], [messages, selectedChannelId]); 643 + const currentMessages = useMemo(() => uniqueMessages(messages[selectedChannelId] || []), [messages, selectedChannelId]); 633 644 const currentMessageMap = useMemo(() => Object.fromEntries(currentMessages.map((message) => [message._id, message])), [currentMessages]); 645 + 646 + const globalReplyMessageMap = useMemo(() => ({ 647 + ...currentMessageMap, 648 + ...replyMessageCacheRef.current 649 + }), [currentMessageMap]); 634 650 635 - const allCurrentMembers = useMemo(() => { 636 - if (selectedServerId === '@me') return []; 637 - return Object.values(members) 638 - .filter((member) => member._id.server === selectedServerId) 639 - .map((member) => ({ ...member, user: users[member._id.user] })) 640 - .filter((member) => Boolean(member.user)); 641 - }, [members, selectedServerId, users]); 651 + const activeReply = useMemo(() => { 652 + if (!replyingTo) return null; 653 + return currentMessageMap[replyingTo._id] || replyingTo; 654 + }, [replyingTo, currentMessageMap]); 642 655 643 - const currentMembers = useMemo( 644 - () => allCurrentMembers.slice(0, MEMBER_RENDER_LIMIT).sort((a, b) => (a.nickname || a.user.username).localeCompare(b.nickname || b.user.username)), 645 - [allCurrentMembers], 646 - ); 656 + const currentChannelName = useMemo(() => { 657 + if (selectedChannelId === 'friends') return 'friends'; 658 + const channel = channels[selectedChannelId]; 659 + if (channel?.name) return channel.name; 660 + if (channel?.channel_type === 'DirectMessage') { 661 + const recipientId = (channel.recipients || []).find((id) => id !== auth.userId); 662 + return users[recipientId]?.username || 'direct-message'; 663 + } 664 + return 'select-a-channel'; 665 + }, [selectedChannelId, channels, auth.userId, users]); 647 666 648 - const hiddenMembersCount = Math.max(0, allCurrentMembers.length - currentMembers.length); 649 - 650 - const openUserProfile = (user, fallbackId = null) => { 651 - const stableId = user?._id || fallbackId || 'unknown-user'; 652 - setPeekUser({ 653 - ...user, 654 - _id: stableId, 655 - username: user?.username || 'Unknown user', 656 - discriminator: user?.discriminator || '0000', 657 - }); 658 - }; 667 + const allCurrentMembers = useMemo(() => { 668 + if (selectedServerId === '@me') return []; 669 + return Object.values(members).filter((member) => member._id.server === selectedServerId); 670 + }, [members, selectedServerId]); 659 671 660 672 const friends = useMemo( 661 673 () => Object.values(users).filter((u) => u.relationship === 'Friend' || u.relationship === 1), ··· 663 675 ); 664 676 665 677 const selectedServer = servers[selectedServerId]; 678 + const isServerOwner = selectedServer?.owner === auth.userId; 679 + 680 + const memberListItems = useMemo(() => { 681 + if (selectedServerId === '@me' || !selectedServer) return []; 682 + return organizeMembers(allCurrentMembers, selectedServer.roles || {}, users); 683 + }, [allCurrentMembers, selectedServer, users, selectedServerId]); 666 684 667 685 const customEmojis = useMemo(() => { 668 686 if (!selectedServer || selectedServerId === '@me') return []; 669 687 const source = selectedServer.emojis || selectedServer.emoji || []; 670 - if (Array.isArray(source)) { 671 - return source.map((entry) => ({ 672 - id: entry?._id || entry?.id, 673 - name: entry?.name || entry?._id || 'emoji', 674 - })); 675 - } 676 - 677 - return Object.entries(source).map(([id, value]) => ({ 678 - id, 679 - name: value?.name || id, 680 - })); 688 + if (Array.isArray(source)) return source.map((entry) => ({ id: entry?._id || entry?.id, name: entry?.name || entry?._id || 'emoji' })); 689 + return Object.entries(source).map(([id, value]) => ({ id, name: value?.name || id })); 681 690 }, [selectedServer, selectedServerId]); 682 691 683 - 684 692 const customEmojiById = useMemo(() => { 685 693 const index = {}; 686 694 Object.values(servers).forEach((server) => { 687 695 const source = server?.emojis || server?.emoji || []; 688 - const entries = Array.isArray(source) 689 - ? source.map((emoji) => ({ id: emoji?._id || emoji?.id, name: emoji?.name })) 690 - : Object.entries(source || {}).map(([id, emoji]) => ({ id, name: emoji?.name })); 691 - 692 - entries.forEach((emoji) => { 693 - if (!emoji?.id) return; 694 - index[emoji.id] = { 695 - id: emoji.id, 696 - name: emoji.name || emoji.id, 697 - serverName: server?.name, 698 - isPrivate: server?.discoverable === false, 699 - }; 700 - }); 696 + const entries = Array.isArray(source) ? source.map((emoji) => ({ id: emoji?._id || emoji?.id, name: emoji?.name })) : Object.entries(source || {}).map(([id, emoji]) => ({ id, name: emoji?.name })); 697 + entries.forEach((emoji) => { if (emoji?.id) index[emoji.id] = { id: emoji.id, name: emoji.name || emoji.id, serverName: server?.name, isPrivate: server?.discoverable === false }; }); 701 698 }); 702 699 return index; 703 700 }, [servers]); ··· 705 702 const reactionOptions = useMemo(() => { 706 703 const base = EXTENDED_EMOJIS.map((emoji) => ({ value: emoji, label: emoji, title: emoji, custom: null })); 707 704 const serverSet = customEmojis.slice(0, 30).map((emoji) => { 708 - const value = `:${emoji.id}:`; 709 705 const custom = customEmojiById[emoji.id]; 710 706 const source = custom?.isPrivate ? 'Private server emoji' : custom?.serverName ? `Server emoji from ${custom.serverName}` : 'Custom emoji'; 711 - return { value, label: emoji.name, title: `${emoji.name} · ${source}`, custom }; 707 + return { value: `:${emoji.id}:`, label: emoji.name, title: `${emoji.name} · ${source}`, custom }; 712 708 }); 713 709 return [...base, ...serverSet]; 714 710 }, [customEmojis, customEmojiById]); 715 711 716 - 717 712 const peekMember = useMemo(() => { 718 713 if (!peekUser?._id || selectedServerId === '@me') return null; 719 714 return members[`${selectedServerId}:${peekUser._id}`] || null; ··· 728 723 if (!peekUser?.badges) return []; 729 724 if (Array.isArray(peekUser.badges)) return peekUser.badges; 730 725 if (typeof peekUser.badges === 'number') { 731 - const flagMap = { 732 - 1: 'Developer', 733 - 2: 'Translator', 734 - 4: 'Supporter', 735 - 8: 'Founder', 736 - 16: 'Platform Moderation', 737 - 32: 'Active Supporter', 738 - 64: 'Paw', 739 - }; 740 - return Object.entries(flagMap) 741 - .filter(([bit]) => (peekUser.badges & Number(bit)) === Number(bit)) 742 - .map(([, label]) => label); 743 - } 744 - if (typeof peekUser.badges === 'object') { 745 - return Object.entries(peekUser.badges) 746 - .filter(([, enabled]) => Boolean(enabled)) 747 - .map(([badge]) => badge); 726 + const flagMap = { 1: 'Developer', 2: 'Translator', 4: 'Supporter', 8: 'Founder', 16: 'Platform Moderation', 32: 'Active Supporter', 64: 'Paw' }; 727 + return Object.entries(flagMap).filter(([bit]) => (peekUser.badges & Number(bit)) === Number(bit)).map(([, label]) => label); 748 728 } 729 + if (typeof peekUser.badges === 'object') return Object.entries(peekUser.badges).filter(([, enabled]) => Boolean(enabled)).map(([badge]) => badge); 749 730 return []; 750 731 }, [peekUser]); 751 732 752 - 753 - const peekAboutMe = useMemo( 754 - () => peekUser?.profile?.content || peekUser?.profile?.bio || peekUser?.bio || null, 755 - [peekUser], 756 - ); 757 - 733 + const peekAboutMe = useMemo(() => peekUser?.profile?.content || peekUser?.profile?.bio || peekUser?.bio || null, [peekUser]); 758 734 const peekPlatformJoined = useMemo(() => getJoinedAt(peekUser), [peekUser]); 759 735 const peekServerJoined = useMemo(() => getJoinedAt(peekMember), [peekMember]); 760 736 const peekBannerUrl = useMemo(() => getBannerUrl(peekUser, config.cdnUrl), [peekUser, config.cdnUrl]); 761 737 762 738 const isSameOriginApi = useMemo(() => { 763 - try { 764 - return new URL(config.apiUrl).origin === window.location.origin; 765 - } catch { 766 - return false; 767 - } 739 + try { return new URL(config.apiUrl).origin === window.location.origin; } catch { return false; } 768 740 }, [config.apiUrl]); 769 741 770 742 const upsertUsers = (list = []) => { 771 743 if (!list.length) return; 772 744 setUsers((prev) => { 773 745 const next = { ...prev }; 774 - list.forEach((user) => { 775 - if (user?._id) next[user._id] = user; 776 - }); 746 + list.forEach((user) => { if (user?._id) next[user._id] = user; }); 777 747 return next; 778 748 }); 779 749 }; 780 750 781 751 const upsertUsersFromMessages = (messageList = []) => { 782 752 const embeddedUsers = []; 783 - 784 753 messageList.forEach((entry) => { 785 754 const author = entry?.author; 786 755 if (author && typeof author === 'object' && author._id) embeddedUsers.push(author); 787 756 if (entry?.user?._id) embeddedUsers.push(entry.user); 788 757 }); 789 - 790 758 upsertUsers(embeddedUsers); 791 759 }; 792 760 793 761 const fetchMissingUsers = async (messageList = []) => { 794 762 if (!canFetchMissingUsersRef.current || !isSameOriginApi) return; 795 - 796 763 const unresolved = new Set(); 797 - 798 764 messageList.forEach((entry) => { 799 765 const authorId = typeof entry?.author === 'string' ? entry.author : entry?.author?._id; 800 - if (!authorId || authorId === '00000000000000000000000000' || users[authorId] || pendingUserFetchRef.current.has(authorId)) return; 801 - unresolved.add(authorId); 766 + if (authorId && authorId !== '00000000000000000000000000' && !users[authorId] && !pendingUserFetchRef.current.has(authorId)) unresolved.add(authorId); 767 + const replyId = getReplyIdFromMessage(entry); 768 + if (replyId) { 769 + const cachedReply = replyMessageCacheRef.current[replyId]; 770 + const replyAuthorId = cachedReply ? (typeof cachedReply.author === 'string' ? cachedReply.author : cachedReply.author?._id) : entry.replyMessage?.authorId; 771 + if (replyAuthorId && replyAuthorId !== '00000000000000000000000000' && !users[replyAuthorId] && !pendingUserFetchRef.current.has(replyAuthorId)) unresolved.add(replyAuthorId); 772 + } 802 773 }); 803 - 804 774 if (!unresolved.size) return; 805 - 806 775 const MAX_BACKGROUND_USER_FETCHES = 6; 807 776 const limitedIds = [...unresolved].slice(0, MAX_BACKGROUND_USER_FETCHES); 808 777 limitedIds.forEach((id) => pendingUserFetchRef.current.add(id)); 809 - 810 778 await Promise.all( 811 779 limitedIds.map(async (userId) => { 812 780 try { 813 - const res = await fetch(`${config.apiUrl}/users/${userId}`, { 814 - headers: { 'x-session-token': auth.token }, 815 - }); 816 - if (!res.ok) { 817 - if (res.status === 401 || res.status === 403 || res.status === 405) { 818 - canFetchMissingUsersRef.current = false; 819 - } 820 - return; 821 - } 781 + const res = await fetch(`${config.apiUrl}/users/${userId}`, { headers: { 'x-session-token': auth.token } }); 782 + if (!res.ok) { if (res.status === 401 || res.status === 403 || res.status === 405) canFetchMissingUsersRef.current = false; return; } 822 783 const data = await res.json(); 823 784 if (data?._id) upsertUsers([data]); 824 785 } catch (error) { 825 786 const message = (error && error.message) || ''; 826 - if (message.includes('Failed to fetch') || message.includes('CORS')) { 827 - canFetchMissingUsersRef.current = false; 828 - } 829 - if (!loggedMissingUserFetchErrorRef.current) { 830 - loggedMissingUserFetchErrorRef.current = true; 831 - console.warn('Disabling background user lookup due to network/CORS restrictions.'); 832 - } 833 - } finally { 834 - pendingUserFetchRef.current.delete(userId); 835 - } 787 + if (message.includes('Failed to fetch') || message.includes('CORS')) canFetchMissingUsersRef.current = false; 788 + if (!loggedMissingUserFetchErrorRef.current) { loggedMissingUserFetchErrorRef.current = true; console.warn('Disabling background user lookup due to network/CORS restrictions.'); } 789 + } finally { pendingUserFetchRef.current.delete(userId); } 836 790 }), 837 791 ); 838 792 }; ··· 842 796 const res = await fetch(apiUrl, { headers: { Accept: 'application/json' } }); 843 797 if (!res.ok) return; 844 798 const data = await res.json(); 845 - setConfig((prev) => ({ 846 - ...prev, 847 - apiUrl, 848 - wsUrl: data.ws || prev.wsUrl, 849 - cdnUrl: data.features?.autumn?.url || prev.cdnUrl, 850 - })); 851 - } catch { 852 - // no-op 853 - } 799 + setConfig((prev) => ({ ...prev, apiUrl, wsUrl: data.ws || prev.wsUrl, cdnUrl: data.features?.autumn?.url || prev.cdnUrl })); 800 + } catch {} 854 801 }; 855 802 856 803 useEffect(() => { 857 804 const savedToken = getCookie(TOKEN_COOKIE_NAME) || localStorage.getItem('stoat_token'); 858 805 const savedUserId = getCookie(USER_COOKIE_NAME) || localStorage.getItem('stoat_user_id'); 859 806 const savedApi = getCookie(API_COOKIE_NAME) || localStorage.getItem('stoat_api_url'); 860 - 861 807 const api = savedApi || DEFAULT_API_URL; 862 808 setConfig((prev) => ({ ...prev, apiUrl: api })); 863 809 discoverConfig(api); 864 - 865 - if (savedToken && savedUserId) { 866 - setAuth({ token: savedToken, userId: savedUserId }); 867 - setView('app'); 868 - } else { 869 - setView('login'); 870 - } 810 + if (savedToken && savedUserId) { setAuth({ token: savedToken, userId: savedUserId }); setView('app'); } else { setView('login'); } 871 811 }, []); 872 812 873 813 const applyEvent = (packet) => { 874 814 if (!packet?.type) return; 875 - 876 815 switch (packet.type) { 877 816 case 'Ready': 878 - setUsers((prev) => { 879 - const next = { ...prev }; 880 - packet.users?.forEach((u) => { 881 - next[u._id] = u; 882 - }); 883 - return next; 884 - }); 885 - setServers((prev) => { 886 - const next = { ...prev }; 887 - packet.servers?.forEach((s) => { 888 - next[s._id] = s; 889 - }); 890 - return next; 891 - }); 892 - setChannels((prev) => { 893 - const next = { ...prev }; 894 - packet.channels?.forEach((c) => { 895 - next[c._id] = c; 896 - }); 897 - return next; 898 - }); 899 - setMembers((prev) => { 900 - const next = { ...prev }; 901 - packet.members?.forEach((m) => { 902 - next[`${m._id.server}:${m._id.user}`] = m; 903 - }); 904 - return next; 905 - }); 906 - setStatus('ready'); 907 - break; 908 - case 'Bulk': 909 - packet.v?.forEach(applyEvent); 910 - break; 911 - case 'Authenticated': 912 - setStatus('authenticated'); 913 - break; 914 - case 'Error': 915 - setStatus(`error:${packet.error || 'unknown'}`); 916 - break; 917 - case 'Logout': 918 - logout(); 919 - break; 817 + setUsers((prev) => { const next = { ...prev }; packet.users?.forEach((u) => { next[u._id] = u; }); return next; }); 818 + setServers((prev) => { const next = { ...prev }; packet.servers?.forEach((s) => { next[s._id] = s; }); return next; }); 819 + setChannels((prev) => { const next = { ...prev }; packet.channels?.forEach((c) => { next[c._id] = c; }); return next; }); 820 + setMembers((prev) => { const next = { ...prev }; packet.members?.forEach((m) => { next[`${m._id.server}:${m._id.user}`] = m; }); return next; }); 821 + setStatus('ready'); break; 822 + case 'Bulk': packet.v?.forEach(applyEvent); break; 823 + case 'Authenticated': setStatus('authenticated'); break; 824 + case 'Error': setStatus(`error:${packet.error || 'unknown'}`); break; 825 + case 'Logout': logout(); break; 920 826 case 'Message': 921 827 upsertUsersFromMessages([packet]); 922 828 void fetchMissingUsers([packet]); 923 829 setMessages((prev) => { 924 830 const list = prev[packet.channel] || []; 925 - if (list.find((m) => m._id === packet._id)) return prev; 831 + if (list.some((m) => m._id === packet._id)) return prev; 926 832 const nextMessages = [...list, packet].slice(-200); 927 - return { ...prev, [packet.channel]: enrichReplies(nextMessages, packet.channel) }; 928 - }); 929 - break; 833 + return { ...prev, [packet.channel]: uniqueMessages(nextMessages) }; 834 + }); break; 930 835 case 'MessageUpdate': 931 - setMessages((prev) => ({ 932 - ...prev, 933 - [packet.channel]: enrichReplies((prev[packet.channel] || []).map((m) => (m._id === packet.id ? { ...m, ...packet.data } : m)), packet.channel), 934 - })); 935 - break; 836 + setMessages((prev) => ({ ...prev, [packet.channel]: uniqueMessages((prev[packet.channel] || []).map((m) => (m._id === packet.id ? { ...m, ...packet.data, edited: new Date().toISOString() } : m))) })); break; 936 837 case 'MessageDelete': 937 - setMessages((prev) => ({ 938 - ...prev, 939 - [packet.channel]: (prev[packet.channel] || []).filter((m) => m._id !== packet.id), 940 - })); 941 - break; 942 - case 'ChannelCreate': 943 - setChannels((prev) => ({ ...prev, [packet._id]: packet })); 944 - break; 945 - case 'ChannelUpdate': 946 - setChannels((prev) => ({ 947 - ...prev, 948 - [packet.id]: withoutClearedFields({ ...prev[packet.id], ...packet.data }, packet.clear), 949 - })); 950 - break; 951 - case 'ChannelDelete': 952 - setChannels((prev) => { 953 - const next = { ...prev }; 954 - delete next[packet.id]; 955 - return next; 956 - }); 957 - break; 958 - case 'ServerCreate': 959 - setServers((prev) => ({ ...prev, [packet._id]: packet })); 960 - break; 961 - case 'ServerUpdate': 962 - setServers((prev) => ({ 963 - ...prev, 964 - [packet.id]: withoutClearedFields({ ...prev[packet.id], ...packet.data }, packet.clear), 965 - })); 966 - break; 967 - case 'ServerDelete': 968 - setServers((prev) => { 969 - const next = { ...prev }; 970 - delete next[packet.id]; 971 - return next; 972 - }); 973 - break; 974 - case 'ServerMemberUpdate': { 975 - const key = `${packet.id.server}:${packet.id.user}`; 976 - setMembers((prev) => ({ 977 - ...prev, 978 - [key]: withoutClearedFields({ ...prev[key], _id: packet.id, ...packet.data }, packet.clear), 979 - })); 980 - break; 981 - } 982 - case 'ServerMemberJoin': 983 - setMembers((prev) => ({ ...prev, [`${packet.id}:${packet.user}`]: { ...packet.member, _id: { server: packet.id, user: packet.user } } })); 984 - break; 985 - case 'ServerMemberLeave': 986 - setMembers((prev) => { 987 - const next = { ...prev }; 988 - delete next[`${packet.id}:${packet.user}`]; 989 - return next; 990 - }); 991 - break; 992 - case 'UserUpdate': 993 - setUsers((prev) => ({ 994 - ...prev, 995 - [packet.id]: withoutClearedFields({ ...prev[packet.id], ...packet.data }, packet.clear), 996 - })); 997 - break; 838 + setMessages((prev) => ({ ...prev, [packet.channel]: (prev[packet.channel] || []).filter((m) => m._id !== packet.id) })); break; 839 + case 'ChannelCreate': setChannels((prev) => ({ ...prev, [packet._id]: packet })); break; 840 + case 'ChannelUpdate': setChannels((prev) => ({ ...prev, [packet.id]: withoutClearedFields({ ...prev[packet.id], ...packet.data }, packet.clear) })); break; 841 + case 'ChannelDelete': setChannels((prev) => { const next = { ...prev }; delete next[packet.id]; return next; }); break; 842 + case 'ServerCreate': setServers((prev) => ({ ...prev, [packet._id]: packet })); break; 843 + case 'ServerUpdate': setServers((prev) => ({ ...prev, [packet.id]: withoutClearedFields({ ...prev[packet.id], ...packet.data }, packet.clear) })); break; 844 + case 'ServerDelete': setServers((prev) => { const next = { ...prev }; delete next[packet.id]; return next; }); if (selectedServerId === packet.id) handleServerSelect('@me'); break; 845 + case 'ServerMemberUpdate': { const key = `${packet.id.server}:${packet.id.user}`; setMembers((prev) => ({ ...prev, [key]: withoutClearedFields({ ...prev[key], _id: packet.id, ...packet.data }, packet.clear) })); break; } 846 + case 'ServerMemberJoin': setMembers((prev) => ({ ...prev, [`${packet.id}:${packet.user}`]: { ...packet.member, _id: { server: packet.id, user: packet.user } } })); break; 847 + case 'ServerMemberLeave': setMembers((prev) => { const next = { ...prev }; delete next[`${packet.id}:${packet.user}`]; return next; }); break; 848 + case 'UserUpdate': setUsers((prev) => ({ ...prev, [packet.id]: withoutClearedFields({ ...prev[packet.id], ...packet.data }, packet.clear) })); break; 998 849 default: 999 850 } 1000 851 }; 1001 852 1002 853 useEffect(() => { 1003 - if (!auth.token || view !== 'app') return undefined; 1004 - 854 + if (!auth.token || view !== 'app') return; 1005 855 let wsUrl = config.wsUrl; 1006 - try { 1007 - const parsed = new URL(wsUrl.startsWith('ws') ? wsUrl : `wss://${wsUrl}`); 1008 - parsed.searchParams.set('version', '1'); 1009 - parsed.searchParams.set('format', 'json'); 1010 - parsed.searchParams.set('token', auth.token); 1011 - wsUrl = parsed.toString(); 1012 - } catch { 1013 - wsUrl = `${DEFAULT_WS_URL}?version=1&format=json&token=${encodeURIComponent(auth.token)}`; 1014 - } 1015 - 856 + try { const parsed = new URL(wsUrl.startsWith('ws') ? wsUrl : `wss://${wsUrl}`); parsed.searchParams.set('version', '1'); parsed.searchParams.set('format', 'json'); parsed.searchParams.set('token', auth.token); wsUrl = parsed.toString(); } catch { wsUrl = `${DEFAULT_WS_URL}?version=1&format=json&token=${encodeURIComponent(auth.token)}`; } 1016 857 setStatus('connecting'); 1017 858 if (wsRef.current) wsRef.current.close(); 1018 - if (wsReconnectTimeoutRef.current) { 1019 - clearTimeout(wsReconnectTimeoutRef.current); 1020 - wsReconnectTimeoutRef.current = null; 1021 - } 1022 - 859 + if (wsReconnectTimeoutRef.current) { clearTimeout(wsReconnectTimeoutRef.current); wsReconnectTimeoutRef.current = null; } 1023 860 let isUnmounting = false; 1024 861 const socket = new WebSocket(wsUrl); 1025 862 wsRef.current = socket; 1026 - 1027 863 socket.onerror = () => setStatus('error'); 1028 - socket.onclose = () => { 1029 - setStatus((prev) => (prev === 'error' ? prev : 'disconnected')); 1030 - if (isUnmounting || view !== 'app') return; 1031 - wsReconnectTimeoutRef.current = setTimeout(() => { 1032 - setWsReconnectAttempt((value) => value + 1); 1033 - }, 2500); 1034 - }; 1035 - 864 + socket.onclose = () => { setStatus((prev) => (prev === 'error' ? prev : 'disconnected')); if (isUnmounting || view !== 'app') return; wsReconnectTimeoutRef.current = setTimeout(() => { setWsReconnectAttempt((value) => value + 1); }, 2500); }; 1036 865 socket.onmessage = (event) => applyEvent(JSON.parse(event.data)); 1037 - 1038 - const heartbeat = setInterval(() => { 1039 - if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ type: 'Ping', data: Date.now() })); 1040 - }, 20000); 1041 - 1042 - return () => { 1043 - isUnmounting = true; 1044 - clearInterval(heartbeat); 1045 - if (wsReconnectTimeoutRef.current) { 1046 - clearTimeout(wsReconnectTimeoutRef.current); 1047 - wsReconnectTimeoutRef.current = null; 1048 - } 1049 - socket.close(); 1050 - }; 866 + const heartbeat = setInterval(() => { if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ type: 'Ping', data: Date.now() })); }, 20000); 867 + return () => { isUnmounting = true; clearInterval(heartbeat); if (wsReconnectTimeoutRef.current) { clearTimeout(wsReconnectTimeoutRef.current); wsReconnectTimeoutRef.current = null; } socket.close(); }; 1051 868 }, [auth.token, config.wsUrl, view, wsReconnectAttempt]); 1052 869 1053 870 useEffect(() => { 1054 871 const socket = wsRef.current; 1055 - if (!socket || socket.readyState !== WebSocket.OPEN || selectedServerId === '@me') return undefined; 1056 - 1057 - const sendSubscribe = () => { 1058 - if (document.visibilityState !== 'visible') return; 1059 - if (!window.document.hasFocus()) return; 1060 - 1061 - const lastSent = subscriptionRef.current[selectedServerId] || 0; 1062 - const tenMinutes = 10 * 60 * 1000; 1063 - if (Date.now() - lastSent < tenMinutes) return; 1064 - 1065 - socket.send(JSON.stringify({ type: 'Subscribe', server_id: selectedServerId })); 1066 - subscriptionRef.current[selectedServerId] = Date.now(); 1067 - }; 1068 - 872 + if (!socket || socket.readyState !== WebSocket.OPEN || selectedServerId === '@me') return; 873 + const sendSubscribe = () => { if (document.visibilityState !== 'visible' || !window.document.hasFocus()) return; const lastSent = subscriptionRef.current[selectedServerId] || 0; if (Date.now() - lastSent < 10 * 60 * 1000) return; socket.send(JSON.stringify({ type: 'Subscribe', server_id: selectedServerId })); subscriptionRef.current[selectedServerId] = Date.now(); }; 1069 874 sendSubscribe(); 1070 - window.addEventListener('focus', sendSubscribe); 1071 - document.addEventListener('visibilitychange', sendSubscribe); 1072 - const interval = setInterval(sendSubscribe, 60 * 1000); 1073 - 1074 - return () => { 1075 - clearInterval(interval); 1076 - window.removeEventListener('focus', sendSubscribe); 1077 - document.removeEventListener('visibilitychange', sendSubscribe); 1078 - }; 875 + window.addEventListener('focus', sendSubscribe); document.addEventListener('visibilitychange', sendSubscribe); const interval = setInterval(sendSubscribe, 60 * 1000); 876 + return () => { clearInterval(interval); window.removeEventListener('focus', sendSubscribe); document.removeEventListener('visibilitychange', sendSubscribe); }; 1079 877 }, [selectedServerId, status]); 1080 878 1081 - const isNearBottom = () => { 1082 - const container = messagesContainerRef.current; 1083 - if (!container) return true; 1084 - const threshold = 72; 1085 - return container.scrollHeight - container.scrollTop - container.clientHeight <= threshold; 1086 - }; 879 + const isNearBottom = () => { const container = messagesContainerRef.current; if (!container) return true; return container.scrollHeight - container.scrollTop - container.clientHeight <= 72; }; 1087 880 1088 881 useEffect(() => { 1089 882 const container = messagesContainerRef.current; 1090 883 if (!container) return; 1091 - 1092 884 autoFollowRef.current = true; 1093 - 1094 - const handleScroll = () => { 1095 - if (isProgrammaticScrollRef.current) return; 1096 - const nearBottom = isNearBottom(); 1097 - autoFollowRef.current = nearBottom; 1098 - setShowGoLatest((prev) => (prev === !nearBottom ? prev : !nearBottom)); 1099 - }; 1100 - 885 + const handleScroll = () => { if (isProgrammaticScrollRef.current) return; const nearBottom = isNearBottom(); autoFollowRef.current = nearBottom; setShowGoLatest((prev) => (prev === !nearBottom ? prev : !nearBottom)); }; 1101 886 container.addEventListener('scroll', handleScroll); 1102 887 return () => container.removeEventListener('scroll', handleScroll); 1103 888 }, [selectedChannelId]); ··· 1106 891 if (autoFollowRef.current || isNearBottom()) { 1107 892 isProgrammaticScrollRef.current = true; 1108 893 messagesBottomRef.current?.scrollIntoView({ behavior: 'auto' }); 1109 - requestAnimationFrame(() => { 1110 - isProgrammaticScrollRef.current = false; 1111 - }); 894 + requestAnimationFrame(() => { isProgrammaticScrollRef.current = false; }); 1112 895 autoFollowRef.current = true; 1113 896 setShowGoLatest(false); 1114 897 return; 1115 898 } 1116 - 1117 899 setShowGoLatest(true); 1118 900 }, [currentMessages]); 1119 901 1120 902 useEffect(() => { 1121 903 if (!selectedChannelId || selectedChannelId === 'friends') return; 1122 - 1123 - const missingReplyIds = currentMessages 1124 - .map((message) => getReplyIdFromMessage(message)) 1125 - .filter((replyId) => replyId && !replyMessageCacheRef.current[replyId] && !currentMessageMap[replyId]); 1126 - 1127 - [...new Set(missingReplyIds)].slice(0, 10).forEach((replyId) => { 1128 - void fetchReplyMessage(selectedChannelId, replyId); 1129 - }); 904 + const missingReplyIds = currentMessages.map((message) => getReplyIdFromMessage(message)).filter((replyId) => replyId && !replyMessageCacheRef.current[replyId] && !currentMessageMap[replyId]); 905 + [...new Set(missingReplyIds)].slice(0, 10).forEach((replyId) => { void fetchReplyMessage(selectedChannelId, replyId); }); 1130 906 }, [currentMessages, currentMessageMap, selectedChannelId]); 1131 907 1132 - useEffect(() => { 1133 - if (!voiceNotice) return undefined; 1134 - const timeout = setTimeout(() => setVoiceNotice(''), 3500); 1135 - return () => clearTimeout(timeout); 1136 - }, [voiceNotice]); 908 + useEffect(() => { if (!voiceNotice) return; const timeout = setTimeout(() => setVoiceNotice(''), 3500); return () => clearTimeout(timeout); }, [voiceNotice]); 1137 909 1138 910 const fetchMembers = async (serverId) => { 1139 911 if (serverId === '@me') return; 1140 - 1141 912 const loadId = Date.now(); 1142 913 membersLoadIdRef.current = loadId; 1143 914 setIsMembersLoading(true); 1144 - 1145 915 try { 1146 - const res = await fetch(`${config.apiUrl}/servers/${serverId}/members`, { 1147 - headers: { 'x-session-token': auth.token }, 1148 - }); 916 + const res = await fetch(`${config.apiUrl}/servers/${serverId}/members`, { headers: { 'x-session-token': auth.token } }); 1149 917 if (!res.ok) return; 1150 918 const data = await res.json(); 1151 - 1152 919 upsertUsers(data.users || []); 1153 920 const payloadMembers = data.members || []; 1154 - 1155 921 for (let index = 0; index < payloadMembers.length; index += MEMBER_HYDRATE_CHUNK) { 1156 922 if (membersLoadIdRef.current !== loadId) break; 1157 923 const chunk = payloadMembers.slice(index, index + MEMBER_HYDRATE_CHUNK); 1158 - 1159 - setMembers((prev) => { 1160 - const next = { ...prev }; 1161 - chunk.forEach((member) => { 1162 - next[`${member._id.server}:${member._id.user}`] = member; 1163 - }); 1164 - return next; 1165 - }); 1166 - 1167 - if (index + MEMBER_HYDRATE_CHUNK < payloadMembers.length) { 1168 - await new Promise((resolve) => setTimeout(resolve, 0)); 1169 - } 1170 - } 1171 - } catch { 1172 - // no-op 1173 - } finally { 1174 - if (membersLoadIdRef.current === loadId) { 1175 - setIsMembersLoading(false); 924 + setMembers((prev) => { const next = { ...prev }; chunk.forEach((member) => { next[`${member._id.server}:${member._id.user}`] = member; }); return next; }); 925 + if (index + MEMBER_HYDRATE_CHUNK < payloadMembers.length) await new Promise((resolve) => setTimeout(resolve, 0)); 1176 926 } 1177 - } 927 + } catch {} finally { if (membersLoadIdRef.current === loadId) setIsMembersLoading(false); } 1178 928 }; 1179 929 1180 930 const resolveReplyPreview = (messageById, channelId, replyId) => { 1181 931 const inBatch = messageById[replyId]; 1182 932 if (inBatch) return inBatch; 1183 - 1184 933 const inChannelState = (messages[channelId] || []).find((entry) => entry._id === replyId); 1185 934 if (inChannelState) return inChannelState; 1186 - 1187 935 return replyMessageCacheRef.current[replyId] || null; 1188 936 }; 1189 937 1190 - const enrichReplies = (nextMessages = [], channelId = selectedChannelId) => { 1191 - const messageById = Object.fromEntries(nextMessages.map((entry) => [entry._id, entry])); 1192 - 1193 - return nextMessages.map((message) => { 1194 - const replyId = getReplyIdFromMessage(message); 1195 - if (!replyId) return { ...message, replyMessage: null }; 1196 - 1197 - const replyMessage = resolveReplyPreview(messageById, channelId, replyId); 1198 - if (!replyMessage) { 1199 - return { 1200 - ...message, 1201 - replyMessage: { 1202 - _id: replyId, 1203 - content: 'Loading reply…', 1204 - authorId: null, 1205 - authorUser: null, 1206 - }, 1207 - }; 1208 - } 1209 - const replyAuthorId = typeof replyMessage.author === 'string' ? replyMessage.author : replyMessage.author?._id; 1210 - 1211 - return { 1212 - ...message, 1213 - replyMessage: { 1214 - _id: replyMessage._id, 1215 - content: replyMessage.content, 1216 - authorId: replyAuthorId, 1217 - authorUser: users[replyAuthorId] || (typeof replyMessage.author === 'object' ? replyMessage.author : null), 1218 - }, 1219 - }; 1220 - }); 1221 - }; 1222 - 1223 938 const fetchReplyMessage = async (channelId, replyId) => { 1224 939 if (!channelId || !replyId || replyMessageCacheRef.current[replyId]) return; 1225 - 1226 940 try { 1227 - const res = await fetch(`${config.apiUrl}/channels/${channelId}/messages/${replyId}`, { 1228 - headers: { 'x-session-token': auth.token }, 1229 - }); 941 + const res = await fetch(`${config.apiUrl}/channels/${channelId}/messages/${replyId}`, { headers: { 'x-session-token': auth.token } }); 1230 942 if (!res.ok) return; 1231 943 const data = await res.json(); 1232 944 if (!data?._id) return; 1233 - 1234 945 replyMessageCacheRef.current[replyId] = data; 1235 946 upsertUsersFromMessages([data]); 1236 - setMessages((prev) => ({ 1237 - ...prev, 1238 - [channelId]: enrichReplies(prev[channelId] || [], channelId), 1239 - })); 1240 - } catch { 1241 - // no-op 1242 - } 947 + setMessages((prev) => ({ ...prev, [channelId]: [...(prev[channelId] || [])] })); 948 + } catch {} 1243 949 }; 1244 950 1245 951 const fetchMessages = async (channelId) => { 1246 952 if (!channelId || channelId === 'friends') return; 1247 - 1248 953 try { 1249 - const res = await fetch(`${config.apiUrl}/channels/${channelId}/messages?limit=100`, { 1250 - headers: { 'x-session-token': auth.token }, 1251 - }); 954 + const res = await fetch(`${config.apiUrl}/channels/${channelId}/messages?limit=100`, { headers: { 'x-session-token': auth.token } }); 1252 955 if (!res.ok) return; 1253 956 const data = await res.json(); 1254 - 1255 957 const payloadMessages = Array.isArray(data) ? data : data.messages || []; 1256 958 const payloadUsers = Array.isArray(data) ? [] : data.users || []; 1257 - 1258 - if (payloadUsers.length) { 1259 - upsertUsers(payloadUsers); 1260 - } 1261 - 959 + if (payloadUsers.length) upsertUsers(payloadUsers); 1262 960 upsertUsersFromMessages(payloadMessages); 1263 961 const orderedMessages = payloadMessages.reverse().slice(-200); 1264 - setMessages((prev) => ({ ...prev, [channelId]: enrichReplies(orderedMessages, channelId) })); 962 + setMessages((prev) => ({ ...prev, [channelId]: uniqueMessages(orderedMessages) })); 1265 963 void fetchMissingUsers(orderedMessages); 1266 964 preloadedChannelRef.current[channelId] = true; 1267 - } catch { 1268 - // no-op 1269 - } 965 + } catch {} 1270 966 }; 1271 967 1272 968 useEffect(() => { 1273 969 if (status !== 'ready' || !auth.token) return; 1274 - 1275 970 if (document.visibilityState !== 'visible') return; 1276 - 1277 971 const maxWarmChannels = Math.min(8, Math.max(3, Math.floor((navigator.hardwareConcurrency || 4) / 2))); 1278 - const channelsToWarm = Object.values(channels) 1279 - .filter((channel) => channel?.channel_type === 'TextChannel' || !channel?.channel_type) 1280 - .slice(0, maxWarmChannels); 1281 - 1282 - channelsToWarm.forEach((channel, index) => { 1283 - if (!channel?._id || preloadedChannelRef.current[channel._id]) return; 1284 - setTimeout(() => { 1285 - fetchMessages(channel._id); 1286 - }, index * 350); 1287 - }); 1288 - 972 + const channelsToWarm = Object.values(channels).filter((channel) => channel?.channel_type === 'TextChannel' || !channel?.channel_type).slice(0, maxWarmChannels); 973 + channelsToWarm.forEach((channel, index) => { if (!channel?._id || preloadedChannelRef.current[channel._id]) return; setTimeout(() => { fetchMessages(channel._id); }, index * 350); }); 1289 974 }, [status, auth.token, channels]); 1290 975 1291 976 const handleServerSelect = (serverId) => { 1292 977 setSelectedServerId(serverId); 1293 - 1294 - if (serverId === '@me') { 1295 - setSelectedChannelId('friends'); 1296 - setReplyingTo(null); 1297 - setShowEmojiPicker(false); 1298 - return; 1299 - } 1300 - 1301 - const firstChannel = Object.values(channels).find((ch) => ch.server === serverId && ch.channel_type !== 'VoiceChannel') 1302 - || Object.values(channels).find((ch) => ch.server === serverId); 978 + if (serverId === '@me') { setSelectedChannelId('friends'); setReplyingTo(null); setShowEmojiPicker(false); return; } 979 + const firstChannel = Object.values(channels).find((ch) => ch.server === serverId && ch.channel_type !== 'VoiceChannel') || Object.values(channels).find((ch) => ch.server === serverId); 1303 980 setSelectedChannelId(firstChannel?._id || null); 1304 981 if (firstChannel?._id) fetchMessages(firstChannel._id); 1305 - if (!preloadedMembersRef.current[serverId]) { 1306 - preloadedMembersRef.current[serverId] = true; 1307 - void fetchMembers(serverId); 1308 - } 982 + if (!preloadedMembersRef.current[serverId]) { preloadedMembersRef.current[serverId] = true; void fetchMembers(serverId); } 1309 983 }; 1310 984 1311 985 const handleChannelSelect = (channelId) => { 1312 986 const channel = channels[channelId]; 1313 - if (channel?.channel_type === 'VoiceChannel') { 1314 - setVoiceNotice("Ermine currently doesn't support voice channels."); 1315 - return; 1316 - } 1317 - 1318 - setVoiceNotice(''); 1319 - setSelectedChannelId(channelId); 1320 - setReplyingTo(null); 1321 - setShowEmojiPicker(false); 1322 - fetchMessages(channelId); 987 + if (channel?.channel_type === 'VoiceChannel') { setVoiceNotice("Ermine currently doesn't support voice channels."); return; } 988 + setVoiceNotice(''); setSelectedChannelId(channelId); setReplyingTo(null); setShowEmojiPicker(false); fetchMessages(channelId); 1323 989 }; 1324 990 1325 991 const openDmWithUser = async (userId) => { 1326 992 if (!userId || !auth.token) return; 1327 - 1328 - const existing = directMessageChannels.find((channel) => 1329 - Array.isArray(channel?.recipients) && channel.recipients.includes(userId), 1330 - ); 1331 - 1332 - if (existing?._id) { 1333 - setSelectedServerId('@me'); 1334 - handleChannelSelect(existing._id); 1335 - return; 1336 - } 1337 - 993 + const existing = directMessageChannels.find((channel) => Array.isArray(channel?.recipients) && channel.recipients.includes(userId)); 994 + if (existing?._id) { setSelectedServerId('@me'); handleChannelSelect(existing._id); return; } 1338 995 try { 1339 - const res = await fetch(`${config.apiUrl}/users/${userId}/dm`, { 1340 - method: 'GET', 1341 - headers: { 'x-session-token': auth.token }, 1342 - }); 1343 - 996 + const res = await fetch(`${config.apiUrl}/users/${userId}/dm`, { method: 'GET', headers: { 'x-session-token': auth.token } }); 1344 997 if (!res.ok) return; 1345 998 const channel = await res.json(); 1346 999 if (!channel?._id) return; 1347 - 1348 - setChannels((prev) => ({ ...prev, [channel._id]: channel })); 1349 - setSelectedServerId('@me'); 1350 - setSelectedChannelId(channel._id); 1351 - fetchMessages(channel._id); 1352 - } catch { 1353 - // no-op 1354 - } 1000 + setChannels((prev) => ({ ...prev, [channel._id]: channel })); setSelectedServerId('@me'); setSelectedChannelId(channel._id); fetchMessages(channel._id); 1001 + } catch {} 1355 1002 }; 1356 1003 1357 1004 const jumpToMessage = (messageId) => { ··· 1360 1007 if (!target) return; 1361 1008 target.scrollIntoView({ behavior: 'smooth', block: 'center' }); 1362 1009 target.classList.add('ring-2', 'ring-[#5865f2]', 'bg-[#3a3f66]/40'); 1363 - setTimeout(() => { 1364 - target.classList.remove('ring-2', 'ring-[#5865f2]', 'bg-[#3a3f66]/40'); 1365 - }, 1200); 1010 + setTimeout(() => { target.classList.remove('ring-2', 'ring-[#5865f2]', 'bg-[#3a3f66]/40'); }, 1200); 1366 1011 }; 1367 1012 1368 1013 const registerMessageRef = (messageId, node) => { 1369 1014 if (!messageId) return; 1370 - if (node) { 1371 - messageRefs.current[messageId] = node; 1372 - } else { 1373 - delete messageRefs.current[messageId]; 1374 - } 1375 - }; 1376 - 1377 - 1378 - const goToLatest = () => { 1379 - isProgrammaticScrollRef.current = true; 1380 - autoFollowRef.current = true; 1381 - messagesBottomRef.current?.scrollIntoView({ behavior: 'smooth' }); 1382 - requestAnimationFrame(() => { 1383 - isProgrammaticScrollRef.current = false; 1384 - }); 1385 - setShowGoLatest(false); 1386 - }; 1387 - 1388 - const requestOpenLink = (url) => { 1389 - setLinkPromptUrl(url); 1015 + if (node) { messageRefs.current[messageId] = node; } else { delete messageRefs.current[messageId]; } 1390 1016 }; 1391 1017 1392 - const confirmOpenLink = () => { 1393 - if (!linkPromptUrl) return; 1394 - window.open(linkPromptUrl, '_blank', 'noopener,noreferrer'); 1395 - setLinkPromptUrl(null); 1396 - }; 1397 - 1398 - const openStatusEditor = () => { 1399 - const currentPresence = users[auth.userId]?.status?.presence || 'Online'; 1400 - const currentText = users[auth.userId]?.status?.text || ''; 1401 - setStatusDraft({ presence: currentPresence, text: currentText }); 1402 - setActiveModal('set-status'); 1403 - }; 1018 + const goToLatest = () => { isProgrammaticScrollRef.current = true; autoFollowRef.current = true; messagesBottomRef.current?.scrollIntoView({ behavior: 'smooth' }); requestAnimationFrame(() => { isProgrammaticScrollRef.current = false; }); setShowGoLatest(false); }; 1019 + const requestOpenLink = (url) => { setLinkPromptUrl(url); }; 1020 + const confirmOpenLink = () => { if (!linkPromptUrl) return; window.open(linkPromptUrl, '_blank', 'noopener,noreferrer'); setLinkPromptUrl(null); }; 1021 + const openStatusEditor = () => { const currentPresence = users[auth.userId]?.status?.presence || 'Online'; const currentText = users[auth.userId]?.status?.text || ''; setStatusDraft({ presence: currentPresence, text: currentText }); setActiveModal('set-status'); }; 1404 1022 1405 1023 const saveStatus = async () => { 1406 1024 setIsSavingStatus(true); 1407 - 1408 - setUsers((prev) => ({ 1409 - ...prev, 1410 - [auth.userId]: { 1411 - ...(prev[auth.userId] || {}), 1412 - status: { 1413 - presence: statusDraft.presence, 1414 - text: statusDraft.text || undefined, 1415 - }, 1416 - }, 1417 - })); 1418 - 1419 - try { 1420 - await fetch(`${config.apiUrl}/users/@me`, { 1421 - method: 'PATCH', 1422 - headers: { 1423 - 'Content-Type': 'application/json', 1424 - 'x-session-token': auth.token, 1425 - }, 1426 - body: JSON.stringify({ 1427 - status: { 1428 - presence: statusDraft.presence, 1429 - text: statusDraft.text || undefined, 1430 - }, 1431 - }), 1432 - }); 1433 - } catch { 1434 - // no-op 1435 - } finally { 1436 - setIsSavingStatus(false); 1437 - setActiveModal(null); 1438 - } 1025 + setUsers((prev) => ({ ...prev, [auth.userId]: { ...(prev[auth.userId] || {}), status: { presence: statusDraft.presence, text: statusDraft.text || undefined } } })); 1026 + try { await fetch(`${config.apiUrl}/users/@me`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', 'x-session-token': auth.token }, body: JSON.stringify({ status: { presence: statusDraft.presence, text: statusDraft.text || undefined } }) }); } catch {} finally { setIsSavingStatus(false); setActiveModal(null); } 1439 1027 }; 1440 1028 1441 1029 const handleLogin = async (event) => { 1442 - event.preventDefault(); 1443 - setLoginError(''); 1444 - if (!privacyConsent) { 1445 - setLoginError('Please accept the privacy policy before signing in.'); 1446 - return; 1447 - } 1030 + event.preventDefault(); setLoginError(''); 1031 + if (!privacyConsent) { setLoginError('Please accept the privacy policy before signing in.'); return; } 1448 1032 setIsLoggingIn(true); 1449 - 1450 1033 try { 1451 1034 await discoverConfig(config.apiUrl); 1452 - 1453 1035 let token = manualToken.trim(); 1454 1036 let userId = null; 1455 - 1456 1037 if (loginMode === 'credentials') { 1457 1038 const payload = { email, password, friendly_name: 'Ermine Web Client' }; 1458 1039 if (mfaCode.trim()) payload.mfa_response = { totp_code: mfaCode.trim() }; 1459 - 1460 - const res = await fetch(`${config.apiUrl}/auth/session/login`, { 1461 - method: 'POST', 1462 - headers: { 'Content-Type': 'application/json' }, 1463 - body: JSON.stringify(payload), 1464 - }); 1465 - 1040 + const res = await fetch(`${config.apiUrl}/auth/session/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); 1466 1041 if (!res.ok) throw new Error(res.status === 401 ? 'Invalid credentials or MFA code.' : 'Login failed.'); 1467 - 1468 1042 const data = await res.json(); 1469 1043 token = data.token || data.session_token; 1470 1044 userId = data.user_id; 1471 1045 } else { 1472 - const meRes = await fetch(`${config.apiUrl}/users/@me`, { 1473 - headers: { 'x-session-token': token }, 1474 - }); 1046 + const meRes = await fetch(`${config.apiUrl}/users/@me`, { headers: { 'x-session-token': token } }); 1475 1047 if (!meRes.ok) throw new Error('Token is invalid.'); 1476 1048 const meData = await meRes.json(); 1477 1049 userId = meData._id; 1478 1050 } 1479 - 1480 1051 if (!token || !userId) throw new Error('Session creation failed.'); 1481 - 1482 - setCookie(TOKEN_COOKIE_NAME, token); 1483 - setCookie(USER_COOKIE_NAME, userId); 1484 - setCookie(API_COOKIE_NAME, config.apiUrl); 1485 - 1486 - setAuth({ token, userId }); 1487 - setView('app'); 1488 - } catch (error) { 1489 - setLoginError(error.message || 'Unknown login error'); 1490 - } finally { 1491 - setIsLoggingIn(false); 1492 - } 1052 + setCookie(TOKEN_COOKIE_NAME, token); setCookie(USER_COOKIE_NAME, userId); setCookie(API_COOKIE_NAME, config.apiUrl); setAuth({ token, userId }); setView('app'); 1053 + } catch (error) { setLoginError(error.message || 'Unknown login error'); } finally { setIsLoggingIn(false); } 1493 1054 }; 1494 1055 1495 1056 const logout = () => { 1496 - clearCookie(TOKEN_COOKIE_NAME); 1497 - clearCookie(USER_COOKIE_NAME); 1498 - clearCookie(API_COOKIE_NAME); 1499 - localStorage.removeItem('stoat_token'); 1500 - localStorage.removeItem('stoat_user_id'); 1501 - localStorage.removeItem('stoat_api_url'); 1502 - setAuth({ token: null, userId: null }); 1503 - setMessages({}); 1504 - setServers({}); 1505 - setChannels({}); 1506 - setUsers({}); 1507 - setMembers({}); 1508 - preloadedChannelRef.current = {}; 1509 - preloadedMembersRef.current = {}; 1510 - pendingUserFetchRef.current = new Set(); 1511 - membersLoadIdRef.current = 0; 1512 - canFetchMissingUsersRef.current = true; 1513 - loggedMissingUserFetchErrorRef.current = false; 1514 - setIsMembersLoading(false); 1515 - setWsReconnectAttempt(0); 1516 - if (wsReconnectTimeoutRef.current) { 1517 - clearTimeout(wsReconnectTimeoutRef.current); 1518 - wsReconnectTimeoutRef.current = null; 1519 - } 1520 - setView('login'); 1521 - setStatus('disconnected'); 1057 + clearCookie(TOKEN_COOKIE_NAME); clearCookie(USER_COOKIE_NAME); clearCookie(API_COOKIE_NAME); localStorage.removeItem('stoat_token'); localStorage.removeItem('stoat_user_id'); localStorage.removeItem('stoat_api_url'); setAuth({ token: null, userId: null }); setMessages({}); setServers({}); setChannels({}); setUsers({}); setMembers({}); preloadedChannelRef.current = {}; preloadedMembersRef.current = {}; pendingUserFetchRef.current = new Set(); membersLoadIdRef.current = 0; canFetchMissingUsersRef.current = true; loggedMissingUserFetchErrorRef.current = false; setIsMembersLoading(false); setWsReconnectAttempt(0); 1058 + if (wsReconnectTimeoutRef.current) { clearTimeout(wsReconnectTimeoutRef.current); wsReconnectTimeoutRef.current = null; } 1059 + setView('login'); setStatus('disconnected'); 1522 1060 }; 1523 1061 1524 1062 const sendMessage = async () => { 1525 - const content = inputText.trim(); 1526 - const hasContent = Boolean(content); 1527 - const hasFiles = pendingFiles.length > 0; 1063 + const content = inputText.trim(); const hasContent = Boolean(content); const hasFiles = pendingFiles.length > 0; 1528 1064 if ((!hasContent && !hasFiles) || !selectedChannelId || selectedChannelId === 'friends') return; 1529 - 1530 - setInputText(''); 1531 - setIsUploadingFiles(hasFiles); 1532 - 1533 - const nonce = crypto.randomUUID(); 1534 - const pendingId = `pending-${nonce}`; 1535 - const meUser = users[auth.userId] || { _id: auth.userId, username: 'You' }; 1536 - 1537 - const optimisticMessage = { 1538 - _id: pendingId, 1539 - channel: selectedChannelId, 1540 - author: auth.userId, 1541 - user: meUser, 1542 - content, 1543 - createdAt: new Date().toISOString(), 1544 - reactions: {}, 1545 - replies: replyingTo ? [{ id: replyingTo._id, mention: false }] : undefined, 1546 - attachments: pendingFiles.map((file, index) => ({ _id: `pending-attachment-${index}-${file.name}`, filename: file.name })), 1547 - }; 1548 - 1549 - setMessages((prev) => { 1550 - const list = prev[selectedChannelId] || []; 1551 - return { ...prev, [selectedChannelId]: enrichReplies([...list, optimisticMessage].slice(-200), selectedChannelId) }; 1552 - }); 1553 - 1065 + setInputText(''); setIsUploadingFiles(hasFiles); 1066 + const nonce = crypto.randomUUID(); const pendingId = `pending-${nonce}`; const meUser = users[auth.userId] || { _id: auth.userId, username: 'You' }; 1067 + const optimisticMessage = { _id: pendingId, channel: selectedChannelId, author: auth.userId, user: meUser, content, createdAt: new Date().toISOString(), reactions: {}, replies: replyingTo ? [{ id: replyingTo._id, mention: false }] : undefined, attachments: pendingFiles.map((file, index) => ({ _id: `pending-attachment-${index}-${file.name}`, filename: file.name })) }; 1068 + setMessages((prev) => { const list = prev[selectedChannelId] || []; return { ...prev, [selectedChannelId]: uniqueMessages([...list, optimisticMessage]).slice(-200) }; }); 1554 1069 try { 1555 1070 const attachmentIds = hasFiles ? await uploadFiles(pendingFiles) : []; 1556 - const payload = { 1557 - content, 1558 - nonce, 1559 - replies: replyingTo ? [{ id: replyingTo._id, mention: false }] : undefined, 1560 - attachments: attachmentIds.length ? attachmentIds : undefined, 1561 - }; 1562 - 1563 - const res = await fetch(`${config.apiUrl}/channels/${selectedChannelId}/messages`, { 1564 - method: 'POST', 1565 - headers: { 1566 - 'Content-Type': 'application/json', 1567 - 'x-session-token': auth.token, 1568 - }, 1569 - body: JSON.stringify(payload), 1570 - }); 1571 - 1071 + const payload = { content, nonce, replies: replyingTo ? [{ id: replyingTo._id, mention: false }] : undefined, attachments: attachmentIds.length ? attachmentIds : undefined }; 1072 + const res = await fetch(`${config.apiUrl}/channels/${selectedChannelId}/messages`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-session-token': auth.token }, body: JSON.stringify(payload) }); 1572 1073 if (!res.ok) throw new Error('Failed to send message'); 1573 1074 const postedMessage = await res.json(); 1574 - 1575 1075 if (postedMessage?._id) { 1576 1076 upsertUsersFromMessages([postedMessage]); 1577 1077 setMessages((prev) => { 1578 - const list = (prev[selectedChannelId] || []).map((entry) => (entry._id === pendingId ? postedMessage : entry)); 1579 - return { ...prev, [selectedChannelId]: enrichReplies(list, selectedChannelId) }; 1078 + const list = prev[selectedChannelId] || []; 1079 + let nextList = list.filter((entry) => entry._id !== pendingId); 1080 + if (!nextList.some(m => m._id === postedMessage._id)) { nextList.push(postedMessage); } else { nextList = nextList.map(m => m._id === postedMessage._id ? postedMessage : m); } 1081 + nextList.sort((a, b) => (a._id > b._id ? 1 : -1)); 1082 + return { ...prev, [selectedChannelId]: uniqueMessages(nextList) }; 1580 1083 }); 1581 1084 } 1085 + setPendingFiles([]); setReplyingTo(null); setShowEmojiPicker(false); 1086 + } catch { setMessages((prev) => ({ ...prev, [selectedChannelId]: (prev[selectedChannelId] || []).filter((entry) => entry._id !== pendingId) })); setInputText(content); } finally { setIsUploadingFiles(false); } 1087 + }; 1088 + 1089 + const editMessage = async (messageId, newContent) => { 1090 + try { const res = await fetch(`${config.apiUrl}/channels/${selectedChannelId}/messages/${messageId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', 'x-session-token': auth.token }, body: JSON.stringify({ content: newContent }) }); if (!res.ok) throw new Error('Failed to edit'); } catch (err) { console.error(err); } 1091 + }; 1582 1092 1583 - setPendingFiles([]); 1584 - setReplyingTo(null); 1585 - setShowEmojiPicker(false); 1586 - } catch { 1587 - setMessages((prev) => ({ 1588 - ...prev, 1589 - [selectedChannelId]: (prev[selectedChannelId] || []).filter((entry) => entry._id !== pendingId), 1590 - })); 1591 - setInputText(content); 1592 - } finally { 1593 - setIsUploadingFiles(false); 1594 - } 1093 + const deleteMessage = async (messageId) => { 1094 + if (!confirm('Are you sure you want to delete this message?')) return; 1095 + try { const res = await fetch(`${config.apiUrl}/channels/${selectedChannelId}/messages/${messageId}`, { method: 'DELETE', headers: { 'x-session-token': auth.token } }); if (!res.ok) throw new Error('Failed to delete'); } catch (err) { console.error(err); } 1595 1096 }; 1596 - 1597 1097 1598 1098 const toggleReaction = async (message, emoji, reacted) => { 1599 1099 if (!message?._id || !selectedChannelId || selectedChannelId === 'friends') return; 1600 1100 const encodedEmoji = encodeURIComponent(emoji); 1601 - 1602 1101 setMessages((prev) => { 1603 1102 const nextList = (prev[selectedChannelId] || []).map((entry) => { 1604 1103 if (entry._id !== message._id) return entry; 1605 1104 const existing = entry.reactions?.[emoji] || []; 1606 1105 const nextUserIds = reacted ? existing.filter((id) => id !== auth.userId) : [...new Set([...existing, auth.userId])]; 1607 - 1608 - return { 1609 - ...entry, 1610 - reactions: { 1611 - ...(entry.reactions || {}), 1612 - [emoji]: nextUserIds, 1613 - }, 1614 - }; 1106 + return { ...entry, reactions: { ...(entry.reactions || {}), [emoji]: nextUserIds } }; 1615 1107 }); 1616 - 1617 1108 return { ...prev, [selectedChannelId]: nextList }; 1618 1109 }); 1619 - 1620 - try { 1621 - await fetch(`${config.apiUrl}/channels/${selectedChannelId}/messages/${message._id}/reactions/${encodedEmoji}`, { 1622 - method: reacted ? 'DELETE' : 'PUT', 1623 - headers: { 'x-session-token': auth.token }, 1624 - }); 1625 - } catch { 1626 - // no-op, websocket sync should self-heal 1627 - } 1628 - }; 1629 - 1630 - const addEmojiToComposer = (emoji) => { 1631 - setInputText((prev) => `${prev}${emoji}`); 1632 - setShowEmojiPicker(false); 1633 - }; 1634 - 1635 - const addCustomEmojiToComposer = (emojiId) => { 1636 - setInputText((prev) => `${prev}:${emojiId}:`); 1637 - setShowEmojiPicker(false); 1638 - }; 1639 - 1640 - const handleFilePick = (event) => { 1641 - const files = Array.from(event.target.files || []); 1642 - if (!files.length) return; 1643 - setPendingFiles((prev) => [...prev, ...files]); 1644 - event.target.value = ''; 1645 - }; 1646 - 1647 - const handleComposerDrop = (event) => { 1648 - event.preventDefault(); 1649 - const files = Array.from(event.dataTransfer?.files || []); 1650 - if (!files.length) return; 1651 - setPendingFiles((prev) => [...prev, ...files]); 1652 - }; 1653 - 1654 - const handleComposerDragOver = (event) => { 1655 - event.preventDefault(); 1110 + try { await fetch(`${config.apiUrl}/channels/${selectedChannelId}/messages/${message._id}/reactions/${encodedEmoji}`, { method: reacted ? 'DELETE' : 'PUT', headers: { 'x-session-token': auth.token } }); } catch {} 1656 1111 }; 1657 1112 1658 - const removePendingFile = (index) => { 1659 - setPendingFiles((prev) => prev.filter((_, fileIndex) => fileIndex !== index)); 1660 - }; 1113 + const addEmojiToComposer = (emoji) => { setInputText((prev) => `${prev}${emoji}`); setShowEmojiPicker(false); }; 1114 + const addCustomEmojiToComposer = (emojiId) => { setInputText((prev) => `${prev}:${emojiId}:`); setShowEmojiPicker(false); }; 1115 + const handleFilePick = (event) => { const files = Array.from(event.target.files || []); if (!files.length) return; setPendingFiles((prev) => [...prev, ...files]); event.target.value = ''; }; 1116 + const handleComposerDrop = (event) => { event.preventDefault(); const files = Array.from(event.dataTransfer?.files || []); if (!files.length) return; setPendingFiles((prev) => [...prev, ...files]); }; 1117 + const handleComposerDragOver = (event) => { event.preventDefault(); }; 1118 + const removePendingFile = (index) => { setPendingFiles((prev) => prev.filter((_, fileIndex) => fileIndex !== index)); }; 1661 1119 1662 1120 const uploadFiles = async (files = []) => { 1663 1121 const ids = []; 1664 - 1665 1122 for (const file of files) { 1666 - const body = new FormData(); 1667 - body.append('file', file); 1668 - 1669 - const res = await fetch(`${config.cdnUrl}/attachments`, { 1670 - method: 'POST', 1671 - body, 1672 - headers: { 1673 - 'X-Session-Token': auth.token, 1674 - }, 1675 - }); 1676 - 1677 - if (!res.ok) { 1678 - throw new Error('Failed to upload file'); 1679 - } 1680 - 1681 - const data = await res.json(); 1682 - if (data?.id) ids.push(data.id); 1123 + const body = new FormData(); body.append('file', file); 1124 + const res = await fetch(`${config.cdnUrl}/attachments`, { method: 'POST', body, headers: { 'X-Session-Token': auth.token } }); 1125 + if (!res.ok) { throw new Error('Failed to upload file'); } 1126 + const data = await res.json(); if (data?.id) ids.push(data.id); 1683 1127 } 1684 - 1685 1128 return ids; 1686 1129 }; 1687 1130 1688 1131 const createServer = async () => { 1689 1132 if (!createServerName.trim()) return; 1690 - 1691 - try { 1692 - const res = await fetch(`${config.apiUrl}/servers/create`, { 1693 - method: 'POST', 1694 - headers: { 1695 - 'Content-Type': 'application/json', 1696 - 'x-session-token': auth.token, 1697 - }, 1698 - body: JSON.stringify({ name: createServerName.trim() }), 1699 - }); 1700 - 1701 - if (res.ok) { 1702 - setCreateServerName(''); 1703 - setActiveModal(null); 1704 - } 1705 - } catch { 1706 - // no-op 1707 - } 1133 + try { const res = await fetch(`${config.apiUrl}/servers/create`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-session-token': auth.token }, body: JSON.stringify({ name: createServerName.trim() }) }); if (res.ok) { setCreateServerName(''); setActiveModal(null); } } catch {} 1134 + }; 1135 + 1136 + const updateServer = async () => { 1137 + if (!selectedServerId || selectedServerId === '@me' || !editServerName.trim()) return; 1138 + setIsUpdatingServer(true); 1139 + try { const res = await fetch(`${config.apiUrl}/servers/${selectedServerId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', 'x-session-token': auth.token }, body: JSON.stringify({ name: editServerName.trim() }) }); if (res.ok) { setActiveModal(null); } } catch {} finally { setIsUpdatingServer(false); } 1140 + }; 1141 + 1142 + const deleteServer = async () => { 1143 + if (!selectedServerId || selectedServerId === '@me') return; 1144 + if (!confirm(`Are you absolutely sure you want to delete ${selectedServer?.name}? This cannot be undone.`)) return; 1145 + setIsUpdatingServer(true); 1146 + try { await fetch(`${config.apiUrl}/servers/${selectedServerId}`, { method: 'DELETE', headers: { 'x-session-token': auth.token } }); setActiveModal(null); } catch {} finally { setIsUpdatingServer(false); } 1708 1147 }; 1148 + 1149 + const openServerSettings = () => { if (selectedServer) { setEditServerName(selectedServer.name); setActiveModal('server-settings'); } }; 1709 1150 1710 - const activeReply = replyingTo ? currentMessageMap[replyingTo._id] || replyingTo : null; 1711 - 1712 - const currentChannelName = (() => { 1713 - if (selectedChannelId === 'friends') return 'friends'; 1714 - const channel = channels[selectedChannelId]; 1715 - if (channel?.name) return channel.name; 1716 - if (channel?.channel_type === 'DirectMessage') { 1717 - const recipientId = (channel.recipients || []).find((id) => id !== auth.userId); 1718 - return users[recipientId]?.username || 'direct-message'; 1719 - } 1720 - return 'select-a-channel'; 1721 - })(); 1722 - 1723 - if (view === 'loading') { 1151 + const renderMemberItem = useCallback((item) => { 1152 + if (item.type === 'header') return <div className="pt-4 pb-1 px-4 text-xs font-bold uppercase tracking-wide text-gray-400 flex items-center gap-1">{item.name} — {item.count}</div>; 1153 + const member = item.data; 1724 1154 return ( 1725 - <div className="grid h-screen place-items-center bg-[#1e1f22] text-[#5865f2]"> 1726 - <Activity className="animate-spin" size={42} /> 1727 - </div> 1155 + <button className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left hover:bg-[#35373c]" onClick={() => openUserProfile(member.user, member._id.user)}> 1156 + <Avatar animateOnHover cdnUrl={config.cdnUrl} size="sm" user={member.user} /> 1157 + <div className="min-w-0"> 1158 + <div className="truncate text-sm font-medium" style={{ color: member.color || '#f2f3f5' }}>{member.nickname || member.user.username}</div> 1159 + <div className="flex items-center gap-1 text-[11px] text-gray-500"><User size={11} /> {member.user.status?.presence || 'Offline'}</div> 1160 + </div> 1161 + </button> 1728 1162 ); 1729 - } 1163 + }, [config.cdnUrl, openUserProfile]); 1164 + 1165 + if (view === 'loading') return <div className="grid h-screen place-items-center bg-[#1e1f22] text-[#5865f2]"><Activity className="animate-spin" size={42} /></div>; 1730 1166 1731 1167 if (view === 'login') { 1732 1168 return ( ··· 1737 1173 <h1 className="text-2xl font-bold text-white">Ermine</h1> 1738 1174 <p className="text-sm text-gray-400">Branded stoat.chat client with a Discord-inspired layout.</p> 1739 1175 </div> 1740 - 1741 1176 <div className="mb-5 grid grid-cols-2 gap-2 rounded-lg bg-[#1e1f22] p-1 text-xs font-semibold uppercase tracking-wide"> 1742 - <button 1743 - className={`rounded py-2 ${loginMode === 'credentials' ? 'bg-[#5865f2] text-white' : 'text-gray-400 hover:text-white'}`} 1744 - onClick={() => setLoginMode('credentials')} 1745 - type="button" 1746 - > 1747 - Credentials 1748 - </button> 1749 - <button 1750 - className={`rounded py-2 ${loginMode === 'token' ? 'bg-[#5865f2] text-white' : 'text-gray-400 hover:text-white'}`} 1751 - onClick={() => setLoginMode('token')} 1752 - type="button" 1753 - > 1754 - Token 1755 - </button> 1177 + <button className={`rounded py-2 ${loginMode === 'credentials' ? 'bg-[#5865f2] text-white' : 'text-gray-400 hover:text-white'}`} onClick={() => setLoginMode('credentials')} type="button">Credentials</button> 1178 + <button className={`rounded py-2 ${loginMode === 'token' ? 'bg-[#5865f2] text-white' : 'text-gray-400 hover:text-white'}`} onClick={() => setLoginMode('token')} type="button">Token</button> 1756 1179 </div> 1757 - 1758 1180 <form className="space-y-4" onSubmit={handleLogin}> 1759 1181 {loginMode === 'credentials' ? ( 1760 1182 <> 1761 1183 <input className={inputBase} onChange={(e) => setEmail(e.target.value)} placeholder="Email" type="email" value={email} /> 1762 1184 <input className={inputBase} onChange={(e) => setPassword(e.target.value)} placeholder="Password" type="password" value={password} /> 1763 - <label className="block text-xs font-semibold uppercase tracking-wide text-gray-400"> 1764 - <span className="mb-1 flex items-center gap-1"><ShieldCheck size={12} /> MFA</span> 1765 - <input className={inputBase} onChange={(e) => setMfaCode(e.target.value)} placeholder="Optional 2FA code" type="text" value={mfaCode} /> 1766 - </label> 1185 + <label className="block text-xs font-semibold uppercase tracking-wide text-gray-400"><span className="mb-1 flex items-center gap-1"><ShieldCheck size={12} /> MFA</span><input className={inputBase} onChange={(e) => setMfaCode(e.target.value)} placeholder="Optional 2FA code" type="text" value={mfaCode} /></label> 1767 1186 </> 1768 1187 ) : ( 1769 - <textarea 1770 - className={`${inputBase} h-24 resize-none`} 1771 - onChange={(e) => setManualToken(e.target.value)} 1772 - placeholder="Paste session token" 1773 - value={manualToken} 1774 - /> 1188 + <textarea className={`${inputBase} h-24 resize-none`} onChange={(e) => setManualToken(e.target.value)} placeholder="Paste session token" value={manualToken} /> 1775 1189 )} 1776 - 1777 1190 <label className="flex items-start gap-2 rounded-md border border-[#2f3237] bg-[#1e1f22] p-2 text-xs text-gray-300"> 1778 1191 <input checked={privacyConsent} className="mt-0.5" onChange={(e) => setPrivacyConsent(e.target.checked)} type="checkbox" /> 1779 - <span> 1780 - I agree to the <a className="text-[#8ea1ff] underline hover:text-[#bdc3ff]" href="/privacy-policy.html" rel="noreferrer" target="_blank">privacy policy</a>. Ermine stores only my session token in a cookie. 1781 - </span> 1192 + <span>I agree to the <a className="text-[#8ea1ff] underline hover:text-[#bdc3ff]" href="/privacy-policy.html" rel="noreferrer" target="_blank">privacy policy</a>. Ermine stores only my session token in a cookie.</span> 1782 1193 </label> 1783 - 1784 - {loginError ? ( 1785 - <div className="flex items-start gap-2 rounded-md border border-red-800 bg-red-900/30 p-2 text-sm text-red-200"> 1786 - <AlertCircle className="mt-0.5" size={15} /> 1787 - <span>{loginError}</span> 1788 - </div> 1789 - ) : null} 1790 - 1791 - <button className="w-full rounded-md bg-[#5865f2] py-2.5 text-sm font-semibold text-white hover:bg-[#4956d8]" disabled={isLoggingIn} type="submit"> 1792 - {isLoggingIn ? 'Signing in…' : 'Log in to Ermine'} 1793 - </button> 1194 + {loginError ? <div className="flex items-start gap-2 rounded-md border border-red-800 bg-red-900/30 p-2 text-sm text-red-200"><AlertCircle className="mt-0.5" size={15} /><span>{loginError}</span></div> : null} 1195 + <button className="w-full rounded-md bg-[#5865f2] py-2.5 text-sm font-semibold text-white hover:bg-[#4956d8]" disabled={isLoggingIn} type="submit">{isLoggingIn ? 'Signing in…' : 'Log in to Ermine'}</button> 1794 1196 </form> 1795 - 1796 1197 <div className="mt-5 border-t border-[#202225] pt-4"> 1797 - <button className="flex items-center gap-1 text-xs text-gray-400 hover:text-gray-200" onClick={() => setShowAdvanced((prev) => !prev)} type="button"> 1798 - <Settings size={12} /> Advanced connection settings 1799 - </button> 1198 + <button className="flex items-center gap-1 text-xs text-gray-400 hover:text-gray-200" onClick={() => setShowAdvanced((prev) => !prev)} type="button"><Settings size={12} /> Advanced connection settings</button> 1800 1199 {showAdvanced ? ( 1801 1200 <div className="mt-2 space-y-2 rounded-md bg-[#1e1f22] p-2"> 1802 1201 <input className={inputBase} onChange={(e) => setConfig((prev) => ({ ...prev, apiUrl: e.target.value }))} placeholder="API URL" value={config.apiUrl} /> ··· 1815 1214 {activeModal === 'create-server' ? ( 1816 1215 <Modal onClose={() => setActiveModal(null)} title="Create a server"> 1817 1216 <input className={inputBase} onChange={(e) => setCreateServerName(e.target.value)} placeholder="Server name" value={createServerName} /> 1818 - <button className="w-full rounded-md bg-[#3ba55d] py-2 text-sm font-semibold text-white hover:bg-[#328a4f]" onClick={createServer}> 1819 - Create server 1820 - </button> 1217 + <button className="w-full rounded-md bg-[#3ba55d] py-2 text-sm font-semibold text-white hover:bg-[#328a4f]" onClick={createServer}>Create server</button> 1218 + </Modal> 1219 + ) : null} 1220 + 1221 + {activeModal === 'server-settings' && selectedServer ? ( 1222 + <Modal onClose={() => setActiveModal(null)} title="Server Settings"> 1223 + <div className="space-y-4"> 1224 + <div> 1225 + <label className="block text-xs font-semibold uppercase tracking-wide text-gray-400 mb-2">Server Name</label> 1226 + <div className="flex gap-2"> 1227 + <input className={inputBase} onChange={(e) => setEditServerName(e.target.value)} value={editServerName} placeholder="Enter server name" /> 1228 + <button onClick={updateServer} disabled={isUpdatingServer || !editServerName.trim()} className="rounded bg-[#5865f2] px-3 text-white hover:bg-[#4956d8] disabled:opacity-50"><Save size={18} /></button> 1229 + </div> 1230 + </div> 1231 + <div className="border-t border-[#202225] pt-4 mt-4"> 1232 + <h4 className="text-xs font-bold text-red-400 uppercase tracking-wide mb-2">Danger Zone</h4> 1233 + <div className="flex items-center justify-between rounded border border-red-900/50 p-3 bg-red-900/10"> 1234 + <div><div className="font-semibold text-white">Delete Server</div><div className="text-xs text-gray-400">Permanently remove this server and all its contents.</div></div> 1235 + <button onClick={deleteServer} disabled={isUpdatingServer} className="px-3 py-1.5 bg-red-600 hover:bg-red-700 text-white rounded text-sm font-semibold transition-colors">Delete Server</button> 1236 + </div> 1237 + </div> 1238 + </div> 1239 + </Modal> 1240 + ) : null} 1241 + 1242 + {/* Discovery Modal Placeholder */} 1243 + {activeModal === 'discovery' ? ( 1244 + <Modal onClose={() => setActiveModal(null)} title="Server Discovery"> 1245 + <div className="p-4 text-center"> 1246 + <Compass size={48} className="mx-auto text-green-500 mb-4" /> 1247 + <h3 className="text-lg font-bold text-white mb-2">Explore Communities</h3> 1248 + <p className="text-sm text-gray-400"> 1249 + Server discovery is coming soon to Ermine! Stay tuned to find new communities. 1250 + </p> 1251 + </div> 1821 1252 </Modal> 1822 1253 ) : null} 1823 1254 ··· 1826 1257 <div> 1827 1258 <p className="mb-2 text-xs font-semibold uppercase tracking-wide text-gray-400">Presence</p> 1828 1259 <div className="grid grid-cols-1 gap-2"> 1829 - {STATUS_OPTIONS.map((option) => { 1830 - const Icon = option.icon; 1831 - const selected = statusDraft.presence === option.value; 1832 - const focusVisual = option.value === 'Focus' 1833 - ? 'border-2 border-[#4f7dff] bg-[#4f7dff]/10 p-[6px] text-[#4f7dff]' 1834 - : ''; 1835 - 1836 - return ( 1837 - <button 1838 - className={`flex items-center gap-3 rounded-lg border px-3 py-2 text-left transition ${selected ? 'border-[#5865f2] bg-[#2c3163]/45' : 'border-[#2f3237] bg-[#1f2024] hover:border-[#3f4451] hover:bg-[#282a31]'}`} 1839 - key={option.value} 1840 - onClick={() => setStatusDraft((prev) => ({ ...prev, presence: option.value }))} 1841 - type="button" 1842 - > 1843 - <span className={`rounded-full ${focusVisual || 'p-1.5'} ${option.iconClass}`}> 1844 - <Icon size={15} /> 1845 - </span> 1846 - <span className="text-sm text-gray-100">{option.label}</span> 1847 - </button> 1848 - ); 1849 - })} 1260 + {STATUS_OPTIONS.map((option) => ( 1261 + <button className={`flex items-center gap-3 rounded-lg border px-3 py-2 text-left transition ${statusDraft.presence === option.value ? 'border-[#5865f2] bg-[#2c3163]/45' : 'border-[#2f3237] bg-[#1f2024] hover:border-[#3f4451] hover:bg-[#282a31]'}`} key={option.value} onClick={() => setStatusDraft((prev) => ({ ...prev, presence: option.value }))} type="button"> 1262 + <span className={`rounded-full ${option.value === 'Focus' ? 'border-2 border-[#4f7dff] bg-[#4f7dff]/10 p-[6px] text-[#4f7dff]' : 'p-1.5'} ${option.iconClass}`}><option.icon size={15} /></span> 1263 + <span className="text-sm text-gray-100">{option.label}</span> 1264 + </button> 1265 + ))} 1850 1266 </div> 1851 1267 </div> 1852 - <label className="block text-xs font-semibold uppercase tracking-wide text-gray-400"> 1853 - Custom status 1854 - <input className={`${inputBase} mt-1`} maxLength={128} onChange={(event) => setStatusDraft((prev) => ({ ...prev, text: event.target.value }))} placeholder="What's up?" value={statusDraft.text} /> 1855 - </label> 1856 - <button className="w-full rounded-md bg-[#5865f2] py-2 text-sm font-semibold text-white hover:bg-[#4956d8]" disabled={isSavingStatus} onClick={saveStatus} type="button"> 1857 - {isSavingStatus ? 'Saving…' : 'Save status'} 1858 - </button> 1268 + <label className="block text-xs font-semibold uppercase tracking-wide text-gray-400">Custom status<input className={`${inputBase} mt-1`} maxLength={128} onChange={(event) => setStatusDraft((prev) => ({ ...prev, text: event.target.value }))} placeholder="What's up?" value={statusDraft.text} /></label> 1269 + <button className="w-full rounded-md bg-[#5865f2] py-2 text-sm font-semibold text-white hover:bg-[#4956d8]" disabled={isSavingStatus} onClick={saveStatus} type="button">{isSavingStatus ? 'Saving…' : 'Save status'}</button> 1859 1270 </Modal> 1860 1271 ) : null} 1861 - 1862 1272 1863 1273 {activeModal === 'user-settings' ? ( 1864 1274 <Modal onClose={() => setActiveModal(null)} title="User settings"> ··· 1901 1311 </div> 1902 1312 </div> 1903 1313 </div> 1904 - 1905 1314 <div className="grid grid-cols-1 gap-2 rounded-md bg-[#1e1f22] p-3 text-xs text-gray-300"> 1906 1315 <p><span className="font-semibold text-gray-100">Joined platform:</span> {toDateLabel(peekPlatformJoined)}</p> 1907 1316 <p><span className="font-semibold text-gray-100">Joined server:</span> {selectedServerId === '@me' ? 'N/A' : toDateLabel(peekServerJoined)}</p> 1908 1317 </div> 1909 - 1910 1318 <div className="space-y-1 rounded-md bg-[#1e1f22] p-3"> 1911 1319 <p className="text-[11px] font-semibold uppercase tracking-wide text-gray-500">About me</p> 1912 1320 <p className="text-sm text-gray-200">{peekAboutMe || 'This user has not set an About Me yet.'}</p> 1913 1321 </div> 1914 - 1915 1322 <div className="space-y-1 rounded-md bg-[#1e1f22] p-3"> 1916 1323 <p className="text-[11px] font-semibold uppercase tracking-wide text-gray-500">Badges</p> 1917 - {peekBadges.length ? ( 1918 - <div className="flex flex-wrap gap-1.5"> 1919 - {peekBadges.map((badge) => ( 1920 - <span className="rounded bg-[#5865f2]/20 px-2 py-1 text-xs text-[#d7ddff]" key={badge}>{badge}</span> 1921 - ))} 1922 - </div> 1923 - ) : ( 1924 - <p className="text-xs text-gray-400">No badges</p> 1925 - )} 1324 + {peekBadges.length ? <div className="flex flex-wrap gap-1.5">{peekBadges.map((badge) => <span className="rounded bg-[#5865f2]/20 px-2 py-1 text-xs text-[#d7ddff]" key={badge}>{badge}</span>)}</div> : <p className="text-xs text-gray-400">No badges</p>} 1926 1325 </div> 1927 - 1928 1326 <div className="space-y-1 rounded-md bg-[#1e1f22] p-3"> 1929 1327 <p className="text-[11px] font-semibold uppercase tracking-wide text-gray-500">Roles</p> 1930 - {peekRoles.length ? ( 1931 - <div className="flex flex-wrap gap-1.5"> 1932 - {peekRoles.map((role) => ( 1933 - <span className="rounded bg-[#3a3d42] px-2 py-1 text-xs text-gray-200" key={role.name}>{role.name}</span> 1934 - ))} 1935 - </div> 1936 - ) : ( 1937 - <p className="text-xs text-gray-400">No server roles</p> 1938 - )} 1328 + {peekRoles.length ? <div className="flex flex-wrap gap-1.5">{peekRoles.map((role) => <span className="rounded bg-[#3a3d42] px-2 py-1 text-xs text-gray-200" key={role.name}>{role.name}</span>)}</div> : <p className="text-xs text-gray-400">No server roles</p>} 1939 1329 </div> 1940 1330 </Modal> 1941 1331 ) : null} 1942 1332 1943 1333 <aside className="flex w-[72px] flex-col items-center gap-2 overflow-y-auto bg-[#111214] py-3"> 1944 - <button className={`grid h-12 w-12 place-items-center transition ${selectedServerId === '@me' ? 'rounded-2xl bg-[#5865f2]' : 'rounded-full bg-[#313338] hover:rounded-2xl hover:bg-[#5865f2]'}`} onClick={() => handleServerSelect('@me')}> 1945 - <MessageSquare size={20} /> 1946 - </button> 1334 + <button className={`grid h-12 w-12 place-items-center transition ${selectedServerId === '@me' ? 'rounded-2xl bg-[#5865f2]' : 'rounded-full bg-[#313338] hover:rounded-2xl hover:bg-[#5865f2]'}`} onClick={() => handleServerSelect('@me')}><MessageSquare size={20} /></button> 1947 1335 <div className="h-px w-8 bg-[#202225]" /> 1948 - 1949 1336 {serverList.map((server) => { 1950 1337 const icon = getIconUrl(server, config.cdnUrl); 1951 1338 const active = selectedServerId === server._id; 1952 - 1953 - return ( 1954 - <button className={`grid h-12 w-12 place-items-center overflow-hidden transition ${active ? 'rounded-2xl bg-[#5865f2]' : 'rounded-full bg-[#313338] hover:rounded-2xl hover:bg-[#5865f2]'}`} key={server._id} onClick={() => handleServerSelect(server._id)} title={server.name}> 1955 - {icon ? <img alt={server.name} className="h-full w-full object-cover" src={icon} /> : <span className="text-sm font-bold">{server.name.slice(0, 2).toUpperCase()}</span>} 1956 - </button> 1957 - ); 1339 + return <button className={`grid h-12 w-12 place-items-center overflow-hidden transition ${active ? 'rounded-2xl bg-[#5865f2]' : 'rounded-full bg-[#313338] hover:rounded-2xl hover:bg-[#5865f2]'}`} key={server._id} onClick={() => handleServerSelect(server._id)} title={server.name}>{icon ? <img alt={server.name} className="h-full w-full object-cover" src={icon} /> : <span className="text-sm font-bold">{server.name.slice(0, 2).toUpperCase()}</span>}</button>; 1958 1340 })} 1959 - 1960 - <button className="grid h-12 w-12 place-items-center rounded-full bg-[#313338] text-[#3ba55d] transition hover:rounded-2xl hover:bg-[#3ba55d] hover:text-white" onClick={() => setActiveModal('create-server')}> 1961 - <Plus size={20} /> 1962 - </button> 1341 + <button className="grid h-12 w-12 place-items-center rounded-full bg-[#313338] text-[#3ba55d] transition hover:rounded-2xl hover:bg-[#3ba55d] hover:text-white" onClick={() => setActiveModal('create-server')}><Plus size={20} /></button> 1342 + <button className="grid h-12 w-12 place-items-center rounded-full bg-[#313338] text-green-500 transition hover:rounded-2xl hover:bg-green-600 hover:text-white" onClick={() => setActiveModal('discovery')} title="Server Discovery"><Compass size={20} /></button> 1963 1343 </aside> 1964 1344 1965 1345 <aside className="hidden w-60 flex-col bg-[#2b2d31] md:flex"> 1966 - <div className="border-b border-[#202225] px-4 py-3"> 1346 + <div className="border-b border-[#202225] px-4 py-3 flex justify-between items-center"> 1967 1347 <div className="truncate text-sm font-bold text-white">{selectedServerId === '@me' ? 'Ermine Home' : servers[selectedServerId]?.name || 'Server'}</div> 1348 + {isServerOwner && selectedServerId !== '@me' && <button onClick={openServerSettings} className="text-gray-400 hover:text-white transition-colors" title="Server Settings"><Settings size={16} /></button>} 1968 1349 </div> 1969 1350 <div className="flex-1 overflow-y-auto p-2"> 1970 1351 {selectedServerId === '@me' ? ( 1971 1352 <> 1972 - <button className={`mb-1 flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm ${selectedChannelId === 'friends' ? 'bg-[#404249] text-white' : 'text-gray-400 hover:bg-[#35373c] hover:text-white'}`} onClick={() => handleChannelSelect('friends')}> 1973 - <Users size={16} /> Friends 1974 - </button> 1353 + <button className={`mb-1 flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm ${selectedChannelId === 'friends' ? 'bg-[#404249] text-white' : 'text-gray-400 hover:bg-[#35373c] hover:text-white'}`} onClick={() => handleChannelSelect('friends')}><Users size={16} /> Friends</button> 1975 1354 {directMessageChannels.map((channel) => { 1976 1355 const recipientId = (channel.recipients || []).find((id) => id !== auth.userId); 1977 1356 const recipient = users[recipientId]; 1978 1357 const label = recipient?.username || channel.name || 'Direct Message'; 1979 - return ( 1980 - <button className={`mb-0.5 flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm ${selectedChannelId === channel._id ? 'bg-[#404249] text-white' : 'text-gray-400 hover:bg-[#35373c] hover:text-white'}`} key={channel._id} onClick={() => handleChannelSelect(channel._id)}> 1981 - <MessageSquare size={16} /> 1982 - <span className="truncate">{label}</span> 1983 - </button> 1984 - ); 1358 + return <button className={`mb-0.5 flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm ${selectedChannelId === channel._id ? 'bg-[#404249] text-white' : 'text-gray-400 hover:bg-[#35373c] hover:text-white'}`} key={channel._id} onClick={() => handleChannelSelect(channel._id)}><MessageSquare size={16} /><span className="truncate">{label}</span></button>; 1985 1359 })} 1986 1360 </> 1987 1361 ) : ( 1988 1362 channelList.map((channel) => { 1989 1363 const isVoice = channel?.channel_type === 'VoiceChannel'; 1990 - return ( 1991 - <button 1992 - className={`mb-0.5 flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm ${isVoice ? 'cursor-not-allowed text-gray-500 hover:bg-[#2f3237]' : selectedChannelId === channel._id ? 'bg-[#404249] text-white' : 'text-gray-400 hover:bg-[#35373c] hover:text-white'}`} 1993 - key={channel._id} 1994 - onClick={() => handleChannelSelect(channel._id)} 1995 - title={isVoice ? "Ermine currently doesn't support voice channels." : channel.name} 1996 - > 1997 - {isVoice ? <Users size={16} /> : <Hash size={16} />} 1998 - <span className="truncate">{channel.name || 'channel'}</span> 1999 - </button> 2000 - ); 1364 + return <button className={`mb-0.5 flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm ${isVoice ? 'cursor-not-allowed text-gray-500 hover:bg-[#2f3237]' : selectedChannelId === channel._id ? 'bg-[#404249] text-white' : 'text-gray-400 hover:bg-[#35373c] hover:text-white'}`} key={channel._id} onClick={() => handleChannelSelect(channel._id)} title={isVoice ? "Ermine currently doesn't support voice channels." : channel.name}>{isVoice ? <Users size={16} /> : <Hash size={16} />}<span className="truncate">{channel.name || 'channel'}</span></button>; 2001 1365 }) 2002 1366 )} 2003 1367 </div> 2004 - 2005 1368 <div className="border-t border-[#202225] p-2"> 2006 1369 <div className="flex items-center justify-between rounded bg-[#232428] p-2" onMouseEnter={() => setIsAccountHovered(true)} onMouseLeave={() => setIsAccountHovered(false)}> 2007 1370 <button className="flex min-w-0 items-center gap-2 rounded p-1 text-left hover:bg-[#2d3036]" onClick={openStatusEditor} type="button"> 2008 1371 <Avatar alwaysAnimate={isAccountHovered} cdnUrl={config.cdnUrl} size="sm" user={users[auth.userId]} /> 2009 - <div className="min-w-0"> 2010 - <div className="truncate text-xs font-semibold text-white">{users[auth.userId]?.username || 'Connected'}</div> 2011 - <div className="truncate text-[10px] uppercase tracking-wide text-gray-400">{users[auth.userId]?.status?.text || status}</div> 2012 - </div> 1372 + <div className="min-w-0"><div className="truncate text-xs font-semibold text-white">{users[auth.userId]?.username || 'Connected'}</div><div className="truncate text-[10px] uppercase tracking-wide text-gray-400">{users[auth.userId]?.status?.text || status}</div></div> 2013 1373 </button> 2014 1374 <div className="flex items-center gap-1"> 2015 - <button className="rounded p-1 text-gray-400 hover:bg-[#35373c] hover:text-white" onClick={() => setActiveModal('user-settings')} title="User settings" type="button"> 2016 - <Settings size={14} /> 2017 - </button> 2018 - <button className="rounded p-1 text-gray-400 hover:bg-[#35373c] hover:text-white" onClick={logout} title="Logout" type="button"> 2019 - <LogOut size={14} /> 2020 - </button> 1375 + <button className="rounded p-1 text-gray-400 hover:bg-[#35373c] hover:text-white" onClick={() => setActiveModal('user-settings')} title="User settings" type="button"><Settings size={14} /></button> 1376 + <button className="rounded p-1 text-gray-400 hover:bg-[#35373c] hover:text-white" onClick={logout} title="Logout" type="button"><LogOut size={14} /></button> 2021 1377 </div> 2022 1378 </div> 2023 1379 </div> ··· 2025 1381 2026 1382 <main className="relative flex min-w-0 flex-1 flex-col bg-[#313338]"> 2027 1383 <header className="flex items-center justify-between border-b border-[#202225] px-4 py-3"> 2028 - <div className="flex items-center gap-2 text-sm font-semibold text-white"> 2029 - {channels[selectedChannelId]?.channel_type === 'DirectMessage' ? <MessageSquare size={17} className="text-gray-400" /> : <Hash size={17} className="text-gray-400" />} 2030 - <span>{currentChannelName}</span> 2031 - </div> 1384 + <div className="flex items-center gap-2 text-sm font-semibold text-white">{channels[selectedChannelId]?.channel_type === 'DirectMessage' ? <MessageSquare size={17} className="text-gray-400" /> : <Hash size={17} className="text-gray-400" />}<span>{currentChannelName}</span></div> 2032 1385 <div className="text-xs text-gray-400">Ermine for stoat.chat</div> 2033 1386 </header> 2034 - 2035 - {voiceNotice ? ( 2036 - <div className="mx-4 mt-3 rounded-md border border-[#665200] bg-[#5c4a00]/25 px-3 py-2 text-xs text-yellow-200"> 2037 - {voiceNotice} 2038 - </div> 2039 - ) : null} 2040 - 1387 + {voiceNotice ? <div className="mx-4 mt-3 rounded-md border border-[#665200] bg-[#5c4a00]/25 px-3 py-2 text-xs text-yellow-200">{voiceNotice}</div> : null} 2041 1388 <section className="flex-1 overflow-y-auto py-2" ref={messagesContainerRef}> 2042 1389 {selectedChannelId === 'friends' ? ( 2043 1390 <div className="space-y-2 p-4"> ··· 2046 1393 {friends.map((friend) => ( 2047 1394 <button className="flex w-full items-center gap-3 rounded bg-[#2b2d31] p-3 text-left hover:bg-[#35373c]" key={friend._id} onClick={() => openDmWithUser(friend._id)}> 2048 1395 <Avatar animateOnHover cdnUrl={config.cdnUrl} user={friend} /> 2049 - <div className="min-w-0"> 2050 - <div className="truncate text-sm font-semibold text-white">{friend.username}</div> 2051 - <div className="text-xs text-gray-400">Direct message</div> 2052 - </div> 1396 + <div className="min-w-0"><div className="truncate text-sm font-semibold text-white">{friend.username}</div><div className="text-xs text-gray-400">Direct message</div></div> 2053 1397 </button> 2054 1398 ))} 2055 1399 </div> 2056 1400 ) : ( 2057 1401 currentMessages.map((message) => ( 2058 1402 <Message 2059 - cdnUrl={config.cdnUrl} 1403 + key={message._id} 1404 + message={message} 1405 + users={users} 2060 1406 channels={channels} 2061 - key={message._id} 2062 1407 me={auth.userId} 2063 - message={message} 1408 + onUserClick={openUserProfile} 1409 + cdnUrl={config.cdnUrl} 1410 + onToggleReaction={toggleReaction} 2064 1411 onReply={setReplyingTo} 2065 - onToggleReaction={toggleReaction} 2066 - onUserClick={openUserProfile} 1412 + replyTarget={replyingTo?._id} 2067 1413 onJumpToMessage={jumpToMessage} 2068 - onRequestOpenLink={requestOpenLink} 2069 - reactionOptions={reactionOptions} 2070 1414 registerMessageRef={registerMessageRef} 2071 - replyTarget={replyingTo?._id} 2072 1415 customEmojiById={customEmojiById} 2073 - users={users} 1416 + reactionOptions={reactionOptions} 1417 + onRequestOpenLink={requestOpenLink} 1418 + onEditMessage={editMessage} 1419 + onDeleteMessage={deleteMessage} 1420 + replyMessageMap={globalReplyMessageMap} 2074 1421 /> 2075 1422 )) 2076 1423 )} 2077 1424 <div ref={messagesBottomRef} /> 2078 1425 </section> 2079 - 2080 - {showGoLatest ? ( 2081 - <div className="pointer-events-none absolute bottom-24 left-1/2 z-20 -translate-x-1/2"> 2082 - <button className="pointer-events-auto rounded-full bg-[#5865f2] px-3 py-1.5 text-xs font-semibold text-white shadow-lg hover:bg-[#4956d8]" onClick={goToLatest} type="button"> 2083 - Go to latest 2084 - </button> 2085 - </div> 2086 - ) : null} 2087 - 1426 + {showGoLatest ? <div className="pointer-events-none absolute bottom-24 left-1/2 z-20 -translate-x-1/2"><button className="pointer-events-auto rounded-full bg-[#5865f2] px-3 py-1.5 text-xs font-semibold text-white shadow-lg hover:bg-[#4956d8]" onClick={goToLatest} type="button">Go to latest</button></div> : null} 2088 1427 <footer className="border-t border-[#202225] p-4"> 2089 - {activeReply ? ( 2090 - <div className="mb-2 flex items-center justify-between gap-2 rounded-md bg-[#2b2d31] px-3 py-2 text-xs text-gray-300"> 2091 - <span className="truncate"> 2092 - Replying to {users[typeof activeReply.author === 'string' ? activeReply.author : activeReply.author?._id]?.username || 'Unknown user'}: {activeReply.content || 'Attachment / embed'} 2093 - </span> 2094 - <button className="rounded p-1 text-gray-400 hover:bg-[#3a3d42] hover:text-white" onClick={() => setReplyingTo(null)} type="button"> 2095 - <X size={13} /> 2096 - </button> 2097 - </div> 2098 - ) : null} 2099 - {pendingFiles.length ? ( 2100 - <div className="mb-2 flex flex-wrap gap-1.5"> 2101 - {pendingFiles.map((file, index) => { 2102 - const previewImage = isImageLike(file.name, file.type); 2103 - return ( 2104 - <button className="flex items-center gap-2 rounded bg-[#2b2d31] px-2 py-1 text-xs text-gray-200 hover:bg-[#35373c]" key={`${file.name}-${index}`} onClick={() => removePendingFile(index)} type="button"> 2105 - <span>{previewImage ? '🖼️' : '📎'}</span> 2106 - <span>{file.name} <span className="text-gray-400">(remove)</span></span> 2107 - </button> 2108 - ); 2109 - })} 2110 - </div> 2111 - ) : null} 2112 - 1428 + {activeReply ? <div className="mb-2 flex items-center justify-between gap-2 rounded-md bg-[#2b2d31] px-3 py-2 text-xs text-gray-300"><span className="truncate">Replying to {users[typeof activeReply.author === 'string' ? activeReply.author : activeReply.author?._id]?.username || 'Unknown user'}: {activeReply.content || 'Attachment / embed'}</span><button className="rounded p-1 text-gray-400 hover:bg-[#3a3d42] hover:text-white" onClick={() => setReplyingTo(null)} type="button"><X size={13} /></button></div> : null} 1429 + {pendingFiles.length ? <div className="mb-2 flex flex-wrap gap-1.5">{pendingFiles.map((file, index) => { const previewImage = isImageLike(file.name, file.type); return <button className="flex items-center gap-2 rounded bg-[#2b2d31] px-2 py-1 text-xs text-gray-200 hover:bg-[#35373c]" key={`${file.name}-${index}`} onClick={() => removePendingFile(index)} type="button"><span>{previewImage ? '🖼️' : '📎'}</span><span>{file.name} <span className="text-gray-400">(remove)</span></span></button>; })}</div> : null} 2113 1430 <div className="flex items-center gap-2 rounded-lg bg-[#383a40] p-2" onDragOver={handleComposerDragOver} onDrop={handleComposerDrop}> 2114 - <button 2115 - className="rounded p-2 text-gray-300 hover:bg-[#4b4d55] hover:text-white" 2116 - disabled={selectedChannelId === 'friends' || isUploadingFiles} 2117 - onClick={() => fileInputRef.current?.click()} 2118 - title="Upload files" 2119 - type="button" 2120 - > 2121 - <Paperclip size={16} /> 2122 - </button> 1431 + <button className="rounded p-2 text-gray-300 hover:bg-[#4b4d55] hover:text-white" disabled={selectedChannelId === 'friends' || isUploadingFiles} onClick={() => fileInputRef.current?.click()} title="Upload files" type="button"><Paperclip size={16} /></button> 2123 1432 <input className="hidden" multiple onChange={handleFilePick} ref={fileInputRef} type="file" /> 2124 1433 <div className="relative"> 2125 - <button 2126 - className="rounded p-2 text-gray-300 hover:bg-[#4b4d55] hover:text-white" 2127 - disabled={selectedChannelId === 'friends'} 2128 - onClick={() => setShowEmojiPicker((prev) => !prev)} 2129 - type="button" 2130 - > 2131 - <Smile size={16} /> 2132 - </button> 1434 + <button className="rounded p-2 text-gray-300 hover:bg-[#4b4d55] hover:text-white" disabled={selectedChannelId === 'friends'} onClick={() => setShowEmojiPicker((prev) => !prev)} type="button"><Smile size={16} /></button> 2133 1435 {showEmojiPicker && selectedChannelId !== 'friends' ? ( 2134 1436 <div className="absolute bottom-12 left-0 z-20 w-72 rounded-lg border border-[#4c4f56] bg-[#232428] p-2 shadow-2xl"> 2135 1437 <p className="mb-1 text-[10px] uppercase tracking-wide text-gray-500">Emoji</p> 2136 - <div className="grid max-h-36 grid-cols-8 gap-1 overflow-y-auto"> 2137 - {EXTENDED_EMOJIS.map((emoji) => ( 2138 - <button className="rounded p-1 text-lg hover:bg-[#3a3d42]" key={emoji} onClick={() => addEmojiToComposer(emoji)} type="button"> 2139 - {emoji} 2140 - </button> 2141 - ))} 2142 - </div> 2143 - {customEmojis.length ? ( 2144 - <> 2145 - <p className="mb-1 mt-2 text-[10px] uppercase tracking-wide text-gray-500">Server emojis</p> 2146 - <div className="flex max-h-24 flex-wrap gap-1 overflow-y-auto"> 2147 - {customEmojis.map((emoji) => { 2148 - const custom = customEmojiById[emoji.id]; 2149 - const source = custom?.isPrivate ? 'Private server emoji' : custom?.serverName ? `Server emoji from ${custom.serverName}` : 'Custom emoji'; 2150 - return ( 2151 - <button className="flex items-center gap-1 rounded bg-[#303239] px-1.5 py-1 text-xs text-gray-200 hover:bg-[#3a3d42]" key={emoji.id || emoji.name} onClick={() => addCustomEmojiToComposer(emoji.id)} title={`${emoji.name} · ${source}`} type="button"> 2152 - {renderEmojiVisual(`:${emoji.id}:`, custom, config.cdnUrl)} 2153 - <span>:{emoji.name}:</span> 2154 - </button> 2155 - ); 2156 - })} 2157 - </div> 2158 - </> 2159 - ) : null} 1438 + <div className="grid max-h-36 grid-cols-8 gap-1 overflow-y-auto">{EXTENDED_EMOJIS.map((emoji) => <button className="rounded p-1 text-lg hover:bg-[#3a3d42]" key={emoji} onClick={() => addEmojiToComposer(emoji)} type="button">{renderTwemoji(emoji, "w-6 h-6 inline-block")}</button>)}</div> 1439 + {customEmojis.length ? <><p className="mb-1 mt-2 text-[10px] uppercase tracking-wide text-gray-500">Server emojis</p><div className="flex max-h-24 flex-wrap gap-1 overflow-y-auto">{customEmojis.map((emoji) => { const custom = customEmojiById[emoji.id]; const source = custom?.isPrivate ? 'Private server emoji' : custom?.serverName ? `Server emoji from ${custom.serverName}` : 'Custom emoji'; return <button className="flex items-center gap-1 rounded bg-[#303239] px-1.5 py-1 text-xs text-gray-200 hover:bg-[#3a3d42]" key={emoji.id || emoji.name} onClick={() => addCustomEmojiToComposer(emoji.id)} title={`${emoji.name} · ${source}`} type="button">{renderEmojiVisual(`:${emoji.id}:`, custom, config.cdnUrl)}<span>:{emoji.name}:</span></button>; })}</div></> : null} 2160 1440 </div> 2161 1441 ) : null} 2162 1442 </div> 2163 - <input 2164 - className="flex-1 bg-transparent px-2 py-1 text-sm text-gray-100 placeholder:text-gray-400 focus:outline-none" 2165 - disabled={selectedChannelId === 'friends'} 2166 - onChange={(e) => setInputText(e.target.value)} 2167 - onKeyDown={(e) => { 2168 - if (e.key === 'Enter' && !e.shiftKey) { 2169 - e.preventDefault(); 2170 - sendMessage(); 2171 - } 2172 - }} 2173 - placeholder={selectedChannelId === 'friends' ? 'Select a text channel to chat.' : `Enter message in #${currentChannelName}`} 2174 - value={inputText} 2175 - /> 2176 - <button className="rounded p-2 text-gray-300 hover:bg-[#4b4d55] hover:text-white disabled:cursor-not-allowed disabled:opacity-60" disabled={isUploadingFiles} onClick={sendMessage} type="button"> 2177 - <Send size={16} /> 2178 - </button> 1443 + <input className="flex-1 bg-transparent px-2 py-1 text-sm text-gray-100 placeholder:text-gray-400 focus:outline-none" disabled={selectedChannelId === 'friends'} onChange={(e) => setInputText(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); } }} placeholder={selectedChannelId === 'friends' ? 'Select a text channel to chat.' : `Enter message in #${currentChannelName}`} value={inputText} /> 1444 + <button className="rounded p-2 text-gray-300 hover:bg-[#4b4d55] hover:text-white disabled:cursor-not-allowed disabled:opacity-60" disabled={isUploadingFiles} onClick={sendMessage} type="button"><Send size={16} /></button> 2179 1445 </div> 2180 1446 </footer> 2181 1447 </main> 2182 1448 2183 1449 <aside className="hidden w-60 flex-col border-l border-[#202225] bg-[#2b2d31] lg:flex"> 2184 - <div className="border-b border-[#202225] px-4 py-3 text-xs font-bold uppercase tracking-wide text-gray-400"> 2185 - Members — {allCurrentMembers.length}{isMembersLoading ? ' (loading...)' : ''} 2186 - </div> 2187 - <div className="min-h-0 flex-1 space-y-1 overflow-y-auto p-2"> 2188 - {hiddenMembersCount > 0 ? <p className="px-2 py-1 text-[11px] text-gray-500">Showing first {currentMembers.length} members for responsiveness.</p> : null} 2189 - {currentMembers.map((member) => ( 2190 - <button className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left hover:bg-[#35373c]" key={member._id.user} onClick={() => openUserProfile(member.user, member._id.user)}> 2191 - <Avatar animateOnHover cdnUrl={config.cdnUrl} size="sm" user={member.user} /> 2192 - <div className="min-w-0"> 2193 - <div className="truncate text-sm text-gray-200">{member.nickname || member.user.username}</div> 2194 - <div className="flex items-center gap-1 text-[11px] text-gray-500"> 2195 - <User size={11} /> {member.user.status?.presence || 'Offline'} 2196 - </div> 2197 - </div> 2198 - </button> 2199 - ))} 1450 + <div className="border-b border-[#202225] px-4 py-3 text-xs font-bold uppercase tracking-wide text-gray-400">Members — {allCurrentMembers.length}{isMembersLoading ? ' (loading...)' : ''}</div> 1451 + <div className="min-h-0 flex-1 relative"> 1452 + <VirtualList items={memberListItems} itemHeight={40} className="h-full w-full" renderItem={renderMemberItem} /> 2200 1453 </div> 2201 1454 </aside> 2202 1455 </div>