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

Improve responsiveness and resolve unknown message authors

Fascinating Pistachio (Feb 14, 2026, 2:53 PM UTC) fa3b3e81 8ea19904

+72 -18
+72 -18
src/App.jsx
··· 292 292 const subscriptionRef = useRef({}); 293 293 const preloadedChannelRef = useRef({}); 294 294 const preloadedMembersRef = useRef({}); 295 + const pendingUserFetchRef = useRef(new Set()); 295 296 296 297 const serverList = useMemo(() => Object.values(servers), [servers]); 297 298 ··· 327 328 [users], 328 329 ); 329 330 331 + const upsertUsers = (list = []) => { 332 + if (!list.length) return; 333 + setUsers((prev) => { 334 + const next = { ...prev }; 335 + list.forEach((user) => { 336 + if (user?._id) next[user._id] = user; 337 + }); 338 + return next; 339 + }); 340 + }; 341 + 342 + const upsertUsersFromMessages = (messageList = []) => { 343 + const embeddedUsers = []; 344 + 345 + messageList.forEach((entry) => { 346 + const author = entry?.author; 347 + if (author && typeof author === 'object' && author._id) embeddedUsers.push(author); 348 + if (entry?.user?._id) embeddedUsers.push(entry.user); 349 + }); 350 + 351 + upsertUsers(embeddedUsers); 352 + }; 353 + 354 + const fetchMissingUsers = async (messageList = []) => { 355 + const unresolved = new Set(); 356 + 357 + messageList.forEach((entry) => { 358 + const authorId = typeof entry?.author === 'string' ? entry.author : entry?.author?._id; 359 + if (!authorId || users[authorId] || pendingUserFetchRef.current.has(authorId)) return; 360 + unresolved.add(authorId); 361 + }); 362 + 363 + if (!unresolved.size) return; 364 + 365 + unresolved.forEach((id) => pendingUserFetchRef.current.add(id)); 366 + 367 + await Promise.all( 368 + [...unresolved].map(async (userId) => { 369 + try { 370 + const res = await fetch(`${config.apiUrl}/users/${userId}`, { 371 + headers: { 'x-session-token': auth.token }, 372 + }); 373 + if (!res.ok) return; 374 + const data = await res.json(); 375 + if (data?._id) upsertUsers([data]); 376 + } catch { 377 + // no-op 378 + } finally { 379 + pendingUserFetchRef.current.delete(userId); 380 + } 381 + }), 382 + ); 383 + }; 384 + 330 385 const discoverConfig = async (apiUrl) => { 331 386 try { 332 387 const res = await fetch(apiUrl, { headers: { Accept: 'application/json' } }); ··· 408 463 logout(); 409 464 break; 410 465 case 'Message': 411 - if (packet.user?._id) { 412 - setUsers((prev) => ({ ...prev, [packet.user._id]: packet.user })); 413 - } 414 - if (typeof packet.author === 'object' && packet.author?._id) { 415 - setUsers((prev) => ({ ...prev, [packet.author._id]: packet.author })); 416 - } 466 + upsertUsersFromMessages([packet]); 467 + void fetchMissingUsers([packet]); 417 468 setMessages((prev) => { 418 469 const list = prev[packet.channel] || []; 419 470 if (list.find((m) => m._id === packet._id)) return prev; ··· 589 640 } 590 641 }; 591 642 592 - const enrichReplies = (nextMessages) => 593 - nextMessages.map((message) => { 643 + const enrichReplies = (nextMessages) => { 644 + const messageById = Object.fromEntries(nextMessages.map((entry) => [entry._id, entry])); 645 + 646 + return nextMessages.map((message) => { 594 647 const replyId = Array.isArray(message.replies) ? message.replies[0] : null; 595 - const replyMessage = replyId ? nextMessages.find((entry) => entry._id === replyId) : null; 648 + const replyMessage = replyId ? messageById[replyId] : null; 596 649 if (!replyMessage) return message; 597 650 const replyAuthorId = typeof replyMessage.author === 'string' ? replyMessage.author : replyMessage.author?._id; 598 651 ··· 606 659 }, 607 660 }; 608 661 }); 662 + }; 609 663 610 664 const fetchMessages = async (channelId) => { 611 665 if (!channelId || channelId === 'friends') return; ··· 621 675 const payloadUsers = Array.isArray(data) ? [] : data.users || []; 622 676 623 677 if (payloadUsers.length) { 624 - setUsers((prev) => { 625 - const next = { ...prev }; 626 - payloadUsers.forEach((u) => { 627 - next[u._id] = u; 628 - }); 629 - return next; 630 - }); 678 + upsertUsers(payloadUsers); 631 679 } 632 680 681 + upsertUsersFromMessages(payloadMessages); 633 682 const orderedMessages = payloadMessages.reverse().slice(-200); 634 683 setMessages((prev) => ({ ...prev, [channelId]: enrichReplies(orderedMessages) })); 684 + void fetchMissingUsers(orderedMessages); 635 685 preloadedChannelRef.current[channelId] = true; 636 686 } catch { 637 687 // no-op ··· 641 691 useEffect(() => { 642 692 if (status !== 'ready' || !auth.token) return; 643 693 694 + if (document.visibilityState !== 'visible') return; 695 + 696 + const maxWarmChannels = Math.min(8, Math.max(3, Math.floor((navigator.hardwareConcurrency || 4) / 2))); 644 697 const channelsToWarm = Object.values(channels) 645 698 .filter((channel) => channel?.channel_type === 'TextChannel' || !channel?.channel_type) 646 - .slice(0, 25); 699 + .slice(0, maxWarmChannels); 647 700 648 701 channelsToWarm.forEach((channel, index) => { 649 702 if (!channel?._id || preloadedChannelRef.current[channel._id]) return; 650 703 setTimeout(() => { 651 704 fetchMessages(channel._id); 652 - }, index * 120); 705 + }, index * 350); 653 706 }); 654 707 655 708 Object.values(servers).forEach((server) => { ··· 743 796 setMembers({}); 744 797 preloadedChannelRef.current = {}; 745 798 preloadedMembersRef.current = {}; 799 + pendingUserFetchRef.current = new Set(); 746 800 setView('login'); 747 801 setStatus('disconnected'); 748 802 };