samagotchi 0.112.0 → 0.114.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +48 -1
  3. data/bin/chi +8 -0
  4. data/docs/cli.md +24 -6
  5. data/docs/configuration.md +22 -10
  6. data/docs/internals/prompt-caching.md +2 -1
  7. data/docs/internals/web-push.md +28 -4
  8. data/docs/sessions.md +3 -3
  9. data/lib/samagotchi/bootstrap_command.rb +70 -5
  10. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  11. data/lib/samagotchi/config.rb +24 -0
  12. data/lib/samagotchi/config_text_edit.rb +10 -3
  13. data/lib/samagotchi/gem_update.rb +5 -1
  14. data/lib/samagotchi/idle_recap.rb +38 -10
  15. data/lib/samagotchi/push_command.rb +188 -0
  16. data/lib/samagotchi/recap_store.rb +20 -3
  17. data/lib/samagotchi/recap_title.rb +78 -0
  18. data/lib/samagotchi/run_options.rb +1 -0
  19. data/lib/samagotchi/session_manager.rb +10 -3
  20. data/lib/samagotchi/sessions_command.rb +14 -11
  21. data/lib/samagotchi/title_command.rb +6 -1
  22. data/lib/samagotchi/version.rb +1 -1
  23. data/lib/samagotchi/web/app.rb +46 -3
  24. data/lib/samagotchi/web/markdown_renderer.rb +20 -0
  25. data/lib/samagotchi/web/public/app.js +6 -0
  26. data/lib/samagotchi/web/public/index.html +43 -4
  27. data/lib/samagotchi/web/public/markdown_tip.js +33 -0
  28. data/lib/samagotchi/web/public/notifications.js +22 -8
  29. data/lib/samagotchi/web/public/push_devices.js +187 -0
  30. data/lib/samagotchi/web/push/attention.rb +2 -1
  31. data/lib/samagotchi/web/push/store.rb +14 -0
  32. data/lib/samagotchi/web/push.rb +5 -0
  33. data/lib/samagotchi/web/push_routes.rb +17 -3
  34. data/lib/samagotchi/web/server.rb +46 -14
  35. data/lib/samagotchi/web/session_show.rb +2 -0
  36. data/lib/samagotchi/web/session_summary.rb +7 -2
  37. metadata +5 -1
@@ -99,6 +99,7 @@ import { createStartPage } from "./start_page.js";
99
99
  import { createSessionActions } from "./session_actions.js";
100
100
  import { createRename } from "./session_rename.js";
101
101
  import { createSendTurn } from "./send_turn.js";
102
+ import { createMarkdownTip } from "./markdown_tip.js";
102
103
 
103
104
  // A link's #code= (behind an auth proxy): traded for the cookie before
104
105
  // anything else runs; the page reloads without it.
@@ -193,6 +194,8 @@ const startPage = createStartPage({
193
194
  listModels,
194
195
  });
195
196
  const markdownWarningEl = $("#markdownWarning");
197
+ // While web.markdown is off: how to turn it on, until dismissed here.
198
+ const markdownTip = createMarkdownTip($("#markdownTip"));
196
199
  // What the page renders that a notice may stand in for (the server's
197
200
  // Web::Capabilities, next to markdown_warning); a notice it names isn't
198
201
  // drawn (shownElsewhere). An older server sends none: every notice shows.
