lvwerra HF Staff commited on
Commit
a46ed89
·
verified ·
1 Parent(s): 21197d1

Deploy msgboard

Browse files
Files changed (1) hide show
  1. src/msgboard/dashboard.html +222 -6
src/msgboard/dashboard.html CHANGED
@@ -124,6 +124,20 @@ a[data-internal] { font-weight: 600; }
124
  .rchip:disabled { cursor: default; }
125
  .rchip.radd { color: var(--ink-3); font: 14px/1 var(--mono); visibility: hidden; }
126
  .msg:hover .rchip.radd, .rchip.radd:focus-visible { visibility: visible; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
127
  .react-pop { position: fixed; z-index: 30; width: min(320px, calc(100vw - 16px)); background: var(--bg); border: 1px solid var(--ink); box-shadow: 4px 4px 0 var(--rule); }
128
  .react-pop .rquick { display: flex; justify-content: space-between; padding: 6px; border-bottom: 1px solid var(--rule); }
129
  .react-pop .rquick button, .react-pop .emoji-grid button { width: 34px; height: 32px; border: 0; background: none; font-size: 20px; line-height: 1; padding: 0; }
@@ -778,6 +792,7 @@ dialog input[type=text] { width: 100%; margin: 4px 0 12px; }
778
  </div>
779
  <div class="side-foot">
780
  <div class="side-toggles">
 
781
  <label class="show-private hidden" id="show-archived-row" title="Archived channels are read-only; hide them from the sidebar"><input type="checkbox" id="show-archived"> show archived</label>
782
  <label class="show-private hidden" id="show-private-row" title="Admins can see private channels and DMs they are not part of"><input type="checkbox" id="show-private"> show private</label>
783
  </div>
@@ -1388,6 +1403,14 @@ async function loadPeople(force = false) {
1388
  if (!state.people || force) state.people = (await api("GET", "/principals")).principals;
1389
  return state.people;
1390
  }
 
 
 
 
 
 
 
 
1391
  async function pick(title, filterFn, onPick) {
1392
  $("pick-title").textContent = title; $("pick-filter").value = ""; $("pick-error").classList.add("hidden");
1393
  const people = (await loadPeople(true)).filter(filterFn);
@@ -1514,6 +1537,7 @@ async function boot() {
1514
  $("show-private-row").classList.toggle("hidden", !can("manage"));
1515
  $("show-private").checked = !!(state.me.prefs && state.me.prefs.show_private);
1516
  await Promise.all([refreshChannels(), renderLibs]);
 
1517
  if (location.hash === "#settings") return openSettings();
1518
  if (location.hash === "#activity") return openActivity();
1519
  if (location.hash === "#analytics" && canAnalytics()) return openAnalytics();
@@ -1533,6 +1557,93 @@ function renderMe() {
1533
  $("me-avatar").innerHTML = avatar(state.me); hydrate($("me-avatar"));
1534
  }
1535
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1536
  // ------------------------------------------------------------------ channels and DMs
1537
  async function refreshChannels() {
1538
  try { state.channels = (await api("GET", "/channels")).channels; }
@@ -1562,7 +1673,7 @@ const joinable = (c) => c.member === false && c.kind !== "dm" && !c.archived &&
1562
  async function joinChannel(c) {
1563
  try {
1564
  await api("POST", `/channels/${c.id}/members`, { body: { principal_id: state.me.id } });
1565
- c.member = true; renderChannels(); updateMeta(true);
1566
  } catch (e) { alert("Could not join: " + e.message); }
1567
  }
1568
  const showPrivate = () => !!(state.me && state.me.prefs && state.me.prefs.show_private);
@@ -1616,6 +1727,8 @@ async function saveChannelOrder(ids) {
1616
  }
1617
  function renderChannels() {
1618
  const total = state.channels.reduce((n, c) => n + (c.for_me || 0), 0);
 
 
1619
  $("settings-open").classList.toggle("on", state.view === "settings");
1620
  $("activity-open").className = (state.view === "activity" ? "active " : "") + (total ? "unread" : "");
1621
  $("activity-open").innerHTML = $("activity-open").innerHTML.replace(/<span class="count">.*?<\/span>/, "") + (total ? `<span class="count">${total > 99 ? "99+" : total}</span>` : "");
@@ -1666,6 +1779,7 @@ function showChat() {
1666
  async function openChannel(id) {
1667
  const c = state.channels.find((x) => x.id === id);
1668
  if (!c) return;
 
1669
  showChat();
1670
  if (state.poll) state.poll.abort();
1671
  if (state.panel && state.panel.type !== "profile") closePanel();
@@ -1791,9 +1905,9 @@ function reactionsHTML(m) {
1791
  if (!list.length) return "";
1792
  const writable = canWriteHere();
1793
  return `<div class="reacts">` + list.map((r) => {
1794
- const who = r.by.map((b) => nameOf(b)).join(", ");
1795
  return `<button class="rchip ${r.me ? "mine" : ""}" data-react="${esc(r.emoji)}" data-mid="${m.id}" ${writable ? "" : "disabled"}
1796
- title="${esc(who)} reacted with ${esc(r.emoji)}" aria-pressed="${r.me}"><span class="re">${esc(r.emoji)}</span><span class="rc">${r.count}</span></button>`;
1797
  }).join("") + (writable ? `<button class="rchip radd" data-react-pick="${m.id}" title="Add reaction" aria-label="Add reaction">+</button>` : "") + `</div>`;
1798
  }
1799
  async function toggleReaction(mid, emoji) {
@@ -1806,6 +1920,104 @@ async function toggleReaction(mid, emoji) {
1806
  store.set("emoji-recent", JSON.stringify([emoji, ...recentEmoji().filter((x) => x !== emoji)].slice(0, 18)));
1807
  } catch (e) { alert("Could not react: " + e.message); }
1808
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1809
  const recentEmoji = () => { try { return JSON.parse(store.get("emoji-recent") || "[]"); } catch { return []; } };
1810
  let reactPop = null;
1811
  function closeReactPicker() { if (reactPop) { reactPop.remove(); reactPop = null; document.removeEventListener("mousedown", reactOutside, true); } }
@@ -2328,7 +2540,8 @@ document.addEventListener("click", (e) => {
2328
  const rpick = e.target.closest("[data-react-pick]");
2329
  if (rpick) { openReactPicker(rpick, Number(rpick.dataset.reactPick)); return; }
2330
  const rchip = e.target.closest("[data-react]");
2331
- if (rchip) { if (!rchip.disabled) toggleReaction(Number(rchip.dataset.mid), rchip.dataset.react); return; }
 
2332
  const edit = e.target.closest("[data-edit]");
2333
  if (edit) { startEdit(Number(edit.dataset.edit), edit.dataset.where); return; }
2334
  if (e.target.closest("[data-edit-save]")) { saveEdit(); return; }
@@ -2601,7 +2814,8 @@ function attachAssist(host, input, autosize) {
2601
  .map(([, c]) => ({ insert: `#${c.name} `, html: `<span class="glyph">${c.kind === "private" ? LOCK : "#"}</span><span>${esc(c.name)}</span>` +
2602
  (c.description ? `<small>${esc(c.description.length > 40 ? c.description.slice(0, 39) + "…" : c.description)}</small>` : "") }));
2603
  } else {
2604
- const people = (await loadPeople()).filter((p) => !p.disabled && p.id !== state.me.id);
 
2605
  const tags = state.current && state.current.kind !== "dm"
2606
  ? ["all", "agents", "humans"].filter((g) => !q || g.startsWith(q)).map((g) => ({ insert: `@${g} `,
2607
  html: `<span class="glyph">@</span><span>${g}</span><small>${esc(GROUP_TAGS[g])}</small>` })) : [];
@@ -2978,9 +3192,10 @@ async function openMembers() {
2978
  hydrate($("panel-scroll"));
2979
  if ($("member-add")) $("member-add").onclick = () => pick("Add to " + c.name,
2980
  (p) => !p.disabled && !members.some((m) => m.id === p.id),
2981
- async (p) => { await api("POST", `/channels/${c.id}/members`, { body: { principal_id: p.id } }); openMembers(); });
2982
  $("panel-scroll").querySelectorAll("[data-remove]").forEach((b) => b.onclick = async () => {
2983
  const r = await api("DELETE", `/channels/${c.id}/members/${b.dataset.remove}`);
 
2984
  if (r.left) { closePanel(); await refreshChannels(); const first = state.channels[0]; if (first) openChannel(first.id); }
2985
  else openMembers();
2986
  });
@@ -3018,6 +3233,7 @@ function profileSuspend(p) {
3018
  ${s.via ? "" : `<button class="linkish" id="prof-unsuspend">Unsuspend</button>`}</div>`;
3019
  }
3020
  async function openProfile(id) {
 
3021
  state.panel = { type: "profile", id };
3022
  openPanel("profile", "Profile", "");
3023
  $("panel-scroll").innerHTML = `<div class="empty">Loading…</div>`;
 
124
  .rchip:disabled { cursor: default; }
125
  .rchip.radd { color: var(--ink-3); font: 14px/1 var(--mono); visibility: hidden; }
126
  .msg:hover .rchip.radd, .rchip.radd:focus-visible { visibility: visible; }
127
+ .rchip[data-react] { -webkit-touch-callout: none; user-select: none; }
128
+ .rchip.who-open { border-color: var(--ink); position: relative; z-index: 31; }
129
+ .rchip.who-open.below { border-bottom-color: var(--bg); }
130
+ .rchip.who-open.above { border-top-color: var(--bg); }
131
+ /* a short neck joins the open chip to its list (covering the list's edge there), so the list clears the neighbouring chips */
132
+ .rchip.who-open::after { content: ""; position: absolute; left: -1px; right: -1px; height: 7px; background: var(--bg); border: 1px solid var(--ink); border-width: 0 1px; }
133
+ .rchip.who-open.below::after { top: 100%; }
134
+ .rchip.who-open.above::after { bottom: 100%; }
135
+ .who-pop { position: fixed; z-index: 30; width: max-content; min-width: 160px; max-width: min(260px, calc(100vw - 16px)); background: var(--bg); border: 1px solid var(--ink); box-shadow: 4px 4px 0 var(--rule); }
136
+ .who-pop .wl { max-height: 264px; overflow-y: auto; padding: 4px 0; overscroll-behavior: contain; }
137
+ .who-pop .person { display: flex; align-items: center; gap: 8px; padding: 4px 10px; width: 100%; border: 0; background: none; color: inherit; font: inherit; text-align: left; }
138
+ .who-pop .person:hover, .who-pop .person:focus-visible { background: var(--hover); outline: none; box-shadow: inset 2px 0 0 var(--ink); }
139
+ .who-pop .pname { display: flex; align-items: baseline; gap: 6px; min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
140
+ .who-pop .pname small { font: 11px var(--mono); color: var(--ink-3); }
141
  .react-pop { position: fixed; z-index: 30; width: min(320px, calc(100vw - 16px)); background: var(--bg); border: 1px solid var(--ink); box-shadow: 4px 4px 0 var(--rule); }
142
  .react-pop .rquick { display: flex; justify-content: space-between; padding: 6px; border-bottom: 1px solid var(--rule); }
143
  .react-pop .rquick button, .react-pop .emoji-grid button { width: 34px; height: 32px; border: 0; background: none; font-size: 20px; line-height: 1; padding: 0; }
 
792
  </div>
793
  <div class="side-foot">
794
  <div class="side-toggles">
795
+ <label class="show-private hidden" id="notify-row" title="A system notification when someone mentions you, replies in your threads or sends you a DM, while this board is open in a tab"><input type="checkbox" id="notify-on"> notify me in this browser</label>
796
  <label class="show-private hidden" id="show-archived-row" title="Archived channels are read-only; hide them from the sidebar"><input type="checkbox" id="show-archived"> show archived</label>
797
  <label class="show-private hidden" id="show-private-row" title="Admins can see private channels and DMs they are not part of"><input type="checkbox" id="show-private"> show private</label>
798
  </div>
 
1403
  if (!state.people || force) state.people = (await api("GET", "/principals")).principals;
1404
  return state.people;
1405
  }
1406
+ // ids of a channel's members, for @ suggestions; dropped when someone joins, leaves or is added
1407
+ const memberIds = new Map(), knownMembers = new Map();
1408
+ async function loadMemberIds(c) {
1409
+ if (!memberIds.has(c.id)) memberIds.set(c.id, api("GET", `/channels/${c.id}/members`)
1410
+ .then((r) => { const ids = new Set(r.members.map((m) => m.id)); knownMembers.set(c.id, ids); return ids; },
1411
+ () => { memberIds.delete(c.id); return knownMembers.get(c.id) || new Set(); })); // on failure: the last list, or none
1412
+ return memberIds.get(c.id);
1413
+ }
1414
  async function pick(title, filterFn, onPick) {
1415
  $("pick-title").textContent = title; $("pick-filter").value = ""; $("pick-error").classList.add("hidden");
1416
  const people = (await loadPeople(true)).filter(filterFn);
 
1537
  $("show-private-row").classList.toggle("hidden", !can("manage"));
1538
  $("show-private").checked = !!(state.me.prefs && state.me.prefs.show_private);
1539
  await Promise.all([refreshChannels(), renderLibs]);
1540
+ initNotify();
1541
  if (location.hash === "#settings") return openSettings();
1542
  if (location.hash === "#activity") return openActivity();
1543
  if (location.hash === "#analytics" && canAnalytics()) return openAnalytics();
 
1557
  $("me-avatar").innerHTML = avatar(state.me); hydrate($("me-avatar"));
1558
  }
1559
 
1560
+ // ------------------------------------------------------------------ browser notifications
1561
+ // While a tab is open, a system notification for what is for me: mentions, replies in my threads, DMs.
1562
+ // The switch is per browser (the permission is too); every open tab listens, and the shared tag keeps one per message.
1563
+ const baseTitle = document.title;
1564
+ let notifyCtrl = null;
1565
+ const notifyWanted = () => store.get("notify") === "on" && "Notification" in window && Notification.permission === "granted";
1566
+ function initNotify() {
1567
+ if (!("Notification" in window)) return;
1568
+ $("notify-row").classList.remove("hidden");
1569
+ syncNotify();
1570
+ }
1571
+ function syncNotify() { // on load, and when another tab flips the switch
1572
+ $("notify-on").checked = notifyWanted();
1573
+ if (notifyWanted()) { if (!notifyCtrl || notifyCtrl.signal.aborted) notifyLoop(); }
1574
+ else if (notifyCtrl) notifyCtrl.abort();
1575
+ }
1576
+ function notifyOff(why) {
1577
+ store.set("notify", "off"); $("notify-on").checked = false; if (notifyCtrl) notifyCtrl.abort();
1578
+ if (why) alert(why);
1579
+ }
1580
+ const NO_NOTIFY = "This browser does not show notifications from web pages (most phone browsers only allow them for installed apps).";
1581
+ // which tab is in front, and on which channel: shared through localStorage, so a tab behind it stays quiet
1582
+ function noteFront() {
1583
+ const front = document.visibilityState === "visible" && document.hasFocus() && state.view === "chat" && state.current;
1584
+ if (front) store.set("notify-front", JSON.stringify({ channel: state.current.id, at: Date.now(), tab: tabId }));
1585
+ else if (frontIsMe) { // only clear the note if it is still ours: another tab may have come to the front meanwhile
1586
+ try { if ((JSON.parse(store.get("notify-front") || "null") || {}).tab === tabId) store.del("notify-front"); } catch {}
1587
+ }
1588
+ frontIsMe = !!front;
1589
+ }
1590
+ let frontIsMe = false;
1591
+ const tabId = Math.random().toString(36).slice(2);
1592
+ ["focus", "blur", "visibilitychange", "pagehide"].forEach((t) => window.addEventListener(t, () => setTimeout(noteFront)));
1593
+ setInterval(() => { if (frontIsMe) noteFront(); }, 30000); // keep it fresh; a crashed tab's note goes stale after a minute
1594
+ function onScreenSomewhere(channelId) {
1595
+ try {
1596
+ const f = JSON.parse(store.get("notify-front") || "null");
1597
+ return !!f && f.channel === channelId && Date.now() - f.at < 60000;
1598
+ } catch { return false; }
1599
+ }
1600
+ window.addEventListener("storage", (e) => { if (e.key === "msgboard:notify") syncNotify(); });
1601
+ $("notify-on").onchange = async () => {
1602
+ const box = $("notify-on");
1603
+ if (!box.checked) { store.set("notify", "off"); if (notifyCtrl) notifyCtrl.abort(); return; }
1604
+ let perm = Notification.permission;
1605
+ if (perm === "default") { try { perm = await Notification.requestPermission(); } catch { perm = "denied"; } }
1606
+ if (perm !== "granted") {
1607
+ box.checked = false;
1608
+ alert(window.top !== window
1609
+ ? "Browsers block notifications for a board shown inside another page. Open the board at its own address and turn this on there."
1610
+ : "Notifications are blocked for this site. Allow them in the browser's site settings (the icon left of the address), then try again.");
1611
+ return;
1612
+ }
1613
+ try { new Notification("Notifications are on", { body: "You will hear about mentions, replies in your threads and DMs here.", tag: "msgboard-on" }); }
1614
+ catch { return notifyOff(NO_NOTIFY); }
1615
+ store.set("notify", "on"); notifyLoop();
1616
+ };
1617
+ async function notifyLoop() {
1618
+ if (notifyCtrl) notifyCtrl.abort();
1619
+ const ctrl = notifyCtrl = new AbortController();
1620
+ let after = null, wait = 5000;
1621
+ while (!ctrl.signal.aborted && notifyWanted()) {
1622
+ try {
1623
+ // no `channels`: only what is addressed to me. The first call returns a cursor meaning "now".
1624
+ const page = await api("GET", after == null ? "/activity" : `/activity?after=${after}&wait=25`, { signal: ctrl.signal });
1625
+ if (typeof page.next_cursor !== "number") throw new Error("no cursor");
1626
+ if (after != null && notifyWanted() && !ctrl.signal.aborted) page.messages.forEach(notifyFor); // the switch may have flipped meanwhile
1627
+ after = page.next_cursor; wait = 5000;
1628
+ } catch (e) {
1629
+ if (ctrl.signal.aborted) return;
1630
+ if (e.status === 401) return;
1631
+ await sleep(wait + Math.random() * wait / 2); wait = Math.min(wait * 2, 300000); // back off, up to 5 min
1632
+ }
1633
+ }
1634
+ }
1635
+ function notifyFor(m) {
1636
+ if (!["mention", "reply", "dm"].includes(m.reason) || m.sender.id === state.me.id) return;
1637
+ const looking = document.visibilityState === "visible" && document.hasFocus() && state.view === "chat" && state.current && state.current.id === m.channel.id;
1638
+ if (looking || onScreenSomewhere(m.channel.id)) return; // it is on screen already, in this tab or another
1639
+ const where = m.channel.kind === "dm" ? "direct message" : m.reason === "reply" ? `replied in #${m.channel.name}` : `in #${m.channel.name}`;
1640
+ const text = (m.body || "").replace(/\s+/g, " ").trim();
1641
+ try {
1642
+ const n = new Notification(`${nameOf(m.sender)} ${where}`, { body: text.length > 160 ? text.slice(0, 159) + "…" : text || "(attachment)", tag: "msgboard-" + m.id });
1643
+ n.onclick = () => { window.focus(); n.close(); routeHash("#msg/" + m.id); };
1644
+ } catch { notifyOff(); $("notify-row").title = NO_NOTIFY; } // some browsers only allow them from a service worker
1645
+ }
1646
+
1647
  // ------------------------------------------------------------------ channels and DMs
1648
  async function refreshChannels() {
1649
  try { state.channels = (await api("GET", "/channels")).channels; }
 
1673
  async function joinChannel(c) {
1674
  try {
1675
  await api("POST", `/channels/${c.id}/members`, { body: { principal_id: state.me.id } });
1676
+ memberIds.delete(c.id); c.member = true; renderChannels(); updateMeta(true);
1677
  } catch (e) { alert("Could not join: " + e.message); }
1678
  }
1679
  const showPrivate = () => !!(state.me && state.me.prefs && state.me.prefs.show_private);
 
1727
  }
1728
  function renderChannels() {
1729
  const total = state.channels.reduce((n, c) => n + (c.for_me || 0), 0);
1730
+ document.title = (total ? `(${total > 99 ? "99+" : total}) ` : "") + baseTitle;
1731
+ noteFront(); // the channel on screen may have changed
1732
  $("settings-open").classList.toggle("on", state.view === "settings");
1733
  $("activity-open").className = (state.view === "activity" ? "active " : "") + (total ? "unread" : "");
1734
  $("activity-open").innerHTML = $("activity-open").innerHTML.replace(/<span class="count">.*?<\/span>/, "") + (total ? `<span class="count">${total > 99 ? "99+" : total}</span>` : "");
 
1779
  async function openChannel(id) {
1780
  const c = state.channels.find((x) => x.id === id);
1781
  if (!c) return;
1782
+ memberIds.delete(c.id); // members change elsewhere too: fetch them fresh for each visit
1783
  showChat();
1784
  if (state.poll) state.poll.abort();
1785
  if (state.panel && state.panel.type !== "profile") closePanel();
 
1905
  if (!list.length) return "";
1906
  const writable = canWriteHere();
1907
  return `<div class="reacts">` + list.map((r) => {
1908
+ const names = r.by.slice(0, 10).map((b) => nameOf(b)).join(", ") + (r.by.length > 10 ? ` and ${r.by.length - 10} more` : "");
1909
  return `<button class="rchip ${r.me ? "mine" : ""}" data-react="${esc(r.emoji)}" data-mid="${m.id}" ${writable ? "" : "disabled"}
1910
+ aria-label="${esc(r.emoji)} ${r.count}: ${esc(names)}. Down arrow lists everyone." aria-haspopup="dialog" aria-pressed="${r.me}"><span class="re">${esc(r.emoji)}</span><span class="rc">${r.count}</span></button>`;
1911
  }).join("") + (writable ? `<button class="rchip radd" data-react-pick="${m.id}" title="Add reaction" aria-label="Add reaction">+</button>` : "") + `</div>`;
1912
  }
1913
  async function toggleReaction(mid, emoji) {
 
1920
  store.set("emoji-recent", JSON.stringify([emoji, ...recentEmoji().filter((x) => x !== emoji)].slice(0, 18)));
1921
  } catch (e) { alert("Could not react: " + e.message); }
1922
  }
1923
+ // who reacted: a list that opens on hover, keyboard focus or a long press; each name opens that profile
1924
+ let whoPop = null, whoTimer = 0, pressTimer = 0, pressedOpen = false, pressStart = [0, 0], quietChip = null;
1925
+ function reactionAt(chip) {
1926
+ const m = state.byId.get(Number(chip.dataset.mid));
1927
+ return m && (m.reactions || []).find((r) => r.emoji === chip.dataset.react);
1928
+ }
1929
+ function openWho(chip) {
1930
+ clearTimeout(whoTimer);
1931
+ const r = reactionAt(chip);
1932
+ if (!r) return closeWho();
1933
+ if (!whoPop) {
1934
+ whoPop = document.createElement("div"); whoPop.className = "who-pop"; whoPop.setAttribute("role", "dialog");
1935
+ whoPop.addEventListener("pointerenter", () => clearTimeout(whoTimer));
1936
+ whoPop.addEventListener("pointerleave", (e) => { if (e.pointerType !== "touch") hideWhoSoon(); });
1937
+ document.body.appendChild(whoPop);
1938
+ }
1939
+ const people = r.by.map((b) => (state.people || []).find((p) => p.id === b.id) || b);
1940
+ const key = chip.dataset.mid + " " + r.emoji, old = whoPop.querySelector(".wl");
1941
+ const keep = whoPop.key === key && old ? { top: old.scrollTop, focus: whoPop.contains(document.activeElement) && document.activeElement.dataset.profile } : null;
1942
+ if (whoPop.chip && whoPop.chip !== chip) whoPop.chip.classList.remove("who-open", "below", "above");
1943
+ whoPop.chip = chip; whoPop.key = key; whoPop.sig = r.by.map((b) => b.id).join(",");
1944
+ whoPop.pane = chip.parentElement.closest("#messages, #panel-scroll") || document; // the feed and a thread can show the same message
1945
+ whoPop.setAttribute("aria-label", `Reacted with ${r.emoji}`);
1946
+ whoPop.innerHTML = `<div class="wl">${people.map((p) => `<button type="button" class="person" data-profile="${p.id}">${avatar(p, "xs", false)}<span class="pname">${esc(nameOf(p))}<small>${esc(kindLabel(p))}</small></span></button>`).join("")}</div>`;
1947
+ hydrate(whoPop);
1948
+ // the list hangs off the chip itself, as if the chip grew open (above it when there is no room below)
1949
+ const a = chip.getBoundingClientRect(), wl = whoPop.querySelector(".wl");
1950
+ const room = { below: window.innerHeight - a.bottom - 14, above: a.top - 14 }; // fit short windows: shrink the list, it scrolls
1951
+ wl.style.maxHeight = "";
1952
+ let below = a.bottom + whoPop.offsetHeight + 14 < window.innerHeight || room.below >= room.above;
1953
+ if (whoPop.offsetHeight > room[below ? "below" : "above"]) wl.style.maxHeight = Math.max(60, room[below ? "below" : "above"] - 2) + "px";
1954
+ if (keep) { wl.scrollTop = keep.top; if (keep.focus) { const b = wl.querySelector(`[data-profile="${keep.focus}"]`); if (b) b.focus({ preventScroll: true }); } }
1955
+ const w = whoPop.offsetWidth, h = whoPop.offsetHeight;
1956
+ chip.classList.add("who-open"); chip.classList.toggle("below", below); chip.classList.toggle("above", !below);
1957
+ whoPop.style.left = Math.max(8, Math.min(window.innerWidth - w - 8, a.left)) + "px";
1958
+ whoPop.style.top = (below ? a.bottom + 5 : Math.max(8, a.top - h - 5)) + "px";
1959
+ }
1960
+ function closeWho(refocus) {
1961
+ clearTimeout(whoTimer);
1962
+ if (!whoPop) return;
1963
+ const chip = whoPop.chip;
1964
+ chip.classList.remove("who-open", "below", "above"); whoPop.remove(); whoPop = null;
1965
+ if (refocus && chip.isConnected) { quietChip = chip; chip.focus(); } // back on the reaction, without reopening its list
1966
+ }
1967
+ function hideWhoSoon() { clearTimeout(whoTimer); whoTimer = setTimeout(closeWho, 250); } // time to move onto the list
1968
+ const chipAt = (t) => t && t.closest && t.closest(".rchip[data-react]");
1969
+ document.addEventListener("pointerover", (e) => {
1970
+ const chip = e.pointerType !== "touch" && chipAt(e.target);
1971
+ if (chip && (!whoPop || whoPop.chip !== chip)) openWho(chip); else if (chip) clearTimeout(whoTimer);
1972
+ });
1973
+ document.addEventListener("pointerout", (e) => {
1974
+ if (e.pointerType !== "touch" && whoPop && chipAt(e.target) === whoPop.chip && !whoPop.contains(e.relatedTarget) && chipAt(e.relatedTarget) !== whoPop.chip) hideWhoSoon();
1975
+ });
1976
+ document.addEventListener("focusin", (e) => {
1977
+ const chip = chipAt(e.target);
1978
+ if (chip && chip !== quietChip && e.target.matches(":focus-visible")) openWho(chip);
1979
+ if (chip !== quietChip) quietChip = null;
1980
+ });
1981
+ document.addEventListener("focusout", (e) => {
1982
+ if (whoPop && (chipAt(e.target) === whoPop.chip || whoPop.contains(e.target))
1983
+ && !whoPop.contains(e.relatedTarget) && chipAt(e.relatedTarget) !== whoPop.chip) hideWhoSoon();
1984
+ });
1985
+ // keyboard: arrow down from the reaction goes into the list, arrows move, Esc or Tab goes back to the reaction
1986
+ document.addEventListener("keydown", (e) => {
1987
+ if (!whoPop && e.key === "ArrowDown" && chipAt(e.target)) { e.preventDefault(); openWho(chipAt(e.target)); }
1988
+ if (!whoPop) return;
1989
+ const rows = [...whoPop.querySelectorAll(".person")], i = rows.indexOf(document.activeElement);
1990
+ if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); closeWho(whoPop.contains(document.activeElement)); } // only the list, not the panel too
1991
+ else if (e.key === "ArrowDown" && chipAt(e.target) === whoPop.chip && rows.length) { e.preventDefault(); rows[0].focus(); }
1992
+ else if (i >= 0 && (e.key === "ArrowDown" || e.key === "ArrowUp")) {
1993
+ e.preventDefault();
1994
+ if (e.key === "ArrowUp" && i === 0) whoPop.chip.focus(); else rows[Math.min(rows.length - 1, i + (e.key === "ArrowDown" ? 1 : -1))].focus();
1995
+ } else if (i >= 0 && e.key === "Tab") { e.preventDefault(); whoPop.chip.focus(); }
1996
+ }, true);
1997
+ // phones: press and hold a reaction for the list; a plain tap still adds or removes yours
1998
+ document.addEventListener("pointerdown", (e) => {
1999
+ if (whoPop && !whoPop.contains(e.target) && chipAt(e.target) !== whoPop.chip) closeWho();
2000
+ const chip = e.pointerType === "touch" && chipAt(e.target);
2001
+ clearTimeout(pressTimer); pressedOpen = false;
2002
+ if (chip) { pressStart = [e.clientX, e.clientY]; pressTimer = setTimeout(() => { pressedOpen = true; openWho(chip); }, 450); }
2003
+ });
2004
+ ["pointerup", "pointercancel"].forEach((t) => document.addEventListener(t, () => clearTimeout(pressTimer)));
2005
+ document.addEventListener("pointermove", (e) => { // a finger always wobbles a little: only a real move cancels the hold
2006
+ if (e.pointerType === "touch" && pressTimer && Math.hypot(e.clientX - pressStart[0], e.clientY - pressStart[1]) > 10) clearTimeout(pressTimer);
2007
+ });
2008
+ document.addEventListener("contextmenu", (e) => { if (chipAt(e.target)) e.preventDefault(); });
2009
+ document.addEventListener("scroll", (e) => { if (whoPop && !whoPop.contains(e.target)) closeWho(); }, true);
2010
+ // new reactions arrive while the list is open: follow the re-rendered chip, or close if the reaction is gone
2011
+ new MutationObserver(() => {
2012
+ if (!whoPop) return;
2013
+ const { mid, react } = whoPop.chip.dataset;
2014
+ const pane = whoPop.pane.isConnected || whoPop.pane === document ? whoPop.pane : document;
2015
+ const chip = whoPop.chip.isConnected ? whoPop.chip
2016
+ : pane.querySelector(`.rchip[data-mid="${mid}"][data-react="${CSS.escape(react)}"]`);
2017
+ const r = chip && reactionAt(chip);
2018
+ if (!r) return closeWho();
2019
+ if (chip !== whoPop.chip || r.by.map((b) => b.id).join(",") !== whoPop.sig) openWho(chip);
2020
+ }).observe(document.body, { childList: true, subtree: true });
2021
  const recentEmoji = () => { try { return JSON.parse(store.get("emoji-recent") || "[]"); } catch { return []; } };
2022
  let reactPop = null;
2023
  function closeReactPicker() { if (reactPop) { reactPop.remove(); reactPop = null; document.removeEventListener("mousedown", reactOutside, true); } }
 
2540
  const rpick = e.target.closest("[data-react-pick]");
2541
  if (rpick) { openReactPicker(rpick, Number(rpick.dataset.reactPick)); return; }
2542
  const rchip = e.target.closest("[data-react]");
2543
+ if (rchip) { if (pressedOpen) { pressedOpen = false; return; } // that was a long press: it opened the list
2544
+ if (!rchip.disabled) toggleReaction(Number(rchip.dataset.mid), rchip.dataset.react); return; }
2545
  const edit = e.target.closest("[data-edit]");
2546
  if (edit) { startEdit(Number(edit.dataset.edit), edit.dataset.where); return; }
2547
  if (e.target.closest("[data-edit-save]")) { saveEdit(); return; }
 
2814
  .map(([, c]) => ({ insert: `#${c.name} `, html: `<span class="glyph">${c.kind === "private" ? LOCK : "#"}</span><span>${esc(c.name)}</span>` +
2815
  (c.description ? `<small>${esc(c.description.length > 40 ? c.description.slice(0, 39) + "…" : c.description)}</small>` : "") }));
2816
  } else {
2817
+ const members = state.current ? await loadMemberIds(state.current) : new Set(); // only people in this channel
2818
+ const people = (await loadPeople()).filter((p) => !p.disabled && p.id !== state.me.id && members.has(p.id));
2819
  const tags = state.current && state.current.kind !== "dm"
2820
  ? ["all", "agents", "humans"].filter((g) => !q || g.startsWith(q)).map((g) => ({ insert: `@${g} `,
2821
  html: `<span class="glyph">@</span><span>${g}</span><small>${esc(GROUP_TAGS[g])}</small>` })) : [];
 
3192
  hydrate($("panel-scroll"));
3193
  if ($("member-add")) $("member-add").onclick = () => pick("Add to " + c.name,
3194
  (p) => !p.disabled && !members.some((m) => m.id === p.id),
3195
+ async (p) => { await api("POST", `/channels/${c.id}/members`, { body: { principal_id: p.id } }); memberIds.delete(c.id); openMembers(); });
3196
  $("panel-scroll").querySelectorAll("[data-remove]").forEach((b) => b.onclick = async () => {
3197
  const r = await api("DELETE", `/channels/${c.id}/members/${b.dataset.remove}`);
3198
+ memberIds.delete(c.id);
3199
  if (r.left) { closePanel(); await refreshChannels(); const first = state.channels[0]; if (first) openChannel(first.id); }
3200
  else openMembers();
3201
  });
 
3233
  ${s.via ? "" : `<button class="linkish" id="prof-unsuspend">Unsuspend</button>`}</div>`;
3234
  }
3235
  async function openProfile(id) {
3236
+ closeWho();
3237
  state.panel = { type: "profile", id };
3238
  openPanel("profile", "Profile", "");
3239
  $("panel-scroll").innerHTML = `<div class="empty">Loading…</div>`;