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

Merge pull request #12 from FascinatingPistachio/codex/fix-multiple-issues-in-ermine-chat-system

Codex-generated pull request

authored by

Fascinating Pistachio and committed by
GitHub
(Feb 14, 2026, 4:30 PM UTC) ecd2dc15 bd842dc4

+933 -73
+933 -73
src/App.jsx
··· 6 6 LogOut, 7 7 MessageSquare, 8 8 Plus, 9 + Paperclip, 9 10 Reply, 10 11 Send, 11 12 Settings, ··· 23 24 return { 24 25 apiUrl: runtime.apiUrl || env.VITE_STOAT_API_URL || 'https://api.stoat.chat', 25 26 wsUrl: runtime.wsUrl || env.VITE_STOAT_WS_URL || 'wss://stoat.chat/events', 26 - cdnUrl: runtime.cdnUrl || env.VITE_STOAT_CDN_URL || 'https://autumn.revolt.chat', 27 + cdnUrl: runtime.cdnUrl || env.VITE_STOAT_CDN_URL || 'https://cdn.stoatusercontent.com', 27 28 }; 28 29 }; 29 30 ··· 40 41 41 42 const getAvatarUrl = (user, cdnUrl) => { 42 43 if (!user?.avatar?._id) return null; 43 - return `${cdnUrl}/avatars/${user.avatar._id}?max_side=256`; 44 + return `${cdnUrl}/avatars/${user.avatar._id}`; 44 45 }; 45 46 46 47 const getIconUrl = (server, cdnUrl) => { ··· 48 49 return `${cdnUrl}/icons/${server.icon._id}`; 49 50 }; 50 51 52 + 53 + const getBannerUrl = (user, cdnUrl) => { 54 + const banner = user?.profile?.background || user?.banner; 55 + if (!banner?._id) return null; 56 + return `${cdnUrl}/backgrounds/${banner._id}`; 57 + }; 58 + 59 + const getJoinedAt = (entry) => entry?.joined_at || entry?.joinedAt || entry?.created_at || entry?.createdAt || null; 60 + 61 + const toDateLabel = (value) => { 62 + if (!value) return 'Unknown'; 63 + const date = new Date(value); 64 + if (Number.isNaN(date.getTime())) return 'Unknown'; 65 + return date.toLocaleDateString([], { year: 'numeric', month: 'short', day: 'numeric' }); 66 + }; 67 + 68 + 69 + const LinkAnchor = ({ href, children, onRequestOpenLink, className = 'text-[#8ea1ff] underline hover:text-[#bdc3ff]' }) => ( 70 + <a 71 + className={className} 72 + href={href} 73 + onClick={(event) => { 74 + event.preventDefault(); 75 + if (onRequestOpenLink) onRequestOpenLink(href); 76 + }} 77 + rel="noreferrer" 78 + target="_blank" 79 + > 80 + {children} 81 + </a> 82 + ); 83 + 84 + const renderLinksInText = (value, keyPrefix = 'link', onRequestOpenLink = null) => { 85 + if (!value) return null; 86 + const parts = value.split(/(https?:\/\/[^\s]+)/gi); 87 + return parts.map((part, index) => { 88 + if (!part) return null; 89 + if (/^https?:\/\//i.test(part)) { 90 + return <LinkAnchor href={part} key={`${keyPrefix}-${index}`} onRequestOpenLink={onRequestOpenLink}>{part}</LinkAnchor>; 91 + } 92 + return <React.Fragment key={`${keyPrefix}-${index}`}>{part}</React.Fragment>; 93 + }); 94 + }; 95 + 96 + const renderMarkdownInline = (text, keyPrefix = 'md', onRequestOpenLink = null) => { 97 + if (!text) return null; 98 + const tokens = text.split(/(`[^`]+`|\*\*[^*]+\*\*|\*[^*]+\*|__[^_]+__|~~[^~]+~~|\[[^\]]+\]\([^\)]+\))/g); 99 + 100 + return tokens.map((token, index) => { 101 + if (!token) return null; 102 + 103 + if (/^`[^`]+`$/.test(token)) { 104 + return <code className="rounded bg-[#232428] px-1 py-0.5 text-xs text-[#f2f3f5]" key={`${keyPrefix}-${index}`}>{token.slice(1, -1)}</code>; 105 + } 106 + 107 + if (/^\*\*[^*]+\*\*$/.test(token)) { 108 + return <strong key={`${keyPrefix}-${index}`}>{token.slice(2, -2)}</strong>; 109 + } 110 + 111 + if (/^\*[^*]+\*$/.test(token)) { 112 + return <em key={`${keyPrefix}-${index}`}>{token.slice(1, -1)}</em>; 113 + } 114 + 115 + if (/^__[^_]+__$/.test(token)) { 116 + return <span className="underline" key={`${keyPrefix}-${index}`}>{token.slice(2, -2)}</span>; 117 + } 118 + 119 + if (/^~~[^~]+~~$/.test(token)) { 120 + return <span className="line-through" key={`${keyPrefix}-${index}`}>{token.slice(2, -2)}</span>; 121 + } 122 + 123 + const markdownLink = token.match(/^\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)$/i); 124 + if (markdownLink) { 125 + return <LinkAnchor href={markdownLink[2]} key={`${keyPrefix}-${index}`} onRequestOpenLink={onRequestOpenLink}>{markdownLink[1]}</LinkAnchor>; 126 + } 127 + 128 + return <React.Fragment key={`${keyPrefix}-${index}`}>{renderLinksInText(token, `${keyPrefix}-plain-${index}`, onRequestOpenLink)}</React.Fragment>; 129 + }); 130 + }; 131 + 51 132 const EMOJI_SHORTCODES = { 52 133 smile: '😄', 53 134 grin: '😁', ··· 65 146 }; 66 147 67 148 const QUICK_EMOJIS = ['👍', '❤️', '😂', '🔥', '😭', '🎉', '😮', '👀', '🤔']; 149 + const EXTENDED_EMOJIS = [ 150 + ...QUICK_EMOJIS, 151 + '😄', '😁', '🤣', '😉', '🙏', '👏', '✨', '🎯', '💯', '🤝', '😅', '😎', '🥳', '✅', '❌', '🎵', '📌', '🚀', '🫡', '🤌', 152 + ]; 68 153 const MEMBER_RENDER_LIMIT = 250; 69 154 const MEMBER_HYDRATE_CHUNK = 400; 70 155 ··· 75 160 .filter((entry) => entry.userIds.length > 0); 76 161 }; 77 162 78 - const renderMessageContent = (content, users, channels, onUserClick) => { 163 + 164 + const getReplyIdFromMessage = (message) => { 165 + const firstReply = Array.isArray(message?.replies) ? message.replies[0] : null; 166 + if (!firstReply) return null; 167 + return typeof firstReply === 'string' ? firstReply : firstReply.id || firstReply._id || null; 168 + }; 169 + 170 + 171 + const isCustomEmojiToken = (value) => /^:[A-Z0-9]{26}:$/i.test(value || ''); 172 + 173 + const getCustomEmojiId = (token) => token?.slice(1, -1); 174 + 175 + const resolveCustomEmojiMeta = (token, customEmojiById) => { 176 + if (!isCustomEmojiToken(token)) return null; 177 + const emojiId = getCustomEmojiId(token); 178 + const mapped = customEmojiById?.[emojiId]; 179 + return mapped || { id: emojiId, name: emojiId, serverName: null, isPrivate: false, unresolved: true }; 180 + }; 181 + 182 + const renderEmojiVisual = (token, customEmoji, cdnUrl) => { 183 + const emojiId = customEmoji?.id || (isCustomEmojiToken(token) ? getCustomEmojiId(token) : null); 184 + if (emojiId) { 185 + return <img alt={customEmoji?.name || token} className="inline h-5 w-5 align-text-bottom" src={`${cdnUrl}/emojis/${emojiId}`} />; 186 + } 187 + return token; 188 + }; 189 + 190 + 191 + const isImageLike = (filename = '', contentType = '') => { 192 + if (typeof contentType === 'string' && contentType.startsWith('image/')) return true; 193 + return /\.(png|jpe?g|webp|gif|bmp|avif|svg)$/i.test(filename || ''); 194 + }; 195 + 196 + const getAttachmentData = (attachment, cdnUrl, index = 0) => { 197 + const attachmentId = typeof attachment === 'string' ? attachment : attachment?._id || attachment?.id; 198 + const filename = attachment?.filename || attachment?.name || `attachment-${index + 1}`; 199 + const contentType = attachment?.content_type || attachment?.contentType || attachment?.metadata?.type || ''; 200 + const url = attachmentId ? `${cdnUrl}/attachments/${attachmentId}` : null; 201 + const image = isImageLike(filename, contentType); 202 + 203 + return { attachmentId, filename, url, image }; 204 + }; 205 + 206 + 207 + const extractUrls = (value = '') => (value.match(/https?:\/\/[^\s]+/gi) || []); 208 + 209 + const isEmbeddableGifLink = (url) => { 210 + if (!url) return false; 211 + if (/^https?:\/\/media\.tenor\.com\//i.test(url)) return true; 212 + return /\.(gif)(\?.*)?$/i.test(url); 213 + }; 214 + 215 + const renderMessageContent = (content, users, channels, onUserClick, customEmojiById, cdnUrl, onRequestOpenLink) => { 79 216 if (!content) return null; 80 217 81 - const parts = content.split(/(<@!?[A-Za-z0-9]+>|<#[A-Za-z0-9]+>|:[a-z0-9_+-]+:)/gi); 218 + const parts = content.split(/(<@!?[A-Za-z0-9]+>|<#[A-Za-z0-9]+>|:[A-Za-z0-9_+-]+:)/g); 82 219 83 220 return parts.map((part, index) => { 84 221 if (!part) return null; ··· 111 248 112 249 const emoji = part.match(/^:([a-z0-9_+-]+):$/i); 113 250 if (emoji) { 251 + if (isCustomEmojiToken(part)) { 252 + const customEmoji = resolveCustomEmojiMeta(part, customEmojiById); 253 + const source = customEmoji?.unresolved ? 'Custom emoji (external/unavailable metadata)' : customEmoji?.isPrivate ? 'Private server emoji' : customEmoji?.serverName ? `Server emoji from ${customEmoji.serverName}` : 'Custom emoji'; 254 + return ( 255 + <span className="mx-0.5 inline-flex items-center" key={`${part}-${index}`} title={`${customEmoji?.name || part} · ${source}`}> 256 + {renderEmojiVisual(part, customEmoji, cdnUrl)} 257 + </span> 258 + ); 259 + } 114 260 return EMOJI_SHORTCODES[emoji[1].toLowerCase()] || part; 115 261 } 116 262 117 - return <React.Fragment key={`${part}-${index}`}>{part}</React.Fragment>; 263 + return <React.Fragment key={`${part}-${index}`}>{renderMarkdownInline(part, `md-${index}`, onRequestOpenLink)}</React.Fragment>; 118 264 }); 119 265 }; 120 266 121 - const Avatar = ({ user, cdnUrl, size = 'md' }) => { 267 + const Avatar = ({ user, cdnUrl, size = 'md', animateOnHover = false, alwaysAnimate = false }) => { 122 268 const sizeMap = { 123 269 sm: 'h-8 w-8 text-xs', 124 270 md: 'h-10 w-10 text-sm', ··· 126 272 }; 127 273 128 274 const initials = (user?.username || '?').slice(0, 2).toUpperCase(); 129 - const src = getAvatarUrl(user, cdnUrl); 275 + const [isHovered, setIsHovered] = useState(false); 276 + const src = (() => { 277 + if (!user?.avatar?._id) return null; 278 + const wantsAnimated = alwaysAnimate || (animateOnHover && isHovered); 279 + return wantsAnimated ? `${cdnUrl}/avatars/${user.avatar._id}/original` : `${cdnUrl}/avatars/${user.avatar._id}`; 280 + })(); 130 281 131 282 return ( 132 - <div className={`grid shrink-0 place-items-center overflow-hidden rounded-full bg-[#313338] font-semibold text-gray-200 ${sizeMap[size]}`}> 283 + <div 284 + className={`grid shrink-0 place-items-center overflow-hidden rounded-full bg-[#313338] font-semibold text-gray-200 ${sizeMap[size]}`} 285 + onMouseEnter={() => setIsHovered(true)} 286 + onMouseLeave={() => setIsHovered(false)} 287 + > 133 288 {src ? <img alt={user?.username || 'User avatar'} className="block h-full w-full object-cover object-center" src={src} /> : initials} 134 289 </div> 135 290 ); ··· 198 353 } 199 354 } 200 355 201 - const Message = ({ message, users, channels, me, onUserClick, cdnUrl, onToggleReaction, onReply, replyTarget }) => { 356 + const Message = ({ message, users, channels, me, onUserClick, cdnUrl, onToggleReaction, onReply, replyTarget, onJumpToMessage, registerMessageRef, customEmojiById, reactionOptions, onRequestOpenLink }) => { 202 357 const authorId = typeof message.author === 'string' ? message.author : message.author?._id; 203 358 const author = users[authorId] || (typeof message.author === 'object' ? message.author : null) || { username: 'Unknown user' }; 204 359 const mine = me === authorId; 205 360 const messageReactions = toReactionEntries(message.reactions); 206 361 const replyPreview = message.replyMessage; 362 + const [showReactionPicker, setShowReactionPicker] = useState(false); 363 + const [isMessageHovered, setIsMessageHovered] = useState(false); 364 + const embeddedGifUrls = useMemo(() => extractUrls(message.content || '').filter(isEmbeddableGifLink), [message.content]); 365 + const [resolvedGifUrls, setResolvedGifUrls] = useState({}); 366 + 367 + useEffect(() => { 368 + let cancelled = false; 369 + 370 + const resolveTenorUrls = async () => { 371 + const tenorLinks = embeddedGifUrls.filter((url) => /^https?:\/\/tenor\.com\//i.test(url)); 372 + if (!tenorLinks.length) return; 373 + 374 + const results = await Promise.all( 375 + tenorLinks.map(async (url) => { 376 + try { 377 + const response = await fetch(`https://tenor.com/oembed?url=${encodeURIComponent(url)}`); 378 + if (!response.ok) return [url, url]; 379 + const data = await response.json(); 380 + return [url, data?.url || data?.thumbnail_url || url]; 381 + } catch { 382 + return [url, url]; 383 + } 384 + }), 385 + ); 386 + 387 + if (!cancelled) { 388 + setResolvedGifUrls((prev) => ({ ...prev, ...Object.fromEntries(results) })); 389 + } 390 + }; 391 + 392 + void resolveTenorUrls(); 393 + return () => { 394 + cancelled = true; 395 + }; 396 + }, [embeddedGifUrls]); 397 + 398 + const contentWithoutEmbeddedGifLinks = useMemo(() => { 399 + let next = message.content || ''; 400 + embeddedGifUrls.forEach((url) => { 401 + next = next.split(url).join(''); 402 + }); 403 + return next.trim(); 404 + }, [embeddedGifUrls, message.content]); 207 405 208 406 return ( 209 - <article className="group flex gap-3 rounded px-4 py-2 hover:bg-[#2e3035]"> 407 + <article className="group flex gap-3 rounded px-4 py-2 hover:bg-[#2e3035]" onMouseEnter={() => setIsMessageHovered(true)} onMouseLeave={() => setIsMessageHovered(false)} ref={(node) => registerMessageRef(message._id, node)}> 210 408 <button className="mt-0.5" onClick={() => onUserClick(author, authorId)} type="button"> 211 - <Avatar cdnUrl={cdnUrl} user={author} /> 409 + <Avatar alwaysAnimate={isMessageHovered} cdnUrl={cdnUrl} user={author} /> 212 410 </button> 213 411 <div className="min-w-0 flex-1"> 214 412 {replyPreview ? ( 215 413 <button 216 414 className="mb-1 flex max-w-full items-center gap-1 text-xs text-gray-400 hover:text-gray-200" 217 - onClick={() => onUserClick(replyPreview.authorUser || { username: 'Unknown user' }, replyPreview.authorId)} 415 + onClick={() => onJumpToMessage(replyPreview._id)} 218 416 type="button" 219 417 > 220 418 <Reply size={12} /> ··· 228 426 {mine && <span className="rounded bg-[#5865f2]/20 px-1.5 py-0.5 text-[10px] font-semibold text-[#bdc3ff]">YOU</span>} 229 427 <time className="text-[11px] text-gray-500">{toTime(message.createdAt)}</time> 230 428 </div> 231 - {message.content ? ( 232 - <p className="whitespace-pre-wrap break-words text-sm text-gray-200">{renderMessageContent(message.content, users, channels, onUserClick)}</p> 429 + {contentWithoutEmbeddedGifLinks ? ( 430 + <p className="whitespace-pre-wrap break-words text-sm text-gray-200">{renderMessageContent(contentWithoutEmbeddedGifLinks, users, channels, onUserClick, customEmojiById, cdnUrl, onRequestOpenLink)}</p> 431 + ) : null} 432 + {embeddedGifUrls.length ? ( 433 + <div className="mt-1.5 flex flex-wrap gap-2"> 434 + {embeddedGifUrls.slice(0, 3).map((url) => { 435 + const renderUrl = resolvedGifUrls[url] || url; 436 + return ( 437 + <button className="block overflow-hidden rounded border border-[#3a3d42]" key={url} onClick={() => onRequestOpenLink(url)} title={url} type="button"> 438 + <img alt="Embedded GIF" className="max-h-52 max-w-[320px] object-contain" src={renderUrl} /> 439 + </button> 440 + ); 441 + })} 442 + </div> 443 + ) : null} 444 + {Array.isArray(message.attachments) && message.attachments.length ? ( 445 + <div className="mt-1.5 flex flex-wrap gap-1.5"> 446 + {message.attachments.map((attachment, index) => { 447 + const { attachmentId, filename, url, image } = getAttachmentData(attachment, cdnUrl, index); 448 + if (!attachmentId || !url) return null; 449 + 450 + if (image) { 451 + return ( 452 + <a className="block overflow-hidden rounded border border-[#3a3d42]" href={url} key={attachmentId} rel="noreferrer" target="_blank" title={filename}> 453 + <img alt={filename} className="max-h-52 max-w-[320px] object-contain" src={url} /> 454 + </a> 455 + ); 456 + } 457 + 458 + return ( 459 + <a 460 + className="rounded bg-[#232428] px-2 py-1 text-xs text-[#bdc3ff] hover:bg-[#2f3136]" 461 + href={url} 462 + key={attachmentId} 463 + rel="noreferrer" 464 + target="_blank" 465 + > 466 + 📎 {filename} 467 + </a> 468 + ); 469 + })} 470 + </div> 233 471 ) : null} 234 472 <div className="mt-1 flex flex-wrap items-center gap-1.5"> 235 473 {messageReactions.map(({ emoji, userIds }) => { 236 474 const reacted = userIds.includes(me); 475 + const customEmoji = resolveCustomEmojiMeta(emoji, customEmojiById); 476 + const source = customEmoji?.unresolved ? 'Custom emoji (external/unavailable metadata)' : customEmoji?.isPrivate ? 'Private server emoji' : customEmoji?.serverName ? `Server emoji from ${customEmoji.serverName}` : 'Emoji'; 237 477 return ( 238 478 <button 239 479 className={`rounded-full border px-2 py-0.5 text-xs ${reacted ? 'border-[#5865f2] bg-[#5865f2]/20 text-[#d7ddff]' : 'border-[#4c4f56] bg-[#2b2d31] text-gray-200 hover:bg-[#35373c]'}`} 240 480 key={emoji} 241 481 onClick={() => onToggleReaction(message, emoji, reacted)} 482 + title={`${customEmoji?.name || emoji} · ${source}`} 242 483 type="button" 243 484 > 244 - {emoji} {userIds.length} 485 + {renderEmojiVisual(emoji, customEmoji, cdnUrl)} {userIds.length} 245 486 </button> 246 487 ); 247 488 })} 248 489 <button className="rounded-full border border-[#4c4f56] px-2 py-0.5 text-xs text-gray-300 hover:bg-[#35373c]" onClick={() => onReply(message)} type="button"> 249 490 <Reply size={12} className="inline" /> Reply 250 491 </button> 251 - <button className="rounded-full border border-[#4c4f56] px-2 py-0.5 text-xs text-gray-300 hover:bg-[#35373c]" onClick={() => onToggleReaction(message, '👍', message.reactions?.['👍']?.includes(me))} type="button"> 252 - + React 253 - </button> 492 + <div className="relative"> 493 + <button className="rounded-full border border-[#4c4f56] px-2 py-0.5 text-xs text-gray-300 hover:bg-[#35373c]" onClick={() => setShowReactionPicker((prev) => !prev)} type="button"> 494 + + React 495 + </button> 496 + {showReactionPicker ? ( 497 + <div className="absolute bottom-7 left-0 z-30 w-72 rounded-lg border border-[#4c4f56] bg-[#232428] p-2 shadow-2xl"> 498 + <p className="mb-1 px-1 text-[10px] uppercase tracking-wide text-gray-500">Add reaction</p> 499 + <div className="grid max-h-44 grid-cols-8 gap-1 overflow-y-auto"> 500 + {reactionOptions.map((option) => ( 501 + <button 502 + className="grid h-8 place-items-center rounded hover:bg-[#3a3d42]" 503 + key={option.value} 504 + onClick={() => { 505 + onToggleReaction(message, option.value, message.reactions?.[option.value]?.includes(me)); 506 + setShowReactionPicker(false); 507 + }} 508 + title={option.title} 509 + type="button" 510 + > 511 + {option.custom ? renderEmojiVisual(option.value, option.custom, cdnUrl) : option.label} 512 + </button> 513 + ))} 514 + </div> 515 + </div> 516 + ) : null} 517 + </div> 254 518 {replyTarget === message._id ? <span className="text-[11px] text-[#bdc3ff]">Reply target</span> : null} 255 519 </div> 256 520 </div> ··· 281 545 const [isMembersLoading, setIsMembersLoading] = useState(false); 282 546 const [replyingTo, setReplyingTo] = useState(null); 283 547 const [showEmojiPicker, setShowEmojiPicker] = useState(false); 548 + const [pendingFiles, setPendingFiles] = useState([]); 549 + const [isUploadingFiles, setIsUploadingFiles] = useState(false); 550 + const [voiceNotice, setVoiceNotice] = useState(''); 551 + const [showGoLatest, setShowGoLatest] = useState(false); 552 + const [linkPromptUrl, setLinkPromptUrl] = useState(null); 553 + const [statusDraft, setStatusDraft] = useState({ presence: 'Online', text: '' }); 554 + const [isSavingStatus, setIsSavingStatus] = useState(false); 555 + const [isAccountHovered, setIsAccountHovered] = useState(false); 284 556 285 557 const [loginMode, setLoginMode] = useState('credentials'); 286 558 const [email, setEmail] = useState(''); ··· 293 565 294 566 const wsRef = useRef(null); 295 567 const messagesBottomRef = useRef(null); 568 + const messagesContainerRef = useRef(null); 296 569 const subscriptionRef = useRef({}); 297 570 const preloadedChannelRef = useRef({}); 298 571 const preloadedMembersRef = useRef({}); ··· 301 574 const canFetchMissingUsersRef = useRef(true); 302 575 const loggedMissingUserFetchErrorRef = useRef(false); 303 576 const wsReconnectTimeoutRef = useRef(null); 577 + const messageRefs = useRef({}); 578 + const replyMessageCacheRef = useRef({}); 579 + const fileInputRef = useRef(null); 580 + const autoFollowRef = useRef(true); 304 581 305 582 const serverList = useMemo(() => Object.values(servers), [servers]); 306 583 ··· 309 586 return Object.values(channels).filter((ch) => ch.server === selectedServerId); 310 587 }, [channels, selectedServerId]); 311 588 589 + const directMessageChannels = useMemo( 590 + () => Object.values(channels).filter((channel) => channel?.channel_type === 'DirectMessage'), 591 + [channels], 592 + ); 593 + 312 594 const currentMessages = useMemo(() => messages[selectedChannelId] || [], [messages, selectedChannelId]); 313 595 const currentMessageMap = useMemo(() => Object.fromEntries(currentMessages.map((message) => [message._id, message])), [currentMessages]); 314 596 ··· 342 624 [users], 343 625 ); 344 626 627 + const selectedServer = servers[selectedServerId]; 628 + 629 + const customEmojis = useMemo(() => { 630 + if (!selectedServer || selectedServerId === '@me') return []; 631 + const source = selectedServer.emojis || selectedServer.emoji || []; 632 + if (Array.isArray(source)) { 633 + return source.map((entry) => ({ 634 + id: entry?._id || entry?.id, 635 + name: entry?.name || entry?._id || 'emoji', 636 + })); 637 + } 638 + 639 + return Object.entries(source).map(([id, value]) => ({ 640 + id, 641 + name: value?.name || id, 642 + })); 643 + }, [selectedServer, selectedServerId]); 644 + 645 + 646 + const customEmojiById = useMemo(() => { 647 + const index = {}; 648 + Object.values(servers).forEach((server) => { 649 + const source = server?.emojis || server?.emoji || []; 650 + const entries = Array.isArray(source) 651 + ? source.map((emoji) => ({ id: emoji?._id || emoji?.id, name: emoji?.name })) 652 + : Object.entries(source || {}).map(([id, emoji]) => ({ id, name: emoji?.name })); 653 + 654 + entries.forEach((emoji) => { 655 + if (!emoji?.id) return; 656 + index[emoji.id] = { 657 + id: emoji.id, 658 + name: emoji.name || emoji.id, 659 + serverName: server?.name, 660 + isPrivate: server?.discoverable === false, 661 + }; 662 + }); 663 + }); 664 + return index; 665 + }, [servers]); 666 + 667 + const reactionOptions = useMemo(() => { 668 + const base = EXTENDED_EMOJIS.map((emoji) => ({ value: emoji, label: emoji, title: emoji, custom: null })); 669 + const serverSet = customEmojis.slice(0, 30).map((emoji) => { 670 + const value = `:${emoji.id}:`; 671 + const custom = customEmojiById[emoji.id]; 672 + const source = custom?.isPrivate ? 'Private server emoji' : custom?.serverName ? `Server emoji from ${custom.serverName}` : 'Custom emoji'; 673 + return { value, label: emoji.name, title: `${emoji.name} · ${source}`, custom }; 674 + }); 675 + return [...base, ...serverSet]; 676 + }, [customEmojis, customEmojiById]); 677 + 678 + 679 + const peekMember = useMemo(() => { 680 + if (!peekUser?._id || selectedServerId === '@me') return null; 681 + return members[`${selectedServerId}:${peekUser._id}`] || null; 682 + }, [members, peekUser, selectedServerId]); 683 + 684 + const peekRoles = useMemo(() => { 685 + if (!peekMember || !selectedServer?.roles) return []; 686 + return (peekMember.roles || []).map((roleId) => selectedServer.roles?.[roleId]).filter(Boolean); 687 + }, [peekMember, selectedServer]); 688 + 689 + const peekBadges = useMemo(() => { 690 + if (!peekUser?.badges) return []; 691 + if (Array.isArray(peekUser.badges)) return peekUser.badges; 692 + if (typeof peekUser.badges === 'number') { 693 + const flagMap = { 694 + 1: 'Developer', 695 + 2: 'Translator', 696 + 4: 'Supporter', 697 + 8: 'Founder', 698 + 16: 'Platform Moderation', 699 + 32: 'Active Supporter', 700 + 64: 'Paw', 701 + }; 702 + return Object.entries(flagMap) 703 + .filter(([bit]) => (peekUser.badges & Number(bit)) === Number(bit)) 704 + .map(([, label]) => label); 705 + } 706 + if (typeof peekUser.badges === 'object') { 707 + return Object.entries(peekUser.badges) 708 + .filter(([, enabled]) => Boolean(enabled)) 709 + .map(([badge]) => badge); 710 + } 711 + return []; 712 + }, [peekUser]); 713 + 714 + 715 + const peekAboutMe = useMemo( 716 + () => peekUser?.profile?.content || peekUser?.profile?.bio || peekUser?.bio || null, 717 + [peekUser], 718 + ); 719 + 720 + const peekPlatformJoined = useMemo(() => getJoinedAt(peekUser), [peekUser]); 721 + const peekServerJoined = useMemo(() => getJoinedAt(peekMember), [peekMember]); 722 + const peekBannerUrl = useMemo(() => getBannerUrl(peekUser, config.cdnUrl), [peekUser, config.cdnUrl]); 723 + 345 724 const isSameOriginApi = useMemo(() => { 346 725 try { 347 726 return new URL(config.apiUrl).origin === window.location.origin; ··· 507 886 const list = prev[packet.channel] || []; 508 887 if (list.find((m) => m._id === packet._id)) return prev; 509 888 const nextMessages = [...list, packet].slice(-200); 510 - return { ...prev, [packet.channel]: enrichReplies(nextMessages) }; 889 + return { ...prev, [packet.channel]: enrichReplies(nextMessages, packet.channel) }; 511 890 }); 512 891 break; 513 892 case 'MessageUpdate': 514 893 setMessages((prev) => ({ 515 894 ...prev, 516 - [packet.channel]: enrichReplies((prev[packet.channel] || []).map((m) => (m._id === packet.id ? { ...m, ...packet.data } : m))), 895 + [packet.channel]: enrichReplies((prev[packet.channel] || []).map((m) => (m._id === packet.id ? { ...m, ...packet.data } : m)), packet.channel), 517 896 })); 518 897 break; 519 898 case 'MessageDelete': ··· 660 1039 document.removeEventListener('visibilitychange', sendSubscribe); 661 1040 }; 662 1041 }, [selectedServerId, status]); 1042 + 1043 + const isNearBottom = () => { 1044 + const container = messagesContainerRef.current; 1045 + if (!container) return true; 1046 + const threshold = 72; 1047 + return container.scrollHeight - container.scrollTop - container.clientHeight <= threshold; 1048 + }; 663 1049 664 1050 useEffect(() => { 665 - messagesBottomRef.current?.scrollIntoView({ behavior: 'smooth' }); 1051 + const container = messagesContainerRef.current; 1052 + if (!container) return; 1053 + 1054 + autoFollowRef.current = true; 1055 + 1056 + const handleScroll = () => { 1057 + const nearBottom = isNearBottom(); 1058 + autoFollowRef.current = nearBottom; 1059 + setShowGoLatest(!nearBottom); 1060 + }; 1061 + 1062 + container.addEventListener('scroll', handleScroll); 1063 + return () => container.removeEventListener('scroll', handleScroll); 1064 + }, [selectedChannelId]); 1065 + 1066 + useEffect(() => { 1067 + if (autoFollowRef.current || isNearBottom()) { 1068 + messagesBottomRef.current?.scrollIntoView({ behavior: 'smooth' }); 1069 + autoFollowRef.current = true; 1070 + setShowGoLatest(false); 1071 + return; 1072 + } 1073 + 1074 + setShowGoLatest(true); 666 1075 }, [currentMessages]); 1076 + 1077 + useEffect(() => { 1078 + if (!selectedChannelId || selectedChannelId === 'friends') return; 1079 + 1080 + const missingReplyIds = currentMessages 1081 + .map((message) => getReplyIdFromMessage(message)) 1082 + .filter((replyId) => replyId && !replyMessageCacheRef.current[replyId] && !currentMessageMap[replyId]); 1083 + 1084 + [...new Set(missingReplyIds)].slice(0, 10).forEach((replyId) => { 1085 + void fetchReplyMessage(selectedChannelId, replyId); 1086 + }); 1087 + }, [currentMessages, currentMessageMap, selectedChannelId]); 1088 + 1089 + useEffect(() => { 1090 + if (!voiceNotice) return undefined; 1091 + const timeout = setTimeout(() => setVoiceNotice(''), 3500); 1092 + return () => clearTimeout(timeout); 1093 + }, [voiceNotice]); 667 1094 668 1095 const fetchMembers = async (serverId) => { 669 1096 if (serverId === '@me') return; ··· 707 1134 } 708 1135 }; 709 1136 710 - const enrichReplies = (nextMessages) => { 1137 + const resolveReplyPreview = (messageById, channelId, replyId) => { 1138 + const inBatch = messageById[replyId]; 1139 + if (inBatch) return inBatch; 1140 + 1141 + const inChannelState = (messages[channelId] || []).find((entry) => entry._id === replyId); 1142 + if (inChannelState) return inChannelState; 1143 + 1144 + return replyMessageCacheRef.current[replyId] || null; 1145 + }; 1146 + 1147 + const enrichReplies = (nextMessages = [], channelId = selectedChannelId) => { 711 1148 const messageById = Object.fromEntries(nextMessages.map((entry) => [entry._id, entry])); 712 1149 713 1150 return nextMessages.map((message) => { 714 - const replyId = Array.isArray(message.replies) ? message.replies[0] : null; 715 - const replyMessage = replyId ? messageById[replyId] : null; 716 - if (!replyMessage) return message; 1151 + const replyId = getReplyIdFromMessage(message); 1152 + if (!replyId) return { ...message, replyMessage: null }; 1153 + 1154 + const replyMessage = resolveReplyPreview(messageById, channelId, replyId); 1155 + if (!replyMessage) { 1156 + return { 1157 + ...message, 1158 + replyMessage: { 1159 + _id: replyId, 1160 + content: 'Loading reply…', 1161 + authorId: null, 1162 + authorUser: null, 1163 + }, 1164 + }; 1165 + } 717 1166 const replyAuthorId = typeof replyMessage.author === 'string' ? replyMessage.author : replyMessage.author?._id; 718 1167 719 1168 return { ··· 728 1177 }); 729 1178 }; 730 1179 1180 + const fetchReplyMessage = async (channelId, replyId) => { 1181 + if (!channelId || !replyId || replyMessageCacheRef.current[replyId]) return; 1182 + 1183 + try { 1184 + const res = await fetch(`${config.apiUrl}/channels/${channelId}/messages/${replyId}`, { 1185 + headers: { 'x-session-token': auth.token }, 1186 + }); 1187 + if (!res.ok) return; 1188 + const data = await res.json(); 1189 + if (!data?._id) return; 1190 + 1191 + replyMessageCacheRef.current[replyId] = data; 1192 + upsertUsersFromMessages([data]); 1193 + setMessages((prev) => ({ 1194 + ...prev, 1195 + [channelId]: enrichReplies(prev[channelId] || [], channelId), 1196 + })); 1197 + } catch { 1198 + // no-op 1199 + } 1200 + }; 1201 + 731 1202 const fetchMessages = async (channelId) => { 732 1203 if (!channelId || channelId === 'friends') return; 733 1204 ··· 747 1218 748 1219 upsertUsersFromMessages(payloadMessages); 749 1220 const orderedMessages = payloadMessages.reverse().slice(-200); 750 - setMessages((prev) => ({ ...prev, [channelId]: enrichReplies(orderedMessages) })); 1221 + setMessages((prev) => ({ ...prev, [channelId]: enrichReplies(orderedMessages, channelId) })); 751 1222 void fetchMissingUsers(orderedMessages); 752 1223 preloadedChannelRef.current[channelId] = true; 753 1224 } catch { ··· 784 1255 return; 785 1256 } 786 1257 787 - const firstChannel = Object.values(channels).find((ch) => ch.server === serverId); 1258 + const firstChannel = Object.values(channels).find((ch) => ch.server === serverId && ch.channel_type !== 'VoiceChannel') 1259 + || Object.values(channels).find((ch) => ch.server === serverId); 788 1260 setSelectedChannelId(firstChannel?._id || null); 789 1261 if (firstChannel?._id) fetchMessages(firstChannel._id); 790 1262 if (!preloadedMembersRef.current[serverId]) { ··· 794 1266 }; 795 1267 796 1268 const handleChannelSelect = (channelId) => { 1269 + const channel = channels[channelId]; 1270 + if (channel?.channel_type === 'VoiceChannel') { 1271 + setVoiceNotice("Ermine currently doesn't support voice channels."); 1272 + return; 1273 + } 1274 + 1275 + setVoiceNotice(''); 797 1276 setSelectedChannelId(channelId); 798 1277 setReplyingTo(null); 799 1278 setShowEmojiPicker(false); 800 1279 fetchMessages(channelId); 801 1280 }; 802 1281 1282 + const openDmWithUser = async (userId) => { 1283 + if (!userId || !auth.token) return; 1284 + 1285 + const existing = directMessageChannels.find((channel) => 1286 + Array.isArray(channel?.recipients) && channel.recipients.includes(userId), 1287 + ); 1288 + 1289 + if (existing?._id) { 1290 + setSelectedServerId('@me'); 1291 + handleChannelSelect(existing._id); 1292 + return; 1293 + } 1294 + 1295 + try { 1296 + const res = await fetch(`${config.apiUrl}/users/${userId}/dm`, { 1297 + method: 'GET', 1298 + headers: { 'x-session-token': auth.token }, 1299 + }); 1300 + 1301 + if (!res.ok) return; 1302 + const channel = await res.json(); 1303 + if (!channel?._id) return; 1304 + 1305 + setChannels((prev) => ({ ...prev, [channel._id]: channel })); 1306 + setSelectedServerId('@me'); 1307 + setSelectedChannelId(channel._id); 1308 + fetchMessages(channel._id); 1309 + } catch { 1310 + // no-op 1311 + } 1312 + }; 1313 + 1314 + const jumpToMessage = (messageId) => { 1315 + if (!messageId) return; 1316 + const target = messageRefs.current[messageId]; 1317 + if (!target) return; 1318 + target.scrollIntoView({ behavior: 'smooth', block: 'center' }); 1319 + target.classList.add('ring-2', 'ring-[#5865f2]', 'bg-[#3a3f66]/40'); 1320 + setTimeout(() => { 1321 + target.classList.remove('ring-2', 'ring-[#5865f2]', 'bg-[#3a3f66]/40'); 1322 + }, 1200); 1323 + }; 1324 + 1325 + const registerMessageRef = (messageId, node) => { 1326 + if (!messageId) return; 1327 + if (node) { 1328 + messageRefs.current[messageId] = node; 1329 + } else { 1330 + delete messageRefs.current[messageId]; 1331 + } 1332 + }; 1333 + 1334 + 1335 + const goToLatest = () => { 1336 + autoFollowRef.current = true; 1337 + messagesBottomRef.current?.scrollIntoView({ behavior: 'smooth' }); 1338 + setShowGoLatest(false); 1339 + }; 1340 + 1341 + const requestOpenLink = (url) => { 1342 + setLinkPromptUrl(url); 1343 + }; 1344 + 1345 + const confirmOpenLink = () => { 1346 + if (!linkPromptUrl) return; 1347 + window.open(linkPromptUrl, '_blank', 'noopener,noreferrer'); 1348 + setLinkPromptUrl(null); 1349 + }; 1350 + 1351 + const openStatusEditor = () => { 1352 + const currentPresence = users[auth.userId]?.status?.presence || 'Online'; 1353 + const currentText = users[auth.userId]?.status?.text || ''; 1354 + setStatusDraft({ presence: currentPresence, text: currentText }); 1355 + setActiveModal('set-status'); 1356 + }; 1357 + 1358 + const saveStatus = async () => { 1359 + setIsSavingStatus(true); 1360 + 1361 + setUsers((prev) => ({ 1362 + ...prev, 1363 + [auth.userId]: { 1364 + ...(prev[auth.userId] || {}), 1365 + status: { 1366 + presence: statusDraft.presence, 1367 + text: statusDraft.text || undefined, 1368 + }, 1369 + }, 1370 + })); 1371 + 1372 + try { 1373 + await fetch(`${config.apiUrl}/users/@me`, { 1374 + method: 'PATCH', 1375 + headers: { 1376 + 'Content-Type': 'application/json', 1377 + 'x-session-token': auth.token, 1378 + }, 1379 + body: JSON.stringify({ 1380 + status: { 1381 + presence: statusDraft.presence, 1382 + text: statusDraft.text || undefined, 1383 + }, 1384 + }), 1385 + }); 1386 + } catch { 1387 + // no-op 1388 + } finally { 1389 + setIsSavingStatus(false); 1390 + setActiveModal(null); 1391 + } 1392 + }; 1393 + 803 1394 const handleLogin = async (event) => { 804 1395 event.preventDefault(); 805 1396 setLoginError(''); ··· 877 1468 878 1469 const sendMessage = async () => { 879 1470 const content = inputText.trim(); 880 - if (!content || !selectedChannelId || selectedChannelId === 'friends') return; 1471 + const hasContent = Boolean(content); 1472 + const hasFiles = pendingFiles.length > 0; 1473 + if ((!hasContent && !hasFiles) || !selectedChannelId || selectedChannelId === 'friends') return; 881 1474 882 1475 setInputText(''); 1476 + setIsUploadingFiles(hasFiles); 1477 + 1478 + const nonce = crypto.randomUUID(); 1479 + const pendingId = `pending-${nonce}`; 1480 + const meUser = users[auth.userId] || { _id: auth.userId, username: 'You' }; 1481 + 1482 + const optimisticMessage = { 1483 + _id: pendingId, 1484 + channel: selectedChannelId, 1485 + author: auth.userId, 1486 + user: meUser, 1487 + content, 1488 + createdAt: new Date().toISOString(), 1489 + reactions: {}, 1490 + replies: replyingTo ? [{ id: replyingTo._id, mention: false }] : undefined, 1491 + attachments: pendingFiles.map((file, index) => ({ _id: `pending-attachment-${index}-${file.name}`, filename: file.name })), 1492 + }; 1493 + 1494 + setMessages((prev) => { 1495 + const list = prev[selectedChannelId] || []; 1496 + return { ...prev, [selectedChannelId]: enrichReplies([...list, optimisticMessage].slice(-200), selectedChannelId) }; 1497 + }); 883 1498 884 1499 try { 885 - await fetch(`${config.apiUrl}/channels/${selectedChannelId}/messages`, { 1500 + const attachmentIds = hasFiles ? await uploadFiles(pendingFiles) : []; 1501 + const payload = { 1502 + content, 1503 + nonce, 1504 + replies: replyingTo ? [{ id: replyingTo._id, mention: false }] : undefined, 1505 + attachments: attachmentIds.length ? attachmentIds : undefined, 1506 + }; 1507 + 1508 + const res = await fetch(`${config.apiUrl}/channels/${selectedChannelId}/messages`, { 886 1509 method: 'POST', 887 1510 headers: { 888 1511 'Content-Type': 'application/json', 889 1512 'x-session-token': auth.token, 890 1513 }, 891 - body: JSON.stringify({ 892 - content, 893 - nonce: crypto.randomUUID(), 894 - replies: replyingTo ? [replyingTo._id] : undefined, 895 - }), 1514 + body: JSON.stringify(payload), 896 1515 }); 1516 + 1517 + if (!res.ok) throw new Error('Failed to send message'); 1518 + const postedMessage = await res.json(); 1519 + 1520 + if (postedMessage?._id) { 1521 + upsertUsersFromMessages([postedMessage]); 1522 + setMessages((prev) => { 1523 + const list = (prev[selectedChannelId] || []).map((entry) => (entry._id === pendingId ? postedMessage : entry)); 1524 + return { ...prev, [selectedChannelId]: enrichReplies(list, selectedChannelId) }; 1525 + }); 1526 + } 1527 + 1528 + setPendingFiles([]); 897 1529 setReplyingTo(null); 898 1530 setShowEmojiPicker(false); 899 1531 } catch { 1532 + setMessages((prev) => ({ 1533 + ...prev, 1534 + [selectedChannelId]: (prev[selectedChannelId] || []).filter((entry) => entry._id !== pendingId), 1535 + })); 900 1536 setInputText(content); 1537 + } finally { 1538 + setIsUploadingFiles(false); 901 1539 } 902 1540 }; 903 1541 ··· 939 1577 setShowEmojiPicker(false); 940 1578 }; 941 1579 1580 + const addCustomEmojiToComposer = (emojiId) => { 1581 + setInputText((prev) => `${prev}:${emojiId}:`); 1582 + setShowEmojiPicker(false); 1583 + }; 1584 + 1585 + const handleFilePick = (event) => { 1586 + const files = Array.from(event.target.files || []); 1587 + if (!files.length) return; 1588 + setPendingFiles((prev) => [...prev, ...files]); 1589 + event.target.value = ''; 1590 + }; 1591 + 1592 + const handleComposerDrop = (event) => { 1593 + event.preventDefault(); 1594 + const files = Array.from(event.dataTransfer?.files || []); 1595 + if (!files.length) return; 1596 + setPendingFiles((prev) => [...prev, ...files]); 1597 + }; 1598 + 1599 + const handleComposerDragOver = (event) => { 1600 + event.preventDefault(); 1601 + }; 1602 + 1603 + const removePendingFile = (index) => { 1604 + setPendingFiles((prev) => prev.filter((_, fileIndex) => fileIndex !== index)); 1605 + }; 1606 + 1607 + const uploadFiles = async (files = []) => { 1608 + const ids = []; 1609 + 1610 + for (const file of files) { 1611 + const body = new FormData(); 1612 + body.append('file', file); 1613 + 1614 + const res = await fetch(`${config.cdnUrl}/attachments`, { 1615 + method: 'POST', 1616 + body, 1617 + headers: { 1618 + 'X-Session-Token': auth.token, 1619 + }, 1620 + }); 1621 + 1622 + if (!res.ok) { 1623 + throw new Error('Failed to upload file'); 1624 + } 1625 + 1626 + const data = await res.json(); 1627 + if (data?.id) ids.push(data.id); 1628 + } 1629 + 1630 + return ids; 1631 + }; 1632 + 942 1633 const createServer = async () => { 943 1634 if (!createServerName.trim()) return; 944 1635 ··· 963 1654 964 1655 const activeReply = replyingTo ? currentMessageMap[replyingTo._id] || replyingTo : null; 965 1656 966 - const currentChannelName = 967 - selectedChannelId === 'friends' ? 'friends' : channels[selectedChannelId]?.name || 'select-a-channel'; 1657 + const currentChannelName = (() => { 1658 + if (selectedChannelId === 'friends') return 'friends'; 1659 + const channel = channels[selectedChannelId]; 1660 + if (channel?.name) return channel.name; 1661 + if (channel?.channel_type === 'DirectMessage') { 1662 + const recipientId = (channel.recipients || []).find((id) => id !== auth.userId); 1663 + return users[recipientId]?.username || 'direct-message'; 1664 + } 1665 + return 'select-a-channel'; 1666 + })(); 968 1667 969 1668 if (view === 'loading') { 970 1669 return ( ··· 1060 1759 </Modal> 1061 1760 ) : null} 1062 1761 1762 + {activeModal === 'set-status' ? ( 1763 + <Modal onClose={() => setActiveModal(null)} title="Set your status"> 1764 + <label className="block text-xs font-semibold uppercase tracking-wide text-gray-400"> 1765 + Presence 1766 + <select className={`${inputBase} mt-1`} onChange={(event) => setStatusDraft((prev) => ({ ...prev, presence: event.target.value }))} value={statusDraft.presence}> 1767 + <option value="Online">Online</option> 1768 + <option value="Idle">Idle</option> 1769 + <option value="Busy">Busy</option> 1770 + <option value="Invisible">Invisible</option> 1771 + </select> 1772 + </label> 1773 + <label className="block text-xs font-semibold uppercase tracking-wide text-gray-400"> 1774 + Custom status 1775 + <input className={`${inputBase} mt-1`} maxLength={128} onChange={(event) => setStatusDraft((prev) => ({ ...prev, text: event.target.value }))} placeholder="What's up?" value={statusDraft.text} /> 1776 + </label> 1777 + <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"> 1778 + {isSavingStatus ? 'Saving…' : 'Save status'} 1779 + </button> 1780 + </Modal> 1781 + ) : null} 1782 + 1783 + {linkPromptUrl ? ( 1784 + <Modal onClose={() => setLinkPromptUrl(null)} title="Ermine link check"> 1785 + <div className="rounded-md bg-[#1e1f22] p-3 text-sm text-gray-200"> 1786 + <p className="text-xs uppercase tracking-wide text-gray-500">Do you trust this link?</p> 1787 + <p className="mt-2 break-all text-[#bdc3ff]">{linkPromptUrl}</p> 1788 + <p className="mt-2 text-xs text-gray-400">Ermine says: only open links from people and servers you trust.</p> 1789 + </div> 1790 + <div className="grid grid-cols-2 gap-2"> 1791 + <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> 1792 + <button className="rounded-md bg-[#5865f2] py-2 text-sm font-semibold text-white hover:bg-[#4956d8]" onClick={confirmOpenLink} type="button">Open link</button> 1793 + </div> 1794 + </Modal> 1795 + ) : null} 1796 + 1063 1797 {peekUser ? ( 1064 1798 <Modal onClose={() => setPeekUser(null)} title="Member profile"> 1065 - <div className="flex items-center gap-3 rounded-lg bg-[#1e1f22] p-3"> 1066 - <Avatar cdnUrl={config.cdnUrl} size="lg" user={peekUser} /> 1067 - <div> 1068 - <div className="text-base font-semibold text-white">{peekUser?.username || 'Unknown user'}</div> 1069 - <div className="text-xs text-gray-400">#{peekUser?.discriminator || '0000'}</div> 1799 + <div className="overflow-hidden rounded-lg bg-[#1e1f22]"> 1800 + {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]" />} 1801 + <div className="flex items-center gap-3 p-3"> 1802 + <Avatar alwaysAnimate cdnUrl={config.cdnUrl} size="lg" user={peekUser} /> 1803 + <div> 1804 + <div className="text-base font-semibold text-white">{peekUser?.username || 'Unknown user'}</div> 1805 + <div className="text-xs text-gray-400">#{peekUser?.discriminator || '0000'}</div> 1806 + </div> 1070 1807 </div> 1071 1808 </div> 1072 - <p className="text-xs text-gray-400">Ermine profile popout for stoat.chat members.</p> 1809 + 1810 + <div className="grid grid-cols-1 gap-2 rounded-md bg-[#1e1f22] p-3 text-xs text-gray-300"> 1811 + <p><span className="font-semibold text-gray-100">Joined platform:</span> {toDateLabel(peekPlatformJoined)}</p> 1812 + <p><span className="font-semibold text-gray-100">Joined server:</span> {selectedServerId === '@me' ? 'N/A' : toDateLabel(peekServerJoined)}</p> 1813 + </div> 1814 + 1815 + <div className="space-y-1 rounded-md bg-[#1e1f22] p-3"> 1816 + <p className="text-[11px] font-semibold uppercase tracking-wide text-gray-500">About me</p> 1817 + <p className="text-sm text-gray-200">{peekAboutMe || 'This user has not set an About Me yet.'}</p> 1818 + </div> 1819 + 1820 + <div className="space-y-1 rounded-md bg-[#1e1f22] p-3"> 1821 + <p className="text-[11px] font-semibold uppercase tracking-wide text-gray-500">Badges</p> 1822 + {peekBadges.length ? ( 1823 + <div className="flex flex-wrap gap-1.5"> 1824 + {peekBadges.map((badge) => ( 1825 + <span className="rounded bg-[#5865f2]/20 px-2 py-1 text-xs text-[#d7ddff]" key={badge}>{badge}</span> 1826 + ))} 1827 + </div> 1828 + ) : ( 1829 + <p className="text-xs text-gray-400">No badges</p> 1830 + )} 1831 + </div> 1832 + 1833 + <div className="space-y-1 rounded-md bg-[#1e1f22] p-3"> 1834 + <p className="text-[11px] font-semibold uppercase tracking-wide text-gray-500">Roles</p> 1835 + {peekRoles.length ? ( 1836 + <div className="flex flex-wrap gap-1.5"> 1837 + {peekRoles.map((role) => ( 1838 + <span className="rounded bg-[#3a3d42] px-2 py-1 text-xs text-gray-200" key={role.name}>{role.name}</span> 1839 + ))} 1840 + </div> 1841 + ) : ( 1842 + <p className="text-xs text-gray-400">No server roles</p> 1843 + )} 1844 + </div> 1073 1845 </Modal> 1074 1846 ) : null} 1075 1847 ··· 1101 1873 </div> 1102 1874 <div className="flex-1 overflow-y-auto p-2"> 1103 1875 {selectedServerId === '@me' ? ( 1104 - <button className={`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')}> 1105 - <Users size={16} /> Friends 1106 - </button> 1876 + <> 1877 + <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')}> 1878 + <Users size={16} /> Friends 1879 + </button> 1880 + {directMessageChannels.map((channel) => { 1881 + const recipientId = (channel.recipients || []).find((id) => id !== auth.userId); 1882 + const recipient = users[recipientId]; 1883 + const label = recipient?.username || channel.name || 'Direct Message'; 1884 + return ( 1885 + <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)}> 1886 + <MessageSquare size={16} /> 1887 + <span className="truncate">{label}</span> 1888 + </button> 1889 + ); 1890 + })} 1891 + </> 1107 1892 ) : ( 1108 - channelList.map((channel) => ( 1109 - <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)}> 1110 - <Hash size={16} /> 1111 - <span className="truncate">{channel.name || 'channel'}</span> 1112 - </button> 1113 - )) 1893 + channelList.map((channel) => { 1894 + const isVoice = channel?.channel_type === 'VoiceChannel'; 1895 + return ( 1896 + <button 1897 + 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'}`} 1898 + key={channel._id} 1899 + onClick={() => handleChannelSelect(channel._id)} 1900 + title={isVoice ? "Ermine currently doesn't support voice channels." : channel.name} 1901 + > 1902 + {isVoice ? <Users size={16} /> : <Hash size={16} />} 1903 + <span className="truncate">{channel.name || 'channel'}</span> 1904 + </button> 1905 + ); 1906 + }) 1114 1907 )} 1115 1908 </div> 1116 1909 1117 1910 <div className="border-t border-[#202225] p-2"> 1118 - <div className="flex items-center justify-between rounded bg-[#232428] p-2"> 1119 - <div className="flex items-center gap-2"> 1120 - <Avatar cdnUrl={config.cdnUrl} size="sm" user={users[auth.userId]} /> 1911 + <div className="flex items-center justify-between rounded bg-[#232428] p-2" onMouseEnter={() => setIsAccountHovered(true)} onMouseLeave={() => setIsAccountHovered(false)}> 1912 + <button className="flex min-w-0 items-center gap-2 rounded p-1 text-left hover:bg-[#2d3036]" onClick={openStatusEditor} type="button"> 1913 + <Avatar alwaysAnimate={isAccountHovered} cdnUrl={config.cdnUrl} size="sm" user={users[auth.userId]} /> 1121 1914 <div className="min-w-0"> 1122 1915 <div className="truncate text-xs font-semibold text-white">{users[auth.userId]?.username || 'Connected'}</div> 1123 - <div className="text-[10px] uppercase tracking-wide text-gray-400">{status}</div> 1916 + <div className="truncate text-[10px] uppercase tracking-wide text-gray-400">{users[auth.userId]?.status?.text || status}</div> 1124 1917 </div> 1125 - </div> 1918 + </button> 1126 1919 <button className="rounded p-1 text-gray-400 hover:bg-[#35373c] hover:text-white" onClick={logout} title="Logout"> 1127 1920 <LogOut size={14} /> 1128 1921 </button> ··· 1130 1923 </div> 1131 1924 </aside> 1132 1925 1133 - <main className="flex min-w-0 flex-1 flex-col bg-[#313338]"> 1926 + <main className="relative flex min-w-0 flex-1 flex-col bg-[#313338]"> 1134 1927 <header className="flex items-center justify-between border-b border-[#202225] px-4 py-3"> 1135 1928 <div className="flex items-center gap-2 text-sm font-semibold text-white"> 1136 - <Hash size={17} className="text-gray-400" /> 1929 + {channels[selectedChannelId]?.channel_type === 'DirectMessage' ? <MessageSquare size={17} className="text-gray-400" /> : <Hash size={17} className="text-gray-400" />} 1137 1930 <span>{currentChannelName}</span> 1138 1931 </div> 1139 1932 <div className="text-xs text-gray-400">Ermine for stoat.chat</div> 1140 1933 </header> 1141 1934 1142 - <section className="flex-1 overflow-y-auto py-2"> 1935 + {voiceNotice ? ( 1936 + <div className="mx-4 mt-3 rounded-md border border-[#665200] bg-[#5c4a00]/25 px-3 py-2 text-xs text-yellow-200"> 1937 + {voiceNotice} 1938 + </div> 1939 + ) : null} 1940 + 1941 + <section className="flex-1 overflow-y-auto py-2" ref={messagesContainerRef}> 1143 1942 {selectedChannelId === 'friends' ? ( 1144 1943 <div className="space-y-2 p-4"> 1145 1944 <h2 className="text-xs font-bold uppercase tracking-wide text-gray-400">Friends ({friends.length})</h2> 1146 1945 {friends.length === 0 ? <p className="text-sm text-gray-400">No friends available yet.</p> : null} 1147 1946 {friends.map((friend) => ( 1148 - <button className="flex w-full items-center gap-3 rounded bg-[#2b2d31] p-3 text-left hover:bg-[#35373c]" key={friend._id} onClick={() => openUserProfile(friend, friend._id)}> 1149 - <Avatar cdnUrl={config.cdnUrl} user={friend} /> 1947 + <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)}> 1948 + <Avatar animateOnHover cdnUrl={config.cdnUrl} user={friend} /> 1150 1949 <div className="min-w-0"> 1151 1950 <div className="truncate text-sm font-semibold text-white">{friend.username}</div> 1152 1951 <div className="text-xs text-gray-400">Direct message</div> ··· 1165 1964 onReply={setReplyingTo} 1166 1965 onToggleReaction={toggleReaction} 1167 1966 onUserClick={openUserProfile} 1967 + onJumpToMessage={jumpToMessage} 1968 + onRequestOpenLink={requestOpenLink} 1969 + reactionOptions={reactionOptions} 1970 + registerMessageRef={registerMessageRef} 1168 1971 replyTarget={replyingTo?._id} 1972 + customEmojiById={customEmojiById} 1169 1973 users={users} 1170 1974 /> 1171 1975 )) ··· 1173 1977 <div ref={messagesBottomRef} /> 1174 1978 </section> 1175 1979 1980 + {showGoLatest ? ( 1981 + <div className="pointer-events-none absolute bottom-24 left-1/2 z-20 -translate-x-1/2"> 1982 + <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"> 1983 + Go to latest 1984 + </button> 1985 + </div> 1986 + ) : null} 1987 + 1176 1988 <footer className="border-t border-[#202225] p-4"> 1177 1989 {activeReply ? ( 1178 1990 <div className="mb-2 flex items-center justify-between gap-2 rounded-md bg-[#2b2d31] px-3 py-2 text-xs text-gray-300"> ··· 1184 1996 </button> 1185 1997 </div> 1186 1998 ) : null} 1187 - <div className="flex items-center gap-2 rounded-lg bg-[#383a40] p-2"> 1999 + {pendingFiles.length ? ( 2000 + <div className="mb-2 flex flex-wrap gap-1.5"> 2001 + {pendingFiles.map((file, index) => { 2002 + const previewImage = isImageLike(file.name, file.type); 2003 + return ( 2004 + <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"> 2005 + <span>{previewImage ? '🖼️' : '📎'}</span> 2006 + <span>{file.name} <span className="text-gray-400">(remove)</span></span> 2007 + </button> 2008 + ); 2009 + })} 2010 + </div> 2011 + ) : null} 2012 + 2013 + <div className="flex items-center gap-2 rounded-lg bg-[#383a40] p-2" onDragOver={handleComposerDragOver} onDrop={handleComposerDrop}> 2014 + <button 2015 + className="rounded p-2 text-gray-300 hover:bg-[#4b4d55] hover:text-white" 2016 + disabled={selectedChannelId === 'friends' || isUploadingFiles} 2017 + onClick={() => fileInputRef.current?.click()} 2018 + title="Upload files" 2019 + type="button" 2020 + > 2021 + <Paperclip size={16} /> 2022 + </button> 2023 + <input className="hidden" multiple onChange={handleFilePick} ref={fileInputRef} type="file" /> 1188 2024 <div className="relative"> 1189 2025 <button 1190 2026 className="rounded p-2 text-gray-300 hover:bg-[#4b4d55] hover:text-white" ··· 1195 2031 <Smile size={16} /> 1196 2032 </button> 1197 2033 {showEmojiPicker && selectedChannelId !== 'friends' ? ( 1198 - <div className="absolute bottom-12 left-0 z-20 grid w-44 grid-cols-3 gap-1 rounded-lg border border-[#4c4f56] bg-[#232428] p-2 shadow-2xl"> 1199 - {QUICK_EMOJIS.map((emoji) => ( 1200 - <button className="rounded p-1.5 text-lg hover:bg-[#3a3d42]" key={emoji} onClick={() => addEmojiToComposer(emoji)} type="button"> 1201 - {emoji} 1202 - </button> 1203 - ))} 2034 + <div className="absolute bottom-12 left-0 z-20 w-72 rounded-lg border border-[#4c4f56] bg-[#232428] p-2 shadow-2xl"> 2035 + <p className="mb-1 text-[10px] uppercase tracking-wide text-gray-500">Emoji</p> 2036 + <div className="grid max-h-36 grid-cols-8 gap-1 overflow-y-auto"> 2037 + {EXTENDED_EMOJIS.map((emoji) => ( 2038 + <button className="rounded p-1 text-lg hover:bg-[#3a3d42]" key={emoji} onClick={() => addEmojiToComposer(emoji)} type="button"> 2039 + {emoji} 2040 + </button> 2041 + ))} 2042 + </div> 2043 + {customEmojis.length ? ( 2044 + <> 2045 + <p className="mb-1 mt-2 text-[10px] uppercase tracking-wide text-gray-500">Server emojis</p> 2046 + <div className="flex max-h-24 flex-wrap gap-1 overflow-y-auto"> 2047 + {customEmojis.map((emoji) => { 2048 + const custom = customEmojiById[emoji.id]; 2049 + const source = custom?.isPrivate ? 'Private server emoji' : custom?.serverName ? `Server emoji from ${custom.serverName}` : 'Custom emoji'; 2050 + return ( 2051 + <button className="flex items-center gap-1 rounded bg-[#303239] px-1.5 py-1 text-xs text-gray-200 hover:bg-[#3a3d42]" key={emoji.id || emoji.name} onClick={() => addCustomEmojiToComposer(emoji.id)} title={`${emoji.name} · ${source}`} type="button"> 2052 + {renderEmojiVisual(`:${emoji.id}:`, custom, config.cdnUrl)} 2053 + <span>:{emoji.name}:</span> 2054 + </button> 2055 + ); 2056 + })} 2057 + </div> 2058 + </> 2059 + ) : null} 1204 2060 </div> 1205 2061 ) : null} 1206 2062 </div> ··· 1208 2064 className="flex-1 bg-transparent px-2 py-1 text-sm text-gray-100 placeholder:text-gray-400 focus:outline-none" 1209 2065 disabled={selectedChannelId === 'friends'} 1210 2066 onChange={(e) => setInputText(e.target.value)} 1211 - onKeyDown={(e) => e.key === 'Enter' && sendMessage()} 1212 - placeholder={selectedChannelId === 'friends' ? 'Select a text channel to chat.' : `Message #${currentChannelName}`} 2067 + onKeyDown={(e) => { 2068 + if (e.key === 'Enter' && !e.shiftKey) { 2069 + e.preventDefault(); 2070 + sendMessage(); 2071 + } 2072 + }} 2073 + placeholder={selectedChannelId === 'friends' ? 'Select a text channel to chat.' : `Enter message in #${currentChannelName}`} 1213 2074 value={inputText} 1214 2075 /> 1215 - <button className="rounded p-2 text-gray-300 hover:bg-[#4b4d55] hover:text-white" onClick={sendMessage} type="button"> 2076 + <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"> 1216 2077 <Send size={16} /> 1217 2078 </button> 1218 2079 </div> ··· 1227 2088 {hiddenMembersCount > 0 ? <p className="px-2 py-1 text-[11px] text-gray-500">Showing first {currentMembers.length} members for responsiveness.</p> : null} 1228 2089 {currentMembers.map((member) => ( 1229 2090 <button className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left hover:bg-[#35373c]" key={member._id.user} onClick={() => openUserProfile(member.user, member._id.user)}> 1230 - <Avatar cdnUrl={config.cdnUrl} size="sm" user={member.user} /> 2091 + <Avatar animateOnHover cdnUrl={config.cdnUrl} size="sm" user={member.user} /> 1231 2092 <div className="min-w-0"> 1232 2093 <div className="truncate text-sm text-gray-200">{member.nickname || member.user.username}</div> 1233 2094 <div className="flex items-center gap-1 text-[11px] text-gray-500"> ··· 1249 2110 </AppErrorBoundary> 1250 2111 ); 1251 2112 } 1252 -