@supersuit/suggest-a-change 0.1.12 → 0.2.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 (62) hide show
  1. package/CHANGELOG.md +22 -1
  2. package/README.md +163 -8
  3. package/dist/client/adapters.d.ts +27 -0
  4. package/dist/client/context.d.ts +61 -0
  5. package/dist/client/context.js +279 -0
  6. package/dist/client/detail.d.ts +2 -2
  7. package/dist/client/detail.js +12 -8
  8. package/dist/client/entry.js +10 -5
  9. package/dist/client/index.d.ts +2 -0
  10. package/dist/client/index.js +1 -0
  11. package/dist/client/mine.js +4 -2
  12. package/dist/client/runtime.js +3 -0
  13. package/dist/client/seen.d.ts +12 -0
  14. package/dist/client/seen.js +30 -7
  15. package/dist/client/sheet.js +64 -4
  16. package/dist/client/words.d.ts +10 -1
  17. package/dist/client/words.js +11 -2
  18. package/dist/contract.d.mts +32 -22
  19. package/dist/contract.mjs +127 -37
  20. package/dist/github/api.d.ts +100 -0
  21. package/dist/github/api.js +164 -0
  22. package/dist/github/compose.d.ts +152 -0
  23. package/dist/github/compose.js +309 -0
  24. package/dist/github/filer.d.ts +72 -0
  25. package/dist/github/filer.js +89 -0
  26. package/dist/github/hosts.d.ts +41 -0
  27. package/dist/github/hosts.js +154 -0
  28. package/dist/github/index.d.ts +12 -0
  29. package/dist/github/index.js +9 -0
  30. package/dist/github/privacy.d.ts +34 -0
  31. package/dist/github/privacy.js +142 -0
  32. package/dist/github/relay.d.ts +159 -0
  33. package/dist/github/relay.js +734 -0
  34. package/dist/server/geo.d.ts +17 -0
  35. package/dist/server/geo.js +35 -0
  36. package/dist/server/handlers.d.ts +54 -3
  37. package/dist/server/handlers.js +143 -41
  38. package/dist/server/index.d.ts +3 -1
  39. package/dist/server/index.js +1 -0
  40. package/package.json +5 -1
  41. package/src/client/adapters.ts +18 -1
  42. package/src/client/context.ts +243 -0
  43. package/src/client/detail.tsx +13 -8
  44. package/src/client/entry.tsx +9 -5
  45. package/src/client/index.ts +2 -0
  46. package/src/client/mine.tsx +7 -3
  47. package/src/client/runtime.ts +3 -0
  48. package/src/client/seen.ts +27 -6
  49. package/src/client/sheet.tsx +78 -4
  50. package/src/client/styles.css +11 -2
  51. package/src/client/words.ts +15 -2
  52. package/src/contract.mjs +110 -37
  53. package/src/github/api.ts +169 -0
  54. package/src/github/compose.ts +354 -0
  55. package/src/github/filer.ts +96 -0
  56. package/src/github/hosts.ts +117 -0
  57. package/src/github/index.ts +15 -0
  58. package/src/github/privacy.ts +131 -0
  59. package/src/github/relay.ts +637 -0
  60. package/src/server/geo.ts +32 -0
  61. package/src/server/handlers.ts +158 -45
  62. package/src/server/index.ts +3 -1
