@supersuit/suggest-a-change 0.1.6 → 0.1.8

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 CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.8 (2026-10-17)
4
+
5
+ - **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.
6
+
7
+ ## 0.1.7 (2026-10-17)
8
+
9
+ - **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`.
10
+ - **In the app, with no setup.** The entry compares each report's status with the one its owner last opened (kept on the phone per app and account, cleared on sign-out): a report that has since started or ended shows a dot on the entry, says New in My suggestions, and the newest move gets one toast per app open ("Your suggestion: Done"; new strings `newMark`, `entryMoved`, `movedToast`). Opening the report clears it. A report seen for the first time is recorded silently, so nothing old is announced.
11
+ - `init` writes a `tellSubmitter` stub into the adapters file and names it in its notes.
12
+
3
13
  ## 0.1.6 (2026-10-15)
4
14
 
5
15
  - **Writes made in the same millisecond leave the phone in the order they were made.** `enqueue` stamps each op with `seq`, and the queue sorts by `at`, then `seq`, then id. Before, ties fell to the id, so a screen that saved a draft and then asked for it to be prepared in one tick sent the prepare first about half the time, and the receiver prepared the draft as it had been.
package/README.md CHANGED
@@ -73,6 +73,32 @@ 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 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.
101
+
76
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`.
77
103
 
78
104
  ## The check: every screen carries the button
@@ -86,7 +112,7 @@ npx suggest-a-change-check --help
86
112
 
87
113
  Exit 0 is clean, 1 is problems (each printed with the file and the fix), 2 is a usage error or nothing to check. `--json` prints `{ ok, problems, checked, exempted }`. The flags are `--repo`, `--config`, `--app-dir`, `--adapters` and `--strict`. An app directory with no pages is a usage error (exit 2: nothing was checked), as is an app directory outside the repo.
88
114
 
89
- **Put `suggest-a-change-check --strict` in the app's check script** once the adapters are real. `init` writes stubs that sign nobody in and refuse every report, and marks the file with a `suggest-a-change: STUB` line. Without `--strict` that line is a `warning:` (also in the `--json` `warnings`) and the exit stays 0; with `--strict` it is a problem and the exit is 1. Delete the line when the four adapters are real.
115
+ **Put `suggest-a-change-check --strict` in the app's check script** once the adapters are real. `init` writes stubs that sign nobody in and refuse every report, and marks the file with a `suggest-a-change: STUB` line. Without `--strict` that line is a `warning:` (also in the `--json` `warnings`) and the exit stays 0; with `--strict` it is a problem and the exit is 1. Delete the line when the four adapters are real. The stub also carries `tellSubmitter` for the server half, which `createReportHandlers` requires (see below), and `init` says so in its notes whenever the adapters file has none.
90
116
 
91
117
  For a Next.js app (app router) it reads source, not a running app:
92
118
 
@@ -162,9 +188,12 @@ const h = createReportHandlers({
162
188
  app: { name: "Notes" }, // stamped into every report from here; an app in the body is ignored
163
189
  maintainersSince: { mo: "2026-10-01" }, // optional: reports from before this are not offered to that maintainer's Mac
164
190
  tell: (e) => notify(e), // ids only, never words; a failure never fails the request
191
+ tellSubmitter: (e) => push(e.member, e.words, e.note), // REQUIRED: tell the person who suggested it (or { none: "<reason>" })
165
192
  });
