@supersuit/artifacts 0.39.0 → 0.41.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +111 -0
- package/README.md +99 -9
- package/lib/artifacts/comments.d.ts +9 -0
- package/lib/artifacts/comments.js +6 -0
- package/lib/artifacts/front-matter.d.ts +3 -0
- package/lib/artifacts/front-matter.js +8 -1
- package/lib/artifacts/index.d.ts +1 -1
- package/lib/artifacts/index.js +1 -1
- package/lib/artifacts/recipients-store.d.ts +15 -6
- package/lib/artifacts/recipients-store.js +29 -4
- package/lib/artifacts/recipients.d.ts +9 -0
- package/lib/artifacts/recipients.js +16 -0
- package/lib/artifacts/state-store.d.ts +5 -0
- package/lib/artifacts/state-store.js +5 -0
- package/lib/artifacts/store.d.ts +1 -0
- package/lib/artifacts/store.js +2 -0
- package/lib/brand/pack.d.ts +4 -0
- package/lib/reader/comment-card.d.ts +10 -2
- package/lib/reader/comment-card.js +15 -9
- package/lib/reader/comment-layer.d.ts +7 -1
- package/lib/reader/comment-layer.js +74 -15
- package/lib/reader/feedback-window.d.ts +13 -0
- package/lib/reader/feedback-window.js +39 -0
- package/lib/routes/artifacts.d.ts +8 -2
- package/lib/routes/artifacts.js +14 -10
- package/lib/routes/per-reader.d.ts +2 -0
- package/lib/routes/per-reader.js +1 -1
- package/lib/routes/recipients-routes.js +22 -4
- package/lib/routes/state-routes.d.ts +1 -0
- package/lib/routes/state-routes.js +132 -11
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,117 @@
|
|
|
3
3
|
`@supersuit/artifacts`. One entry per version, newest first. Each entry says what changed, how a
|
|
4
4
|
host can tell whether it is affected (DETECTOR), what a host does about it (REMEDY), and the tests.
|
|
5
5
|
|
|
6
|
+
## 0.41.0
|
|
7
|
+
|
|
8
|
+
**A reader who may not see a page's history is no longer told its version number.** The line under
|
|
9
|
+
the summary read "Version N · Updated <minute>" to everyone. A page sent to someone reads as a
|
|
10
|
+
message, and "Version 9" on a message tells its reader about eight drafts they were never meant to
|
|
11
|
+
think about. The number now goes with the history: whoever `history` lets see past versions
|
|
12
|
+
(the owner and `admins` by default, every reader under `history: 'everyone'`) sees
|
|
13
|
+
"Version N · Updated <minute>", and everyone else sees "Updated <minute>". There is no new config
|
|
14
|
+
key; the existing `history` setting is the toggle, and its default is off for readers.
|
|
15
|
+
|
|
16
|
+
- `CoverProps` gains `showVersion: boolean`, true exactly when this reader may see the number. The
|
|
17
|
+
`meta` node a Cover places already follows it. `version` is still handed over unchanged, so a
|
|
18
|
+
Cover that draws the number itself keeps drawing it until it reads `showVersion`.
|
|
19
|
+
- A past version's page (`/<id>/v/<n>`) is unchanged: only a reader who may see the history reaches
|
|
20
|
+
it, so it still names its version.
|
|
21
|
+
|
|
22
|
+
DETECTOR: a host whose pack has a `Cover` that renders `version`: `rg -n "version" <your packs>`.
|
|
23
|
+
Such a Cover still shows the number to every reader after this release, because the shell cannot
|
|
24
|
+
take a prop away from a pack. A host that relied on every reader seeing the version line and does
|
|
25
|
+
not set `history: 'everyone'` will find the number gone for signed-out readers.
|
|
26
|
+
|
|
27
|
+
REMEDY: in a Cover that draws the number, draw it only when `showVersion` is true. To show every
|
|
28
|
+
reader the number (and the history with it), set `history: 'everyone'`.
|
|
29
|
+
|
|
30
|
+
Tests: `src/routes/page-menu.test.tsx` (a visitor, a reader and a stranger get "Updated" with no
|
|
31
|
+
version number; an admin and `history: 'everyone'` get "Version 2 · Updated"),
|
|
32
|
+
`src/routes/extension-points.test.tsx` (a Cover is handed `showVersion` false for a reader and true
|
|
33
|
+
under `history: 'everyone'`), `src/routes/versions.test.tsx` and `src/routes/broadcast.test.tsx`
|
|
34
|
+
(the two tests that pinned the old line now pin the new one). All three new tests failed against
|
|
35
|
+
0.40.1.
|
|
36
|
+
|
|
37
|
+
## 0.40.1
|
|
38
|
+
|
|
39
|
+
**0.40.0 was tagged and never published: this is the first release carrying it.** The publish
|
|
40
|
+
workflow runs the tests before it publishes, and one test failed there, so nothing reached npm
|
|
41
|
+
under 0.40.0. Everything in the 0.40.0 entry below ships in this version, unchanged.
|
|
42
|
+
|
|
43
|
+
- The failing test was the comment card's, and the defect was in the tests, not the card: a name
|
|
44
|
+
one test saved was still remembered by the next wherever the runtime has a working
|
|
45
|
+
`localStorage` (Node 24, which the workflow uses) and gone where it does not (Node 25), so
|
|
46
|
+
"asks for a first name before sending" passed on one and failed on the other. Every card test
|
|
47
|
+
now starts with its own empty storage.
|
|
48
|
+
|
|
49
|
+
DETECTOR: `npm view @supersuit/artifacts versions --json` lists no `0.40.0`. A host pinned to
|
|
50
|
+
exactly `0.40.0` cannot install.
|
|
51
|
+
|
|
52
|
+
REMEDY: take `0.40.1`. There is no code difference from what 0.40.0 described.
|
|
53
|
+
|
|
54
|
+
Tests: `src/reader/comment-card.test.tsx` gives each test a fresh storage; the old file fails three
|
|
55
|
+
tests when storage persists between tests, and the new one passes either way.
|
|
56
|
+
|
|
57
|
+
## 0.40.0
|
|
58
|
+
|
|
59
|
+
**A per-reader page can be a feedback round: a window per reader, a name on every note where the
|
|
60
|
+
page asks for one, a reply the commenter can see.** Nothing changes for a page that uses none of it.
|
|
61
|
+
|
|
62
|
+
- A reader carries `closesAt`. The recipients route sets it (`add[].closes`), moves or clears it
|
|
63
|
+
(`closes: { rids, at }`), and reports it (`closesAt` in the listing and in `/views`). Past it the
|
|
64
|
+
state route refuses a new shared comment or an edit with `403 { closed: true }`; the page and the
|
|
65
|
+
reader's own comments stay readable, and deleting their own still works. `RecipientsStore.setCloses`
|
|
66
|
+
is optional, so a host's own store keeps working (the route answers 501 to a `closes` change
|
|
67
|
+
without it).
|
|
68
|
+
- The page draws it: whose draft, the publisher's note to that reader, and a live countdown, in
|
|
69
|
+
place of the `For <label>: <note>` line. A reader with no window sees what they saw before.
|
|
70
|
+
- `comment_name: required` (front matter, per-reader pages only): a group reader's shared comment
|
|
71
|
+
needs a typed first name (`400 { needsName: true }` without one); a reader on a named link is
|
|
72
|
+
never asked, in the card or the reply form, and a typed name is dropped.
|
|
73
|
+
- Under `comment_name: required`, and only there, **each browser on a group link is its own
|
|
74
|
+
reader**. Until now a group link was one reader (`r:<rid>`): everyone holding it saw every note
|
|
75
|
+
left through it as their own, could edit and delete them, and saw the publisher's reply to any of
|
|
76
|
+
them. With the line, a browser's notes are stored under `r:<rid>.<24 hex>`, which the server
|
|
77
|
+
derives (an HMAC keyed by the recipient secret) from the page, the link's verified rid and the
|
|
78
|
+
anonymous-reader cookie (`artifact_anon`) it sets with that browser's first note. A client cannot
|
|
79
|
+
name a key or work out another browser's, and the stored key is not the cookie. A browser that
|
|
80
|
+
clears its cookies is a new reader. A named link is unchanged, and so is a group link on a page
|
|
81
|
+
without the line (one shared reader, as in 0.39.0). The window, the feed's `reader.rid` and the
|
|
82
|
+
`/views` comments stay per link.
|
|
83
|
+
- `POST /api/artifacts/<id>/reply` (publish key): the publisher's one reply in a thread, optionally
|
|
84
|
+
pinned to the passage that changed. On a per-reader page only the thread's own writer is shown it
|
|
85
|
+
(under `comment_name: required`, only the browser that wrote it). It never reopens a resolved
|
|
86
|
+
thread under `open=1`.
|
|
87
|
+
- A `template: broadcast` page with comments on hands its comment layer the window and the name
|
|
88
|
+
rule, as a document does: a group reader is asked for a first name, and a closed window stops
|
|
89
|
+
offering Share. A broadcast draws no banner.
|
|
90
|
+
- An owner added with `closes` gets no window (the owner never has one).
|
|
91
|
+
- Feed rows gain `reader: { rid, label } | null`, `by` and `publisher`. No field is removed.
|
|
92
|
+
|
|
93
|
+
DETECTOR: on a page with at least one reader,
|
|
94
|
+
`curl -s -H "Authorization: Bearer $KEY" "$HOST/api/artifacts/<id>/recipients" | grep -c closesAt`
|
|
95
|
+
answers 0 on a host older than this (a page with no readers answers 0 on any version). For the
|
|
96
|
+
group link: a host is affected if it has sent one group link to people who should not read each
|
|
97
|
+
other's notes. `GET /api/artifacts/<id>/responses` shows it: rows in the `comments` slot whose
|
|
98
|
+
`reader` is the bare `r:<rid>` of a link `GET /recipients` lists with `group: true` were written as
|
|
99
|
+
the one shared reader.
|
|
100
|
+
|
|
101
|
+
REMEDY: take 0.40.0 and add the reply route file (README, "Replying as the publisher"). A host with
|
|
102
|
+
its own `RecipientsStore` adds `setCloses` to use feedback windows. A publisher that strips unknown
|
|
103
|
+
front matter keys lets `comment_name` through. To give the people on a group link their own notes,
|
|
104
|
+
republish the page with `comment_name: required`; the host needs nothing else (the cookie is the
|
|
105
|
+
one anonymous readers already get). Notes left through that link before the republish stay with
|
|
106
|
+
the publisher and are shown to no browser afterwards. Anything that matched a reader key to a link
|
|
107
|
+
with `key === 'r:' + rid` now reads the rid with `ridOfReaderKey(key)` instead, or it misses the
|
|
108
|
+
notes of a browser on a group link.
|
|
109
|
+
|
|
110
|
+
Tests: `recipients-store.test.ts`, `recipients-api.test.tsx`, `recipients.test.ts`,
|
|
111
|
+
`front-matter.test.ts`, `store.test.ts`, `comments.test.tsx` (closed window, names, reply
|
|
112
|
+
visibility, two browsers on one group link with and without the line), `comments-feed.test.tsx`,
|
|
113
|
+
`feedback-window.test.tsx`, `comment-card.test.tsx`, `comment-layer.test.tsx`,
|
|
114
|
+
`per-reader.test.tsx`, `broadcast-feedback.test.tsx`; `unchanged-default.test.tsx` and
|
|
115
|
+
`broadcast.test.tsx` unedited.
|
|
116
|
+
|
|
6
117
|
## 0.39.0
|
|
7
118
|
|
|
8
119
|
**A session demo's frame fits its player, and is wider than the reading column on a wide screen.**
|
package/README.md
CHANGED
|
@@ -194,7 +194,8 @@ before 0.31.0 has no `declared` and takes its file on its next publish, as every
|
|
|
194
194
|
|
|
195
195
|
## Version history
|
|
196
196
|
|
|
197
|
-
Every page
|
|
197
|
+
Every page says when it was last updated under the summary, and to its admins that line also names
|
|
198
|
+
the version: "Version 4 · Updated <minute>". The page
|
|
198
199
|
menu's **Version history** opens a panel listing every version newest first: its number, the minute it went live in
|
|
199
200
|
the reader's own time zone, and its change note. The panel is drawn over the page (a sheet on a
|
|
200
201
|
phone, a side panel on a desk), so opening it moves no text. Each past version is readable at
|
|
@@ -205,9 +206,15 @@ the same password or confidential door as the page itself. A version that does n
|
|
|
205
206
|
**Past versions are for admins by default (0.21.0).** An earlier draft can hold what the current
|
|
206
207
|
one was edited to remove, so only the page's admins see Version history in the page menu or open
|
|
207
208
|
`/<id>/v/<n>`: the reader signed in as `ownerEmail` or any address in `admins` (case does not
|
|
208
|
-
matter).
|
|
209
|
-
|
|
210
|
-
|
|
209
|
+
matter). A past version is a 404 to everyone else. With neither configured, nobody does.
|
|
210
|
+
`history: 'everyone'` in the config restores the old behaviour, where every reader who can open
|
|
211
|
+
the page reads its history.
|
|
212
|
+
|
|
213
|
+
**The version number goes with the history (0.41.0).** A page sent to someone reads as a message,
|
|
214
|
+
and "Version 9" on a message tells its reader about eight drafts. So whoever may not see past
|
|
215
|
+
versions sees only "Updated <minute>" under the summary; admins, and every reader under
|
|
216
|
+
`history: 'everyone'`, see "Version N · Updated <minute>". A pack's `Cover` is handed
|
|
217
|
+
`showVersion` alongside `version`, and draws the number itself only when it is true.
|
|
211
218
|
|
|
212
219
|
```ts
|
|
213
220
|
createArtifactRoutes({ ..., ownerEmail: 'owner@example.com', admins: ['editor@example.com'] })
|
|
@@ -253,6 +260,7 @@ summary: Three bets, what each costs, and what we stop doing to afford them.
|
|
|
253
260
|
| `definitions` | Terms defined inline (below). A republish without it removes them |
|
|
254
261
|
| `links` | `per-reader`: the page opens only through a link made for one reader (see Per-reader links). Sticky: a republish whose file leaves the line out changes nothing |
|
|
255
262
|
| `media` | `private`: the page's body media is kept private and served through short-lived signed URLs. Needs `links: per-reader`. Sticky like `links` |
|
|
263
|
+
| `comment_name` | `required`: on a per-reader page, a comment through a group link needs a typed first name, and each browser on a group link is its own reader (see Per-reader links). Needs `links: per-reader` |
|
|
256
264
|
| `change` | One line saying what this version changed (see Version history) |
|
|
257
265
|
| `template` | `document` (the default, the read-along reader) or `broadcast` (one message card sent to several people; see Broadcasts) |
|
|
258
266
|
|
|
@@ -748,6 +756,36 @@ type Ctx = { params: Promise<{ id: string }> }
|
|
|
748
756
|
export function POST(request: NextRequest, ctx: Ctx) { return artifacts.RESOLVE(request, ctx) }
|
|
749
757
|
```
|
|
750
758
|
|
|
759
|
+
|
|
760
|
+
### Replying as the publisher (0.40.0)
|
|
761
|
+
|
|
762
|
+
POST /api/artifacts/<id>/reply
|
|
763
|
+
Authorization: Bearer <publish key or publisher pass>
|
|
764
|
+
Content-Type: application/json
|
|
765
|
+
|
|
766
|
+
{ "entry": "<entryId>", "body": "Named the river in the second paragraph.", "by": "Pat", "quote": "the river Ash" }
|
|
767
|
+
|
|
768
|
+
200 { id, entry, parent, existing }
|
|
769
|
+
|
|
770
|
+
One reply per thread when calls do not overlap. `entry` may name a reply: the thread it belongs to
|
|
771
|
+
is answered. A second call for a thread already answered changes nothing and names the first reply
|
|
772
|
+
back with `existing: true`, so a caller that retries does not post twice. The check is a read and
|
|
773
|
+
then an append, so two overlapping calls can both post. `by` defaults to "the publisher". `quote`
|
|
774
|
+
(text on the page, at most 500 characters) pins the reply to the passage that changed, and the
|
|
775
|
+
thread then offers "Show what changed"; without it the reply sits where its thread does. On a
|
|
776
|
+
per-reader page the reply is shown to the reader whose comment started the thread and to no other
|
|
777
|
+
reader. It is allowed after that reader's window has closed. On a page that is not per-reader
|
|
778
|
+
and whose comments are owner-only (`comments_visible: owner`), `REPLY` still answers 200 and stores
|
|
779
|
+
the reply, but the commenter is not shown it: only the owner, the admins and the feed are. The feed lists it with
|
|
780
|
+
`publisher: true`, and a publisher reply never reopens a resolved thread under `open=1`. 404 for a
|
|
781
|
+
page or comment that is not there, 501 with no `state`.
|
|
782
|
+
|
|
783
|
+
// app/api/artifacts/[id]/reply/route.ts
|
|
784
|
+
import type { NextRequest } from 'next/server'
|
|
785
|
+
import { artifacts } from '@/lib/artifacts'
|
|
786
|
+
type Ctx = { params: Promise<{ id: string }> }
|
|
787
|
+
export function POST(request: NextRequest, ctx: Ctx) { return artifacts.REPLY(request, ctx) }
|
|
788
|
+
|
|
751
789
|
### Telling commenters their comments were resolved (`createResolveDigests`)
|
|
752
790
|
|
|
753
791
|
When threads are resolved, each signed-in reader whose comments were in them can get ONE email per
|
|
@@ -800,12 +838,12 @@ appear (the privacy test covers this handler like every other).
|
|
|
800
838
|
GET /api/comments?since=2026-09-28T12:00:00Z
|
|
801
839
|
Authorization: Bearer <publish key>
|
|
802
840
|
|
|
803
|
-
200 { now, cursor, more, comments: [ { artifactId, title, entryId, parent, name,
|
|
804
|
-
body, transcript, audioUrl, at, resolved, link } ] }
|
|
841
|
+
200 { now, cursor, more, comments: [ { artifactId, title, entryId, parent, name, reader, by, publisher,
|
|
842
|
+
quote, region, body, transcript, audioUrl, at, resolved, link } ] }
|
|
805
843
|
```
|
|
806
844
|
|
|
807
845
|
Oldest first, at most 200, strictly after `since` (an ISO 8601 time with a zone; anything else is
|
|
808
|
-
a 400). `name` is null for an anonymous reader; `quote` is the text the comment is pinned to, or
|
|
846
|
+
a 400). `name` is null for an anonymous reader; `reader` is `{ rid, label }` for a comment left through a per-reader link, else null; `by` is the name its writer typed, else null; `publisher` is true for a reply written through the reply route; `quote` is the text the comment is pinned to, or
|
|
809
847
|
null for a box (`region: true`); `parent` is the comment a reply answers; `audioUrl` is a signed
|
|
810
848
|
URL to the recording, when there is one and the host can sign it. `link` is the page with
|
|
811
849
|
`#comment-<entryId>`: opening it scrolls to the pin and opens its thread (a reply opens its
|
|
@@ -863,7 +901,7 @@ Routes (spread into your route files by `createArtifactRoutes`): `RECIPIENTS`, `
|
|
|
863
901
|
| Route | Who | What |
|
|
864
902
|
|---|---|---|
|
|
865
903
|
| `GET /api/artifacts/<id>/recipients` | publish key | Every reader, with the working link for each |
|
|
866
|
-
| `POST /api/artifacts/<id>/recipients` | publish key | Body `{ add?: [{ label, note?, group? }], revoke?: [rid] or '*', expires?: ISO time or null }`. `add` answers each new reader's link; `expires: null` is Keep |
|
|
904
|
+
| `POST /api/artifacts/<id>/recipients` | publish key | Body `{ add?: [{ label, note?, group?, closes? }], revoke?: [rid] or '*', expires?: ISO time or null, closes?: { rids: [rid] or '*', at: ISO time or null } }`. `add` answers each new reader's link; `expires: null` is Keep; `closes` sets, moves or clears a reader's feedback window |
|
|
867
905
|
| `GET /api/artifacts/<id>/views` | publish key | One line per reader: opened, came back, active seconds, plays, devices, comments. A page with no readers yet answers 200 with an empty list; a missing page answers 404 |
|
|
868
906
|
| `POST /api/artifacts/<id>/seen` | the page itself (public) | The reader's own signal, below |
|
|
869
907
|
|
|
@@ -879,6 +917,58 @@ A view is the page rendered in a real browser, visible, for at least 2 seconds.
|
|
|
879
917
|
|
|
880
918
|
`expires` sets the date the page, its media, its readers, their views and their comments go. `sweepExpired()` (call it daily from a cron route) deletes them and returns `{ pages, files, skipped, failed }`: pages removed, media files removed, ids it refused to delete (an expiry that is not a past ISO time, or a page republished without per-reader links), and ids that failed and will be retried by the next run. Your `assets` must provide `deletePage`, or every expired page lands in `failed` and nothing is deleted. With a private bucket configured it deletes from both buckets. A Keep that lands mid-sweep is honored. Deleting a page also removes every prior version of its body.
|
|
881
919
|
|
|
920
|
+
### A feedback window (0.40.0)
|
|
921
|
+
|
|
922
|
+
A reader may carry `closesAt`, the time their feedback window closes. It is per reader, set when
|
|
923
|
+
the reader is added (`add[].closes`) or afterwards (`closes: { rids, at }`, where `rids` is a list
|
|
924
|
+
of reader ids or `'*'` for every reader but the owner, and `at: null` clears the window). The owner
|
|
925
|
+
never has one: `closes` on an `add` entry with `owner: true` is dropped. It is
|
|
926
|
+
separate from the page's `expires`: a reader past their window still opens the page and reads
|
|
927
|
+
their own comments. A time in the past closes the window now, and a window that cannot be read as
|
|
928
|
+
a time counts as closed. `closes` answers `windowed`, the reader ids it changed, beside `added` and
|
|
929
|
+
`revoked`. A host with its own `RecipientsStore` needs `setCloses` for this; without it the route
|
|
930
|
+
answers 501 to a `closes` change.
|
|
931
|
+
|
|
932
|
+
Once the window is closed, a new shared comment or an edit is refused with
|
|
933
|
+
`403 { error: "Feedback on this draft is closed. Your note was not sent.", closed: true }`.
|
|
934
|
+
Deleting their own comment (`remove`) still works. `GET /state` answers
|
|
935
|
+
`feedback: { closesAt, closed }` for a reader who has a window, and `GET /recipients` and
|
|
936
|
+
`GET /views` carry each reader's `closesAt` (null when there is none).
|
|
937
|
+
|
|
938
|
+
The page says so. A reader with a window sees, above the title, "A draft from <owner>, for
|
|
939
|
+
<label>" (a group link names nobody), the note the publisher wrote for them, and how long is left,
|
|
940
|
+
counted in days from two days up, then hours from two hours, then minutes, and live while the page
|
|
941
|
+
stays open. Past it the line reads "Feedback is closed. You can still read the draft and your own
|
|
942
|
+
notes." A reader with no window sees the `For <label>: <note>` line as before. In a thread, a
|
|
943
|
+
comment of theirs that nobody has answered shows "Waiting for a reply." while the window exists.
|
|
944
|
+
|
|
945
|
+
### A name on every note: `comment_name: required` (0.40.0)
|
|
946
|
+
|
|
947
|
+
A per-reader page that says `comment_name: required` in its front matter puts a name on every
|
|
948
|
+
shared comment. A `group` reader (one link shared by several people) must type a first name:
|
|
949
|
+
without one the state route answers `400 { error: "Add your first name, so your note has a name on
|
|
950
|
+
it.", needsName: true }`, and the comment card, the reply form and a signed-in reader's card all
|
|
951
|
+
ask before they send. A reader on a named link is never asked, and a name sent for them is
|
|
952
|
+
dropped: the label is who they are. The line needs `links: per-reader` (a page without it is
|
|
953
|
+
refused), and a republish that leaves it out removes it. A page without the line takes names
|
|
954
|
+
exactly as before.
|
|
955
|
+
|
|
956
|
+
**Who a group link is.** Without `comment_name: required`, a group link is one shared reader:
|
|
957
|
+
everyone who opens it sees every note left through it as their own, can edit or delete any of
|
|
958
|
+
them, and sees the publisher's reply to any of them. Do not send one to people who should not read
|
|
959
|
+
each other's notes. With the line, each browser on a group link is its own reader: it sees, edits
|
|
960
|
+
and deletes only the notes written from that browser, and is shown only the publisher's replies to
|
|
961
|
+
those. The browser is known by the anonymous-reader cookie (`artifact_anon`, HttpOnly, a year), set
|
|
962
|
+
with its first note, and its notes are stored under a key the server derives from the link and that
|
|
963
|
+
cookie (`r:<rid>.<24 hex>`), never under the cookie itself; `ridOfReaderKey(key)` reads the
|
|
964
|
+
link's rid out of either shape. A browser that clears its cookies, or a
|
|
965
|
+
second device, is a new reader: the notes it left stay with the publisher and are no longer shown
|
|
966
|
+
to it. So does a reader who signs in on another page of the same host, since signing in clears the
|
|
967
|
+
anonymous cookie. Notes left through a group link before the page gained the line were written as
|
|
968
|
+
the one shared reader (`r:<rid>`) and are shown to no browser afterwards. A named link is one
|
|
969
|
+
reader either way. The feedback window, the feed's `reader: { rid, label }` and the `/views` line
|
|
970
|
+
stay per link: every browser on a group link shares them.
|
|
971
|
+
|
|
882
972
|
## Broadcasts (`template: broadcast`)
|
|
883
973
|
|
|
884
974
|
A broadcast is its own page kind, not a document with extra flags. It is one message sent to several people, each through their own link, and it renders as one card: the sender's words at the top, then the media in the order the words put it (a video with sound and controls, photos, a voice note's player, link cards), then that person's own note. None of the document's furniture: no table of contents, no version line, no read-along, and no page menu for a reader.
|
|
@@ -902,7 +992,7 @@ What the kind decides, so the file does not have to:
|
|
|
902
992
|
- **Per-reader links, always.** `links: per-reader` is implied. The kind is sticky like `links:`: a republish that leaves `template:` out stays a broadcast, and one that says `document` is refused (409). `access`, `password` and `comments_visible: readers` are refused in words.
|
|
903
993
|
- **Private media wherever there is media.** `media: private` is implied as soon as the body carries a stored private file or a local one (a remote URL alone stores nothing, so it does not count). A body that points at a local file (one the publisher will upload) must still say `media: private`, because that line is what makes the publisher upload it privately; without it the page is refused before anything is uploaded.
|
|
904
994
|
- **Expires 90 days after it is published** (`BROADCAST_TTL_MS`), set by `POST /api/artifacts` when the page has no date yet, and answered as `expireAt` in its response. A date set on the recipients route stands, a republish never moves it, and Keep (`expires: null`) holds.
|
|
905
|
-
- **Comments off** unless the file turns them on. **No narration player**, ever: a voice note is its own audio (`` draws a player). Write `voice: none` so the publisher does not record one.
|
|
995
|
+
- **Comments off** unless the file turns them on. With them on, a reader's feedback window and `comment_name: required` work as on a document: past the window the card stops offering Share, and a group reader is asked for a first name. A broadcast draws no banner, though: no "A draft from" line, no countdown and no "Feedback is closed" line above the card. **No narration player**, ever: a voice note is its own audio (`` draws a player). Write `voice: none` so the publisher does not record one.
|
|
906
996
|
- **Silent view tracking**: the quiet view beacon, as on every per-reader page.
|
|
907
997
|
|
|
908
998
|
Routes:
|
|
@@ -16,6 +16,12 @@ export declare function mergeCommentsState(state: StateConfig | undefined, comme
|
|
|
16
16
|
};
|
|
17
17
|
export declare const MAX_COMMENT_CHARS = 4000;
|
|
18
18
|
export declare const MAX_COMMENT_NAME_CHARS = 80;
|
|
19
|
+
/** What a reader is told when their feedback window has closed (0.40.0). The card shows it under the
|
|
20
|
+
* words they typed, which stay in the box. */
|
|
21
|
+
export declare const FEEDBACK_CLOSED = "Feedback on this draft is closed. Your note was not sent.";
|
|
22
|
+
/** What a reader on an open (group) link is told when a comment arrives with no name, on a page
|
|
23
|
+
* that says `comment_name: required` (0.40.0). */
|
|
24
|
+
export declare const NAME_NEEDED = "Add your first name, so your note has a name on it.";
|
|
19
25
|
export declare const COMMENT_TRANSCRIPTS: readonly ["browser", "host", "typed"];
|
|
20
26
|
/** One shared comment or personal note. `parent` makes it a reply to a top-level comment (one
|
|
21
27
|
* level only). `audio` and `transcript` are filled by voice memos; a memo may have an empty body. */
|
|
@@ -28,6 +34,9 @@ export type CommentValue = {
|
|
|
28
34
|
transcript?: (typeof COMMENT_TRANSCRIPTS)[number];
|
|
29
35
|
/** The name a reader typed so the owner can credit them. A signed-in reader's account name wins. */
|
|
30
36
|
by?: string;
|
|
37
|
+
/** On a reply only: the publisher wrote it, through the reply route with the publish key (0.40.0).
|
|
38
|
+
* Never set by a reader: `validateComment` refuses the key. */
|
|
39
|
+
publisher?: true;
|
|
31
40
|
/** On a thread's top-level comment only: the owner, an admin or the publisher marked the thread
|
|
32
41
|
* done (0.33.0). Written by the resolve route, never by a reader: `validateComment` refuses the
|
|
33
42
|
* key, and an edit keeps whatever the comment already carried. */
|
|
@@ -15,6 +15,12 @@ export function mergeCommentsState(state, comments, visible) {
|
|
|
15
15
|
}
|
|
16
16
|
export const MAX_COMMENT_CHARS = 4000;
|
|
17
17
|
export const MAX_COMMENT_NAME_CHARS = 80;
|
|
18
|
+
/** What a reader is told when their feedback window has closed (0.40.0). The card shows it under the
|
|
19
|
+
* words they typed, which stay in the box. */
|
|
20
|
+
export const FEEDBACK_CLOSED = 'Feedback on this draft is closed. Your note was not sent.';
|
|
21
|
+
/** What a reader on an open (group) link is told when a comment arrives with no name, on a page
|
|
22
|
+
* that says `comment_name: required` (0.40.0). */
|
|
23
|
+
export const NAME_NEEDED = 'Add your first name, so your note has a name on it.';
|
|
18
24
|
export const COMMENT_TRANSCRIPTS = ['browser', 'host', 'typed'];
|
|
19
25
|
export const MAX_RESOLVE_NOTE_CHARS = 280;
|
|
20
26
|
/** The resolve route's check on a note: absent, null or blank means no note; otherwise one line of
|
|
@@ -60,6 +60,9 @@ export type ArtifactMeta = {
|
|
|
60
60
|
comments?: CommentsMode;
|
|
61
61
|
/** Who sees shared comments: `owner` (the default) or every reader (`readers`). */
|
|
62
62
|
commentsVisible?: CommentsVisible;
|
|
63
|
+
/** `comment_name: required` (0.40.0): on a per-reader page, a comment through a group link must
|
|
64
|
+
* carry a typed first name, and a named link is never asked for one. Opt-in; content, like `toc:`. */
|
|
65
|
+
commentName?: 'required';
|
|
63
66
|
};
|
|
64
67
|
/** What a page is. A broadcast is its own kind, not a document with extra flags: it renders as a
|
|
65
68
|
* message card, opens only through per-reader links, keeps its media private, has comments off
|
|
@@ -19,7 +19,7 @@ const PRIVATE_REF_IN_BODY = /^private:/;
|
|
|
19
19
|
export const PACK_NAME = /^[a-z0-9][a-z0-9-]{0,39}$/;
|
|
20
20
|
/** The longest `to:` a page may carry: a name or two, never a sentence. */
|
|
21
21
|
export const MAX_TO_CHARS = 60;
|
|
22
|
-
const KNOWN = new Set(['title', 'summary', 'subtitle', 'to', 'template', 'audience', 'cover', 'id', 'voice', 'narration', 'timings', 'narrationHash', 'password', 'access', 'state', 'theme', 'pack', 'toc', 'definitions', 'change', 'comments', 'comments_visible', 'links', 'media']);
|
|
22
|
+
const KNOWN = new Set(['title', 'summary', 'subtitle', 'to', 'template', 'audience', 'cover', 'id', 'voice', 'narration', 'timings', 'narrationHash', 'password', 'access', 'state', 'theme', 'pack', 'toc', 'definitions', 'change', 'comments', 'comments_visible', 'links', 'media', 'comment_name']);
|
|
23
23
|
export function parseArtifactSource(text,
|
|
24
24
|
/** The kind a file that leaves `template:` out is read as: the stored page's kind on a
|
|
25
25
|
* republish, so a broadcast stays one (sticky, like `links:`). Default `document`. */
|
|
@@ -87,6 +87,11 @@ opts = {}) {
|
|
|
87
87
|
return { ok: false, error: 'media must be private' };
|
|
88
88
|
meta.privateMedia = true;
|
|
89
89
|
}
|
|
90
|
+
if (d.comment_name !== undefined && d.comment_name !== null) {
|
|
91
|
+
if (d.comment_name !== 'required')
|
|
92
|
+
return { ok: false, error: 'comment_name must be required' };
|
|
93
|
+
meta.commentName = 'required';
|
|
94
|
+
}
|
|
90
95
|
if (d.theme !== undefined) {
|
|
91
96
|
if (!THEME_MODES.includes(d.theme))
|
|
92
97
|
return { ok: false, error: `theme must be one of: ${THEME_MODES.join(', ')}` };
|
|
@@ -145,6 +150,8 @@ opts = {}) {
|
|
|
145
150
|
// People on one broadcast never see each other's comments, so a per-reader page cannot share them.
|
|
146
151
|
if (meta.links === 'per-reader' && meta.commentsVisible === 'readers')
|
|
147
152
|
return { ok: false, error: 'a per-reader page keeps each reader\'s comments to the owner: drop comments_visible: readers' };
|
|
153
|
+
if (meta.commentName && meta.links !== 'per-reader')
|
|
154
|
+
return { ok: false, error: 'comment_name: required needs links: per-reader' };
|
|
148
155
|
// Widgets declare their own slots, so a notes block (and a checklist with send) is merged into state: here, before the
|
|
149
156
|
// page is stored, and the state API and the republish shape checks see it like any slot.
|
|
150
157
|
// Errors name the line in the FILE: the body's lines are counted after the front matter's.
|
package/lib/artifacts/index.d.ts
CHANGED
|
@@ -24,6 +24,6 @@ export { deepgramTranscriber, openaiTranscriber, type Transcriber } from './tran
|
|
|
24
24
|
export { AUDIO_TYPES, audioExtFor, audioTypeFor, baseMime, MAX_AUDIO_BYTES, MAX_MEMO_MS, MEMO_NAME, commentAudioPath, isCommentAudioPath, personalAudioPath, isPersonalAudioPath } from './audio.js';
|
|
25
25
|
export { settleOnPublish, applySettingsPatch, withComments, declaredOf, passwordDigest, newPageKey, type DeclaredSettings, type LiveSettings, type SettingsPatch } from './settings.js';
|
|
26
26
|
export type { PageSettings, SettingsFact, AccessLevel } from '../reader/settings-sheet.js';
|
|
27
|
-
export { ridLink, parseRidParam, verifyRid, isUnfurlAgent, usableSecret, RECIPIENT_SECRET_MIN } from './recipients.js';
|
|
27
|
+
export { ridLink, parseRidParam, verifyRid, isUnfurlAgent, usableSecret, RECIPIENT_SECRET_MIN, ridOfReaderKey } from './recipients.js';
|
|
28
28
|
export { createRecipientsStore, memoryRecipientsStore, summarizeViews, type RecipientsStore, type Recipient, type RecipientsRecord, type ViewDoc, type RecipientSummary } from './recipients-store.js';
|
|
29
29
|
export { migrateMemosToPrivate, MEMO_KEY, type MemoMigration } from './migrate-memos.js';
|
package/lib/artifacts/index.js
CHANGED
|
@@ -24,6 +24,6 @@ export { deepgramTranscriber, openaiTranscriber } from './transcribe.js';
|
|
|
24
24
|
export { AUDIO_TYPES, audioExtFor, audioTypeFor, baseMime, MAX_AUDIO_BYTES, MAX_MEMO_MS, MEMO_NAME, commentAudioPath, isCommentAudioPath, personalAudioPath, isPersonalAudioPath } from './audio.js';
|
|
25
25
|
export { settleOnPublish, applySettingsPatch, withComments, declaredOf, passwordDigest, newPageKey } from './settings.js';
|
|
26
26
|
// Per-reader links: the host-facing surface only. The signing and token primitives stay internal.
|
|
27
|
-
export { ridLink, parseRidParam, verifyRid, isUnfurlAgent, usableSecret, RECIPIENT_SECRET_MIN } from './recipients.js';
|
|
27
|
+
export { ridLink, parseRidParam, verifyRid, isUnfurlAgent, usableSecret, RECIPIENT_SECRET_MIN, ridOfReaderKey } from './recipients.js';
|
|
28
28
|
export { createRecipientsStore, memoryRecipientsStore, summarizeViews } from './recipients-store.js';
|
|
29
29
|
export { migrateMemosToPrivate, MEMO_KEY } from './migrate-memos.js';
|
|
@@ -7,6 +7,7 @@ export type Recipient = {
|
|
|
7
7
|
group?: true;
|
|
8
8
|
addedAt: string;
|
|
9
9
|
revokedAt?: string;
|
|
10
|
+
closesAt?: string | null;
|
|
10
11
|
};
|
|
11
12
|
export type RecipientsRecord = {
|
|
12
13
|
recipients: Record<string, Recipient>;
|
|
@@ -29,6 +30,7 @@ export type RecipientSummary = {
|
|
|
29
30
|
label: string;
|
|
30
31
|
group: boolean;
|
|
31
32
|
revoked: boolean;
|
|
33
|
+
closesAt: string | null;
|
|
32
34
|
opened: boolean;
|
|
33
35
|
firstOpenAt: string | null;
|
|
34
36
|
cameBack: boolean;
|
|
@@ -40,15 +42,21 @@ export type RecipientSummary = {
|
|
|
40
42
|
text: string;
|
|
41
43
|
}[];
|
|
42
44
|
};
|
|
45
|
+
type NewRecipient = {
|
|
46
|
+
label: string;
|
|
47
|
+
note?: string;
|
|
48
|
+
owner?: boolean;
|
|
49
|
+
group?: boolean;
|
|
50
|
+
closesAt?: string;
|
|
51
|
+
};
|
|
43
52
|
export interface RecipientsStore {
|
|
44
53
|
get(artifactId: string): Promise<RecipientsRecord | null>;
|
|
45
|
-
add(artifactId: string, input:
|
|
46
|
-
label: string;
|
|
47
|
-
note?: string;
|
|
48
|
-
owner?: boolean;
|
|
49
|
-
group?: boolean;
|
|
50
|
-
}[]): Promise<Recipient[]>;
|
|
54
|
+
add(artifactId: string, input: NewRecipient[]): Promise<Recipient[]>;
|
|
51
55
|
revoke(artifactId: string, rids: string[] | '*'): Promise<string[]>;
|
|
56
|
+
/** Set (an ISO time) or clear (null) the feedback window of the named readers, or of every reader
|
|
57
|
+
* with '*'. Never the owner's. Returns the rids changed. Optional so a host's own store keeps
|
|
58
|
+
* working; without it the recipients route answers 501 to a `closes` change. */
|
|
59
|
+
setCloses?(artifactId: string, rids: string[] | '*', closesAt: string | null): Promise<string[]>;
|
|
52
60
|
setExpiry(artifactId: string, expireAt: string | null): Promise<void>;
|
|
53
61
|
useToken(key: string, expiresAt: number): Promise<boolean>;
|
|
54
62
|
recordView(v: {
|
|
@@ -73,3 +81,4 @@ export declare function summarizeViews(rec: RecipientsRecord, views: ViewDoc[],
|
|
|
73
81
|
at: string;
|
|
74
82
|
text: string;
|
|
75
83
|
}[]): RecipientSummary[];
|
|
84
|
+
export {};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { newRid } from './recipients.js';
|
|
1
|
+
import { newRid, ridOfReaderKey } from './recipients.js';
|
|
2
2
|
export const CAME_BACK_MS = 3_600_000;
|
|
3
3
|
const LABEL_MAX = 60;
|
|
4
4
|
const NOTE_MAX = 2000;
|
|
@@ -13,7 +13,9 @@ function makeRecipients(input, taken) {
|
|
|
13
13
|
rid = newRid();
|
|
14
14
|
taken.add(rid);
|
|
15
15
|
const note = typeof i.note === 'string' && i.note.trim() ? i.note.trim().slice(0, NOTE_MAX) : undefined;
|
|
16
|
-
|
|
16
|
+
// The owner never carries a window: setCloses skips them and the page hides the closed state
|
|
17
|
+
// from them, so one stored here would offer Share on their link and then refuse it.
|
|
18
|
+
return { rid, label, addedAt: at, ...(note ? { note } : {}), ...(i.owner ? { owner: true } : {}), ...(i.group ? { group: true } : {}), ...(typeof i.closesAt === 'string' && !i.owner ? { closesAt: i.closesAt } : {}) };
|
|
17
19
|
});
|
|
18
20
|
}
|
|
19
21
|
function revokeIn(rec, rids) {
|
|
@@ -29,6 +31,16 @@ function revokeIn(rec, rids) {
|
|
|
29
31
|
rec.revokedAll = at;
|
|
30
32
|
return hit;
|
|
31
33
|
}
|
|
34
|
+
function closeIn(rec, rids, closesAt) {
|
|
35
|
+
const hit = [];
|
|
36
|
+
for (const r of Object.values(rec.recipients)) {
|
|
37
|
+
if (r.owner || (rids !== '*' && !rids.includes(r.rid)))
|
|
38
|
+
continue;
|
|
39
|
+
r.closesAt = closesAt;
|
|
40
|
+
hit.push(r.rid);
|
|
41
|
+
}
|
|
42
|
+
return hit;
|
|
43
|
+
}
|
|
32
44
|
function addView(prev, v) {
|
|
33
45
|
const doc = prev ? { ...prev, plays: { ...prev.plays } } : { artifactId: v.artifactId, rid: v.rid, session: v.session, device: v.device, firstAt: v.at, lastAt: v.at, activeSeconds: 0, plays: {} };
|
|
34
46
|
doc.activeSeconds += Math.max(0, Math.min(60, v.addSeconds));
|
|
@@ -54,6 +66,7 @@ export function memoryRecipientsStore() {
|
|
|
54
66
|
return structuredClone(out);
|
|
55
67
|
},
|
|
56
68
|
async revoke(id, rids) { const rec = recs.get(id); return rec ? revokeIn(rec, rids) : []; },
|
|
69
|
+
async setCloses(id, rids, closesAt) { const rec = recs.get(id); return rec ? closeIn(rec, rids, closesAt) : []; },
|
|
57
70
|
async setExpiry(id, expireAt) {
|
|
58
71
|
const rec = recs.get(id) ?? { recipients: {}, expireAt: null, keep: false };
|
|
59
72
|
rec.expireAt = expireAt;
|
|
@@ -105,6 +118,18 @@ export function createRecipientsStore(db, base) {
|
|
|
105
118
|
await recsCol().doc(id).set(patch, { merge: true });
|
|
106
119
|
return hit;
|
|
107
120
|
},
|
|
121
|
+
async setCloses(id, rids, closesAt) {
|
|
122
|
+
const rec = await read(id);
|
|
123
|
+
if (!rec)
|
|
124
|
+
return [];
|
|
125
|
+
const hit = closeIn(rec, rids, closesAt);
|
|
126
|
+
if (!hit.length)
|
|
127
|
+
return hit;
|
|
128
|
+
// A merge writes only this field on these readers: a revoke or an expiry that landed after the
|
|
129
|
+
// read above is left alone. `null` clears the window (read back as no window).
|
|
130
|
+
await recsCol().doc(id).set({ recipients: Object.fromEntries(hit.map((rid) => [rid, { closesAt }])) }, { merge: true });
|
|
131
|
+
return hit;
|
|
132
|
+
},
|
|
108
133
|
async setExpiry(id, expireAt) {
|
|
109
134
|
await recsCol().doc(id).set({ expireAt, keep: expireAt === null }, { merge: true });
|
|
110
135
|
},
|
|
@@ -147,14 +172,14 @@ export function summarizeViews(rec, views, comments) {
|
|
|
147
172
|
const mine = views.filter((v) => v.rid === r.rid).sort((a, b) => a.firstAt.localeCompare(b.firstAt));
|
|
148
173
|
const firsts = mine.map((v) => Date.parse(v.firstAt));
|
|
149
174
|
return {
|
|
150
|
-
rid: r.rid, label: r.label, group: r.group === true, revoked: !!r.revokedAt,
|
|
175
|
+
rid: r.rid, label: r.label, group: r.group === true, revoked: !!r.revokedAt, closesAt: r.closesAt ?? null,
|
|
151
176
|
opened: mine.length > 0,
|
|
152
177
|
firstOpenAt: mine[0]?.firstAt ?? null,
|
|
153
178
|
cameBack: firsts.length > 1 && firsts[firsts.length - 1] - firsts[0] >= CAME_BACK_MS,
|
|
154
179
|
activeSeconds: mine.reduce((n, v) => n + v.activeSeconds, 0),
|
|
155
180
|
plays: mine.reduce((n, v) => n + Object.values(v.plays).reduce((a, b) => a + b, 0), 0),
|
|
156
181
|
devices: new Set(mine.map((v) => v.device)).size,
|
|
157
|
-
comments: comments.filter((c) => c.readerKey ===
|
|
182
|
+
comments: comments.filter((c) => ridOfReaderKey(c.readerKey) === r.rid).map((c) => ({ at: c.at, text: c.text })),
|
|
158
183
|
};
|
|
159
184
|
});
|
|
160
185
|
}
|
|
@@ -25,6 +25,15 @@ export declare function verifyRenderToken(secret: string, id: string, rid: strin
|
|
|
25
25
|
why: string;
|
|
26
26
|
};
|
|
27
27
|
export declare function beatKey(secret: string, id: string, rid: string, session: string): string;
|
|
28
|
+
/** One browser on a group link, as the 24 hex characters its reader key ends in (0.40.0). Derived
|
|
29
|
+
* here from the page, the link's verified rid and the anonymous id this server handed that browser,
|
|
30
|
+
* keyed by the recipient secret: a client cannot compute another browser's, and the stored key is
|
|
31
|
+
* not the cookie, so reading a key (the publisher's exports carry it) never lets anyone write as
|
|
32
|
+
* that reader. */
|
|
33
|
+
export declare function browserIdFor(secret: string, id: string, rid: string, anon: string): string;
|
|
34
|
+
/** The link a reader key was written through: `r:<rid>` (one link, one reader) or
|
|
35
|
+
* `r:<rid>.<browser>` (one browser on a group link). Null for any other key. */
|
|
36
|
+
export declare function ridOfReaderKey(key: string): string | null;
|
|
28
37
|
/** Messaging apps' and search engines' preview fetchers. Apple's LinkPresentation presents as
|
|
29
38
|
* `facebookexternalhit/1.1 Facebot Twitterbot/1.0`. A request with no user agent is treated as one. */
|
|
30
39
|
export declare const UNFURL_AGENTS: RegExp;
|
|
@@ -77,6 +77,22 @@ export function verifyRenderToken(secret, id, rid, token, now = Date.now()) {
|
|
|
77
77
|
export function beatKey(secret, id, rid, session) {
|
|
78
78
|
return mac(secret, `beat\n${id}\n${rid}\n${session}`);
|
|
79
79
|
}
|
|
80
|
+
/** One browser on a group link, as the 24 hex characters its reader key ends in (0.40.0). Derived
|
|
81
|
+
* here from the page, the link's verified rid and the anonymous id this server handed that browser,
|
|
82
|
+
* keyed by the recipient secret: a client cannot compute another browser's, and the stored key is
|
|
83
|
+
* not the cookie, so reading a key (the publisher's exports carry it) never lets anyone write as
|
|
84
|
+
* that reader. */
|
|
85
|
+
export function browserIdFor(secret, id, rid, anon) {
|
|
86
|
+
if (!secret)
|
|
87
|
+
throw new Error('a signing secret is required');
|
|
88
|
+
return createHmac('sha256', secret).update(`browser\n${id}\n${rid}\n${anon}`).digest('hex').slice(0, 24);
|
|
89
|
+
}
|
|
90
|
+
const RECIPIENT_KEY = /^r:([a-z0-9]{12})(\.[0-9a-f]{24})?$/;
|
|
91
|
+
/** The link a reader key was written through: `r:<rid>` (one link, one reader) or
|
|
92
|
+
* `r:<rid>.<browser>` (one browser on a group link). Null for any other key. */
|
|
93
|
+
export function ridOfReaderKey(key) {
|
|
94
|
+
return RECIPIENT_KEY.exec(key)?.[1] ?? null;
|
|
95
|
+
}
|
|
80
96
|
/** Messaging apps' and search engines' preview fetchers. Apple's LinkPresentation presents as
|
|
81
97
|
* `facebookexternalhit/1.1 Facebot Twitterbot/1.0`. A request with no user agent is treated as one. */
|
|
82
98
|
export const UNFURL_AGENTS = /facebookexternalhit|facebot|twitterbot|slackbot|whatsapp|linkedinbot|discordbot|telegrambot|skypeuripreview|redditbot|applebot|googlebot|bingbot|embedly|iframely|pinterest|bitlybot|vkshare|mastodon|preview/i;
|
|
@@ -82,6 +82,11 @@ export declare const readerKeyFor: {
|
|
|
82
82
|
signedIn: (uid: string) => string;
|
|
83
83
|
anonymous: (id: string) => string;
|
|
84
84
|
recipient: (rid: string) => string;
|
|
85
|
+
/** One browser on a group link, on a page with `comment_name: required`: the link and the id
|
|
86
|
+
* `browserIdFor` derives. `ridOfReaderKey` reads the link back out of either `r:` shape. */
|
|
87
|
+
groupBrowser: (rid: string, browser: string) => string;
|
|
88
|
+
/** The publisher's one reply in a thread, keyed by the thread's top comment. */
|
|
89
|
+
publisher: (entryId: string) => string;
|
|
85
90
|
};
|
|
86
91
|
export declare function createStateStore(db: Firestore, base: string): StateStore;
|
|
87
92
|
/** The same contract in memory: for tests, and for hosts' own tests. */
|
|
@@ -18,6 +18,11 @@ export const readerKeyFor = {
|
|
|
18
18
|
signedIn: (uid) => `u:${uid}`,
|
|
19
19
|
anonymous: (id) => `a:${id}`,
|
|
20
20
|
recipient: (rid) => `r:${rid}`,
|
|
21
|
+
/** One browser on a group link, on a page with `comment_name: required`: the link and the id
|
|
22
|
+
* `browserIdFor` derives. `ridOfReaderKey` reads the link back out of either `r:` shape. */
|
|
23
|
+
groupBrowser: (rid, browser) => `r:${rid}.${browser}`,
|
|
24
|
+
/** The publisher's one reply in a thread, keyed by the thread's top comment. */
|
|
25
|
+
publisher: (entryId) => `p:${entryId}`,
|
|
21
26
|
};
|
|
22
27
|
const writerOf = (w) => ({
|
|
23
28
|
...(w.uid ? { uid: w.uid } : {}), ...(w.email ? { email: w.email } : {}), name: w.name, anonymous: w.anonymous,
|
package/lib/artifacts/store.d.ts
CHANGED
|
@@ -32,6 +32,7 @@ export type ArtifactRecord = {
|
|
|
32
32
|
/** The brand pack the page asked for by name; see ArtifactMeta.pack. */
|
|
33
33
|
pack?: string;
|
|
34
34
|
toc?: boolean;
|
|
35
|
+
commentName?: 'required';
|
|
35
36
|
definitions?: Definition[];
|
|
36
37
|
/** Who may leave shared comments; absent is off. Personal notes need nothing. */
|
|
37
38
|
comments?: CommentsMode;
|
package/lib/artifacts/store.js
CHANGED
|
@@ -163,6 +163,7 @@ async function saveArtifact(col, input) {
|
|
|
163
163
|
...(input.meta.theme ? { theme: input.meta.theme } : {}),
|
|
164
164
|
...(input.meta.pack ? { pack: input.meta.pack } : {}),
|
|
165
165
|
...(input.meta.toc !== undefined ? { toc: input.meta.toc } : {}),
|
|
166
|
+
...(input.meta.commentName ? { commentName: input.meta.commentName } : {}),
|
|
166
167
|
...(input.meta.definitions?.length ? { definitions: input.meta.definitions } : {}),
|
|
167
168
|
};
|
|
168
169
|
if (input.id) {
|
|
@@ -215,6 +216,7 @@ async function saveArtifact(col, input) {
|
|
|
215
216
|
...(input.meta.theme ? {} : { theme: FieldValue.delete() }),
|
|
216
217
|
...(input.meta.pack ? {} : { pack: FieldValue.delete() }),
|
|
217
218
|
...(input.meta.toc !== undefined ? {} : { toc: FieldValue.delete() }),
|
|
219
|
+
...(input.meta.commentName ? {} : { commentName: FieldValue.delete() }),
|
|
218
220
|
...(input.meta.definitions?.length ? {} : { definitions: FieldValue.delete() }),
|
|
219
221
|
};
|
|
220
222
|
// `voice: none` is the page declaring it has no audio. An update merges, so the recording it
|
package/lib/brand/pack.d.ts
CHANGED
|
@@ -84,6 +84,10 @@ export type CoverProps = {
|
|
|
84
84
|
/** The version line and, for a signed-in reader, who they are signed in as. Unspoken. */
|
|
85
85
|
meta: ReactNode;
|
|
86
86
|
version: number;
|
|
87
|
+
/** Whether this reader may be shown the version number: true for whoever the host's `history`
|
|
88
|
+
* lets see past versions (admins by default), false for everyone else. A Cover that draws
|
|
89
|
+
* `version` itself draws it only when this is true; `meta` already follows it. */
|
|
90
|
+
showVersion: boolean;
|
|
87
91
|
updatedAt: string;
|
|
88
92
|
createdAt: string;
|
|
89
93
|
};
|