// 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
}