[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 #9 from FascinatingPistachio/codex/fix-avatar-cropping-issue-and-document-events

Improve avatar clipping and websocket event handling

authored by

Fascinating Pistachio and committed by
GitHub
(Feb 14, 2026, 12:16 PM UTC) 547ab892 9a781294

+174 -87
+174 -87
src/App.jsx
··· 57 57 const src = getAvatarUrl(user, cdnUrl); 58 58 59 59 return ( 60 - <div className={`grid place-items-center rounded-full bg-[#313338] font-semibold text-gray-200 ${sizeMap[size]}`}> 61 - {src ? <img alt={user?.username || 'User avatar'} className="h-full w-full rounded-full object-cover" src={src} /> : initials} 60 + <div className={`grid shrink-0 place-items-center overflow-hidden rounded-full bg-[#313338] font-semibold text-gray-200 ${sizeMap[size]}`}> 61 + {src ? <img alt={user?.username || 'User avatar'} className="block h-full w-full object-cover object-center" src={src} /> : initials} 62 62 </div> 63 63 ); 64 64 }; 65 65 66 + const withoutClearedFields = (base, clear = []) => { 67 + if (!clear?.length) return base; 68 + const next = { ...base }; 69 + clear.forEach((field) => { 70 + const key = field?.[0]?.toLowerCase() + field?.slice(1); 71 + if (key) delete next[key]; 72 + }); 73 + return next; 74 + }; 75 + 66 76 const Modal = ({ title, onClose, children }) => ( 67 77 <div className="fixed inset-0 z-50 grid place-items-center bg-black/70 p-4 backdrop-blur-sm"> 68 78 <div className="w-full max-w-md overflow-hidden rounded-xl border border-[#202225] bg-[#2b2d31] shadow-2xl"> ··· 132 142 133 143 const wsRef = useRef(null); 134 144 const messagesBottomRef = useRef(null); 145 + const subscriptionRef = useRef({}); 135 146 136 147 const serverList = useMemo(() => Object.values(servers), [servers]); 137 148 ··· 199 210 } 200 211 }, []); 201 212 213 + const applyEvent = (packet) => { 214 + if (!packet?.type) return; 215 + 216 + switch (packet.type) { 217 + case 'Ready': 218 + setUsers((prev) => { 219 + const next = { ...prev }; 220 + packet.users?.forEach((u) => { 221 + next[u._id] = u; 222 + }); 223 + return next; 224 + }); 225 + setServers((prev) => { 226 + const next = { ...prev }; 227 + packet.servers?.forEach((s) => { 228 + next[s._id] = s; 229 + }); 230 + return next; 231 + }); 232 + setChannels((prev) => { 233 + const next = { ...prev }; 234 + packet.channels?.forEach((c) => { 235 + next[c._id] = c; 236 + }); 237 + return next; 238 + }); 239 + setMembers((prev) => { 240 + const next = { ...prev }; 241 + packet.members?.forEach((m) => { 242 + next[`${m._id.server}:${m._id.user}`] = m; 243 + }); 244 + return next; 245 + }); 246 + setStatus('ready'); 247 + break; 248 + case 'Bulk': 249 + packet.v?.forEach(applyEvent); 250 + break; 251 + case 'Authenticated': 252 + setStatus('authenticated'); 253 + break; 254 + case 'Error': 255 + setStatus(`error:${packet.error || 'unknown'}`); 256 + break; 257 + case 'Logout': 258 + logout(); 259 + break; 260 + case 'Message': 261 + if (packet.user?._id) { 262 + setUsers((prev) => ({ ...prev, [packet.user._id]: packet.user })); 263 + } 264 + if (typeof packet.author === 'object' && packet.author?._id) { 265 + setUsers((prev) => ({ ...prev, [packet.author._id]: packet.author })); 266 + } 267 + setMessages((prev) => { 268 + const list = prev[packet.channel] || []; 269 + if (list.find((m) => m._id === packet._id)) return prev; 270 + return { ...prev, [packet.channel]: [...list, packet] }; 271 + }); 272 + break; 273 + case 'MessageUpdate': 274 + setMessages((prev) => ({ 275 + ...prev, 276 + [packet.channel]: (prev[packet.channel] || []).map((m) => (m._id === packet.id ? { ...m, ...packet.data } : m)), 277 + })); 278 + break; 279 + case 'MessageDelete': 280 + setMessages((prev) => ({ 281 + ...prev, 282 + [packet.channel]: (prev[packet.channel] || []).filter((m) => m._id !== packet.id), 283 + })); 284 + break; 285 + case 'ChannelCreate': 286 + setChannels((prev) => ({ ...prev, [packet._id]: packet })); 287 + break; 288 + case 'ChannelUpdate': 289 + setChannels((prev) => ({ 290 + ...prev, 291 + [packet.id]: withoutClearedFields({ ...prev[packet.id], ...packet.data }, packet.clear), 292 + })); 293 + break; 294 + case 'ChannelDelete': 295 + setChannels((prev) => { 296 + const next = { ...prev }; 297 + delete next[packet.id]; 298 + return next; 299 + }); 300 + break; 301 + case 'ServerCreate': 302 + setServers((prev) => ({ ...prev, [packet._id]: packet })); 303 + break; 304 + case 'ServerUpdate': 305 + setServers((prev) => ({ 306 + ...prev, 307 + [packet.id]: withoutClearedFields({ ...prev[packet.id], ...packet.data }, packet.clear), 308 + })); 309 + break; 310 + case 'ServerDelete': 311 + setServers((prev) => { 312 + const next = { ...prev }; 313 + delete next[packet.id]; 314 + return next; 315 + }); 316 + break; 317 + case 'ServerMemberUpdate': { 318 + const key = `${packet.id.server}:${packet.id.user}`; 319 + setMembers((prev) => ({ 320 + ...prev, 321 + [key]: withoutClearedFields({ ...prev[key], _id: packet.id, ...packet.data }, packet.clear), 322 + })); 323 + break; 324 + } 325 + case 'ServerMemberJoin': 326 + setMembers((prev) => ({ ...prev, [`${packet.id}:${packet.user}`]: { ...packet.member, _id: { server: packet.id, user: packet.user } } })); 327 + break; 328 + case 'ServerMemberLeave': 329 + setMembers((prev) => { 330 + const next = { ...prev }; 331 + delete next[`${packet.id}:${packet.user}`]; 332 + return next; 333 + }); 334 + break; 335 + case 'UserUpdate': 336 + setUsers((prev) => ({ 337 + ...prev, 338 + [packet.id]: withoutClearedFields({ ...prev[packet.id], ...packet.data }, packet.clear), 339 + })); 340 + break; 341 + default: 342 + } 343 + }; 344 + 202 345 useEffect(() => { 203 346 if (!auth.token || view !== 'app') return undefined; 204 347 ··· 222 365 socket.onerror = () => setStatus('error'); 223 366 socket.onclose = () => setStatus((prev) => (prev === 'error' ? prev : 'disconnected')); 224 367 225 - socket.onmessage = (event) => { 226 - const packet = JSON.parse(event.data); 227 - 228 - switch (packet.type) { 229 - case 'Ready': 230 - setUsers((prev) => { 231 - const next = { ...prev }; 232 - packet.users?.forEach((u) => { 233 - next[u._id] = u; 234 - }); 235 - return next; 236 - }); 237 - setServers((prev) => { 238 - const next = { ...prev }; 239 - packet.servers?.forEach((s) => { 240 - next[s._id] = s; 241 - }); 242 - return next; 243 - }); 244 - setChannels((prev) => { 245 - const next = { ...prev }; 246 - packet.channels?.forEach((c) => { 247 - next[c._id] = c; 248 - }); 249 - return next; 250 - }); 251 - setMembers((prev) => { 252 - const next = { ...prev }; 253 - packet.members?.forEach((m) => { 254 - next[`${m._id.server}:${m._id.user}`] = m; 255 - }); 256 - return next; 257 - }); 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 - }); 276 - break; 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 - } 284 - setMessages((prev) => { 285 - const list = prev[packet.channel] || []; 286 - if (list.find((m) => m._id === packet._id)) return prev; 287 - return { ...prev, [packet.channel]: [...list, packet] }; 288 - }); 289 - break; 290 - case 'MessageUpdate': 291 - setMessages((prev) => ({ 292 - ...prev, 293 - [packet.channelId]: (prev[packet.channelId] || []).map((m) => 294 - m._id === packet.id ? { ...m, ...packet.data } : m, 295 - ), 296 - })); 297 - break; 298 - case 'MessageDelete': 299 - setMessages((prev) => ({ 300 - ...prev, 301 - [packet.channelId]: (prev[packet.channelId] || []).filter((m) => m._id !== packet.id), 302 - })); 303 - break; 304 - case 'UserUpdate': 305 - setUsers((prev) => ({ ...prev, [packet.id]: { ...prev[packet.id], ...packet.data } })); 306 - break; 307 - default: 308 - } 309 - }; 368 + socket.onmessage = (event) => applyEvent(JSON.parse(event.data)); 310 369 311 370 const heartbeat = setInterval(() => { 312 371 if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ type: 'Ping', data: Date.now() })); ··· 317 376 socket.close(); 318 377 }; 319 378 }, [auth.token, config.wsUrl, view]); 379 + 380 + useEffect(() => { 381 + const socket = wsRef.current; 382 + if (!socket || socket.readyState !== WebSocket.OPEN || selectedServerId === '@me') return undefined; 383 + 384 + const sendSubscribe = () => { 385 + if (document.visibilityState !== 'visible') return; 386 + if (!window.document.hasFocus()) return; 387 + 388 + const lastSent = subscriptionRef.current[selectedServerId] || 0; 389 + const tenMinutes = 10 * 60 * 1000; 390 + if (Date.now() - lastSent < tenMinutes) return; 391 + 392 + socket.send(JSON.stringify({ type: 'Subscribe', server_id: selectedServerId })); 393 + subscriptionRef.current[selectedServerId] = Date.now(); 394 + }; 395 + 396 + sendSubscribe(); 397 + window.addEventListener('focus', sendSubscribe); 398 + document.addEventListener('visibilitychange', sendSubscribe); 399 + const interval = setInterval(sendSubscribe, 60 * 1000); 400 + 401 + return () => { 402 + clearInterval(interval); 403 + window.removeEventListener('focus', sendSubscribe); 404 + document.removeEventListener('visibilitychange', sendSubscribe); 405 + }; 406 + }, [selectedServerId, status]); 320 407 321 408 useEffect(() => { 322 409 messagesBottomRef.current?.scrollIntoView({ behavior: 'smooth' });