166
193
  ```
167
194
 
195
+ **Telling the person who suggested it is required.** `tellSubmitter` is called once when a report moves to `being-made` and once when it ends (`live`, `sent-to-template`, `filed`, `not-now`; the list is exported as `TOLD`), with `{ member, reportId, status, words, meaning, ended, note? }`. `words` and `meaning` are the module's own words for the status (`wordsFor`/`meaningFor`, with `app.owner` as the owner's name when given), never the report's text, so a lock screen never shows what someone wrote; `note` is the one-line reason and comes with `not-now` only. The planning steps (`with-freeda`, `planned`, `waiting-on-owner`) tell nobody, a repeat of a status tells nobody, a move the store refused tells nobody, and a maintainer is not told that the work they just started on their own report has started (its end is still told). Send it on the app's own channel (push, email, an activity row) to that member only. A failure never fails the move. `createReportHandlers` throws at startup when `tellSubmitter` is missing; an app that genuinely has nobody to tell passes `tellSubmitter: { none: "only the maintainer uses this app" }`, and an empty reason throws too. The `tell` event for a status move now also carries `member` and `status`.
196
+
168
197
  | Route | Handler | Answers |
169
198
  | --- | --- | --- |
170
199
  | `POST /api/reports` | `submit` | 200 (a repeat of the same id is 200 too), 400, 401, 413, 422, 429 |
@@ -10,6 +10,7 @@ import { draftKey } from './drafts.js';
10
10
  import { recoverTakes } from './recorder.js';
11
11
  import { queueFollowUp } from './reports.js';
12
12
  import { useRuntime, useTick } from './runtime.js';
13
+ import { markSeen } from './seen.js';
13
14
  import { Toasts } from './toast.js';
14
15
  import { dropCache, isRefusal, oneCacheKey, readCache, useClearOnSignOut, writeCache } from './view-cache.js';
15
16
  import { effectiveCeilings, lengthWords, sizeWords, statusMeaning, statusWords, stringsFor } from './words.js';
@@ -158,6 +159,8 @@ export function SuggestionDetail({ adapters, id }) {
158
159
  setOffline(false);
159
160
  setReport(r);
160
161
  void writeCache(rt.store, ckey, r);
162
+ // The person has now seen it at this status: its New mark and the entry's dot clear.
163
+ void markSeen(rt.store, { app: adapters.words.app, account: rt.who.account }, id, statusOf(r));
161
164
  }
162
165
  else if (refused) {
163
166
  shown.current = false;
@@ -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;
@@ -5,19 +5,103 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
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
7
  import { useRuntime } from './runtime.js';
8
+ import { firstToastThisOpen, moved, onSeen } from './seen.js';
8
9
  import { SuggestSheet } from './sheet.js';
9
- import { stringsFor } from './words.js';
10
+ import { toast, Toasts } from './toast.js';
11
+ import { useClearOnSignOut } from './view-cache.js';
12
+ import { statusWords, stringsFor } from './words.js';
10
13
  /** Keeps the phone's outbox moving (on reconnect and every 15 seconds) for as long as it is mounted, with nothing drawn.
11
14
  * <SuggestEntry> already does this; a host with no always-mounted entry mounts this once, high in the app. */
12
15
  export function KeepSending({ adapters }) {
13
16
  useRuntime(adapters);
14
17
  return null;
15
18
  }
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) {
24
+ const S = stringsFor(adapters.words);
25
+ const [news, setNews] = useState(0);
26
+ const [toasted, setToasted] = useState(false);
27
+ useEffect(() => {
28
+ setNews(0);
29
+ if (!rt)
30
+ return;
31
+ let live = true;
32
+ const id = { app: adapters.words.app, account: rt.who.account };
33
+ let rows = null;
34
+ const count = async () => {
35
+ if (!rows)
36
+ return;
37
+ const m = await moved(rt.store, id, rows);
38
+ if (live)
39
+ setNews(m.length);
40
+ return m;
41
+ };
42
+ void (async () => {
43
+ try {
44
+ rows = await adapters.store.mine();
45
+ }
46
+ catch {
47
+ rows = null;
48
+ }
49
+ let now = null;
50
+ try {
51
+ now = await adapters.identity();
52
+ }
53
+ catch {
54
+ now = null;
55
+ }
56
+ if (!live || !rows || !now || now.account !== rt.who.account)
57
+ return;
58
+ const m = await count();
59
+ if (!live || !m?.length || !firstToastThisOpen(id))
60
+ return;
61
+ 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;
64
+ setToasted(true);
65
+ toast({ id: `sac-moved-${top.id}`, text: S.movedToast(statusWords(top.status, adapters.words.owner), typeof note === 'string' && note ? note : undefined), ms: 6000 });
66
+ })();
67
+ const off = onSeen(() => { void count(); });
68
+ return () => { live = false; off(); };
69
+ // eslint-disable-next-line react-hooks/exhaustive-deps
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
+ }
16
96
  export function SuggestEntry({ adapters, onOpen, className, role }) {
17
97
  const S = stringsFor(adapters.words);
18
98
  const [why, setWhy] = useState('');
19
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);
20
103
  useEffect(() => { setWhy(''); }, [adapters]);
104
+ useClearOnSignOut(rt, who, adapters.words.app);
21
105
  async function press() {
22
106
  setBusy(true);
23
107
  let who = null;
@@ -35,7 +119,7 @@ export function SuggestEntry({ adapters, onOpen, className, role }) {
35
119
  setWhy('');
36
120
  onOpen();
37
121
  }
38
- return (_jsxs("span", { className: "sac-entry-wrap", children: [_jsx(KeepSending, { adapters: adapters }), _jsx("button", { type: "button", role: role, "data-suggest-entry": "", className: `sac-entry${className ? ` ${className}` : ''}`, "aria-busy": busy || undefined, onClick: () => void press(), children: S.entry }), why ? _jsx("span", { role: "status", className: "sac-entry-why", children: why }) : null] }));
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] }));
39
123
  }
40
124
  /** For an error screen: opens the sheet as "something is broken", with the screen and what went wrong filled in. */
41
125
  export function ReportProblem({ adapters, screen, error }) {
@@ -12,5 +12,6 @@ export * from './receipt.tsx';
12
12
  export * from './entry.tsx';
13
13
  export * from './sheet.tsx';
14
14
  export * from './mine.tsx';
15
+ export { seenStatuses, markSeen, moved, onSeen, seenKey, type Seen } from './seen.ts';
15
16
  export * from './detail.tsx';
16
17
  export { refreshIdentity } from './runtime.ts';
@@ -12,5 +12,6 @@ export * from './receipt.js';
12
12
  export * from './entry.js';
13
13
  export * from './sheet.js';
14
14
  export * from './mine.js';
15
+ export { seenStatuses, markSeen, moved, onSeen, seenKey } from './seen.js';
15
16
  export * from './detail.js';
16
17
  export { refreshIdentity } from './runtime.js';
@@ -7,6 +7,7 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
7
7
  import { useCallback, useEffect, useRef, useState } from 'react';
8
8
  import { SuggestionDetail } from './detail.js';
9
9
  import { useRuntime, useTick } from './runtime.js';
10
+ import { moved, onSeen } from './seen.js';
10
11
  import { dropCache, isRefusal, mineCacheKey, pruneOnes, readCache, useClearOnSignOut, writeCache } from './view-cache.js';
11
12
  import { Toasts } from './toast.js';
12
13
  import { statusMeaning, statusWords, stringsFor } from './words.js';
@@ -19,6 +20,8 @@ export function MySuggestions({ adapters, onOpen }) {
19
20
  const [open, setOpen] = useState(null);
20
21
  const [bad, setBad] = useState('');
21
22
  const [updating, setUpdating] = useState(false);
23
+ // Reports that moved to a telling status since the person last opened them: their rows say New.
24
+ const [fresh, setFresh] = useState(() => new Set());
22
25
  // Another account (or none) shows nothing of the last one's rows.
23
26
  const loads = useRef(0);
24
27
  // What the app last said (or the phone kept of it) for this account; null until either is known.
@@ -26,7 +29,16 @@ export function MySuggestions({ adapters, onOpen }) {
26
29
  // The first answer after mount ends "Updating"; later periodic reloads refresh quietly.
27
30
  const answered = useRef(false);
28
31
  useClearOnSignOut(rt, who, adapters.words.app);
29
- useEffect(() => { loads.current++; known.current = null; answered.current = false; setRows(null); setOpen(null); setBad(''); setUpdating(false); }, [rt]);
32
+ useEffect(() => { loads.current++; known.current = null; answered.current = false; setRows(null); setOpen(null); setBad(''); setUpdating(false); setFresh(new Set()); }, [rt]);
33
+ const recount = useCallback(async () => {
34
+ if (!rt || !known.current)
35
+ return;
36
+ const mine_ = loads.current;
37
+ const m = await moved(rt.store, { app: adapters.words.app, account: rt.who.account }, known.current);
38
+ if (mine_ === loads.current)
39
+ setFresh(new Set(m.map((x) => x.id)));
40
+ }, [adapters.words.app, rt]);
41
+ useEffect(() => onSeen(() => { void recount(); }), [recount]);
30
42
  const load = useCallback(async () => {
31
43
  if (!rt)
32
44
  return;
@@ -85,6 +97,7 @@ export function MySuggestions({ adapters, onOpen }) {
85
97
  void pruneOnes(rt.store, { app: adapters.words.app, account: rt.who.account }, theirs.map((t) => t.id));
86
98
  setBad('');
87
99
  paint(theirs, local);
100
+ void recount();
88
101
  }
89
102
  else if (refused) {
90
103
  // The app said no: what the phone held is no longer this person's to see.
@@ -101,7 +114,7 @@ export function MySuggestions({ adapters, onOpen }) {
101
114
  setBad(S.couldNotLoadMine);
102
115
  paint([], local);
103
116
  }
104
- }, [adapters, rt, S.couldNotLoadMine, S.offlineShowingSaved]);
117
+ }, [adapters, rt, recount, S.couldNotLoadMine, S.offlineShowingSaved]);
105
118
  useEffect(() => { void load(); }, [load, tick]);
106
119
  if (who === null)
107
120
  return _jsx("p", { role: "alert", children: S.signedOutMine });
@@ -110,6 +123,6 @@ export function MySuggestions({ adapters, onOpen }) {
110
123
  }
111
124
  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) => {
112
125
  const preview = typeof r.words === 'string' ? r.words : typeof r.title === 'string' ? r.title : '';
113
- 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: [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));
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));
114
127
  }) })), _jsx(Toasts, {})] }));
115
128
  }
@@ -0,0 +1,24 @@
1
+ import type { Store } from './idb.ts';
2
+ type Id = {
3
+ app: string;
4
+ account: string;
5
+ };
6
+ export type Seen = Record<string, string>;
7
+ export declare const seenKey: (id: Id) => string;
8
+ /** Called whenever something is marked seen, so a dot drawn elsewhere can clear. */
9
+ export declare function onSeen(fn: () => void): () => void;
10
+ export declare function seenStatuses(store: Store, id: Id): Promise<Seen>;
11
+ /** The person has looked at this report at this status. */
12
+ export declare function markSeen(store: Store, id: Id, reportId: string, status: string): Promise<void>;
13
+ /** The rows that moved to a telling status since the person last looked, in the order given. A report seen for the first
14
+ * time is recorded silently (so an app taking this release does not announce every old report), and so is a move to a
15
+ * quiet status (the planning steps); a telling move stays unrecorded until the person opens the report. */
16
+ export declare function moved(store: Store, id: Id, rows: {
17
+ id: string;
18
+ status: string;
19
+ }[]): Promise<{
20
+ id: string;
21
+ status: string;
22
+ }[]>;
23
+ export declare function firstToastThisOpen(id: Id): boolean;
24
+ export {};
@@ -0,0 +1,72 @@
1
+ 'use client';
2
+ // WHAT THE PERSON LAST LOOKED AT. Per app and account, the status each of their reports had the last time they
3
+ // opened it. A report whose status has since moved to one they should hear about (`TOLD`: the work starting, and
4
+ // every end) is "moved": the entry shows a dot, My suggestions marks the row New, and one toast says it. Opening
5
+ // the report marks it seen. The record sits beside the cached views, under the same app-and-account key, so another
6
+ // account never reads it and signing out clears it with them (clearViewCache).
7
+ import { TOLD } from '../contract.mjs';
8
+ import { draftKey } from './drafts.js';
9
+ import { readCache, writeCache } from './view-cache.js';
10
+ export const seenKey = (id) => `${draftKey(id)}#view:seen`;
11
+ const listeners = new Set();
12
+ /** Called whenever something is marked seen, so a dot drawn elsewhere can clear. */
13
+ export function onSeen(fn) { listeners.add(fn); return () => { listeners.delete(fn); }; }
14
+ // One write at a time per key, so two screens recording at once never drop each other's entries.
15
+ const chains = new Map();
16
+ function serial(key, job) {
17
+ const next = (chains.get(key) ?? Promise.resolve()).then(job, job);
18
+ chains.set(key, next.catch(() => { }));
19
+ return next;
20
+ }
21
+ export async function seenStatuses(store, id) {
22
+ const v = await readCache(store, seenKey(id));
23
+ return v && typeof v === 'object' ? { ...v } : {};
24
+ }
25
+ /** The person has looked at this report at this status. */
26
+ export function markSeen(store, id, reportId, status) {
27
+ return serial(seenKey(id), async () => {
28
+ const seen = await seenStatuses(store, id);
29
+ if (seen[reportId] === status)
30
+ return;
31
+ seen[reportId] = status;
32
+ await writeCache(store, seenKey(id), seen);
33
+ for (const l of listeners)
34
+ l();
35
+ });
36
+ }
37
+ /** The rows that moved to a telling status since the person last looked, in the order given. A report seen for the first
38
+ * time is recorded silently (so an app taking this release does not announce every old report), and so is a move to a
39
+ * quiet status (the planning steps); a telling move stays unrecorded until the person opens the report. */
40
+ export function moved(store, id, rows) {
41
+ return serial(seenKey(id), async () => {
42
+ const seen = await seenStatuses(store, id);
43
+ const out = [];
44
+ let changed = false;
45
+ for (const r of rows) {
46
+ if (typeof r?.id !== 'string' || typeof r.status !== 'string' || r.status === 'on-phone')
47
+ continue;
48
+ const had = seen[r.id];
49
+ if (had === r.status)
50
+ continue;
51
+ if (had === undefined || !TOLD.includes(r.status)) {
52
+ seen[r.id] = r.status;
53
+ changed = true;
54
+ continue;
55
+ }
56
+ out.push({ id: r.id, status: r.status });
57
+ }
58
+ if (changed)
59
+ await writeCache(store, seenKey(id), seen);
60
+ return out;
61
+ });
62
+ }
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. */
65
+ const toasted = new Set();
66
+ export function firstToastThisOpen(id) {
67
+ const k = seenKey(id);
68
+ if (toasted.has(k))
69
+ return false;
70
+ toasted.add(k);
71
+ return true;
72
+ }
@@ -90,6 +90,12 @@ export interface Strings {
90
90
  addFailed: string;
91
91
  overLimit: string;
92
92
  stepLabel: string;
93
+ /** On a row in My suggestions that moved since the person last opened it. */
94
+ newMark: string;
95
+ /** Read by a screen reader on the entry when a suggestion has moved (the dot itself is decoration). */
96
+ entryMoved: string;
97
+ /** The one toast per open. `words` is the status's own word (wordsFor); `note` is the not-now reason, when there is one. */
98
+ movedToast: (words: string, note?: string) => string;
93
99
  }
