Spaces:
Running
Running
Deploy msgboard
Browse files- 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
|
| 1795 |
return `<button class="rchip ${r.me ? "mine" : ""}" data-react="${esc(r.emoji)}" data-mid="${m.id}" ${writable ? "" : "disabled"}
|
| 1796 |
-
|
| 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 (
|
|
|
|
| 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
|
|
|
|
| 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>`;
|