@@ -0,0 +1,279 @@
1
+ const MAX = { routes: 10, taps: 20, errors: 10, items: 40 };
2
+ const SHARED = Symbol.for('@supersuit/suggest-a-change/context');
3
+ const shared = globalThis[SHARED]
4
+ ?? (globalThis[SHARED] = { trail: { routes: [], taps: [], errors: [] }, installedAt: 0, undo: null });
5
+ const trail = shared.trail;
6
+ const clip = (s, max) => (s.length <= max ? s : `${s.slice(0, max - 1).trimEnd()}…`);
7
+ const iso = () => new Date().toISOString();
8
+ const push = (list, x, max) => { list.push(x); if (list.length > max)
9
+ list.splice(0, list.length - max); };
10
+ /** A URL or path with no query and no hash, wherever it appears in a piece of text. */
11
+ export const withoutQueries = (s) => s.replace(/([^\s?#'"()]*)[?#][^\s'"():]*/g, (_m, before) => before);
12
+ /** A path segment that is an id, a token or an address rather than a name: a uuid, a long number, a long hex or
13
+ * base64 run with letters and digits, anything over 24 characters, or anything with an @. */
14
+ const idLike = (raw) => {
15
+ let s = raw;
16
+ try {
17
+ s = decodeURIComponent(raw);
18
+ }
19
+ catch { /* as it is */ }
20
+ return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s)
21
+ || /^\d{4,}$/.test(s)
22
+ || (/^[0-9a-f]{12,}$/i.test(s) && /\d/.test(s))
23
+ || (/^[A-Za-z0-9_-]{16,}$/.test(s) && /\d/.test(s) && /[A-Za-z]/.test(s))
24
+ || s.length > 24
25
+ || s.includes('@');
26
+ };
27
+ const routeOf = (href) => {
28
+ let path;
29
+ try {
30
+ path = new URL(href, 'http://x').pathname;
31
+ }
32
+ catch {
33
+ path = String(href).split(/[?#]/)[0];
34
+ }
35
+ return path.split('/').map((seg) => (seg && idLike(seg) ? ':id' : seg)).join('/');
36
+ };
37
+ function recordRoute() {
38
+ if (typeof location === 'undefined')
39
+ return;
40
+ const route = routeOf(location.href);
41
+ if (trail.routes.at(-1)?.route === route)
42
+ return;
43
+ push(trail.routes, { route: clip(route, 300), at: iso() }, MAX.routes);
44
+ }
45
+ /** What a tapped thing is called: its label or its text, never what was typed into it. */
46
+ function labelOf(target) {
47
+ if (!(target instanceof Element))
48
+ return null;
49
+ // Anything someone types into is "a text field", by its own label at most and never its text: an editable region,
50
+ // a text, search or combo box, wherever inside it the tap landed.
51
+ const typing = target.closest('[contenteditable]:not([contenteditable="false"]), [role="textbox"], [role="searchbox"], [role="combobox"]');
52
+ if (typing)
53
+ return clip(typing.getAttribute('aria-label')?.trim() || 'a text field', 60);
54
+ const el = target.closest('button, a, summary, label, select, textarea, input, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="checkbox"], [aria-label]') ?? target;
55
+ const aria = el.getAttribute('aria-label')?.trim();
56
+ if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement || el instanceof HTMLSelectElement) {
57
+ if (aria)
58
+ return clip(aria, 60);
59
+ const byId = el.id ? document.querySelector(`label[for="${CSS.escape(el.id)}"]`)?.textContent?.trim() : '';
60
+ if (byId)
61
+ return clip(byId.replace(/\s+/g, ' '), 60);
62
+ if (el instanceof HTMLTextAreaElement)
63
+ return 'a text box';
64
+ if (el instanceof HTMLSelectElement)
65
+ return 'a menu';
66
+ const type = el.type;
67
+ return type === 'checkbox' ? 'a checkbox' : type === 'radio' ? 'a choice' : type === 'button' || type === 'submit' ? clip(el.value ? 'a button' : 'a button', 60) : 'a text field';
68
+ }
69
+ if (aria)
70
+ return clip(aria, 60);
71
+ const text = (el.textContent ?? '').replace(/\s+/g, ' ').trim();
72
+ if (text)
73
+ return clip(text, 60);
74
+ const title = el.getAttribute('title')?.trim();
75
+ return title ? clip(title, 60) : el.tagName.toLowerCase();
76
+ }
77
+ const firstFrame = (stack) => {
78
+ if (typeof stack !== 'string')
79
+ return undefined;
80
+ const line = stack.split('\n').slice(1).map((l) => l.trim()).find((l) => /^at |@/.test(l));
81
+ return line ? clip(withoutQueries(line), 200) : undefined;
82
+ };
83
+ const messageOf = (x) => (x instanceof Error ? x.message : typeof x === 'string' ? x : x === undefined ? '' : typeof x === 'number' || typeof x === 'boolean' ? String(x) : '[object]');
84
+ function recordError(message, stack) {
85
+ push(trail.errors, { message: clip(withoutQueries(message || 'An error with no message'), 300), ...(firstFrame(stack) ? { frame: firstFrame(stack) } : {}), at: iso() }, MAX.errors);
86
+ }
87
+ /** Starts keeping the trail. Idempotent: every screen of the module calls it on mount, and it installs once. */
88
+ export function installContextListeners() {
89
+ if (shared.undo || typeof window === 'undefined' || typeof document === 'undefined')
90
+ return;
91
+ shared.installedAt = Date.now();
92
+ // A wrapper a host put on top of ours may outlive a reset; once reset, ours only passes the call through.
93
+ let live = true;
94
+ recordRoute();
95
+ const onClick = (e) => { const l = labelOf(e.target); if (l)
96
+ push(trail.taps, { label: l, at: iso() }, MAX.taps); };
97
+ const onError = (e) => { const ev = e; recordError(ev.message || messageOf(ev.error), ev.error?.stack); };
98
+ const onRejection = (e) => { const reason = e.reason; recordError(`Unhandled rejection: ${messageOf(reason)}`, reason?.stack); };
99
+ const onNav = () => recordRoute();
100
+ document.addEventListener('click', onClick, true);
101
+ window.addEventListener('error', onError);
102
+ window.addEventListener('unhandledrejection', onRejection);
103
+ window.addEventListener('popstate', onNav);
104
+ window.addEventListener('hashchange', onNav);
105
+ const { pushState, replaceState } = history;
106
+ const ourPush = function (...a) { const r = pushState.apply(this, a); if (live)
107
+ recordRoute(); return r; };
108
+ history.pushState = ourPush;
109
+ const ourReplace = function (...a) { const r = replaceState.apply(this, a); if (live)
110
+ recordRoute(); return r; };
111
+ history.replaceState = ourReplace;
112
+ const consoleError = console.error;
113
+ const ourError = function (...args) {
114
+ if (live)
115
+ try {
116
+ recordError(args.map(messageOf).filter(Boolean).join(' '), args.find((a) => a instanceof Error)?.stack);
117
+ }
118
+ catch { /* the trail never breaks logging */ }
119
+ return consoleError.apply(this, args);
120
+ };
121
+ console.error = ourError;
122
+ shared.undo = () => {
123
+ live = false;
124
+ document.removeEventListener('click', onClick, true);
125
+ window.removeEventListener('error', onError);
126
+ window.removeEventListener('unhandledrejection', onRejection);
127
+ window.removeEventListener('popstate', onNav);
128
+ window.removeEventListener('hashchange', onNav);
129
+ // Put back only what is still ours: a wrapper the host added later stays, and calls through ours harmlessly.
130
+ if (history.pushState === ourPush)
131
+ history.pushState = pushState;
132
+ if (history.replaceState === ourReplace)
133
+ history.replaceState = replaceState;
134
+ if (console.error === ourError)
135
+ console.error = consoleError;
136
+ };
137
+ }
138
+ /** Stops keeping the trail and forgets it (tests, and a host that signs someone out). */
139
+ export function resetContext() {
140
+ shared.undo?.();
141
+ shared.undo = null;
142
+ trail.routes.length = 0;
143
+ trail.taps.length = 0;
144
+ trail.errors.length = 0;
145
+ }
146
+ /** A copy of the trail as it stands. */
147
+ export const contextTrail = () => ({ routes: trail.routes.map((r) => ({ ...r })), taps: trail.taps.map((t) => ({ ...t })), errors: trail.errors.map((e) => ({ ...e })) });
148
+ /** A user agent in plain words: "iPhone, iOS 18.1, Safari 18.1". */
149
+ export function parseUserAgent(ua) {
150
+ const v = (re) => re.exec(ua)?.[1]?.replace(/_/g, '.');
151
+ let device = 'Unknown device';
152
+ let os = '';
153
+ if (/iPhone/.test(ua)) {
154
+ device = 'iPhone';
155
+ os = `iOS ${v(/OS (\d+[_.]\d+)/) ?? ''}`.trim();
156
+ }
157
+ else if (/iPad/.test(ua)) {
158
+ device = 'iPad';
159
+ os = `iPadOS ${v(/OS (\d+[_.]\d+)/) ?? ''}`.trim();
160
+ }
161
+ else if (/Android/.test(ua)) {
162
+ device = /Mobile/.test(ua) ? 'Android phone' : 'Android tablet';
163
+ os = `Android ${v(/Android (\d+(?:\.\d+)?)/) ?? ''}`.trim();
164
+ }
165
+ else if (/Macintosh|Mac OS X/.test(ua)) {
166
+ device = 'Mac';
167
+ os = 'macOS';
168
+ }
169
+ else if (/Windows/.test(ua)) {
170
+ device = 'Windows PC';
171
+ os = 'Windows';
172
+ }
173
+ else if (/CrOS/.test(ua)) {
174
+ device = 'Chromebook';
175
+ os = 'ChromeOS';
176
+ }
177
+ else if (/Linux/.test(ua)) {
178
+ device = 'Linux computer';
179
+ os = 'Linux';
180
+ }
181
+ const browser = v(/Edg(?:A|iOS)?\/(\d+)/) ? `Edge ${v(/Edg(?:A|iOS)?\/(\d+)/)}`
182
+ : v(/(?:Firefox|FxiOS)\/(\d+)/) ? `Firefox ${v(/(?:Firefox|FxiOS)\/(\d+)/)}`
183
+ : v(/(?:Chrome|CriOS)\/(\d+)/) ? `Chrome ${v(/(?:Chrome|CriOS)\/(\d+)/)}`
184
+ : /Safari/.test(ua) && v(/Version\/(\d+(?:\.\d+)?)/) ? `Safari ${v(/Version\/(\d+(?:\.\d+)?)/)}`
185
+ : '';
186
+ return [device, os, browser].filter(Boolean).join(', ');
187
+ }
188
+ /** The device in plain words from User-Agent Client Hints when the browser has them, else from the user agent. */
189
+ async function deviceWords(given) {
190
+ if (given)
191
+ return parseUserAgent(given);
192
+ const nav = typeof navigator === 'undefined' ? undefined : navigator;
193
+ const uad = nav?.userAgentData;
194
+ if (uad?.getHighEntropyValues) {
195
+ try {
196
+ const h = await Promise.race([uad.getHighEntropyValues(['platform', 'platformVersion', 'model', 'fullVersionList', 'mobile']), new Promise((r) => setTimeout(() => r(null), 300))]);
197
+ if (h) {
198
+ const brands = h.fullVersionList?.filter((b) => !/Not.?A.?Brand|Chromium/i.test(b.brand)) ?? [];
199
+ const b = brands.find((x) => /Edge/.test(x.brand)) ?? brands[0];
200
+ const platform = String(h.platform ?? '');
201
+ const device = String(h.model ?? '') || (platform === 'macOS' ? 'Mac' : platform === 'Windows' ? 'Windows PC' : platform === 'Android' ? (h.mobile ? 'Android phone' : 'Android tablet') : platform || 'Unknown device');
202
+ const os = [platform, String(h.platformVersion ?? '').split('.').slice(0, 2).join('.')].filter(Boolean).join(' ');
203
+ return [device, os, b ? `${b.brand.replace('Google ', '').replace('Microsoft ', '')} ${b.version.split('.')[0]}` : ''].filter(Boolean).join(', ');
204
+ }
205
+ }
206
+ catch { /* fall back to the user agent */ }
207
+ }
208
+ return parseUserAgent(nav?.userAgent ?? '');
209
+ }
210
+ const two = (n) => String(n).padStart(2, '0');
211
+ function timeWords(d) {
212
+ const zone = (() => { try {
213
+ return Intl.DateTimeFormat().resolvedOptions().timeZone;
214
+ }
215
+ catch {
216
+ return '';
217
+ } })();
218
+ const off = -d.getTimezoneOffset();
219
+ const offset = `UTC${off >= 0 ? '+' : '-'}${two(Math.floor(Math.abs(off) / 60))}:${two(Math.abs(off) % 60)}`;
220
+ return `${d.getFullYear()}-${two(d.getMonth() + 1)}-${two(d.getDate())} ${two(d.getHours())}:${two(d.getMinutes())} (${[zone, offset].filter(Boolean).join(', ')})`;
221
+ }
222
+ const media = (q) => { try {
223
+ return typeof matchMedia === 'function' && matchMedia(q).matches;
224
+ }
225
+ catch {
226
+ return false;
227
+ } };
228
+ /** Everything the module knows that would help fix it, each item with why it is shared, and the trail. */
229
+ export async function collectContext(o = {}) {
230
+ const now = new Date(o.now?.() ?? Date.now());
231
+ const items = [];
232
+ const add = (key, label, value, why) => { if (value)
233
+ items.push({ key, label: clip(label, 80), value: clip(value, 500), why: clip(why, 200) }); };
234
+ const who = o.identity;
235
+ add('account', 'Your name in the app', who?.name || who?.username, 'so whoever fixes it knows who to thank, and can look at your account if the problem is in it');
236
+ add('time', 'Your local time', timeWords(now), 'so the fixer can line it up with what the app logged at that moment');
237
+ const langs = typeof navigator === 'undefined' ? [] : (navigator.languages?.length ? [...navigator.languages] : navigator.language ? [navigator.language] : []);
238
+ add('language', 'Your languages', langs.join(', '), 'some problems only happen in one language or date format');
239
+ add('device', 'Your device', await deviceWords(o.userAgent), 'so the fixer can try it on the same kind of device and browser');
240
+ if (typeof window !== 'undefined') {
241
+ const s = typeof screen === 'undefined' ? null : screen;
242
+ add('screen', 'Screen and window', `${s ? `${s.width}x${s.height} screen, ` : ''}${window.innerWidth}x${window.innerHeight} window, ${window.devicePixelRatio || 1}x pixels`, 'how things are laid out depends on the size of the screen and window');
243
+ const look = [media('(prefers-color-scheme: dark)') ? 'dark mode' : 'light mode', ...(media('(prefers-reduced-motion: reduce)') ? ['reduced motion'] : [])];
244
+ add('appearance', 'Appearance', look.join(', '), 'some problems only show in dark mode or with animations turned down');
245
+ }
246
+ if (typeof navigator !== 'undefined') {
247
+ const c = navigator.connection;
248
+ add('connection', 'Connection', [navigator.onLine === false ? 'offline' : 'online', c?.type, c?.effectiveType && `${c.effectiveType} speed`].filter(Boolean).join(', '), 'a slow or dropped connection explains many problems');
249
+ }
250
+ const since = shared.installedAt || (typeof performance !== 'undefined' ? performance.timeOrigin : 0);
251
+ if (since) {
252
+ const min = Math.max(0, Math.round((now.getTime() - since) / 60_000));
253
+ add('age', 'How long the page was open', min < 1 ? 'under a minute' : `${min} minute${min === 1 ? '' : 's'}`, 'some problems only start after a page has been open a while');
254
+ }
255
+ if (o.precise && typeof navigator !== 'undefined' && navigator.geolocation) {
256
+ const pos = await new Promise((resolve) => {
257
+ try {
258
+ navigator.geolocation.getCurrentPosition(resolve, () => resolve(null), { maximumAge: 600_000, timeout: 5000 });
259
+ }
260
+ catch {
261
+ resolve(null);
262
+ }
263
+ });
264
+ if (pos)
265
+ add('place', 'Where you were', `${pos.coords.latitude.toFixed(3)}, ${pos.coords.longitude.toFixed(3)}`, 'this app asked for it, because some problems only happen in one place');
266
+ }
267
+ if (o.extra) {
268
+ try {
269
+ const got = await o.extra();
270
+ (Array.isArray(got) ? got : []).forEach((x) => {
271
+ // An app item that does not say why it is shared is not shared.
272
+ if (x && typeof x.label === 'string' && typeof x.value === 'string' && typeof x.why === 'string' && x.why.trim())
273
+ add(`app:${items.filter((i) => i.key.startsWith('app:')).length}`, x.label, x.value, x.why);
274
+ });
275
+ }
276
+ catch { /* the app's own context failing never blocks a suggestion */ }
277
+ }
278
+ return { items: items.slice(0, MAX.items), ...contextTrail() };
279
+ }
@@ -1,10 +1,10 @@
1
1
  import type { Adapters } from './adapters.ts';
2
2
  export declare function statusOf(r: Record<string, unknown>): string;
3
- /** The step shown under "Being made". The timeline entry is the truth (that is where the receiving side puts it); a top-level `step` is read only when there is no timeline. */
3
+ /** The step shown under the current status. The timeline entry is the truth (that is where the receiving side puts it); a top-level `step` is read only when there is no timeline. */
4
4
  export declare function stepOf(r: Record<string, unknown>): string | undefined;
5
5
  /** The strip is made from the contract's own statuses, one step per distinct word ("planned" reads as "With Pluma"),
6
6
  * and a step is marked from the report's timeline: reached, skipped (the report went past it), or still ahead. */
7
- export declare function StatusStrip({ status, owner, step, timeline, label }: {
7
+ export declare function StatusStrip({ status: given, owner, step, timeline: givenTimeline, label }: {
8
8
  status: string;
9
9
  owner: string;
10
10
  step?: string;
@@ -4,7 +4,7 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
4
4
  // it has an end status. A report's words are data: they are rendered as text, never as markup. A report
5
5
  // that is still only on this phone is shown from the phone's own copy, with the app's refusal if it gave one.
6
6
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
7
- import { END, STATUSES } from '../contract.mjs';
7
+ import { END, STATUSES, currentStatus } from '../contract.mjs';
8
8
  import { DefaultField } from './default-field.js';
9
9
  import { draftKey } from './drafts.js';
10
10
  import { recoverTakes } from './recorder.js';
@@ -13,7 +13,7 @@ import { useRuntime, useTick } from './runtime.js';
13
13
  import { markSeen } from './seen.js';
14
14
  import { Toasts } from './toast.js';
15
15
  import { dropCache, isRefusal, oneCacheKey, readCache, useClearOnSignOut, writeCache } from './view-cache.js';
16
- import { effectiveCeilings, lengthWords, sizeWords, statusMeaning, statusWords, stringsFor } from './words.js';
16
+ import { effectiveCeilings, linkableIssue, lengthWords, sizeWords, statusMeaning, statusWords, stringsFor } from './words.js';
17
17
  export function statusOf(r) {
18
18
  if (typeof r.status === 'string')
19
19
  return r.status;
@@ -25,7 +25,7 @@ export function statusOf(r) {
25
25
  }
26
26
  return 'received';
27
27
  }
28
- /** The step shown under "Being made". The timeline entry is the truth (that is where the receiving side puts it); a top-level `step` is read only when there is no timeline. */
28
+ /** The step shown under the current status. The timeline entry is the truth (that is where the receiving side puts it); a top-level `step` is read only when there is no timeline. */
29
29
  export function stepOf(r) {
30
30
  const t = r.timeline;
31
31
  if (Array.isArray(t) && t.length) {
@@ -37,7 +37,10 @@ export function stepOf(r) {
37
37
  const timelineOf = (r) => Array.isArray(r?.timeline) ? r.timeline.map((t) => String(t?.status)) : undefined;
38
38
  /** The strip is made from the contract's own statuses, one step per distinct word ("planned" reads as "With Pluma"),
39
39
  * and a step is marked from the report's timeline: reached, skipped (the report went past it), or still ahead. */
40
- export function StatusStrip({ status, owner, step, timeline, label }) {
40
+ export function StatusStrip({ status: given, owner, step, timeline: givenTimeline, label }) {
41
+ // An old (0.1.x) status reads as the new one it means, on the strip and in the timeline it is marked from.
42
+ const status = currentStatus(given);
43
+ const timeline = givenTimeline?.map((s) => currentStatus(s));
41
44
  const nodes = useMemo(() => {
42
45
  const out = [];
43
46
  for (const id of STATUSES.filter((s) => !END.includes(s))) {
@@ -53,7 +56,7 @@ export function StatusStrip({ status, owner, step, timeline, label }) {
53
56
  const ended = END.includes(status);
54
57
  const here = nodes.findIndex((n) => n.ids.includes(status));
55
58
  const at = ended ? nodes.length : Math.max(0, here);
56
- const items = [...nodes, { words: statusWords(ended ? status : 'live', owner), ids: [ended ? status : 'end'] }];
59
+ const items = [...nodes, { words: statusWords(ended ? status : 'done', owner), ids: [ended ? status : 'end'] }];
57
60
  const reached = new Set(timeline ?? []);
58
61
  return (_jsx("ol", { className: "sac-strip", "aria-label": label ?? stringsFor({ app: 'x', owner }).stepLabel, "data-status": status, children: items.map((it, i) => {
59
62
  const state = i === at ? 'now'
@@ -62,7 +65,7 @@ export function StatusStrip({ status, owner, step, timeline, label }) {
62
65
  : timeline ? 'skipped'
63
66
  // No timeline to read: an open report has passed what is before it; an ended one may have skipped any step.
64
67
  : ended ? 'unknown' : 'past';
65
- return (_jsxs("li", { className: "sac-step", "data-state": state, "aria-current": i === at ? 'step' : undefined, children: [_jsx("span", { children: it.words }), i === at && it.ids.includes('being-made') && step ? _jsx("span", { className: "sac-step-detail", children: step }) : null] }, it.words + i));
68
+ return (_jsxs("li", { className: "sac-step", "data-state": state, "aria-current": i === at ? 'step' : undefined, children: [_jsx("span", { children: it.words }), i === at && step ? _jsx("span", { className: "sac-step-detail", children: step }) : null] }, it.words + i));
66
69
  }) }));
67
70
  }
68
71
  function makeDeferred() { let done = () => { }; const ready = new Promise((r) => { done = r; }); return { ready, done: () => done() }; }
@@ -226,9 +229,10 @@ export function SuggestionDetail({ adapters, id }) {
226
229
  return _jsx("p", { children: S.loading });
227
230
  if (report === null)
228
231
  return _jsx("p", { role: "alert", children: S.couldNotOpen });
229
- const status = statusOf(report);
232
+ const status = currentStatus(statusOf(report));
230
233
  const owner = adapters.words.owner;
231
234
  const open = !END.includes(status) && status !== 'on-phone';
235
+ const issue = linkableIssue(report.issue);
232
236
  const answers = (report.answers ?? {});
233
237
  const voice = Array.isArray(report.voice) ? report.voice : [];
234
238
  const media = Array.isArray(report.media) ? report.media : [];
@@ -271,5 +275,5 @@ export function SuggestionDetail({ adapters, id }) {
271
275
  setHeard((cur) => [...cur, { key: k, file, ms }]);
272
276
  return { audio: k, text: '' };
273
277
  }
274
- return (_jsxs("article", { className: "sac-detail", "data-report": id, children: [_jsx("h3", { className: "sac-status", "data-status": status, children: statusWords(status, owner) }), _jsxs("p", { className: "sac-muted", children: [statusMeaning(status, owner), status === 'live' && typeof report.liveAt === 'string' ? ` (${new Date(report.liveAt).toLocaleDateString()})` : ''] }), refusal ? (_jsxs(_Fragment, { children: [_jsx("p", { role: "alert", className: "sac-error", children: refusal }), rt ? _jsx("button", { type: "button", className: "sac-button", onClick: () => void rt.retry(id), children: S.retry }) : null] })) : null, updating ? _jsx("p", { role: "status", className: "sac-muted", "data-updating": "", children: S.updating }) : null, offline ? _jsx("p", { role: "alert", className: "sac-error", children: S.offlineShowingSaved }) : null, _jsx(StatusStrip, { status: status, owner: owner, label: S.stepLabel, step: stepOf(report), timeline: timelineOf(report) }), typeof report.words === 'string' && report.words ? _jsx("p", { className: "sac-words", children: report.words }) : null, Object.entries(answers).filter(([, v]) => v).map(([k, v]) => _jsx("p", { className: "sac-words", "data-answer": k, children: v }, k)), voice.length ? _jsx("ul", { className: "sac-list", "aria-label": S.voiceNotesLabel, children: voice.map((v, i) => _jsx("li", { className: "sac-item", children: S.voiceNote(i + 1, lengthWords(v.ms)) }, v.file)) }) : null, media.length ? _jsx("ul", { className: "sac-list", "aria-label": S.attachmentsLabel, children: media.map((m) => _jsxs("li", { className: "sac-item", children: [m.file, ", ", sizeWords(m.bytes)] }, m.file)) }) : null, Array.isArray(report.added) ? report.added.map((a, i) => _jsx("p", { className: "sac-words", children: a.words }, i)) : null, pending.length ? (_jsx("ul", { className: "sac-list", "aria-label": S.waitingToSend, "data-followups": "", children: pending.map((f) => (_jsxs("li", { className: "sac-item", "data-followup": f.followId, children: [_jsx("span", { className: "sac-words", children: f.words || S.attachmentsLabel }), f.error ? _jsx("span", { role: "alert", className: "sac-error", children: f.error }) : _jsx("span", { className: "sac-muted", children: S.waitingToSend }), f.error && rt ? _jsx("button", { type: "button", className: "sac-button", onClick: () => void rt.retry(id), children: S.retry }) : null, f.error && rt ? _jsx("button", { type: "button", className: "sac-button", onClick: () => void rt.discardFollowUp(id, f.followId), children: S.discard }) : null] }, f.followId))) })) : null, open ? (_jsxs("form", { className: "sac-form", onSubmit: (e) => { e.preventDefault(); void add(); }, children: [rt ? (_jsx(Field, { rt: { store: rt.store }, id: `append-${id}`, label: S.appendLabel, value: more, onChange: setMore, multiline: true, persist: false, startTake: adapters.startTake, maxMs: ceilings.voiceMs, scope: key ?? undefined, labels: labels, ...(adapters.appendVoice === false ? {} : { upload: (k, b, ms) => keepHeard(k, b, ms) }) })) : null, heard.length ? _jsx("ul", { className: "sac-list", "aria-label": S.voiceNotesLabel, children: heard.map((h, i) => _jsx("li", { className: "sac-item", children: S.voiceNote(i + 1, lengthWords(h.ms)) }, h.key)) }) : null, bad ? _jsx("p", { role: "alert", className: "sac-error", children: bad }) : null, note ? _jsx("p", { role: "status", children: note }) : null, _jsx("button", { type: "submit", className: "sac-button", disabled: !more.trim() && !heard.length, children: S.appendSend })] })) : null, _jsx(Toasts, {})] }));
278
+ return (_jsxs("article", { className: "sac-detail", "data-report": id, children: [_jsx("h3", { className: "sac-status", "data-status": status, children: statusWords(status, owner) }), _jsx("p", { className: "sac-muted", children: statusMeaning(status, owner, report.issue) }), issue ? _jsx("p", { children: _jsx("a", { className: "sac-issue-link", href: issue.url, target: "_blank", rel: "noopener noreferrer", children: S.issueLink(issue.number) }) }) : null, refusal ? (_jsxs(_Fragment, { children: [_jsx("p", { role: "alert", className: "sac-error", children: refusal }), rt ? _jsx("button", { type: "button", className: "sac-button", onClick: () => void rt.retry(id), children: S.retry }) : null] })) : null, updating ? _jsx("p", { role: "status", className: "sac-muted", "data-updating": "", children: S.updating }) : null, offline ? _jsx("p", { role: "alert", className: "sac-error", children: S.offlineShowingSaved }) : null, _jsx(StatusStrip, { status: status, owner: owner, label: S.stepLabel, step: stepOf(report), timeline: timelineOf(report) }), typeof report.words === 'string' && report.words ? _jsx("p", { className: "sac-words", children: report.words }) : null, Object.entries(answers).filter(([, v]) => v).map(([k, v]) => _jsx("p", { className: "sac-words", "data-answer": k, children: v }, k)), voice.length ? _jsx("ul", { className: "sac-list", "aria-label": S.voiceNotesLabel, children: voice.map((v, i) => _jsx("li", { className: "sac-item", children: S.voiceNote(i + 1, lengthWords(v.ms)) }, v.file)) }) : null, media.length ? _jsx("ul", { className: "sac-list", "aria-label": S.attachmentsLabel, children: media.map((m) => _jsxs("li", { className: "sac-item", children: [m.file, ", ", sizeWords(m.bytes)] }, m.file)) }) : null, Array.isArray(report.added) ? report.added.map((a, i) => _jsx("p", { className: "sac-words", children: a.words }, i)) : null, pending.length ? (_jsx("ul", { className: "sac-list", "aria-label": S.waitingToSend, "data-followups": "", children: pending.map((f) => (_jsxs("li", { className: "sac-item", "data-followup": f.followId, children: [_jsx("span", { className: "sac-words", children: f.words || S.attachmentsLabel }), f.error ? _jsx("span", { role: "alert", className: "sac-error", children: f.error }) : _jsx("span", { className: "sac-muted", children: S.waitingToSend }), f.error && rt ? _jsx("button", { type: "button", className: "sac-button", onClick: () => void rt.retry(id), children: S.retry }) : null, f.error && rt ? _jsx("button", { type: "button", className: "sac-button", onClick: () => void rt.discardFollowUp(id, f.followId), children: S.discard }) : null] }, f.followId))) })) : null, open ? (_jsxs("form", { className: "sac-form", onSubmit: (e) => { e.preventDefault(); void add(); }, children: [rt ? (_jsx(Field, { rt: { store: rt.store }, id: `append-${id}`, label: S.appendLabel, value: more, onChange: setMore, multiline: true, persist: false, startTake: adapters.startTake, maxMs: ceilings.voiceMs, scope: key ?? undefined, labels: labels, ...(adapters.appendVoice === false ? {} : { upload: (k, b, ms) => keepHeard(k, b, ms) }) })) : null, heard.length ? _jsx("ul", { className: "sac-list", "aria-label": S.voiceNotesLabel, children: heard.map((h, i) => _jsx("li", { className: "sac-item", children: S.voiceNote(i + 1, lengthWords(h.ms)) }, h.key)) }) : null, bad ? _jsx("p", { role: "alert", className: "sac-error", children: bad }) : null, note ? _jsx("p", { role: "status", children: note }) : null, _jsx("button", { type: "submit", className: "sac-button", disabled: !more.trim() && !heard.length, children: S.appendSend })] })) : null, _jsx(Toasts, {})] }));
275
279
  }
@@ -4,8 +4,9 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
4
4
  // Signed out, it opens nothing and says why. Signed in, it hands over to the host (`onOpen`), which
5
5
  // shows the sheet. `<ReportProblem>` is the same control for an error screen, already filled in.
6
6
  import { useEffect, useState } from 'react';
7
+ import { currentStatus } from '../contract.mjs';
7
8
  import { useRuntime } from './runtime.js';
8
- import { firstToastThisOpen, moved, onSeen } from './seen.js';
9
+ import { firstToastThisOpen, fresh, moved, onSeen } from './seen.js';
9
10
  import { SuggestSheet } from './sheet.js';
10
11
  import { toast, Toasts } from './toast.js';
11
12
  import { useClearOnSignOut } from './view-cache.js';
@@ -17,7 +18,7 @@ export function KeepSending({ adapters }) {
17
18
  return null;
18
19
  }
19
20
  // THE NEWS: once per mount, asks the app for this person's reports and compares each with what they last opened. A move
20
- // since then is counted (the dot), and the newest one gets the page load's one toast, whichever of the entry or a
21
+ // since then is counted (the dot), and the newest one the last page load had not already seen gets the page load's one toast, whichever of the entry or a
21
22
  // root-mounted SuggestNews asks first (the guard is per page load and account, in seen.ts). Opening a report anywhere
22
23
  // recounts. `toasted` says this mount showed the toast, so a standalone entry knows to draw a toast host for it.
23
24
  function useNews(adapters, rt) {
@@ -56,11 +57,15 @@ function useNews(adapters, rt) {
56
57
  if (!live || !rows || !now || now.account !== rt.who.account)
57
58
  return;
58
59
  const m = await count();
59
- if (!live || !m?.length || !firstToastThisOpen(id))
60
+ if (!live || !m)
61
+ return;
62
+ // Only a move the last page load had not already seen is toasted; an older one keeps just the dot.
63
+ const f = await fresh(rt.store, id, m);
64
+ if (!f.length || !firstToastThisOpen(id))
60
65
  return;
61
66
  const at = (x) => rows.find((r) => r.id === x)?.at ?? 0;
62
- const top = [...m].sort((a, b) => at(b.id) - at(a.id))[0];
63
- const note = top.status === 'not-now' ? rows.find((r) => r.id === top.id)?.note : undefined;
67
+ const top = [...f].sort((a, b) => at(b.id) - at(a.id))[0];
68
+ const note = currentStatus(top.status) === 'not-planned' ? rows.find((r) => r.id === top.id)?.note : undefined;
64
69
  setToasted(true);
65
70
  toast({ id: `sac-moved-${top.id}`, text: S.movedToast(statusWords(top.status, adapters.words.owner), typeof note === 'string' && note ? note : undefined), ms: 6000 });
66
71
  })();
@@ -15,3 +15,5 @@ export * from './mine.tsx';
15
15
  export { seenStatuses, markSeen, moved, onSeen, seenKey, type Seen } from './seen.ts';
16
16
  export * from './detail.tsx';
17
17
  export { refreshIdentity } from './runtime.ts';
18
+ export { collectContext, installContextListeners, contextTrail, resetContext, parseUserAgent, withoutQueries } from './context.ts';
19
+ export type { ContextItem, AppContextItem, ReportContext, Trail, CollectOptions } from './context.ts';
@@ -15,3 +15,4 @@ export * from './mine.js';
15
15
  export { seenStatuses, markSeen, moved, onSeen, seenKey } from './seen.js';
16
16
  export * from './detail.js';
17
17
  export { refreshIdentity } from './runtime.js';
18
+ export { collectContext, installContextListeners, contextTrail, resetContext, parseUserAgent, withoutQueries } from './context.js';
@@ -10,7 +10,8 @@ import { useRuntime, useTick } from './runtime.js';
10
10
  import { moved, onSeen } from './seen.js';
11
11
  import { dropCache, isRefusal, mineCacheKey, pruneOnes, readCache, useClearOnSignOut, writeCache } from './view-cache.js';
12
12
  import { Toasts } from './toast.js';
13
- import { statusMeaning, statusWords, stringsFor } from './words.js';
13
+ import { currentStatus } from '../contract.mjs';
14
+ import { linkableIssue, statusMeaning, statusWords, stringsFor } from './words.js';
14
15
  export function MySuggestions({ adapters, onOpen }) {
15
16
  const S = stringsFor(adapters.words);
16
17
  const owner = adapters.words.owner;
@@ -123,6 +124,7 @@ export function MySuggestions({ adapters, onOpen }) {
123
124
  }
124
125
  return (_jsxs("section", { className: "sac-mine", "aria-labelledby": "sac-mine-title", children: [_jsx("h2", { id: "sac-mine-title", className: "sac-title", children: S.mineTitle }), bad ? _jsx("p", { role: "alert", className: "sac-error", children: bad }) : null, updating ? _jsx("p", { role: "status", className: "sac-muted", "data-updating": "", children: S.updating }) : null, rows === null ? _jsx("p", { children: S.loading }) : rows.length === 0 ? _jsx("p", { children: S.mineEmpty }) : (_jsx("ul", { className: "sac-list", children: rows.map((r) => {
125
126
  const preview = typeof r.words === 'string' ? r.words : typeof r.title === 'string' ? r.title : '';
126
- return (_jsxs("li", { className: "sac-row", "data-status": r.status, children: [_jsxs("button", { type: "button", className: "sac-row-button", onClick: () => (onOpen ? onOpen(r.id) : setOpen(r.id)), children: [fresh.has(r.id) ? _jsx("span", { className: "sac-new", "data-new": "", children: S.newMark }) : null, preview ? _jsx("span", { className: "sac-row-words", children: preview }) : null, _jsx("span", { className: "sac-row-status", children: statusWords(r.status, owner) }), _jsx("span", { className: "sac-muted", children: statusMeaning(r.status, owner) })] }), r.error ? (_jsxs(_Fragment, { children: [_jsx("p", { role: "alert", className: "sac-error", children: r.error }), rt ? _jsx("button", { type: "button", className: "sac-button", onClick: () => void rt.retry(r.id), children: S.retry }) : null] })) : null] }, r.id));
127
+ const issue = linkableIssue(r.issue);
128
+ return (_jsxs("li", { className: "sac-row", "data-status": currentStatus(r.status), children: [_jsxs("button", { type: "button", className: "sac-row-button", onClick: () => (onOpen ? onOpen(r.id) : setOpen(r.id)), children: [fresh.has(r.id) ? _jsx("span", { className: "sac-new", "data-new": "", children: S.newMark }) : null, preview ? _jsx("span", { className: "sac-row-words", children: preview }) : null, _jsx("span", { className: "sac-row-status", children: statusWords(r.status, owner) }), _jsx("span", { className: "sac-muted", children: statusMeaning(r.status, owner, r.issue) })] }), issue ? _jsx("a", { className: "sac-issue-link", href: issue.url, target: "_blank", rel: "noopener noreferrer", children: S.issueLink(issue.number) }) : null, r.error ? (_jsxs(_Fragment, { children: [_jsx("p", { role: "alert", className: "sac-error", children: r.error }), rt ? _jsx("button", { type: "button", className: "sac-button", onClick: () => void rt.retry(r.id), children: S.retry }) : null] })) : null] }, r.id));
127
129
  }) })), _jsx(Toasts, {})] }));
128
130
  }
@@ -6,6 +6,7 @@
6
6
  // is sent only while that person is signed in. "Received" is derived from the outbox: a report has landed
7
7
  // when none of its writes is left there, because a write leaves only after the adapter said `done`.
8
8
  import { useEffect, useRef, useState } from 'react';
9
+ import { installContextListeners } from './context.js';
9
10
  import { draftKey } from './drafts.js';
10
11
  import { openStore } from './idb.js';
11
12
  import { makeOutbox } from './outbox.js';
@@ -170,6 +171,8 @@ export function useRuntime(adapters) {
170
171
  const [signal, setSignal] = useState(0);
171
172
  const current = useRef({ account: null, release: () => { } });
172
173
  const alive = useRef(true);
174
+ // Every screen of the module mounts this, so the trail (pages, taps, errors) starts with the first one; it installs once.
175
+ useEffect(() => { installContextListeners(); }, []);
173
176
  useEffect(() => { alive.current = true; return () => { alive.current = false; current.current.release(); current.current = { account: null, release: () => { } }; }; }, []);
174
177
  useEffect(() => { const l = () => setSignal((n) => n + 1); identityListeners.add(l); return () => { identityListeners.delete(l); }; }, []);
175
178
  useEffect(() => { if (state.rt)
@@ -21,4 +21,16 @@ export declare function moved(store: Store, id: Id, rows: {
21
21
  status: string;
22
22
  }[]>;
23
23
  export declare function firstToastThisOpen(id: Id): boolean;
24
+ /** WHAT THE LAST PAGE LOAD ALREADY SAW. The moves (report and status) that were already news the last time the app was
25
+ * loaded, so a move the person was told about, or could have been, is not toasted again on the next load; it keeps
26
+ * only the dot and the New mark until they open it. Returns the moves in `m` that are new since then, and records `m`
27
+ * as what this load saw. With no record yet (the first load on this release) every move is fresh, as before. */
28
+ export declare const noticedKey: (id: Id) => string;
29
+ export declare function fresh(store: Store, id: Id, m: {
30
+ id: string;
31
+ status: string;
32
+ }[]): Promise<{
33
+ id: string;
34
+ status: string;
35
+ }[]>;
24
36
  export {};
@@ -4,7 +4,7 @@
4
4
  // every end) is "moved": the entry shows a dot, My suggestions marks the row New, and one toast says it. Opening
5
5
  // the report marks it seen. The record sits beside the cached views, under the same app-and-account key, so another
6
6
  // account never reads it and signing out clears it with them (clearViewCache).
7
- import { TOLD } from '../contract.mjs';
7
+ import { TOLD, currentStatus } from '../contract.mjs';
8
8
  import { draftKey } from './drafts.js';
9
9
  import { readCache, writeCache } from './view-cache.js';
10
10
  export const seenKey = (id) => `${draftKey(id)}#view:seen`;
@@ -26,9 +26,10 @@ export async function seenStatuses(store, id) {
26
26
  export function markSeen(store, id, reportId, status) {
27
27
  return serial(seenKey(id), async () => {
28
28
  const seen = await seenStatuses(store, id);
29
- if (seen[reportId] === status)
29
+ const now = currentStatus(status); // kept in the new words, so an old status and the one it means are the same news
30
+ if (seen[reportId] === now)
30
31
  return;
31
- seen[reportId] = status;
32
+ seen[reportId] = now;
32
33
  await writeCache(store, seenKey(id), seen);
33
34
  for (const l of listeners)
34
35
  l();
@@ -45,11 +46,12 @@ export function moved(store, id, rows) {
45
46
  for (const r of rows) {
46
47
  if (typeof r?.id !== 'string' || typeof r.status !== 'string' || r.status === 'on-phone')
47
48
  continue;
48
- const had = seen[r.id];
49
- if (had === r.status)
49
+ const had = seen[r.id] === undefined ? undefined : currentStatus(seen[r.id]);
50
+ const now = currentStatus(r.status);
51
+ if (had === now)
50
52
  continue;
51
- if (had === undefined || !TOLD.includes(r.status)) {
52
- seen[r.id] = r.status;
53
+ if (had === undefined || !TOLD.includes(now)) {
54
+ seen[r.id] = now;
53
55
  changed = true;
54
56
  continue;
55
57
  }
@@ -70,3 +72,24 @@ export function firstToastThisOpen(id) {
70
72
  toasted.add(k);
71
73
  return true;
72
74
  }
75
+ /** WHAT THE LAST PAGE LOAD ALREADY SAW. The moves (report and status) that were already news the last time the app was
76
+ * loaded, so a move the person was told about, or could have been, is not toasted again on the next load; it keeps
77
+ * only the dot and the New mark until they open it. Returns the moves in `m` that are new since then, and records `m`
78
+ * as what this load saw. With no record yet (the first load on this release) every move is fresh, as before. */
79
+ export const noticedKey = (id) => `${draftKey(id)}#view:noticed`;
80
+ export function fresh(store, id, m) {
81
+ return serial(noticedKey(id), async () => {
82
+ const had = await readCache(store, noticedKey(id));
83
+ const before = had && typeof had === 'object' ? had : {};
84
+ const out = m.filter((r) => before[r.id] !== r.status);
85
+ const now = { ...before };
86
+ for (const r of m)
87
+ now[r.id] = r.status;
88
+ // A report no longer moved (opened, or gone) drops out, so the record stays the size of the dot.
89
+ for (const k of Object.keys(now))
90
+ if (!m.some((r) => r.id === k))
91
+ delete now[k];
92
+ await writeCache(store, noticedKey(id), now);
93
+ return out;
94
+ });
95
+ }