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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +48 -1
- data/bin/chi +8 -0
- data/docs/cli.md +24 -6
- data/docs/configuration.md +22 -10
- data/docs/internals/prompt-caching.md +2 -1
- data/docs/internals/web-push.md +28 -4
- data/docs/sessions.md +3 -3
- data/lib/samagotchi/bootstrap_command.rb +70 -5
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/config.rb +24 -0
- data/lib/samagotchi/config_text_edit.rb +10 -3
- data/lib/samagotchi/gem_update.rb +5 -1
- data/lib/samagotchi/idle_recap.rb +38 -10
- data/lib/samagotchi/push_command.rb +188 -0
- data/lib/samagotchi/recap_store.rb +20 -3
- data/lib/samagotchi/recap_title.rb +78 -0
- data/lib/samagotchi/run_options.rb +1 -0
- data/lib/samagotchi/session_manager.rb +10 -3
- data/lib/samagotchi/sessions_command.rb +14 -11
- data/lib/samagotchi/title_command.rb +6 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +46 -3
- data/lib/samagotchi/web/markdown_renderer.rb +20 -0
- data/lib/samagotchi/web/public/app.js +6 -0
- data/lib/samagotchi/web/public/index.html +43 -4
- data/lib/samagotchi/web/public/markdown_tip.js +33 -0
- data/lib/samagotchi/web/public/notifications.js +22 -8
- data/lib/samagotchi/web/public/push_devices.js +187 -0
- data/lib/samagotchi/web/push/attention.rb +2 -1
- data/lib/samagotchi/web/push/store.rb +14 -0
- data/lib/samagotchi/web/push.rb +5 -0
- data/lib/samagotchi/web/push_routes.rb +17 -3
- data/lib/samagotchi/web/server.rb +46 -14
- data/lib/samagotchi/web/session_show.rb +2 -0
- data/lib/samagotchi/web/session_summary.rb +7 -2
- 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
|
-
|
|
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
|
-
/*
|
|
1475
|
-
|
|
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
|
|
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
|
|
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
|
|
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]]
|
|
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
|
data/lib/samagotchi/web/push.rb
CHANGED
|
@@ -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
|
|
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,
|
|
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")
|