// Renders the AIM buddy list: Online / Idle / Offline groups, each a sorted set // of buddy rows with the running-man status icon. // // Rendering is INCREMENTAL: row elements are created once and reused (keyed by // DID), updated in place, and moved between groups as state changes. A full // innerHTML rebuild would recreate every on every render — and the // last-seen backfill fires many renders — which made the avatars flicker. import { esc, relTime } from './util.js'; import { verbFor } from './status.js'; // Displayed label for a buddy (the bold name in the row), used for render + sort. const nameOf = (b) => b.profile?.displayName?.trim() || b.profile?.handle || b.did; const subOf = (b) => !b.lastSeen ? 'no recent activity' : b.lastSeenApprox ? `last seen ${relTime(b.lastSeen)}` // from rev backfill (action unknown) : `${verbFor(b.lastAction)} · ${relTime(b.lastSeen)}`; // from a live/replay event // The little yellow running man (appearance follows the parent .buddy.). const RUNNER = ``; // Build a row element once. The avatar here is never recreated, so it // won't reload/flicker across renders. function createRow(b) { const li = document.createElement('li'); li.dataset.did = b.did; const name = nameOf(b); const av = b.profile?.avatar ? `` : `${esc((name[0] || '?').toUpperCase())}`; li.innerHTML = `${RUNNER}${av} `; return li; } // Update only the volatile bits (state class, name, subtext) — never the avatar. function updateRow(el, b, flashing) { el.className = `buddy ${b.state}${flashing.has(b.did) ? ' flash' : ''}`; el.title = `@${b.profile?.handle || ''} — open IM`; el.querySelector('.bd-name').textContent = nameOf(b); el.querySelector('.bd-sub').textContent = subOf(b); } // Make `ul`'s children exactly `list` (in order), reusing cached row elements. function reconcile(ul, list, rows, flashing) { let prev = null; for (const b of list) { let el = rows.get(b.did); if (!el) { el = createRow(b); rows.set(b.did, el); } updateRow(el, b, flashing); const target = prev ? prev.nextSibling : ul.firstChild; if (el !== target) ul.insertBefore(el, target); prev = el; } // drop any trailing leftovers (rows that left this group) while (prev ? prev.nextSibling : ul.firstChild) (prev ? prev.nextSibling : ul.firstChild).remove(); } function ensureSkeleton(listEl) { if (listEl.querySelector('.group')) return; listEl.innerHTML = ['online', 'idle', 'offline'].map((k) => `
`).join(''); } const LABELS = { online: 'Online', idle: 'Idle', offline: 'Offline' }; export function renderBuddyList(listEl, countEl, buddies, { groupsOpen, flashing }) { ensureSkeleton(listEl); const rows = listEl._rows || (listEl._rows = new Map()); const all = [...buddies.values()]; const grouped = { online: [], idle: [], offline: [] }; for (const b of all) grouped[b.state].push(b); for (const key of ['online', 'idle', 'offline']) { const list = grouped[key].sort((a, b) => nameOf(a).localeCompare(nameOf(b), undefined, { sensitivity: 'base' })); const groupEl = listEl.querySelector(`.group[data-group="${key}"]`); const open = groupsOpen[key]; groupEl.className = `group ${open ? 'open' : 'closed'}`; groupEl.querySelector('.group-hd').innerHTML = `${open ? '▼' : '▶'} ${LABELS[key]} (${list.length})`; const ul = groupEl.querySelector('.buddies'); if (open) reconcile(ul, list, rows, flashing); else ul.replaceChildren(); // collapsed: don't hold rows for it } countEl.textContent = `Buddies (${grouped.online.length + grouped.idle.length}/${all.length})`; for (const [did, el] of rows) if (!el.isConnected) rows.delete(did); // prune detached }