PageSourceSearch

https://smartcone.ai/src/shell/dockStatus.js

js smartcone.ai collected 2026-10-05 15:05:52 UTC 10,759 bytes, 258 lines download raw bytes

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.