[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 #8 from FascinatingPistachio/codex/fix-member-display-issues-and-crashes

Codex-generated pull request

authored by

Fascinating Pistachio and committed by
GitHub
(Feb 14, 2026, 12:10 PM UTC) 9a781294 7b695960

+61 -13
+1 -1
README.md
··· 1 1 # Ermine 2 2 3 - Ermine is a Discord-inspired web client for **stoat.chat** with custom branding, a cleaner layout, and a more polished day-to-day chat experience. 3 + Ermine is a Discord-inspired web client for **stoat.chat** with custom branding and a cleaner layout. It is currently **experimental**, known to be **buggy**, and still missing many expected features. 4 4 5 5 ## Highlights 6 6
+60 -12
src/App.jsx
··· 78 78 ); 79 79 80 80 const Message = ({ message, users, me, onUserClick, cdnUrl }) => { 81 - const author = users[message.author] || { username: 'Unknown user' }; 82 - const mine = me === message.author; 81 + const authorId = typeof message.author === 'string' ? message.author : message.author?._id; 82 + const author = users[authorId] || (typeof message.author === 'object' ? message.author : null) || { username: 'Unknown user' }; 83 + const mine = me === authorId; 83 84 84 85 return ( 85 86 <article className="group flex gap-3 rounded px-4 py-2 hover:bg-[#2e3035]"> 86 - <button className="mt-0.5" onClick={() => onUserClick(author)}> 87 + <button className="mt-0.5" onClick={() => onUserClick(author, authorId)}> 87 88 <Avatar cdnUrl={cdnUrl} user={author} /> 88 89 </button> 89 90 <div className="min-w-0 flex-1"> 90 91 <div className="flex items-baseline gap-2"> 91 - <button className="text-sm font-semibold text-white hover:underline" onClick={() => onUserClick(author)}> 92 + <button className="text-sm font-semibold text-white hover:underline" onClick={() => onUserClick(author, authorId)}> 92 93 {author.username} 93 94 </button> 94 95 {mine && <span className="rounded bg-[#5865f2]/20 px-1.5 py-0.5 text-[10px] font-semibold text-[#bdc3ff]">YOU</span>} ··· 150 151 .sort((a, b) => a.user.username.localeCompare(b.user.username)); 151 152 }, [members, selectedServerId, users]); 152 153 154 + const openUserProfile = (user, fallbackId = null) => { 155 + const stableId = user?._id || fallbackId || 'unknown-user'; 156 + setPeekUser({ 157 + ...user, 158 + _id: stableId, 159 + username: user?.username || 'Unknown user', 160 + discriminator: user?.discriminator || '0000', 161 + }); 162 + }; 163 + 153 164 const friends = useMemo( 154 165 () => Object.values(users).filter((u) => u.relationship === 'Friend' || u.relationship === 1), 155 166 [users], ··· 245 256 return next; 246 257 }); 247 258 setStatus('ready'); 259 + break; 260 + case 'Bulk': 261 + packet.v?.forEach((event) => { 262 + if (event.type === 'UserUpdate') { 263 + setUsers((prev) => ({ ...prev, [event.id]: { ...prev[event.id], ...event.data } })); 264 + } 265 + if (event.type === 'ServerMemberUpdate' && event.data) { 266 + setMembers((prev) => ({ 267 + ...prev, 268 + [`${event.id.server}:${event.id.user}`]: { 269 + ...prev[`${event.id.server}:${event.id.user}`], 270 + _id: event.id, 271 + ...event.data, 272 + }, 273 + })); 274 + } 275 + }); 248 276 break; 249 277 case 'Message': 278 + if (packet.user?._id) { 279 + setUsers((prev) => ({ ...prev, [packet.user._id]: packet.user })); 280 + } 281 + if (typeof packet.author === 'object' && packet.author?._id) { 282 + setUsers((prev) => ({ ...prev, [packet.author._id]: packet.author })); 283 + } 250 284 setMessages((prev) => { 251 285 const list = prev[packet.channel] || []; 252 286 if (list.find((m) => m._id === packet._id)) return prev; ··· 326 360 }); 327 361 if (!res.ok) return; 328 362 const data = await res.json(); 329 - setMessages((prev) => ({ ...prev, [channelId]: Array.isArray(data) ? data.reverse() : [] })); 363 + 364 + const payloadMessages = Array.isArray(data) ? data : data.messages || []; 365 + const payloadUsers = Array.isArray(data) ? [] : data.users || []; 366 + 367 + if (payloadUsers.length) { 368 + setUsers((prev) => { 369 + const next = { ...prev }; 370 + payloadUsers.forEach((u) => { 371 + next[u._id] = u; 372 + }); 373 + return next; 374 + }); 375 + } 376 + 377 + setMessages((prev) => ({ ...prev, [channelId]: payloadMessages.reverse() })); 330 378 } catch { 331 379 // no-op 332 380 } ··· 558 606 <div className="flex items-center gap-3 rounded-lg bg-[#1e1f22] p-3"> 559 607 <Avatar cdnUrl={config.cdnUrl} size="lg" user={peekUser} /> 560 608 <div> 561 - <div className="text-base font-semibold text-white">{peekUser.username}</div> 562 - <div className="text-xs text-gray-400">#{peekUser.discriminator || '0000'}</div> 609 + <div className="text-base font-semibold text-white">{peekUser?.username || 'Unknown user'}</div> 610 + <div className="text-xs text-gray-400">#{peekUser?.discriminator || '0000'}</div> 563 611 </div> 564 612 </div> 565 613 <p className="text-xs text-gray-400">Ermine profile popout for stoat.chat members.</p> ··· 638 686 <h2 className="text-xs font-bold uppercase tracking-wide text-gray-400">Friends ({friends.length})</h2> 639 687 {friends.length === 0 ? <p className="text-sm text-gray-400">No friends available yet.</p> : null} 640 688 {friends.map((friend) => ( 641 - <button className="flex w-full items-center gap-3 rounded bg-[#2b2d31] p-3 text-left hover:bg-[#35373c]" key={friend._id} onClick={() => setPeekUser(friend)}> 689 + <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)}> 642 690 <Avatar cdnUrl={config.cdnUrl} user={friend} /> 643 691 <div className="min-w-0"> 644 692 <div className="truncate text-sm font-semibold text-white">{friend.username}</div> ··· 649 697 </div> 650 698 ) : ( 651 699 currentMessages.map((message) => ( 652 - <Message cdnUrl={config.cdnUrl} key={message._id} me={auth.userId} message={message} onUserClick={setPeekUser} users={users} /> 700 + <Message cdnUrl={config.cdnUrl} key={message._id} me={auth.userId} message={message} onUserClick={openUserProfile} users={users} /> 653 701 )) 654 702 )} 655 703 <div ref={messagesBottomRef} /> ··· 672 720 </footer> 673 721 </main> 674 722 675 - <aside className="hidden w-60 border-l border-[#202225] bg-[#2b2d31] lg:block"> 723 + <aside className="hidden w-60 flex-col border-l border-[#202225] bg-[#2b2d31] lg:flex"> 676 724 <div className="border-b border-[#202225] px-4 py-3 text-xs font-bold uppercase tracking-wide text-gray-400">Members — {currentMembers.length}</div> 677 - <div className="space-y-1 overflow-y-auto p-2"> 725 + <div className="min-h-0 flex-1 space-y-1 overflow-y-auto p-2"> 678 726 {currentMembers.map((member) => ( 679 - <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={() => setPeekUser(member.user)}> 727 + <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)}> 680 728 <Avatar cdnUrl={config.cdnUrl} size="sm" user={member.user} /> 681 729 <div className="min-w-0"> 682 730 <div className="truncate text-sm text-gray-200">{member.nickname || member.user.username}</div>