[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.

Add files via upload

authored by

Fascinating Pistachio and committed by
GitHub
(Mar 15, 2026, 9:20 AM UTC) 8d0753fc 229188c7

+1499 -757
+32 -10
index.html
··· 3 3 <head> 4 4 <meta charset="UTF-8" /> 5 5 <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 6 + <meta name="description" content="Ermine — a refined client for stoat.chat." /> 7 + <meta name="theme-color" content="#0F1115" /> 6 8 <title>Ermine</title> 7 9 <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='12' fill='%230F1115'/%3E%3Cpath d='M18 16h28v8H28v8h14v8H28v8h18v8H18V16z' fill='%23E6EDF3'/%3E%3C/svg%3E" /> 8 10 <link rel="preconnect" href="https://fonts.googleapis.com" /> ··· 36 38 } 37 39 38 40 @keyframes ermineModalIn { 39 - from { 40 - opacity: 0; 41 - transform: translateY(4px); 42 - } 43 - to { 44 - opacity: 1; 45 - transform: translateY(0); 46 - } 41 + from { opacity: 0; transform: translateY(6px) scale(0.98); } 42 + to { opacity: 1; transform: translateY(0) scale(1); } 43 + } 44 + 45 + @keyframes ermineSlideDown { 46 + from { opacity: 0; transform: translateY(-4px); } 47 + to { opacity: 1; transform: translateY(0); } 47 48 } 48 49 49 - #root { 50 - min-height: 100vh; 50 + @keyframes ermineSpinSlow { 51 + to { transform: rotate(360deg); } 51 52 } 53 + 54 + #root { min-height: 100vh; } 55 + 56 + /* ── Scrollbars ── */ 57 + ::-webkit-scrollbar { width: 6px; height: 6px; } 58 + ::-webkit-scrollbar-track { background: transparent; } 59 + ::-webkit-scrollbar-thumb { background: #3f4249; border-radius: 3px; } 60 + ::-webkit-scrollbar-thumb:hover { background: #5c606a; } 61 + * { scrollbar-width: thin; scrollbar-color: #3f4249 transparent; } 62 + 63 + /* ── Textarea auto-resize ── */ 64 + .composer-textarea { 65 + field-sizing: content; /* Chrome 123+ native */ 66 + max-height: 200px; 67 + overflow-y: auto; 68 + line-height: 1.4; 69 + } 70 + 71 + /* ── Reaction button transitions ── */ 72 + .reaction-btn { transition: transform 80ms ease, background-color 80ms ease; } 73 + .reaction-btn:active { transform: scale(0.88); } 52 74 </style> 53 75 </head> 54 76 <body>
+1
package.json
··· 14 14 "react-dom": "^18.3.1" 15 15 }, 16 16 "devDependencies": { 17 + "@vitejs/plugin-react": "^4.3.4", 17 18 "vite": "^5.4.19" 18 19 } 19 20 }
+1451 -747
src/App.jsx
··· 5 5 EyeOff, 6 6 Hash, 7 7 LogOut, 8 + Menu, 8 9 MessageSquare, 9 10 Moon, 10 11 MinusCircle, ··· 21 22 Trash2, 22 23 Edit2, 23 24 Save, 24 - MoreVertical, 25 25 Search, 26 - Image as ImageIcon 26 + Loader, 27 27 } from 'lucide-react'; 28 28 29 - // --- ULID Decoder for Timestamps --- 30 - const ENCODING = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; 29 + // ─── ULID → timestamp ──────────────────────────────────────────────────────── 30 + const ENCODING = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'; 31 31 const DECODE_LOOKUP = {}; 32 32 for (let i = 0; i < ENCODING.length; i++) DECODE_LOOKUP[ENCODING[i]] = i; 33 33 ··· 35 35 if (typeof id !== 'string' || id.length !== 26) return Date.now(); 36 36 const timePart = id.substring(0, 10).toUpperCase(); 37 37 let time = 0; 38 - for (let i = 0; i < 10; i++) { 39 - time = time * 32 + (DECODE_LOOKUP[timePart[i]] || 0); 40 - } 38 + for (let i = 0; i < 10; i++) time = time * 32 + (DECODE_LOOKUP[timePart[i]] || 0); 41 39 return time; 42 40 }; 43 41 44 - // --- Low Spec Detection (Wii U / Old Browsers) --- 45 - const isLowSpec = typeof navigator !== 'undefined' && ( 46 - /Nintendo WiiU/i.test(navigator.userAgent) || 47 - /Nintendo 3DS/i.test(navigator.userAgent) || 48 - /PlayStation/i.test(navigator.userAgent) || 49 - (typeof window.matchMedia === 'function' && window.matchMedia('(prefers-reduced-motion: reduce)').matches) 50 - ); 42 + // ─── Low-spec detection ─────────────────────────────────────────────────────── 43 + const isLowSpec = 44 + typeof navigator !== 'undefined' && 45 + (/Nintendo WiiU/i.test(navigator.userAgent) || 46 + /Nintendo 3DS/i.test(navigator.userAgent) || 47 + /PlayStation/i.test(navigator.userAgent) || 48 + (typeof window.matchMedia === 'function' && 49 + window.matchMedia('(prefers-reduced-motion: reduce)').matches)); 51 50 52 - // --- Twemoji Utilities --- 51 + // ─── Twemoji ───────────────────────────────────────────────────────────────── 53 52 const TWEMOJI_BASE = 'https://cdnjs.cloudflare.com/ajax/libs/twemoji/14.0.2/72x72/'; 54 53 55 - const toCodePoint = (unicodeSurrogates, sep) => { 54 + const toCodePoint = (unicodeSurrogates) => { 56 55 const r = []; 57 - let c = 0; 58 - let p = 0; 59 - let i = 0; 56 + let c = 0, p = 0, i = 0; 60 57 while (i < unicodeSurrogates.length) { 61 58 c = unicodeSurrogates.charCodeAt(i++); 62 59 if (p) { ··· 68 65 r.push(c.toString(16)); 69 66 } 70 67 } 71 - return r.join(sep || '-'); 68 + return r.join('-'); 72 69 }; 73 70 74 - const renderTwemoji = (text, className = "inline-block w-5 h-5 align-bottom") => { 71 + const renderTwemoji = (text, className = 'inline-block w-5 h-5 align-bottom') => { 75 72 if (!text) return null; 76 - const parts = text.split(/([\uD800-\uDBFF][\uDC00-\uDFFF])/g); 77 - return parts.map((part, index) => { 73 + return text.split(/([\uD800-\uDBFF][\uDC00-\uDFFF])/g).map((part, index) => { 78 74 if (/[\uD800-\uDBFF][\uDC00-\uDFFF]/.test(part)) { 79 - const codePoint = toCodePoint(part); 75 + const cp = toCodePoint(part); 80 76 return ( 81 - <img 77 + <img 82 78 key={index} 83 - src={`${TWEMOJI_BASE}${codePoint}.png`} 84 - alt={part} 79 + src={`${TWEMOJI_BASE}${cp}.png`} 80 + alt={part} 85 81 className={className} 86 82 draggable={false} 87 83 onError={(e) => { e.currentTarget.style.display = 'none'; }} ··· 92 88 }); 93 89 }; 94 90 95 - // --- Config & Helpers --- 91 + // ─── Config ────────────────────────────────────────────────────────────────── 96 92 const getRuntimeConfig = () => { 97 93 const runtime = typeof window !== 'undefined' ? window.__ERMINE_CONFIG__ || {} : {}; 94 + // Support Vite build-time env vars (injected via vite.config.js define) 95 + const buildApiUrl = typeof __ERMINE_API_URL__ !== 'undefined' ? __ERMINE_API_URL__ : ''; 96 + const buildWsUrl = typeof __ERMINE_WS_URL__ !== 'undefined' ? __ERMINE_WS_URL__ : ''; 97 + const buildCdnUrl = typeof __ERMINE_CDN_URL__ !== 'undefined' ? __ERMINE_CDN_URL__ : ''; 98 98 return { 99 - apiUrl: runtime.apiUrl || 'https://api.stoat.chat', 100 - wsUrl: runtime.wsUrl || 'wss://stoat.chat/events', 101 - cdnUrl: runtime.cdnUrl || 'https://cdn.stoatusercontent.com', 99 + apiUrl: runtime.apiUrl || buildApiUrl || 'https://api.stoat.chat', 100 + wsUrl: runtime.wsUrl || buildWsUrl || 'wss://stoat.chat/events', 101 + cdnUrl: runtime.cdnUrl || buildCdnUrl || 'https://cdn.stoatusercontent.com', 102 102 }; 103 103 }; 104 104 ··· 108 108 'w-full rounded-md border border-[#242A35] bg-[#141821] px-3 py-2 text-sm text-[#E6EDF3] placeholder:text-[#8892A6] focus:border-[#8AB4F8] focus:outline-none focus:ring-1 focus:ring-[#8AB4F8]'; 109 109 110 110 const TOKEN_COOKIE_NAME = 'ermine_session_token'; 111 - const USER_COOKIE_NAME = 'ermine_user_id'; 112 - const API_COOKIE_NAME = 'ermine_api_url'; 111 + const USER_COOKIE_NAME = 'ermine_user_id'; 112 + const API_COOKIE_NAME = 'ermine_api_url'; 113 113 114 114 const getCookie = (name) => { 115 115 if (typeof document === 'undefined') return null; 116 - const value = document.cookie.split('; ').find((entry) => entry.startsWith(`${name}=`)); 116 + const value = document.cookie.split('; ').find((e) => e.startsWith(`${name}=`)); 117 117 return value ? decodeURIComponent(value.slice(name.length + 1)) : null; 118 118 }; 119 - 120 - const setCookie = (name, value, maxAgeSeconds = 60 * 60 * 24 * 30) => { 119 + const setCookie = (name, value, maxAge = 60 * 60 * 24 * 30) => { 121 120 if (typeof document === 'undefined') return; 122 - document.cookie = `${name}=${encodeURIComponent(value)}; Max-Age=${maxAgeSeconds}; Path=/; SameSite=Lax; Secure`; 121 + document.cookie = `${name}=${encodeURIComponent(value)}; Max-Age=${maxAge}; Path=/; SameSite=Lax; Secure`; 123 122 }; 124 - 125 123 const clearCookie = (name) => { 126 124 if (typeof document === 'undefined') return; 127 125 document.cookie = `${name}=; Max-Age=0; Path=/; SameSite=Lax; Secure`; 128 126 }; 129 127 128 + // ─── Status options ─────────────────────────────────────────────────────────── 130 129 const STATUS_OPTIONS = [ 131 - { value: 'Online', label: 'Online', icon: Circle, iconClass: 'text-[#3ba55d]' }, // Swapped Activity for Circle 132 - { value: 'Idle', label: 'Idle', icon: Moon, iconClass: 'text-[#f0b232]' }, 133 - { value: 'Busy', label: 'Do Not Disturb', icon: MinusCircle, iconClass: 'text-[#ed4245]' }, 134 - { value: 'Focus', label: 'Focus', icon: Circle, iconClass: 'text-[#4f7dff]' }, 135 - { value: 'Invisible', label: 'Invisible', icon: EyeOff, iconClass: 'text-gray-400' }, 130 + { value: 'Online', label: 'Online', icon: Circle, iconClass: 'text-[#3ba55d]' }, 131 + { value: 'Idle', label: 'Idle', icon: Moon, iconClass: 'text-[#f0b232]' }, 132 + { value: 'Busy', label: 'Do Not Disturb', icon: MinusCircle, iconClass: 'text-[#ed4245]' }, 133 + { value: 'Focus', label: 'Focus', icon: Circle, iconClass: 'text-[#4f7dff]' }, 134 + { value: 'Invisible', label: 'Invisible', icon: EyeOff, iconClass: 'text-gray-400' }, 136 135 ]; 137 136 137 + // ─── Time helpers ───────────────────────────────────────────────────────────── 138 138 const formatSmartTime = (valueOrId) => { 139 139 let date; 140 140 if (typeof valueOrId === 'string' && valueOrId.length === 26) { ··· 152 152 return `${date.toLocaleDateString()} at ${timeStr}`; 153 153 }; 154 154 155 + const getMessageDayKey = (message) => { 156 + const ts = message.createdAt 157 + ? new Date(message.createdAt) 158 + : new Date(ulidToMillis(message._id)); 159 + return Number.isNaN(ts.getTime()) ? '' : ts.toDateString(); 160 + }; 161 + 162 + const formatDaySeparator = (message) => { 163 + const ts = message.createdAt 164 + ? new Date(message.createdAt) 165 + : new Date(ulidToMillis(message._id)); 166 + if (Number.isNaN(ts.getTime())) return ''; 167 + const now = new Date(); 168 + const yesterday = new Date(); 169 + yesterday.setDate(now.getDate() - 1); 170 + if (ts.toDateString() === now.toDateString()) return 'Today'; 171 + if (ts.toDateString() === yesterday.toDateString()) return 'Yesterday'; 172 + return ts.toLocaleDateString([], { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }); 173 + }; 174 + 175 + // ─── Asset helpers ──────────────────────────────────────────────────────────── 155 176 const getAvatarUrl = (user, cdnUrl) => user?.avatar?._id ? `${cdnUrl}/avatars/${user.avatar._id}` : null; 156 - const getIconUrl = (server, cdnUrl) => server?.icon?._id ? `${cdnUrl}/icons/${server.icon._id}` : null; 177 + const getIconUrl = (server, cdnUrl) => server?.icon?._id ? `${cdnUrl}/icons/${server.icon._id}` : null; 157 178 const getBannerUrl = (user, cdnUrl) => { 158 - const banner = user?.profile?.background || user?.banner; 179 + const banner = user?.profile?.background || user?.banner; 159 180 const bannerId = typeof banner === 'string' ? banner : banner?._id; 160 181 return bannerId ? `${cdnUrl}/backgrounds/${bannerId}/original` : null; 161 182 }; 162 183 const getJoinedAt = (entry) => entry?.joined_at || entry?.joinedAt || entry?.created_at || entry?.createdAt || null; 163 184 const toDateLabel = (value) => { 164 185 if (!value) return 'Unknown'; 165 - const date = new Date(value); 166 - return Number.isNaN(date.getTime()) ? 'Unknown' : date.toLocaleDateString([], { year: 'numeric', month: 'short', day: 'numeric' }); 186 + const d = new Date(value); 187 + return Number.isNaN(d.getTime()) ? 'Unknown' : d.toLocaleDateString([], { year: 'numeric', month: 'short', day: 'numeric' }); 167 188 }; 168 189 169 - // --- Shared Components --- 170 - 190 + // ─── Shared components ──────────────────────────────────────────────────────── 171 191 const Avatar = ({ user, cdnUrl, size = 'md', animateOnHover = false, alwaysAnimate = false }) => { 172 192 const sizeMap = { sm: 'h-8 w-8 text-xs', md: 'h-10 w-10 text-sm', lg: 'h-12 w-12 text-base' }; 173 193 const initials = (user?.username || '?').slice(0, 2).toUpperCase(); 174 194 const [isHovered, setIsHovered] = useState(false); 175 - const [hasError, setHasError] = useState(false); 176 - 177 - // Reset error state if avatar changes 195 + const [hasError, setHasError] = useState(false); 178 196 useEffect(() => { setHasError(false); }, [user?.avatar?._id]); 179 - 180 197 const effectiveAnimate = (alwaysAnimate || (animateOnHover && isHovered)) && !isLowSpec && !hasError; 181 198 const src = (() => { 182 199 if (!user?.avatar?._id) return null; 183 - return effectiveAnimate ? `${cdnUrl}/avatars/${user.avatar._id}/original` : `${cdnUrl}/avatars/${user.avatar._id}`; 200 + return effectiveAnimate 201 + ? `${cdnUrl}/avatars/${user.avatar._id}/original` 202 + : `${cdnUrl}/avatars/${user.avatar._id}`; 184 203 })(); 185 - 186 204 return ( 187 205 <div 188 206 className={`grid shrink-0 place-items-center overflow-hidden rounded-full bg-[#313338] font-semibold text-gray-200 ${sizeMap[size]}`} ··· 190 208 onMouseLeave={() => setIsHovered(false)} 191 209 > 192 210 {src ? ( 193 - <img 194 - alt={user?.username || 'User avatar'} 195 - className="block h-full w-full object-cover object-center" 196 - src={src} 197 - onError={() => { if(effectiveAnimate) setHasError(true); }} 211 + <img 212 + alt={user?.username || 'User avatar'} 213 + className="block h-full w-full object-cover object-center" 214 + src={src} 215 + onError={() => { if (effectiveAnimate) setHasError(true); }} 198 216 /> 199 217 ) : initials} 200 218 </div> ··· 202 220 }; 203 221 204 222 class AppErrorBoundary extends Component { 205 - constructor(props) { 206 - super(props); 207 - this.state = { hasError: false }; 208 - } 223 + constructor(props) { super(props); this.state = { hasError: false }; } 209 224 static getDerivedStateFromError() { return { hasError: true }; } 210 225 componentDidCatch(error) { console.error('Ermine crashed:', error); } 211 226 render() { ··· 215 230 <div className="w-full max-w-lg rounded-2xl border border-[#242A35] bg-[#171A21] p-8 text-center shadow-2xl"> 216 231 <h1 className="mt-3 text-2xl font-bold text-white">Connection interrupted.</h1> 217 232 <p className="mt-2 text-sm text-[#A6B0C3]">Attempting recovery requires a reload.</p> 218 - <button className="mt-6 rounded-md bg-[#8AB4F8] px-4 py-2 text-sm font-semibold text-[#0F1115] hover:bg-[#6FA6E8]" onClick={() => window.location.reload()} type="button">Reload Ermine</button> 233 + <button 234 + className="mt-6 rounded-md bg-[#8AB4F8] px-4 py-2 text-sm font-semibold text-[#0F1115] hover:bg-[#6FA6E8]" 235 + onClick={() => window.location.reload()} 236 + type="button" 237 + > 238 + Reload Ermine 239 + </button> 219 240 </div> 220 241 </div> 221 242 ); ··· 225 246 } 226 247 227 248 const Modal = ({ title, onClose, children }) => ( 228 - <div className="fixed inset-0 z-50 grid place-items-center bg-black/70 p-4 backdrop-blur-sm overflow-y-auto"> 249 + <div 250 + className="fixed inset-0 z-50 grid place-items-center bg-black/70 p-4 backdrop-blur-sm overflow-y-auto" 251 + onKeyDown={(e) => { if (e.key === 'Escape') onClose(); }} 252 + > 229 253 <div className="w-full max-w-lg m-auto rounded-xl border border-[#242A35] bg-[#171A21] shadow-2xl flex max-h-[85vh] flex-col animate-[ermineModalIn_140ms_ease-out]"> 230 254 <div className="flex shrink-0 items-center justify-between border-b border-[#202225] px-4 py-3"> 231 255 <h3 className="text-sm font-bold text-white">{title}</h3> 232 - <button className="rounded p-1 text-gray-400 hover:bg-[#3a3d42] hover:text-white" onClick={onClose}><X size={16} /></button> 256 + <button className="rounded p-1 text-gray-400 hover:bg-[#3a3d42] hover:text-white" onClick={onClose}> 257 + <X size={16} /> 258 + </button> 233 259 </div> 234 - <div className="p-4 overflow-y-auto custom-scrollbar">{children}</div> 260 + <div className="p-4 overflow-y-auto space-y-3">{children}</div> 235 261 </div> 236 262 </div> 237 263 ); 238 264 265 + const DateSeparator = ({ message }) => ( 266 + <div className="flex items-center gap-3 px-4 py-3 select-none"> 267 + <div className="flex-1 h-px bg-[#3f4249]" /> 268 + <span className="text-xs font-semibold text-gray-400">{formatDaySeparator(message)}</span> 269 + <div className="flex-1 h-px bg-[#3f4249]" /> 270 + </div> 271 + ); 272 + 239 273 const VirtualList = ({ items, renderItem, itemHeight, className }) => { 240 274 const containerRef = useRef(null); 241 - const [scrollTop, setScrollTop] = useState(0); 275 + const [scrollTop, setScrollTop] = useState(0); 242 276 const [viewportHeight, setViewportHeight] = useState(0); 243 - const requestRef = useRef(); 244 - 277 + const rafRef = useRef(); 245 278 useEffect(() => { 246 - const container = containerRef.current; 247 - if (!container) return; 248 - const handleResize = () => setViewportHeight(container.clientHeight); 249 - const handleScroll = () => { 250 - if (requestRef.current) return; 251 - requestRef.current = requestAnimationFrame(() => { 252 - setScrollTop(container.scrollTop); 253 - requestRef.current = null; 279 + const el = containerRef.current; 280 + if (!el) return; 281 + const onResize = () => setViewportHeight(el.clientHeight); 282 + const onScroll = () => { 283 + if (rafRef.current) return; 284 + rafRef.current = requestAnimationFrame(() => { 285 + setScrollTop(el.scrollTop); 286 + rafRef.current = null; 254 287 }); 255 288 }; 256 - setViewportHeight(container.clientHeight); 257 - window.addEventListener('resize', handleResize); 258 - container.addEventListener('scroll', handleScroll); 289 + setViewportHeight(el.clientHeight); 290 + window.addEventListener('resize', onResize); 291 + el.addEventListener('scroll', onScroll); 259 292 return () => { 260 - window.removeEventListener('resize', handleResize); 261 - container.removeEventListener('scroll', handleScroll); 262 - if (requestRef.current) cancelAnimationFrame(requestRef.current); 293 + window.removeEventListener('resize', onResize); 294 + el.removeEventListener('scroll', onScroll); 295 + if (rafRef.current) cancelAnimationFrame(rafRef.current); 263 296 }; 264 297 }, []); 265 - 266 - const totalHeight = items.length * itemHeight; 267 - const startIndex = Math.max(0, Math.floor(scrollTop / itemHeight) - 5); 268 - const endIndex = Math.min(items.length, Math.ceil((scrollTop + viewportHeight) / itemHeight) + 5); 269 - const visibleItems = items.slice(startIndex, endIndex).map((item, index) => ({ item, index: startIndex + index, offsetTop: (startIndex + index) * itemHeight })); 270 - 298 + const totalHeight = items.length * itemHeight; 299 + const startIndex = Math.max(0, Math.floor(scrollTop / itemHeight) - 5); 300 + const endIndex = Math.min(items.length, Math.ceil((scrollTop + viewportHeight) / itemHeight) + 5); 301 + const visibleItems = items.slice(startIndex, endIndex).map((item, i) => ({ 302 + item, index: startIndex + i, offsetTop: (startIndex + i) * itemHeight, 303 + })); 271 304 return ( 272 305 <div ref={containerRef} className={className} style={{ position: 'relative', overflowY: 'auto' }}> 273 306 <div style={{ height: totalHeight, width: '100%' }}> 274 307 {visibleItems.map(({ item, index, offsetTop }) => ( 275 - <div key={item.key || index} style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: itemHeight, transform: `translateY(${offsetTop}px)` }}> 308 + <div 309 + key={item.key || index} 310 + style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: itemHeight, transform: `translateY(${offsetTop}px)` }} 311 + > 276 312 {renderItem(item)} 277 313 </div> 278 314 ))} ··· 281 317 ); 282 318 }; 283 319 284 - // --- Logic Helpers --- 320 + // ─── Link / Markdown rendering ──────────────────────────────────────────────── 285 321 const LinkAnchor = ({ href, children, onRequestOpenLink, className = 'text-[#8ea1ff] underline hover:text-[#bdc3ff]' }) => ( 286 - <a className={className} href={href} onClick={(e) => { e.preventDefault(); if (onRequestOpenLink) onRequestOpenLink(href); }} rel="noreferrer" target="_blank">{children}</a> 322 + <a 323 + className={className} 324 + href={href} 325 + onClick={(e) => { e.preventDefault(); onRequestOpenLink?.(href); }} 326 + rel="noreferrer" 327 + target="_blank" 328 + > 329 + {children} 330 + </a> 287 331 ); 288 332 289 333 const renderLinksInText = (value, keyPrefix = 'link', onRequestOpenLink = null) => { 290 334 if (!value) return null; 291 335 return value.split(/(https?:\/\/[^\s]+)/gi).map((part, index) => { 292 - if (/^https?:\/\//i.test(part)) return <LinkAnchor href={part} key={`${keyPrefix}-${index}`} onRequestOpenLink={onRequestOpenLink}>{part}</LinkAnchor>; 336 + if (/^https?:\/\//i.test(part)) 337 + return <LinkAnchor href={part} key={`${keyPrefix}-${index}`} onRequestOpenLink={onRequestOpenLink}>{part}</LinkAnchor>; 293 338 return <React.Fragment key={`${keyPrefix}-${index}`}>{part}</React.Fragment>; 294 339 }); 295 340 }; 296 341 297 342 const renderMarkdownInline = (text, keyPrefix = 'md', onRequestOpenLink = null) => { 298 343 if (!text) return null; 299 - const tokens = text.split(/(`[^`]+`|\*\*[^*]+\*\*|\*[^*]+\*|__[^_]+__|~~[^~]+~~|\[[^\]]+\]\([^\)]+\))/g); 344 + const tokens = text.split(/(`[^`]+`|\*\*[^*]+\*\*|\*[^*]+\*|__[^_]+__|~~[^~]+~~|\[[^\]]+\]\([^)]+\))/g); 300 345 return tokens.map((token, index) => { 301 - 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>; 302 - if (/^\*\*[^*]+\*\*$/.test(token)) return <strong key={`${keyPrefix}-${index}`}>{token.slice(2, -2)}</strong>; 303 - if (/^\*[^*]+\*$/.test(token)) return <em key={`${keyPrefix}-${index}`}>{token.slice(1, -1)}</em>; 304 - if (/^__[^_]+__$/.test(token)) return <span key={`${keyPrefix}-${index}`} className="underline">{token.slice(2, -2)}</span>; 305 - if (/^~~[^~]+~~$/.test(token)) return <span key={`${keyPrefix}-${index}`} className="line-through">{token.slice(2, -2)}</span>; 306 - const markdownLink = token.match(/^\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)$/i); 307 - if (markdownLink) return <LinkAnchor href={markdownLink[2]} key={`${keyPrefix}-${index}`} onRequestOpenLink={onRequestOpenLink}>{markdownLink[1]}</LinkAnchor>; 308 - return <React.Fragment key={`${keyPrefix}-${index}`}>{renderLinksInText(token, `${keyPrefix}-plain-${index}`, onRequestOpenLink)}</React.Fragment>; 346 + if (/^`[^`]+`$/.test(token)) 347 + return <code key={`${keyPrefix}-${index}`} className="rounded bg-[#232428] px-1 py-0.5 text-xs text-[#f2f3f5]">{token.slice(1, -1)}</code>; 348 + if (/^\*\*[^*]+\*\*$/.test(token)) 349 + return <strong key={`${keyPrefix}-${index}`}>{token.slice(2, -2)}</strong>; 350 + if (/^\*[^*]+\*$/.test(token)) 351 + return <em key={`${keyPrefix}-${index}`}>{token.slice(1, -1)}</em>; 352 + if (/^__[^_]+__$/.test(token)) 353 + return <span key={`${keyPrefix}-${index}`} className="underline">{token.slice(2, -2)}</span>; 354 + if (/^~~[^~]+~~$/.test(token)) 355 + return <span key={`${keyPrefix}-${index}`} className="line-through">{token.slice(2, -2)}</span>; 356 + const mdLink = token.match(/^\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)$/i); 357 + if (mdLink) 358 + return <LinkAnchor href={mdLink[2]} key={`${keyPrefix}-${index}`} onRequestOpenLink={onRequestOpenLink}>{mdLink[1]}</LinkAnchor>; 359 + return ( 360 + <React.Fragment key={`${keyPrefix}-${index}`}> 361 + {renderLinksInText(token, `${keyPrefix}-plain-${index}`, onRequestOpenLink)} 362 + </React.Fragment> 363 + ); 309 364 }); 310 365 }; 311 366 312 - const EMOJI_SHORTCODES = { smile: '😄', grin: '😁', joy: '😂', rofl: '🤣', wink: '😉', heart: '❤️', thumbs_up: '👍', thumbs_down: '👎', fire: '🔥', sob: '😭', thinking: '🤔', tada: '🎉', eyes: '👀' }; 313 - const QUICK_EMOJIS = ['👍', '❤️', '😂', '🔥', '😭', '🎉', '😮', '👀', '🤔']; 314 - const EXTENDED_EMOJIS = [...QUICK_EMOJIS, '😄', '😁', '🤣', '😉', '🙏', '👏', '✨', '🎯', '💯', '🤝', '😅', '😎', '🥳', '✅', '❌', '🎵', '📌', '🚀', '🫡', '🤌']; 367 + // ─── Emoji data ─────────────────────────────────────────────────────────────── 368 + const EMOJI_SHORTCODES = { 369 + smile: '😄', grin: '😁', joy: '😂', rofl: '🤣', wink: '😉', 370 + heart: '❤️', thumbs_up: '👍', thumbs_down: '👎', fire: '🔥', 371 + sob: '😭', thinking: '🤔', tada: '🎉', eyes: '👀', 372 + }; 373 + 315 374 const STANDARD_EMOJIS = [ 316 - '😀','😃','😄','😁','😆','😅','🤣','😂','🙂','🙃','😉','😊','😇','🥰','😍','🤩','😘','😗','😚','😙','🥲','😋','😛','😜','🤪','😝','🤑','🤗','🤭','🫢','🫣','🤫','🤔','🫡','🤐','🤨','😐','😑','😶','🫥','😏','😒','🙄','😬','🤥','😌','😔','😪','🤤','😴','😷','🤒','🤕','🤢','🤮','🤧','🥵','🥶','🥴','😵','😵‍💫','🤯','🤠','🥳','🥸','😎','🤓','🧐','😕','😟','🙁','☹️','😮','😯','😲','😳','🥺','🥹','😦','😧','😨','😰','😥','😢','😭','😱','😖','😣','😞','😓','😩','😫','🥱','😤','😡','😠','🤬','😈','👿','💀','☠️','💩','🤡','👹','👺','👻','👽','👾','🤖','😺','😸','😹','😻','😼','😽','🙀','😿','😾', 317 - '👋','🤚','🖐','✋','🖖','👌','🤌','🤏','✌️','🤞','🫰','🤟','🤘','🤙','👈','👉','👆','🖕','👇','☝️','👍','👎','✊','👊','🤛','🤜','👏','🙌','🫶','👐','🤲','🤝','🙏','✍️','💅','🤳','💪','🦾','🦿','🦵','🦶','👂','🦻','👃','🧠','🫀','🫁','🦷','🦴','👀','👁','👅','👄','🫦','💋','👶','🧒','👦','👧','🧑','👱','👨','🧔','👩','🧓','👴','👵', 318 - '🐶','🐱','🐭','🐹','🐰','🦊','🐻','🐼','🐻‍❄️','🐨','🐯','🦁','🐮','🐷','🐽','🐸','🐵','🙈','🙉','🙊','🐒','🐔','🐧','🐦','🐤','🐣','🐥','🦆','🦅','🦉','🦇','🐺','🐗','🐴','🦄','🐝','🪱','🐛','🦋','🐌','🐞','🐜','🪰','🪲','🪳','🦟','🦗','🕷','🕸','🦂','🐢','🐍','🦎','🦖','🦕','🐙','🦑','🦐','🦞','🦀','🐡','🐠','🐟','🐬','🐳','🐋','🦈','🦭','🐊','🐅','🐆','🦓','🦍','🦧','🦣','🐘','🦛','🦏','🐪','🐫','🦒','🦘','🦬','🐃','🐂','🐄','🐎','🐖','🐏','🐑','🦙','🐐','🦌','🐕','🐩','🦮','🐕‍🦺','🐈','🐈‍⬛','🪶','🐓','🦃','🦤','🦚','🦜','🦢','🦩','🕊','🐇','🦝','🦨','🦡','🦫','🦦','🦥','🐁','🐀','🐿','🦔' 375 + '😀','😃','😄','😁','😆','😅','🤣','😂','🙂','🙃','😉','😊','😇','🥰','😍','🤩','😘','😗','😚','😙','🥲','😋','😛','😜','🤪','😝','🤑','🤗','🤭','🫢','🫣','🤫','🤔','🫡','🤐','🤨','😐','😑','😶','🫥','😏','😒','🙄','😬','🤥','😌','😔','😪','🤤','😴','😷','🤒','🤕','🤢','🤮','🤧','🥵','🥶','🥴','😵','🤯','🤠','🥳','🥸','😎','🤓','🧐','😕','😟','🙁','☹️','😮','😯','😲','😳','🥺','🥹','😦','😧','😨','😰','😥','😢','😭','😱','😖','😣','😞','😓','😩','😫','🥱','😤','😡','😠','🤬','😈','👿','💀','☠️','💩','🤡','👹','👺','👻','👽','👾','🤖', 376 + '👋','🤚','🖐','✋','🖖','👌','🤌','🤏','✌️','🤞','🫰','🤟','🤘','🤙','👈','👉','👆','👇','☝️','👍','👎','✊','👊','🤛','🤜','👏','🙌','🫶','👐','🤲','🤝','🙏','✍️','💅','💪','👀','👅','👄','🫦','💋', 377 + '🐶','🐱','🐭','🐹','🐰','🦊','🐻','🐼','🐨','🐯','🦁','🐮','🐷','🐸','🐵','🐔','🐧','🐦','🐤','🦆','🦅','🦉','🦇','🐺','🐴','🦄','🐝','🐛','🦋','🐌','🐞','🐜','🦟','🦗','🐢','🐍','🦎','🐙','🦑','🐡','🐠','🐟','🐬','🐳','🐋','🦈','🐊','🐘','🦍','🐇','🦔', 378 + '🌸','🌺','🌻','🌹','🍀','🌿','🍁','🍂','🌊','🌙','⭐','🌟','☀️','⛅','🌈','❄️','⚡','🔥','💧','🌍', 379 + '🍎','🍊','🍋','🍇','🍓','🍑','🍒','🥝','🍕','🍔','🍟','🌮','🍜','🍣','🍦','🎂','🍰','🍫','🍬','☕','🧃','🍺','🥂', 380 + '⚽','🏀','🎮','🎲','🎯','🏆','🥇','🎵','🎶','🎤','🎸','🎺','📱','💻','⌨️','🖥️','📷','🔑','💡','🔔','📢','📌','📎','✏️','📝','📚','💰','💎','🚀','✈️','🚗','🚢','🏠','🏰','🗼','🌉', 381 + '❤️','🧡','💛','💚','💙','💜','🖤','🤍','🤎','💔','❣️','💕','💞','💓','💗','💖','💘','💝','💟','☮️','✝️','☯️','🔴','🟠','🟡','🟢','🔵','🟣','⚫','⚪','🟤', 382 + '✅','❌','⭕','🔱','♻️','🆘','⛔','🚫','💯','🔞','🔝','🆕','🆙','🆒','🆓','🆖','🆗','📶','🔇','🔊','📣','🔔','🔕','📵','🚷','🚯','🚱','🚳','🔒','🔓','🔐', 319 383 ]; 320 - const MEMBER_RENDER_LIMIT = 250; 384 + 385 + const MEMBER_RENDER_LIMIT = 250; 321 386 const MEMBER_HYDRATE_CHUNK = 400; 322 387 388 + // ─── Reaction / emoji helpers ───────────────────────────────────────────────── 323 389 const toReactionEntries = (reactions) => { 324 390 if (!reactions || typeof reactions !== 'object') return []; 325 - return Object.entries(reactions).map(([emoji, userIds]) => ({ emoji, userIds: Array.isArray(userIds) ? userIds : [] })).filter(e => e.userIds.length > 0); 391 + return Object.entries(reactions) 392 + .map(([emoji, userIds]) => ({ emoji, userIds: Array.isArray(userIds) ? userIds : [] })) 393 + .filter((e) => e.userIds.length > 0); 326 394 }; 327 395 328 396 const getReplyIdFromMessage = (message) => { ··· 330 398 return typeof firstReply === 'string' ? firstReply : firstReply?.id || firstReply?._id || null; 331 399 }; 332 400 333 - const isCustomEmojiToken = (value) => /^:[A-Z0-9]{26}:$/i.test(value || ''); 334 - const getCustomEmojiId = (token) => token?.slice(1, -1); 401 + const isCustomEmojiToken = (value) => /^:[A-Z0-9]{26}:$/i.test(value || ''); 402 + const getCustomEmojiId = (token) => token?.slice(1, -1); 403 + 335 404 const resolveCustomEmojiMeta = (token, customEmojiById) => { 336 405 const effectiveToken = /^[A-Z0-9]{26}$/i.test(token) ? `:${token}:` : token; 337 406 if (!isCustomEmojiToken(effectiveToken)) return null; ··· 340 409 }; 341 410 342 411 const renderEmojiVisual = (token, customEmoji, cdnUrl) => { 343 - const rawId = /^[A-Z0-9]{26}$/i.test(token) ? token : (isCustomEmojiToken(token) ? getCustomEmojiId(token) : null); 412 + const rawId = /^[A-Z0-9]{26}$/i.test(token) ? token : (isCustomEmojiToken(token) ? getCustomEmojiId(token) : null); 344 413 const emojiId = customEmoji?.id || rawId; 345 - if (emojiId) return <img alt={customEmoji?.name || token} className="inline h-5 w-5 align-text-bottom object-contain" src={`${cdnUrl}/emojis/${emojiId}`} />; 346 - if (Object.values(EMOJI_SHORTCODES).includes(token) || STANDARD_EMOJIS.includes(token) || !token.startsWith(':')) return renderTwemoji(token); 414 + if (emojiId) 415 + return <img alt={customEmoji?.name || token} className="inline h-5 w-5 align-text-bottom object-contain" src={`${cdnUrl}/emojis/${emojiId}`} />; 416 + if (Object.values(EMOJI_SHORTCODES).includes(token) || STANDARD_EMOJIS.includes(token) || !token.startsWith(':')) 417 + return renderTwemoji(token); 347 418 return token; 348 419 }; 349 420 421 + // ─── Attachment helpers ─────────────────────────────────────────────────────── 350 422 const isImageLike = (filename = '', contentType = '') => { 351 - if (typeof contentType === 'string' && contentType.startsWith('image/')) return true; 352 - const ext = filename?.split('.').pop()?.toLowerCase(); 353 - return ['png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp', 'avif', 'svg'].includes(ext); 423 + if (typeof contentType === 'string' && contentType.startsWith('image/')) return true; 424 + const ext = filename?.split('.').pop()?.toLowerCase(); 425 + return ['png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp', 'avif', 'svg'].includes(ext); 354 426 }; 355 427 356 428 const getAttachmentData = (attachment, cdnUrl, index = 0) => { 357 429 const attachmentId = typeof attachment === 'string' ? attachment : attachment?._id || attachment?.id; 358 - const filename = attachment?.filename || attachment?.name || `attachment-${index + 1}`; 359 - const contentType = attachment?.content_type || attachment?.contentType || attachment?.metadata?.type || ''; 360 - const url = attachmentId ? `${cdnUrl}/attachments/${attachmentId}` : null; 361 - const image = isImageLike(filename, contentType); 430 + const filename = attachment?.filename || attachment?.name || `attachment-${index + 1}`; 431 + const contentType = attachment?.content_type || attachment?.contentType || attachment?.metadata?.type || ''; 432 + const url = attachmentId ? `${cdnUrl}/attachments/${attachmentId}` : null; 433 + const image = isImageLike(filename, contentType); 362 434 return { attachmentId, filename, url, image }; 363 435 }; 364 436 365 - const extractUrls = (value = '') => (value.match(/https?:\/\/[^\s]+/gi) || []); 366 - const isEmbeddableGifLink = (url) => url && (/^https?:\/\/media\.tenor\.com\//i.test(url) || /\.(gif)(\?.*)?$/i.test(url)); 367 - 437 + const extractUrls = (value = '') => value.match(/https?:\/\/[^\s]+/gi) || []; 368 438 const isEmbeddableImageLink = (url) => { 369 - if (!url) return false; 370 - const cleanUrl = url.split('?')[0].toLowerCase(); 371 - return /\.(png|jpe?g|webp|gif|bmp|avif|svg)$/i.test(cleanUrl); 439 + if (!url) return false; 440 + return /\.(png|jpe?g|webp|gif|bmp|avif|svg)(\?.*)?$/i.test(url); 372 441 }; 373 442 374 - 443 + // ─── Message content renderer ───────────────────────────────────────────────── 375 444 const renderMessageContent = (content, users, channels, onUserClick, customEmojiById, cdnUrl, onRequestOpenLink) => { 376 445 if (!content) return null; 377 - const parts = content.split(/(<@!?[A-Za-z0-9]+>|<#[A-Za-z0-9]+>|:[A-Z0-9]{26}:)/g); 378 - return parts.map((part, index) => { 379 - const userMention = part.match(/^<@!?([A-Za-z0-9]+)>$/); 380 - if (userMention) { 381 - const userId = userMention[1]; 382 - const user = users[userId]; 383 - 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: 'Loading user…', _loading: true }, userId)}>@{user?.username || 'loading-user'}</button>; 384 - } 385 - const channelMention = part.match(/^<#([A-Za-z0-9]+)>$/); 386 - 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>; 387 - 388 - // Check for Custom Emoji Token :ID: 389 - if (isCustomEmojiToken(part)) { 390 - const customEmoji = resolveCustomEmojiMeta(part, customEmojiById); 391 - return <span className="mx-0.5 inline-flex items-center" key={`${part}-${index}`} title={`${customEmoji?.name || part}`}>{renderEmojiVisual(part, customEmoji, cdnUrl)}</span>; 392 - } 393 - 394 - // Check for Shortcodes 395 - const shortcodeMatch = part.match(/^:([a-z0-9_+-]+):$/i); 396 - if (shortcodeMatch) { 397 - const mapped = EMOJI_SHORTCODES[shortcodeMatch[1].toLowerCase()]; 398 - if (mapped) return renderTwemoji(mapped); 399 - } 400 - 401 - const textElement = renderMarkdownInline(part, `md-${index}`, onRequestOpenLink); 402 - return typeof textElement === 'string' ? <React.Fragment key={`${part}-${index}`}>{renderTwemoji(textElement)}</React.Fragment> : <React.Fragment key={`${part}-${index}`}>{textElement}</React.Fragment>; 403 - }); 446 + return content 447 + .split(/(<@!?[A-Za-z0-9]+>|<#[A-Za-z0-9]+>|:[A-Z0-9]{26}:)/g) 448 + .map((part, index) => { 449 + const userMention = part.match(/^<@!?([A-Za-z0-9]+)>$/); 450 + if (userMention) { 451 + const userId = userMention[1]; 452 + const user = users[userId]; 453 + return ( 454 + <button 455 + className="mx-0.5 inline rounded bg-[#5865f2]/25 px-1 text-[#bdc3ff] hover:bg-[#5865f2]/35" 456 + key={`${part}-${index}`} 457 + onClick={() => onUserClick(user || { _id: userId, username: 'Loading user…', _loading: true }, userId)} 458 + > 459 + @{user?.username || 'loading-user'} 460 + </button> 461 + ); 462 + } 463 + const channelMention = part.match(/^<#([A-Za-z0-9]+)>$/); 464 + if (channelMention) 465 + 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>; 466 + if (isCustomEmojiToken(part)) { 467 + const ce = resolveCustomEmojiMeta(part, customEmojiById); 468 + return ( 469 + <span className="mx-0.5 inline-flex items-center" key={`${part}-${index}`} title={ce?.name || part}> 470 + {renderEmojiVisual(part, ce, cdnUrl)} 471 + </span> 472 + ); 473 + } 474 + const shortcodeMatch = part.match(/^:([a-z0-9_+-]+):$/i); 475 + if (shortcodeMatch) { 476 + const mapped = EMOJI_SHORTCODES[shortcodeMatch[1].toLowerCase()]; 477 + if (mapped) return renderTwemoji(mapped); 478 + } 479 + const textEl = renderMarkdownInline(part, `md-${index}`, onRequestOpenLink); 480 + return <React.Fragment key={`${part}-${index}`}>{textEl}</React.Fragment>; 481 + }); 404 482 }; 405 483 484 + // ─── State helpers ──────────────────────────────────────────────────────────── 406 485 const withoutClearedFields = (base, clear = []) => { 407 486 if (!clear?.length) return base; 408 487 const next = { ...base }; 409 - clear.forEach((field) => { const key = field?.[0]?.toLowerCase() + field?.slice(1); if (key) delete next[key]; }); 488 + clear.forEach((field) => { 489 + const key = field?.[0]?.toLowerCase() + field?.slice(1); 490 + if (key) delete next[key]; 491 + }); 410 492 return next; 411 493 }; 412 494 ··· 415 497 return list.filter((item) => { if (!item?._id || seen.has(item._id)) return false; seen.add(item._id); return true; }); 416 498 }; 417 499 418 - const sortRoles = (rolesMap) => !rolesMap ? [] : Object.entries(rolesMap).map(([id, role]) => ({ id, ...role })).sort((a, b) => (b.rank || 0) - (a.rank || 0)); 419 - const getMemberHighestRole = (member, sortedRoles) => { 420 - if (!member.roles?.length) return null; 421 - for (const role of sortedRoles) { if (member.roles.includes(role.id)) return role; } 422 - return null; 423 - }; 424 - const getMemberHoistedRole = (member, sortedRoles) => { 425 - if (!member.roles?.length) return null; 426 - for (const role of sortedRoles) { if (member.roles.includes(role.id) && role.hoist) return role; } 427 - return null; 428 - }; 500 + // ─── Member list helpers ────────────────────────────────────────────────────── 501 + const sortRoles = (rolesMap) => !rolesMap ? [] : Object.entries(rolesMap).map(([id, role]) => ({ id, ...role })).sort((a, b) => (b.rank || 0) - (a.rank || 0)); 502 + const getMemberHighestRole = (member, sortedRoles) => { if (!member.roles?.length) return null; for (const r of sortedRoles) if (member.roles.includes(r.id)) return r; return null; }; 503 + const getMemberHoistedRole = (member, sortedRoles) => { if (!member.roles?.length) return null; for (const r of sortedRoles) if (member.roles.includes(r.id) && r.hoist) return r; return null; }; 504 + 429 505 const organizeMembers = (members, rolesMap, users) => { 430 506 const sortedRoles = sortRoles(rolesMap); 431 507 const groups = {}; 432 - const onlineKey = 'Online'; 433 - const offlineKey = 'Offline'; 434 - sortedRoles.filter(r => r.hoist).forEach(role => { groups[role.id] = { id: role.id, name: role.name, color: role.colour, rank: role.rank, members: [] }; }); 435 - groups[onlineKey] = { id: onlineKey, name: 'Online', color: null, rank: -1, members: [] }; 436 - groups[offlineKey] = { id: offlineKey, name: 'Offline', color: null, rank: -2, members: [] }; 508 + sortedRoles.filter((r) => r.hoist).forEach((role) => { 509 + groups[role.id] = { id: role.id, name: role.name, color: role.colour, rank: role.rank, members: [] }; 510 + }); 511 + groups['Online'] = { id: 'Online', name: 'Online', color: null, rank: -1, members: [] }; 512 + groups['Offline'] = { id: 'Offline', name: 'Offline', color: null, rank: -2, members: [] }; 437 513 438 - members.forEach(member => { 514 + members.forEach((member) => { 439 515 const user = users[member._id.user]; 440 516 if (!user) return; 441 517 const hoistedRole = getMemberHoistedRole(member, sortedRoles); 442 518 const highestRole = getMemberHighestRole(member, sortedRoles); 443 - const isOffline = !user.status || user.status.presence === 'Invisible' || user.status.presence === 'Offline'; 444 - const enrichedMember = { ...member, user, color: highestRole?.colour || null }; 519 + const isOffline = !user.status || user.status.presence === 'Invisible' || user.status.presence === 'Offline'; 520 + const enriched = { ...member, user, color: highestRole?.colour || null }; 445 521 if (hoistedRole && !isOffline) { 446 - if (!groups[hoistedRole.id]) groups[hoistedRole.id] = { id: hoistedRole.id, name: hoistedRole.name, color: hoistedRole.colour, rank: hoistedRole.rank, members: [] }; 447 - groups[hoistedRole.id].members.push(enrichedMember); 522 + if (!groups[hoistedRole.id]) 523 + groups[hoistedRole.id] = { id: hoistedRole.id, name: hoistedRole.name, color: hoistedRole.colour, rank: hoistedRole.rank, members: [] }; 524 + groups[hoistedRole.id].members.push(enriched); 448 525 } else { 449 - groups[isOffline ? offlineKey : onlineKey].members.push(enrichedMember); 526 + groups[isOffline ? 'Offline' : 'Online'].members.push(enriched); 450 527 } 451 528 }); 452 529 453 530 const flatList = []; 454 - const sortedGroups = Object.values(groups).filter(g => g.members.length > 0).sort((a, b) => b.rank - a.rank); 455 - sortedGroups.forEach(group => { 456 - flatList.push({ type: 'header', key: `header-${group.id}`, name: group.name, count: group.members.length, color: group.color }); 457 - const sortedMembers = group.members.sort((a, b) => { 458 - const nameA = a.nickname || a.user?.username || ''; 459 - const nameB = b.nickname || b.user?.username || ''; 460 - return nameA.localeCompare(nameB); 531 + Object.values(groups) 532 + .filter((g) => g.members.length > 0) 533 + .sort((a, b) => b.rank - a.rank) 534 + .forEach((group) => { 535 + flatList.push({ type: 'header', key: `header-${group.id}`, name: group.name, count: group.members.length, color: group.color }); 536 + group.members 537 + .sort((a, b) => (a.nickname || a.user?.username || '').localeCompare(b.nickname || b.user?.username || '')) 538 + .forEach((m) => flatList.push({ type: 'member', key: m._id.user, data: m })); 461 539 }); 462 - sortedMembers.forEach(m => flatList.push({ type: 'member', key: m._id.user, data: m })); 463 - }); 464 540 return flatList; 465 541 }; 466 542 467 - // --- Message Component --- 468 - const Message = memo(({ 469 - message, users, channels, me, onUserClick, cdnUrl, 470 - onToggleReaction, onReply, replyTarget, onJumpToMessage, 543 + // ─── Connection status badge ────────────────────────────────────────────────── 544 + const StatusBadge = ({ status }) => { 545 + const map = { 546 + ready: { label: 'Connected', color: 'bg-[#3ba55d]' }, 547 + authenticated: { label: 'Authenticating',color: 'bg-[#f0b232]' }, 548 + connecting: { label: 'Connecting', color: 'bg-[#f0b232]' }, 549 + disconnected: { label: 'Disconnected', color: 'bg-[#ed4245]' }, 550 + error: { label: 'Error', color: 'bg-[#ed4245]' }, 551 + }; 552 + const key = status.startsWith('error:') ? 'error' : status; 553 + const entry = map[key] || { label: status, color: 'bg-gray-400' }; 554 + return ( 555 + <span className="flex items-center gap-1.5 text-[11px] text-gray-400"> 556 + <span className={`h-1.5 w-1.5 rounded-full ${entry.color} ${key === 'connecting' || key === 'authenticated' ? 'animate-pulse' : ''}`} /> 557 + {entry.label} 558 + </span> 559 + ); 560 + }; 561 + 562 + // ─── Message component ──────────────────────────────────────────────────────── 563 + const Message = memo(({ 564 + message, users, channels, me, onUserClick, cdnUrl, 565 + onToggleReaction, onReply, replyTarget, onJumpToMessage, 471 566 registerMessageRef, customEmojiById, reactionOptions, onRequestOpenLink, 472 - onEditMessage, onDeleteMessage, replyMessageMap 567 + onEditMessage, onDeleteMessage, replyMessageMap, 473 568 }) => { 474 - const authorId = typeof message.author === 'string' ? message.author : message.author?._id; 475 - const author = users[authorId] || (typeof message.author === 'object' ? message.author : null) || { _id: authorId, username: 'Loading user…', _loading: true }; 476 - const mine = me === authorId; 569 + const authorId = typeof message.author === 'string' ? message.author : message.author?._id; 570 + const author = users[authorId] || (typeof message.author === 'object' ? message.author : null) || { _id: authorId, username: 'Loading user…', _loading: true }; 571 + const mine = me === authorId; 477 572 const messageReactions = toReactionEntries(message.reactions); 478 - 479 - const replyId = getReplyIdFromMessage(message); 573 + const replyId = getReplyIdFromMessage(message); 480 574 const fullReplyMessage = replyId ? (replyMessageMap[replyId] || (message.replyMessage?._id === replyId ? message.replyMessage : null)) : null; 481 - const replyAuthorId = fullReplyMessage ? (typeof fullReplyMessage.author === 'string' ? fullReplyMessage.author : fullReplyMessage.author?._id) : message.replyMessage?.authorId; 482 - const replyUser = replyAuthorId ? users[replyAuthorId] : message.replyMessage?.authorUser; 483 - 575 + const replyAuthorId = fullReplyMessage ? (typeof fullReplyMessage.author === 'string' ? fullReplyMessage.author : fullReplyMessage.author?._id) : message.replyMessage?.authorId; 576 + const replyUser = replyAuthorId ? users[replyAuthorId] : message.replyMessage?.authorUser; 577 + 484 578 const [showReactionPicker, setShowReactionPicker] = useState(false); 485 - const [isMessageHovered, setIsMessageHovered] = useState(false); 486 - 487 - const embeddedLinks = useMemo(() => extractUrls(message.content || ''), [message.content]); 488 - const [resolvedImages, setResolvedImages] = useState([]); 489 - 490 - const [isEditing, setIsEditing] = useState(false); 491 - const [editContent, setEditContent] = useState(''); 492 - 493 - const pickerRef = useRef(null); 579 + const [isMessageHovered, setIsMessageHovered] = useState(false); 580 + const [isEditing, setIsEditing] = useState(false); 581 + const [editContent, setEditContent] = useState(''); 582 + const [emojiSearch, setEmojiSearch] = useState(''); 583 + 584 + const pickerRef = useRef(null); 585 + const editAreaRef = useRef(null); 494 586 587 + // Auto-resize edit textarea 495 588 useEffect(() => { 496 - if (!showReactionPicker) return; 497 - const handleClickOutside = (event) => { 498 - if (pickerRef.current && !pickerRef.current.contains(event.target)) setShowReactionPicker(false); 499 - }; 500 - document.addEventListener('mousedown', handleClickOutside); 501 - return () => document.removeEventListener('mousedown', handleClickOutside); 589 + const el = editAreaRef.current; 590 + if (!el || !isEditing) return; 591 + el.style.height = 'auto'; 592 + el.style.height = Math.min(el.scrollHeight, 200) + 'px'; 593 + }, [editContent, isEditing]); 594 + 595 + useEffect(() => { 596 + if (!showReactionPicker) { setEmojiSearch(''); return; } 597 + const handler = (e) => { if (pickerRef.current && !pickerRef.current.contains(e.target)) setShowReactionPicker(false); }; 598 + document.addEventListener('mousedown', handler); 599 + return () => document.removeEventListener('mousedown', handler); 502 600 }, [showReactionPicker]); 503 601 602 + const embeddedLinks = useMemo(() => extractUrls(message.content || ''), [message.content]); 603 + const [resolvedImages, setResolvedImages] = useState([]); 604 + 504 605 useEffect(() => { 505 606 let cancelled = false; 506 - const resolve = async () => { 507 - const results = await Promise.all(embeddedLinks.map(async (url) => { 508 - if (/^https?:\/\/media\.tenor\.com\//i.test(url)) { 509 - try { 510 - const response = await fetch(`https://tenor.com/oembed?url=${encodeURIComponent(url)}`); 511 - if (!response.ok) return { url, type: 'image', src: url }; 512 - const data = await response.json(); 513 - return { url, type: 'image', src: data?.url || data?.thumbnail_url || url }; 514 - } catch { return { url, type: 'image', src: url }; } 515 - } 516 - if (isEmbeddableImageLink(url)) { 517 - return { url, type: 'image', src: url }; 518 - } 519 - return null; 520 - })); 521 - const validResults = results.filter(Boolean); 522 - if (!cancelled) setResolvedImages(validResults); 523 - }; 524 - if(embeddedLinks.length > 0) resolve(); else setResolvedImages([]); 607 + if (!embeddedLinks.length) { setResolvedImages([]); return; } 608 + Promise.all( 609 + embeddedLinks.map(async (url) => { 610 + if (/^https?:\/\/media\.tenor\.com\//i.test(url)) { 611 + try { 612 + const res = await fetch(`https://tenor.com/oembed?url=${encodeURIComponent(url)}`); 613 + if (!res.ok) return { url, src: url }; 614 + const data = await res.json(); 615 + return { url, src: data?.url || data?.thumbnail_url || url }; 616 + } catch { return { url, src: url }; } 617 + } 618 + if (isEmbeddableImageLink(url)) return { url, src: url }; 619 + return null; 620 + }), 621 + ).then((results) => { if (!cancelled) setResolvedImages(results.filter(Boolean)); }); 525 622 return () => { cancelled = true; }; 526 623 }, [embeddedLinks]); 527 624 528 - const contentWithoutLinks = useMemo(() => { 529 - let next = message.content || ''; 530 - return next.trim(); 531 - }, [message.content]); 532 - 533 625 const startEditing = () => { setEditContent(message.content || ''); setIsEditing(true); setShowReactionPicker(false); }; 534 - const saveEdit = () => { if (editContent.trim() !== message.content) onEditMessage(message._id, editContent); setIsEditing(false); }; 535 - const cancelEdit = () => { setIsEditing(false); setEditContent(''); }; 626 + const saveEdit = () => { if (editContent.trim() !== message.content) onEditMessage(message._id, editContent); setIsEditing(false); }; 627 + const cancelEdit = () => { setIsEditing(false); setEditContent(''); }; 628 + 629 + const filteredReactionEmojis = useMemo(() => { 630 + if (!emojiSearch) return null; 631 + const q = emojiSearch.toLowerCase(); 632 + return reactionOptions.filter((o) => o.custom && o.label.toLowerCase().includes(q)); 633 + }, [emojiSearch, reactionOptions]); 536 634 537 - const timestampDisplay = message.createdAt 538 - ? formatSmartTime(message.createdAt) 539 - : formatSmartTime(message._id); 635 + const timestampDisplay = message.createdAt ? formatSmartTime(message.createdAt) : formatSmartTime(message._id); 540 636 541 637 return ( 542 - <article 543 - className={`group relative flex gap-3 px-4 py-0.5 hover:bg-[#2e3035] ${replyTarget === message._id ? 'bg-[#3a3f66]/20' : ''}`} 544 - onMouseEnter={() => setIsMessageHovered(true)} 545 - onMouseLeave={() => setIsMessageHovered(false)} 638 + <article 639 + className={`group relative flex gap-3 px-4 py-0.5 transition-colors duration-75 hover:bg-[#2e3035] ${replyTarget === message._id ? 'bg-[#3a3f66]/20' : ''}`} 640 + onMouseEnter={() => setIsMessageHovered(true)} 641 + onMouseLeave={() => setIsMessageHovered(false)} 546 642 ref={(node) => registerMessageRef(message._id, node)} 547 643 > 548 - <button className="mt-0.5" onClick={() => onUserClick(author, authorId)} type="button"> 644 + <button className="mt-0.5 shrink-0" onClick={() => onUserClick(author, authorId)} type="button"> 549 645 <Avatar alwaysAnimate={isMessageHovered} cdnUrl={cdnUrl} user={author} /> 550 646 </button> 551 647 <div className="min-w-0 flex-1 py-1"> 552 648 {replyId ? ( 553 649 <button 554 - className="mb-1 flex max-w-full items-center gap-1 text-xs text-gray-400 hover:text-gray-200" 650 + className="mb-1 flex max-w-full items-center gap-1 text-xs text-gray-400 hover:text-gray-200 transition-colors" 555 651 onClick={() => onJumpToMessage(replyId)} 556 652 type="button" 557 653 > 558 654 <Reply size={12} /> 559 - <span className="truncate">Replying to {replyUser?.username || 'Loading user…'}: {fullReplyMessage?.content || message.replyMessage?.content || 'Attachment / embed'}</span> 655 + <span className="truncate"> 656 + {replyUser?.username || 'Loading user…'}: {fullReplyMessage?.content || message.replyMessage?.content || 'Attachment / embed'} 657 + </span> 560 658 </button> 561 659 ) : null} 562 - 563 - <div className="flex items-baseline justify-between"> 564 - <div className="flex items-baseline gap-2"> 565 - <button className="text-sm font-semibold text-white hover:underline" onClick={() => onUserClick(author, authorId)} type="button" style={{ color: message.author?.color }}>{author.username}</button> 566 - {mine && <span className="rounded bg-[#5865f2]/20 px-1.5 py-0.5 text-[10px] font-semibold text-[#bdc3ff]">YOU</span>} 567 - <time className="text-[11px] text-gray-500">{timestampDisplay}</time> 568 - {message.edited && <span className="text-[10px] text-gray-500">(edited)</span>} 569 - </div> 660 + 661 + <div className="flex items-baseline gap-2"> 662 + <button 663 + className="text-sm font-semibold text-white hover:underline" 664 + onClick={() => onUserClick(author, authorId)} 665 + style={{ color: message.author?.color }} 666 + type="button" 667 + > 668 + {author.username} 669 + </button> 670 + {mine && <span className="rounded bg-[#5865f2]/20 px-1.5 py-0.5 text-[10px] font-semibold text-[#bdc3ff]">YOU</span>} 671 + <time className="text-[11px] text-gray-500">{timestampDisplay}</time> 672 + {message.edited && <span className="text-[10px] text-gray-500">(edited)</span>} 570 673 </div> 571 674 572 675 {isEditing ? ( 573 - <div className="mt-1 bg-[#383a40] p-2 rounded"> 574 - <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(); }} /> 575 - <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> 676 + <div className="mt-1 rounded bg-[#383a40] p-2"> 677 + <textarea 678 + ref={editAreaRef} 679 + className="composer-textarea w-full bg-transparent text-gray-200 text-sm focus:outline-none mb-2 resize-none" 680 + value={editContent} 681 + rows={1} 682 + autoFocus 683 + onChange={(e) => setEditContent(e.target.value)} 684 + onKeyDown={(e) => { 685 + if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); saveEdit(); } 686 + if (e.key === 'Escape') cancelEdit(); 687 + }} 688 + /> 689 + <div className="text-xs text-gray-400 flex gap-2"> 690 + <span className="flex-1">esc to cancel · enter to save</span> 691 + <button onClick={cancelEdit} className="hover:underline text-[#ed4245]">cancel</button> 692 + <button onClick={saveEdit} className="hover:underline text-[#5865f2]">save</button> 693 + </div> 576 694 </div> 577 695 ) : ( 578 - <> 579 - {contentWithoutLinks ? <p className="whitespace-pre-wrap break-words text-sm text-gray-200">{renderMessageContent(contentWithoutLinks, users, channels, onUserClick, customEmojiById, cdnUrl, onRequestOpenLink)}</p> : null} 580 - </> 696 + message.content 697 + ? <p className="whitespace-pre-wrap break-words text-sm text-gray-200"> 698 + {renderMessageContent(message.content, users, channels, onUserClick, customEmojiById, cdnUrl, onRequestOpenLink)} 699 + </p> 700 + : null 581 701 )} 582 702 583 703 {resolvedImages.length > 0 && ( 584 704 <div className="mt-1.5 flex flex-wrap gap-2"> 585 705 {resolvedImages.map((img, idx) => ( 586 - <button className="block overflow-hidden rounded-lg border border-[#2f3237]" key={`${img.url}-${idx}`} onClick={() => onRequestOpenLink(img.url)} title={img.url} type="button"> 706 + <button 707 + className="block overflow-hidden rounded-lg border border-[#2f3237] hover:border-[#4a4d55] transition-colors" 708 + key={`${img.url}-${idx}`} 709 + onClick={() => onRequestOpenLink(img.url)} 710 + title={img.url} 711 + type="button" 712 + > 587 713 <img alt="Embed" className="max-h-64 max-w-full object-contain" src={img.src} loading="lazy" /> 588 714 </button> 589 715 ))} 590 716 </div> 591 717 )} 592 - 718 + 593 719 {Array.isArray(message.attachments) && message.attachments.length > 0 && ( 594 720 <div className="mt-1.5 flex flex-wrap gap-1.5"> 595 721 {message.attachments.map((attachment, index) => { 596 722 const { attachmentId, filename, url, image } = getAttachmentData(attachment, cdnUrl, index); 597 723 if (!attachmentId || !url) return null; 598 - 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-64 max-w-full object-contain" src={url} /></a>; 724 + if (image) 725 + return <a className="block overflow-hidden rounded border border-[#3a3d42] hover:border-[#5c606a] transition-colors" href={url} key={attachmentId} rel="noreferrer" target="_blank" title={filename}><img alt={filename} className="max-h-64 max-w-full object-contain" src={url} /></a>; 599 726 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>; 600 727 })} 601 728 </div> 602 729 )} 603 - 730 + 604 731 <div className="mt-1 flex flex-wrap items-center gap-1.5"> 605 732 {messageReactions.map(({ emoji, userIds }) => { 606 - const reacted = userIds.includes(me); 733 + const reacted = userIds.includes(me); 607 734 const customEmoji = resolveCustomEmojiMeta(emoji, customEmojiById); 608 735 return ( 609 - <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"> 736 + <button 737 + className={`reaction-btn 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]'}`} 738 + key={emoji} 739 + onClick={() => onToggleReaction(message, emoji, reacted)} 740 + title={customEmoji?.name || emoji} 741 + type="button" 742 + > 610 743 {renderEmojiVisual(emoji, customEmoji, cdnUrl)} {userIds.length} 611 744 </button> 612 745 ); ··· 614 747 </div> 615 748 </div> 616 749 617 - <div className={`absolute right-4 -top-2 z-10 ${isMessageHovered || showReactionPicker ? 'opacity-100' : 'opacity-0'} transition-opacity duration-100`}> 618 - <div className="flex items-center bg-[#313338] border border-[#2f3237] rounded shadow-sm"> 619 - <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> 620 - <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> 621 - {mine && ( 622 - <> 623 - <button className="p-1.5 text-gray-400 hover:text-gray-200 hover:bg-[#404249]" onClick={startEditing} title="Edit"><Edit2 size={14} /></button> 624 - <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> 625 - </> 626 - )} 627 - </div> 750 + {/* Message action bar */} 751 + <div className={`absolute right-4 -top-3 z-10 transition-opacity duration-100 ${isMessageHovered || showReactionPicker ? 'opacity-100' : 'opacity-0 pointer-events-none'}`}> 752 + <div className="flex items-center bg-[#313338] border border-[#2f3237] rounded-md shadow-md"> 753 + <button className="p-1.5 text-gray-400 hover:text-gray-200 hover:bg-[#404249] rounded-l-md" onClick={() => onReply(message)} title="Reply"><Reply size={14} /></button> 754 + <button className="p-1.5 text-gray-400 hover:text-gray-200 hover:bg-[#404249]" onClick={() => setShowReactionPicker((p) => !p)} title="Add Reaction"><Smile size={14} /></button> 755 + {mine && ( 756 + <> 757 + <button className="p-1.5 text-gray-400 hover:text-gray-200 hover:bg-[#404249]" onClick={startEditing} title="Edit"><Edit2 size={14} /></button> 758 + <button className="p-1.5 text-red-400 hover:text-red-200 hover:bg-[#404249] rounded-r-md" onClick={() => onDeleteMessage(message._id)} title="Delete"><Trash2 size={14} /></button> 759 + </> 760 + )} 761 + </div> 628 762 </div> 629 763 764 + {/* Reaction picker */} 630 765 {showReactionPicker && ( 631 - <div ref={pickerRef} onMouseLeave={() => setShowReactionPicker(false)} className="absolute right-0 bottom-8 z-30 w-72 max-h-64 rounded-lg border border-[#4c4f56] bg-[#232428] p-2 shadow-2xl overflow-y-auto"> 632 - <p className="mb-2 px-1 text-[10px] uppercase tracking-wide text-gray-500 font-bold sticky top-0 bg-[#232428] z-10">Standard</p> 633 - <div className="grid grid-cols-8 gap-1 mb-2"> 634 - {STANDARD_EMOJIS.map((emoji) => ( 635 - <button 636 - className="grid h-8 place-items-center rounded hover:bg-[#3a3d42]" 637 - key={emoji} 638 - onClick={() => { 639 - onToggleReaction(message, emoji, message.reactions?.[emoji]?.includes(me)); 640 - setShowReactionPicker(false); 641 - }} 642 - type="button" 643 - > 644 - {renderTwemoji(emoji, "w-5 h-5")} 645 - </button> 646 - ))} 766 + <div 767 + ref={pickerRef} 768 + className="absolute right-0 bottom-8 z-30 w-72 max-h-72 rounded-lg border border-[#4c4f56] bg-[#232428] shadow-2xl overflow-hidden flex flex-col" 769 + onMouseLeave={() => setShowReactionPicker(false)} 770 + > 771 + <div className="p-2 border-b border-[#2f3237]"> 772 + <input 773 + className="w-full rounded bg-[#1e1f22] px-2 py-1 text-xs text-gray-200 placeholder:text-gray-500 focus:outline-none focus:ring-1 focus:ring-[#5865f2]" 774 + placeholder="Search custom emojis…" 775 + value={emojiSearch} 776 + onChange={(e) => setEmojiSearch(e.target.value)} 777 + /> 647 778 </div> 648 - {reactionOptions.some(o => o.custom) && ( 779 + <div className="overflow-y-auto p-2"> 780 + {emojiSearch ? ( 781 + <> 782 + {(filteredReactionEmojis?.length ?? 0) > 0 ? ( 783 + <> 784 + <p className="mb-1 px-1 text-[10px] uppercase tracking-wide text-gray-500 font-bold">Custom</p> 785 + <div className="grid grid-cols-8 gap-1"> 786 + {filteredReactionEmojis.map((option) => ( 787 + <button 788 + className="grid h-8 place-items-center rounded hover:bg-[#3a3d42]" 789 + key={option.value} 790 + onClick={() => { onToggleReaction(message, option.value, message.reactions?.[option.value]?.includes(me)); setShowReactionPicker(false); }} 791 + title={option.title} 792 + type="button" 793 + > 794 + {renderEmojiVisual(option.value, option.custom, cdnUrl)} 795 + </button> 796 + ))} 797 + </div> 798 + </> 799 + ) : ( 800 + <p className="text-xs text-gray-500 text-center py-4">No custom emojis match "{emojiSearch}"</p> 801 + )} 802 + </> 803 + ) : ( 649 804 <> 650 - <p className="mb-2 px-1 text-[10px] uppercase tracking-wide text-gray-500 font-bold sticky top-0 bg-[#232428] z-10">Custom</p> 651 - <div className="grid grid-cols-8 gap-1"> 652 - {reactionOptions.filter(o => o.custom).map((option) => ( 805 + <p className="mb-1 px-1 text-[10px] uppercase tracking-wide text-gray-500 font-bold sticky top-0 bg-[#232428] z-10">Standard</p> 806 + <div className="grid grid-cols-8 gap-1 mb-2"> 807 + {STANDARD_EMOJIS.map((emoji) => ( 653 808 <button 654 809 className="grid h-8 place-items-center rounded hover:bg-[#3a3d42]" 655 - key={option.value} 656 - onClick={() => { 657 - onToggleReaction(message, option.value, message.reactions?.[option.value]?.includes(me)); 658 - setShowReactionPicker(false); 659 - }} 660 - title={option.title} 810 + key={emoji} 811 + onClick={() => { onToggleReaction(message, emoji, message.reactions?.[emoji]?.includes(me)); setShowReactionPicker(false); }} 661 812 type="button" 662 813 > 663 - {renderEmojiVisual(option.value, option.custom, cdnUrl)} 814 + {renderTwemoji(emoji, 'w-5 h-5')} 664 815 </button> 665 816 ))} 666 817 </div> 818 + {reactionOptions.some((o) => o.custom) && ( 819 + <> 820 + <p className="mb-1 px-1 text-[10px] uppercase tracking-wide text-gray-500 font-bold sticky top-0 bg-[#232428] z-10">Custom</p> 821 + <div className="grid grid-cols-8 gap-1"> 822 + {reactionOptions.filter((o) => o.custom).map((option) => ( 823 + <button 824 + className="grid h-8 place-items-center rounded hover:bg-[#3a3d42]" 825 + key={option.value} 826 + onClick={() => { onToggleReaction(message, option.value, message.reactions?.[option.value]?.includes(me)); setShowReactionPicker(false); }} 827 + title={option.title} 828 + type="button" 829 + > 830 + {renderEmojiVisual(option.value, option.custom, cdnUrl)} 831 + </button> 832 + ))} 833 + </div> 834 + </> 835 + )} 667 836 </> 668 - )} 837 + )} 838 + </div> 669 839 </div> 670 840 )} 671 841 </article> 672 842 ); 673 843 }); 674 844 675 - // --- AppShell --- 845 + // ─── AppShell ───────────────────────────────────────────────────────────────── 676 846 function AppShell() { 677 - const [view, setView] = useState('loading'); 678 - const [status, setStatus] = useState('disconnected'); 679 - const [config, setConfig] = useState({ apiUrl: DEFAULT_API_URL, wsUrl: DEFAULT_WS_URL, cdnUrl: DEFAULT_CDN_URL }); 680 - const [auth, setAuth] = useState({ token: null, userId: null }); 847 + const [view, setView] = useState('loading'); 848 + const [status, setStatus] = useState('disconnected'); 849 + const [config, setConfig] = useState({ apiUrl: DEFAULT_API_URL, wsUrl: DEFAULT_WS_URL, cdnUrl: DEFAULT_CDN_URL }); 850 + const [auth, setAuth] = useState({ token: null, userId: null }); 681 851 const [peekUser, setPeekUser] = useState(null); 682 - 683 - // Define openUserProfile callback first since it is used in other helpers 852 + 684 853 const openUserProfile = useCallback((user, fallbackId = null) => { 685 854 const stableId = user?._id || fallbackId || 'unknown-user'; 686 855 setPeekUser({ ...user, _id: stableId, username: user?.username || 'Loading user…', discriminator: user?.discriminator || '0000' }); 687 856 }, []); 688 857 689 - const [servers, setServers] = useState({}); 690 - const [channels, setChannels] = useState({}); 691 - const [users, setUsers] = useState({}); 692 - const [members, setMembers] = useState({}); 693 - const [messages, setMessages] = useState({}); 694 - const [selectedServerId, setSelectedServerId] = useState('@me'); 858 + const [servers, setServers] = useState({}); 859 + const [channels, setChannels] = useState({}); 860 + const [users, setUsers] = useState({}); 861 + const [members, setMembers] = useState({}); 862 + const [messages, setMessages] = useState({}); 863 + const [selectedServerId, setSelectedServerId] = useState('@me'); 695 864 const [selectedChannelId, setSelectedChannelId] = useState('friends'); 696 - const [inputText, setInputText] = useState(''); 697 - const [showAdvanced, setShowAdvanced] = useState(false); 698 - const [activeModal, setActiveModal] = useState(null); 699 - const [createServerName, setCreateServerName] = useState(''); 700 - const [isMembersLoading, setIsMembersLoading] = useState(false); 701 - const [replyingTo, setReplyingTo] = useState(null); 702 - const [showEmojiPicker, setShowEmojiPicker] = useState(false); 703 - const [pendingFiles, setPendingFiles] = useState([]); 704 - const [isUploadingFiles, setIsUploadingFiles] = useState(false); 705 - const [voiceNotice, setVoiceNotice] = useState(''); 706 - const [showGoLatest, setShowGoLatest] = useState(false); 707 - const [linkPromptUrl, setLinkPromptUrl] = useState(null); 708 - const [statusDraft, setStatusDraft] = useState({ presence: 'Online', text: '' }); 709 - const [isSavingStatus, setIsSavingStatus] = useState(false); 710 - const [isAccountHovered, setIsAccountHovered] = useState(false); 711 - const [privacyAcknowledged, setPrivacyAcknowledged] = useState(false); 712 - const [editServerName, setEditServerName] = useState(''); 713 - const [isUpdatingServer, setIsUpdatingServer] = useState(false); 714 - const [loginMode, setLoginMode] = useState('credentials'); 715 - const [email, setEmail] = useState(''); 716 - const [password, setPassword] = useState(''); 717 - const [mfaCode, setMfaCode] = useState(''); 718 - const [manualToken, setManualToken] = useState(''); 719 - const [loginError, setLoginError] = useState(''); 720 - const [isLoggingIn, setIsLoggingIn] = useState(false); 721 - const [privacyConsent, setPrivacyConsent] = useState(false); 865 + const [inputText, setInputText] = useState(''); 866 + const [showAdvanced, setShowAdvanced] = useState(false); 867 + const [activeModal, setActiveModal] = useState(null); 868 + const [createServerName, setCreateServerName] = useState(''); 869 + const [isMembersLoading, setIsMembersLoading] = useState(false); 870 + const [replyingTo, setReplyingTo] = useState(null); 871 + const [showEmojiPicker, setShowEmojiPicker] = useState(false); 872 + const [emojiSearch, setEmojiSearch] = useState(''); 873 + const [pendingFiles, setPendingFiles] = useState([]); 874 + const [isUploadingFiles, setIsUploadingFiles] = useState(false); 875 + const [voiceNotice, setVoiceNotice] = useState(''); 876 + const [showGoLatest, setShowGoLatest] = useState(false); 877 + const [isLoadingOlder, setIsLoadingOlder] = useState(false); 878 + const [linkPromptUrl, setLinkPromptUrl] = useState(null); 879 + const [statusDraft, setStatusDraft] = useState({ presence: 'Online', text: '' }); 880 + const [isSavingStatus, setIsSavingStatus] = useState(false); 881 + const [isAccountHovered, setIsAccountHovered] = useState(false); 882 + const [editServerName, setEditServerName] = useState(''); 883 + const [isUpdatingServer, setIsUpdatingServer] = useState(false); 884 + const [loginMode, setLoginMode] = useState('credentials'); 885 + const [email, setEmail] = useState(''); 886 + const [password, setPassword] = useState(''); 887 + const [mfaCode, setMfaCode] = useState(''); 888 + const [manualToken, setManualToken] = useState(''); 889 + const [loginError, setLoginError] = useState(''); 890 + const [isLoggingIn, setIsLoggingIn] = useState(false); 891 + const [privacyConsent, setPrivacyConsent] = useState(false); 722 892 const [wsReconnectAttempt, setWsReconnectAttempt] = useState(0); 893 + const [showMobileSidebar, setShowMobileSidebar] = useState(false); 723 894 724 - const wsRef = useRef(null); 725 - const messagesBottomRef = useRef(null); 726 - const messagesContainerRef = useRef(null); 727 - const subscriptionRef = useRef({}); 728 - const preloadedChannelRef = useRef({}); 729 - const preloadedMembersRef = useRef({}); 730 - const pendingUserFetchRef = useRef(new Set()); 731 - const membersLoadIdRef = useRef(0); 732 - const canFetchMissingUsersRef = useRef(true); 895 + // ── Refs ────────────────────────────────────────────────────────────────── 896 + const wsRef = useRef(null); 897 + const messagesBottomRef = useRef(null); 898 + const messagesContainerRef = useRef(null); 899 + const subscriptionRef = useRef({}); 900 + const preloadedChannelRef = useRef({}); 901 + const preloadedMembersRef = useRef({}); 902 + const pendingUserFetchRef = useRef(new Set()); 903 + const membersLoadIdRef = useRef(0); 904 + const canFetchMissingUsersRef = useRef(true); 733 905 const loggedMissingUserFetchErrorRef = useRef(false); 734 - const wsReconnectTimeoutRef = useRef(null); 735 - const messageRefs = useRef({}); 736 - const replyMessageCacheRef = useRef({}); 737 - const fileInputRef = useRef(null); 738 - const autoFollowRef = useRef(true); 739 - const isProgrammaticScrollRef = useRef(false); 740 - 741 - const pickerRef = useRef(null); 906 + const wsReconnectTimeoutRef = useRef(null); 907 + const messageRefs = useRef({}); 908 + const replyMessageCacheRef = useRef({}); 909 + const fileInputRef = useRef(null); 910 + const autoFollowRef = useRef(true); 911 + const isProgrammaticScrollRef = useRef(false); 912 + const pickerRef = useRef(null); 913 + const composerRef = useRef(null); 914 + const applyEventRef = useRef(null); // ← stale-closure fix 915 + const isLoadingOlderRef = useRef(false); 916 + // Kept-fresh refs for scroll handler 917 + const currentMessagesRef = useRef([]); 918 + const fetchMessagesRef = useRef(null); 919 + const selectedChannelIdRef = useRef(selectedChannelId); 742 920 921 + // Auto-resize composer textarea 743 922 useEffect(() => { 744 - if (!showEmojiPicker) return; 745 - const handleClickOutside = (event) => { 746 - if (pickerRef.current && !pickerRef.current.contains(event.target)) setShowEmojiPicker(false); 747 - }; 748 - document.addEventListener('mousedown', handleClickOutside); 749 - return () => document.removeEventListener('mousedown', handleClickOutside); 923 + const el = composerRef.current; 924 + if (!el) return; 925 + el.style.height = 'auto'; 926 + el.style.height = Math.min(el.scrollHeight, 200) + 'px'; 927 + }, [inputText]); 928 + 929 + // Emoji picker outside-click 930 + useEffect(() => { 931 + if (!showEmojiPicker) { setEmojiSearch(''); return; } 932 + const handler = (e) => { if (pickerRef.current && !pickerRef.current.contains(e.target)) setShowEmojiPicker(false); }; 933 + document.addEventListener('mousedown', handler); 934 + return () => document.removeEventListener('mousedown', handler); 750 935 }, [showEmojiPicker]); 751 936 752 - // --- Helper Functions in Scope --- 753 - const isSameOriginApi = useMemo(() => { try { return new URL(config.apiUrl).origin === window.location.origin; } catch { return false; } }, [config.apiUrl]); 937 + // ── Helpers ─────────────────────────────────────────────────────────────── 938 + const isSameOriginApi = useMemo(() => { 939 + try { return new URL(config.apiUrl).origin === window.location.origin; } catch { return false; } 940 + }, [config.apiUrl]); 754 941 755 942 const upsertUsers = useCallback((list = []) => { 756 - if (!list.length) return; 757 - setUsers((prev) => { 758 - const next = { ...prev }; 759 - list.forEach((user) => { if (user?._id) next[user._id] = user; }); 760 - return next; 761 - }); 943 + if (!list.length) return; 944 + setUsers((prev) => { const next = { ...prev }; list.forEach((u) => { if (u?._id) next[u._id] = u; }); return next; }); 762 945 }, []); 763 946 764 - // --- Render Item Helper --- 765 - // Defined here so it can access config.cdnUrl and openUserProfile from closure 766 947 const renderMemberItem = useCallback((item) => { 767 - 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>; 768 - const member = item.data; 769 - const memberUser = member.user || { _id: member?._id?.user, username: 'Loading user…', _loading: true }; 770 - const isUserLoading = !member.user; 948 + if (item.type === 'header') 949 + return <div className="pt-4 pb-1 px-4 text-xs font-bold uppercase tracking-wide text-gray-400">{item.name} — {item.count}</div>; 950 + const member = item.data; 951 + const mUser = member.user || { _id: member?._id?.user, username: 'Loading user…', _loading: true }; 952 + const isLoading = !member.user; 771 953 return ( 772 - <button className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left hover:bg-[#35373c]" onClick={() => openUserProfile(memberUser, member._id.user)}> 773 - <Avatar animateOnHover cdnUrl={config.cdnUrl} size="sm" user={memberUser} /> 954 + <button className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left hover:bg-[#35373c]" onClick={() => openUserProfile(mUser, member._id.user)}> 955 + <Avatar animateOnHover cdnUrl={config.cdnUrl} size="sm" user={mUser} /> 774 956 <div className="min-w-0 flex-1"> 775 - <div className="truncate text-sm font-medium" style={{ color: member.color || '#f2f3f5' }}>{member.nickname || memberUser.username}</div> 776 - {isUserLoading ? ( 777 - <div className="mt-1 h-1.5 w-24 overflow-hidden rounded-full bg-[#242A35]"><div className="h-full w-1/2 animate-pulse rounded-full bg-[#8AB4F8]" /></div> 778 - ) : ( 779 - <div className="flex items-center gap-1 text-[11px] text-gray-500"><User size={11} /> {memberUser.status?.presence || 'Offline'}</div> 780 - )} 957 + <div className="truncate text-sm font-medium" style={{ color: member.color || '#f2f3f5' }}>{member.nickname || mUser.username}</div> 958 + {isLoading 959 + ? <div className="mt-1 h-1.5 w-24 overflow-hidden rounded-full bg-[#242A35]"><div className="h-full w-1/2 animate-pulse rounded-full bg-[#8AB4F8]" /></div> 960 + : <div className="flex items-center gap-1 text-[11px] text-gray-500"><User size={11} /> {mUser.status?.presence || 'Offline'}</div> 961 + } 781 962 </div> 782 963 </button> 783 964 ); 784 965 }, [config.cdnUrl, openUserProfile]); 785 966 786 967 const upsertUsersFromMessages = useCallback((messageList = []) => { 787 - const embeddedUsers = []; 788 - messageList.forEach((entry) => { 789 - if (entry?.author && typeof entry.author === 'object' && entry.author._id) embeddedUsers.push(entry.author); 790 - if (entry?.user?._id) embeddedUsers.push(entry.user); 791 - }); 792 - upsertUsers(embeddedUsers); 968 + const embedded = []; 969 + messageList.forEach((entry) => { 970 + if (entry?.author && typeof entry.author === 'object' && entry.author._id) embedded.push(entry.author); 971 + if (entry?.user?._id) embedded.push(entry.user); 972 + }); 973 + upsertUsers(embedded); 793 974 }, [upsertUsers]); 794 975 795 976 const fetchMembers = useCallback(async (serverId) => { ··· 803 984 const data = await res.json(); 804 985 upsertUsers(data.users || []); 805 986 const payloadMembers = data.members || []; 806 - for (let index = 0; index < payloadMembers.length; index += MEMBER_HYDRATE_CHUNK) { 987 + for (let i = 0; i < payloadMembers.length; i += MEMBER_HYDRATE_CHUNK) { 807 988 if (membersLoadIdRef.current !== loadId) break; 808 - const chunk = payloadMembers.slice(index, index + MEMBER_HYDRATE_CHUNK); 809 - setMembers((prev) => { 810 - const next = { ...prev }; 811 - chunk.forEach((member) => { next[`${member._id.server}:${member._id.user}`] = member; }); 812 - return next; 813 - }); 814 - if (index + MEMBER_HYDRATE_CHUNK < payloadMembers.length) await new Promise((resolve) => setTimeout(resolve, 0)); 989 + const chunk = payloadMembers.slice(i, i + MEMBER_HYDRATE_CHUNK); 990 + setMembers((prev) => { const next = { ...prev }; chunk.forEach((m) => { next[`${m._id.server}:${m._id.user}`] = m; }); return next; }); 991 + if (i + MEMBER_HYDRATE_CHUNK < payloadMembers.length) await new Promise((r) => setTimeout(r, 0)); 815 992 } 816 993 } catch {} finally { 817 994 if (membersLoadIdRef.current === loadId) setIsMembersLoading(false); ··· 823 1000 const unresolved = new Set(); 824 1001 messageList.forEach((entry) => { 825 1002 const authorId = typeof entry?.author === 'string' ? entry.author : entry?.author?._id; 826 - if (authorId && authorId !== '00000000000000000000000000' && !users[authorId] && !pendingUserFetchRef.current.has(authorId)) unresolved.add(authorId); 827 - const replyId = getReplyIdFromMessage(entry); 828 - if (replyId) { 829 - const cachedReply = replyMessageCacheRef.current[replyId]; 830 - const replyAuthorId = cachedReply ? (typeof cachedReply.author === 'string' ? cachedReply.author : cachedReply.author?._id) : entry.replyMessage?.authorId; 831 - if (replyAuthorId && replyAuthorId !== '00000000000000000000000000' && !users[replyAuthorId] && !pendingUserFetchRef.current.has(replyAuthorId)) unresolved.add(replyAuthorId); 832 - } 1003 + if (authorId && authorId !== '00000000000000000000000000' && !users[authorId] && !pendingUserFetchRef.current.has(authorId)) 1004 + unresolved.add(authorId); 833 1005 }); 834 1006 if (!unresolved.size) return; 835 - const MAX_BACKGROUND_USER_FETCHES = 6; 836 - const limitedIds = [...unresolved].slice(0, MAX_BACKGROUND_USER_FETCHES); 837 - limitedIds.forEach((id) => pendingUserFetchRef.current.add(id)); 838 - await Promise.all( 839 - limitedIds.map(async (userId) => { 840 - try { 841 - const res = await fetch(`${config.apiUrl}/users/${userId}`, { headers: { 'x-session-token': auth.token } }); 842 - if (!res.ok) { if (res.status === 401 || res.status === 403 || res.status === 405) canFetchMissingUsersRef.current = false; return; } 843 - const data = await res.json(); 844 - if (data?._id) upsertUsers([data]); 845 - } catch (error) { 846 - const message = (error && error.message) || ''; 847 - if (message.includes('Failed to fetch') || message.includes('CORS')) canFetchMissingUsersRef.current = false; 848 - if (!loggedMissingUserFetchErrorRef.current) { loggedMissingUserFetchErrorRef.current = true; console.warn('Disabling background user lookup due to network/CORS restrictions.'); } 849 - } finally { pendingUserFetchRef.current.delete(userId); } 850 - }), 851 - ); 1007 + const ids = [...unresolved].slice(0, 6); 1008 + ids.forEach((id) => pendingUserFetchRef.current.add(id)); 1009 + await Promise.all(ids.map(async (userId) => { 1010 + try { 1011 + const res = await fetch(`${config.apiUrl}/users/${userId}`, { headers: { 'x-session-token': auth.token } }); 1012 + if (!res.ok) { if (res.status === 401 || res.status === 403 || res.status === 405) canFetchMissingUsersRef.current = false; return; } 1013 + const data = await res.json(); 1014 + if (data?._id) upsertUsers([data]); 1015 + } catch (err) { 1016 + const msg = err?.message || ''; 1017 + if (msg.includes('Failed to fetch') || msg.includes('CORS')) canFetchMissingUsersRef.current = false; 1018 + if (!loggedMissingUserFetchErrorRef.current) { loggedMissingUserFetchErrorRef.current = true; console.warn('Disabling background user lookup due to network/CORS restrictions.'); } 1019 + } finally { pendingUserFetchRef.current.delete(userId); } 1020 + })); 852 1021 }, [auth.token, config.apiUrl, isSameOriginApi, upsertUsers, users]); 853 1022 854 1023 const fetchReplyMessage = useCallback(async (channelId, replyId) => { ··· 872 1041 if (!res.ok) return; 873 1042 const data = await res.json(); 874 1043 const payloadMessages = Array.isArray(data) ? data : data.messages || []; 875 - const payloadUsers = Array.isArray(data) ? [] : data.users || []; 1044 + const payloadUsers = Array.isArray(data) ? [] : data.users || []; 876 1045 if (payloadUsers.length) upsertUsers(payloadUsers); 877 1046 upsertUsersFromMessages(payloadMessages); 878 - 879 - const orderedMessages = payloadMessages.reverse(); 880 - 1047 + const ordered = payloadMessages.reverse(); 881 1048 setMessages((prev) => { 882 - const existing = prev[channelId] || []; 883 - if (beforeId) { 884 - // Merging older messages 885 - return { ...prev, [channelId]: uniqueMessages([...orderedMessages, ...existing]) }; 886 - } else { 887 - // Initial load or new load 888 - return { ...prev, [channelId]: uniqueMessages(orderedMessages) }; 889 - } 1049 + const existing = prev[channelId] || []; 1050 + return { 1051 + ...prev, 1052 + [channelId]: beforeId 1053 + ? uniqueMessages([...ordered, ...existing]) 1054 + : uniqueMessages(ordered), 1055 + }; 890 1056 }); 891 - 892 - void fetchMissingUsers(orderedMessages); 1057 + void fetchMissingUsers(ordered); 893 1058 if (!beforeId) preloadedChannelRef.current[channelId] = true; 894 1059 } catch {} 895 1060 }, [auth.token, config.apiUrl, upsertUsers, upsertUsersFromMessages, fetchMissingUsers]); 896 1061 897 - 898 - // --- Derived State --- 899 - const serverList = useMemo(() => Object.values(servers), [servers]); 900 - const channelList = useMemo(() => selectedServerId === '@me' ? [] : Object.values(channels).filter((ch) => ch.server === selectedServerId), [channels, selectedServerId]); 901 - const directMessageChannels = useMemo(() => Object.values(channels).filter((channel) => channel?.channel_type === 'DirectMessage'), [channels]); 902 - const currentMessages = useMemo(() => uniqueMessages(messages[selectedChannelId] || []), [messages, selectedChannelId]); 903 - const currentMessageMap = useMemo(() => Object.fromEntries(currentMessages.map((message) => [message._id, message])), [currentMessages]); 904 - const globalReplyMessageMap = useMemo(() => ({ ...currentMessageMap, ...replyMessageCacheRef.current }), [currentMessageMap]); 905 - const activeReply = useMemo(() => replyingTo ? currentMessageMap[replyingTo._id] || replyingTo : null, [replyingTo, currentMessageMap]); 906 - 907 - const currentChannelName = useMemo(() => { 1062 + // ── Derived state ───────────────────────────────────────────────────────── 1063 + const serverList = useMemo(() => Object.values(servers), [servers]); 1064 + const channelList = useMemo(() => selectedServerId === '@me' ? [] : Object.values(channels).filter((ch) => ch.server === selectedServerId), [channels, selectedServerId]); 1065 + const directMessageChannels = useMemo(() => Object.values(channels).filter((ch) => ch?.channel_type === 'DirectMessage'), [channels]); 1066 + const currentMessages = useMemo(() => uniqueMessages(messages[selectedChannelId] || []), [messages, selectedChannelId]); 1067 + const currentMessageMap = useMemo(() => Object.fromEntries(currentMessages.map((m) => [m._id, m])), [currentMessages]); 1068 + const globalReplyMessageMap = useMemo(() => ({ ...currentMessageMap, ...replyMessageCacheRef.current }), [currentMessageMap]); 1069 + const activeReply = useMemo(() => replyingTo ? currentMessageMap[replyingTo._id] || replyingTo : null, [replyingTo, currentMessageMap]); 1070 + const currentChannelName = useMemo(() => { 908 1071 if (selectedChannelId === 'friends') return 'friends'; 909 1072 const channel = channels[selectedChannelId]; 910 1073 if (channel?.name) return channel.name; ··· 914 1077 } 915 1078 return 'select-a-channel'; 916 1079 }, [selectedChannelId, channels, auth.userId, users]); 917 - 918 - const allCurrentMembers = useMemo(() => selectedServerId === '@me' ? [] : Object.values(members).filter((member) => member._id.server === selectedServerId), [members, selectedServerId]); 919 - const friends = useMemo(() => Object.values(users).filter((u) => u.relationship === 'Friend' || u.relationship === 1), [users]); 920 - const selectedServer = servers[selectedServerId]; 921 - const isServerOwner = selectedServer?.owner === auth.userId; 922 - const memberListItems = useMemo(() => selectedServerId === '@me' || !selectedServer ? [] : organizeMembers(allCurrentMembers, selectedServer.roles || {}, users), [allCurrentMembers, selectedServer, users, selectedServerId]); 923 - 924 - const customEmojis = useMemo(() => { 925 - if (!selectedServer || selectedServerId === '@me') return []; 926 - const source = selectedServer.emojis || selectedServer.emoji || []; 927 - if (Array.isArray(source)) return source.map((entry) => ({ id: entry?._id || entry?.id, name: entry?.name || entry?._id || 'emoji' })); 928 - return Object.entries(source).map(([id, value]) => ({ id, name: value?.name || id })); 929 - }, [selectedServer, selectedServerId]); 1080 + const currentChannelTopic = useMemo(() => channels[selectedChannelId]?.description || channels[selectedChannelId]?.topic || null, [channels, selectedChannelId]); 1081 + const allCurrentMembers = useMemo(() => selectedServerId === '@me' ? [] : Object.values(members).filter((m) => m._id.server === selectedServerId), [members, selectedServerId]); 1082 + const friends = useMemo(() => Object.values(users).filter((u) => u.relationship === 'Friend' || u.relationship === 1), [users]); 1083 + const selectedServer = servers[selectedServerId]; 1084 + const isServerOwner = selectedServer?.owner === auth.userId; 1085 + const memberListItems = useMemo(() => selectedServerId === '@me' || !selectedServer ? [] : organizeMembers(allCurrentMembers, selectedServer.roles || {}, users), [allCurrentMembers, selectedServer, users, selectedServerId]); 930 1086 931 1087 const allCustomEmojis = useMemo(() => { 932 1088 const all = []; 933 - Object.values(servers).forEach(server => { 934 - const source = server.emojis || server.emoji || []; 1089 + Object.values(servers).forEach((server) => { 1090 + const source = server.emojis || server.emoji || []; 935 1091 const entries = Array.isArray(source) ? source : Object.entries(source).map(([id, val]) => ({ id, ...val })); 936 - entries.forEach(emoji => { if(emoji.id || emoji._id) all.push({ id: emoji.id || emoji._id, name: emoji.name, serverName: server.name, animated: emoji.animated, isPrivate: server.discoverable === false }); }); 1092 + entries.forEach((emoji) => { 1093 + if (emoji.id || emoji._id) 1094 + all.push({ id: emoji.id || emoji._id, name: emoji.name, serverName: server.name, animated: emoji.animated, isPrivate: server.discoverable === false }); 1095 + }); 937 1096 }); 938 1097 return all; 939 1098 }, [servers]); ··· 941 1100 const customEmojiById = useMemo(() => { 942 1101 const index = {}; 943 1102 Object.values(servers).forEach((server) => { 944 - const source = server?.emojis || server?.emoji || []; 945 - 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 })); 946 - entries.forEach((emoji) => { if (emoji?.id) index[emoji.id] = { id: emoji.id, name: emoji.name || emoji.id, serverName: server?.name, isPrivate: server?.discoverable === false }; }); 1103 + const source = server?.emojis || server?.emoji || []; 1104 + const entries = Array.isArray(source) 1105 + ? source.map((e) => ({ id: e?._id || e?.id, name: e?.name })) 1106 + : Object.entries(source || {}).map(([id, e]) => ({ id, name: e?.name })); 1107 + entries.forEach((e) => { if (e?.id) index[e.id] = { id: e.id, name: e.name || e.id, serverName: server?.name, isPrivate: server?.discoverable === false }; }); 947 1108 }); 948 1109 return index; 949 1110 }, [servers]); 950 1111 951 1112 const reactionOptions = useMemo(() => { 952 1113 const base = STANDARD_EMOJIS.map((emoji) => ({ value: emoji, label: emoji, title: emoji, custom: null })); 953 - const serverSet = allCustomEmojis.map((emoji) => { 954 - const source = emoji.isPrivate ? 'Private' : emoji.serverName; 955 - return { value: `:${emoji.id}:`, label: emoji.name, title: `${emoji.name} · ${source}`, custom: { id: emoji.id, name: emoji.name } }; 956 - }); 1114 + const serverSet = allCustomEmojis.map((emoji) => ({ 1115 + value: `:${emoji.id}:`, 1116 + label: emoji.name, 1117 + title: `${emoji.name} · ${emoji.isPrivate ? 'Private' : emoji.serverName}`, 1118 + custom: { id: emoji.id, name: emoji.name }, 1119 + })); 957 1120 return [...base, ...serverSet]; 958 1121 }, [allCustomEmojis]); 959 1122 960 - const peekMember = useMemo(() => !peekUser?._id || selectedServerId === '@me' ? null : members[`${selectedServerId}:${peekUser._id}`], [members, peekUser, selectedServerId]); 961 - const peekRoles = useMemo(() => !peekMember || !selectedServer?.roles ? [] : (peekMember.roles || []).map((roleId) => selectedServer.roles?.[roleId]).filter(Boolean), [peekMember, selectedServer]); 962 - const peekBadges = useMemo(() => { 1123 + const filteredComposerCustomEmojis = useMemo(() => { 1124 + if (!emojiSearch) return allCustomEmojis; 1125 + const q = emojiSearch.toLowerCase(); 1126 + return allCustomEmojis.filter((e) => e.name.toLowerCase().includes(q)); 1127 + }, [emojiSearch, allCustomEmojis]); 1128 + 1129 + // User profile peek helpers 1130 + const peekMember = useMemo(() => !peekUser?._id || selectedServerId === '@me' ? null : members[`${selectedServerId}:${peekUser._id}`], [members, peekUser, selectedServerId]); 1131 + const peekRoles = useMemo(() => !peekMember || !selectedServer?.roles ? [] : (peekMember.roles || []).map((roleId) => selectedServer.roles?.[roleId]).filter(Boolean), [peekMember, selectedServer]); 1132 + const peekBadges = useMemo(() => { 963 1133 if (!peekUser?.badges) return []; 964 1134 if (Array.isArray(peekUser.badges)) return peekUser.badges; 965 1135 if (typeof peekUser.badges === 'number') { 966 1136 const flagMap = { 1: 'Developer', 2: 'Translator', 4: 'Supporter', 8: 'Founder', 16: 'Platform Moderation', 32: 'Active Supporter', 64: 'Paw' }; 967 1137 return Object.entries(flagMap).filter(([bit]) => (peekUser.badges & Number(bit)) === Number(bit)).map(([, label]) => label); 968 1138 } 969 - if (typeof peekUser.badges === 'object') return Object.entries(peekUser.badges).filter(([, enabled]) => Boolean(enabled)).map(([badge]) => badge); 1139 + if (typeof peekUser.badges === 'object') return Object.entries(peekUser.badges).filter(([, v]) => Boolean(v)).map(([badge]) => badge); 970 1140 return []; 971 1141 }, [peekUser]); 972 - const peekAboutMe = useMemo(() => peekUser?.profile?.content || peekUser?.profile?.bio || peekUser?.bio || null, [peekUser]); 1142 + const peekAboutMe = useMemo(() => peekUser?.profile?.content || peekUser?.profile?.bio || peekUser?.bio || null, [peekUser]); 973 1143 const peekPlatformJoined = useMemo(() => getJoinedAt(peekUser), [peekUser]); 974 - const peekServerJoined = useMemo(() => getJoinedAt(peekMember), [peekMember]); 975 - const peekBannerUrl = useMemo(() => getBannerUrl(peekUser, config.cdnUrl), [peekUser, config.cdnUrl]); 976 - 1144 + const peekServerJoined = useMemo(() => getJoinedAt(peekMember), [peekMember]); 1145 + const peekBannerUrl = useMemo(() => getBannerUrl(peekUser, config.cdnUrl), [peekUser, config.cdnUrl]); 977 1146 1147 + // ── Config discovery ────────────────────────────────────────────────────── 978 1148 const discoverConfig = async (apiUrl) => { 979 1149 try { 980 - const res = await fetch(apiUrl, { headers: { Accept: 'application/json' } }); 1150 + const res = await fetch(apiUrl, { headers: { Accept: 'application/json' } }); 981 1151 if (!res.ok) return; 982 1152 const data = await res.json(); 983 1153 setConfig((prev) => ({ ...prev, apiUrl, wsUrl: data.ws || prev.wsUrl, cdnUrl: data.features?.autumn?.url || prev.cdnUrl })); 984 1154 } catch {} 985 1155 }; 986 1156 987 - useEffect(() => { const savedToken = getCookie(TOKEN_COOKIE_NAME) || localStorage.getItem('stoat_token'); const savedUserId = getCookie(USER_COOKIE_NAME) || localStorage.getItem('stoat_user_id'); const savedApi = getCookie(API_COOKIE_NAME) || localStorage.getItem('stoat_api_url'); const api = savedApi || DEFAULT_API_URL; setConfig((prev) => ({ ...prev, apiUrl: api })); discoverConfig(api); if (savedToken && savedUserId) { setAuth({ token: savedToken, userId: savedUserId }); setView('app'); } else { setView('login'); } }, []); 1157 + // Restore session on mount 1158 + useEffect(() => { 1159 + const savedToken = getCookie(TOKEN_COOKIE_NAME) || localStorage.getItem('stoat_token'); 1160 + const savedUserId = getCookie(USER_COOKIE_NAME) || localStorage.getItem('stoat_user_id'); 1161 + const savedApi = getCookie(API_COOKIE_NAME) || localStorage.getItem('stoat_api_url'); 1162 + const api = savedApi || DEFAULT_API_URL; 1163 + setConfig((prev) => ({ ...prev, apiUrl: api })); 1164 + discoverConfig(api); 1165 + if (savedToken && savedUserId) { setAuth({ token: savedToken, userId: savedUserId }); setView('app'); } 1166 + else setView('login'); 1167 + // eslint-disable-next-line react-hooks/exhaustive-deps 1168 + }, []); 988 1169 1170 + // ── Event handler (plain fn — captured by ref every render) ────────────── 989 1171 const applyEvent = (packet) => { 990 1172 if (!packet?.type) return; 991 1173 switch (packet.type) { 992 - case 'Ready': setUsers((prev) => { const next = { ...prev }; packet.users?.forEach((u) => { next[u._id] = u; }); return next; }); setServers((prev) => { const next = { ...prev }; packet.servers?.forEach((s) => { next[s._id] = s; }); return next; }); setChannels((prev) => { const next = { ...prev }; packet.channels?.forEach((c) => { next[c._id] = c; }); return next; }); setMembers((prev) => { const next = { ...prev }; packet.members?.forEach((m) => { next[`${m._id.server}:${m._id.user}`] = m; }); return next; }); setStatus('ready'); break; 993 - case 'Bulk': packet.v?.forEach(applyEvent); break; 1174 + case 'Ready': 1175 + setUsers((prev) => { const n = { ...prev }; packet.users?.forEach((u) => { n[u._id] = u; }); return n; }); 1176 + setServers((prev) => { const n = { ...prev }; packet.servers?.forEach((s) => { n[s._id] = s; }); return n; }); 1177 + setChannels((prev) => { const n = { ...prev }; packet.channels?.forEach((c) => { n[c._id] = c; }); return n; }); 1178 + setMembers((prev) => { const n = { ...prev }; packet.members?.forEach((m) => { n[`${m._id.server}:${m._id.user}`] = m; }); return n; }); 1179 + setStatus('ready'); 1180 + break; 1181 + case 'Bulk': packet.v?.forEach((e) => applyEventRef.current?.(e)); break; 994 1182 case 'Authenticated': setStatus('authenticated'); break; 995 - case 'Error': setStatus(`error:${packet.error || 'unknown'}`); break; 996 - case 'Logout': logout(); break; 997 - case 'Message': upsertUsersFromMessages([packet]); void fetchMissingUsers([packet]); setMessages((prev) => { const list = prev[packet.channel] || []; if (list.some((m) => m._id === packet._id)) return prev; const nextMessages = [...list, packet].slice(-200); return { ...prev, [packet.channel]: uniqueMessages(nextMessages) }; }); break; 998 - case 'MessageUpdate': setMessages((prev) => ({ ...prev, [packet.channel]: uniqueMessages((prev[packet.channel] || []).map((m) => (m._id === packet.id ? { ...m, ...packet.data, edited: new Date().toISOString() } : m))) })); break; 999 - case 'MessageDelete': setMessages((prev) => ({ ...prev, [packet.channel]: (prev[packet.channel] || []).filter((m) => m._id !== packet.id) })); break; 1183 + case 'Error': setStatus(`error:${packet.error || 'unknown'}`); break; 1184 + case 'Logout': logout(); break; 1185 + case 'Message': 1186 + upsertUsersFromMessages([packet]); 1187 + void fetchMissingUsers([packet]); 1188 + setMessages((prev) => { 1189 + const list = prev[packet.channel] || []; 1190 + if (list.some((m) => m._id === packet._id)) return prev; 1191 + return { ...prev, [packet.channel]: uniqueMessages([...list, packet].slice(-200)) }; 1192 + }); 1193 + break; 1194 + case 'MessageUpdate': 1195 + setMessages((prev) => ({ 1196 + ...prev, 1197 + [packet.channel]: uniqueMessages((prev[packet.channel] || []).map((m) => m._id === packet.id ? { ...m, ...packet.data, edited: new Date().toISOString() } : m)), 1198 + })); 1199 + break; 1200 + case 'MessageDelete': 1201 + setMessages((prev) => ({ ...prev, [packet.channel]: (prev[packet.channel] || []).filter((m) => m._id !== packet.id) })); 1202 + break; 1000 1203 case 'ChannelCreate': setChannels((prev) => ({ ...prev, [packet._id]: packet })); break; 1001 1204 case 'ChannelUpdate': setChannels((prev) => ({ ...prev, [packet.id]: withoutClearedFields({ ...prev[packet.id], ...packet.data }, packet.clear) })); break; 1002 - case 'ChannelDelete': setChannels((prev) => { const next = { ...prev }; delete next[packet.id]; return next; }); break; 1003 - case 'ServerCreate': setServers((prev) => ({ ...prev, [packet._id]: packet })); break; 1004 - case 'ServerUpdate': setServers((prev) => ({ ...prev, [packet.id]: withoutClearedFields({ ...prev[packet.id], ...packet.data }, packet.clear) })); break; 1005 - case 'ServerDelete': setServers((prev) => { const next = { ...prev }; delete next[packet.id]; return next; }); if (selectedServerId === packet.id) handleServerSelect('@me'); break; 1006 - 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; } 1205 + case 'ChannelDelete': setChannels((prev) => { const n = { ...prev }; delete n[packet.id]; return n; }); break; 1206 + case 'ServerCreate': setServers((prev) => ({ ...prev, [packet._id]: packet })); break; 1207 + case 'ServerUpdate': setServers((prev) => ({ ...prev, [packet.id]: withoutClearedFields({ ...prev[packet.id], ...packet.data }, packet.clear) })); break; 1208 + case 'ServerDelete': 1209 + setServers((prev) => { const n = { ...prev }; delete n[packet.id]; return n; }); 1210 + if (selectedServerId === packet.id) handleServerSelect('@me'); 1211 + break; 1212 + case 'ServerMemberUpdate': { 1213 + const key = `${packet.id.server}:${packet.id.user}`; 1214 + setMembers((prev) => ({ ...prev, [key]: withoutClearedFields({ ...prev[key], _id: packet.id, ...packet.data }, packet.clear) })); 1215 + break; 1216 + } 1007 1217 case 'ServerMemberJoin': setMembers((prev) => ({ ...prev, [`${packet.id}:${packet.user}`]: { ...packet.member, _id: { server: packet.id, user: packet.user } } })); break; 1008 - case 'ServerMemberLeave': setMembers((prev) => { const next = { ...prev }; delete next[`${packet.id}:${packet.user}`]; return next; }); break; 1218 + case 'ServerMemberLeave': setMembers((prev) => { const n = { ...prev }; delete n[`${packet.id}:${packet.user}`]; return n; }); break; 1009 1219 case 'UserUpdate': setUsers((prev) => ({ ...prev, [packet.id]: withoutClearedFields({ ...prev[packet.id], ...packet.data }, packet.clear) })); break; 1010 - default: 1220 + default: break; 1011 1221 } 1012 1222 }; 1013 1223 1224 + // Keep ref fresh every render so the WS handler is never stale 1225 + applyEventRef.current = applyEvent; 1226 + 1227 + // ── WebSocket ───────────────────────────────────────────────────────────── 1014 1228 useEffect(() => { 1015 1229 if (!auth.token || view !== 'app') return; 1016 1230 let wsUrl = config.wsUrl; 1017 - 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)}`; } 1231 + try { 1232 + const parsed = new URL(wsUrl.startsWith('ws') ? wsUrl : `wss://${wsUrl}`); 1233 + parsed.searchParams.set('version', '1'); 1234 + parsed.searchParams.set('format', 'json'); 1235 + parsed.searchParams.set('token', auth.token); 1236 + wsUrl = parsed.toString(); 1237 + } catch { 1238 + wsUrl = `${DEFAULT_WS_URL}?version=1&format=json&token=${encodeURIComponent(auth.token)}`; 1239 + } 1018 1240 setStatus('connecting'); 1019 1241 if (wsRef.current) wsRef.current.close(); 1020 1242 if (wsReconnectTimeoutRef.current) { clearTimeout(wsReconnectTimeoutRef.current); wsReconnectTimeoutRef.current = null; } ··· 1022 1244 const socket = new WebSocket(wsUrl); 1023 1245 wsRef.current = socket; 1024 1246 socket.onerror = () => setStatus('error'); 1025 - socket.onclose = () => { setStatus((prev) => (prev === 'error' ? prev : 'disconnected')); if (isUnmounting || view !== 'app') return; wsReconnectTimeoutRef.current = setTimeout(() => { setWsReconnectAttempt((value) => value + 1); }, 2500); }; 1026 - socket.onmessage = (event) => applyEvent(JSON.parse(event.data)); 1247 + socket.onclose = () => { 1248 + setStatus((prev) => (prev === 'error' ? prev : 'disconnected')); 1249 + if (isUnmounting || view !== 'app') return; 1250 + wsReconnectTimeoutRef.current = setTimeout(() => setWsReconnectAttempt((v) => v + 1), 2500); 1251 + }; 1252 + // Use ref so we always call the latest applyEvent (stale-closure fix) 1253 + socket.onmessage = (event) => applyEventRef.current?.(JSON.parse(event.data)); 1027 1254 const heartbeat = setInterval(() => { if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ type: 'Ping', data: Date.now() })); }, 20000); 1028 - return () => { isUnmounting = true; clearInterval(heartbeat); if (wsReconnectTimeoutRef.current) { clearTimeout(wsReconnectTimeoutRef.current); wsReconnectTimeoutRef.current = null; } socket.close(); }; 1255 + return () => { 1256 + isUnmounting = true; 1257 + clearInterval(heartbeat); 1258 + if (wsReconnectTimeoutRef.current) { clearTimeout(wsReconnectTimeoutRef.current); wsReconnectTimeoutRef.current = null; } 1259 + socket.close(); 1260 + }; 1261 + // eslint-disable-next-line react-hooks/exhaustive-deps 1029 1262 }, [auth.token, config.wsUrl, view, wsReconnectAttempt]); 1030 1263 1264 + // Server subscription heartbeat 1031 1265 useEffect(() => { 1032 1266 const socket = wsRef.current; 1033 1267 if (!socket || socket.readyState !== WebSocket.OPEN || selectedServerId === '@me') return; 1034 - 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(); }; 1268 + const sendSubscribe = () => { 1269 + if (document.visibilityState !== 'visible' || !window.document.hasFocus()) return; 1270 + const lastSent = subscriptionRef.current[selectedServerId] || 0; 1271 + if (Date.now() - lastSent < 10 * 60 * 1000) return; 1272 + socket.send(JSON.stringify({ type: 'Subscribe', server_id: selectedServerId })); 1273 + subscriptionRef.current[selectedServerId] = Date.now(); 1274 + }; 1035 1275 sendSubscribe(); 1036 - window.addEventListener('focus', sendSubscribe); document.addEventListener('visibilitychange', sendSubscribe); const interval = setInterval(sendSubscribe, 60 * 1000); 1276 + window.addEventListener('focus', sendSubscribe); 1277 + document.addEventListener('visibilitychange', sendSubscribe); 1278 + const interval = setInterval(sendSubscribe, 60 * 1000); 1037 1279 return () => { clearInterval(interval); window.removeEventListener('focus', sendSubscribe); document.removeEventListener('visibilitychange', sendSubscribe); }; 1038 1280 }, [selectedServerId, status]); 1039 1281 1040 - const isNearBottom = () => { const container = messagesContainerRef.current; if (!container) return true; return container.scrollHeight - container.scrollTop - container.clientHeight <= 72; }; 1282 + // ── Scroll handler (unified: auto-follow + load-older) ──────────────────── 1283 + // Keep refs fresh so the stable effect can see current values 1284 + currentMessagesRef.current = currentMessages; 1285 + fetchMessagesRef.current = fetchMessages; 1286 + selectedChannelIdRef.current = selectedChannelId; 1287 + 1288 + useEffect(() => { 1289 + const container = messagesContainerRef.current; 1290 + if (!container) return; 1291 + autoFollowRef.current = true; 1292 + 1293 + const handleScroll = () => { 1294 + if (isProgrammaticScrollRef.current) return; 1295 + 1296 + // Near-bottom detection 1297 + const nearBottom = container.scrollHeight - container.scrollTop - container.clientHeight <= 72; 1298 + autoFollowRef.current = nearBottom; 1299 + setShowGoLatest(!nearBottom); 1300 + 1301 + // Load older messages when scrolled near the top 1302 + if (container.scrollTop < 150 && !isLoadingOlderRef.current) { 1303 + const msgs = currentMessagesRef.current; 1304 + const oldest = msgs[0]; 1305 + if (oldest && !oldest._id.startsWith('pending-') && msgs.length >= 50) { 1306 + const prevScrollHeight = container.scrollHeight; 1307 + const prevScrollTop = container.scrollTop; 1308 + isLoadingOlderRef.current = true; 1309 + setIsLoadingOlder(true); 1310 + fetchMessagesRef.current(selectedChannelIdRef.current, oldest._id) 1311 + .finally(() => { 1312 + isLoadingOlderRef.current = false; 1313 + setIsLoadingOlder(false); 1314 + // Restore scroll position so content doesn't jump 1315 + requestAnimationFrame(() => { 1316 + const delta = container.scrollHeight - prevScrollHeight; 1317 + container.scrollTop = prevScrollTop + delta; 1318 + }); 1319 + }); 1320 + } 1321 + } 1322 + }; 1041 1323 1042 - useEffect(() => { const container = messagesContainerRef.current; if (!container) return; autoFollowRef.current = true; const handleScroll = () => { if (isProgrammaticScrollRef.current) return; const nearBottom = isNearBottom(); autoFollowRef.current = nearBottom; setShowGoLatest((prev) => (prev === !nearBottom ? prev : !nearBottom)); }; container.addEventListener('scroll', handleScroll); return () => container.removeEventListener('scroll', handleScroll); }, [selectedChannelId]); 1043 - useEffect(() => { if (autoFollowRef.current || isNearBottom()) { isProgrammaticScrollRef.current = true; messagesBottomRef.current?.scrollIntoView({ behavior: 'auto' }); requestAnimationFrame(() => { isProgrammaticScrollRef.current = false; }); autoFollowRef.current = true; setShowGoLatest(false); return; } setShowGoLatest(true); }, [currentMessages]); 1044 - useEffect(() => { if (!selectedChannelId || selectedChannelId === 'friends') return; const missingReplyIds = currentMessages.map((message) => getReplyIdFromMessage(message)).filter((replyId) => replyId && !replyMessageCacheRef.current[replyId] && !currentMessageMap[replyId]); [...new Set(missingReplyIds)].slice(0, 10).forEach((replyId) => { void fetchReplyMessage(selectedChannelId, replyId); }); }, [currentMessages, currentMessageMap, selectedChannelId]); 1045 - useEffect(() => { if (!voiceNotice) return; const timeout = setTimeout(() => setVoiceNotice(''), 3500); return () => clearTimeout(timeout); }, [voiceNotice]); 1046 - useEffect(() => { if (status !== 'ready' || !auth.token) return; if (document.visibilityState !== 'visible') return; const maxWarmChannels = Math.min(8, Math.max(3, Math.floor((navigator.hardwareConcurrency || 4) / 2))); const channelsToWarm = Object.values(channels).filter((channel) => channel?.channel_type === 'TextChannel' || !channel?.channel_type).slice(0, maxWarmChannels); channelsToWarm.forEach((channel, index) => { if (!channel?._id || preloadedChannelRef.current[channel._id]) return; setTimeout(() => { fetchMessages(channel._id); }, index * 350); }); }, [status, auth.token, channels, fetchMessages]); 1324 + container.addEventListener('scroll', handleScroll, { passive: true }); 1325 + return () => container.removeEventListener('scroll', handleScroll); 1326 + }, [selectedChannelId]); 1047 1327 1328 + // Auto-scroll to bottom on new messages 1329 + useEffect(() => { 1330 + if (autoFollowRef.current || (messagesContainerRef.current && messagesContainerRef.current.scrollHeight - messagesContainerRef.current.scrollTop - messagesContainerRef.current.clientHeight <= 72)) { 1331 + isProgrammaticScrollRef.current = true; 1332 + messagesBottomRef.current?.scrollIntoView({ behavior: 'auto' }); 1333 + requestAnimationFrame(() => { isProgrammaticScrollRef.current = false; }); 1334 + autoFollowRef.current = true; 1335 + setShowGoLatest(false); 1336 + } else { 1337 + setShowGoLatest(true); 1338 + } 1339 + }, [currentMessages]); 1048 1340 1049 - const handleServerSelect = (serverId) => { setSelectedServerId(serverId); if (serverId === '@me') { setSelectedChannelId('friends'); setReplyingTo(null); setShowEmojiPicker(false); return; } const firstChannel = Object.values(channels).find((ch) => ch.server === serverId && ch.channel_type !== 'VoiceChannel') || Object.values(channels).find((ch) => ch.server === serverId); setSelectedChannelId(firstChannel?._id || null); if (firstChannel?._id) fetchMessages(firstChannel._id); if (!preloadedMembersRef.current[serverId]) { preloadedMembersRef.current[serverId] = true; void fetchMembers(serverId); } }; 1050 - const handleChannelSelect = (channelId) => { const channel = channels[channelId]; if (channel?.channel_type === 'VoiceChannel') { setVoiceNotice("Ermine currently doesn't support voice channels."); return; } setVoiceNotice(''); setSelectedChannelId(channelId); setReplyingTo(null); setShowEmojiPicker(false); fetchMessages(channelId); }; 1051 - const openDmWithUser = async (userId) => { if (!userId || !auth.token) return; const existing = directMessageChannels.find((channel) => Array.isArray(channel?.recipients) && channel.recipients.includes(userId)); if (existing?._id) { setSelectedServerId('@me'); handleChannelSelect(existing._id); return; } try { const res = await fetch(`${config.apiUrl}/users/${userId}/dm`, { method: 'GET', headers: { 'x-session-token': auth.token } }); if (!res.ok) return; const channel = await res.json(); if (!channel?._id) return; setChannels((prev) => ({ ...prev, [channel._id]: channel })); setSelectedServerId('@me'); setSelectedChannelId(channel._id); fetchMessages(channel._id); } catch {} }; 1052 - const jumpToMessage = (messageId) => { if (!messageId) return; const target = messageRefs.current[messageId]; if (!target) return; target.scrollIntoView({ behavior: 'smooth', block: 'center' }); target.classList.add('ring-2', 'ring-[#5865f2]', 'bg-[#3a3f66]/40'); setTimeout(() => { target.classList.remove('ring-2', 'ring-[#5865f2]', 'bg-[#3a3f66]/40'); }, 1200); }; 1053 - const registerMessageRef = (messageId, node) => { if (!messageId) return; if (node) { messageRefs.current[messageId] = node; } else { delete messageRefs.current[messageId]; } }; 1054 - const goToLatest = () => { isProgrammaticScrollRef.current = true; autoFollowRef.current = true; messagesBottomRef.current?.scrollIntoView({ behavior: 'smooth' }); requestAnimationFrame(() => { isProgrammaticScrollRef.current = false; }); setShowGoLatest(false); }; 1055 - const requestOpenLink = (url) => { setLinkPromptUrl(url); }; 1341 + // Fetch missing reply messages 1342 + useEffect(() => { 1343 + if (!selectedChannelId || selectedChannelId === 'friends') return; 1344 + const missingReplyIds = currentMessages 1345 + .map((m) => getReplyIdFromMessage(m)) 1346 + .filter((id) => id && !replyMessageCacheRef.current[id] && !currentMessageMap[id]); 1347 + [...new Set(missingReplyIds)].slice(0, 10).forEach((id) => { void fetchReplyMessage(selectedChannelId, id); }); 1348 + }, [currentMessages, currentMessageMap, selectedChannelId, fetchReplyMessage]); 1349 + 1350 + // Voice notice auto-clear 1351 + useEffect(() => { 1352 + if (!voiceNotice) return; 1353 + const t = setTimeout(() => setVoiceNotice(''), 3500); 1354 + return () => clearTimeout(t); 1355 + }, [voiceNotice]); 1356 + 1357 + // Warm channel cache on connect 1358 + useEffect(() => { 1359 + if (status !== 'ready' || !auth.token) return; 1360 + if (document.visibilityState !== 'visible') return; 1361 + const maxWarm = Math.min(8, Math.max(3, Math.floor((navigator.hardwareConcurrency || 4) / 2))); 1362 + Object.values(channels) 1363 + .filter((ch) => ch?.channel_type === 'TextChannel' || !ch?.channel_type) 1364 + .slice(0, maxWarm) 1365 + .forEach((ch, index) => { 1366 + if (!ch?._id || preloadedChannelRef.current[ch._id]) return; 1367 + setTimeout(() => { fetchMessages(ch._id); }, index * 350); 1368 + }); 1369 + // eslint-disable-next-line react-hooks/exhaustive-deps 1370 + }, [status, auth.token]); 1371 + 1372 + // ── Navigation handlers ─────────────────────────────────────────────────── 1373 + const handleServerSelect = (serverId) => { 1374 + setSelectedServerId(serverId); 1375 + setShowMobileSidebar(false); 1376 + if (serverId === '@me') { setSelectedChannelId('friends'); setReplyingTo(null); setShowEmojiPicker(false); return; } 1377 + const firstChannel = Object.values(channels).find((ch) => ch.server === serverId && ch.channel_type !== 'VoiceChannel') || Object.values(channels).find((ch) => ch.server === serverId); 1378 + setSelectedChannelId(firstChannel?._id || null); 1379 + if (firstChannel?._id) fetchMessages(firstChannel._id); 1380 + if (!preloadedMembersRef.current[serverId]) { preloadedMembersRef.current[serverId] = true; void fetchMembers(serverId); } 1381 + }; 1382 + 1383 + const handleChannelSelect = (channelId) => { 1384 + const channel = channels[channelId]; 1385 + if (channel?.channel_type === 'VoiceChannel') { setVoiceNotice("Ermine currently doesn't support voice channels."); return; } 1386 + setVoiceNotice(''); 1387 + setSelectedChannelId(channelId); 1388 + setReplyingTo(null); 1389 + setShowEmojiPicker(false); 1390 + setShowMobileSidebar(false); 1391 + fetchMessages(channelId); 1392 + }; 1393 + 1394 + const openDmWithUser = async (userId) => { 1395 + if (!userId || !auth.token) return; 1396 + const existing = directMessageChannels.find((ch) => Array.isArray(ch?.recipients) && ch.recipients.includes(userId)); 1397 + if (existing?._id) { setSelectedServerId('@me'); handleChannelSelect(existing._id); return; } 1398 + try { 1399 + const res = await fetch(`${config.apiUrl}/users/${userId}/dm`, { method: 'GET', headers: { 'x-session-token': auth.token } }); 1400 + if (!res.ok) return; 1401 + const channel = await res.json(); 1402 + if (!channel?._id) return; 1403 + setChannels((prev) => ({ ...prev, [channel._id]: channel })); 1404 + setSelectedServerId('@me'); 1405 + setSelectedChannelId(channel._id); 1406 + fetchMessages(channel._id); 1407 + } catch {} 1408 + }; 1409 + 1410 + const jumpToMessage = (messageId) => { 1411 + if (!messageId) return; 1412 + const target = messageRefs.current[messageId]; 1413 + if (!target) return; 1414 + target.scrollIntoView({ behavior: 'smooth', block: 'center' }); 1415 + target.classList.add('ring-2', 'ring-[#5865f2]', 'bg-[#3a3f66]/40'); 1416 + setTimeout(() => target.classList.remove('ring-2', 'ring-[#5865f2]', 'bg-[#3a3f66]/40'), 1200); 1417 + }; 1418 + 1419 + const registerMessageRef = (messageId, node) => { 1420 + if (!messageId) return; 1421 + if (node) messageRefs.current[messageId] = node; 1422 + else delete messageRefs.current[messageId]; 1423 + }; 1424 + 1425 + const goToLatest = () => { 1426 + isProgrammaticScrollRef.current = true; 1427 + autoFollowRef.current = true; 1428 + messagesBottomRef.current?.scrollIntoView({ behavior: 'smooth' }); 1429 + requestAnimationFrame(() => { isProgrammaticScrollRef.current = false; }); 1430 + setShowGoLatest(false); 1431 + }; 1432 + 1433 + const requestOpenLink = (url) => setLinkPromptUrl(url); 1056 1434 const confirmOpenLink = () => { if (!linkPromptUrl) return; window.open(linkPromptUrl, '_blank', 'noopener,noreferrer'); setLinkPromptUrl(null); }; 1057 - 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'); }; 1058 - const saveStatus = async () => { setIsSavingStatus(true); setUsers((prev) => ({ ...prev, [auth.userId]: { ...(prev[auth.userId] || {}), status: { presence: statusDraft.presence, text: statusDraft.text || undefined } } })); 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); } }; 1059 - const handleLogin = async (event) => { event.preventDefault(); setLoginError(''); if (!privacyConsent) { setLoginError('Please accept the privacy policy before signing in.'); return; } setIsLoggingIn(true); try { await discoverConfig(config.apiUrl); let token = manualToken.trim(); let userId = null; if (loginMode === 'credentials') { const payload = { email, password, friendly_name: 'Ermine Web Client' }; if (mfaCode.trim()) payload.mfa_response = { totp_code: mfaCode.trim() }; const res = await fetch(`${config.apiUrl}/auth/session/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); if (!res.ok) throw new Error(res.status === 401 ? 'Invalid credentials or MFA code.' : 'Login failed.'); const data = await res.json(); token = data.token || data.session_token; userId = data.user_id; } else { const meRes = await fetch(`${config.apiUrl}/users/@me`, { headers: { 'x-session-token': token } }); if (!meRes.ok) throw new Error('Token is invalid.'); const meData = await meRes.json(); userId = meData._id; } if (!token || !userId) throw new Error('Session creation failed.'); setCookie(TOKEN_COOKIE_NAME, token); setCookie(USER_COOKIE_NAME, userId); setCookie(API_COOKIE_NAME, config.apiUrl); setAuth({ token, userId }); setView('app'); } catch (error) { setLoginError(error.message || 'Unknown login error'); } finally { setIsLoggingIn(false); } }; 1060 - const logout = () => { 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); if (wsReconnectTimeoutRef.current) { clearTimeout(wsReconnectTimeoutRef.current); wsReconnectTimeoutRef.current = null; } setView('login'); setStatus('disconnected'); }; 1061 - const sendMessage = async () => { const content = inputText.trim(); const hasContent = Boolean(content); const hasFiles = pendingFiles.length > 0; if ((!hasContent && !hasFiles) || !selectedChannelId || selectedChannelId === 'friends') return; setInputText(''); setIsUploadingFiles(hasFiles); const nonce = crypto.randomUUID(); const pendingId = `pending-${nonce}`; const meUser = users[auth.userId] || { _id: auth.userId, username: 'You' }; 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 })) }; setMessages((prev) => { const list = prev[selectedChannelId] || []; return { ...prev, [selectedChannelId]: uniqueMessages([...list, optimisticMessage]).slice(-200) }; }); try { const attachmentIds = hasFiles ? await uploadFiles(pendingFiles) : []; const payload = { content, nonce, replies: replyingTo ? [{ id: replyingTo._id, mention: false }] : undefined, attachments: attachmentIds.length ? attachmentIds : undefined }; 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) }); if (!res.ok) throw new Error('Failed to send message'); const postedMessage = await res.json(); if (postedMessage?._id) { upsertUsersFromMessages([postedMessage]); setMessages((prev) => { const list = prev[selectedChannelId] || []; let nextList = list.filter((entry) => entry._id !== pendingId); if (!nextList.some(m => m._id === postedMessage._id)) { nextList.push(postedMessage); } else { nextList = nextList.map(m => m._id === postedMessage._id ? postedMessage : m); } nextList.sort((a, b) => (a._id > b._id ? 1 : -1)); return { ...prev, [selectedChannelId]: uniqueMessages(nextList) }; }); } setPendingFiles([]); setReplyingTo(null); setShowEmojiPicker(false); } catch { setMessages((prev) => ({ ...prev, [selectedChannelId]: (prev[selectedChannelId] || []).filter((entry) => entry._id !== pendingId) })); setInputText(content); } finally { setIsUploadingFiles(false); } }; 1062 - const editMessage = async (messageId, newContent) => { 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); } }; 1063 - const deleteMessage = async (messageId) => { if (!confirm('Are you sure you want to delete this message?')) return; 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); } }; 1064 - const toggleReaction = async (message, emoji, reacted) => { if (!message?._id || !selectedChannelId || selectedChannelId === 'friends') return; const encodedEmoji = encodeURIComponent(emoji); setMessages((prev) => { const nextList = (prev[selectedChannelId] || []).map((entry) => { if (entry._id !== message._id) return entry; const existing = entry.reactions?.[emoji] || []; const nextUserIds = reacted ? existing.filter((id) => id !== auth.userId) : [...new Set([...existing, auth.userId])]; return { ...entry, reactions: { ...(entry.reactions || {}), [emoji]: nextUserIds } }; }); return { ...prev, [selectedChannelId]: nextList }; }); try { await fetch(`${config.apiUrl}/channels/${selectedChannelId}/messages/${message._id}/reactions/${encodedEmoji}`, { method: reacted ? 'DELETE' : 'PUT', headers: { 'x-session-token': auth.token } }); } catch {} }; 1065 - const addEmojiToComposer = (emoji) => { setInputText((prev) => `${prev}${emoji}`); setShowEmojiPicker(false); }; 1066 - const addCustomEmojiToComposer = (emojiId) => { setInputText((prev) => `${prev}:${emojiId}:`); setShowEmojiPicker(false); }; 1067 - const handleFilePick = (event) => { const files = Array.from(event.target.files || []); if (!files.length) return; setPendingFiles((prev) => [...prev, ...files]); event.target.value = ''; }; 1068 - const handleComposerDrop = (event) => { event.preventDefault(); const files = Array.from(event.dataTransfer?.files || []); if (!files.length) return; setPendingFiles((prev) => [...prev, ...files]); }; 1069 - const handleComposerDragOver = (event) => { event.preventDefault(); }; 1070 - const removePendingFile = (index) => { setPendingFiles((prev) => prev.filter((_, fileIndex) => fileIndex !== index)); }; 1071 - const uploadFiles = async (files = []) => { const ids = []; for (const file of files) { const body = new FormData(); body.append('file', file); const res = await fetch(`${config.cdnUrl}/attachments`, { method: 'POST', body, headers: { 'X-Session-Token': auth.token } }); if (!res.ok) { throw new Error('Failed to upload file'); } const data = await res.json(); if (data?.id) ids.push(data.id); } return ids; }; 1072 - const createServer = async () => { if (!createServerName.trim()) return; 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 {} }; 1073 - const updateServer = async () => { if (!selectedServerId || selectedServerId === '@me' || !editServerName.trim()) return; setIsUpdatingServer(true); 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); } }; 1074 - const deleteServer = async () => { if (!selectedServerId || selectedServerId === '@me') return; if (!confirm(`Are you absolutely sure you want to delete ${selectedServer?.name}? This cannot be undone.`)) return; setIsUpdatingServer(true); try { await fetch(`${config.apiUrl}/servers/${selectedServerId}`, { method: 'DELETE', headers: { 'x-session-token': auth.token } }); setActiveModal(null); } catch {} finally { setIsUpdatingServer(false); } }; 1435 + 1436 + const openStatusEditor = () => { 1437 + const currentPresence = users[auth.userId]?.status?.presence || 'Online'; 1438 + const currentText = users[auth.userId]?.status?.text || ''; 1439 + setStatusDraft({ presence: currentPresence, text: currentText }); 1440 + setActiveModal('set-status'); 1441 + }; 1442 + 1443 + const saveStatus = async () => { 1444 + setIsSavingStatus(true); 1445 + setUsers((prev) => ({ ...prev, [auth.userId]: { ...(prev[auth.userId] || {}), status: { presence: statusDraft.presence, text: statusDraft.text || undefined } } })); 1446 + try { 1447 + 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 } }) }); 1448 + } catch {} finally { setIsSavingStatus(false); setActiveModal(null); } 1449 + }; 1450 + 1451 + // ── Auth handlers ───────────────────────────────────────────────────────── 1452 + const handleLogin = async (event) => { 1453 + event.preventDefault(); 1454 + setLoginError(''); 1455 + if (!privacyConsent) { setLoginError('Please accept the privacy policy before signing in.'); return; } 1456 + setIsLoggingIn(true); 1457 + try { 1458 + await discoverConfig(config.apiUrl); 1459 + let token = manualToken.trim(), userId = null; 1460 + if (loginMode === 'credentials') { 1461 + const payload = { email, password, friendly_name: 'Ermine Web Client' }; 1462 + if (mfaCode.trim()) payload.mfa_response = { totp_code: mfaCode.trim() }; 1463 + const res = await fetch(`${config.apiUrl}/auth/session/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); 1464 + if (!res.ok) throw new Error(res.status === 401 ? 'Invalid credentials or MFA code.' : 'Login failed.'); 1465 + const data = await res.json(); 1466 + token = data.token || data.session_token; 1467 + userId = data.user_id; 1468 + } else { 1469 + const meRes = await fetch(`${config.apiUrl}/users/@me`, { headers: { 'x-session-token': token } }); 1470 + if (!meRes.ok) throw new Error('Token is invalid.'); 1471 + const meData = await meRes.json(); 1472 + userId = meData._id; 1473 + } 1474 + if (!token || !userId) throw new Error('Session creation failed.'); 1475 + setCookie(TOKEN_COOKIE_NAME, token); 1476 + setCookie(USER_COOKIE_NAME, userId); 1477 + setCookie(API_COOKIE_NAME, config.apiUrl); 1478 + setAuth({ token, userId }); 1479 + setView('app'); 1480 + } catch (err) { 1481 + setLoginError(err.message || 'Unknown login error'); 1482 + } finally { 1483 + setIsLoggingIn(false); 1484 + } 1485 + }; 1486 + 1487 + const logout = () => { 1488 + clearCookie(TOKEN_COOKIE_NAME); clearCookie(USER_COOKIE_NAME); clearCookie(API_COOKIE_NAME); 1489 + localStorage.removeItem('stoat_token'); localStorage.removeItem('stoat_user_id'); localStorage.removeItem('stoat_api_url'); 1490 + setAuth({ token: null, userId: null }); 1491 + setMessages({}); setServers({}); setChannels({}); setUsers({}); setMembers({}); 1492 + preloadedChannelRef.current = {}; 1493 + preloadedMembersRef.current = {}; 1494 + pendingUserFetchRef.current = new Set(); 1495 + membersLoadIdRef.current = 0; 1496 + canFetchMissingUsersRef.current = true; 1497 + loggedMissingUserFetchErrorRef.current = false; 1498 + setIsMembersLoading(false); 1499 + setWsReconnectAttempt(0); 1500 + if (wsReconnectTimeoutRef.current) { clearTimeout(wsReconnectTimeoutRef.current); wsReconnectTimeoutRef.current = null; } 1501 + setView('login'); setStatus('disconnected'); 1502 + }; 1503 + 1504 + // ── Messaging handlers ──────────────────────────────────────────────────── 1505 + const sendMessage = async () => { 1506 + const content = inputText.trim(); 1507 + const hasFiles = pendingFiles.length > 0; 1508 + if ((!content && !hasFiles) || !selectedChannelId || selectedChannelId === 'friends') return; 1509 + setInputText(''); 1510 + setIsUploadingFiles(hasFiles); 1511 + const nonce = crypto.randomUUID(); 1512 + const pendingId = `pending-${nonce}`; 1513 + const meUser = users[auth.userId] || { _id: auth.userId, username: 'You' }; 1514 + const optimisticMessage = { 1515 + _id: pendingId, channel: selectedChannelId, author: auth.userId, user: meUser, content, 1516 + createdAt: new Date().toISOString(), reactions: {}, 1517 + replies: replyingTo ? [{ id: replyingTo._id, mention: false }] : undefined, 1518 + attachments: pendingFiles.map((file, i) => ({ _id: `pending-attachment-${i}-${file.name}`, filename: file.name })), 1519 + }; 1520 + setMessages((prev) => { 1521 + const list = prev[selectedChannelId] || []; 1522 + return { ...prev, [selectedChannelId]: uniqueMessages([...list, optimisticMessage]).slice(-200) }; 1523 + }); 1524 + try { 1525 + const attachmentIds = hasFiles ? await uploadFiles(pendingFiles) : []; 1526 + const payload = { content, nonce, replies: replyingTo ? [{ id: replyingTo._id, mention: false }] : undefined, attachments: attachmentIds.length ? attachmentIds : undefined }; 1527 + 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) }); 1528 + if (!res.ok) throw new Error('Failed to send message'); 1529 + const posted = await res.json(); 1530 + if (posted?._id) { 1531 + upsertUsersFromMessages([posted]); 1532 + setMessages((prev) => { 1533 + const list = (prev[selectedChannelId] || []).filter((m) => m._id !== pendingId); 1534 + const deduped = list.some((m) => m._id === posted._id) ? list.map((m) => m._id === posted._id ? posted : m) : [...list, posted]; 1535 + return { ...prev, [selectedChannelId]: uniqueMessages(deduped.sort((a, b) => a._id > b._id ? 1 : -1)) }; 1536 + }); 1537 + } 1538 + setPendingFiles([]); setReplyingTo(null); setShowEmojiPicker(false); 1539 + } catch { 1540 + setMessages((prev) => ({ ...prev, [selectedChannelId]: (prev[selectedChannelId] || []).filter((m) => m._id !== pendingId) })); 1541 + setInputText(content); 1542 + } finally { 1543 + setIsUploadingFiles(false); 1544 + } 1545 + }; 1546 + 1547 + const editMessage = async (messageId, newContent) => { 1548 + try { 1549 + 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 }) }); 1550 + if (!res.ok) throw new Error('Failed to edit'); 1551 + } catch (err) { console.error(err); } 1552 + }; 1553 + 1554 + const deleteMessage = async (messageId) => { 1555 + if (!confirm('Are you sure you want to delete this message?')) return; 1556 + try { 1557 + await fetch(`${config.apiUrl}/channels/${selectedChannelId}/messages/${messageId}`, { method: 'DELETE', headers: { 'x-session-token': auth.token } }); 1558 + } catch (err) { console.error(err); } 1559 + }; 1560 + 1561 + const toggleReaction = async (message, emoji, reacted) => { 1562 + if (!message?._id || !selectedChannelId || selectedChannelId === 'friends') return; 1563 + const encodedEmoji = encodeURIComponent(emoji); 1564 + setMessages((prev) => { 1565 + const nextList = (prev[selectedChannelId] || []).map((entry) => { 1566 + if (entry._id !== message._id) return entry; 1567 + const existing = entry.reactions?.[emoji] || []; 1568 + const nextUserIds = reacted ? existing.filter((id) => id !== auth.userId) : [...new Set([...existing, auth.userId])]; 1569 + return { ...entry, reactions: { ...(entry.reactions || {}), [emoji]: nextUserIds } }; 1570 + }); 1571 + return { ...prev, [selectedChannelId]: nextList }; 1572 + }); 1573 + try { 1574 + await fetch(`${config.apiUrl}/channels/${selectedChannelId}/messages/${message._id}/reactions/${encodedEmoji}`, { method: reacted ? 'DELETE' : 'PUT', headers: { 'x-session-token': auth.token } }); 1575 + } catch {} 1576 + }; 1577 + 1578 + const addEmojiToComposer = (emoji) => { setInputText((p) => `${p}${emoji}`); setShowEmojiPicker(false); }; 1579 + const addCustomEmojiToComposer = (emojiId) => { setInputText((p) => `${p}:${emojiId}:`); setShowEmojiPicker(false); }; 1580 + 1581 + const handleFilePick = (e) => { const files = Array.from(e.target.files || []); if (!files.length) return; setPendingFiles((p) => [...p, ...files]); e.target.value = ''; }; 1582 + const handleComposerDrop = (e) => { e.preventDefault(); const files = Array.from(e.dataTransfer?.files || []); if (files.length) setPendingFiles((p) => [...p, ...files]); }; 1583 + const handleComposerDragOver = (e) => e.preventDefault(); 1584 + const removePendingFile = (index) => setPendingFiles((p) => p.filter((_, i) => i !== index)); 1585 + 1586 + const uploadFiles = async (files = []) => { 1587 + const ids = []; 1588 + for (const file of files) { 1589 + const body = new FormData(); body.append('file', file); 1590 + const res = await fetch(`${config.cdnUrl}/attachments`, { method: 'POST', body, headers: { 'X-Session-Token': auth.token } }); 1591 + if (!res.ok) throw new Error('Failed to upload file'); 1592 + const data = await res.json(); 1593 + if (data?.id) ids.push(data.id); 1594 + } 1595 + return ids; 1596 + }; 1597 + 1598 + const createServer = async () => { 1599 + if (!createServerName.trim()) return; 1600 + try { 1601 + 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() }) }); 1602 + if (res.ok) { setCreateServerName(''); setActiveModal(null); } 1603 + } catch {} 1604 + }; 1605 + 1606 + const updateServer = async () => { 1607 + if (!selectedServerId || selectedServerId === '@me' || !editServerName.trim()) return; 1608 + setIsUpdatingServer(true); 1609 + try { 1610 + 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() }) }); 1611 + if (res.ok) setActiveModal(null); 1612 + } catch {} finally { setIsUpdatingServer(false); } 1613 + }; 1614 + 1615 + const deleteServer = async () => { 1616 + if (!selectedServerId || selectedServerId === '@me') return; 1617 + if (!confirm(`Are you absolutely sure you want to delete "${selectedServer?.name}"? This cannot be undone.`)) return; 1618 + setIsUpdatingServer(true); 1619 + try { await fetch(`${config.apiUrl}/servers/${selectedServerId}`, { method: 'DELETE', headers: { 'x-session-token': auth.token } }); setActiveModal(null); } 1620 + catch {} finally { setIsUpdatingServer(false); } 1621 + }; 1622 + 1075 1623 const openServerSettings = () => { if (selectedServer) { setEditServerName(selectedServer.name); setActiveModal('server-settings'); } }; 1076 1624 1077 - if (view === 'loading') return <div className="grid h-screen place-items-center bg-[#0F1115] text-[#8AB4F8]"><Hash className="animate-spin" size={42} /></div>; 1625 + // ── Loading screen ──────────────────────────────────────────────────────── 1626 + if (view === 'loading') 1627 + return <div className="grid h-screen place-items-center bg-[#0F1115] text-[#8AB4F8]"><Hash className="animate-spin" size={42} /></div>; 1078 1628 1629 + // ── Login screen ────────────────────────────────────────────────────────── 1079 1630 if (view === 'login') { 1080 1631 return ( 1081 1632 <div className="min-h-screen bg-[#0F1115] px-4 py-10 text-[#E6EDF3]"> 1082 1633 <div className="relative mx-auto max-w-md rounded-2xl border border-[#242A35] bg-[#171A21] p-8 shadow-2xl"> 1083 1634 <div className="absolute right-4 top-4"> 1084 - <span className="rounded-full border border-[#242A35] bg-[#141821] px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-[#B6D8F6]" title="This client is under active development. Some features may be incomplete.">Experimental Build</span> 1635 + <span className="rounded-full border border-[#242A35] bg-[#141821] px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-[#B6D8F6]" title="Under active development"> 1636 + Experimental Build 1637 + </span> 1085 1638 </div> 1086 1639 <div className="mb-6 text-center"> 1087 1640 <div className="mx-auto mb-3 h-16 w-16 overflow-hidden rounded-2xl border border-[#242A35] bg-[#141821]"> 1088 - <img 1089 - alt="Ermine logo" 1090 - className="h-full w-full object-cover" 1091 - src="/assets/android-chrome-192x192.png" 1092 - /> 1641 + <img alt="Ermine logo" className="h-full w-full object-cover" src="/assets/android-chrome-192x192.png" /> 1093 1642 </div> 1094 1643 <h1 className="text-2xl font-bold text-white">Ermine</h1> 1095 1644 <p className="text-sm text-[#A6B0C3]">A refined client for stoat.chat.</p> 1096 1645 <p className="mt-1 text-xs text-[#8892A6]">Clean layout. Familiar flow. Experimental build.</p> 1097 1646 </div> 1098 1647 <div className="mb-5 grid grid-cols-2 gap-2 rounded-lg bg-[#141821] p-1 text-xs font-semibold uppercase tracking-wide"> 1099 - <button className={`rounded py-2 ${loginMode === 'credentials' ? 'bg-[#8AB4F8] text-[#0F1115]' : 'text-gray-400 hover:text-white'}`} onClick={() => setLoginMode('credentials')} type="button">Credentials</button> 1100 - <button className={`rounded py-2 ${loginMode === 'token' ? 'bg-[#8AB4F8] text-[#0F1115]' : 'text-gray-400 hover:text-white'}`} onClick={() => setLoginMode('token')} type="button">Token</button> 1648 + <button className={`rounded py-2 transition-colors ${loginMode === 'credentials' ? 'bg-[#8AB4F8] text-[#0F1115]' : 'text-gray-400 hover:text-white'}`} onClick={() => setLoginMode('credentials')} type="button">Credentials</button> 1649 + <button className={`rounded py-2 transition-colors ${loginMode === 'token' ? 'bg-[#8AB4F8] text-[#0F1115]' : 'text-gray-400 hover:text-white'}`} onClick={() => setLoginMode('token')} type="button">Token</button> 1101 1650 </div> 1102 1651 <form className="space-y-4" onSubmit={handleLogin}> 1103 1652 {loginMode === 'credentials' ? ( 1104 1653 <> 1105 1654 <input className={inputBase} onChange={(e) => setEmail(e.target.value)} placeholder="Email" type="email" value={email} /> 1106 1655 <input className={inputBase} onChange={(e) => setPassword(e.target.value)} placeholder="Password" type="password" value={password} /> 1107 - <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> 1656 + <label className="block text-xs font-semibold uppercase tracking-wide text-gray-400"> 1657 + <span className="mb-1 flex items-center gap-1"><ShieldCheck size={12} /> MFA</span> 1658 + <input className={inputBase} onChange={(e) => setMfaCode(e.target.value)} placeholder="Optional 2FA code" type="text" value={mfaCode} /> 1659 + </label> 1108 1660 </> 1109 1661 ) : ( 1110 1662 <textarea className={`${inputBase} h-24 resize-none`} onChange={(e) => setManualToken(e.target.value)} placeholder="Paste session token" value={manualToken} /> 1111 1663 )} 1112 - <label className="flex items-start gap-2 rounded-md border border-[#242A35] bg-[#141821] p-2 text-xs text-[#A6B0C3]"> 1664 + <label className="flex items-start gap-2 rounded-md border border-[#242A35] bg-[#141821] p-2 text-xs text-[#A6B0C3] cursor-pointer"> 1113 1665 <input checked={privacyConsent} className="mt-0.5" onChange={(e) => setPrivacyConsent(e.target.checked)} type="checkbox" /> 1114 1666 <span>I agree to the <a className="text-[#B6D8F6] underline hover:text-[#E6F0FF]" href="/privacy-policy.html" rel="noreferrer" target="_blank">privacy policy</a>. Ermine does not store user content outside configured stoat.chat endpoints.</span> 1115 1667 </label> 1116 - {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} 1117 - <button className="w-full rounded-md bg-[#8AB4F8] py-2.5 text-sm font-semibold text-[#0F1115] hover:bg-[#6FA6E8]" disabled={isLoggingIn} type="submit">{isLoggingIn ? 'Signing in…' : 'Log in to Ermine'}</button> 1668 + {loginError ? ( 1669 + <div className="flex items-start gap-2 rounded-md border border-red-800 bg-red-900/30 p-2 text-sm text-red-200"> 1670 + <AlertCircle className="mt-0.5 shrink-0" size={15} /><span>{loginError}</span> 1671 + </div> 1672 + ) : null} 1673 + <button 1674 + className="w-full rounded-md bg-[#8AB4F8] py-2.5 text-sm font-semibold text-[#0F1115] hover:bg-[#6FA6E8] disabled:opacity-60 transition-opacity flex items-center justify-center gap-2" 1675 + disabled={isLoggingIn} 1676 + type="submit" 1677 + > 1678 + {isLoggingIn ? <><Loader className="animate-spin" size={14} /> Signing in…</> : 'Log in to Ermine'} 1679 + </button> 1118 1680 </form> 1119 1681 <div className="mt-5 border-t border-[#202225] pt-4"> 1120 - <button className="flex items-center gap-1 text-xs text-[#A6B0C3] hover:text-[#E6EDF3]" onClick={() => setShowAdvanced((prev) => !prev)} type="button"><Settings size={12} /> Advanced connection configuration</button> 1682 + <button className="flex items-center gap-1 text-xs text-[#A6B0C3] hover:text-[#E6EDF3] transition-colors" onClick={() => setShowAdvanced((p) => !p)} type="button"> 1683 + <Settings size={12} /> Advanced connection configuration 1684 + </button> 1121 1685 {showAdvanced ? ( 1122 1686 <div className="mt-2 space-y-2 rounded-md bg-[#141821] p-2"> 1123 - <input className={inputBase} onChange={(e) => setConfig((prev) => ({ ...prev, apiUrl: e.target.value }))} placeholder="API URL" value={config.apiUrl} /> 1124 - <input className={inputBase} onChange={(e) => setConfig((prev) => ({ ...prev, wsUrl: e.target.value }))} placeholder="WS URL" value={config.wsUrl} /> 1125 - <input className={inputBase} onChange={(e) => setConfig((prev) => ({ ...prev, cdnUrl: e.target.value }))} placeholder="CDN URL" value={config.cdnUrl} /> 1687 + <input className={inputBase} onChange={(e) => setConfig((p) => ({ ...p, apiUrl: e.target.value }))} placeholder="API URL" value={config.apiUrl} /> 1688 + <input className={inputBase} onChange={(e) => setConfig((p) => ({ ...p, wsUrl: e.target.value }))} placeholder="WS URL" value={config.wsUrl} /> 1689 + <input className={inputBase} onChange={(e) => setConfig((p) => ({ ...p, cdnUrl: e.target.value }))} placeholder="CDN URL" value={config.cdnUrl} /> 1126 1690 </div> 1127 1691 ) : null} 1128 1692 </div> ··· 1131 1695 ); 1132 1696 } 1133 1697 1698 + // ── App shell ───────────────────────────────────────────────────────────── 1134 1699 return ( 1135 1700 <div className="flex h-screen overflow-hidden bg-[#0F1115] text-[#E6EDF3]"> 1701 + 1702 + {/* ── Modals ── */} 1136 1703 {activeModal === 'create-server' ? ( 1137 1704 <Modal onClose={() => setActiveModal(null)} title="Create a Space"> 1138 - <input className={inputBase} onChange={(e) => setCreateServerName(e.target.value)} placeholder="Space name" value={createServerName} /> 1139 - <button className="w-full rounded-md bg-[#3ba55d] py-2 text-sm font-semibold text-white hover:bg-[#328a4f]" onClick={createServer}>Create Space</button> 1705 + <input className={inputBase} onChange={(e) => setCreateServerName(e.target.value)} placeholder="Space name" value={createServerName} autoFocus /> 1706 + <button className="w-full rounded-md bg-[#3ba55d] py-2 text-sm font-semibold text-white hover:bg-[#328a4f] transition-colors" onClick={createServer}>Create Space</button> 1140 1707 </Modal> 1141 1708 ) : null} 1142 - 1709 + 1143 1710 {activeModal === 'server-settings' && selectedServer ? ( 1144 1711 <Modal onClose={() => setActiveModal(null)} title="Space Settings"> 1145 - <div className="space-y-4"> 1146 - <div> 1147 - <label className="block text-xs font-semibold uppercase tracking-wide text-gray-400 mb-2">Space Name</label> 1148 - <div className="flex gap-2"> 1149 - <input className={inputBase} onChange={(e) => setEditServerName(e.target.value)} value={editServerName} placeholder="Enter space name" /> 1150 - <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> 1151 - </div> 1152 - </div> 1153 - <div className="border-t border-[#202225] pt-4 mt-4"> 1154 - <h4 className="text-xs font-bold text-red-400 uppercase tracking-wide mb-2">Danger Zone</h4> 1155 - <div className="flex items-center justify-between rounded border border-red-900/50 p-3 bg-red-900/10"> 1156 - <div><div className="font-semibold text-white">Delete Space</div><div className="text-xs text-gray-400">Permanently remove this space and all its contents.</div></div> 1157 - <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 Space</button> 1158 - </div> 1159 - </div> 1712 + <div> 1713 + <label className="block text-xs font-semibold uppercase tracking-wide text-gray-400 mb-2">Space Name</label> 1714 + <div className="flex gap-2"> 1715 + <input className={inputBase} onChange={(e) => setEditServerName(e.target.value)} value={editServerName} placeholder="Enter space name" /> 1716 + <button onClick={updateServer} disabled={isUpdatingServer || !editServerName.trim()} className="rounded bg-[#5865f2] px-3 text-white hover:bg-[#4956d8] disabled:opacity-50 transition-colors"><Save size={18} /></button> 1717 + </div> 1718 + </div> 1719 + <div className="border-t border-[#202225] pt-3"> 1720 + <h4 className="text-xs font-bold text-red-400 uppercase tracking-wide mb-2">Danger Zone</h4> 1721 + <div className="flex items-center justify-between rounded border border-red-900/50 p-3 bg-red-900/10"> 1722 + <div><div className="font-semibold text-white text-sm">Delete Space</div><div className="text-xs text-gray-400">Permanently remove this space and all its contents.</div></div> 1723 + <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</button> 1724 + </div> 1160 1725 </div> 1161 1726 </Modal> 1162 1727 ) : null} 1163 - 1728 + 1164 1729 {activeModal === 'discovery' ? ( 1165 1730 <Modal onClose={() => setActiveModal(null)} title="Space Discovery"> 1166 - <div className="p-4 text-center"> 1167 - <Search size={48} className="mx-auto text-green-500 mb-4" /> 1168 - <h3 className="text-lg font-bold text-white mb-2">Explore Communities</h3> 1169 - <p className="text-sm text-gray-400"> 1170 - Server discovery is coming soon to Ermine! Stay tuned to find new communities. 1171 - </p> 1172 - </div> 1731 + <div className="p-4 text-center"> 1732 + <Search size={48} className="mx-auto text-green-500 mb-4" /> 1733 + <h3 className="text-lg font-bold text-white mb-2">Explore Communities</h3> 1734 + <p className="text-sm text-gray-400">Server discovery is coming soon to Ermine!</p> 1735 + </div> 1173 1736 </Modal> 1174 1737 ) : null} 1175 1738 ··· 1179 1742 <p className="mb-2 text-xs font-semibold uppercase tracking-wide text-gray-400">Presence</p> 1180 1743 <div className="grid grid-cols-1 gap-2"> 1181 1744 {STATUS_OPTIONS.map((option) => ( 1182 - <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"> 1183 - <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> 1745 + <button 1746 + className={`flex items-center gap-3 rounded-lg border px-3 py-2 text-left transition-colors ${statusDraft.presence === option.value ? 'border-[#5865f2] bg-[#2c3163]/45' : 'border-[#2f3237] bg-[#1f2024] hover:border-[#3f4451] hover:bg-[#282a31]'}`} 1747 + key={option.value} 1748 + onClick={() => setStatusDraft((p) => ({ ...p, presence: option.value }))} 1749 + type="button" 1750 + > 1751 + <span className={`p-1.5 ${option.iconClass}`}><option.icon size={15} /></span> 1184 1752 <span className="text-sm text-gray-100">{option.label}</span> 1185 1753 </button> 1186 1754 ))} 1187 1755 </div> 1188 1756 </div> 1189 - <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> 1190 - <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> 1757 + <label className="block text-xs font-semibold uppercase tracking-wide text-gray-400"> 1758 + Custom status 1759 + <input className={`${inputBase} mt-1`} maxLength={128} onChange={(e) => setStatusDraft((p) => ({ ...p, text: e.target.value }))} placeholder="What's up?" value={statusDraft.text} /> 1760 + </label> 1761 + <button className="w-full rounded-md bg-[#5865f2] py-2 text-sm font-semibold text-white hover:bg-[#4956d8] transition-colors" disabled={isSavingStatus} onClick={saveStatus} type="button"> 1762 + {isSavingStatus ? 'Saving…' : 'Save status'} 1763 + </button> 1191 1764 </Modal> 1192 1765 ) : null} 1193 1766 1194 1767 {activeModal === 'user-settings' ? ( 1195 1768 <Modal onClose={() => setActiveModal(null)} title="Preferences"> 1196 - <div className="space-y-6"> 1197 - <div className="flex flex-col items-center"> 1198 - <div className="w-full h-24 rounded-t-lg bg-gray-700 overflow-hidden relative"> 1199 - {getBannerUrl(users[auth.userId], config.cdnUrl) ? ( 1200 - <img src={getBannerUrl(users[auth.userId], config.cdnUrl)} alt="Banner" className="w-full h-full object-cover rounded-t-lg" /> 1201 - ) : ( 1202 - <div className="w-full h-full bg-gradient-to-r from-[#3b3f6b] to-[#5865f2]" /> 1203 - )} 1204 - <div className="absolute -bottom-8 left-4 p-1 bg-[#1e1f22] rounded-full"> 1205 - <Avatar user={users[auth.userId]} cdnUrl={config.cdnUrl} size="lg" /> 1206 - </div> 1207 - </div> 1208 - <div className="w-full px-4 pt-10"> 1209 - <h2 className="text-xl font-bold text-white">{users[auth.userId]?.username || 'User'}</h2> 1210 - <p className="text-xs text-gray-400">#{users[auth.userId]?.discriminator || '0000'}</p> 1211 - </div> 1769 + <div className="space-y-6"> 1770 + <div className="flex flex-col items-center"> 1771 + <div className="w-full h-24 rounded-t-lg bg-gray-700 overflow-hidden relative"> 1772 + {getBannerUrl(users[auth.userId], config.cdnUrl) 1773 + ? <img src={getBannerUrl(users[auth.userId], config.cdnUrl)} alt="Banner" className="w-full h-full object-cover" /> 1774 + : <div className="w-full h-full bg-gradient-to-r from-[#3b3f6b] to-[#5865f2]" /> 1775 + } 1776 + <div className="absolute -bottom-8 left-4 p-1 bg-[#1e1f22] rounded-full"> 1777 + <Avatar user={users[auth.userId]} cdnUrl={config.cdnUrl} size="lg" /> 1778 + </div> 1779 + </div> 1780 + <div className="w-full px-4 pt-10"> 1781 + <h2 className="text-xl font-bold text-white">{users[auth.userId]?.username || 'User'}</h2> 1782 + <p className="text-xs text-gray-400">#{users[auth.userId]?.discriminator || '0000'}</p> 1783 + </div> 1784 + </div> 1785 + <div className="border-t border-[#2f3237] pt-4 px-4 space-y-3"> 1786 + <h3 className="text-xs font-bold text-gray-400 uppercase tracking-wide">About Ermine</h3> 1787 + <div className="flex items-start gap-3 bg-[#2b2d31] p-3 rounded-lg"> 1788 + <div className="p-2 bg-[#5865f2] rounded-lg"><Smile size={20} className="text-white" /></div> 1789 + <div> 1790 + <h4 className="text-sm font-bold text-white">Support Stoat Chat</h4> 1791 + <p className="text-xs text-gray-400 mt-1">Ermine is built for the Stoat community. Consider donating to keep the lights on!</p> 1792 + <a href="https://ko-fi.com/stoatchat" target="_blank" rel="noopener noreferrer" className="inline-block mt-2 text-xs font-semibold text-[#5865f2] hover:underline">Donate on Ko-fi →</a> 1793 + </div> 1212 1794 </div> 1213 - 1214 - <div className="border-t border-[#2f3237] pt-4 px-4"> 1215 - <h3 className="text-xs font-bold text-gray-400 uppercase tracking-wide mb-3">About Ermine</h3> 1216 - <div className="flex items-start gap-3 bg-[#2b2d31] p-3 rounded-lg mb-3"> 1217 - <div className="p-2 bg-[#5865f2] rounded-lg"> 1218 - <Smile size={20} className="text-white" /> 1219 - </div> 1220 - <div> 1221 - <h4 className="text-sm font-bold text-white">Support Stoat Chat</h4> 1222 - <p className="text-xs text-gray-400 mt-1">Ermine is a passionate project built for the Stoat community. Consider donating to keep the lights on!</p> 1223 - <a href="https://ko-fi.com/stoatchat" target="_blank" rel="noopener noreferrer" className="inline-block mt-2 text-xs font-semibold text-[#5865f2] hover:underline">Donate on Ko-fi &rarr;</a> 1224 - </div> 1225 - </div> 1226 - 1227 - <div className="flex items-start gap-3 bg-[#2b2d31] p-3 rounded-lg"> 1228 - <div className="p-2 bg-gray-700 rounded-lg"> 1229 - <AlertCircle size={20} className="text-white" /> 1230 - </div> 1231 - <div> 1232 - <h4 className="text-sm font-bold text-white">Client Info</h4> 1233 - <p className="text-xs text-gray-400 mt-1"> 1234 - Version: 1.2.0 (Wii U Compatible)<br/> 1235 - Mode: {isLowSpec ? 'Lite (Performance Mode)' : 'Standard'} 1236 - </p> 1237 - </div> 1238 - </div> 1795 + <div className="flex items-start gap-3 bg-[#2b2d31] p-3 rounded-lg"> 1796 + <div className="p-2 bg-gray-700 rounded-lg"><AlertCircle size={20} className="text-white" /></div> 1797 + <div> 1798 + <h4 className="text-sm font-bold text-white">Client Info</h4> 1799 + <p className="text-xs text-gray-400 mt-1"> 1800 + Version: 1.3.0<br /> 1801 + Mode: {isLowSpec ? 'Lite (Performance)' : 'Standard'} 1802 + </p> 1803 + </div> 1239 1804 </div> 1240 - </div> 1805 + </div> 1806 + </div> 1241 1807 </Modal> 1242 1808 ) : null} 1243 1809 ··· 1249 1815 <p className="mt-2 text-xs text-gray-400">Only open links from contacts and spaces you trust.</p> 1250 1816 </div> 1251 1817 <div className="grid grid-cols-2 gap-2"> 1252 - <button className="rounded-md bg-[#3a3d42] py-2 text-sm font-semibold text-gray-200 hover:bg-[#4a4d55]" onClick={() => setLinkPromptUrl(null)} type="button">Cancel</button> 1253 - <button className="rounded-md bg-[#5865f2] py-2 text-sm font-semibold text-white hover:bg-[#4956d8]" onClick={confirmOpenLink} type="button">Open link</button> 1818 + <button className="rounded-md bg-[#3a3d42] py-2 text-sm font-semibold text-gray-200 hover:bg-[#4a4d55] transition-colors" onClick={() => setLinkPromptUrl(null)} type="button">Cancel</button> 1819 + <button className="rounded-md bg-[#5865f2] py-2 text-sm font-semibold text-white hover:bg-[#4956d8] transition-colors" onClick={confirmOpenLink} type="button">Open link</button> 1254 1820 </div> 1255 1821 </Modal> 1256 1822 ) : null} ··· 1258 1824 {peekUser ? ( 1259 1825 <Modal onClose={() => setPeekUser(null)} title="Member profile"> 1260 1826 <div className="overflow-hidden rounded-lg bg-[#1e1f22]"> 1261 - {peekBannerUrl ? <img alt="Profile banner" className="h-24 w-full object-cover" src={peekBannerUrl} /> : <div className="h-20 w-full bg-gradient-to-r from-[#3b3f6b] to-[#5865f2]" />} 1827 + {peekBannerUrl 1828 + ? <img alt="Profile banner" className="h-24 w-full object-cover" src={peekBannerUrl} /> 1829 + : <div className="h-20 w-full bg-gradient-to-r from-[#3b3f6b] to-[#5865f2]" /> 1830 + } 1262 1831 <div className="flex items-center gap-3 p-3"> 1263 1832 <Avatar alwaysAnimate cdnUrl={config.cdnUrl} size="lg" user={peekUser} /> 1264 1833 <div> 1265 1834 <div className="text-base font-semibold text-white">{peekUser?.username || 'Loading user…'}</div> 1266 1835 <div className="text-xs text-gray-400">#{peekUser?.discriminator || '0000'}</div> 1267 1836 </div> 1837 + {peekUser?._id && peekUser._id !== auth.userId && ( 1838 + <button className="ml-auto rounded bg-[#5865f2] px-3 py-1.5 text-xs font-semibold text-white hover:bg-[#4956d8] transition-colors" onClick={() => { openDmWithUser(peekUser._id); setPeekUser(null); }} type="button">Message</button> 1839 + )} 1268 1840 </div> 1269 1841 </div> 1270 - <div className="grid grid-cols-1 gap-2 rounded-md bg-[#1e1f22] p-3 text-xs text-gray-300"> 1271 - <p><span className="font-semibold text-gray-100">Joined platform:</span> {toDateLabel(peekPlatformJoined)}</p> 1272 - <p><span className="font-semibold text-gray-100">Joined space:</span> {selectedServerId === '@me' ? 'N/A' : toDateLabel(peekServerJoined)}</p> 1842 + <div className="grid grid-cols-2 gap-2 rounded-md bg-[#1e1f22] p-3 text-xs text-gray-300"> 1843 + <p><span className="font-semibold text-gray-100">Joined platform:</span><br />{toDateLabel(peekPlatformJoined)}</p> 1844 + <p><span className="font-semibold text-gray-100">Joined space:</span><br />{selectedServerId === '@me' ? 'N/A' : toDateLabel(peekServerJoined)}</p> 1273 1845 </div> 1274 1846 <div className="space-y-1 rounded-md bg-[#1e1f22] p-3"> 1275 1847 <p className="text-[11px] font-semibold uppercase tracking-wide text-gray-500">About me</p> 1276 - <p className="text-sm text-gray-200">{peekAboutMe || 'This user has not set an About Me yet.'}</p> 1848 + <p className="text-sm text-gray-200">{peekAboutMe || 'No about me set.'}</p> 1277 1849 </div> 1278 - <div className="space-y-1 rounded-md bg-[#1e1f22] p-3"> 1279 - <p className="text-[11px] font-semibold uppercase tracking-wide text-gray-500">Badges</p> 1280 - {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>} 1281 - </div> 1282 - <div className="space-y-1 rounded-md bg-[#1e1f22] p-3"> 1283 - <p className="text-[11px] font-semibold uppercase tracking-wide text-gray-500">Roles</p> 1284 - {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 space roles</p>} 1285 - </div> 1850 + {peekBadges.length > 0 && ( 1851 + <div className="space-y-1 rounded-md bg-[#1e1f22] p-3"> 1852 + <p className="text-[11px] font-semibold uppercase tracking-wide text-gray-500">Badges</p> 1853 + <div className="flex flex-wrap gap-1.5">{peekBadges.map((b) => <span className="rounded bg-[#5865f2]/20 px-2 py-1 text-xs text-[#d7ddff]" key={b}>{b}</span>)}</div> 1854 + </div> 1855 + )} 1856 + {peekRoles.length > 0 && ( 1857 + <div className="space-y-1 rounded-md bg-[#1e1f22] p-3"> 1858 + <p className="text-[11px] font-semibold uppercase tracking-wide text-gray-500">Roles</p> 1859 + <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} style={{ borderLeft: `3px solid ${role.colour || '#4c4f56'}` }}>{role.name}</span>)}</div> 1860 + </div> 1861 + )} 1286 1862 </Modal> 1287 1863 ) : null} 1288 1864 1289 - <aside className="flex w-[72px] flex-col items-center gap-2 overflow-y-auto bg-[#111214] py-3"> 1290 - <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')}> 1865 + {/* ── Server rail ── */} 1866 + <aside className="flex w-[72px] flex-col items-center gap-2 overflow-y-auto bg-[#111214] py-3 shrink-0"> 1867 + <button 1868 + className={`grid h-12 w-12 place-items-center transition-all duration-150 ${selectedServerId === '@me' ? 'rounded-2xl bg-[#5865f2]' : 'rounded-full bg-[#313338] hover:rounded-2xl hover:bg-[#5865f2]'}`} 1869 + onClick={() => handleServerSelect('@me')} 1870 + title="Direct messages" 1871 + > 1291 1872 <MessageSquare size={20} /> 1292 1873 </button> 1293 1874 <div className="h-px w-8 bg-[#202225]" /> 1294 - 1295 1875 {serverList.map((server) => { 1296 - const icon = getIconUrl(server, config.cdnUrl); 1876 + const icon = getIconUrl(server, config.cdnUrl); 1297 1877 const active = selectedServerId === server._id; 1298 - 1299 1878 return ( 1300 - <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}> 1301 - {icon ? <img alt={server.name} className="h-full w-full object-contain p-1" src={icon} /> : <span className="text-sm font-bold">{server.name.slice(0, 2).toUpperCase()}</span>} 1879 + <button 1880 + className={`relative grid h-12 w-12 place-items-center overflow-hidden transition-all duration-150 ${active ? 'rounded-2xl bg-[#5865f2]' : 'rounded-full bg-[#313338] hover:rounded-2xl hover:bg-[#5865f2]'}`} 1881 + key={server._id} 1882 + onClick={() => handleServerSelect(server._id)} 1883 + title={server.name} 1884 + > 1885 + {icon 1886 + ? <img alt={server.name} className="h-full w-full object-contain p-1" src={icon} /> 1887 + : <span className="text-sm font-bold">{server.name.slice(0, 2).toUpperCase()}</span> 1888 + } 1302 1889 </button> 1303 1890 ); 1304 1891 })} 1305 - 1306 - <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')}> 1892 + <button className="grid h-12 w-12 place-items-center rounded-full bg-[#313338] text-[#3ba55d] transition-all duration-150 hover:rounded-2xl hover:bg-[#3ba55d] hover:text-white" onClick={() => setActiveModal('create-server')} title="Create a space"> 1307 1893 <Plus size={20} /> 1308 1894 </button> 1309 - <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="Space Discovery"> 1310 - <Search size={20} /> 1895 + <button className="grid h-12 w-12 place-items-center rounded-full bg-[#313338] text-green-500 transition-all duration-150 hover:rounded-2xl hover:bg-green-600 hover:text-white" onClick={() => setActiveModal('discovery')} title="Space Discovery"> 1896 + <Search size={20} /> 1311 1897 </button> 1312 1898 </aside> 1313 1899 1314 - <aside className="hidden w-60 flex-col bg-[#2b2d31] md:flex"> 1900 + {/* ── Channel sidebar ── */} 1901 + <aside className={`${showMobileSidebar ? 'flex' : 'hidden'} md:flex w-60 flex-col bg-[#2b2d31] shrink-0 z-30 md:z-auto absolute md:relative h-full`}> 1315 1902 <div className="border-b border-[#202225] px-4 py-3 flex justify-between items-center"> 1316 1903 <div className="truncate text-sm font-bold text-white">{selectedServerId === '@me' ? 'Live' : servers[selectedServerId]?.name || 'Space'}</div> 1317 1904 {isServerOwner && selectedServerId !== '@me' && ( 1318 - <button 1319 - onClick={openServerSettings} 1320 - className="text-gray-400 hover:text-white transition-colors" 1321 - title="Space Settings" 1322 - > 1323 - <Settings size={16} /> 1324 - </button> 1905 + <button onClick={openServerSettings} className="text-gray-400 hover:text-white transition-colors" title="Space Settings"> 1906 + <Settings size={16} /> 1907 + </button> 1325 1908 )} 1326 1909 </div> 1327 1910 <div className="flex-1 overflow-y-auto p-2"> 1328 1911 {selectedServerId === '@me' ? ( 1329 1912 <> 1330 - <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')}> 1913 + <button 1914 + className={`mb-1 flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm transition-colors ${selectedChannelId === 'friends' ? 'bg-[#404249] text-white' : 'text-gray-400 hover:bg-[#35373c] hover:text-white'}`} 1915 + onClick={() => handleChannelSelect('friends')} 1916 + > 1331 1917 <Users size={16} /> <span>Direct</span> 1332 1918 </button> 1333 1919 {directMessageChannels.map((channel) => { 1334 1920 const recipientId = (channel.recipients || []).find((id) => id !== auth.userId); 1335 - const recipient = users[recipientId]; 1336 - const label = recipient?.username || channel.name || 'Loading direct…'; 1921 + const recipient = users[recipientId]; 1922 + const label = recipient?.username || channel.name || 'Loading direct…'; 1337 1923 return ( 1338 - <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)}> 1924 + <button 1925 + className={`mb-0.5 flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm transition-colors ${selectedChannelId === channel._id ? 'bg-[#404249] text-white' : 'text-gray-400 hover:bg-[#35373c] hover:text-white'}`} 1926 + key={channel._id} 1927 + onClick={() => handleChannelSelect(channel._id)} 1928 + > 1339 1929 <MessageSquare size={16} /> 1340 1930 <span className="truncate">{label}</span> 1341 1931 </button> ··· 1347 1937 const isVoice = channel?.channel_type === 'VoiceChannel'; 1348 1938 return ( 1349 1939 <button 1350 - 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'}`} 1940 + className={`mb-0.5 flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm transition-colors ${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'}`} 1351 1941 key={channel._id} 1352 1942 onClick={() => handleChannelSelect(channel._id)} 1353 - title={isVoice ? "Ermine currently doesn't support voice channels." : channel.name} 1943 + title={isVoice ? "Voice channels are not supported in Ermine." : channel.name} 1354 1944 > 1355 1945 {isVoice ? <Users size={16} /> : <Hash size={16} />} 1356 1946 <span className="truncate">{channel.name || 'channel'}</span> ··· 1360 1950 )} 1361 1951 </div> 1362 1952 1953 + {/* Account footer */} 1363 1954 <div className="border-t border-[#202225] p-2"> 1364 1955 <div className="flex items-center justify-between rounded bg-[#232428] p-2" onMouseEnter={() => setIsAccountHovered(true)} onMouseLeave={() => setIsAccountHovered(false)}> 1365 - <button className="flex min-w-0 items-center gap-2 rounded p-1 text-left hover:bg-[#2d3036]" onClick={openStatusEditor} type="button"> 1956 + <button className="flex min-w-0 items-center gap-2 rounded p-1 text-left hover:bg-[#2d3036] flex-1" onClick={openStatusEditor} type="button"> 1366 1957 <Avatar alwaysAnimate={isAccountHovered} cdnUrl={config.cdnUrl} size="sm" user={users[auth.userId]} /> 1367 1958 <div className="min-w-0"> 1368 1959 <div className="truncate text-xs font-semibold text-white">{users[auth.userId]?.username || 'Connected'}</div> 1369 1960 <div className="truncate text-[10px] uppercase tracking-wide text-gray-400">{users[auth.userId]?.status?.text || status}</div> 1370 1961 </div> 1371 1962 </button> 1372 - <div className="flex items-center gap-1"> 1373 - <button className="rounded p-1 text-gray-400 hover:bg-[#35373c] hover:text-white" onClick={() => setActiveModal('user-settings')} title="Preferences" type="button"> 1374 - <Settings size={14} /> 1375 - </button> 1376 - <button className="rounded p-1 text-gray-400 hover:bg-[#35373c] hover:text-white" onClick={logout} title="Logout" type="button"> 1377 - <LogOut size={14} /> 1378 - </button> 1963 + <div className="flex items-center gap-1 shrink-0"> 1964 + <button className="rounded p-1 text-gray-400 hover:bg-[#35373c] hover:text-white transition-colors" onClick={() => setActiveModal('user-settings')} title="Preferences" type="button"><Settings size={14} /></button> 1965 + <button className="rounded p-1 text-gray-400 hover:bg-[#35373c] hover:text-white transition-colors" onClick={logout} title="Logout" type="button"><LogOut size={14} /></button> 1379 1966 </div> 1380 1967 </div> 1381 1968 </div> 1382 1969 </aside> 1383 1970 1971 + {/* Mobile sidebar backdrop */} 1972 + {showMobileSidebar && <div className="fixed inset-0 z-20 bg-black/60 md:hidden" onClick={() => setShowMobileSidebar(false)} />} 1973 + 1974 + {/* ── Main content ── */} 1384 1975 <main className="relative flex min-w-0 flex-1 flex-col bg-[#313338]"> 1385 1976 <header className="flex items-center justify-between border-b border-[#202225] px-4 py-3"> 1386 - <div className="flex items-center gap-2 text-sm font-semibold text-white"> 1387 - {channels[selectedChannelId]?.channel_type === 'DirectMessage' ? <MessageSquare size={17} className="text-gray-400" /> : <Hash size={17} className="text-gray-400" />} 1388 - <span>{currentChannelName}</span> 1977 + <div className="flex items-center gap-2"> 1978 + <button className="md:hidden mr-1 text-gray-400 hover:text-white transition-colors" onClick={() => setShowMobileSidebar((p) => !p)} type="button"> 1979 + <Menu size={20} /> 1980 + </button> 1981 + <div className="flex items-center gap-2 text-sm font-semibold text-white"> 1982 + {channels[selectedChannelId]?.channel_type === 'DirectMessage' 1983 + ? <MessageSquare size={17} className="text-gray-400" /> 1984 + : <Hash size={17} className="text-gray-400" /> 1985 + } 1986 + <span>{currentChannelName}</span> 1987 + </div> 1988 + {currentChannelTopic && ( 1989 + <span className="hidden lg:block text-xs text-gray-400 border-l border-[#3f4249] pl-2 ml-1 truncate max-w-xs" title={currentChannelTopic}>{currentChannelTopic}</span> 1990 + )} 1389 1991 </div> 1390 - <div className="text-xs text-gray-400">Stoat.chat, distilled.</div> 1992 + <StatusBadge status={status} /> 1391 1993 </header> 1392 1994 1393 1995 {voiceNotice ? ( ··· 1397 1999 ) : null} 1398 2000 1399 2001 <section className="flex-1 overflow-y-auto py-2" ref={messagesContainerRef}> 2002 + {/* Load-older spinner */} 2003 + {isLoadingOlder && ( 2004 + <div className="flex items-center justify-center py-3 text-xs text-gray-400 gap-2"> 2005 + <Loader className="animate-spin" size={14} /> Loading older messages… 2006 + </div> 2007 + )} 2008 + 1400 2009 {selectedChannelId === 'friends' ? ( 1401 2010 <div className="space-y-2 p-4"> 1402 2011 <h2 className="text-xs font-bold uppercase tracking-wide text-gray-400">Direct ({friends.length})</h2> 1403 - {friends.length === 0 ? <p className="text-sm text-gray-400">No direct contacts available yet.</p> : null} 2012 + {friends.length === 0 2013 + ? <p className="text-sm text-gray-400">No direct contacts available yet.</p> 2014 + : null 2015 + } 1404 2016 {friends.map((friend) => ( 1405 - <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)}> 2017 + <button className="flex w-full items-center gap-3 rounded bg-[#2b2d31] p-3 text-left hover:bg-[#35373c] transition-colors" key={friend._id} onClick={() => openDmWithUser(friend._id)}> 1406 2018 <Avatar animateOnHover cdnUrl={config.cdnUrl} user={friend} /> 1407 2019 <div className="min-w-0"> 1408 2020 <div className="truncate text-sm font-semibold text-white">{friend.username}</div> 1409 - <div className="text-xs text-gray-400">Direct</div> 2021 + <div className="text-xs text-gray-400">{friend.status?.text || 'Direct'}</div> 1410 2022 </div> 1411 2023 </button> 1412 2024 ))} 1413 2025 </div> 2026 + ) : currentMessages.length === 0 ? ( 2027 + <div className="flex flex-col items-center justify-center h-full text-center p-8 text-gray-400"> 2028 + <Hash size={40} className="mb-3 opacity-30" /> 2029 + <p className="text-sm font-semibold">No messages yet</p> 2030 + <p className="text-xs mt-1 opacity-70">Be the first to say something!</p> 2031 + </div> 1414 2032 ) : ( 1415 - currentMessages.map((message) => ( 1416 - <Message 1417 - key={message._id} 1418 - message={message} 1419 - users={users} 1420 - channels={channels} 1421 - me={auth.userId} 1422 - onUserClick={openUserProfile} 1423 - cdnUrl={config.cdnUrl} 1424 - onToggleReaction={toggleReaction} 1425 - onReply={setReplyingTo} 1426 - replyTarget={replyingTo?._id} 1427 - onJumpToMessage={jumpToMessage} 1428 - registerMessageRef={registerMessageRef} 1429 - customEmojiById={customEmojiById} 1430 - reactionOptions={reactionOptions} 1431 - onRequestOpenLink={requestOpenLink} 1432 - onEditMessage={editMessage} 1433 - onDeleteMessage={deleteMessage} 1434 - replyMessageMap={globalReplyMessageMap} 1435 - /> 1436 - )) 2033 + currentMessages.map((message, index) => { 2034 + const prev = index > 0 ? currentMessages[index - 1] : null; 2035 + const showSep = prev && getMessageDayKey(prev) !== getMessageDayKey(message); 2036 + const showTopSep = index === 0; 2037 + return ( 2038 + <React.Fragment key={message._id}> 2039 + {showTopSep && <DateSeparator message={message} />} 2040 + {showSep && <DateSeparator message={message} />} 2041 + <Message 2042 + message={message} 2043 + users={users} 2044 + channels={channels} 2045 + me={auth.userId} 2046 + onUserClick={openUserProfile} 2047 + cdnUrl={config.cdnUrl} 2048 + onToggleReaction={toggleReaction} 2049 + onReply={setReplyingTo} 2050 + replyTarget={replyingTo?._id} 2051 + onJumpToMessage={jumpToMessage} 2052 + registerMessageRef={registerMessageRef} 2053 + customEmojiById={customEmojiById} 2054 + reactionOptions={reactionOptions} 2055 + onRequestOpenLink={requestOpenLink} 2056 + onEditMessage={editMessage} 2057 + onDeleteMessage={deleteMessage} 2058 + replyMessageMap={globalReplyMessageMap} 2059 + /> 2060 + </React.Fragment> 2061 + ); 2062 + }) 1437 2063 )} 1438 2064 <div ref={messagesBottomRef} /> 1439 2065 </section> 1440 2066 1441 2067 {showGoLatest ? ( 1442 2068 <div className="pointer-events-none absolute bottom-24 left-1/2 z-20 -translate-x-1/2"> 1443 - <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"> 1444 - Go to latest 2069 + <button 2070 + className="pointer-events-auto rounded-full bg-[#5865f2] px-3 py-1.5 text-xs font-semibold text-white shadow-lg hover:bg-[#4956d8] transition-colors" 2071 + onClick={goToLatest} 2072 + type="button" 2073 + > 2074 + ↓ Go to latest 1445 2075 </button> 1446 2076 </div> 1447 2077 ) : null} 1448 2078 2079 + {/* ── Composer ── */} 1449 2080 <footer className="border-t border-[#202225] p-4"> 1450 2081 {activeReply ? ( 1451 2082 <div className="mb-2 flex items-center justify-between gap-2 rounded-md bg-[#2b2d31] px-3 py-2 text-xs text-gray-300"> 1452 - <span className="truncate"> 2083 + <span className="truncate flex items-center gap-1"> 2084 + <Reply size={11} className="shrink-0" /> 1453 2085 Replying to {users[typeof activeReply.author === 'string' ? activeReply.author : activeReply.author?._id]?.username || 'Loading user…'}: {activeReply.content || 'Attachment / embed'} 1454 2086 </span> 1455 - <button className="rounded p-1 text-gray-400 hover:bg-[#3a3d42] hover:text-white" onClick={() => setReplyingTo(null)} type="button"> 1456 - <X size={13} /> 1457 - </button> 2087 + <button className="rounded p-1 text-gray-400 hover:bg-[#3a3d42] hover:text-white transition-colors shrink-0" onClick={() => setReplyingTo(null)} type="button"><X size={13} /></button> 1458 2088 </div> 1459 2089 ) : null} 1460 - {pendingFiles.length ? ( 2090 + 2091 + {pendingFiles.length > 0 ? ( 1461 2092 <div className="mb-2 flex flex-wrap gap-1.5"> 1462 - {pendingFiles.map((file, index) => { 1463 - const previewImage = isImageLike(file.name, file.type); 1464 - return ( 1465 - <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"> 1466 - <span>{previewImage ? '🖼️' : '📎'}</span> 1467 - <span>{file.name} <span className="text-gray-400">(remove)</span></span> 1468 - </button> 1469 - ); 1470 - })} 2093 + {pendingFiles.map((file, index) => ( 2094 + <button 2095 + className="flex items-center gap-2 rounded bg-[#2b2d31] px-2 py-1 text-xs text-gray-200 hover:bg-[#35373c] transition-colors" 2096 + key={`${file.name}-${index}`} 2097 + onClick={() => removePendingFile(index)} 2098 + type="button" 2099 + > 2100 + <span>{isImageLike(file.name, file.type) ? '🖼️' : '📎'}</span> 2101 + <span>{file.name} <span className="text-gray-400">(remove)</span></span> 2102 + </button> 2103 + ))} 1471 2104 </div> 1472 2105 ) : null} 1473 2106 1474 - <div className="flex items-center gap-2 rounded-lg bg-[#383a40] p-2" onDragOver={handleComposerDragOver} onDrop={handleComposerDrop}> 2107 + <div 2108 + className="flex items-end gap-2 rounded-lg bg-[#383a40] px-2 py-2" 2109 + onDragOver={handleComposerDragOver} 2110 + onDrop={handleComposerDrop} 2111 + > 1475 2112 <button 1476 - className="rounded p-2 text-gray-300 hover:bg-[#4b4d55] hover:text-white" 2113 + className="rounded p-2 text-gray-300 hover:bg-[#4b4d55] hover:text-white transition-colors mb-0.5" 1477 2114 disabled={selectedChannelId === 'friends' || isUploadingFiles} 1478 2115 onClick={() => fileInputRef.current?.click()} 1479 2116 title="Upload files" ··· 1482 2119 <Paperclip size={16} /> 1483 2120 </button> 1484 2121 <input className="hidden" multiple onChange={handleFilePick} ref={fileInputRef} type="file" /> 2122 + 1485 2123 <div className="relative"> 1486 2124 <button 1487 - className="rounded p-2 text-gray-300 hover:bg-[#4b4d55] hover:text-white" 2125 + className="rounded p-2 text-gray-300 hover:bg-[#4b4d55] hover:text-white transition-colors mb-0.5" 1488 2126 disabled={selectedChannelId === 'friends'} 1489 - onClick={() => setShowEmojiPicker((prev) => !prev)} 2127 + onClick={() => setShowEmojiPicker((p) => !p)} 1490 2128 type="button" 1491 2129 > 1492 2130 <Smile size={16} /> 1493 2131 </button> 1494 2132 {showEmojiPicker && selectedChannelId !== 'friends' ? ( 1495 - <div ref={pickerRef} onMouseLeave={() => setShowEmojiPicker(false)} className="absolute bottom-12 left-0 z-20 w-72 rounded-lg border border-[#4c4f56] bg-[#232428] p-2 shadow-2xl max-h-64 overflow-y-auto"> 1496 - <p className="mb-2 px-1 text-[10px] uppercase tracking-wide text-gray-500 font-bold sticky top-0 bg-[#232428] z-10">Standard</p> 1497 - <div className="grid grid-cols-8 gap-1 mb-2">{STANDARD_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> 1498 - {allCustomEmojis.length > 0 ? <><p className="mb-2 px-1 text-[10px] uppercase tracking-wide text-gray-500 font-bold sticky top-0 bg-[#232428] z-10">Space Emojis</p><div className="grid grid-cols-8 gap-1">{allCustomEmojis.map((emoji) => <button className="grid h-8 place-items-center rounded hover:bg-[#3a3d42]" key={emoji.id} onClick={() => addCustomEmojiToComposer(emoji.id)} title={`${emoji.name} from ${emoji.serverName}`} type="button">{renderEmojiVisual(`:${emoji.id}:`, { id: emoji.id, name: emoji.name }, config.cdnUrl)}</button>)}</div></> : null} 2133 + <div 2134 + ref={pickerRef} 2135 + className="absolute bottom-12 left-0 z-20 w-72 rounded-lg border border-[#4c4f56] bg-[#232428] shadow-2xl max-h-72 overflow-hidden flex flex-col" 2136 + > 2137 + <div className="p-2 border-b border-[#2f3237]"> 2138 + <input 2139 + className="w-full rounded bg-[#1e1f22] px-2 py-1 text-xs text-gray-200 placeholder:text-gray-500 focus:outline-none focus:ring-1 focus:ring-[#5865f2]" 2140 + placeholder={allCustomEmojis.length > 0 ? 'Search custom emojis…' : 'Standard emojis below'} 2141 + value={emojiSearch} 2142 + onChange={(e) => setEmojiSearch(e.target.value)} 2143 + /> 2144 + </div> 2145 + <div className="overflow-y-auto p-2"> 2146 + {emojiSearch ? ( 2147 + <> 2148 + {filteredComposerCustomEmojis.length > 0 ? ( 2149 + <> 2150 + <p className="mb-1 px-1 text-[10px] uppercase tracking-wide text-gray-500 font-bold">Custom</p> 2151 + <div className="grid grid-cols-8 gap-1"> 2152 + {filteredComposerCustomEmojis.map((emoji) => ( 2153 + <button className="grid h-8 place-items-center rounded hover:bg-[#3a3d42]" key={emoji.id} onClick={() => addCustomEmojiToComposer(emoji.id)} title={`${emoji.name} from ${emoji.serverName}`} type="button"> 2154 + {renderEmojiVisual(`:${emoji.id}:`, { id: emoji.id, name: emoji.name }, config.cdnUrl)} 2155 + </button> 2156 + ))} 2157 + </div> 2158 + </> 2159 + ) : ( 2160 + <p className="text-xs text-gray-500 text-center py-4">No custom emojis match "{emojiSearch}"</p> 2161 + )} 2162 + </> 2163 + ) : ( 2164 + <> 2165 + <p className="mb-1 px-1 text-[10px] uppercase tracking-wide text-gray-500 font-bold sticky top-0 bg-[#232428]">Standard</p> 2166 + <div className="grid grid-cols-8 gap-1 mb-2"> 2167 + {STANDARD_EMOJIS.map((emoji) => ( 2168 + <button className="rounded p-1 text-lg hover:bg-[#3a3d42]" key={emoji} onClick={() => addEmojiToComposer(emoji)} type="button"> 2169 + {renderTwemoji(emoji, 'w-6 h-6 inline-block')} 2170 + </button> 2171 + ))} 2172 + </div> 2173 + {allCustomEmojis.length > 0 ? ( 2174 + <> 2175 + <p className="mb-1 px-1 text-[10px] uppercase tracking-wide text-gray-500 font-bold sticky top-0 bg-[#232428]">Space Emojis</p> 2176 + <div className="grid grid-cols-8 gap-1"> 2177 + {allCustomEmojis.map((emoji) => ( 2178 + <button className="grid h-8 place-items-center rounded hover:bg-[#3a3d42]" key={emoji.id} onClick={() => addCustomEmojiToComposer(emoji.id)} title={`${emoji.name} from ${emoji.serverName}`} type="button"> 2179 + {renderEmojiVisual(`:${emoji.id}:`, { id: emoji.id, name: emoji.name }, config.cdnUrl)} 2180 + </button> 2181 + ))} 2182 + </div> 2183 + </> 2184 + ) : null} 2185 + </> 2186 + )} 2187 + </div> 1499 2188 </div> 1500 2189 ) : null} 1501 2190 </div> 1502 - <input 1503 - className="flex-1 bg-transparent px-2 py-1 text-sm text-gray-100 placeholder:text-gray-400 focus:outline-none" 2191 + 2192 + <textarea 2193 + ref={composerRef} 2194 + className="composer-textarea flex-1 bg-transparent px-2 py-1.5 text-sm text-gray-100 placeholder:text-gray-400 focus:outline-none resize-none" 2195 + rows={1} 1504 2196 disabled={selectedChannelId === 'friends'} 1505 2197 onChange={(e) => setInputText(e.target.value)} 1506 2198 onKeyDown={(e) => { 1507 - if (e.key === 'Enter' && !e.shiftKey) { 1508 - e.preventDefault(); 1509 - sendMessage(); 1510 - } 2199 + if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); } 2200 + if (e.key === 'Escape') { setReplyingTo(null); setInputText(''); } 1511 2201 }} 1512 - placeholder={selectedChannelId === 'friends' ? 'Select a text channel to chat.' : `Enter message in #${currentChannelName}`} 2202 + placeholder={selectedChannelId === 'friends' ? 'Select a text channel to chat.' : `Message #${currentChannelName}`} 1513 2203 value={inputText} 1514 2204 /> 1515 - <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"> 1516 - <Send size={16} /> 2205 + 2206 + <button 2207 + className="rounded p-2 text-gray-300 hover:bg-[#4b4d55] hover:text-white transition-colors disabled:cursor-not-allowed disabled:opacity-60 mb-0.5" 2208 + disabled={isUploadingFiles || (!inputText.trim() && !pendingFiles.length)} 2209 + onClick={sendMessage} 2210 + type="button" 2211 + > 2212 + {isUploadingFiles ? <Loader className="animate-spin" size={16} /> : <Send size={16} />} 1517 2213 </button> 1518 2214 </div> 2215 + <p className="mt-1 text-right text-[10px] text-gray-500 pr-1">Shift+Enter for newline · Esc to cancel reply</p> 1519 2216 </footer> 1520 2217 </main> 1521 2218 1522 - <aside className="hidden w-60 flex-col border-l border-[#202225] bg-[#2b2d31] lg:flex"> 2219 + {/* ── Members sidebar ── */} 2220 + <aside className="hidden w-60 flex-col border-l border-[#202225] bg-[#2b2d31] lg:flex shrink-0"> 1523 2221 <div className="border-b border-[#202225] px-4 py-3 text-xs font-bold uppercase tracking-wide text-gray-400"> 1524 2222 <div>Members — {allCurrentMembers.length}</div> 1525 - {isMembersLoading ? <div className="mt-2 h-1.5 w-full overflow-hidden rounded-full bg-[#242A35]"><div className="h-full w-1/3 animate-pulse rounded-full bg-[#8AB4F8]" /></div> : null} 2223 + {isMembersLoading 2224 + ? <div className="mt-2 h-1.5 w-full overflow-hidden rounded-full bg-[#242A35]"><div className="h-full w-1/3 animate-pulse rounded-full bg-[#8AB4F8]" /></div> 2225 + : null 2226 + } 1526 2227 </div> 1527 2228 <div className="min-h-0 flex-1 relative"> 1528 - <VirtualList items={memberListItems} itemHeight={40} className="h-full w-full" renderItem={renderMemberItem} /> 2229 + {selectedServerId === '@me' 2230 + ? <div className="p-4 text-xs text-gray-400">Member list is available in spaces.</div> 2231 + : <VirtualList items={memberListItems} itemHeight={40} className="h-full w-full" renderItem={renderMemberItem} /> 2232 + } 1529 2233 </div> 1530 2234 </aside> 1531 2235 </div>
+15
vite.config.js
··· 1 + import { defineConfig } from 'vite'; 2 + import react from '@vitejs/plugin-react'; 3 + 4 + export default defineConfig({ 5 + plugins: [react()], 6 + define: { 7 + // Allow env-var overrides at build time 8 + __ERMINE_API_URL__: JSON.stringify(process.env.VITE_STOAT_API_URL || ''), 9 + __ERMINE_WS_URL__: JSON.stringify(process.env.VITE_STOAT_WS_URL || ''), 10 + __ERMINE_CDN_URL__: JSON.stringify(process.env.VITE_STOAT_CDN_URL || ''), 11 + }, 12 + server: { 13 + port: 5173, 14 + }, 15 + });