@supersuit/suggest-a-change 0.1.7 → 0.1.9
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.
- package/CHANGELOG.md +8 -0
- package/README.md +27 -1
- package/dist/client/entry.d.ts +24 -1
- package/dist/client/entry.js +42 -12
- package/dist/client/seen.js +2 -1
- package/dist/server/handlers.js +6 -28
- package/dist/server/http.d.ts +10 -0
- package/dist/server/http.js +49 -0
- package/dist/server/index.d.ts +1 -0
- package/dist/server/index.js +1 -0
- package/package.json +1 -1
- package/src/client/entry.tsx +47 -14
- package/src/client/seen.ts +2 -1
- package/src/client/styles.css +1 -1
- package/src/server/handlers.ts +5 -21
- package/src/server/http.ts +38 -0
- package/src/server/index.ts +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.9 (2026-10-17)
|
|
4
|
+
|
|
5
|
+
- **The door helpers are exported from `./server`.** `json`, `signIn`, `notFound`, `cappedText` and `peek` are what an app's own route wrapper needs to answer as the handlers do and never read a body past a cap. Two apps (cofounders-template, first-years-template) carried byte-identical copies; they now import these. The handlers use the same functions, so there is one copy of the door. No behaviour change.
|
|
6
|
+
|
|
7
|
+
## 0.1.8 (2026-10-17)
|
|
8
|
+
|
|
9
|
+
- **The news reaches a person whose entry sits in a menu.** In the Cofounders and Us apps the entry lives in a three-dot menu that stays closed and remounts it on every open, so the 0.1.7 dot was never seen and the toast was drawn inside the closed menu. New in `./client`: `<SuggestNews adapters>`, mounted once at the root, shows the toast with no entry on screen (and keeps the outbox moving); `useSuggestNews(adapters)` returns `{ moved }` for the menu's trigger; `<SuggestDot adapters moved>` draws the entry's dot there (nothing at 0), and the class `sac-dot-host` gives the trigger the `position: relative` it needs. The toast is once per page load and account however many times the entry mounts, whichever of these asks first. `<SuggestEntry>` keeps its own dot and, alone, behaves as in 0.1.7.
|
|
10
|
+
|
|
3
11
|
## 0.1.7 (2026-10-17)
|
|
4
12
|
|
|
5
13
|
- **The person who suggested a change is told when it is being made and when it ends.** `createReportHandlers` takes a required `tellSubmitter(e)`, called once per move to `being-made` or an end (`TOLD`, now in the contract) with `{ member, reportId, status, words, meaning, ended, note? }`. The words are the module's own (`wordsFor`/`meaningFor`), never the report's text; `note` is the one-line reason, on `not-now` only. The planning steps, a repeat, a refused move and a maintainer's own report starting tell nobody, and a failure never fails the move. **Breaking:** without `tellSubmitter` the handlers throw at startup; an app with nobody to tell passes `{ none: "<reason>" }`. The `status` tell event also carries `member` and `status`, and a `not-now` row in the member's list carries its `note`.
|
package/README.md
CHANGED
|
@@ -73,7 +73,31 @@ The four diagnostics are the app's version, the time, the screen size and the ph
|
|
|
73
73
|
|
|
74
74
|
**Identity.** Every screen asks `adapters.identity()` when it mounts, whenever it is handed a different `adapters` object, and whenever the host calls `refreshIdentity()` (exported from `"./client"`; call it after a sign-in, sign-out or account switch). When the account differs, the old account's rows leave the screen and its waiting reports stay on the phone, to be sent only while that account is signed in; every send re-confirms the account first. Keep `adapters` stable where you can; a parent that rebuilds it every render is safe, only slower (one `identity()` call per render).
|
|
75
75
|
|
|
76
|
-
**Telling the person in the app, with no setup.** The entry asks `mine()` once when it mounts and compares each report's status with the one this account last opened (kept on the phone beside the cached views, per app and account, and cleared on sign-out). A report that has since been started or ended (`TOLD`) shows a dot on the entry, says New on its row in My suggestions, and the newest such move gets one toast per
|
|
76
|
+
**Telling the person in the app, with no setup.** The entry asks `mine()` once when it mounts and compares each report's status with the one this account last opened (kept on the phone beside the cached views, per app and account, and cleared on sign-out). A report that has since been started or ended (`TOLD`) shows a dot on the entry, says New on its row in My suggestions, and the newest such move gets one toast per page load in the module's words (`words.text.movedToast`, given the status's own word and, on `not-now`, the reason: "Your suggestion: Done", "Your suggestion: Not now. too big for now"). Opening the report clears it. A report this phone sees for the first time is recorded silently, so an app taking this release does not announce every old report. The list's `not-now` rows carry their one-line `note` for this. Out of the app, the server's `tellSubmitter` does the telling (below).
|
|
77
|
+
|
|
78
|
+
**When the entry sits in a menu.** A host that shows the entry inside a menu that stays closed (a three-dot menu, often remounting its rows on every open) would never show the dot, and the toast would be drawn inside the closed menu. Mount `<SuggestNews>` once at the app's root, always mounted: it asks `mine()` when it mounts, shows the page load's one toast with no entry on screen, and keeps the outbox moving. Put the dot on the menu's own trigger with `useSuggestNews(adapters)`, which returns `{ moved }`, and `<SuggestDot>`, which draws the entry's dot (nothing when `moved` is 0). The trigger needs `position: relative`; the class `sac-dot-host` does it. The toast is once per page load and account however many times the entry mounts, whichever of these asks first. The entry in the menu keeps its own dot.
|
|
79
|
+
|
|
80
|
+
```tsx
|
|
81
|
+
import { SuggestDot, SuggestEntry, SuggestNews, useSuggestNews } from "@supersuit/suggest-a-change/client";
|
|
82
|
+
|
|
83
|
+
// once, at the root
|
|
84
|
+
<SuggestNews adapters={adapters} />
|
|
85
|
+
|
|
86
|
+
// the menu's trigger
|
|
87
|
+
function MoreButton({ onClick }: { onClick(): void }) {
|
|
88
|
+
const { moved } = useSuggestNews(adapters);
|
|
89
|
+
return (
|
|
90
|
+
<button type="button" className="sac-dot-host" aria-label="More" onClick={onClick}>
|
|
91
|
+
...<SuggestDot adapters={adapters} moved={moved} />
|
|
92
|
+
</button>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// inside the menu, as before
|
|
97
|
+
<SuggestEntry adapters={adapters} role="menuitem" onOpen={() => setOpen(true)} />
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
`<SuggestNews>` also takes `children`, a function given `{ moved }`, for a root that draws the trigger itself. A host using only the hook renders `<Toasts />` somewhere always mounted so the toast has a place to show.
|
|
77
101
|
|
|
78
102
|
**Follow-ups.** Words and voice notes added to a report afterwards are queued in the outbox like the report (a `followUp` write, then one part per voice note), keep the app's refusal with a Try again, and delete their local copy only once they have landed. `StoreAdapter.append` must therefore dedupe by report id and `more.followId`.
|
|
79
103
|
|
|
@@ -188,6 +212,8 @@ A client adapter's `uploadPart` asks `upload-url`, PUTs the bytes straight to st
|
|
|
188
212
|
|
|
189
213
|
The role stored on a report comes from the signed-in member and the `maintainers` list. A `who` sent by the phone is ignored; a report carrying `timeline`, `status`, `plan` or `decision` is refused. `sniffType(firstBytes)` names a file by its bytes (jpeg, png, webp, heic/heif, gif, mp4/mov, webm, m4a/aac, mp3, wav, ogg/opus; anything else is `null`) and `finalize` refuses a file whose bytes are not the type it was declared as.
|
|
190
214
|
|
|
215
|
+
**A wrapper of your own answers in the module's words.** When an app's route has to check a caller or read one field before the handler runs, it uses the same door the handlers use: `json(status, body)` (JSON, never cached), `signIn()` (401) and `notFound()` (404), `cappedText(req, cap)` (the body as text, or a 413 `Response` past `cap` bytes, by the declared length and again by counting what arrives; no body reads as `""`), and `peek(text)` (a JSON object, or `{}` for anything else, for reading one field while the module judges the body). Copying these into an app made a second door that could drift from the first.
|
|
216
|
+
|
|
191
217
|
**Settle carries a revision.** `agentList` gives each report a `revision`; the Mac sends it back with `settle`, and the store settles only if the report still has exactly that content. A follow-up or file that arrived in between answers 409 `stale`, drops the claim, and the next list offers the report with the addition. Adapters compute the same value with `reportRevision`.
|
|
192
218
|
|
|
193
219
|
**The collector MUST verify.** `agentFile` answers the verified `bytes` and `sha256`; a Mac that downloads the link must check both before it trusts the file.
|
package/dist/client/entry.d.ts
CHANGED
|
@@ -1,10 +1,33 @@
|
|
|
1
|
-
import { type AriaRole } from 'react';
|
|
1
|
+
import { type AriaRole, type ReactNode } from 'react';
|
|
2
2
|
import type { Adapters } from './adapters.ts';
|
|
3
3
|
/** Keeps the phone's outbox moving (on reconnect and every 15 seconds) for as long as it is mounted, with nothing drawn.
|
|
4
4
|
* <SuggestEntry> already does this; a host with no always-mounted entry mounts this once, high in the app. */
|
|
5
5
|
export declare function KeepSending({ adapters }: {
|
|
6
6
|
adapters: Adapters;
|
|
7
7
|
}): null;
|
|
8
|
+
/** For a host that shows the entry where it is not always visible (inside a menu): call this where the menu's trigger is
|
|
9
|
+
* drawn. It asks the app once per mount and returns how many of this person's suggestions moved since they last opened
|
|
10
|
+
* them (draw `<SuggestDot>` with it). The page load's one toast is shown by whichever of this, `<SuggestNews>` or the
|
|
11
|
+
* entry asks first; a host using only this hook must also render `<Toasts />` somewhere always mounted. */
|
|
12
|
+
export declare function useSuggestNews(adapters: Adapters): {
|
|
13
|
+
moved: number;
|
|
14
|
+
};
|
|
15
|
+
/** Mount once at the app's root, always mounted: shows the page load's one toast when a suggestion moved, with no entry
|
|
16
|
+
* on screen, and keeps the outbox moving (as `<KeepSending>` does). `children`, if given, receives `{ moved }`. */
|
|
17
|
+
export declare function SuggestNews({ adapters, children }: {
|
|
18
|
+
adapters: Adapters;
|
|
19
|
+
children?: (news: {
|
|
20
|
+
moved: number;
|
|
21
|
+
}) => ReactNode;
|
|
22
|
+
}): import("react").JSX.Element;
|
|
23
|
+
/** The entry's dot, for a host's own trigger (the three-dot button of a menu the entry sits in). Draws nothing when
|
|
24
|
+
* `moved` is 0. It is positioned like the entry's dot, so the trigger needs `position: relative` (the class
|
|
25
|
+
* `sac-dot-host` in the module's styles does it). */
|
|
26
|
+
export declare function SuggestDot({ adapters, moved: count, className }: {
|
|
27
|
+
adapters: Adapters;
|
|
28
|
+
moved: number;
|
|
29
|
+
className?: string;
|
|
30
|
+
}): import("react").JSX.Element | null;
|
|
8
31
|
export declare function SuggestEntry({ adapters, onOpen, className, role }: {
|
|
9
32
|
adapters: Adapters;
|
|
10
33
|
onOpen(): void;
|
package/dist/client/entry.js
CHANGED
|
@@ -16,18 +16,14 @@ export function KeepSending({ adapters }) {
|
|
|
16
16
|
useRuntime(adapters);
|
|
17
17
|
return null;
|
|
18
18
|
}
|
|
19
|
-
|
|
19
|
+
// 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
|
+
// root-mounted SuggestNews asks first (the guard is per page load and account, in seen.ts). Opening a report anywhere
|
|
22
|
+
// recounts. `toasted` says this mount showed the toast, so a standalone entry knows to draw a toast host for it.
|
|
23
|
+
function useNews(adapters, rt) {
|
|
20
24
|
const S = stringsFor(adapters.words);
|
|
21
|
-
const [why, setWhy] = useState('');
|
|
22
|
-
const [busy, setBusy] = useState(false);
|
|
23
|
-
// The runtime keeps the outbox moving while the entry is mounted (what <KeepSending> does).
|
|
24
|
-
const { rt, who } = useRuntime(adapters);
|
|
25
25
|
const [news, setNews] = useState(0);
|
|
26
|
-
const [
|
|
27
|
-
useEffect(() => { setWhy(''); }, [adapters]);
|
|
28
|
-
useClearOnSignOut(rt, who, adapters.words.app);
|
|
29
|
-
// Once per mount, asks the app for this person's reports and compares each with what they last opened: a move since
|
|
30
|
-
// then shows the dot, and the newest one gets the open's one toast. Opening a report (anywhere) clears it.
|
|
26
|
+
const [toasted, setToasted] = useState(false);
|
|
31
27
|
useEffect(() => {
|
|
32
28
|
setNews(0);
|
|
33
29
|
if (!rt)
|
|
@@ -65,13 +61,47 @@ export function SuggestEntry({ adapters, onOpen, className, role }) {
|
|
|
65
61
|
const at = (x) => rows.find((r) => r.id === x)?.at ?? 0;
|
|
66
62
|
const top = [...m].sort((a, b) => at(b.id) - at(a.id))[0];
|
|
67
63
|
const note = top.status === 'not-now' ? rows.find((r) => r.id === top.id)?.note : undefined;
|
|
68
|
-
|
|
64
|
+
setToasted(true);
|
|
69
65
|
toast({ id: `sac-moved-${top.id}`, text: S.movedToast(statusWords(top.status, adapters.words.owner), typeof note === 'string' && note ? note : undefined), ms: 6000 });
|
|
70
66
|
})();
|
|
71
67
|
const off = onSeen(() => { void count(); });
|
|
72
68
|
return () => { live = false; off(); };
|
|
73
69
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
74
70
|
}, [rt]);
|
|
71
|
+
return { moved: news, toasted };
|
|
72
|
+
}
|
|
73
|
+
/** For a host that shows the entry where it is not always visible (inside a menu): call this where the menu's trigger is
|
|
74
|
+
* drawn. It asks the app once per mount and returns how many of this person's suggestions moved since they last opened
|
|
75
|
+
* them (draw `<SuggestDot>` with it). The page load's one toast is shown by whichever of this, `<SuggestNews>` or the
|
|
76
|
+
* entry asks first; a host using only this hook must also render `<Toasts />` somewhere always mounted. */
|
|
77
|
+
export function useSuggestNews(adapters) {
|
|
78
|
+
const { rt } = useRuntime(adapters);
|
|
79
|
+
return { moved: useNews(adapters, rt).moved };
|
|
80
|
+
}
|
|
81
|
+
/** Mount once at the app's root, always mounted: shows the page load's one toast when a suggestion moved, with no entry
|
|
82
|
+
* on screen, and keeps the outbox moving (as `<KeepSending>` does). `children`, if given, receives `{ moved }`. */
|
|
83
|
+
export function SuggestNews({ adapters, children }) {
|
|
84
|
+
const news = useSuggestNews(adapters);
|
|
85
|
+
return _jsxs(_Fragment, { children: [_jsx(Toasts, {}), children ? children(news) : null] });
|
|
86
|
+
}
|
|
87
|
+
/** The entry's dot, for a host's own trigger (the three-dot button of a menu the entry sits in). Draws nothing when
|
|
88
|
+
* `moved` is 0. It is positioned like the entry's dot, so the trigger needs `position: relative` (the class
|
|
89
|
+
* `sac-dot-host` in the module's styles does it). */
|
|
90
|
+
export function SuggestDot({ adapters, moved: count, className }) {
|
|
91
|
+
const S = stringsFor(adapters.words);
|
|
92
|
+
if (!count)
|
|
93
|
+
return null;
|
|
94
|
+
return _jsxs(_Fragment, { children: [_jsx("span", { className: `sac-entry-dot${className ? ` ${className}` : ''}`, "data-sac-moved": "", "aria-hidden": "true" }), _jsx("span", { className: "sac-sr", children: S.entryMoved })] });
|
|
95
|
+
}
|
|
96
|
+
export function SuggestEntry({ adapters, onOpen, className, role }) {
|
|
97
|
+
const S = stringsFor(adapters.words);
|
|
98
|
+
const [why, setWhy] = useState('');
|
|
99
|
+
const [busy, setBusy] = useState(false);
|
|
100
|
+
// The runtime keeps the outbox moving while the entry is mounted (what <KeepSending> does).
|
|
101
|
+
const { rt, who } = useRuntime(adapters);
|
|
102
|
+
const { moved: news, toasted: toasting } = useNews(adapters, rt);
|
|
103
|
+
useEffect(() => { setWhy(''); }, [adapters]);
|
|
104
|
+
useClearOnSignOut(rt, who, adapters.words.app);
|
|
75
105
|
async function press() {
|
|
76
106
|
setBusy(true);
|
|
77
107
|
let who = null;
|
|
@@ -89,7 +119,7 @@ export function SuggestEntry({ adapters, onOpen, className, role }) {
|
|
|
89
119
|
setWhy('');
|
|
90
120
|
onOpen();
|
|
91
121
|
}
|
|
92
|
-
return (_jsxs("span", { className: "sac-entry-wrap", children: [_jsxs("button", { type: "button", role: role, "data-suggest-entry": "", className: `sac-entry${className ? ` ${className}` : ''}`, "aria-busy": busy || undefined, onClick: () => void press(), children: [S.entry,
|
|
122
|
+
return (_jsxs("span", { className: "sac-entry-wrap", children: [_jsxs("button", { type: "button", role: role, "data-suggest-entry": "", className: `sac-entry${className ? ` ${className}` : ''}`, "aria-busy": busy || undefined, onClick: () => void press(), children: [S.entry, _jsx(SuggestDot, { adapters: adapters, moved: news })] }), why ? _jsx("span", { role: "status", className: "sac-entry-why", children: why }) : null, toasting ? _jsx(Toasts, {}) : null] }));
|
|
93
123
|
}
|
|
94
124
|
/** For an error screen: opens the sheet as "something is broken", with the screen and what went wrong filled in. */
|
|
95
125
|
export function ReportProblem({ adapters, screen, error }) {
|
package/dist/client/seen.js
CHANGED
|
@@ -60,7 +60,8 @@ export function moved(store, id, rows) {
|
|
|
60
60
|
return out;
|
|
61
61
|
});
|
|
62
62
|
}
|
|
63
|
-
/** One toast per
|
|
63
|
+
/** One toast per page load, per account, whoever asks: a remounted entry (a menu that remounts it on every open) or a
|
|
64
|
+
* root-mounted SuggestNews beside it never says it again until the page is loaded again. */
|
|
64
65
|
const toasted = new Set();
|
|
65
66
|
export function firstToastThisOpen(id) {
|
|
66
67
|
const k = seenKey(id);
|
package/dist/server/handlers.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import { END, FLOW, STATUSES, TOLD, isAcceptedMedia, isReportId, mayMove, meaningFor, validateReport, wordsFor } from '../contract.mjs';
|
|
11
11
|
import { effectiveLimits } from './rate.js';
|
|
12
12
|
import { ACCEPTED_TYPES, canonicalType, sameType } from './sniff.js';
|
|
13
|
+
import { json, notFound, signIn, cappedText } from './http.js';
|
|
13
14
|
/** What a collector saw of a report: its verified files and its follow-ups. A store computes this from its own copy,
|
|
14
15
|
* inside `settle`, and settles only when it equals what the collector says it collected. */
|
|
15
16
|
export function reportRevision(r) {
|
|
@@ -35,40 +36,17 @@ export const INCOMPLETE_GRACE_MS = 60 * 60_000;
|
|
|
35
36
|
const FILE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
|
|
36
37
|
const FOLLOW = /^[A-Za-z0-9_-]{1,64}$/;
|
|
37
38
|
const FORBIDDEN = ['timeline', 'status', 'plan', 'decision', 'files', 'receivedAt', 'followUps', 'claimedBy', 'settledBy'];
|
|
38
|
-
const json = (status, body) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json', 'cache-control': 'no-store' } });
|
|
39
|
-
const notFound = () => json(404, { error: 'not found' });
|
|
40
|
-
const signIn = () => json(401, { error: 'sign in first' });
|
|
41
39
|
const bad = (error) => json(400, { error });
|
|
42
40
|
const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
43
41
|
/** Reads a JSON object body, refusing past `BODY_CAP` by the header and again by counting what arrives. */
|
|
44
42
|
async function readBody(req, cap = BODY_CAP, optional = false) {
|
|
45
|
-
|
|
46
|
-
if (Number.isFinite(declared) && declared > cap)
|
|
47
|
-
return { res: json(413, { error: 'too large' }) };
|
|
48
|
-
if (!req.body)
|
|
43
|
+
if (!req.body && !(Number(req.headers.get('content-length')) > cap))
|
|
49
44
|
return optional ? { body: {} } : { res: bad('a JSON body is needed') };
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
for (;;) {
|
|
54
|
-
const { done, value } = await reader.read();
|
|
55
|
-
if (done)
|
|
56
|
-
break;
|
|
57
|
-
n += value.byteLength;
|
|
58
|
-
if (n > cap) {
|
|
59
|
-
await reader.cancel().catch(() => { });
|
|
60
|
-
return { res: json(413, { error: 'too large' }) };
|
|
61
|
-
}
|
|
62
|
-
chunks.push(value);
|
|
63
|
-
}
|
|
64
|
-
const all = new Uint8Array(n);
|
|
65
|
-
let o = 0;
|
|
66
|
-
for (const c of chunks) {
|
|
67
|
-
all.set(c, o);
|
|
68
|
-
o += c.byteLength;
|
|
69
|
-
}
|
|
45
|
+
const text = await cappedText(req, cap);
|
|
46
|
+
if (typeof text !== 'string')
|
|
47
|
+
return { res: text };
|
|
70
48
|
try {
|
|
71
|
-
const v = JSON.parse(
|
|
49
|
+
const v = JSON.parse(text);
|
|
72
50
|
return isObj(v) ? { body: v } : optional ? { body: {} } : { res: bad('a JSON object is needed') };
|
|
73
51
|
}
|
|
74
52
|
catch {
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** A JSON response that is never cached. */
|
|
2
|
+
export declare const json: (status: number, body: unknown) => Response;
|
|
3
|
+
/** 401, the module's answer to a caller it does not know. */
|
|
4
|
+
export declare const signIn: () => Response;
|
|
5
|
+
/** 404, the module's answer to a report that is gone or not the caller's (the two are never told apart). */
|
|
6
|
+
export declare const notFound: () => Response;
|
|
7
|
+
/** The body as text, or a 413 once it passes `cap` bytes: by its declared length, and again by counting what arrives. A request with no body reads as ''. */
|
|
8
|
+
export declare function cappedText(req: Request, cap: number): Promise<string | Response>;
|
|
9
|
+
/** A JSON object out of text, or an empty one: the module judges the body itself; a wrapper only peeks at a field. */
|
|
10
|
+
export declare function peek(text: string): Record<string, unknown>;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// The door, in the module's own words: the answers the handlers give for "who are you" and "no such report", a body
|
|
2
|
+
// read that stops at a cap, and a peek at one field. Exported so an app's own route wrapper (one that has to check a
|
|
3
|
+
// caller, or read a field, before the module sees the request) answers exactly as the module does and never reads a
|
|
4
|
+
// body past the cap. Web-standard APIs only.
|
|
5
|
+
/** A JSON response that is never cached. */
|
|
6
|
+
export const json = (status, body) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json', 'cache-control': 'no-store' } });
|
|
7
|
+
/** 401, the module's answer to a caller it does not know. */
|
|
8
|
+
export const signIn = () => json(401, { error: 'sign in first' });
|
|
9
|
+
/** 404, the module's answer to a report that is gone or not the caller's (the two are never told apart). */
|
|
10
|
+
export const notFound = () => json(404, { error: 'not found' });
|
|
11
|
+
/** The body as text, or a 413 once it passes `cap` bytes: by its declared length, and again by counting what arrives. A request with no body reads as ''. */
|
|
12
|
+
export async function cappedText(req, cap) {
|
|
13
|
+
const declared = Number(req.headers.get('content-length'));
|
|
14
|
+
if (Number.isFinite(declared) && declared > cap)
|
|
15
|
+
return json(413, { error: 'too large' });
|
|
16
|
+
if (!req.body)
|
|
17
|
+
return '';
|
|
18
|
+
const reader = req.body.getReader();
|
|
19
|
+
const chunks = [];
|
|
20
|
+
let n = 0;
|
|
21
|
+
for (;;) {
|
|
22
|
+
const { done, value } = await reader.read();
|
|
23
|
+
if (done)
|
|
24
|
+
break;
|
|
25
|
+
n += value.byteLength;
|
|
26
|
+
if (n > cap) {
|
|
27
|
+
await reader.cancel().catch(() => { });
|
|
28
|
+
return json(413, { error: 'too large' });
|
|
29
|
+
}
|
|
30
|
+
chunks.push(value);
|
|
31
|
+
}
|
|
32
|
+
const all = new Uint8Array(n);
|
|
33
|
+
let o = 0;
|
|
34
|
+
for (const c of chunks) {
|
|
35
|
+
all.set(c, o);
|
|
36
|
+
o += c.byteLength;
|
|
37
|
+
}
|
|
38
|
+
return new TextDecoder().decode(all);
|
|
39
|
+
}
|
|
40
|
+
/** A JSON object out of text, or an empty one: the module judges the body itself; a wrapper only peeks at a field. */
|
|
41
|
+
export function peek(text) {
|
|
42
|
+
try {
|
|
43
|
+
const v = JSON.parse(text);
|
|
44
|
+
return v && typeof v === 'object' && !Array.isArray(v) ? v : {};
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return {};
|
|
48
|
+
}
|
|
49
|
+
}
|
package/dist/server/index.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { createReportHandlers, reportRevision, TOLD, VOICE_BYTES_PER_SECOND, LEASE_MS, BODY_CAP, APPROVAL_LEASE_MS, HELD_MS, READ_TTL_MS, INCOMPLETE_GRACE_MS, VOICE_FILE_BYTES } from './handlers.ts';
|
|
2
2
|
export type { HostedStore, StoredReport, TimelineEntry, FileState, FollowUp, TellEvent, SubmitterNews, ReportHandlerOptions } from './handlers.ts';
|
|
3
|
+
export { json, signIn, notFound, cappedText, peek } from './http.ts';
|
|
3
4
|
export { sniffType, sameType, canonicalType, ACCEPTED_TYPES } from './sniff.ts';
|
|
4
5
|
export { effectiveLimits } from './rate.ts';
|
|
5
6
|
export type { Limits } from './rate.ts';
|
package/dist/server/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// "@supersuit/suggest-a-change/server": web-standard APIs only, no runtime dependency, nothing from react or the client.
|
|
2
2
|
export { createReportHandlers, reportRevision, TOLD, VOICE_BYTES_PER_SECOND, LEASE_MS, BODY_CAP, APPROVAL_LEASE_MS, HELD_MS, READ_TTL_MS, INCOMPLETE_GRACE_MS, VOICE_FILE_BYTES } from './handlers.js';
|
|
3
|
+
export { json, signIn, notFound, cappedText, peek } from './http.js';
|
|
3
4
|
export { sniffType, sameType, canonicalType, ACCEPTED_TYPES } from './sniff.js';
|
|
4
5
|
export { effectiveLimits } from './rate.js';
|
|
5
6
|
export { runStoreConformance, checks as conformanceChecks } from './conformance.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@supersuit/suggest-a-change",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.9",
|
|
4
4
|
"description": "The report format and flow behind a Suggest a change button: what a report is, who acts at each step, the screens, and the check every app runs. The contract has zero dependencies.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/client/entry.tsx
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
// THE ENTRY: one control, `data-suggest-entry`, which is the contract the screen checker looks for.
|
|
3
3
|
// Signed out, it opens nothing and says why. Signed in, it hands over to the host (`onOpen`), which
|
|
4
4
|
// shows the sheet. `<ReportProblem>` is the same control for an error screen, already filled in.
|
|
5
|
-
import { useEffect, useState, type AriaRole } from 'react'
|
|
5
|
+
import { useEffect, useState, type AriaRole, type ReactNode } from 'react'
|
|
6
6
|
import type { Adapters } from './adapters.ts'
|
|
7
|
-
import { useRuntime } from './runtime.ts'
|
|
7
|
+
import { useRuntime, type Runtime } from './runtime.ts'
|
|
8
8
|
import { firstToastThisOpen, moved, onSeen } from './seen.ts'
|
|
9
9
|
import { SuggestSheet } from './sheet.tsx'
|
|
10
10
|
import { toast, Toasts } from './toast.tsx'
|
|
@@ -18,18 +18,14 @@ export function KeepSending({ adapters }: { adapters: Adapters }) {
|
|
|
18
18
|
return null
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
// THE NEWS: once per mount, asks the app for this person's reports and compares each with what they last opened. A move
|
|
22
|
+
// since then is counted (the dot), and the newest one gets the page load's one toast, whichever of the entry or a
|
|
23
|
+
// root-mounted SuggestNews asks first (the guard is per page load and account, in seen.ts). Opening a report anywhere
|
|
24
|
+
// recounts. `toasted` says this mount showed the toast, so a standalone entry knows to draw a toast host for it.
|
|
25
|
+
function useNews(adapters: Adapters, rt: Runtime | null): { moved: number; toasted: boolean } {
|
|
22
26
|
const S = stringsFor(adapters.words)
|
|
23
|
-
const [why, setWhy] = useState('')
|
|
24
|
-
const [busy, setBusy] = useState(false)
|
|
25
|
-
// The runtime keeps the outbox moving while the entry is mounted (what <KeepSending> does).
|
|
26
|
-
const { rt, who } = useRuntime(adapters)
|
|
27
27
|
const [news, setNews] = useState(0)
|
|
28
|
-
const [
|
|
29
|
-
useEffect(() => { setWhy('') }, [adapters])
|
|
30
|
-
useClearOnSignOut(rt, who, adapters.words.app)
|
|
31
|
-
// Once per mount, asks the app for this person's reports and compares each with what they last opened: a move since
|
|
32
|
-
// then shows the dot, and the newest one gets the open's one toast. Opening a report (anywhere) clears it.
|
|
28
|
+
const [toasted, setToasted] = useState(false)
|
|
33
29
|
useEffect(() => {
|
|
34
30
|
setNews(0)
|
|
35
31
|
if (!rt) return
|
|
@@ -52,13 +48,50 @@ export function SuggestEntry({ adapters, onOpen, className, role }: { adapters:
|
|
|
52
48
|
const at = (x: string) => rows!.find((r) => r.id === x)?.at ?? 0
|
|
53
49
|
const top = [...m].sort((a, b) => at(b.id) - at(a.id))[0]
|
|
54
50
|
const note = top.status === 'not-now' ? rows.find((r) => r.id === top.id)?.note : undefined
|
|
55
|
-
|
|
51
|
+
setToasted(true)
|
|
56
52
|
toast({ id: `sac-moved-${top.id}`, text: S.movedToast(statusWords(top.status, adapters.words.owner), typeof note === 'string' && note ? note : undefined), ms: 6000 })
|
|
57
53
|
})()
|
|
58
54
|
const off = onSeen(() => { void count() })
|
|
59
55
|
return () => { live = false; off() }
|
|
60
56
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
61
57
|
}, [rt])
|
|
58
|
+
return { moved: news, toasted }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** For a host that shows the entry where it is not always visible (inside a menu): call this where the menu's trigger is
|
|
62
|
+
* drawn. It asks the app once per mount and returns how many of this person's suggestions moved since they last opened
|
|
63
|
+
* them (draw `<SuggestDot>` with it). The page load's one toast is shown by whichever of this, `<SuggestNews>` or the
|
|
64
|
+
* entry asks first; a host using only this hook must also render `<Toasts />` somewhere always mounted. */
|
|
65
|
+
export function useSuggestNews(adapters: Adapters): { moved: number } {
|
|
66
|
+
const { rt } = useRuntime(adapters)
|
|
67
|
+
return { moved: useNews(adapters, rt).moved }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Mount once at the app's root, always mounted: shows the page load's one toast when a suggestion moved, with no entry
|
|
71
|
+
* on screen, and keeps the outbox moving (as `<KeepSending>` does). `children`, if given, receives `{ moved }`. */
|
|
72
|
+
export function SuggestNews({ adapters, children }: { adapters: Adapters; children?: (news: { moved: number }) => ReactNode }) {
|
|
73
|
+
const news = useSuggestNews(adapters)
|
|
74
|
+
return <><Toasts />{children ? children(news) : null}</>
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The entry's dot, for a host's own trigger (the three-dot button of a menu the entry sits in). Draws nothing when
|
|
78
|
+
* `moved` is 0. It is positioned like the entry's dot, so the trigger needs `position: relative` (the class
|
|
79
|
+
* `sac-dot-host` in the module's styles does it). */
|
|
80
|
+
export function SuggestDot({ adapters, moved: count, className }: { adapters: Adapters; moved: number; className?: string }) {
|
|
81
|
+
const S = stringsFor(adapters.words)
|
|
82
|
+
if (!count) return null
|
|
83
|
+
return <><span className={`sac-entry-dot${className ? ` ${className}` : ''}`} data-sac-moved="" aria-hidden="true" /><span className="sac-sr">{S.entryMoved}</span></>
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function SuggestEntry({ adapters, onOpen, className, role }: { adapters: Adapters; onOpen(): void; className?: string; /** An ARIA role for the button (for example `menuitem` when the host shows it as a menu row). Unset by default. */ role?: AriaRole }) {
|
|
87
|
+
const S = stringsFor(adapters.words)
|
|
88
|
+
const [why, setWhy] = useState('')
|
|
89
|
+
const [busy, setBusy] = useState(false)
|
|
90
|
+
// The runtime keeps the outbox moving while the entry is mounted (what <KeepSending> does).
|
|
91
|
+
const { rt, who } = useRuntime(adapters)
|
|
92
|
+
const { moved: news, toasted: toasting } = useNews(adapters, rt)
|
|
93
|
+
useEffect(() => { setWhy('') }, [adapters])
|
|
94
|
+
useClearOnSignOut(rt, who, adapters.words.app)
|
|
62
95
|
async function press() {
|
|
63
96
|
setBusy(true)
|
|
64
97
|
let who = null
|
|
@@ -72,7 +105,7 @@ export function SuggestEntry({ adapters, onOpen, className, role }: { adapters:
|
|
|
72
105
|
<span className="sac-entry-wrap">
|
|
73
106
|
<button type="button" role={role} data-suggest-entry="" className={`sac-entry${className ? ` ${className}` : ''}`} aria-busy={busy || undefined} onClick={() => void press()}>
|
|
74
107
|
{S.entry}
|
|
75
|
-
|
|
108
|
+
<SuggestDot adapters={adapters} moved={news} />
|
|
76
109
|
</button>
|
|
77
110
|
{why ? <span role="status" className="sac-entry-why">{why}</span> : null}
|
|
78
111
|
{toasting ? <Toasts /> : null}
|
package/src/client/seen.ts
CHANGED
|
@@ -61,7 +61,8 @@ export function moved(store: Store, id: Id, rows: { id: string; status: string }
|
|
|
61
61
|
})
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
/** One toast per
|
|
64
|
+
/** One toast per page load, per account, whoever asks: a remounted entry (a menu that remounts it on every open) or a
|
|
65
|
+
* root-mounted SuggestNews beside it never says it again until the page is loaded again. */
|
|
65
66
|
const toasted = new Set<string>()
|
|
66
67
|
export function firstToastThisOpen(id: Id): boolean {
|
|
67
68
|
const k = seenKey(id)
|
package/src/client/styles.css
CHANGED
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
.sac-item { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px; }
|
|
46
46
|
.sac-row-button { width: 100%; text-align: left; display: flex; flex-direction: column; gap: 2px; }
|
|
47
47
|
.sac-row-status { font-weight: 600; }
|
|
48
|
-
.sac-entry { position: relative; }
|
|
48
|
+
.sac-entry, .sac-dot-host { position: relative; }
|
|
49
49
|
.sac-entry-dot { position: absolute; top: 6px; right: 6px; width: 10px; height: 10px; border-radius: 50%; background: var(--sac-accent); box-shadow: 0 0 0 2px var(--sac-bg); }
|
|
50
50
|
.sac-new { align-self: flex-start; font-size: 0.8rem; font-weight: 600; padding: 1px 8px; border-radius: 999px; background: var(--sac-accent); color: var(--sac-accent-fg); }
|
|
51
51
|
.sac-muted { color: var(--sac-muted); font-size: 0.9rem; }
|
package/src/server/handlers.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import { END, FLOW, STATUSES, TOLD, isAcceptedMedia, isReportId, mayMove, meaningFor, validateReport, wordsFor } from '../contract.mjs'
|
|
11
11
|
import { effectiveLimits, type Limits } from './rate.ts'
|
|
12
12
|
import { ACCEPTED_TYPES, canonicalType, sameType } from './sniff.ts'
|
|
13
|
+
import { json, notFound, signIn, cappedText } from './http.ts'
|
|
13
14
|
|
|
14
15
|
/** `by` is the actor (contract FLOW). `holder` is the maintainer whose Mac started or ended the work, written on a being-made
|
|
15
16
|
* or end entry: the approval on such a report never lapses, and it is how the list and the claim name who has it. */
|
|
@@ -129,34 +130,17 @@ const FILE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/
|
|
|
129
130
|
const FOLLOW = /^[A-Za-z0-9_-]{1,64}$/
|
|
130
131
|
const FORBIDDEN = ['timeline', 'status', 'plan', 'decision', 'files', 'receivedAt', 'followUps', 'claimedBy', 'settledBy']
|
|
131
132
|
|
|
132
|
-
const json = (status: number, body: unknown): Response =>
|
|
133
|
-
new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json', 'cache-control': 'no-store' } })
|
|
134
|
-
const notFound = () => json(404, { error: 'not found' })
|
|
135
|
-
const signIn = () => json(401, { error: 'sign in first' })
|
|
136
133
|
const bad = (error: string) => json(400, { error })
|
|
137
134
|
const isObj = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v)
|
|
138
135
|
|
|
139
136
|
type Body = { body: Record<string, unknown> } | { res: Response }
|
|
140
137
|
/** Reads a JSON object body, refusing past `BODY_CAP` by the header and again by counting what arrives. */
|
|
141
138
|
async function readBody(req: Request, cap = BODY_CAP, optional = false): Promise<Body> {
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
if (
|
|
145
|
-
const reader = req.body.getReader()
|
|
146
|
-
const chunks: Uint8Array[] = []
|
|
147
|
-
let n = 0
|
|
148
|
-
for (;;) {
|
|
149
|
-
const { done, value } = await reader.read()
|
|
150
|
-
if (done) break
|
|
151
|
-
n += value.byteLength
|
|
152
|
-
if (n > cap) { await reader.cancel().catch(() => {}); return { res: json(413, { error: 'too large' }) } }
|
|
153
|
-
chunks.push(value)
|
|
154
|
-
}
|
|
155
|
-
const all = new Uint8Array(n)
|
|
156
|
-
let o = 0
|
|
157
|
-
for (const c of chunks) { all.set(c, o); o += c.byteLength }
|
|
139
|
+
if (!req.body && !(Number(req.headers.get('content-length')) > cap)) return optional ? { body: {} } : { res: bad('a JSON body is needed') }
|
|
140
|
+
const text = await cappedText(req, cap)
|
|
141
|
+
if (typeof text !== 'string') return { res: text }
|
|
158
142
|
try {
|
|
159
|
-
const v = JSON.parse(
|
|
143
|
+
const v = JSON.parse(text)
|
|
160
144
|
return isObj(v) ? { body: v } : optional ? { body: {} } : { res: bad('a JSON object is needed') }
|
|
161
145
|
} catch { return optional ? { body: {} } : { res: bad('the body is not JSON') } }
|
|
162
146
|
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// The door, in the module's own words: the answers the handlers give for "who are you" and "no such report", a body
|
|
2
|
+
// read that stops at a cap, and a peek at one field. Exported so an app's own route wrapper (one that has to check a
|
|
3
|
+
// caller, or read a field, before the module sees the request) answers exactly as the module does and never reads a
|
|
4
|
+
// body past the cap. Web-standard APIs only.
|
|
5
|
+
|
|
6
|
+
/** A JSON response that is never cached. */
|
|
7
|
+
export const json = (status: number, body: unknown): Response =>
|
|
8
|
+
new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json', 'cache-control': 'no-store' } })
|
|
9
|
+
/** 401, the module's answer to a caller it does not know. */
|
|
10
|
+
export const signIn = (): Response => json(401, { error: 'sign in first' })
|
|
11
|
+
/** 404, the module's answer to a report that is gone or not the caller's (the two are never told apart). */
|
|
12
|
+
export const notFound = (): Response => json(404, { error: 'not found' })
|
|
13
|
+
|
|
14
|
+
/** The body as text, or a 413 once it passes `cap` bytes: by its declared length, and again by counting what arrives. A request with no body reads as ''. */
|
|
15
|
+
export async function cappedText(req: Request, cap: number): Promise<string | Response> {
|
|
16
|
+
const declared = Number(req.headers.get('content-length'))
|
|
17
|
+
if (Number.isFinite(declared) && declared > cap) return json(413, { error: 'too large' })
|
|
18
|
+
if (!req.body) return ''
|
|
19
|
+
const reader = req.body.getReader()
|
|
20
|
+
const chunks: Uint8Array[] = []
|
|
21
|
+
let n = 0
|
|
22
|
+
for (;;) {
|
|
23
|
+
const { done, value } = await reader.read()
|
|
24
|
+
if (done) break
|
|
25
|
+
n += value.byteLength
|
|
26
|
+
if (n > cap) { await reader.cancel().catch(() => {}); return json(413, { error: 'too large' }) }
|
|
27
|
+
chunks.push(value)
|
|
28
|
+
}
|
|
29
|
+
const all = new Uint8Array(n)
|
|
30
|
+
let o = 0
|
|
31
|
+
for (const c of chunks) { all.set(c, o); o += c.byteLength }
|
|
32
|
+
return new TextDecoder().decode(all)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A JSON object out of text, or an empty one: the module judges the body itself; a wrapper only peeks at a field. */
|
|
36
|
+
export function peek(text: string): Record<string, unknown> {
|
|
37
|
+
try { const v = JSON.parse(text) as unknown; return v && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : {} } catch { return {} }
|
|
38
|
+
}
|
package/src/server/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// "@supersuit/suggest-a-change/server": web-standard APIs only, no runtime dependency, nothing from react or the client.
|
|
2
2
|
export { createReportHandlers, reportRevision, TOLD, VOICE_BYTES_PER_SECOND, LEASE_MS, BODY_CAP, APPROVAL_LEASE_MS, HELD_MS, READ_TTL_MS, INCOMPLETE_GRACE_MS, VOICE_FILE_BYTES } from './handlers.ts'
|
|
3
3
|
export type { HostedStore, StoredReport, TimelineEntry, FileState, FollowUp, TellEvent, SubmitterNews, ReportHandlerOptions } from './handlers.ts'
|
|
4
|
+
export { json, signIn, notFound, cappedText, peek } from './http.ts'
|
|
4
5
|
export { sniffType, sameType, canonicalType, ACCEPTED_TYPES } from './sniff.ts'
|
|
5
6
|
export { effectiveLimits } from './rate.ts'
|
|
6
7
|
export type { Limits } from './rate.ts'
|