@supersuit/artifacts 0.38.0 → 0.40.1
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 +117 -0
- package/README.md +88 -5
- package/lib/artifacts/comments.d.ts +9 -0
- package/lib/artifacts/comments.js +6 -0
- package/lib/artifacts/embed-frame.d.ts +5 -0
- package/lib/artifacts/embed-frame.js +23 -0
- package/lib/artifacts/front-matter.d.ts +3 -0
- package/lib/artifacts/front-matter.js +8 -1
- package/lib/artifacts/index.d.ts +2 -2
- package/lib/artifacts/index.js +2 -2
- 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/render.js +14 -1
- package/lib/artifacts/simulator.d.ts +7 -0
- package/lib/artifacts/simulator.js +32 -1
- 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/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 +5 -0
- package/lib/routes/artifacts.js +5 -4
- 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,123 @@
|
|
|
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.40.1
|
|
7
|
+
|
|
8
|
+
**0.40.0 was tagged and never published: this is the first release carrying it.** The publish
|
|
9
|
+
workflow runs the tests before it publishes, and one test failed there, so nothing reached npm
|
|
10
|
+
under 0.40.0. Everything in the 0.40.0 entry below ships in this version, unchanged.
|
|
11
|
+
|
|
12
|
+
- The failing test was the comment card's, and the defect was in the tests, not the card: a name
|
|
13
|
+
one test saved was still remembered by the next wherever the runtime has a working
|
|
14
|
+
`localStorage` (Node 24, which the workflow uses) and gone where it does not (Node 25), so
|
|
15
|
+
"asks for a first name before sending" passed on one and failed on the other. Every card test
|
|
16
|
+
now starts with its own empty storage.
|
|
17
|
+
|
|
18
|
+
DETECTOR: `npm view @supersuit/artifacts versions --json` lists no `0.40.0`. A host pinned to
|
|
19
|
+
exactly `0.40.0` cannot install.
|
|
20
|
+
|
|
21
|
+
REMEDY: take `0.40.1`. There is no code difference from what 0.40.0 described.
|
|
22
|
+
|
|
23
|
+
Tests: `src/reader/comment-card.test.tsx` gives each test a fresh storage; the old file fails three
|
|
24
|
+
tests when storage persists between tests, and the new one passes either way.
|
|
25
|
+
|
|
26
|
+
## 0.40.0
|
|
27
|
+
|
|
28
|
+
**A per-reader page can be a feedback round: a window per reader, a name on every note where the
|
|
29
|
+
page asks for one, a reply the commenter can see.** Nothing changes for a page that uses none of it.
|
|
30
|
+
|
|
31
|
+
- A reader carries `closesAt`. The recipients route sets it (`add[].closes`), moves or clears it
|
|
32
|
+
(`closes: { rids, at }`), and reports it (`closesAt` in the listing and in `/views`). Past it the
|
|
33
|
+
state route refuses a new shared comment or an edit with `403 { closed: true }`; the page and the
|
|
34
|
+
reader's own comments stay readable, and deleting their own still works. `RecipientsStore.setCloses`
|
|
35
|
+
is optional, so a host's own store keeps working (the route answers 501 to a `closes` change
|
|
36
|
+
without it).
|
|
37
|
+
- The page draws it: whose draft, the publisher's note to that reader, and a live countdown, in
|
|
38
|
+
place of the `For <label>: <note>` line. A reader with no window sees what they saw before.
|
|
39
|
+
- `comment_name: required` (front matter, per-reader pages only): a group reader's shared comment
|
|
40
|
+
needs a typed first name (`400 { needsName: true }` without one); a reader on a named link is
|
|
41
|
+
never asked, in the card or the reply form, and a typed name is dropped.
|
|
42
|
+
- Under `comment_name: required`, and only there, **each browser on a group link is its own
|
|
43
|
+
reader**. Until now a group link was one reader (`r:<rid>`): everyone holding it saw every note
|
|
44
|
+
left through it as their own, could edit and delete them, and saw the publisher's reply to any of
|
|
45
|
+
them. With the line, a browser's notes are stored under `r:<rid>.<24 hex>`, which the server
|
|
46
|
+
derives (an HMAC keyed by the recipient secret) from the page, the link's verified rid and the
|
|
47
|
+
anonymous-reader cookie (`artifact_anon`) it sets with that browser's first note. A client cannot
|
|
48
|
+
name a key or work out another browser's, and the stored key is not the cookie. A browser that
|
|
49
|
+
clears its cookies is a new reader. A named link is unchanged, and so is a group link on a page
|
|
50
|
+
without the line (one shared reader, as in 0.39.0). The window, the feed's `reader.rid` and the
|
|
51
|
+
`/views` comments stay per link.
|
|
52
|
+
- `POST /api/artifacts/<id>/reply` (publish key): the publisher's one reply in a thread, optionally
|
|
53
|
+
pinned to the passage that changed. On a per-reader page only the thread's own writer is shown it
|
|
54
|
+
(under `comment_name: required`, only the browser that wrote it). It never reopens a resolved
|
|
55
|
+
thread under `open=1`.
|
|
56
|
+
- A `template: broadcast` page with comments on hands its comment layer the window and the name
|
|
57
|
+
rule, as a document does: a group reader is asked for a first name, and a closed window stops
|
|
58
|
+
offering Share. A broadcast draws no banner.
|
|
59
|
+
- An owner added with `closes` gets no window (the owner never has one).
|
|
60
|
+
- Feed rows gain `reader: { rid, label } | null`, `by` and `publisher`. No field is removed.
|
|
61
|
+
|
|
62
|
+
DETECTOR: on a page with at least one reader,
|
|
63
|
+
`curl -s -H "Authorization: Bearer $KEY" "$HOST/api/artifacts/<id>/recipients" | grep -c closesAt`
|
|
64
|
+
answers 0 on a host older than this (a page with no readers answers 0 on any version). For the
|
|
65
|
+
group link: a host is affected if it has sent one group link to people who should not read each
|
|
66
|
+
other's notes. `GET /api/artifacts/<id>/responses` shows it: rows in the `comments` slot whose
|
|
67
|
+
`reader` is the bare `r:<rid>` of a link `GET /recipients` lists with `group: true` were written as
|
|
68
|
+
the one shared reader.
|
|
69
|
+
|
|
70
|
+
REMEDY: take 0.40.0 and add the reply route file (README, "Replying as the publisher"). A host with
|
|
71
|
+
its own `RecipientsStore` adds `setCloses` to use feedback windows. A publisher that strips unknown
|
|
72
|
+
front matter keys lets `comment_name` through. To give the people on a group link their own notes,
|
|
73
|
+
republish the page with `comment_name: required`; the host needs nothing else (the cookie is the
|
|
74
|
+
one anonymous readers already get). Notes left through that link before the republish stay with
|
|
75
|
+
the publisher and are shown to no browser afterwards. Anything that matched a reader key to a link
|
|
76
|
+
with `key === 'r:' + rid` now reads the rid with `ridOfReaderKey(key)` instead, or it misses the
|
|
77
|
+
notes of a browser on a group link.
|
|
78
|
+
|
|
79
|
+
Tests: `recipients-store.test.ts`, `recipients-api.test.tsx`, `recipients.test.ts`,
|
|
80
|
+
`front-matter.test.ts`, `store.test.ts`, `comments.test.tsx` (closed window, names, reply
|
|
81
|
+
visibility, two browsers on one group link with and without the line), `comments-feed.test.tsx`,
|
|
82
|
+
`feedback-window.test.tsx`, `comment-card.test.tsx`, `comment-layer.test.tsx`,
|
|
83
|
+
`per-reader.test.tsx`, `broadcast-feedback.test.tsx`; `unchanged-default.test.tsx` and
|
|
84
|
+
`broadcast.test.tsx` unedited.
|
|
85
|
+
|
|
86
|
+
## 0.39.0
|
|
87
|
+
|
|
88
|
+
**A session demo's frame fits its player, and is wider than the reading column on a wide screen.**
|
|
89
|
+
A ```` ```simulator ```` block drew its player in a frame of one fixed height (`height:`, 720px
|
|
90
|
+
unless the block said). A session demo draws a different device for each viewer, a short laptop on
|
|
91
|
+
a desktop and a tall phone on a phone, so one height suited neither: on a desktop a band of
|
|
92
|
+
nothing, some 300px, sat between the laptop and the caption.
|
|
93
|
+
|
|
94
|
+
- The frame now asks its embed page `fit=content` (the address gains `&fit=content`), and a page
|
|
95
|
+
that understands posts `{ type: 'embed:fit', height: <px> }` to its parent. The frame takes that
|
|
96
|
+
height (`EmbedFrame`, a client component; `fitFrom`, `FIT_MESSAGE`): only that message, only from
|
|
97
|
+
the frame's own window and the origin of the page it loaded, held between 240 and 2000px.
|
|
98
|
+
- `height:` still means what it meant: the height the frame opens at, and so the room the player is
|
|
99
|
+
given. A page that posts nothing keeps it. No published page needs a change.
|
|
100
|
+
- From 1024px a framed session demo is up to 64rem wide, centred on the column and clear of the
|
|
101
|
+
contents rail in the left margin from 1280px, with its caption centred under it. In a broadcast's
|
|
102
|
+
card, below 1024px, for the Messages phone, and for a caption with no player, the column is the
|
|
103
|
+
width, as before.
|
|
104
|
+
|
|
105
|
+
DETECTOR: open a page with a ```` ```simulator ```` block on a desktop. Affected: the frame is the
|
|
106
|
+
block's height whatever the device in it, with empty space under a laptop. Or
|
|
107
|
+
`curl -s <page> | grep -o 'simulator/embed[^"]*'` shows no `fit=content`.
|
|
108
|
+
|
|
109
|
+
REMEDY: take 0.39.0. A host whose embed page is not its own does nothing more: an unknown query key
|
|
110
|
+
is ignored and the frame keeps the block's height. A host that serves its own embed page makes it
|
|
111
|
+
answer `fit=content` in two steps, in this order: hold the player at the height the frame opened
|
|
112
|
+
with (inside a frame the viewport IS the frame, so a player sized from it shrinks as the frame
|
|
113
|
+
does and the two chase each other), then post its content's height as `embed:fit` whenever it
|
|
114
|
+
changes. Centre the player in the frame as well, for hosts that never listen.
|
|
115
|
+
|
|
116
|
+
Tests: `simulator.test.ts` (the address asks to fit and the Messages one does not; `fitFrom` takes
|
|
117
|
+
the message from the right origin, refuses every other shape, and clamps), `embed-frame.test.tsx`
|
|
118
|
+
(the frame opens at the block's height, takes a reported one, and ignores another window, origin
|
|
119
|
+
and message), `render.test.tsx` (the opening height with and without `height:`; the wide rule is on
|
|
120
|
+
a framed session demo only, never the Messages phone or a caption with no player). The wide rule
|
|
121
|
+
itself is CSS and is not run by a test here: it was checked in a browser at 390, 1100 and 1440px.
|
|
122
|
+
|
|
6
123
|
## 0.38.0
|
|
7
124
|
|
|
8
125
|
**Readers' voice memos live in the private bucket.** A memo left on a page (`comments/<memo>`, or a
|
package/README.md
CHANGED
|
@@ -253,6 +253,7 @@ summary: Three bets, what each costs, and what we stop doing to afford them.
|
|
|
253
253
|
| `definitions` | Terms defined inline (below). A republish without it removes them |
|
|
254
254
|
| `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
255
|
| `media` | `private`: the page's body media is kept private and served through short-lived signed URLs. Needs `links: per-reader`. Sticky like `links` |
|
|
256
|
+
| `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
257
|
| `change` | One line saying what this version changed (see Version history) |
|
|
257
258
|
| `template` | `document` (the default, the read-along reader) or `broadcast` (one message card sent to several people; see Broadcasts) |
|
|
258
259
|
|
|
@@ -748,6 +749,36 @@ type Ctx = { params: Promise<{ id: string }> }
|
|
|
748
749
|
export function POST(request: NextRequest, ctx: Ctx) { return artifacts.RESOLVE(request, ctx) }
|
|
749
750
|
```
|
|
750
751
|
|
|
752
|
+
|
|
753
|
+
### Replying as the publisher (0.40.0)
|
|
754
|
+
|
|
755
|
+
POST /api/artifacts/<id>/reply
|
|
756
|
+
Authorization: Bearer <publish key or publisher pass>
|
|
757
|
+
Content-Type: application/json
|
|
758
|
+
|
|
759
|
+
{ "entry": "<entryId>", "body": "Named the river in the second paragraph.", "by": "Pat", "quote": "the river Ash" }
|
|
760
|
+
|
|
761
|
+
200 { id, entry, parent, existing }
|
|
762
|
+
|
|
763
|
+
One reply per thread when calls do not overlap. `entry` may name a reply: the thread it belongs to
|
|
764
|
+
is answered. A second call for a thread already answered changes nothing and names the first reply
|
|
765
|
+
back with `existing: true`, so a caller that retries does not post twice. The check is a read and
|
|
766
|
+
then an append, so two overlapping calls can both post. `by` defaults to "the publisher". `quote`
|
|
767
|
+
(text on the page, at most 500 characters) pins the reply to the passage that changed, and the
|
|
768
|
+
thread then offers "Show what changed"; without it the reply sits where its thread does. On a
|
|
769
|
+
per-reader page the reply is shown to the reader whose comment started the thread and to no other
|
|
770
|
+
reader. It is allowed after that reader's window has closed. On a page that is not per-reader
|
|
771
|
+
and whose comments are owner-only (`comments_visible: owner`), `REPLY` still answers 200 and stores
|
|
772
|
+
the reply, but the commenter is not shown it: only the owner, the admins and the feed are. The feed lists it with
|
|
773
|
+
`publisher: true`, and a publisher reply never reopens a resolved thread under `open=1`. 404 for a
|
|
774
|
+
page or comment that is not there, 501 with no `state`.
|
|
775
|
+
|
|
776
|
+
// app/api/artifacts/[id]/reply/route.ts
|
|
777
|
+
import type { NextRequest } from 'next/server'
|
|
778
|
+
import { artifacts } from '@/lib/artifacts'
|
|
779
|
+
type Ctx = { params: Promise<{ id: string }> }
|
|
780
|
+
export function POST(request: NextRequest, ctx: Ctx) { return artifacts.REPLY(request, ctx) }
|
|
781
|
+
|
|
751
782
|
### Telling commenters their comments were resolved (`createResolveDigests`)
|
|
752
783
|
|
|
753
784
|
When threads are resolved, each signed-in reader whose comments were in them can get ONE email per
|
|
@@ -800,12 +831,12 @@ appear (the privacy test covers this handler like every other).
|
|
|
800
831
|
GET /api/comments?since=2026-09-28T12:00:00Z
|
|
801
832
|
Authorization: Bearer <publish key>
|
|
802
833
|
|
|
803
|
-
200 { now, cursor, more, comments: [ { artifactId, title, entryId, parent, name,
|
|
804
|
-
body, transcript, audioUrl, at, resolved, link } ] }
|
|
834
|
+
200 { now, cursor, more, comments: [ { artifactId, title, entryId, parent, name, reader, by, publisher,
|
|
835
|
+
quote, region, body, transcript, audioUrl, at, resolved, link } ] }
|
|
805
836
|
```
|
|
806
837
|
|
|
807
838
|
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
|
|
839
|
+
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
840
|
null for a box (`region: true`); `parent` is the comment a reply answers; `audioUrl` is a signed
|
|
810
841
|
URL to the recording, when there is one and the host can sign it. `link` is the page with
|
|
811
842
|
`#comment-<entryId>`: opening it scrolls to the pin and opens its thread (a reply opens its
|
|
@@ -863,7 +894,7 @@ Routes (spread into your route files by `createArtifactRoutes`): `RECIPIENTS`, `
|
|
|
863
894
|
| Route | Who | What |
|
|
864
895
|
|---|---|---|
|
|
865
896
|
| `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 |
|
|
897
|
+
| `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
898
|
| `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
899
|
| `POST /api/artifacts/<id>/seen` | the page itself (public) | The reader's own signal, below |
|
|
869
900
|
|
|
@@ -879,6 +910,58 @@ A view is the page rendered in a real browser, visible, for at least 2 seconds.
|
|
|
879
910
|
|
|
880
911
|
`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
912
|
|
|
913
|
+
### A feedback window (0.40.0)
|
|
914
|
+
|
|
915
|
+
A reader may carry `closesAt`, the time their feedback window closes. It is per reader, set when
|
|
916
|
+
the reader is added (`add[].closes`) or afterwards (`closes: { rids, at }`, where `rids` is a list
|
|
917
|
+
of reader ids or `'*'` for every reader but the owner, and `at: null` clears the window). The owner
|
|
918
|
+
never has one: `closes` on an `add` entry with `owner: true` is dropped. It is
|
|
919
|
+
separate from the page's `expires`: a reader past their window still opens the page and reads
|
|
920
|
+
their own comments. A time in the past closes the window now, and a window that cannot be read as
|
|
921
|
+
a time counts as closed. `closes` answers `windowed`, the reader ids it changed, beside `added` and
|
|
922
|
+
`revoked`. A host with its own `RecipientsStore` needs `setCloses` for this; without it the route
|
|
923
|
+
answers 501 to a `closes` change.
|
|
924
|
+
|
|
925
|
+
Once the window is closed, a new shared comment or an edit is refused with
|
|
926
|
+
`403 { error: "Feedback on this draft is closed. Your note was not sent.", closed: true }`.
|
|
927
|
+
Deleting their own comment (`remove`) still works. `GET /state` answers
|
|
928
|
+
`feedback: { closesAt, closed }` for a reader who has a window, and `GET /recipients` and
|
|
929
|
+
`GET /views` carry each reader's `closesAt` (null when there is none).
|
|
930
|
+
|
|
931
|
+
The page says so. A reader with a window sees, above the title, "A draft from <owner>, for
|
|
932
|
+
<label>" (a group link names nobody), the note the publisher wrote for them, and how long is left,
|
|
933
|
+
counted in days from two days up, then hours from two hours, then minutes, and live while the page
|
|
934
|
+
stays open. Past it the line reads "Feedback is closed. You can still read the draft and your own
|
|
935
|
+
notes." A reader with no window sees the `For <label>: <note>` line as before. In a thread, a
|
|
936
|
+
comment of theirs that nobody has answered shows "Waiting for a reply." while the window exists.
|
|
937
|
+
|
|
938
|
+
### A name on every note: `comment_name: required` (0.40.0)
|
|
939
|
+
|
|
940
|
+
A per-reader page that says `comment_name: required` in its front matter puts a name on every
|
|
941
|
+
shared comment. A `group` reader (one link shared by several people) must type a first name:
|
|
942
|
+
without one the state route answers `400 { error: "Add your first name, so your note has a name on
|
|
943
|
+
it.", needsName: true }`, and the comment card, the reply form and a signed-in reader's card all
|
|
944
|
+
ask before they send. A reader on a named link is never asked, and a name sent for them is
|
|
945
|
+
dropped: the label is who they are. The line needs `links: per-reader` (a page without it is
|
|
946
|
+
refused), and a republish that leaves it out removes it. A page without the line takes names
|
|
947
|
+
exactly as before.
|
|
948
|
+
|
|
949
|
+
**Who a group link is.** Without `comment_name: required`, a group link is one shared reader:
|
|
950
|
+
everyone who opens it sees every note left through it as their own, can edit or delete any of
|
|
951
|
+
them, and sees the publisher's reply to any of them. Do not send one to people who should not read
|
|
952
|
+
each other's notes. With the line, each browser on a group link is its own reader: it sees, edits
|
|
953
|
+
and deletes only the notes written from that browser, and is shown only the publisher's replies to
|
|
954
|
+
those. The browser is known by the anonymous-reader cookie (`artifact_anon`, HttpOnly, a year), set
|
|
955
|
+
with its first note, and its notes are stored under a key the server derives from the link and that
|
|
956
|
+
cookie (`r:<rid>.<24 hex>`), never under the cookie itself; `ridOfReaderKey(key)` reads the
|
|
957
|
+
link's rid out of either shape. A browser that clears its cookies, or a
|
|
958
|
+
second device, is a new reader: the notes it left stay with the publisher and are no longer shown
|
|
959
|
+
to it. So does a reader who signs in on another page of the same host, since signing in clears the
|
|
960
|
+
anonymous cookie. Notes left through a group link before the page gained the line were written as
|
|
961
|
+
the one shared reader (`r:<rid>`) and are shown to no browser afterwards. A named link is one
|
|
962
|
+
reader either way. The feedback window, the feed's `reader: { rid, label }` and the `/views` line
|
|
963
|
+
stay per link: every browser on a group link shares them.
|
|
964
|
+
|
|
882
965
|
## Broadcasts (`template: broadcast`)
|
|
883
966
|
|
|
884
967
|
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 +985,7 @@ What the kind decides, so the file does not have to:
|
|
|
902
985
|
- **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
986
|
- **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
987
|
- **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.
|
|
988
|
+
- **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
989
|
- **Silent view tracking**: the quiet view beacon, as on every per-reader page.
|
|
907
990
|
|
|
908
991
|
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
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
// The frame an embed block plays in. It opens at the block's height and, when its embed page
|
|
4
|
+
// reports how tall the player is (simulator.ts, "A frame that fits"), takes that height, so a
|
|
5
|
+
// short device has no empty band under it and a tall one is not cut off.
|
|
6
|
+
import { useEffect, useRef, useState } from 'react';
|
|
7
|
+
import { fitFrom } from './simulator.js';
|
|
8
|
+
export function EmbedFrame({ src, title, height }) {
|
|
9
|
+
const ref = useRef(null);
|
|
10
|
+
const [fit, setFit] = useState(null);
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
const onMessage = (e) => {
|
|
13
|
+
if (!ref.current || e.source !== ref.current.contentWindow)
|
|
14
|
+
return;
|
|
15
|
+
const h = fitFrom(e.data, e.origin, src);
|
|
16
|
+
if (h != null)
|
|
17
|
+
setFit(h);
|
|
18
|
+
};
|
|
19
|
+
window.addEventListener('message', onMessage);
|
|
20
|
+
return () => window.removeEventListener('message', onMessage);
|
|
21
|
+
}, [src]);
|
|
22
|
+
return (_jsx("iframe", { ref: ref, src: src, title: title, loading: "lazy", allow: "microphone; autoplay; fullscreen", className: "block w-full rounded-xl border border-[color:var(--a-line)]", style: { height: fit != null ? `${fit}px` : height, border: 0 } }));
|
|
23
|
+
}
|
|
@@ -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
|
@@ -13,7 +13,7 @@ export { parseStateConfig, effectiveWriters, slotVisibility, checkValue, shapeCh
|
|
|
13
13
|
export { createStateStore, createMemoryStateStore, readerKeyFor, type StateStore, type StateEntry, type Writer } from './state-store.js';
|
|
14
14
|
export { stateView, responsesOf, responsesCsv, type SlotView, type Tally, type SharedEntry, type Response as StateResponse } from './state-view.js';
|
|
15
15
|
export { slugify, headingsOf, blocksOf, scanWidgets, mergeWidgetState, mergeChecklistState, checklistsOf, checklistSendIds, checklistAnswerIds, checklistSyncs, hasNotesWidget, checkNoteValue, placeNotes, NOTES_SLOT, MAX_NOTE_CHARS, type Heading, type NotesWidget, type NoteValue, type PlacedNote } from './widgets.js';
|
|
16
|
-
export { parseSimulatorFence, simulatorSrc, parseEmbedFence, embedSrc, EMBED_KINDS, type SimulatorBlock, type EmbedBlock, type EmbedKind } from './simulator.js';
|
|
16
|
+
export { parseSimulatorFence, simulatorSrc, parseEmbedFence, embedSrc, fitFrom, FIT_MESSAGE, EMBED_KINDS, type SimulatorBlock, type EmbedBlock, type EmbedKind } from './simulator.js';
|
|
17
17
|
export { CHECKLIST_SLOT, checklistStorageKey, checkChecklistValue, parseChecklistFence, type ChecklistBlock, type ChecklistItem, type ChecklistValue } from './checklist.js';
|
|
18
18
|
export { textAnchorFrom, findText, blockId, parseAnchor, CONTEXT_CHARS, MAX_QUOTE_CHARS, BLOCK_ID, type Anchor, type TextAnchor, type RegionAnchor } from './anchor.js';
|
|
19
19
|
export { createResolveDigests, noticesFor, composeDigest, resolverName, itemsOf, emailKey, burstKey, stopToken, verifyStopToken, maskEmail, RESOLVE_DIGEST_QUIET_MS, RESOLVE_DIGEST_MAX_WAIT_MS, RESOLVE_DIGEST_LEASE_MS, RESOLVE_DIGEST_MAX_ATTEMPTS, RESOLVE_DIGEST_STOP_PATH, DIGEST_BODY_CHARS, DIGEST_QUOTE_CHARS, type ResolveEvent, type DigestItem, type Burst, type Digest, type DigestStatus, type ResolveDigestStore, type DigestMessage, type DigestSender, type ResolveDigestOptions, type ComposeOptions, type FlushReport } from './resolve-digest.js';
|
|
@@ -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
|
@@ -13,7 +13,7 @@ export { parseStateConfig, effectiveWriters, slotVisibility, checkValue, shapeCh
|
|
|
13
13
|
export { createStateStore, createMemoryStateStore, readerKeyFor } from './state-store.js';
|
|
14
14
|
export { stateView, responsesOf, responsesCsv } from './state-view.js';
|
|
15
15
|
export { slugify, headingsOf, blocksOf, scanWidgets, mergeWidgetState, mergeChecklistState, checklistsOf, checklistSendIds, checklistAnswerIds, checklistSyncs, hasNotesWidget, checkNoteValue, placeNotes, NOTES_SLOT, MAX_NOTE_CHARS } from './widgets.js';
|
|
16
|
-
export { parseSimulatorFence, simulatorSrc, parseEmbedFence, embedSrc, EMBED_KINDS } from './simulator.js';
|
|
16
|
+
export { parseSimulatorFence, simulatorSrc, parseEmbedFence, embedSrc, fitFrom, FIT_MESSAGE, EMBED_KINDS } from './simulator.js';
|
|
17
17
|
export { CHECKLIST_SLOT, checklistStorageKey, checkChecklistValue, parseChecklistFence } from './checklist.js';
|
|
18
18
|
export { textAnchorFrom, findText, blockId, parseAnchor, CONTEXT_CHARS, MAX_QUOTE_CHARS, BLOCK_ID } from './anchor.js';
|
|
19
19
|
export { createResolveDigests, noticesFor, composeDigest, resolverName, itemsOf, emailKey, burstKey, stopToken, verifyStopToken, maskEmail, RESOLVE_DIGEST_QUIET_MS, RESOLVE_DIGEST_MAX_WAIT_MS, RESOLVE_DIGEST_LEASE_MS, RESOLVE_DIGEST_MAX_ATTEMPTS, RESOLVE_DIGEST_STOP_PATH, DIGEST_BODY_CHARS, DIGEST_QUOTE_CHARS } from './resolve-digest.js';
|
|
@@ -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;
|
package/lib/artifacts/render.js
CHANGED
|
@@ -17,6 +17,7 @@ import { blocksOf, checklistSyncs, checklistsOf, hasNotesWidget, headingsOf } fr
|
|
|
17
17
|
import { HeadingNotes, NoteToggle, NotesEarlier, NotesProvider } from '../widgets/notes.js';
|
|
18
18
|
import { Checklist } from '../widgets/checklist.js';
|
|
19
19
|
import { embedSrc, parseEmbedFence } from './simulator.js';
|
|
20
|
+
import { EmbedFrame } from './embed-frame.js';
|
|
20
21
|
import { VideoAutoplay } from '../reader/video-autoplay.js';
|
|
21
22
|
import { CodeCopy } from '../reader/code-copy.js';
|
|
22
23
|
import { DefinitionLayer } from '../reader/definition-layer.js';
|
|
@@ -110,6 +111,17 @@ function InlineAudio({ src, label }) {
|
|
|
110
111
|
const html = `<audio src="${escapeAttr(src)}" aria-label="${escapeAttr(label)}" data-artifact-audio data-artifact-media="${escapeAttr(mediaName(src))}" controls preload="none" class="block w-full"></audio>`;
|
|
111
112
|
return (_jsxs("span", { className: "my-6 block rounded-lg border border-[color:var(--a-line)] bg-[color:var(--a-surface)] px-4 py-3", children: [label ? _jsx("span", { className: "mb-2 block text-sm text-[color:var(--a-strong)]", children: label }) : null, _jsx("span", { className: "block", dangerouslySetInnerHTML: { __html: html } })] }));
|
|
112
113
|
}
|
|
114
|
+
// A session demo on a wide screen is wider than the reading column, so the device it draws (a
|
|
115
|
+
// laptop, or a phone beside what was made) has room: up to 64rem, centred on the column, and clear
|
|
116
|
+
// of the contents rail fixed in the left margin from 1280px (w-56 at left-6, w-64 at left-12).
|
|
117
|
+
// Below 1024px, in a broadcast's card, for the Messages phone, and for a caption with no player
|
|
118
|
+
// over it (a host that names no embed page), the column is the width.
|
|
119
|
+
const WIDE = '[data-artifact-embed="simulator"][data-wide]';
|
|
120
|
+
const WIDE_EMBED_CSS = `@media (min-width:1024px){${WIDE}{width:max(100%,min(64rem,calc(100vw - 4rem)));margin-left:50%;transform:translateX(-50%)}` +
|
|
121
|
+
`${WIDE} figcaption{text-align:center}` +
|
|
122
|
+
`[data-broadcast] ${WIDE}{width:auto;margin-left:0;transform:none}[data-broadcast] ${WIDE} figcaption{text-align:inherit}}` +
|
|
123
|
+
`@media (min-width:1280px){${WIDE}{width:max(100%,min(64rem,calc(100vw - 36rem)))}}` +
|
|
124
|
+
`@media (min-width:1536px){${WIDE}{width:max(100%,min(64rem,calc(100vw - 40rem)))}}`;
|
|
113
125
|
/** An embed block (```simulator, ```messages): the player in a frame when the host names one, its
|
|
114
126
|
* caption alone when it does not, and nothing when the block does not parse (a page published
|
|
115
127
|
* before validation existed). */
|
|
@@ -118,7 +130,8 @@ function EmbedFence({ kind, source, ctx }) {
|
|
|
118
130
|
if (!r.ok)
|
|
119
131
|
return null;
|
|
120
132
|
const src = ctx ? embedSrc(kind, ctx.embed, r.block.value) : null;
|
|
121
|
-
|
|
133
|
+
const wide = kind === 'simulator' && src != null;
|
|
134
|
+
return (_jsxs("figure", { "data-artifact-embed": kind, ...(kind === 'simulator' ? { 'data-artifact-simulator': '' } : {}), ...(wide ? { 'data-wide': '' } : {}), className: "my-8", children: [wide ? _jsx("style", { children: WIDE_EMBED_CSS }) : null, src ? _jsx(EmbedFrame, { src: src, title: r.block.caption, height: r.block.height }) : null, _jsx("figcaption", { className: "mt-2 text-sm opacity-80", children: r.block.caption })] }));
|
|
122
135
|
}
|
|
123
136
|
/** The one image a paragraph holds and nothing else (whitespace aside), or null. */
|
|
124
137
|
function soleImage(node) {
|