1// /src/shell/dockStatus.js 2// The status cluster that sits at the RIGHT-HAND end of the shell-dock tab strip: 3// login state, unread messages, unread mail. Jason's ask (board eMessages ffSeq 291): 4// the strip keeps a permanent APPS tab at one end, and "login status, message and mail 5// indicator status" at the other. 6// 7// Three indicators, three DIFFERENT truth sources, and the differences are the design: 8// 9// LOGIN - read locally from the cached profile (auth/userProfile.js). No round trip: 10// the page already knows who is signed in, and an indicator that needs the 11// network to say "signed out" is wrong exactly when the network is. 12// 13// MESSAGES - counted from the user's visible eMessages rows against a watermark held 14// in localStorage. This is a PER-DEVICE mark on purpose, not a server one: 15// pMsgSeen (shellDocStore.markSeen) requires a linkId - read marks exist per 16// NODE (program/project/task), and there is no global inbox mark to write. 17// Inventing one is a schema change, which is zim-contact's lane, not mine. 18// So the badge answers "new since I last opened the board ON THIS DEVICE", 19// and the tooltip says exactly that rather than implying more. 20// 21// MAIL - NOT polled here. The mail token lives inside the mailclient shell (it is 22// per-mailbox, entered by the operator; the browser is not a trust boundary 23// and this page cannot ask for it). mailclient broadcasts its own total on 24// `zimx:mail-unread` and this listens. No session -> no badge, and the pill 25// says why instead of showing a confident zero. 26// 27// One poll, single-flight, paused while the page is hidden. tasks:Team card 208 tracks 28// unread-poll STACKING in appTray - a second stacking poll on the same board is exactly 29// the bug this must not reproduce. 30 31import { getCurrentProfile } from "../auth/userProfile.js"; 32import { loadMessages, whoAmI } from "./common/shellDocStore.js"; 33 34const POLL_MS = 60000; // one read a minute; the badge is a hint, not a live feed 35const MSG_LIMIT = 500; // a team board is hundreds of rows - read enough to not undercount 36const MARK_KEY = "zimx.dock.msgSeen"; // + ":" + user 37 38// -- one-time CSS ----------------------------------------------------------- 39function ensureStyles() { 40 if (document.getElementById("dock-status-styles")) return; 41 const st = document.createElement("style"); 42 st.id = "dock-status-styles"; 43 st.textContent = ` 44 .dock-stat { 45 display: flex; align-items: center; gap: 6px; flex-shrink: 0; 46 height: 26px; padding: 0 10px; margin-top: 4px; 47 background: rgba(255,255,255,.10); color: #e7e3f5; 48 border: 1px solid rgba(255,255,255,.14); border-radius: 13px; 49 font: 600 11.5px system-ui,-apple-system,sans-serif; white-space: nowrap; 50 text-decoration: none; user-select: none; 51 } 52 .dock-stat.click { cursor: pointer; } 53 .dock-stat.click:hover { background: rgba(255,255,255,.20); } 54 .dock-stat.off { opacity: .55; } 55 .dock-stat .ds-icon { font-size: 13px; line-height: 1; } 56 .dock-stat .ds-badge { 57 min-width: 16px; text-align: center; padding: 0 5px; 58 background: #ef4444; color: #fff; border-radius: 9px; 59 font-size: 10px; font-weight: 700; line-height: 15px; 60 } 61 .dock-stat .ds-badge.zero { display: none; } 62 @media (max-width: 640px) { .dock-stat .ds-label { display: none; } } 63 `; 64 document.head.appendChild(st); 65} 66 67// -- one pill --------------------------------------------------------------- 68function makePill({ icon, label, onClick }) { 69 const el = document.createElement(onClick ? "button" : "span"); 70 el.className = "dock-stat" + (onClick ? " click" : ""); 71 if (onClick) { el.type = "button"; el.addEventListener("click", onClick); } 72 73 const ic = document.createElement("span"); 74 ic.className = "ds-icon"; 75 ic.textContent = icon; 76 77 const tx = document.createElement("span"); 78 tx.className = "ds-label"; 79 tx.textContent = label; 80 81 const badge = document.createElement("span"); 82 badge.className = "ds-badge zero"; 83 84 el.append(ic, tx, badge); 85 return { el, tx, badge }; 86} 87 88function setBadge(pill, n) { 89 const v = Number(n) || 0; 90 pill.badge.textContent = v > 99 ? "99+" : String(v); 91 pill.badge.classList.toggle("zero", v <= 0); 92} 93 94function readMark(user) { 95 try { return Number(localStorage.getItem(`${MARK_KEY}:${user}`)) || 0; }
96 catch { return 0; } 97} 98function writeMark(user, seq) { 99 try { localStorage.setItem(`${MARK_KEY}:${user}`, String(Number(seq) || 0)); } 100 catch { /* private mode - the badge degrades to counting from 0, never throws */ } 101} 102 103/** 104 * Build the status cluster into `host` (the dock strip's right-hand section). 105 * 106 * @param {HTMLElement} host 107 * @param {object} opts 108 * @param {(appId: string) => any} opts.openApp open a catalogue app as a docked tab 109 * @returns {{refresh: () => void, destroy: () => void}} 110 */ 111export function mountDockStatus(host, { openApp } = {}) { 112 if (!host) return { refresh() {}, destroy() {} }; 113 ensureStyles(); 114 host.textContent = ""; 115 116 const open = (id) => { 117 try { openApp?.(id); } 118 catch (e) { console.warn("[dockStatus] open failed:", e); } 119 }; 120 121 // -- login ---------------------------------------------------------------- 122 // Two elements, swapped in place: a real <a> when signed out (a link the browser 123 // can middle-click and the keyboard can tab to) and a static pill when signed in. 124 // A button that navigates would be neither. 125 const login = makePill({ icon: "\u{1F464}", label: "Sign in", onClick: null }); 126 const loginLink = document.createElement("a"); 127 loginLink.className = "dock-stat click"; 128 loginLink.href = "./login.html"; 129 loginLink.title = "Not signed in - saved projects, feeds and the message board need an account"; 130 const llIcon = document.createElement("span"); 131 llIcon.className = "ds-icon"; 132 llIcon.textContent = "\u{1F464}"; 133 const llText = document.createElement("span"); 134 llText.className = "ds-label"; 135 llText.textContent = "Sign in"; 136 loginLink.append(llIcon, llText); 137 138 function paintLogin() { 139 const p = getCurrentProfile(); 140 const who = (p?.username || p?.userId || "").trim(); 141 if (!who) { 142 if (login.el.isConnected) login.el.replaceWith(loginLink); 143 return; 144 } 145 const tier = (p.tier || "").trim(); 146 login.tx.textContent = who; 147 login.el.title = `Signed in as ${who}${tier ? ` (${tier})` : ""}` 148 + (p.sessionExpiry ? ` - session ends ${new Date(p.sessionExpiry).toLocaleString()}` : ""); 149 if (loginLink.isConnected) loginLink.replaceWith(login.el); 150 } 151 152 // -- messages ------------------------------------------------------------- 153 let lastMaxSeq = 0; 154 let inFlight = false; 155 156 const msg = makePill({ 157 icon: "\u{1F4EC}", 158 label: "Messages", 159 onClick: () => { 160 // Opening the board IS the read: stamp the watermark to what we last counted, 161 // so the badge clears the moment the user acts on it, not a poll later. 162 const who = whoAmI(); 163 if (who && lastMaxSeq > 0) { writeMark(who, lastMaxSeq); setBadge(msg, 0); } 164 open("messages-view"); 165 }, 166 }); 167 msg.el.title = "Message board"; 168 169 async function pollMessages() { 170 if (inFlight) return; // single-flight: no stacking (card 208) 171 if (document.visibilityState === "hidden") return; 172 const who = whoAmI(); 173 if (!who) { // logged out: no round trip, no badge 174 msg.el.classList.add("off"); 175 msg.el.title = "Message board - sign in to see your unread count"; 176 setBadge(msg, 0); 177 return; 178 } 179 inFlight = true; 180 try { 181 const { rows } = await loadMessages({ to: who, limit: MSG_LIMIT }); 182 const mark = readMark(who); 183 let unread = 0; 184 lastMaxSeq = 0; 185 for (const m of rows || []) { 186 const seq = Number(m.seq || 0); 187 if (seq > lastMaxSeq) lastMaxSeq = seq; 188 // Your own posts are visible to you but are not news to you. 189 if (seq > mark && String(m.from || "").trim() !== who) unread += 1; 190 } 191 msg.el.classList.remove("off"); 192 setBadge(msg, unread); 193 msg.el.title = unread 194 ? `${unread} new message${unread === 1 ? "" : "s"} since you last opened the board on this device` 195 : "Message board - nothing new on this device"; 196 } catch (e) { 197 // AUTH_REQUIRED or an unreachable store: say nothing rather than a wrong zero. 198 msg.el.classList.add("off"); 199 msg.el.title = `Message board - unread count unavailable (${(e && e.message) || e})`; 200 setBadge(msg, 0); 201 } finally { 202 inFlight = false;
203 } 204 } 205 206 // -- mail ----------------------------------------------------------------- 207 const mail = makePill({ icon: "âï¸", label: "Mail", onClick: () => open("mailclient") }); 208 const mailIdle = "Mail - open Mail and sign in to a mailbox to see unread here"; 209 mail.el.classList.add("off"); 210 mail.el.title = mailIdle; 211 212 function onMailUnread(ev) { 213 const d = ev?.detail || {}; 214 const accounts = Number(d.accounts) || 0; 215 const unseen = Number(d.unseen) || 0; 216 if (!accounts) { 217 mail.el.classList.add("off"); 218 mail.el.title = mailIdle; 219 setBadge(mail, 0); 220 return; 221 } 222 mail.el.classList.remove("off"); 223 setBadge(mail, unseen); 224 // The time is not decoration: this number is whatever the mail shell last saw, and 225 // it stops updating the moment that shell is closed. 226 mail.el.title = `${unseen} unread across ${accounts} mailbox${accounts === 1 ? "" : "es"}` 227 + ` - as of ${new Date().toLocaleTimeString()}`; 228 } 229 230 host.append(loginLink, msg.el, mail.el); 231 paintLogin(); 232 void pollMessages(); 233 234 // Repaint login on a sign-in/out in ANOTHER tab (storage fires cross-tab only) and on 235 // this page's own auth events; poll messages on a timer and whenever the page is 236 // brought back to the front (a hidden page skips its ticks by design). 237 const timer = setInterval(pollMessages, POLL_MS); 238 const onVis = () => { 239 if (document.visibilityState === "visible") { paintLogin(); void pollMessages(); } 240 }; 241 const onAuth = () => paintLogin(); 242 document.addEventListener("visibilitychange", onVis); 243 window.addEventListener("storage", onAuth); 244 window.addEventListener("zimx:auth-changed", onAuth); 245 window.addEventListener("zimx:mail-unread", onMailUnread); 246 247 return { 248 refresh() { paintLogin(); void pollMessages(); }, 249 destroy() { 250 clearInterval(timer); 251 document.removeEventListener("visibilitychange", onVis); 252 window.removeEventListener("storage", onAuth); 253 window.removeEventListener("zimx:auth-changed", onAuth); 254 window.removeEventListener("zimx:mail-unread", onMailUnread); 255 host.textContent = ""; 256 }, 257 }; 258}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.