94
100
  export declare const DEFAULT_STRINGS: Strings;
95
101
  export declare function stringsFor(words: Words): Strings;
@@ -84,6 +84,9 @@ export const DEFAULT_STRINGS = {
84
84
  addFailed: 'That could not be added. Try again.',
85
85
  overLimit: 'Something in this suggestion is over a limit. Remove an attachment and try again.',
86
86
  stepLabel: 'Where it is',
87
+ newMark: 'New',
88
+ entryMoved: 'One of your suggestions has news',
89
+ movedToast: (words, note) => (note ? `Your suggestion: ${words}. ${note}` : `Your suggestion: ${words}`),
87
90
  };
88
91
  export function stringsFor(words) {
89
92
  return { ...DEFAULT_STRINGS, ...words.text };
@@ -12,6 +12,7 @@ export declare const CEILINGS: {
12
12
  };
13
13
  export declare const STATUSES: string[];
14
14
  export declare const END: string[];
15
+ export declare const TOLD: string[];
15
16
  export declare const FLOW: {
16
17
  "on-phone": {
17
18
  actor: string;
package/dist/contract.mjs CHANGED
@@ -13,6 +13,8 @@ export const CEILINGS = {
13
13
  };
14
14
  export const STATUSES = ["on-phone", "received", "with-freeda", "planned", "waiting-on-owner", "being-made", "live", "sent-to-template", "filed", "not-now"];
15
15
  export const END = ["live", "sent-to-template", "filed", "not-now"];
16
+ // The moves the person who suggested a change hears about: the work starting, and every end. The planning steps stay quiet.
17
+ export const TOLD = ["being-made", ...END];
16
18
  // actor: who PERFORMS this step (design section 7): sent by the person, received by the app, with Pluma by the Mac. A Helm on the owner's Mac posts straight into
17
19
  // the inbox, so a report may go from the phone to Pluma without the hosted app's "received". The owner may decline
18
20
  // ("not-now") before Pluma has a plan or before she asks: a report she cannot plan would otherwise have no exit.
@@ -1,3 +1,4 @@
1
+ import { TOLD } from '../contract.mjs';
1
2
  import { type Limits } from './rate.ts';
2
3
  /** `by` is the actor (contract FLOW). `holder` is the maintainer whose Mac started or ended the work, written on a being-made
3
4
  * or end entry: the approval on such a report never lapses, and it is how the list and the claim name who has it. */
@@ -131,10 +132,24 @@ export interface HostedStore {
131
132
  expiresAt: number;
132
133
  }>;
133
134
  }
135
+ /** `status` events also carry the report's member and its new status. */
134
136
  export interface TellEvent {
135
137
  type: 'submitted' | 'followed-up' | 'status' | 'settled';
136
138
  reportId: string;
137
139
  member?: string;
140
+ status?: string;
141
+ }
142
+ export { TOLD };
143
+ /** What `tellSubmitter` is handed. `words` and `meaning` are the module's own words for the status (`wordsFor`/`meaningFor`),
144
+ * never the report's text, so a lock screen never shows what someone wrote. `note` is the one-line reason, on `not-now` only. */
145
+ export interface SubmitterNews {
146
+ member: string;
147
+ reportId: string;
148
+ status: string;
149
+ words: string;
150
+ meaning: string;
151
+ ended: boolean;
152
+ note?: string;
138
153
  }
139
154
  export interface ReportHandlerOptions {
140
155
  store: HostedStore;
@@ -153,6 +168,13 @@ export interface ReportHandlerOptions {
153
168
  maintainersSince?: Record<string, number | string>;
154
169
  /** Told when something happens, with ids and never words. A failure here never fails the request. */
155
170
  tell?(event: TellEvent): void | Promise<void>;
171
+ /** REQUIRED: tells the person who suggested a change that it is being made or has ended, on the app's own channel (push,
172
+ * email, an activity row). Called once per telling move (`TOLD`), never on a repeat, never when the store refused the move,
173
+ * and never to a maintainer about their own report being made. A failure never fails the move. An app that genuinely
174
+ * has nobody to tell passes `{ none: "<reason>" }`; leaving it out throws at startup. */
175
+ tellSubmitter: ((news: SubmitterNews) => void | Promise<void>) | {
176
+ none: string;
177
+ };
156
178
  now?: () => number;
157
179
  }
158
180
  export declare const LEASE_MS: number;
@@ -7,7 +7,7 @@
7
7
  // a report is decided here from the signed-in member and the explicit maintainers list, never from the
8
8
  // request. Nothing is uploaded through this server: `uploadUrl` issues a direct-to-storage link with the
9
9
  // size and type fixed, and `finalize` checks what actually landed.
10
- import { END, FLOW, STATUSES, isAcceptedMedia, isReportId, mayMove, validateReport } from '../contract.mjs';
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
13
  /** What a collector saw of a report: its verified files and its follow-ups. A store computes this from its own copy,
@@ -17,6 +17,7 @@ export function reportRevision(r) {
17
17
  const ups = (r.followUps ?? []).map((f) => f.followId).sort();
18
18
  return `f:${files.join(',')}|u:${ups.join(',')}`;
19
19
  }
20
+ export { TOLD };
20
21
  export const LEASE_MS = 10 * 60_000;
21
22
  const WORDS_MAX = 20_000;
22
23
  // Sized from the field limits: words and the three answers at 4 bytes a character (any script), plus the rest.
@@ -100,6 +101,15 @@ function declaredFile(r, file) {
100
101
  }
101
102
  export function createReportHandlers(opts) {
102
103
  const { store, identity, agent } = opts;
104
+ const hook = opts.tellSubmitter;
105
+ if (typeof hook !== 'function') {
106
+ const reason = hook && typeof hook === 'object' ? hook.none : undefined;
107
+ if (hook === undefined || hook === null)
108
+ throw new Error('createReportHandlers needs tellSubmitter: a function that tells the person who suggested a change when it is being made and when it ends, or { none: "<reason>" } when the app has nobody to tell');
109
+ if (typeof reason !== 'string' || !reason.trim())
110
+ throw new Error('tellSubmitter { none } needs a reason: say why this app tells nobody, as { none: "<reason>" }');
111
+ }
112
+ const tellSubmitter = typeof hook === 'function' ? hook : null;
103
113
  const maintainers = [...opts.maintainers];
104
114
  const limits = effectiveLimits(opts.limits);
105
115
  const contractCeilings = { images: limits.images, imageBytes: limits.imageBytes, videos: limits.videos, videoBytes: limits.videoBytes, videoMs: limits.videoMs, voiceMs: limits.voiceMs, voiceTotalMs: limits.voiceTotalMs, perPersonPerDay: limits.perPersonPerDay };
@@ -109,6 +119,11 @@ export function createReportHandlers(opts) {
109
119
  await opts.tell?.(e);
110
120
  }
111
121
  catch { /* telling is best effort */ } };
122
+ const owner = typeof opts.app?.owner === 'string' && opts.app.owner ? opts.app.owner : 'the owner';
123
+ const news = async (n) => { try {
124
+ await tellSubmitter?.(n);
125
+ }
126
+ catch { /* telling is best effort */ } };
112
127
  const isMaintainer = (m) => maintainers.includes(m);
113
128
  const roleOf = (m) => (isMaintainer(m) ? 'maintainer' : 'member');
114
129
  const wrongMethod = (req, m) => (req.method === m ? null : json(405, { error: 'method not allowed' }));
@@ -259,7 +274,9 @@ export function createReportHandlers(opts) {
259
274
  // Filtered again here: a store that over-returns still cannot show another member's report.
260
275
  const rows = (await store.listFor(member)).filter((r) => r.who?.member === member);
261
276
  rows.sort((a, b) => (a.receivedAt < b.receivedAt ? 1 : -1));
262
- return json(200, rows.map((r) => ({ id: r.id, status: lastStatus(r), kind: r.kind, at: Date.parse(r.receivedAt) })));
277
+ // A declined report carries its one-line reason, which the member's own view already shows; no other note is listed.
278
+ const reason = (r) => { const t = r.timeline[r.timeline.length - 1]; return t?.status === 'not-now' && t.note ? { note: t.note } : {}; };
279
+ return json(200, rows.map((r) => ({ id: r.id, status: lastStatus(r), kind: r.kind, at: Date.parse(r.receivedAt), ...reason(r) })));
263
280
  }
264
281
  async function one(req, id) {
265
282
  const m = wrongMethod(req, 'GET');
@@ -572,7 +589,13 @@ export function createReportHandlers(opts) {
572
589
  await store.claimApproval(id, by, APPROVAL_LEASE_MS);
573
590
  return json(409, { error: 'the report changed while this was sent; read it again' });
574
591
  }
575
- await tell({ type: 'status', reportId: id });
592
+ await tell({ type: 'status', reportId: id, member: r.who?.member, status });
593
+ const member = r.who?.member;
594
+ // A maintainer is not told that the work they just started on their own report has started; an end is still news.
595
+ const ownStart = status === 'being-made' && member === by;
596
+ if (member && TOLD.includes(status) && !ownStart) {
597
+ await news({ member, reportId: id, status, words: wordsFor(status, { owner }), meaning: meaningFor(status, { owner }), ended: END.includes(status), ...(status === 'not-now' && note ? { note } : {}) });
598
+ }
576
599
  return json(200, { id, status });
577
600
  }
578
601
  return { submit, uploadUrl, finalize, mine, one, append, agentList, agentFile, agentSettle, agentClaimApproval, agentReleaseApproval, agentStatus };
@@ -1,5 +1,5 @@
1
- export { createReportHandlers, reportRevision, 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
- export type { HostedStore, StoredReport, TimelineEntry, FileState, FollowUp, TellEvent, ReportHandlerOptions } from './handlers.ts';
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
+ export type { HostedStore, StoredReport, TimelineEntry, FileState, FollowUp, TellEvent, SubmitterNews, ReportHandlerOptions } from './handlers.ts';
3
3
  export { sniffType, sameType, canonicalType, ACCEPTED_TYPES } from './sniff.ts';
4
4
  export { effectiveLimits } from './rate.ts';
5
5
  export type { Limits } from './rate.ts';
@@ -1,5 +1,5 @@
1
1
  // "@supersuit/suggest-a-change/server": web-standard APIs only, no runtime dependency, nothing from react or the client.
2
- export { createReportHandlers, reportRevision, 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';
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
3
  export { sniffType, sameType, canonicalType, ACCEPTED_TYPES } from './sniff.js';
4
4
  export { effectiveLimits } from './rate.js';
5
5
  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.6",
3
+ "version": "0.1.8",
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": {
@@ -39,6 +39,7 @@ const stubFor = (app, owner) => `// ${STUB_SENTINEL}. These adapters sign nobody
39
39
  // Fill in the four below, then delete this line. "suggest-a-change-check --strict" fails while it is here.
40
40
  // The four things this app supplies to Suggest a change; the module owns everything else.
41
41
  import type { Adapters } from "${NAME}/client";
42
+ import type { ReportHandlerOptions } from "${NAME}/server";
42
43
 
43
44
  /** Who is signed in: { account, role } where role is "operator", "member" or "maintainer", or null. */
44
45
  export const identity: Adapters["identity"] = async () => null; // TODO: read this app's own session
@@ -57,6 +58,11 @@ export const words: Adapters["words"] = { app: ${JSON.stringify(app)}, owner: ${
57
58
 
58
59
  /** Attachment limits. May lower the module's ceilings, never raise them. */
59
60
  export const limits: Adapters["limits"] = {};
61
+
62
+ /** For the server half: createReportHandlers({ ..., tellSubmitter }) requires it. Tell e.member that their suggestion
63
+ * is being made or has ended, on this app's own channel (push, email, an activity row), using e.words and e.note,
64
+ * never the report's text. An app with nobody to tell passes { none: "<reason>" } instead. */
65
+ export const tellSubmitter: ReportHandlerOptions["tellSubmitter"] = async () => {}; // TODO: tell e.member e.words
60
66
  `
61
67
 
62
68
  const componentFor = (adaptersImport) => `"use client";
@@ -143,6 +149,12 @@ export function initApp({ repo, app, owner = 'Owner' }) {
143
149
  if (writeIfAbsent(join(root, 'tailor', 'suggest-a-change.ts'), stubFor(id, owner), log, 'tailor/suggest-a-change.ts'))
144
150
  log.notes.push('the adapters in tailor/suggest-a-change.ts are stubs: nobody is signed in and every report is refused, so the button opens nothing until you fill them in. Then delete the "suggest-a-change: STUB" line and use "suggest-a-change-check --strict" in your check script')
145
151
 
152
+ // The server half refuses to start without tellSubmitter, so say so wherever the adapters file does not carry one.
153
+ const tailor = join(root, 'tailor', 'suggest-a-change.ts')
154
+ const tailorText = existsSync(tailor) ? readFileSync(tailor, 'utf8') : ''
155
+ if (log.did.includes('wrote tailor/suggest-a-change.ts') || !/\btellSubmitter\b/.test(tailorText))
156
+ log.notes.push('the server half needs tellSubmitter: createReportHandlers({ ..., tellSubmitter }) throws at startup without it. Tell the person who suggested a change, on this app\'s own channel, when it is being made and when it ends (the stub in tailor/suggest-a-change.ts is where it goes), or pass tellSubmitter: { none: "<reason>" } when the app has nobody to tell')
157
+
146
158
  // 4. The component and the root layout.
147
159
  const compPath = join(root, dirname(appDir), 'components', 'suggest-a-change.tsx')
148
160
  const adaptersImport = dotted(posix(relative(dirname(compPath), join(root, 'tailor', 'suggest-a-change'))))
@@ -10,6 +10,7 @@ import { draftKey } from './drafts.ts'
10
10
  import { recoverTakes } from './recorder.ts'
11
11
  import { queueFollowUp, type Report } from './reports.ts'
12
12
  import { useRuntime, useTick, type FollowUp } from './runtime.ts'
13
+ import { markSeen } from './seen.ts'
13
14
  import { Toasts } from './toast.tsx'
14
15
  import { dropCache, isRefusal, oneCacheKey, readCache, useClearOnSignOut, writeCache } from './view-cache.ts'
15
16
  import { effectiveCeilings, lengthWords, sizeWords, statusMeaning, statusWords, stringsFor } from './words.ts'
@@ -137,7 +138,11 @@ export function SuggestionDetail({ adapters, id }: { adapters: Adapters; id: str
137
138
  if (!fresh()) return
138
139
  if (!now || now.account !== rt.who.account) return
139
140
  setUpdating(false)
140
- if (r) { shown.current = true; setOffline(false); setReport(r); void writeCache(rt.store, ckey, r) }
141
+ if (r) {
142
+ shown.current = true; setOffline(false); setReport(r); void writeCache(rt.store, ckey, r)
143
+ // The person has now seen it at this status: its New mark and the entry's dot clear.
144
+ void markSeen(rt.store, { app: adapters.words.app, account: rt.who.account }, id, statusOf(r))
145
+ }
141
146
  else if (refused) { shown.current = false; setOffline(false); void dropCache(rt.store, ckey); setReport(null) }
142
147
  else if (shown.current) setOffline(true)
143
148
  else setReport(null)
@@ -2,11 +2,14 @@
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
+ import { firstToastThisOpen, moved, onSeen } from './seen.ts'
8
9
  import { SuggestSheet } from './sheet.tsx'
9
- import { stringsFor } from './words.ts'
10
+ import { toast, Toasts } from './toast.tsx'
11
+ import { useClearOnSignOut } from './view-cache.ts'
12
+ import { statusWords, stringsFor } from './words.ts'
10
13
 
11
14
  /** Keeps the phone's outbox moving (on reconnect and every 15 seconds) for as long as it is mounted, with nothing drawn.
12
15
  * <SuggestEntry> already does this; a host with no always-mounted entry mounts this once, high in the app. */
@@ -15,11 +18,80 @@ export function KeepSending({ adapters }: { adapters: Adapters }) {
15
18
  return null
16
19
  }
17
20
 
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 } {
26
+ const S = stringsFor(adapters.words)
27
+ const [news, setNews] = useState(0)
28
+ const [toasted, setToasted] = useState(false)
29
+ useEffect(() => {
30
+ setNews(0)
31
+ if (!rt) return
32
+ let live = true
33
+ const id = { app: adapters.words.app, account: rt.who.account }
34
+ let rows: { id: string; status: string; note?: unknown; at?: number }[] | null = null
35
+ const count = async () => {
36
+ if (!rows) return
37
+ const m = await moved(rt.store, id, rows)
38
+ if (live) setNews(m.length)
39
+ return m
40
+ }
41
+ void (async () => {
42
+ try { rows = await adapters.store.mine() } catch { rows = null }
43
+ let now: { account: string } | null = null
44
+ try { now = await adapters.identity() } catch { now = null }
45
+ if (!live || !rows || !now || now.account !== rt.who.account) return
46
+ const m = await count()
47
+ if (!live || !m?.length || !firstToastThisOpen(id)) return
48
+ const at = (x: string) => rows!.find((r) => r.id === x)?.at ?? 0
49
+ const top = [...m].sort((a, b) => at(b.id) - at(a.id))[0]
50
+ const note = top.status === 'not-now' ? rows.find((r) => r.id === top.id)?.note : undefined
51
+ setToasted(true)
52
+ toast({ id: `sac-moved-${top.id}`, text: S.movedToast(statusWords(top.status, adapters.words.owner), typeof note === 'string' && note ? note : undefined), ms: 6000 })
53
+ })()
54
+ const off = onSeen(() => { void count() })
55
+ return () => { live = false; off() }
56
+ // eslint-disable-next-line react-hooks/exhaustive-deps
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
+
18
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 }) {
19
87
  const S = stringsFor(adapters.words)
20
88
  const [why, setWhy] = useState('')
21
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)
22
93
  useEffect(() => { setWhy('') }, [adapters])
94
+ useClearOnSignOut(rt, who, adapters.words.app)
23
95
  async function press() {
24
96
  setBusy(true)
25
97
  let who = null
@@ -31,9 +103,12 @@ export function SuggestEntry({ adapters, onOpen, className, role }: { adapters:
31
103
  }
32
104
  return (
33
105
  <span className="sac-entry-wrap">
34
- <KeepSending adapters={adapters} />
35
- <button type="button" role={role} data-suggest-entry="" className={`sac-entry${className ? ` ${className}` : ''}`} aria-busy={busy || undefined} onClick={() => void press()}>{S.entry}</button>
106
+ <button type="button" role={role} data-suggest-entry="" className={`sac-entry${className ? ` ${className}` : ''}`} aria-busy={busy || undefined} onClick={() => void press()}>
107
+ {S.entry}
108
+ <SuggestDot adapters={adapters} moved={news} />
109
+ </button>
36
110
  {why ? <span role="status" className="sac-entry-why">{why}</span> : null}
111
+ {toasting ? <Toasts /> : null}
37
112
  </span>
38
113
  )
39
114
  }
@@ -12,5 +12,6 @@ export * from './receipt.tsx'
12
12
  export * from './entry.tsx'
13
13
  export * from './sheet.tsx'
14
14
  export * from './mine.tsx'
15
+ export { seenStatuses, markSeen, moved, onSeen, seenKey, type Seen } from './seen.ts'
15
16
  export * from './detail.tsx'
16
17
  export { refreshIdentity } from './runtime.ts'
@@ -8,6 +8,7 @@ import type { Adapters } from './adapters.ts'
8
8
  import { SuggestionDetail } from './detail.tsx'
9
9
  import type { Summary } from './reports.ts'
10
10
  import { useRuntime, useTick } from './runtime.ts'
11
+ import { moved, onSeen } from './seen.ts'
11
12
  import { dropCache, isRefusal, mineCacheKey, pruneOnes, readCache, useClearOnSignOut, writeCache } from './view-cache.ts'
12
13
  import { Toasts } from './toast.tsx'
13
14
  import { statusMeaning, statusWords, stringsFor } from './words.ts'
@@ -23,6 +24,8 @@ export function MySuggestions({ adapters, onOpen }: { adapters: Adapters; onOpen
23
24
  const [open, setOpen] = useState<string | null>(null)
24
25
  const [bad, setBad] = useState('')
25
26
  const [updating, setUpdating] = useState(false)
27
+ // Reports that moved to a telling status since the person last opened them: their rows say New.
28
+ const [fresh, setFresh] = useState<Set<string>>(() => new Set())
26
29
 
27
30
  // Another account (or none) shows nothing of the last one's rows.
28
31
  const loads = useRef(0)
@@ -31,7 +34,15 @@ export function MySuggestions({ adapters, onOpen }: { adapters: Adapters; onOpen
31
34
  // The first answer after mount ends "Updating"; later periodic reloads refresh quietly.
32
35
  const answered = useRef(false)
33
36
  useClearOnSignOut(rt, who, adapters.words.app)
34
- useEffect(() => { loads.current++; known.current = null; answered.current = false; setRows(null); setOpen(null); setBad(''); setUpdating(false) }, [rt])
37
+ useEffect(() => { loads.current++; known.current = null; answered.current = false; setRows(null); setOpen(null); setBad(''); setUpdating(false); setFresh(new Set()) }, [rt])
38
+
39
+ const recount = useCallback(async () => {
40
+ if (!rt || !known.current) return
41
+ const mine_ = loads.current
42
+ const m = await moved(rt.store, { app: adapters.words.app, account: rt.who.account }, known.current)
43
+ if (mine_ === loads.current) setFresh(new Set(m.map((x) => x.id)))
44
+ }, [adapters.words.app, rt])
45
+ useEffect(() => onSeen(() => { void recount() }), [recount])
35
46
 
36
47
  const load = useCallback(async () => {
37
48
  if (!rt) return
@@ -73,6 +84,7 @@ export function MySuggestions({ adapters, onOpen }: { adapters: Adapters; onOpen
73
84
  void pruneOnes(rt.store, { app: adapters.words.app, account: rt.who.account }, theirs.map((t) => t.id))
74
85
  setBad('')
75
86
  paint(theirs, local)
87
+ void recount()
76
88
  } else if (refused) {
77
89
  // The app said no: what the phone held is no longer this person's to see.
78
90
  known.current = null
@@ -86,7 +98,7 @@ export function MySuggestions({ adapters, onOpen }: { adapters: Adapters; onOpen
86
98
  setBad(S.couldNotLoadMine)
87
99
  paint([], local)
88
100
  }
89
- }, [adapters, rt, S.couldNotLoadMine, S.offlineShowingSaved])
101
+ }, [adapters, rt, recount, S.couldNotLoadMine, S.offlineShowingSaved])
90
102
  useEffect(() => { void load() }, [load, tick])
91
103
 
92
104
  if (who === null) return <p role="alert">{S.signedOutMine}</p>
@@ -110,6 +122,7 @@ export function MySuggestions({ adapters, onOpen }: { adapters: Adapters; onOpen
110
122
  return (
111
123
  <li key={r.id} className="sac-row" data-status={r.status}>
112
124
  <button type="button" className="sac-row-button" onClick={() => (onOpen ? onOpen(r.id) : setOpen(r.id))}>
125
+ {fresh.has(r.id) ? <span className="sac-new" data-new="">{S.newMark}</span> : null}
113
126
  {preview ? <span className="sac-row-words">{preview}</span> : null}
114
127
  <span className="sac-row-status">{statusWords(r.status, owner)}</span>
115
128
  <span className="sac-muted">{statusMeaning(r.status, owner)}</span>
@@ -0,0 +1,72 @@
1
+ 'use client'
2
+ // WHAT THE PERSON LAST LOOKED AT. Per app and account, the status each of their reports had the last time they
3
+ // opened it. A report whose status has since moved to one they should hear about (`TOLD`: the work starting, and
4
+ // every end) is "moved": the entry shows a dot, My suggestions marks the row New, and one toast says it. Opening
5
+ // the report marks it seen. The record sits beside the cached views, under the same app-and-account key, so another
6
+ // account never reads it and signing out clears it with them (clearViewCache).
7
+ import { TOLD } from '../contract.mjs'
8
+ import { draftKey } from './drafts.ts'
9
+ import type { Store } from './idb.ts'
10
+ import { readCache, writeCache } from './view-cache.ts'
11
+
12
+ type Id = { app: string; account: string }
13
+ export type Seen = Record<string, string>
14
+ export const seenKey = (id: Id) => `${draftKey(id)}#view:seen`
15
+
16
+ const listeners = new Set<() => void>()
17
+ /** Called whenever something is marked seen, so a dot drawn elsewhere can clear. */
18
+ export function onSeen(fn: () => void): () => void { listeners.add(fn); return () => { listeners.delete(fn) } }
19
+
20
+ // One write at a time per key, so two screens recording at once never drop each other's entries.
21
+ const chains = new Map<string, Promise<unknown>>()
22
+ function serial<T>(key: string, job: () => Promise<T>): Promise<T> {
23
+ const next = (chains.get(key) ?? Promise.resolve()).then(job, job)
24
+ chains.set(key, next.catch(() => {}))
25
+ return next
26
+ }
27
+
28
+ export async function seenStatuses(store: Store, id: Id): Promise<Seen> {
29
+ const v = await readCache<Seen>(store, seenKey(id))
30
+ return v && typeof v === 'object' ? { ...v } : {}
31
+ }
32
+
33
+ /** The person has looked at this report at this status. */
34
+ export function markSeen(store: Store, id: Id, reportId: string, status: string): Promise<void> {
35
+ return serial(seenKey(id), async () => {
36
+ const seen = await seenStatuses(store, id)
37
+ if (seen[reportId] === status) return
38
+ seen[reportId] = status
39
+ await writeCache(store, seenKey(id), seen)
40
+ for (const l of listeners) l()
41
+ })
42
+ }
43
+
44
+ /** The rows that moved to a telling status since the person last looked, in the order given. A report seen for the first
45
+ * time is recorded silently (so an app taking this release does not announce every old report), and so is a move to a
46
+ * quiet status (the planning steps); a telling move stays unrecorded until the person opens the report. */
47
+ export function moved(store: Store, id: Id, rows: { id: string; status: string }[]): Promise<{ id: string; status: string }[]> {
48
+ return serial(seenKey(id), async () => {
49
+ const seen = await seenStatuses(store, id)
50
+ const out: { id: string; status: string }[] = []
51
+ let changed = false
52
+ for (const r of rows) {
53
+ if (typeof r?.id !== 'string' || typeof r.status !== 'string' || r.status === 'on-phone') continue
54
+ const had = seen[r.id]
55
+ if (had === r.status) continue
56
+ if (had === undefined || !TOLD.includes(r.status)) { seen[r.id] = r.status; changed = true; continue }
57
+ out.push({ id: r.id, status: r.status })
58
+ }
59
+ if (changed) await writeCache(store, seenKey(id), seen)
60
+ return out
61
+ })
62
+ }
63
+
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. */
66
+ const toasted = new Set<string>()
67
+ export function firstToastThisOpen(id: Id): boolean {
68
+ const k = seenKey(id)
69
+ if (toasted.has(k)) return false
70
+ toasted.add(k)
71
+ return true
72
+ }
@@ -45,6 +45,9 @@
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, .sac-dot-host { position: relative; }
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
+ .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); }
48
51
  .sac-muted { color: var(--sac-muted); font-size: 0.9rem; }
49
52
  .sac-error { color: var(--sac-danger); margin: 0; }
50
53
  .sac-receipt-line { font-weight: 600; margin: 0; }
@@ -96,6 +96,12 @@ export interface Strings {
96
96
  addFailed: string
97
97
  overLimit: string
98
98
  stepLabel: string
99
+ /** On a row in My suggestions that moved since the person last opened it. */
100
+ newMark: string
101
+ /** Read by a screen reader on the entry when a suggestion has moved (the dot itself is decoration). */
102
+ entryMoved: string
103
+ /** The one toast per open. `words` is the status's own word (wordsFor); `note` is the not-now reason, when there is one. */
104
+ movedToast: (words: string, note?: string) => string
99
105
  }
100
106
 
101
107
  export const DEFAULT_STRINGS: Strings = {
@@ -179,6 +185,9 @@ export const DEFAULT_STRINGS: Strings = {
179
185
  addFailed: 'That could not be added. Try again.',
180
186
  overLimit: 'Something in this suggestion is over a limit. Remove an attachment and try again.',
181
187
  stepLabel: 'Where it is',
188
+ newMark: 'New',
189
+ entryMoved: 'One of your suggestions has news',
190
+ movedToast: (words, note) => (note ? `Your suggestion: ${words}. ${note}` : `Your suggestion: ${words}`),
182
191
  }
183
192
 
184
193
  export function stringsFor(words: Words): Strings {
package/src/contract.mjs CHANGED
@@ -16,6 +16,8 @@ export const CEILINGS = {
16
16
 
17
17
  export const STATUSES = ["on-phone", "received", "with-freeda", "planned", "waiting-on-owner", "being-made", "live", "sent-to-template", "filed", "not-now"];
18
18
  export const END = ["live", "sent-to-template", "filed", "not-now"];
19
+ // The moves the person who suggested a change hears about: the work starting, and every end. The planning steps stay quiet.
20
+ export const TOLD = ["being-made", ...END];
19
21
 
20
22
  // actor: who PERFORMS this step (design section 7): sent by the person, received by the app, with Pluma by the Mac. A Helm on the owner's Mac posts straight into
21
23
  // the inbox, so a report may go from the phone to Pluma without the hosted app's "received". The owner may decline
@@ -7,7 +7,7 @@
7
7
  // a report is decided here from the signed-in member and the explicit maintainers list, never from the
8
8
  // request. Nothing is uploaded through this server: `uploadUrl` issues a direct-to-storage link with the
9
9
  // size and type fixed, and `finalize` checks what actually landed.
10
- import { END, FLOW, STATUSES, isAcceptedMedia, isReportId, mayMove, validateReport } from '../contract.mjs'
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
13
 
@@ -82,7 +82,12 @@ export interface HostedStore {
82
82
  readUrl(a: { reportId: string; file: string; ttlMs: number }): Promise<{ url: string; expiresAt: number }>
83
83
  }
84
84
 
85
- export interface TellEvent { type: 'submitted' | 'followed-up' | 'status' | 'settled'; reportId: string; member?: string }
85
+ /** `status` events also carry the report's member and its new status. */
86
+ export interface TellEvent { type: 'submitted' | 'followed-up' | 'status' | 'settled'; reportId: string; member?: string; status?: string }
87
+ export { TOLD }
88
+ /** What `tellSubmitter` is handed. `words` and `meaning` are the module's own words for the status (`wordsFor`/`meaningFor`),
89
+ * never the report's text, so a lock screen never shows what someone wrote. `note` is the one-line reason, on `not-now` only. */
90
+ export interface SubmitterNews { member: string; reportId: string; status: string; words: string; meaning: string; ended: boolean; note?: string }
86
91
  export interface ReportHandlerOptions {
87
92
  store: HostedStore
88
93
  /** The signed-in member, or null. Anything but `account` is ignored: the role is decided here. */
@@ -98,6 +103,11 @@ export interface ReportHandlerOptions {
98
103
  maintainersSince?: Record<string, number | string>
99
104
  /** Told when something happens, with ids and never words. A failure here never fails the request. */
100
105
  tell?(event: TellEvent): void | Promise<void>
106
+ /** REQUIRED: tells the person who suggested a change that it is being made or has ended, on the app's own channel (push,
107
+ * email, an activity row). Called once per telling move (`TOLD`), never on a repeat, never when the store refused the move,
108
+ * and never to a maintainer about their own report being made. A failure never fails the move. An app that genuinely
109
+ * has nobody to tell passes `{ none: "<reason>" }`; leaving it out throws at startup. */
110
+ tellSubmitter: ((news: SubmitterNews) => void | Promise<void>) | { none: string }
101
111
  now?: () => number
102
112
  }
103
113
 
@@ -177,12 +187,21 @@ function declaredFile(r: StoredReport, file: string): Declared | null {
177
187
 
178
188
  export function createReportHandlers(opts: ReportHandlerOptions) {
179
189
  const { store, identity, agent } = opts
190
+ const hook = opts.tellSubmitter as unknown
191
+ if (typeof hook !== 'function') {
192
+ const reason = hook && typeof hook === 'object' ? (hook as { none?: unknown }).none : undefined
193
+ if (hook === undefined || hook === null) throw new Error('createReportHandlers needs tellSubmitter: a function that tells the person who suggested a change when it is being made and when it ends, or { none: "<reason>" } when the app has nobody to tell')
194
+ if (typeof reason !== 'string' || !reason.trim()) throw new Error('tellSubmitter { none } needs a reason: say why this app tells nobody, as { none: "<reason>" }')
195
+ }
196
+ const tellSubmitter = typeof hook === 'function' ? (hook as (n: SubmitterNews) => void | Promise<void>) : null
180
197
  const maintainers = [...opts.maintainers]
181
198
  const limits = effectiveLimits(opts.limits)
182
199
  const contractCeilings = { images: limits.images, imageBytes: limits.imageBytes, videos: limits.videos, videoBytes: limits.videoBytes, videoMs: limits.videoMs, voiceMs: limits.voiceMs, voiceTotalMs: limits.voiceTotalMs, perPersonPerDay: limits.perPersonPerDay }
183
200
  const now = opts.now ?? (() => Date.now())
184
201
  const since = (m: string) => { const v = opts.maintainersSince?.[m]; const t = typeof v === 'string' ? Date.parse(v) : v; return t !== undefined && Number.isFinite(t) ? t : undefined }
185
202
  const tell = async (e: TellEvent) => { try { await opts.tell?.(e) } catch { /* telling is best effort */ } }
203
+ const owner = typeof opts.app?.owner === 'string' && opts.app.owner ? opts.app.owner : 'the owner'
204
+ const news = async (n: SubmitterNews) => { try { await tellSubmitter?.(n) } catch { /* telling is best effort */ } }
186
205
  const isMaintainer = (m: string) => maintainers.includes(m)
187
206
  const roleOf = (m: string) => (isMaintainer(m) ? 'maintainer' : 'member')
188
207
 
@@ -296,7 +315,9 @@ export function createReportHandlers(opts: ReportHandlerOptions) {
296
315
  // Filtered again here: a store that over-returns still cannot show another member's report.
297
316
  const rows = (await store.listFor(member)).filter((r) => r.who?.member === member)
298
317
  rows.sort((a, b) => (a.receivedAt < b.receivedAt ? 1 : -1))
299
- return json(200, rows.map((r) => ({ id: r.id, status: lastStatus(r), kind: r.kind, at: Date.parse(r.receivedAt) })))
318
+ // A declined report carries its one-line reason, which the member's own view already shows; no other note is listed.
319
+ const reason = (r: StoredReport) => { const t = r.timeline[r.timeline.length - 1]; return t?.status === 'not-now' && t.note ? { note: t.note } : {} }
320
+ return json(200, rows.map((r) => ({ id: r.id, status: lastStatus(r), kind: r.kind, at: Date.parse(r.receivedAt), ...reason(r) })))
300
321
  }
301
322
 
302
323
  async function one(req: Request, id: string): Promise<Response> {
@@ -510,7 +531,13 @@ export function createReportHandlers(opts: ReportHandlerOptions) {
510
531
  if (needsApproval && from !== 'being-made' && PAST_ASKING.includes(status)) await store.claimApproval(id, by, APPROVAL_LEASE_MS)
511
532
  return json(409, { error: 'the report changed while this was sent; read it again' })
512
533
  }
513
- await tell({ type: 'status', reportId: id })
534
+ await tell({ type: 'status', reportId: id, member: r.who?.member, status })
535
+ const member = r.who?.member
536
+ // A maintainer is not told that the work they just started on their own report has started; an end is still news.
537
+ const ownStart = status === 'being-made' && member === by
538
+ if (member && TOLD.includes(status) && !ownStart) {
539
+ await news({ member, reportId: id, status, words: wordsFor(status, { owner }), meaning: meaningFor(status, { owner }), ended: END.includes(status), ...(status === 'not-now' && note ? { note } : {}) })
540
+ }
514
541
  return json(200, { id, status })
515
542
  }
516
543
 
@@ -1,6 +1,6 @@
1
1
  // "@supersuit/suggest-a-change/server": web-standard APIs only, no runtime dependency, nothing from react or the client.
2
- export { createReportHandlers, reportRevision, 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
- export type { HostedStore, StoredReport, TimelineEntry, FileState, FollowUp, TellEvent, ReportHandlerOptions } from './handlers.ts'
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
+ export type { HostedStore, StoredReport, TimelineEntry, FileState, FollowUp, TellEvent, SubmitterNews, ReportHandlerOptions } from './handlers.ts'
4
4
  export { sniffType, sameType, canonicalType, ACCEPTED_TYPES } from './sniff.ts'
5
5
  export { effectiveLimits } from './rate.ts'
6
6
  export type { Limits } from './rate.ts'