samagotchi 0.111.1 → 0.113.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +53 -1
- data/bin/chi +8 -0
- data/docs/architecture.md +2 -2
- data/docs/cli.md +4 -2
- data/docs/configuration.md +41 -21
- data/docs/internals/web-push.md +132 -42
- data/docs/sessions.md +3 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/push_command.rb +188 -0
- data/lib/samagotchi/run_options.rb +1 -0
- data/lib/samagotchi/session.rb +4 -2
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +49 -3
- data/lib/samagotchi/web/public/chains.js +9 -6
- data/lib/samagotchi/web/public/format.js +20 -5
- data/lib/samagotchi/web/public/index.html +47 -5
- data/lib/samagotchi/web/public/lists_view.js +3 -2
- data/lib/samagotchi/web/public/notifications.js +91 -16
- data/lib/samagotchi/web/public/notify.js +3 -3
- data/lib/samagotchi/web/public/participant.html +4 -0
- data/lib/samagotchi/web/public/push.js +16 -17
- data/lib/samagotchi/web/public/push_devices.js +187 -0
- data/lib/samagotchi/web/public/session_state.js +4 -3
- data/lib/samagotchi/web/public/sw.js +6 -5
- data/lib/samagotchi/web/push/attention.rb +80 -35
- data/lib/samagotchi/web/push/client.rb +23 -6
- data/lib/samagotchi/web/push/notifier.rb +152 -20
- data/lib/samagotchi/web/push/presence.rb +60 -0
- data/lib/samagotchi/web/push/store.rb +86 -4
- data/lib/samagotchi/web/push.rb +13 -4
- data/lib/samagotchi/web/push_routes.rb +58 -8
- data/lib/samagotchi/web/server.rb +48 -15
- metadata +4 -1
|
@@ -6,23 +6,33 @@
|
|
|
6
6
|
// (notify.js, Several tabs).
|
|
7
7
|
//
|
|
8
8
|
// In the Home Screen app (push.js pushSupported) the bell means "push to
|
|
9
|
-
// this device" instead (Web Push): chi web pushes
|
|
10
|
-
//
|
|
9
|
+
// this device" instead (Web Push): chi web pushes what needs the user (an
|
|
10
|
+
// approval, a question, a step-limit continue, a card, a failed turn) to it
|
|
11
|
+
// while the app is closed. chi web without push (no https public URL)
|
|
11
12
|
// leaves the bell as it is in a tab. With the bell on, the app also tidies
|
|
12
13
|
// the device (syncDevice): it closes the pushed notifications whose
|
|
13
|
-
// question isn't open any more and sets the app's badge, as chi
|
|
14
|
-
// no close push (WebKit's silent-push quota).
|
|
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). 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.
|
|
18
|
+
//
|
|
19
|
+
// Every page, in a tab or the app, also tells chi web whether the user is
|
|
20
|
+
// at it (POST /api/push/presence, reportPresence): chi web pushes nothing
|
|
21
|
+
// while one is in front, as the user is looking (the iPhone's service
|
|
22
|
+
// worker can't see the app in front, so chi web decides).
|
|
15
23
|
//
|
|
16
24
|
// The browser parts come in as deps (globals by default) so the specs can
|
|
17
25
|
// drive it without a DOM.
|
|
18
26
|
|
|
19
27
|
import { sessionHash } from "./route.js";
|
|
20
28
|
import {
|
|
21
|
-
NOTIFY_CHANNEL, attentionText, createNotifyGate, initialAttentionState, notifyState as bellState,
|
|
29
|
+
NOTIFY_CHANNEL, attentionText, createNotifyGate, initialAttentionState, notifyState as bellState, randomTabId,
|
|
30
|
+
trackAttention,
|
|
22
31
|
} from "./notify.js";
|
|
23
32
|
import {
|
|
24
|
-
|
|
33
|
+
isWaitingTag, openPushKeys, pushSupported, subscribe as pushSubscribe, unsubscribe as pushUnsubscribe,
|
|
25
34
|
} from "./push.js";
|
|
35
|
+
import { createPushDevices } from "./push_devices.js";
|
|
26
36
|
|
|
27
37
|
export const NOTIFY_KEY = "chi_notify";
|
|
28
38
|
// The device id chi web gave this app's subscription (POST subscribe's
|
|
@@ -32,6 +42,12 @@ export const PUSH_DEVICE_KEY = "chi_push_device";
|
|
|
32
42
|
// A long-lived Home Screen app checks for a new sw.js at most this often
|
|
33
43
|
// when it comes back in front (reg.update()).
|
|
34
44
|
export const SW_UPDATE_MS = 60 * 60 * 1000;
|
|
45
|
+
// Presence: a page in front reports this often (chi web counts a report
|
|
46
|
+
// 45 s), and is in front only if touched this recently (a tab left focused
|
|
47
|
+
// on a screen nobody watches stops quieting the phone).
|
|
48
|
+
export const PRESENCE_PATH = "/api/push/presence";
|
|
49
|
+
export const PRESENCE_EVERY_MS = 30 * 1000;
|
|
50
|
+
export const TOUCH_FRESH_MS = 2 * 60 * 1000;
|
|
35
51
|
|
|
36
52
|
const NOTIFY_TITLES = {
|
|
37
53
|
on: "Notifications on: a session that needs you while this tab is in the background. Click to turn off",
|
|
@@ -40,8 +56,8 @@ const NOTIFY_TITLES = {
|
|
|
40
56
|
};
|
|
41
57
|
|
|
42
58
|
const PUSH_TITLES = {
|
|
43
|
-
on: "Notifications to this phone: on (
|
|
44
|
-
off: "Notify this phone when a session needs
|
|
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",
|
|
45
61
|
denied: "Notifications are blocked for this app in the phone's settings",
|
|
46
62
|
};
|
|
47
63
|
|
|
@@ -57,6 +73,21 @@ async function defaultApi(method, path, body) {
|
|
|
57
73
|
return data;
|
|
58
74
|
}
|
|
59
75
|
|
|
76
|
+
// How a presence report goes: a beacon when the page is leaving (it
|
|
77
|
+
// outlives the page; same origin, so the cookie goes), else the api's
|
|
78
|
+
// fetch. Errors swallowed: a lost report only means a push or a quiet one.
|
|
79
|
+
// @return (body, leaving) => void
|
|
80
|
+
export function presenceSender({ nav, api, BlobApi = globalThis.Blob }) {
|
|
81
|
+
return (body, leaving) => {
|
|
82
|
+
if (leaving && typeof nav?.sendBeacon === "function" && typeof BlobApi === "function") {
|
|
83
|
+
try {
|
|
84
|
+
if (nav.sendBeacon(PRESENCE_PATH, new BlobApi([JSON.stringify(body)], { type: "application/json" }))) return;
|
|
85
|
+
} catch (_) {}
|
|
86
|
+
}
|
|
87
|
+
try { api("POST", PRESENCE_PATH, body)?.catch?.(() => {}); } catch (_) {}
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
60
91
|
function defaultChannel() {
|
|
61
92
|
try {
|
|
62
93
|
if (typeof globalThis.BroadcastChannel === "function") return new BroadcastChannel(NOTIFY_CHANNEL);
|
|
@@ -75,7 +106,13 @@ function defaultChannel() {
|
|
|
75
106
|
// @param api (method, path, body) => Promise of chi web's JSON
|
|
76
107
|
// @param scoped the page shows one project's sessions (?dir=): it never
|
|
77
108
|
// tidies the device, as it doesn't see the others'
|
|
78
|
-
// @param now () => ms (the service worker update's spacing
|
|
109
|
+
// @param now () => ms (the service worker update's spacing, presence's
|
|
110
|
+
// touch; specs)
|
|
111
|
+
// @param sendPresence (body, leaving) => void (presenceSender's by default)
|
|
112
|
+
// @param every (fn, ms) => void, a repeating timer (presence's tick)
|
|
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)
|
|
79
116
|
// @return {noticeSessions(type, data), noticeList(sessions), setBaseTitle(title), clearBadge()}
|
|
80
117
|
export function createNotifications({
|
|
81
118
|
button,
|
|
@@ -89,12 +126,18 @@ export function createNotifications({
|
|
|
89
126
|
api = defaultApi,
|
|
90
127
|
scoped = false,
|
|
91
128
|
now = () => Date.now(),
|
|
129
|
+
sendPresence = presenceSender({ nav, api }),
|
|
130
|
+
every = (fn, ms) => setInterval(fn, ms),
|
|
131
|
+
pageId = randomTabId(),
|
|
132
|
+
devices = (hooks) => createPushDevices({ ...hooks, doc, win }),
|
|
92
133
|
}) {
|
|
93
134
|
let attention = initialAttentionState();
|
|
94
135
|
// Web Push in the Home Screen app: null until chi web says it pushes
|
|
95
136
|
// ({reg, key, on}), and for good in a tab or without push.
|
|
96
137
|
let push = null;
|
|
97
138
|
let pushBusy = false;
|
|
139
|
+
// The notifications sheet the bell opens in push mode.
|
|
140
|
+
let sheet = null;
|
|
98
141
|
let syncing = null;
|
|
99
142
|
let syncAgain = false;
|
|
100
143
|
// When this page last had the browser check for a newer sw.js.
|
|
@@ -151,7 +194,7 @@ export function createNotifications({
|
|
|
151
194
|
}
|
|
152
195
|
|
|
153
196
|
async function toggleNotify() {
|
|
154
|
-
if (push) return
|
|
197
|
+
if (push) return sheet?.toggle();
|
|
155
198
|
const state = notifyState();
|
|
156
199
|
if (state === "unsupported" || state === "denied") return;
|
|
157
200
|
let wanted = state !== "on";
|
|
@@ -162,10 +205,11 @@ export function createNotifications({
|
|
|
162
205
|
renderNotifyBtn();
|
|
163
206
|
}
|
|
164
207
|
|
|
165
|
-
// This device's subscription on (permission from the tap
|
|
166
|
-
// 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
|
|
167
211
|
async function togglePush() {
|
|
168
|
-
if (pushBusy || notifyState() === "denied") return;
|
|
212
|
+
if (pushBusy || notifyState() === "denied") return null;
|
|
169
213
|
pushBusy = true;
|
|
170
214
|
let failed = null;
|
|
171
215
|
try {
|
|
@@ -193,8 +237,9 @@ export function createNotifications({
|
|
|
193
237
|
pushBusy = false;
|
|
194
238
|
}
|
|
195
239
|
renderNotifyBtn();
|
|
196
|
-
// 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.
|
|
197
241
|
if (failed) button.title = `Notifications to this phone failed: ${failed}`;
|
|
242
|
+
return failed && `Notifications to this phone failed: ${failed}`;
|
|
198
243
|
}
|
|
199
244
|
|
|
200
245
|
// The Home Screen app: its service worker, chi web's key, and whether
|
|
@@ -208,6 +253,10 @@ export function createNotifications({
|
|
|
208
253
|
const info = await api("GET", `/api/push${query}`);
|
|
209
254
|
if (!info?.enabled || !info.public_key) return;
|
|
210
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
|
+
});
|
|
211
260
|
// register() has just checked for a newer sw.js.
|
|
212
261
|
swCheckedAt = now();
|
|
213
262
|
renderNotifyBtn();
|
|
@@ -234,7 +283,8 @@ export function createNotifications({
|
|
|
234
283
|
}
|
|
235
284
|
|
|
236
285
|
// The Home Screen app with the bell on: closes the pushed notifications
|
|
237
|
-
// whose question isn't open (chi's own, chi:test,
|
|
286
|
+
// whose question or card isn't open (chi's own, chi:test, and a failed
|
|
287
|
+
// or done turn's stay) and sets the
|
|
238
288
|
// app's badge. Never before the first snapshot (an empty byId would close
|
|
239
289
|
// them all) nor on a project's page (its byId lacks the others). One at a
|
|
240
290
|
// time; asked again meanwhile, it runs once more after.
|
|
@@ -250,7 +300,7 @@ export function createNotifications({
|
|
|
250
300
|
const open = new Set(openPushKeys(attention.byId));
|
|
251
301
|
try {
|
|
252
302
|
const shown = await push.reg.getNotifications();
|
|
253
|
-
for (const n of shown || []) if (
|
|
303
|
+
for (const n of shown || []) if (isWaitingTag(n.tag) && !open.has(n.tag)) n.close();
|
|
254
304
|
} catch (_) {}
|
|
255
305
|
if (push?.on) setDeviceBadge(open.size);
|
|
256
306
|
} while (syncAgain);
|
|
@@ -321,6 +371,31 @@ export function createNotifications({
|
|
|
321
371
|
syncDevice();
|
|
322
372
|
updateWorker();
|
|
323
373
|
}
|
|
374
|
+
// ── Presence ──────────────────────────────────────────────────────────
|
|
375
|
+
// Loading the page counts as a touch.
|
|
376
|
+
let touchedAt = now();
|
|
377
|
+
let reportedFront = null;
|
|
378
|
+
const atPage = () => tabInFront() && now() - touchedAt < TOUCH_FRESH_MS;
|
|
379
|
+
function reportPresence(front = atPage(), leaving = false) {
|
|
380
|
+
reportedFront = front;
|
|
381
|
+
sendPresence({ page: pageId, front }, leaving);
|
|
382
|
+
}
|
|
383
|
+
// The first touch that brings the page to the front (after being idle,
|
|
384
|
+
// or behind) says so at once; the tick has the rest.
|
|
385
|
+
function touched() {
|
|
386
|
+
touchedAt = now();
|
|
387
|
+
if (reportedFront !== true && atPage()) reportPresence(true);
|
|
388
|
+
}
|
|
389
|
+
// Every 30 s while in front, and once more when it no longer is (no
|
|
390
|
+
// touch for 2 min).
|
|
391
|
+
every(() => { if (reportedFront || atPage()) reportPresence(); }, PRESENCE_EVERY_MS);
|
|
392
|
+
doc.addEventListener("visibilitychange", () => (doc.visibilityState === "visible" ? reportPresence() : reportPresence(false, true)));
|
|
393
|
+
win.addEventListener("focus", () => reportPresence());
|
|
394
|
+
win.addEventListener("blur", () => reportPresence(false));
|
|
395
|
+
win.addEventListener("pagehide", () => reportPresence(false, true));
|
|
396
|
+
for (const type of ["pointerdown", "keydown", "touchstart"]) doc.addEventListener(type, touched, { capture: true, passive: true });
|
|
397
|
+
if (atPage()) reportPresence(true);
|
|
398
|
+
|
|
324
399
|
doc.addEventListener("visibilitychange", backInFront);
|
|
325
400
|
win.addEventListener("focus", backInFront);
|
|
326
401
|
doc.addEventListener("pointerdown", clearBadge, true);
|
|
@@ -96,7 +96,7 @@ const cardKey = (sessionId, cardId) => `${sessionId}:card:${cardId}`;
|
|
|
96
96
|
// @param prev the summary held before (null for a session not seen yet)
|
|
97
97
|
// @param next the new summary (null when the session is gone)
|
|
98
98
|
// @return [String] keys, maybe none
|
|
99
|
-
function closedKeys(prev, next) {
|
|
99
|
+
export function closedKeys(prev, next) {
|
|
100
100
|
const keys = [];
|
|
101
101
|
const q = prev?.pending_question;
|
|
102
102
|
const nq = next?.pending_question;
|
|
@@ -152,7 +152,7 @@ export function trackAttention(state, type, data = {}) {
|
|
|
152
152
|
// The notification's words: a short title (the session) and the reason.
|
|
153
153
|
// No question or answer text (it would show on a lock screen).
|
|
154
154
|
export function attentionText(session, attention) {
|
|
155
|
-
const name = String(session?.title || session?.first_preview || session?.last_prompt || "chi session").trim();
|
|
155
|
+
const name = String(session?.title || session?.auto_title || session?.first_preview || session?.last_prompt || "chi session").trim();
|
|
156
156
|
const title = clip(name, 61);
|
|
157
157
|
const seconds = Math.round(Number(session?.last_turn?.seconds) || 0);
|
|
158
158
|
const body = {
|
|
@@ -262,7 +262,7 @@ export function createNotifyGate({ channel = null, holdMs = NOTIFY_HOLD_MS, onSe
|
|
|
262
262
|
};
|
|
263
263
|
}
|
|
264
264
|
|
|
265
|
-
function randomTabId() {
|
|
265
|
+
export function randomTabId() {
|
|
266
266
|
try {
|
|
267
267
|
if (typeof globalThis.crypto?.randomUUID === "function") return globalThis.crypto.randomUUID();
|
|
268
268
|
} catch (_) {}
|
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
(the Plan): beside the thread on a wide screen, a Plan | Thread toggle on a narrow one (the composer pinned in both).
|
|
12
12
|
The owner's participant view is the same page with no composer. Colours are tokens.css's. */
|
|
13
13
|
*{box-sizing:border-box;margin:0;padding:0}
|
|
14
|
+
/* No iOS text autosizing (index.html's twin). */
|
|
15
|
+
html{-webkit-text-size-adjust:100%;text-size-adjust:100%}
|
|
14
16
|
html,body{height:100%;height:100dvh}
|
|
15
17
|
.p-body{flex:1;min-height:0;display:flex}
|
|
16
18
|
.p-col{flex:1;min-width:0;min-height:0;display:flex;flex-direction:column}
|
|
@@ -126,6 +128,8 @@ button.small{font-size:12px;padding:2px 8px}
|
|
|
126
128
|
.p-bubble{padding:9px 12px}
|
|
127
129
|
.p-hint,.p-note{display:none}
|
|
128
130
|
}
|
|
131
|
+
/* Under 16px iOS Safari zooms the page on focus (and a Home Screen app never zooms back). */
|
|
132
|
+
@media (max-width:600px),(pointer:coarse){.p-compose textarea{font-size:16px}}
|
|
129
133
|
</style>
|
|
130
134
|
</head>
|
|
131
135
|
<body>
|
|
@@ -6,31 +6,30 @@
|
|
|
6
6
|
// a browser tab keeps the in-tab notifications). See
|
|
7
7
|
// docs/internals/web-push.md.
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
//
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
//
|
|
15
|
-
// summary): a pending question with an id, not relayed, of a reason chi
|
|
16
|
-
// web pushes. Attention.open_keys's rule, so the page, the worker's open
|
|
17
|
-
// list and the badge agree.
|
|
18
|
-
// @return [String] "<session id>:<question id>" keys
|
|
9
|
+
// The waiting keys still open in a byId (notify.js trackAttention's:
|
|
10
|
+
// session id → summary): a pending question with an id, not relayed (any
|
|
11
|
+
// reason: a step-limit continue too), and a card with actions.
|
|
12
|
+
// Attention.open_keys's rule, so the page, the worker's open list and the
|
|
13
|
+
// badge agree.
|
|
14
|
+
// @return [String] "<session id>:<question id>" and "<session id>:card:<card id>" keys
|
|
19
15
|
export function openPushKeys(byId) {
|
|
20
16
|
const keys = [];
|
|
21
17
|
for (const summary of Object.values(byId || {})) {
|
|
22
18
|
const q = summary?.pending_question;
|
|
23
|
-
if (
|
|
24
|
-
|
|
19
|
+
if (q && q.id && !q.relayed_to) keys.push(`${summary.id}:${q.id}`);
|
|
20
|
+
const card = summary?.pending_card;
|
|
21
|
+
if (card && card.id) keys.push(`${summary.id}:card:${card.id}`);
|
|
25
22
|
}
|
|
26
23
|
return keys;
|
|
27
24
|
}
|
|
28
25
|
|
|
29
|
-
// A pushed
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
|
|
33
|
-
|
|
26
|
+
// A pushed waiting key's tag: a question's "<session id>:<question id>" or
|
|
27
|
+
// a card's "<session id>:card:<card id>", not one of chi's own (chi:test,
|
|
28
|
+
// chi:other) nor a failed or done turn's "<session id>:<ended_at>" (its
|
|
29
|
+
// time has colons): only these are ever cleaned up. sw.js keeps the same
|
|
30
|
+
// pattern (a classic script imports nothing).
|
|
31
|
+
export const WAITING_TAG = /^(?!chi:)[^:]+:(?:card:)?[^:]+$/;
|
|
32
|
+
export const isWaitingTag = (tag) => typeof tag === "string" && WAITING_TAG.test(tag);
|
|
34
33
|
|
|
35
34
|
// A standalone app with a service worker and PushManager, on a secure
|
|
36
35
|
// context (chi web's https public URL; the LAN's plain http has none).
|
|
@@ -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
|
+
}
|
|
@@ -121,12 +121,13 @@ export function sessionStateFrom(data) {
|
|
|
121
121
|
return state;
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
-
// The info bar's title: the session's own title (/title), else
|
|
125
|
-
// user message when the payload has messages, else the card's preview, else
|
|
124
|
+
// The info bar's title: the session's own title (/title), else its auto
|
|
125
|
+
// title, else its recap's first sentence (as its card), else the first user message when the payload has messages, else the card's preview, else
|
|
126
126
|
// the first prompt of its history.
|
|
127
127
|
function sessionFirstPreview(data) {
|
|
128
128
|
const s = data.session;
|
|
129
|
-
|
|
129
|
+
const named = s.title || s.auto_title || s.recap;
|
|
130
|
+
if (named) return firstPreview(named);
|
|
130
131
|
let preview = s.first_preview || previewForCard(s);
|
|
131
132
|
if (data.messages && data.messages.length) {
|
|
132
133
|
const first = data.messages.find((m) => m.role === "user");
|
|
@@ -17,9 +17,10 @@
|
|
|
17
17
|
|
|
18
18
|
const ICON = "/icons/chi-192.png";
|
|
19
19
|
const FALLBACK = { title: "chi", body: "open chi", tag: "chi:other", url: "/" };
|
|
20
|
-
// push.js
|
|
21
|
-
// never chi's own (chi:test,
|
|
22
|
-
|
|
20
|
+
// push.js WAITING_TAG: a pushed question's "<session id>:<question id>" or
|
|
21
|
+
// card's "<session id>:card:<card id>", never chi's own (chi:test,
|
|
22
|
+
// chi:other) nor a failed or done turn's (its time has colons).
|
|
23
|
+
const WAITING_TAG = /^(?!chi:)[^:]+:(?:card:)?[^:]+$/;
|
|
23
24
|
|
|
24
25
|
function payloadOf(event) {
|
|
25
26
|
try {
|
|
@@ -53,7 +54,7 @@ async function show({ title, body, tag, url }) {
|
|
|
53
54
|
}
|
|
54
55
|
}
|
|
55
56
|
|
|
56
|
-
// Closes the shown
|
|
57
|
+
// Closes the shown waiting notifications chi web no longer has open
|
|
57
58
|
// (never the new one nor chi's own). The unfiltered list only names the
|
|
58
59
|
// tags: on the iPhone (iOS 26, 2026-10-24) close() on a notification from
|
|
59
60
|
// it did nothing, while one looked up by its tag closes (the probe in
|
|
@@ -63,7 +64,7 @@ async function closeAnswered(open, keep) {
|
|
|
63
64
|
const still = new Set(open.map(String));
|
|
64
65
|
const tags = new Set();
|
|
65
66
|
for (const n of await self.registration.getNotifications()) {
|
|
66
|
-
if (n.tag !== keep &&
|
|
67
|
+
if (n.tag !== keep && WAITING_TAG.test(n.tag || "") && !still.has(n.tag)) tags.add(n.tag);
|
|
67
68
|
}
|
|
68
69
|
for (const tag of tags) {
|
|
69
70
|
for (const n of await self.registration.getNotifications({ tag })) n.close();
|