@@ -817,6 +820,7 @@ function clearSelection() {
817
820
  setTurnRunning(false);
818
821
  closeStream();
819
822
  setMarkdownWarning(null);
823
+ markdownTip.show(null);
820
824
  setCapabilities(null, null);
821
825
  composerGrip.newVisit();
822
826
  updateComposerMode();
@@ -944,6 +948,7 @@ async function resync({ endText = null } = {}) {
944
948
  // then a pending question.
945
949
  function renderSessionView(data, { kept = [] } = {}) {
946
950
  setMarkdownWarning(data.markdown_warning);
951
+ markdownTip.show(data.markdown_tip);
947
952
  setCapabilities(data.capabilities, data.editor_url);
948
953
  // The saved fill (when there is one) goes in here too, so a reloaded or
949
954
  // stopped session shows it before its next turn (a running turn's replay
@@ -1092,6 +1097,7 @@ async function readTurnEnd() {
1092
1097
  if (!(await mergeTurnTiming(id, d.timing))) return;
1093
1098
  liveTurn.timing.finishLine(line);
1094
1099
  setMarkdownWarning(d.markdown_warning);
1100
+ markdownTip.show(d.markdown_tip);
1095
1101
  setCapabilities(d.capabilities, d.editor_url);
1096
1102
  if (Array.isArray(d.messages)) applyFinalMarkdown(d.messages);
1097
1103
  updateInfoBar();
@@ -109,6 +109,11 @@ main{flex:1;display:flex;flex-direction:column;gap:16px;padding:16px;overflow:au
109
109
  @keyframes init-spin{to{transform:rotate(360deg)}}
110
110
  @media (prefers-reduced-motion:reduce){.init-spin{animation:none;border-top-color:var(--accent)}.init-task{transition:none}}
111
111
  .markdown-warning{margin:0 16px 4px;padding:8px 10px;border:1px solid var(--warn);border-radius:8px;background:var(--warn-bg);color:var(--warn-bright);font-size:12px;line-height:1.4}
112
+ .markdown-tip{display:flex;align-items:flex-start;gap:8px;margin:0 16px 4px;padding:6px 6px 6px 10px;border:1px solid var(--accent-border);border-radius:8px;background:var(--accent-bg);color:var(--text-muted);font-size:12px;line-height:1.4}
113
+ .markdown-tip[hidden]{display:none}
114
+ .markdown-tip-text{flex:1;min-width:0}
115
+ .markdown-tip-close{flex:none;width:18px;height:18px;padding:0;border:0;border-radius:4px;background:transparent;color:var(--text-muted);font-size:14px;line-height:1;cursor:pointer}
116
+ .markdown-tip-close:hover{background:var(--bg-hover);color:var(--text)}
112
117
  .bubble.user{border-left:3px solid var(--accent);background:var(--accent-bg);align-self:flex-end;max-width:85%;margin-left:auto;display:flex;align-items:center;justify-content:space-between;gap:10px}
113
118
  .bubble.user.queued{border-left-style:dashed;opacity:.8}
114
119
  .bubble.user.steered{border-left-style:dashed;border-left-color:var(--accent);opacity:.9}
@@ -1183,6 +1188,37 @@ body.has-session.composer-folded #infoBar{border-top-color:transparent}
1183
1188
  .pp-foot{display:flex;align-items:center;gap:8px;justify-content:flex-end;padding-top:4px}
1184
1189
  .pp-view{font-size:12px;color:var(--accent);text-decoration:none}
1185
1190
  .pp-view:hover{text-decoration:underline}
1191
+ /* The Home Screen app's notifications sheet (push_devices.js): the bell opens it; a bottom sheet on a phone. */
1192
+ .push-devices .ctx-pop-head strong{font-family:inherit}
1193
+ .push-devices.sheet{border-radius:14px;padding:14px 16px;font-size:14px}
1194
+ .pd-list{list-style:none;display:flex;flex-direction:column;gap:8px;margin:2px 0;padding:0}
1195
+ .pd-row{display:grid;grid-template-columns:1fr auto;align-items:center;gap:2px 10px;padding:8px 10px;border-radius:10px;background:var(--bg-inset)}
1196
+ .pd-who{display:flex;align-items:center;gap:6px;min-width:0;color:var(--text)}
1197
+ .pd-who b{font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
1198
+ .pd-tag{font-size:10.5px;padding:0 6px;border-radius:999px;background:var(--accent-bg);color:var(--accent);white-space:nowrap}
1199
+ .pd-act{grid-column:2;grid-row:1/3;display:flex;align-items:center}
1200
+ .pd-meta{grid-column:1;font-size:11.5px;color:var(--text-muted)}
1201
+ .pd-error{color:var(--danger-text)}
1202
+ .pd-done{grid-column:1/3;display:flex;align-items:center;justify-content:space-between;gap:8px;margin-top:4px;padding-top:6px;border-top:1px solid var(--border);font-size:12px;color:var(--text-3)}
1203
+ .pd-remove{padding:3px 10px;border-radius:7px;font-size:12px;background:var(--bg-raised);color:var(--danger-text);border:1px solid var(--border-strong);cursor:pointer}
1204
+ .pd-remove:hover{color:var(--text-hover)}
1205
+ .pd-switch{appearance:none;-webkit-appearance:none;position:relative;flex-shrink:0;width:34px;height:20px;margin:0;border-radius:999px;background:var(--bg-raised);border:1px solid var(--border-strong);cursor:pointer;transition:background .15s}
1206
+ .pd-switch::after{content:"";position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;background:var(--text-muted);transition:transform .15s,background .15s}
1207
+ .pd-switch:checked{background:var(--accent-strong);border-color:var(--accent-strong)}
1208
+ .pd-switch:checked::after{transform:translateX(14px);background:var(--on-solid)}
1209
+ .pd-switch:disabled{opacity:.5;cursor:default}
1210
+ .pd-switch:focus-visible{outline:2px solid var(--accent-ring);outline-offset:2px}
1211
+ .pd-done .pd-switch{width:30px;height:18px}
1212
+ .pd-done .pd-switch::after{width:12px;height:12px}
1213
+ .pd-done .pd-switch:checked::after{transform:translateX(12px)}
1214
+ .pd-note{font-size:11.5px;color:var(--text-muted)}
1215
+ .pd-note.pd-error{color:var(--danger-text)}
1216
+ .push-devices.sheet .pd-switch{width:42px;height:26px}
1217
+ .push-devices.sheet .pd-switch::after{width:20px;height:20px}
1218
+ .push-devices.sheet .pd-switch:checked::after{transform:translateX(16px)}
1219
+ .push-devices.sheet .pd-done{font-size:13px}
1220
+ .push-devices.sheet .pd-meta,.push-devices.sheet .pd-note{font-size:12.5px}
1221
+ .push-devices.sheet .pd-remove{padding:6px 12px;font-size:13px}
1186
1222
  .ctx-pop-head{display:flex;align-items:baseline;gap:8px}
1187
1223
  .ctx-pop-head strong{font-family:ui-monospace,monospace;color:var(--text-bright)}
1188
1224
  .ctx-pop-kind{font-size:11px;color:var(--text-muted)}
@@ -1240,7 +1276,7 @@ body.has-session.composer-folded #infoBar{border-top-color:transparent}
1240
1276
  /* Start page (no session): the composer alone, centered; no info bar. */
1241
1277
  body:not(.has-session) #center{justify-content:safe center;background:transparent}
1242
1278
  body:not(.has-session) #stage{flex:0 0 auto;min-height:0;background:transparent}
1243
- body:not(.has-session) #history,body:not(.has-session) #markdownWarning{display:none}
1279
+ body:not(.has-session) #history,body:not(.has-session) #markdownWarning,body:not(.has-session) #markdownTip{display:none}
1244
1280
  body:not(.has-session) #empty{position:static;background:transparent;padding:0}
1245
1281
  body:not(.has-session) #dock{width:min(720px,calc(100% - 32px));margin:0 auto}
1246
1282
  body:not(.has-session) #infoBar{display:none}
@@ -1457,7 +1493,9 @@ body.all-view #center, body.all-view #topStrip{display:none}
1457
1493
  #toast .toast-action{background:transparent;border:0;color:var(--accent);font-weight:600;cursor:pointer;padding:2px 4px}
1458
1494
  #toast .toast-close{background:transparent;border:0;color:var(--text-muted);cursor:pointer;padding:2px 4px;margin-left:-6px}
1459
1495
  #toast .toast-close:hover{color:var(--text)}
1460
- #allList{flex:1;min-height:0;overflow:auto;display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:12px;align-content:start;padding:2px}
1496
+ /* Each row is as tall as its tallest card. An `auto` row in a grid that overflows its
1497
+ height has no free space to grow into, so it collapses to each card's min-height. */
1498
+ #allList{flex:1;min-height:0;overflow:auto;display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:12px;align-content:start;grid-auto-rows:max-content;padding:2px}
1461
1499
  .card.skeleton{pointer-events:none;border-style:dashed;opacity:.9;animation:skeleton-pulse 1.2s ease-in-out infinite}
1462
1500
  .card.skeleton .line{height:10px;background:var(--bg-raised);border-radius:6px}
1463
1501
  .card.skeleton .line.long{width:78%}
@@ -1471,8 +1509,8 @@ body.all-view #center, body.all-view #topStrip{display:none}
1471
1509
  .card{padding:12px;border-radius:10px;background:var(--bg-panel);border:1px solid var(--border);cursor:pointer;display:flex;flex-direction:column;gap:6px;min-height:72px}
1472
1510
  .card:hover{border-color:var(--border-strong);background:var(--bg-hover)}
1473
1511
  #allList .card{position:relative}
1474
- /* Every row keeps its content's height: a card shorter than its rows (seen on iOS) cut the clamped preview and the
1475
- single-line title and recap through the middle. */
1512
+ /* A card's lines are never squeezed to fit a short card: the flex side of the same overflow the rule above fixes
1513
+ (an overflowing grid collapsing its rows to min-height), seen on iOS as the title, recap and preview cut through. */
1476
1514
  #allList .card > *{flex-shrink:0}
1477
1515
  /* A card's tools in the all view: archive (or unarchive) and delete, shown on hover, always under hover:none. */
1478
1516
  #allList .card-tools{position:absolute;top:6px;right:6px;display:flex;gap:2px}
@@ -1547,6 +1585,7 @@ a{color:var(--accent)}
1547
1585
  <section id="center">
1548
1586
  <div id="stage">
1549
1587
  <div id="markdownWarning" class="markdown-warning" hidden></div>
1588
+ <div id="markdownTip" class="markdown-tip" hidden><span class="markdown-tip-text"></span><button type="button" class="markdown-tip-close" aria-label="Dismiss tip" title="Dismiss tip">×</button></div>
1550
1589
  <div id="history"></div>
1551
1590
  <div id="empty"></div>
1552
1591
  <div id="notice" role="status" hidden></div>
@@ -0,0 +1,33 @@
1
+ // The session view's Markdown tip (the server's Web::MarkdownRenderer#tip,
2
+ // sent as markdown_tip while web.markdown is off): one calm line with a ×
3
+ // that hides it for this browser. The text is the server's; an older server
4
+ // sends none, so no tip.
5
+ import { readStored, writeStored } from "./storage.js";
6
+
7
+ export const TIP_DISMISSED_KEY = "chi_markdown_tip_dismissed";
8
+
9
+ // +el+ holds a .markdown-tip-text span and a .markdown-tip-close button
10
+ // (index.html's #markdownTip).
11
+ export function createMarkdownTip(el) {
12
+ const textEl = el.querySelector(".markdown-tip-text");
13
+ const closeBtn = el.querySelector(".markdown-tip-close");
14
+
15
+ function hide() {
16
+ el.hidden = true;
17
+ textEl.textContent = "";
18
+ }
19
+
20
+ // The tip for this payload: shown unless there is none or it was dismissed.
21
+ function show(text) {
22
+ if (!text || readStored(TIP_DISMISSED_KEY) === "1") return hide();
23
+ textEl.textContent = text;
24
+ el.hidden = false;
25
+ }
26
+
27
+ closeBtn.addEventListener("click", () => {
28
+ writeStored(TIP_DISMISSED_KEY, "1");
29
+ hide();
30
+ });
31
+
32
+ return { show };
33
+ }
@@ -12,7 +12,9 @@
12
12
  // leaves the bell as it is in a tab. With the bell on, the app also tidies
13
13
  // the device (syncDevice): it closes the pushed notifications whose
14
14
  // question or card isn't open any more and sets the app's badge, as chi
15
- // web sends no close push (WebKit's silent-push quota).
15
+ // web sends no close push (WebKit's silent-push quota). There the bell
16
+ // opens the notifications sheet (push_devices.js): this device's switch,
17
+ // the other devices with their "long turns done" switch and Remove.
16
18
  //
17
19
  // Every page, in a tab or the app, also tells chi web whether the user is
18
20
  // at it (POST /api/push/presence, reportPresence): chi web pushes nothing
@@ -30,6 +32,7 @@ import {
30
32
  import {
31
33
  isWaitingTag, openPushKeys, pushSupported, subscribe as pushSubscribe, unsubscribe as pushUnsubscribe,
32
34
  } from "./push.js";
35
+ import { createPushDevices } from "./push_devices.js";
33
36
 
34
37
  export const NOTIFY_KEY = "chi_notify";
35
38
  // The device id chi web gave this app's subscription (POST subscribe's
@@ -53,8 +56,8 @@ const NOTIFY_TITLES = {
53
56
  };
54
57
 
55
58
  const PUSH_TITLES = {
56
- on: "Notifications to this phone: on (a session that needs you or failed, also while the app is closed). Tap to turn off",
57
- off: "Notify this phone when a session needs you or a turn fails, also while the app is closed",
59
+ on: "Notifications to this phone: on (a session that needs you or failed, also while the app is closed). Tap for the devices",
60
+ off: "Notify this phone when a session needs you or a turn fails, also while the app is closed. Tap to turn it on",
58
61
  denied: "Notifications are blocked for this app in the phone's settings",
59
62
  };
60
63
 
@@ -108,6 +111,8 @@ function defaultChannel() {
108
111
  // @param sendPresence (body, leaving) => void (presenceSender's by default)
109
112
  // @param every (fn, ms) => void, a repeating timer (presence's tick)
110
113
  // @param pageId this page load's id in its presence reports
114
+ // @param devices (hooks) => the notifications sheet ({toggle, refresh}),
115
+ // made once chi web says it pushes (push_devices.js by default)
111
116
  // @return {noticeSessions(type, data), noticeList(sessions), setBaseTitle(title), clearBadge()}
112
117
  export function createNotifications({
113
118
  button,
@@ -124,12 +129,15 @@ export function createNotifications({
124
129
  sendPresence = presenceSender({ nav, api }),
125
130
  every = (fn, ms) => setInterval(fn, ms),
126
131
  pageId = randomTabId(),
132
+ devices = (hooks) => createPushDevices({ ...hooks, doc, win }),
127
133
  }) {
128
134
  let attention = initialAttentionState();
129
135
  // Web Push in the Home Screen app: null until chi web says it pushes
130
136
  // ({reg, key, on}), and for good in a tab or without push.
131
137
  let push = null;
132
138
  let pushBusy = false;
139
+ // The notifications sheet the bell opens in push mode.
140
+ let sheet = null;
133
141
  let syncing = null;
134
142
  let syncAgain = false;
135
143
  // When this page last had the browser check for a newer sw.js.
@@ -186,7 +194,7 @@ export function createNotifications({
186
194
  }
187
195
 
188
196
  async function toggleNotify() {
189
- if (push) return togglePush();
197
+ if (push) return sheet?.toggle();
190
198
  const state = notifyState();
191
199
  if (state === "unsupported" || state === "denied") return;
192
200
  let wanted = state !== "on";
@@ -197,10 +205,11 @@ export function createNotifications({
197
205
  renderNotifyBtn();
198
206
  }
199
207
 
200
- // This device's subscription on (permission from the tap: iOS asks only
201
- // from one), a test push to show it works; or off.
208
+ // This device's subscription on (permission from the tap on the sheet's
209
+ // switch: iOS asks only from one), a test push to show it works; or off.
210
+ // @return the failure's message, or null
202
211
  async function togglePush() {
203
- if (pushBusy || notifyState() === "denied") return;
212
+ if (pushBusy || notifyState() === "denied") return null;
204
213
  pushBusy = true;
205
214
  let failed = null;
206
215
  try {
@@ -228,8 +237,9 @@ export function createNotifications({
228
237
  pushBusy = false;
229
238
  }
230
239
  renderNotifyBtn();
231
- // Said on the bell (its tooltip); the next tap tries again.
240
+ // Said on the bell (its tooltip) and in the sheet; the next tap tries again.
232
241
  if (failed) button.title = `Notifications to this phone failed: ${failed}`;
242
+ return failed && `Notifications to this phone failed: ${failed}`;
233
243
  }
234
244
 
235
245
  // The Home Screen app: its service worker, chi web's key, and whether
@@ -243,6 +253,10 @@ export function createNotifications({
243
253
  const info = await api("GET", `/api/push${query}`);
244
254
  if (!info?.enabled || !info.public_key) return;
245
255
  push = { reg, key: info.public_key, on: !!(sub && info.this) };
256
+ sheet = devices({
257
+ button, api, state: notifyState, toggleThis: togglePush,
258
+ endpoint: async () => (await push.reg.pushManager.getSubscription())?.endpoint || null,
259
+ });
246
260
  // register() has just checked for a newer sw.js.
247
261
  swCheckedAt = now();
248
262
  renderNotifyBtn();
@@ -0,0 +1,187 @@
1
+ // The Home Screen app's notifications sheet (Web Push; notifications.js
2
+ // opens it from the bell when chi web pushes): this device's switch (the
3
+ // bell's subscribe / unsubscribe), then every device chi web pushes to from
4
+ // this origin (GET /api/push's devices): its label, "this device", the last
5
+ // delivery and the last error, a "Long turns done" switch (PATCH
6
+ // /api/push/subscriptions/ID {prefs: {done}}), Remove for another device
7
+ // (DELETE /api/push/subscriptions/ID), and the stale ones (another origin's,
8
+ // never sent to) as one line. A phone gets it as a bottom sheet, a wider
9
+ // window as a popover at the bell (chip_popover.js), small enough to need
10
+ // no scrolling for a few devices.
11
+ //
12
+ // This device's row has the switch and never Remove: turning it off is the
13
+ // bell turned off (the page forgets its device id), so the page's state and
14
+ // chi web's store stay one. A device iOS revoked silently still shows a
15
+ // recent delivery (Apple keeps answering 201): the list can't tell; switch
16
+ // it off and on there.
17
+ //
18
+ // Pure templates and models, plus createPushDevices (the injected api and
19
+ // the popover shell), so the specs run them under node --test.
20
+
21
+ import { html, nothing, render } from "./vendor/lit-html.js";
22
+ import { createChipPopover } from "./chip_popover.js";
23
+ import { relativeTime } from "./format.js";
24
+
25
+ export const DEVICE_PATH = "/api/push/subscriptions";
26
+
27
+ // The rows GET /api/push's +info+ gives, this device first.
28
+ // @return [{id, label, this, last, error, done}]
29
+ export function deviceRows(info, now) {
30
+ const devices = Array.isArray(info?.devices) ? info.devices : [];
31
+ const rows = devices.map((d) => ({
32
+ id: String(d.id || ""),
33
+ label: d.label || d.id || "device",
34
+ this: d.this === true,
35
+ last: d.last_ok_at ? `last delivery ${relativeTime(d.last_ok_at, now)}` : "no delivery yet",
36
+ error: d.last_error ? `last error ${d.last_error}` : "",
37
+ done: d.prefs?.done === true,
38
+ }));
39
+ return [...rows.filter((r) => r.this), ...rows.filter((r) => !r.this)];
40
+ }
41
+
42
+ // "2 more devices from an earlier address, never sent to" (another origin's:
43
+ // a renamed machine or tailnet), "" for none.
44
+ export function staleText(count) {
45
+ const n = Number(count) || 0;
46
+ if (n <= 0) return "";
47
+ return `${n === 1 ? "1 more device" : `${n} more devices`} from an earlier address, never sent to (chi web push rm)`;
48
+ }
49
+
50
+ // What the sheet draws: +state+ the bell's ("on" | "off" | "denied"),
51
+ // +info+ GET /api/push's answer (null before the first), +busy+ a change
52
+ // in flight, +error+ the last one's failure.
53
+ export function sheetModel({ state, info, busy = false, error = "", now = Date.now() }) {
54
+ const rows = deviceRows(info, now);
55
+ const mine = rows.find((r) => r.this) || null;
56
+ const thisRow = mine || {
57
+ id: "", label: "This device", this: true, done: false, error: "",
58
+ last: state === "denied" ? "notifications are blocked for this app in the phone's settings" : "off: nothing is pushed here",
59
+ };
60
+ return {
61
+ on: state === "on" && !!mine,
62
+ denied: state === "denied",
63
+ busy,
64
+ error,
65
+ thisRow,
66
+ others: rows.filter((r) => !r.this),
67
+ stale: staleText(info?.stale),
68
+ loading: !info,
69
+ };
70
+ }
71
+
72
+ const doneSwitch = (row, busy) => html`<label class="pd-done"><span>Long turns done (10 s or more)</span
73
+ ><input type="checkbox" class="pd-switch" role="switch" data-pd="done" data-id=${row.id} .checked=${row.done}
74
+ ?disabled=${busy}></label>`;
75
+
76
+ function rowTemplate(row, model) {
77
+ const mine = row.this;
78
+ const subscribed = !mine || model.on;
79
+ return html`<li class=${`pd-row${mine ? " this" : ""}`} data-id=${row.id || nothing}>
80
+ <div class="pd-who"><b>${row.label}</b>${mine ? html`<span class="pd-tag">this device</span>` : nothing}</div>
81
+ <div class="pd-act">${mine
82
+ ? html`<input type="checkbox" class="pd-switch" role="switch" data-pd="this" aria-label="Notifications to this device"
83
+ .checked=${model.on} ?disabled=${model.busy || model.denied}>`
84
+ : html`<button type="button" class="pd-remove" data-pd="remove" data-id=${row.id} ?disabled=${model.busy}>Remove</button>`}</div>
85
+ <div class="pd-meta">${row.last}${row.error ? html` · <span class="pd-error">${row.error}</span>` : nothing}</div>
86
+ ${subscribed ? doneSwitch(row, model.busy) : nothing}
87
+ </li>`;
88
+ }
89
+
90
+ // The sheet's content (a lit-html template).
91
+ export function sheetTemplate(model) {
92
+ return html`<div class="ctx-pop-head"><strong>Notifications</strong><span class="ctx-pop-kind">devices</span
93
+ ><button type="button" class="ctx-pop-close" data-act="close" aria-label="Close">×</button></div
94
+ ><ul class="pd-list">${rowTemplate(model.thisRow, model)}${model.others.map((row) => rowTemplate(row, model))}</ul
95
+ >${model.loading ? html`<div class="pd-note">Loading the devices…</div>` : nothing
96
+ }${model.stale ? html`<div class="pd-note">${model.stale}</div>` : nothing
97
+ }${model.error ? html`<div class="pd-note pd-error" role="alert">${model.error}</div>` : nothing
98
+ }<div class="pd-note">Approvals, questions, step limits, cards and failed turns; quiet while a chi web page is in front.</div>`;
99
+ }
100
+
101
+ // The sheet at the bell. +state+() the bell's state; +endpoint+() this
102
+ // device's push endpoint (a Promise; null without a subscription);
103
+ // +toggleThis+() the bell's subscribe / unsubscribe (resolves to its
104
+ // failure's message, or null); +api+ chi web's JSON (notifications.js's).
105
+ // @return {toggle(), refresh(), isOpen(), pop}
106
+ export function createPushDevices({ button, api, state, endpoint, toggleThis, now = () => Date.now(),
107
+ doc = globalThis.document, win = globalThis.window }) {
108
+ let info = null;
109
+ let busy = false;
110
+ let error = "";
111
+ // Each load's turn: an older answer that arrives late is dropped.
112
+ let loads = 0;
113
+
114
+ const draw = () => {
115
+ if (shell.isOpen()) render(sheetTemplate(sheetModel({ state: state(), info, busy, error, now: now() })), shell.pop);
116
+ };
117
+
118
+ const shell = createChipPopover({
119
+ id: "pushDevices", className: "ctx-popover push-devices", width: 340, el: doc.createElement("span"),
120
+ anchor: "#notifyBtn", openers: "#notifyBtn", summary: () => ({}), sheet: true, label: "Notifications", doc, win,
121
+ show: () => render(sheetTemplate(sheetModel({ state: state(), info, busy, error, now: now() })), shell.pop),
122
+ onClose: () => { error = ""; },
123
+ });
124
+
125
+ async function load() {
126
+ const turn = ++loads;
127
+ try {
128
+ const at = await endpoint();
129
+ const answer = await api("GET", `/api/push${at ? `?endpoint=${encodeURIComponent(at)}` : ""}`);
130
+ if (turn !== loads) return;
131
+ info = answer;
132
+ } catch (e) {
133
+ if (turn !== loads) return;
134
+ error = `Can't read the devices: ${e?.message || e}`;
135
+ }
136
+ draw();
137
+ shell.replace();
138
+ }
139
+
140
+ // One change at a time; the list is read again after it.
141
+ async function change(run) {
142
+ if (busy) return;
143
+ busy = true;
144
+ error = "";
145
+ draw();
146
+ try {
147
+ error = (await run()) || "";
148
+ } catch (e) {
149
+ error = e?.message || String(e);
150
+ } finally {
151
+ busy = false;
152
+ }
153
+ await load();
154
+ }
155
+
156
+ shell.pop.addEventListener("change", (e) => {
157
+ const input = e.target.closest?.("[data-pd]");
158
+ if (!input) return;
159
+ if (input.dataset.pd === "this") change(toggleThis);
160
+ else if (input.dataset.pd === "done") {
161
+ const id = input.dataset.id;
162
+ const done = input.checked;
163
+ change(() => api("PATCH", `${DEVICE_PATH}/${encodeURIComponent(id)}`, { prefs: { done } }).then(() => null));
164
+ }
165
+ });
166
+ shell.pop.addEventListener("click", (e) => {
167
+ const remove = e.target.closest?.('[data-pd="remove"]');
168
+ if (!remove) return;
169
+ const id = remove.dataset.id;
170
+ change(() => api("DELETE", `${DEVICE_PATH}/${encodeURIComponent(id)}`).then(() => null));
171
+ });
172
+
173
+ return {
174
+ pop: shell.pop,
175
+ isOpen: shell.isOpen,
176
+ // The bell's tap: open (drawn from what is known, then read again) or close.
177
+ toggle() {
178
+ if (shell.isOpen()) return shell.close();
179
+ shell.open(button);
180
+ return load();
181
+ },
182
+ // The bell's state changed elsewhere: redraw an open sheet.
183
+ refresh: draw,
184
+ // Read the devices again (specs await it).
185
+ load,
186
+ };
187
+ }
@@ -128,7 +128,8 @@ module Samagotchi
128
128
  # reason, never the question or the command (a lock screen shows it).
129
129
  # @return [Array(String, String)] title, body
130
130
  def text(summary, reason)
131
- name = (summary && [summary[:title], summary[:first_preview], summary[:last_prompt]].find { |v| present?(v) }) ||
131
+ name = (summary && [summary[:title], summary[:auto_title], summary[:first_preview], summary[:last_prompt]]
132
+ .find { |v| present?(v) }) ||
132
133
  "chi session"
133
134
  body = reason == "done" ? "done in #{seconds(summary&.dig(:last_turn) || {}).round} s" : BODIES.fetch(reason, "")
134
135
  [clip(name.to_s.strip, TITLE_MAX), body]
@@ -163,6 +163,10 @@ module Samagotchi
163
163
  []
164
164
  end
165
165
 
166
+ # As #subscriptions, but a corrupt file raises (chi web push says so).
167
+ # @raise [Corrupt]
168
+ def subscriptions! = read
169
+
166
170
  # The subscriptions for +origin+ (the ones chi web sends to now).
167
171
  def for_origin(origin) = subscriptions.select { |sub| sub.origin == origin }
168
172
 
@@ -206,6 +210,16 @@ module Samagotchi
206
210
  end
207
211
  end
208
212
 
213
+ # Removes the device with this short id, only one subscribed on
214
+ # +origin+ (nil: any; chi web push rm).
215
+ # @return [Sub, nil] the one removed
216
+ def remove_device(id, origin: nil)
217
+ update do |subs|
218
+ gone = subs.find { |sub| sub.id == id && (origin.nil? || sub.origin == origin) }
219
+ [subs - [gone], gone]
220
+ end
221
+ end
222
+
209
223
  # Sets the device's prefs (+done+: a push for a long turn done).
210
224
  # @param origin [String, nil] only a device subscribed there
211
225
  # @return [Sub, nil] the device as it is now, nil when there is none
@@ -29,6 +29,11 @@ module Samagotchi
29
29
  def initialize(presence: Presence.new, **) = super
30
30
  end
31
31
 
32
+ # The test push (POST /api/push/test, chi web push test): no badge
33
+ # (it would clear the app's) and no open list (no cleanup).
34
+ TEST_PAYLOAD = { v: 1, kind: "show", tag: "chi:test", title: "chi", body: "test notification", url: "/" }.freeze
35
+ TEST_TTL = 600
36
+
32
37
  module_function
33
38
 
34
39
  # Push runs for +public_url+ (nil: none) when web.push.enabled.
@@ -67,12 +67,25 @@ module Samagotchi
67
67
  end
68
68
 
69
69
  sub = Push::Store.short_id?(id) && @push.store.set_prefs(id, done: prefs["done"], origin: @push.origin)
70
- return error_response(404, "unknown_device", "no device #{id[0, 16]} pushes from this origin") unless sub
70
+ return unknown_device(id) unless sub
71
71
 
72
72
  Log.info(:web, "push_prefs", device: sub.id, done: sub.done?)
73
73
  json_response(200, sub.public_h)
74
74
  end
75
75
 
76
+ # DELETE …/subscriptions/ID → 204: the devices list's Remove for
77
+ # another device (this device's row turns the bell off instead, so
78
+ # the page and the store agree).
79
+ def handle_push_remove(_req, id)
80
+ return push_off unless @push
81
+
82
+ sub = Push::Store.short_id?(id) && @push.store.remove_device(id, origin: @push.origin)
83
+ return unknown_device(id) unless sub
84
+
85
+ Log.info(:web, "push_removed", device: sub.id)
86
+ [204, { "Cache-Control" => "no-store" }, []]
87
+ end
88
+
76
89
  # {endpoint} → 204: this device's bell turned off.
77
90
  def handle_push_unsubscribe(req)
78
91
  return push_off unless @push
@@ -95,9 +108,8 @@ module Samagotchi
95
108
  endpoint = body["endpoint"] if body.is_a?(Hash)
96
109
  subs = @push.store.for_origin(@push.origin)
97
110
  subs = subs.select { |sub| sub.endpoint == endpoint } if endpoint.is_a?(String) && !endpoint.empty?
98
- payload = { v: 1, kind: "show", tag: "chi:test", title: "chi", body: "test notification", url: "/" }
99
111
  results = subs.map do |sub|
100
- result = @push.client.deliver(sub, payload, ttl: 600, urgency: "high")
112
+ result = @push.client.deliver(sub, Push::TEST_PAYLOAD, ttl: Push::TEST_TTL, urgency: "high")
101
113
  Log.info(:web, "push_test", device: sub.id, code: result.code, ms: result.ms)
102
114
  @push.store.remove(sub.id) if result.status == :gone
103
115
  { id: sub.id, label: sub.label, status: result.status.to_s, code: result.code, ms: result.ms }
@@ -116,6 +128,8 @@ module Samagotchi
116
128
  [204, { "Cache-Control" => "no-store" }, []]
117
129
  end
118
130
 
131
+ def unknown_device(id) = error_response(404, "unknown_device", "no device #{id[0, 16]} pushes from this origin")
132
+
119
133
  def push_off
120
134
  error_response(409, "push_off", "push needs an https public URL (chi web --tunnel tailscale, or web.public_url) " \
121
135
  "and web.push.enabled")