mdeck 3.10.1 → 3.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +15 -4
  3. package/assets/base.css +6 -1
  4. package/docs/reference/authoring.md +111 -9
  5. package/docs/site/assets/site.css +2 -0
  6. package/docs/site/content/appearance.md +1 -1
  7. package/docs/site/content/audience.md +36 -8
  8. package/docs/site/content/commands.md +6 -3
  9. package/docs/site/content/drawing.md +5 -5
  10. package/docs/site/content/getting-started.md +2 -32
  11. package/docs/site/content/install.md +49 -0
  12. package/docs/site/content/media.md +20 -1
  13. package/docs/site/content/presenting.md +67 -9
  14. package/docs/site/content/sharing.md +1 -0
  15. package/docs/site/content/welcome.md +21 -1
  16. package/docs/site/content/writing.md +27 -0
  17. package/docs/site/images/features/hotspots.webp +0 -0
  18. package/docs/site/images/features/languages.webp +0 -0
  19. package/docs/site/pages.js +1 -0
  20. package/examples/custom-layouts/slides.md +21 -9
  21. package/examples/showcase/refs.bib +21 -0
  22. package/examples/showcase/slides.ar.drawings.json +11 -0
  23. package/examples/showcase/slides.ar.md +567 -0
  24. package/examples/showcase/slides.de.drawings.json +11 -0
  25. package/examples/showcase/slides.de.md +567 -0
  26. package/examples/showcase/slides.de.results.json +57 -0
  27. package/examples/showcase/slides.en.drawings.json +11 -0
  28. package/examples/showcase/{slides.md → slides.en.md} +112 -5
  29. package/package.json +1 -1
  30. package/skills/write-slides/SKILL.md +14 -2
  31. package/src/build/check.js +34 -7
  32. package/src/build/components.js +5 -3
  33. package/src/build/config.js +2 -0
  34. package/src/build/homePlugin.js +64 -14
  35. package/src/build/include.js +249 -0
  36. package/src/build/inkPlugin.js +12 -2
  37. package/src/build/relayAccess.js +3 -1
  38. package/src/build/renderCheck.js +23 -3
  39. package/src/build/slidesPlugin.js +70 -22
  40. package/src/build/svgCheck.js +65 -0
  41. package/src/build/timingPlugin.js +96 -0
  42. package/src/cli/args.js +6 -5
  43. package/src/cli/index.js +77 -21
  44. package/src/components/Bibliography.jsx +7 -2
  45. package/src/components/Poll.jsx +2 -2
  46. package/src/components/QrCodeTag.jsx +2 -2
  47. package/src/components/VideoPlayer.jsx +6 -1
  48. package/src/components/WordCloud.jsx +7 -6
  49. package/src/components/activity.jsx +11 -8
  50. package/src/components/poll.css +2 -0
  51. package/src/core/editDeck.js +44 -7
  52. package/src/core/icons.js +9 -1
  53. package/src/core/labels.js +78 -0
  54. package/src/core/parseSlides.js +16 -6
  55. package/src/core/reactions.js +27 -0
  56. package/src/core/resultsReport.js +153 -0
  57. package/src/core/source.js +8 -1
  58. package/src/core/timing.js +83 -0
  59. package/src/core/validateDeck.js +11 -2
  60. package/src/editor/components/DeckSettings.jsx +1 -0
  61. package/src/editor/components/MetaFields.jsx +1 -0
  62. package/src/home/Home.jsx +39 -93
  63. package/src/home/home.css +5 -17
  64. package/src/layouts/layoutApi.jsx +51 -8
  65. package/src/layouts/renderSlide.jsx +13 -1
  66. package/src/live/answer/answer.css +9 -0
  67. package/src/live/answer/answer.js +32 -4
  68. package/src/live/client.js +23 -4
  69. package/src/live/rooms.js +18 -6
  70. package/src/runtime/KeyHelp.jsx +115 -0
  71. package/src/runtime/PresenterChrome.jsx +168 -0
  72. package/src/runtime/Reactions.jsx +56 -0
  73. package/src/runtime/RehearsalSummary.jsx +129 -0
  74. package/src/runtime/component.js +27 -0
  75. package/src/runtime/deck-stage.js +371 -37
  76. package/src/runtime/helpKeys.js +75 -0
  77. package/src/runtime/ink/bus.js +13 -5
  78. package/src/runtime/ink/outbox.js +27 -0
  79. package/src/runtime/ink/room.js +37 -12
  80. package/src/runtime/ink/segments.js +21 -0
  81. package/src/runtime/main.jsx +508 -286
  82. package/src/runtime/shared.js +77 -0
  83. package/src/runtime/slideControls.js +60 -0
  84. package/src/runtime/steps.js +36 -0
  85. package/src/runtime/useTalkTimer.js +67 -0
  86. package/examples/citations/refs.bib +0 -30
  87. package/examples/citations/refs.yml +0 -21
  88. package/examples/citations/slides.md +0 -43
  89. package/examples/citations/slides.pdf +0 -0
  90. package/examples/poll/slides.md +0 -127
  91. package/examples/showcase/slides.drawings.json +0 -11
package/src/home/Home.jsx CHANGED
@@ -1,10 +1,9 @@
1
1
  import { h } from 'preact'
2
2
  import { useEffect, useRef, useState } from 'preact/hooks'
3
3
  import mark from '../../assets/logo/mark.svg'
4
- import QrCode from '../components/QrCode.jsx'
5
4
  import { Icon } from '../components/Icon.jsx'
6
5
  import { ConnectedViews } from '../components/ConnectedViews.jsx'
7
- import { devicesText, phonesText } from '../live/presence.js'
6
+ import { devicesText } from '../live/presence.js'
8
7
  import { devPath } from '../core/devPath.js'
9
8
 
10
9
  // Where the dev server serves from: / normally, /t/<id>/ with `mdeck run --server`.
@@ -118,8 +117,6 @@ function Preview({ info }) {
118
117
  <button class="home-btn home-btn--small" onClick={() => control('prev')} aria-label="Previous"><HomeIcon name="prev" size={14} /></button>
119
118
  <button class="home-btn home-btn--small" onClick={() => control('next')} aria-label="Next"><HomeIcon name="next" size={14} /></button>
120
119
  <span class="home-preview-count">{index + 1} / {info.slides}</span>
121
- <span class="home-spacer" />
122
- <a class="home-btn home-btn--small" href={audienceUrl} target="_blank" rel="noopener">Open audience window <HomeIcon name="external" size={13} /></a>
123
120
  </div>
124
121
  </section>
125
122
  }
@@ -171,102 +168,45 @@ function Diagnostics({ diagnostics, name }) {
171
168
  </ul>
172
169
  }
173
170
 
174
- function CopyButton({ text, label = 'Copy' }) {
175
- const [copied, setCopied] = useState(false)
176
- const copy = async () => {
177
- try { await navigator.clipboard.writeText(text); setCopied(true); setTimeout(() => setCopied(false), 1400) } catch {}
178
- }
179
- return <button class="home-btn home-btn--small" onClick={copy}><HomeIcon name={copied ? 'check' : 'copy'} size={14} />{copied ? 'Copied' : label}</button>
180
- }
181
171
 
182
172
  // One session pairs the presenter view with the audience window, so both
183
173
  // links on this page drive each other.
184
174
  const SESSION = typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : String(Math.random()).slice(2)
185
175
 
186
- // For decks with a `server` setting: the server's health, the viewers' link
187
- // and the key (MDECK_SERVER_KEY) that lets a browser steer phones and reset
188
- // polls.
189
- function LivePolls({ live, presence, onRecheck }) {
190
- const presenterUrl = `${BASE}?view=presenter&session=${SESSION}${live.key ? `&serverkey=${encodeURIComponent(live.key)}` : ''}`
191
- const keyState = !live.key ? null : live.keyAccepted ? ['is-ok', 'Accepted by the server'] : live.reachable ? ['is-bad', 'Not accepted by the server'] : ['is-idle', 'Not checked']
192
- return <section class="home-section">
176
+ // The answers kept in the talk: each question with how it came out, and
177
+ // the files to save them as (the same as `mdeck results`).
178
+ function Answers({ results, file, onRefresh }) {
179
+ const href = format => `${devPath('__mdeck/home/results')}?format=${format}`
180
+ return <section class="home-section home-section--wide home-answers">
193
181
  <div class="home-section-head">
194
- <h2>Polls</h2>
195
- <button class="home-btn home-btn--small" onClick={onRecheck}>Check again</button>
182
+ <h2>Answers from the talk</h2>
183
+ <button class="home-btn home-btn--small" onClick={onRefresh}>Refresh</button>
196
184
  </div>
197
- <dl class="home-live">
198
- <div>
199
- <dt>Server</dt>
200
- <dd>
201
- {live.server
202
- ? <><code>{live.server}</code><span class={`home-pill ${live.reachable ? 'is-ok' : 'is-bad'}`}>{live.reachable ? `Reachable · ${live.ms} ms` : `Not reachable · ${live.error}`}</span></>
203
- : <span>Built into this dev server</span>}
204
- </dd>
205
- </div>
206
- <div>
207
- <dt>Join link for phones</dt>
208
- <dd>
209
- {live.joinUrl
210
- ? <><a href={live.joinUrl} target="_blank" rel="noopener"><code>{live.joinUrl}</code></a> <CopyButton text={live.joinUrl} /></>
211
- : <>
212
- <p class="home-note">Phones cannot reach this computer. Start with <code>--network</code>, or set <code>server</code> in the deck.</p>
213
- {live.localJoinUrl && <p><a href={live.localJoinUrl} target="_blank" rel="noopener">Try the answer page here</a> <span>in another browser tab or window.</span></p>}
214
- </>}
215
- <p>Session code <code class="home-code">{live.code}</code>, the same for every poll in this deck.</p>
216
- {presence && <p>{presence.phones ? `${phonesText(presence.phones)} connected now.` : 'No phones connected yet.'}</p>}
217
- </dd>
218
- </div>
219
- {live.server && <div>
220
- <dt>Server key</dt>
221
- <dd>
222
- {live.key
223
- ? <>
224
- <code class="home-code">{live.key}</code> <CopyButton text={live.key} />
225
- <span class={`home-pill ${keyState[0]}`}>{keyState[1]}</span>
226
- <p>The presenter view and audience window buttons above carry it. Or open <a href={presenterUrl} target="_blank" rel="noopener">the presenter view with the key</a>.</p>
227
- </>
228
- : <p>Start <code>mdeck run</code> with <code>MDECK_SERVER_KEY</code> set to the server's key to see it here.</p>}
229
- </dd>
230
- </div>}
231
- </dl>
232
- </section>
233
- }
234
-
235
- // Presenting from an iPad: a one-time QR code opens the presenter view there,
236
- // paired with this server so it may save ink and steer the projector, which
237
- // shows the audience window on this computer.
238
- function Tablet({ pairing, relay, presence, onChange, onError }) {
239
- const away = (presence?.views ?? []).filter(entry => entry.view === 'presenter' && entry.device !== 'computer')
240
- const [url, setUrl] = useState(null)
241
- const offer = () => api('/action', { action: 'pair' }).then(result => { setUrl(result.url); onChange() }).catch(error => onError(error.message))
242
- const unpair = () => api('/action', { action: 'unpair' }).then(() => { setUrl(null); onChange() }).catch(error => onError(error.message))
243
- return <section class="home-section home-tablet">
244
- <div class="home-section-head">
245
- <h2>Present from an iPad</h2>
246
- {pairing.devices > 0 && <button class="home-btn home-btn--small" onClick={unpair}>Unpair {pairing.devices === 1 ? 'the device' : `${pairing.devices} devices`}</button>}
247
- </div>
248
- {relay && <p class={`home-note home-relay home-relay--${relay.state}`}>{relay.state === 'up' ? 'Connected to your server: the iPad can reach this computer from any network.' : relay.state === 'refused' ? `Connecting to the server failed: ${relay.reason}` : relay.state === 'down' ? 'Lost the connection to the server; reconnecting…' : 'Connecting to your server…'}</p>}
249
- {away.length > 0 && <p class="home-ok"><HomeIcon name="check" /> Presenter view open on {devicesText(away)}.</p>}
250
- {!pairing.available
251
- ? <p class="home-note">Start <code>mdeck run</code> with <code>--network</code>, so an iPad in the same network can reach it, or with <code>--server</code> to reach it through your server.</p>
252
- : url
253
- ? <div class="home-pair">
254
- <QrCode url={url} size="180" />
255
- <div>
256
- <p>Scan this with the iPad's camera. It opens the presenter view there, where you draw and go through the slides.</p>
257
- <p>Show the <a href={`${BASE}?view=audience&session=${SESSION}`} target="_blank" rel="noopener">audience window</a> on the projector from this computer: it follows the iPad.</p>
258
- <p class="home-note">The code works once, for ten minutes. {pairing.devices > 0 ? `${pairing.devices} paired so far.` : ''}</p>
185
+ <ul class="home-outputs">
186
+ {results.map(question => {
187
+ const { counts = [], voted, phones, average } = question.summary
188
+ const top = counts.slice(0, 3).map(row => `${row.answer} ${row.count}`).join(' · ')
189
+ return <li class="home-output" key={question.room}>
190
+ <div class="home-output-body">
191
+ <strong>{question.slide ? `Slide ${question.slide}: ` : ''}{question.question || question.room}</strong>
192
+ <span>{phones === 1 ? '1 phone' : `${phones} phones`}{voted != null && voted !== phones ? `, ${voted} counted` : ''}{question.closed ? ', closed' : ''}{average != null ? ` · average ${average}` : ''}{top ? ` · ${top}` : ''}</span>
259
193
  </div>
260
- </div>
261
- : <>
262
- <p>The iPad opens the same presenter view as this computer, laid out for its screen: you draw and go through the slides there while this computer drives the projector. Drawings are saved in the drawings file beside the deck.</p>
263
- <p class="home-note">Pairing is what lets the iPad save drawings and steer the audience window here. Other devices in the network can open the presenter view too, but only watch.</p>
264
- <button class="home-btn" onClick={offer}>Show pairing code</button>
265
- {pairing.devices > 0 && <p class="home-note">{pairing.devices === 1 ? 'One device' : `${pairing.devices} devices`} paired.</p>}
266
- </>}
194
+ </li>
195
+ })}
196
+ </ul>
197
+ <div class="home-answer-files">
198
+ <a class="home-btn home-btn--primary" href={href('csv')} download><HomeIcon name="download" size={15} />Answers (CSV)</a>
199
+ <a class="home-btn" href={href('summary')} download><HomeIcon name="download" size={15} />Summary (CSV)</a>
200
+ <a class="home-btn" href={href('json')} download><HomeIcon name="download" size={15} />Everything (JSON)</a>
201
+ </div>
202
+ <p class="home-hint">Kept in <code>{file}</code> beside the deck while <code>mdeck run</code> runs; phones are numbered per question, never named.</p>
267
203
  </section>
268
204
  }
269
205
 
206
+ // For decks with a `server` setting: the server's health, the viewers' link
207
+ // and the key (MDECK_SERVER_KEY) that lets a browser steer phones and reset
208
+ // polls.
209
+
270
210
  export function Home() {
271
211
  const [info, setInfo] = useState(null)
272
212
  const [error, setError] = useState('')
@@ -313,6 +253,11 @@ export function Home() {
313
253
  <p class="home-meta">{meta.join(' · ')}</p>
314
254
  <p class="home-file"><code>{info.file}</code> <button class="home-link" onClick={() => api('/action', { action: 'reveal', output: 'deck' }).catch(e => setError(e.message))}>Show</button></p>
315
255
  </div>
256
+ {/* mdeck run --server: whether the connection to the server is up. */}
257
+ {info.relay && <span class={`home-health home-relay${info.relay.state === 'up' ? '' : info.relay.state === 'refused' ? ' is-bad' : ' is-warn'}`} title={info.relay.reason ?? info.relay.url}>
258
+ <HomeIcon name={info.relay.state === 'up' ? 'check' : 'alert'} />
259
+ {info.relay.state === 'up' ? 'Connected to your server' : info.relay.state === 'refused' ? 'Server refused the connection' : info.relay.state === 'down' ? 'Reconnecting to your server…' : 'Connecting to your server…'}
260
+ </span>}
316
261
  <a class={`home-health${errors ? ' is-bad' : warnings ? ' is-warn' : ''}`} href="#check">
317
262
  <HomeIcon name={errors || warnings ? 'alert' : 'check'} />
318
263
  {errors ? `${errors} error${errors === 1 ? '' : 's'}` : warnings ? `${warnings} warning${warnings === 1 ? '' : 's'}` : 'All good'}
@@ -330,13 +275,14 @@ export function Home() {
330
275
  <ConnectedViews presence={presence} phones={!!info.live} />
331
276
  </div>
332
277
  <div class="home-tiles">
333
- <ViewTile icon="presenter" title="Presenter view" text="Present and draw, with notes, timer and next slide. Switch to slides only or fullscreen." href={`${BASE}?view=presenter&session=${SESSION}${code}`} open={openOn('presenter')} />
278
+ <ViewTile icon="presenter" title="Presenter view" text="Present and draw, with notes, timer and next slide. Pair an iPad or a phone clicker from it." href={`${BASE}?view=presenter&session=${SESSION}${code}`} open={openOn('presenter')} />
279
+ <ViewTile icon="rehearse" title="Rehearse" text="Go through the talk as if giving it: each slide is timed, and the times can be saved into the deck." href={`${BASE}?view=presenter&rehearse=1&session=${SESSION}${code}`} />
334
280
  <ViewTile icon="projector" title="Audience window" text="Slides for the projector, without notes. Navigation stays in sync with the presenter." href={`${BASE}?view=audience&session=${SESSION}${code}`} open={openOn('audience')} />
335
281
  <ViewTile icon="reader" title="Reader view" text="Outline, reading mode and look picker, as people you send it to see it." href={`${BASE}?view=reader`} />
336
282
  </div>
337
283
  </section>
338
284
 
339
- <section class={`home-section${info.live ? '' : ' home-section--wide'}`}>
285
+ <section class="home-section home-section--wide">
340
286
  <h2>Write and learn</h2>
341
287
  <div class="home-tiles">
342
288
  <ServiceTile icon="editor" title="Visual editor" text="Forms and a live preview; saves into the file and keeps a backup." action="editor" url={info.services.editor} onError={setError} onStarted={load} />
@@ -345,9 +291,9 @@ export function Home() {
345
291
  </div>
346
292
  </section>
347
293
 
348
- {info.live && <LivePolls live={info.live} presence={presence} onRecheck={load} />}
349
294
 
350
- {info.pairing && <Tablet pairing={info.pairing} relay={info.relay} presence={presence} onChange={load} onError={setError} />}
295
+ {info.results?.length > 0 && <Answers results={info.results} file={info.name.replace(/\.md$/i, '') + '.results.json'} onRefresh={load} />}
296
+
351
297
 
352
298
  <section class="home-section home-section--wide">
353
299
  <h2>Share</h2>
package/src/home/home.css CHANGED
@@ -80,6 +80,11 @@ button:focus-visible, a:focus-visible { outline: 2px solid var(--home-focus); ou
80
80
  .home-tile-body strong { font-weight: 600; color: var(--home-strong); font-size: 13px; }
81
81
  .home-tile-body span { color: var(--home-muted); font-size: 12px; overflow-wrap: anywhere; }
82
82
 
83
+ /* ── Answers from the talk ─────────────────────────────────────────── */
84
+ .home-answer-files { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
85
+ .home-answer-files a { text-decoration: none; }
86
+ .home-answers .home-hint { color: var(--home-faint); font-size: 12px; margin: 8px 0 0; }
87
+
83
88
  /* ── Share ────────────────────────────────────────────────────────────── */
84
89
  .home-outputs { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }
85
90
  .home-output { display: flex; gap: 12px; align-items: center; padding: 8px 10px; border: 1px solid var(--home-line); border-radius: var(--home-radius); background: var(--home-raised); }
@@ -90,8 +95,6 @@ button:focus-visible, a:focus-visible { outline: 2px solid var(--home-focus); ou
90
95
  .home-output-body details { margin-top: 6px; font-size: 12px; }
91
96
  .home-output-body pre { max-height: 220px; overflow: auto; padding: 8px; border-radius: 4px; background: var(--home-bg); white-space: pre-wrap; }
92
97
  .home-note { color: var(--home-warn) !important; }
93
- .home-relay--up { color: var(--home-ok) !important; }
94
- .home-relay--refused { color: var(--home-bad) !important; }
95
98
  .home-output-actions { flex: none; display: flex; align-items: center; gap: 6px; flex-wrap: wrap; justify-content: flex-end; }
96
99
  .home-status { font-size: 12px; color: var(--home-faint); min-width: 90px; text-align: right; }
97
100
  .home-status--done { color: var(--home-ok); }
@@ -117,17 +120,6 @@ button:focus-visible, a:focus-visible { outline: 2px solid var(--home-focus); ou
117
120
  .home-line { margin-left: auto; flex: none; color: var(--home-faint); font-size: 12px; }
118
121
 
119
122
  /* ── Live polls ───────────────────────────────────────────────────────── */
120
- .home-live { display: grid; gap: 10px; margin: 0; }
121
- .home-live > div { display: grid; grid-template-columns: 150px minmax(0, 1fr); gap: 12px; align-items: baseline; }
122
- .home-live dt { color: var(--home-muted); font-size: 12px; }
123
- .home-live dd { margin: 0; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 8px; min-width: 0; font-size: 13px; }
124
- .home-live dd > p { flex-basis: 100%; margin: 0; color: var(--home-muted); font-size: 12px; }
125
- .home-live code { overflow-wrap: anywhere; }
126
- .home-live a { color: var(--home-ink); }
127
- .home-code { padding: 1px 6px; border: 1px solid var(--home-control); border-radius: 4px; background: var(--home-button); color: var(--home-strong); }
128
- .home-pill { display: inline-block; padding: 0 8px; border: 1px solid var(--home-control); border-radius: 999px; font-size: 11px; color: var(--home-muted); }
129
- .home-pill.is-ok { border-color: #2c4a35; color: var(--home-ok); }
130
- .home-pill.is-bad { border-color: #5c2b28; color: var(--home-bad); }
131
123
 
132
124
  @media (max-width: 760px) {
133
125
  .home > :not(.home-header) { padding-left: 12px; padding-right: 12px; }
@@ -136,12 +128,8 @@ button:focus-visible, a:focus-visible { outline: 2px solid var(--home-focus); ou
136
128
  .home-output { flex-wrap: wrap; }
137
129
  .home-output-actions { width: 100%; justify-content: flex-start; }
138
130
  .home-status { text-align: left; min-width: 0; }
139
- .home-live > div { grid-template-columns: minmax(0, 1fr); gap: 2px; }
140
131
  }
141
132
 
142
- .home-pair { --qr-ink: #000; --qr-bg: #fff; display: flex; gap: 14px; align-items: flex-start; flex-wrap: wrap; }
143
- .home-pair canvas, .home-pair img, .home-pair svg { background: #fff; padding: 8px; border-radius: 6px; flex-shrink: 0; }
144
- .home-pair p, .home-tablet > p { font-size: 13px; margin: 0 0 8px; }
145
133
 
146
134
  /* A view that is open now, next to its title; the section head lists who is connected. */
147
135
  .home-open { margin-left: 8px; padding: 1px 7px; border-radius: 999px; font-size: 11px; font-weight: 500; color: var(--home-ok); background: rgba(129, 201, 149, 0.12); white-space: nowrap; }
@@ -6,6 +6,7 @@ import { registry } from '../runtime/registry'
6
6
  import { scanTags, closeTags } from '../core/tags.js'
7
7
  import { expandPollLists } from '../core/pollList.js'
8
8
  import { textDirection } from '../core/languages.js'
9
+ import { sourceLines, fenceState } from '../core/source.js'
9
10
  import { InkLayer } from '../runtime/ink/InkLayer.jsx'
10
11
  import themedSvgs from 'virtual:deck-svgs'
11
12
  import { renderCitations, citationsHtml as referencesOf, bibliographyOf } from '../runtime/citations.js'
@@ -15,19 +16,32 @@ import { renderCitations, citationsHtml as referencesOf, bibliographyOf } from '
15
16
  const FN_DEF_RE = /^\[\^([^\]\n]+)\]:\s+([^\n]+)/gm
16
17
  const FN_REF_RE = /\[\^([^\]\n]+)\]/g
17
18
 
19
+ // `change(text, lineStart)` applied to the prose of `markdown` only: fenced
20
+ // code blocks and `inline code` keep what is written in them.
21
+ function inProse(markdown, change) {
22
+ let fence = null
23
+ return sourceLines(markdown).map(line => {
24
+ const raw = markdown.slice(line.start, line.end)
25
+ const before = fence
26
+ fence = fenceState(line.text, fence)
27
+ if (before || fence) return raw
28
+ return raw.split(/((`+)[^`]*?\2)/).map((part, i) => i % 3 === 0 ? change(part, i === 0) : i % 3 === 1 ? part : '').join('')
29
+ }).join('')
30
+ }
31
+
18
32
  function preprocessFootnotes(markdown) {
19
33
  const defs = {}
20
- const stripped = markdown.replace(FN_DEF_RE, (_, label, text) => {
21
- defs[label] = text.trim()
34
+ const stripped = inProse(markdown, (text, lineStart) => lineStart ? text.replace(FN_DEF_RE, (_, label, note) => {
35
+ defs[label] = note.trim()
22
36
  return ''
23
- })
37
+ }) : text)
24
38
 
25
39
  let counter = 0
26
40
  const labelToNum = {}
27
- const processed = stripped.replace(FN_REF_RE, (_, label) => {
41
+ const processed = inProse(stripped, text => text.replace(FN_REF_RE, (_, label) => {
28
42
  if (labelToNum[label] === undefined) labelToNum[label] = ++counter
29
43
  return `<sup>${labelToNum[label]}</sup>`
30
- })
44
+ }))
31
45
 
32
46
  if (counter === 0) return { processed: markdown, footnotesHtml: '' }
33
47
 
@@ -94,15 +108,39 @@ export function inlineThemedPictures(html) {
94
108
  return out + html.slice(last)
95
109
  }
96
110
 
111
+ // A drawing in the page more than once (on two slides, or in a slide shown
112
+ // twice) would repeat its ids, and url(#…) and href="#…" find the first copy,
113
+ // which may be on a hidden slide: each copy gets ids of its own.
114
+ let drawingCopies = 0
115
+ export function ownIds(svg) {
116
+ const named = svg.querySelectorAll('[id]')
117
+ if (!named.length || svg.hasAttribute('data-own-ids')) return
118
+ const suffix = `--${++drawingCopies}`
119
+ const renamed = new Map([...named].map(element => [element.id, element.id + suffix]))
120
+ for (const element of named) element.id = renamed.get(element.id)
121
+ const swap = text => text.replace(/url\(\s*(['"]?)#([^'")\s]+)\1\s*\)/g, (match, quote, id) => renamed.has(id) ? `url(#${renamed.get(id)})` : match)
122
+ for (const element of svg.querySelectorAll('*')) {
123
+ for (const attribute of [...element.attributes]) {
124
+ const local = attribute.value.startsWith('#') && renamed.get(attribute.value.slice(1))
125
+ if (attribute.localName === 'href' && local) attribute.value = '#' + local
126
+ else if (attribute.value.includes('url(')) attribute.value = swap(attribute.value)
127
+ }
128
+ if (element.localName === 'style') element.textContent = swap(element.textContent)
129
+ }
130
+ svg.setAttribute('data-own-ids', '')
131
+ }
132
+
97
133
  // A picture from the slide's settings; `fit: cover` fills its box like a
98
134
  // photo, otherwise the drawing fits inside.
99
135
  export function Picture({ src, alt, fit, position, class: className, ...attributes }) {
136
+ const ref = useRef()
100
137
  const markup = themedSvg(src)
138
+ useEffect(() => { const svg = ref.current?.querySelector(':scope > svg'); if (svg) ownIds(svg) }, [markup])
101
139
  if (!markup) return <img class={className} src={src} alt={alt} style={{ objectFit: fit, objectPosition: position }} {...attributes} />
102
140
  const aspect = fit === 'cover' ? 'xMidYMid slice' : 'xMidYMid meet'
103
141
  // The box sets the size; the drawing scales inside it.
104
142
  const html = markup.replace(/<svg\b[^>]*>/, tag => tag.replace(/\s(width|height|preserveAspectRatio)="[^"]*"/g, '').replace('<svg', `<svg preserveAspectRatio="${aspect}"`))
105
- return <div class={['themed-picture', className].filter(Boolean).join(' ')} role="img" aria-label={alt} dangerouslySetInnerHTML={{ __html: html }} {...attributes} />
143
+ return <div ref={ref} class={['themed-picture', className].filter(Boolean).join(' ')} role="img" aria-label={alt} dangerouslySetInnerHTML={{ __html: html }} {...attributes} />
106
144
  }
107
145
 
108
146
  // ─── Hydration for inline components ──────────────────────────────────────
@@ -116,6 +154,7 @@ export function HtmlContent({ html, class: className, ...attributes }) {
116
154
 
117
155
  useEffect(() => {
118
156
  if (!ref.current) return
157
+ ref.current.querySelectorAll('.themed-picture > svg').forEach(ownIds)
119
158
  const mounted = []
120
159
  for (const [name, Component] of Object.entries(registry)) {
121
160
  for (const el of [...ref.current.querySelectorAll(name)]) {
@@ -197,8 +236,12 @@ export function prepareSlide(slide) {
197
236
  const position = region => region.source?.start ?? region.ranges?.[0]?.start ?? 0
198
237
  const entries = Object.entries(slide.regions ?? { body: { name: 'body', content: slide.content } })
199
238
  .sort((a, b) => position(a[1]) - position(b[1]))
200
- let separator = '\u0000mdeck-region\u0000'
201
- while (entries.some(([, r]) => r.content.includes(separator))) separator += '\u0000'
239
+ // The regions are scanned as one text, so numbered citations and footnotes
240
+ // run on across them. The separator stands on a line of its own: joined
241
+ // mid-line, a region's closing ``` fence read as an opening one, and the
242
+ // next region as code.
243
+ let separator = '\n\u0000mdeck-region\u0000\n'
244
+ while (entries.some(([, r]) => r.content.includes(separator.trim()))) separator = separator.replace('\u0000\n', '\u0000\u0000\n')
202
245
  const bib = bibliographyOf(slide.deckConfig)
203
246
  const cited = renderCitations(entries.map(([, r]) => r.content).join(separator), bib)
204
247
  const { processed, footnotesHtml } = preprocessFootnotes(cited.markdown)
@@ -2,12 +2,24 @@ import { h } from 'preact'
2
2
  import { layouts } from 'virtual:mdeck-extensions'
3
3
  import { resolveLayoutProps } from './layoutProps'
4
4
  import { SlideFrame, prepareSlide } from './layoutApi'
5
+ import { includeOf } from '../core/parseSlides.js'
5
6
 
6
7
  // Every layout — built-in or deck-local — arrives through the same registry.
7
8
  export { layouts }
8
9
  export const manifests = Object.fromEntries(Object.entries(layouts).map(([id, t]) => [id, t.manifest]))
9
10
 
10
- export function SlideRenderer(slide) {
11
+ // An include that reaches the page is one the deck was not expanded for: the
12
+ // editor works on the file as written (build/include.js). It shows as a card
13
+ // naming the file, in the editor's own words.
14
+ function includeCard(slide) {
15
+ const include = includeOf(slide.content)
16
+ if (!include) return slide
17
+ const content = `## Slides from \`${include.file}\`\n\n${include.slides ? `Slides ${include.slides} of that file appear` : 'The slides of that file appear'} here when the deck is presented. Edit them in that file.`
18
+ return { ...slide, content, regions: { ...slide.regions, body: { ...slide.regions?.body, name: 'body', content, explicit: false } } }
19
+ }
20
+
21
+ export function SlideRenderer(given) {
22
+ const slide = includeCard(given)
11
23
  const id = slide.meta.layout ?? 'generic'
12
24
  const entry = Object.hasOwn(layouts, id) ? layouts[id] : layouts.generic
13
25
  const prepared = prepareSlide(slide)
@@ -52,3 +52,12 @@ footer { padding: 16px 20px; text-align: center; color: var(--muted); font-size:
52
52
  .answer-number { display: grid; grid-template-columns: 1fr auto; gap: 12px; }
53
53
  .answer-number input { width: 100%; min-width: 0; padding: 14px; border: 2px solid var(--rule); border-radius: 14px; background: var(--bg); color: var(--ink); font: inherit; font-size: 22px; font-variant-numeric: tabular-nums; }
54
54
  .answer-number button[type=submit] { background: var(--accent); border-color: var(--accent); color: var(--on-accent, var(--bg)); }
55
+ /* Reactions: a row of emoji that float up on the presenter's screen. */
56
+ .answer-reactions { display: flex; flex-wrap: wrap; justify-content: center; gap: 8px; width: 100%; max-width: 520px; margin: 0 auto; padding: 0 20px 8px; }
57
+ .answer-reactions button {
58
+ min-width: 46px; height: 46px; padding: 0 10px; border: 1px solid var(--rule); border-radius: 23px; background: var(--bg); color: var(--ink);
59
+ font: inherit; font-size: 24px; line-height: 1; cursor: pointer; touch-action: manipulation; -webkit-tap-highlight-color: transparent;
60
+ }
61
+ .answer-reactions button.is-sent { animation: answer-react .5s ease-out; }
62
+ @keyframes answer-react { 30% { transform: scale(1.25); border-color: var(--accent); } 100% { transform: scale(1); } }
63
+ @media (prefers-reduced-motion: reduce) { .answer-reactions button.is-sent { animation: none; border-color: var(--accent); } }
@@ -12,12 +12,14 @@
12
12
  pickSeveral: 'Tap all that apply.', thanksSeveral: 'Thanks! You chose {choice}. Tap to change.', closed: 'Closed: no more answers.',
13
13
  right: 'Right!', wrong: 'Not quite. The right answer: {answer}', solution: 'The right answer: {answer}',
14
14
  number: 'A number', invalid: 'Enter a number, such as 0.5 or 1/2.', numberSent: 'Sent: {value}. Send another to change.',
15
+ react: 'React',
15
16
  },
16
17
  de: {
17
18
  waiting: 'Die nächste Frage erscheint hier.', pick: 'Eine Antwort antippen.', thanks: 'Danke! Gewählt: „{choice}“. Zum Ändern eine andere antippen.', send: 'Senden', sent: 'Gesendet. Danke!', offline: 'Verbinde…',
18
19
  pickSeveral: 'Alle zutreffenden antippen.', thanksSeveral: 'Danke! Gewählt: {choice}. Zum Ändern antippen.', closed: 'Geschlossen: keine weiteren Antworten.',
19
20
  right: 'Richtig!', wrong: 'Leider nicht. Richtig ist: {answer}', solution: 'Richtig ist: {answer}',
20
21
  number: 'Eine Zahl', invalid: 'Eine Zahl eingeben, z. B. 0,5 oder 1/2.', numberSent: 'Gesendet: {value}. Zum Ändern eine andere senden.',
22
+ react: 'Reagieren',
21
23
  },
22
24
  }
23
25
  let words = FALLBACK[navigator.language?.slice(0, 2) === 'de' ? 'de' : 'en']
@@ -191,17 +193,23 @@
191
193
  solution ? verdict(sent != null, right, drawn(solution.html, solution.text)) : null,
192
194
  ]
193
195
  },
194
- // { question, placeholder, maxLength }: short text, as often as people like.
196
+ // { question, placeholder, maxLength, once }: short text, as often as
197
+ // people like; with `once` (a word cloud) the latest one counts, and the
198
+ // phone says which it sent.
195
199
  text(activity, room) {
196
- const input = el('textarea', { rows: 3, maxlength: activity.maxLength ?? 200, placeholder: activity.placeholder ?? '', disabled: !!controls.closed })
200
+ const sent = activity.once ? remembered(room) : null
201
+ const input = el('textarea', { rows: activity.once ? 1 : 3, maxlength: activity.maxLength ?? 200, placeholder: activity.placeholder ?? '', disabled: !!controls.closed })
197
202
  const form = el('form', { class: 'answer-text', onsubmit: async event => {
198
203
  event.preventDefault()
199
204
  const value = input.value.trim()
200
205
  if (!value) return
201
- try { await answer(room, value); status = words.sent } catch (error) { status = error.message }
206
+ try {
207
+ await answer(room, value)
208
+ if (activity.once) { remember(room, value); status = '' } else status = words.sent
209
+ } catch (error) { status = error.message }
202
210
  render()
203
211
  } }, input, el('button', { type: 'submit', disabled: !!controls.closed }, words.send))
204
- return [form, statusLine('')]
212
+ return [form, statusLine(sent != null ? fill(words.numberSent, { value: sent }) : '')]
205
213
  },
206
214
  // { question, min, max, minLabel, maxLabel }: a number on a scale.
207
215
  scale(activity, room) {
@@ -222,6 +230,25 @@
222
230
  },
223
231
  }
224
232
 
233
+ // Reactions (src/core/reactions.js): a row of buttons whenever the deck
234
+ // offers them; a tap floats up on the presenter's screen. One tap a
235
+ // second at most, so a thumb on a button is not a flood.
236
+ const REACT_MS = 1000
237
+ let reactedAt = 0
238
+ async function react(value, button) {
239
+ const now = Date.now()
240
+ if (now - reactedAt < REACT_MS) return
241
+ reactedAt = now
242
+ button.classList.remove('is-sent')
243
+ void button.offsetWidth
244
+ button.classList.add('is-sent')
245
+ try {
246
+ await fetch(new URL(`rooms/${encodeURIComponent(`${code}.reactions`)}`, base), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ from: me, data: { value } }) })
247
+ } catch {}
248
+ }
249
+ const reactions = list => el('nav', { class: 'answer-reactions', 'aria-label': words.react ?? 'React' },
250
+ list.map(value => el('button', { type: 'button', 'aria-label': value, onclick: event => react(value, event.currentTarget) }, value)))
251
+
225
252
  function render() {
226
253
  const activity = current?.activity
227
254
  const form = activity && FORMS[activity.type]
@@ -230,6 +257,7 @@
230
257
  form
231
258
  ? el('main', { class: 'answer' }, activity.question && el('h1', {}, drawn(activity.questionHtml, activity.question)), form(activity, current.room))
232
259
  : el('main', { class: 'answer answer--waiting' }, el('p', {}, connected ? words.waiting : words.offline)),
260
+ connected && Array.isArray(current?.reactions) && current.reactions.length ? reactions(current.reactions.filter(value => typeof value === 'string').slice(0, 8)) : null,
233
261
  current?.title ? el('footer', {}, current.title) : null,
234
262
  // The presenter's screen sends the link when other devices can follow the slides.
235
263
  /^https?:\/\//.test(current?.follow ?? '') ? el('a', { class: 'answer-follow', href: current.follow }, words.follow ?? 'Follow the slides') : null,
@@ -13,11 +13,12 @@ import { pairToken, pairHeaders } from './pairing.js'
13
13
  import { keptAnswers, answersAsMessages } from '../core/results.js'
14
14
  import { devPath } from '../core/devPath.js'
15
15
  import { t, deckLanguage } from '../core/labels.js'
16
+ import { reactionsOf, REACTIONS_ROOM } from '../core/reactions.js'
16
17
 
17
18
  // The QR code component the built-in poll uses, for your own activities.
18
19
  export { default as QrCode } from '../components/QrCode.jsx'
19
20
 
20
- let settings = { server: null, code: '000000' }
21
+ let settings = { server: null, code: '000000', reactions: null }
21
22
 
22
23
  // Called by the runtime with the deck settings.
23
24
  // `mdeck run --server <address>` and MDECK_SERVER name the server without the
@@ -26,7 +27,7 @@ const givenServer = typeof __MDECK_SERVER__ === 'string' ? __MDECK_SERVER__ : nu
26
27
 
27
28
  export function configureLive(deckConfig = {}) {
28
29
  const server = givenServer ?? deckConfig.server
29
- settings = { server: typeof server === 'string' ? server.replace(/\/+$/, '') : null, code: sessionCode(deckConfig) }
30
+ settings = { server: typeof server === 'string' ? server.replace(/\/+$/, '') : null, code: sessionCode(deckConfig), reactions: reactionsOf(deckConfig) }
30
31
  infoRequest = null
31
32
  takeKeyFromAddress()
32
33
  }
@@ -211,6 +212,22 @@ export function useRoom(room) {
211
212
  return { messages: kept ? answersAsMessages(kept.answers) : messages, reset, state, setState, present, connected, canReset: !!info.canReset, joinUrl: joinUrl(info), localJoinUrl: localJoinUrl(), code: settings.code, ...reach(info) }
212
213
  }
213
214
 
215
+ /**
216
+ * Reactions sent from the phones, for the presenter's screen:
217
+ * `onReaction({ value, at, n })` for each one as it arrives. Returns a
218
+ * function that stops listening. Nothing happens without a room server.
219
+ */
220
+ export function listenToReactions(onReaction) {
221
+ if (typeof EventSource === 'undefined') return () => {}
222
+ const source = new EventSource(`${roomPath(REACTIONS_ROOM)}/events`)
223
+ source.addEventListener('message', event => {
224
+ const message = JSON.parse(event.data)
225
+ const value = message.data?.value
226
+ if (typeof value === 'string' && settings.reactions?.includes(value)) onReaction({ value, at: message.at, n: message.n })
227
+ })
228
+ return () => source.close()
229
+ }
230
+
214
231
  /** The deck's join link alone, for a slide that only invites people in (<qrcode join />). */
215
232
  export function useJoinLink() {
216
233
  const [info, setInfo] = useState({})
@@ -259,7 +276,7 @@ export function captureLook(doc = lookSource()) {
259
276
 
260
277
  // The words the answer page shows, in the deck's language.
261
278
  const phoneWords = () => ({
262
- waiting: t('respond.waiting'), pick: t('poll.pick'), thanks: t('poll.thanks'), send: t('respond.send'), sent: t('respond.sent'), follow: t('follow.open'),
279
+ waiting: t('respond.waiting'), pick: t('poll.pick'), thanks: t('poll.thanks'), send: t('respond.send'), sent: t('respond.sent'), follow: t('follow.open'), react: t('respond.react'),
263
280
  pickSeveral: t('poll.pickSeveral'), thanksSeveral: t('poll.thanksSeveral'), several: t('poll.several'),
264
281
  closed: t('respond.closed'), right: t('respond.right'), wrong: t('respond.wrong'), solution: t('respond.solution'),
265
282
  number: t('numeric.placeholder'), invalid: t('numeric.invalid'), numberSent: t('numeric.sent'),
@@ -304,7 +321,9 @@ async function sendCurrent() {
304
321
  if (look) lastLook = look
305
322
  // Phones that answer can also follow the slides, where that works.
306
323
  const follow = followUrl(info)
307
- const state = { ...current, look: lastLook, lang: deckLanguage(), labels: phoneWords(), ...(follow ? { follow } : {}) }
324
+ // The reactions the phones offer, whatever is on the slide.
325
+ const reactions = settings.reactions ? { reactions: settings.reactions } : {}
326
+ const state = { ...current, look: lastLook, lang: deckLanguage(), labels: phoneWords(), ...reactions, ...(follow ? { follow } : {}) }
308
327
  try {
309
328
  const response = await fetch(`${controlSessionPath()}/state`, { method: 'POST', headers: withKey({ 'Content-Type': 'application/json' }), body: JSON.stringify({ state }) })
310
329
  const body = response.ok ? await response.json().catch(() => ({})) : {}
package/src/live/rooms.js CHANGED
@@ -9,11 +9,17 @@ import { timingSafeEqual } from 'node:crypto'
9
9
  import { readFileSync } from 'node:fs'
10
10
  import { CODE_RE } from './code.js'
11
11
 
12
+ // This mdeck's version, in /info: a presenter on a newer mdeck can tell that
13
+ // this server lacks what its deck uses (src/build/homePlugin.js).
14
+ const VERSION = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')).version
15
+
12
16
  // The phones' answer page, served by the server itself at /<code>.
13
17
  const ANSWER_FILES = Object.fromEntries(['answer.html', 'answer.js', 'answer.css'].map(name => [name, readFileSync(new URL(`./answer/${name}`, import.meta.url))]))
14
18
  const ANSWER_TYPES = { html: 'text/html; charset=utf-8', js: 'text/javascript; charset=utf-8', css: 'text/css; charset=utf-8' }
15
19
 
16
20
  export const ROOM_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/
21
+ // A session's reactions room: `<code>.reactions`.
22
+ const isReactionRoom = id => id.endsWith('.reactions')
17
23
  // What a connected window may say it is, for the presenter's overview of
18
24
  // who is connected (src/components/ConnectedViews.jsx); phones only count.
19
25
  export const VIEWS = ['presenter', 'audience', 'deck', 'phone']
@@ -46,9 +52,11 @@ export function createRooms({ maxMessages = 5000, maxInk = 5000, maxRooms = 500,
46
52
  return {
47
53
  post(id, { from = null, data }) {
48
54
  const room = get(id)
49
- if (room.messages.length >= maxMessages) throw Object.assign(new Error('This room is full'), { status: 429 })
55
+ // Reactions (src/core/reactions.js) are passed on and forgotten.
56
+ const keep = !isReactionRoom(id)
57
+ if (keep && room.messages.length >= maxMessages) throw Object.assign(new Error('This room is full'), { status: 429 })
50
58
  const message = { n: room.next++, at: new Date(now()).toISOString(), from, data }
51
- room.messages.push(message)
59
+ if (keep) room.messages.push(message)
52
60
  broadcast(room, { type: 'message', message })
53
61
  return message
54
62
  },
@@ -181,12 +189,13 @@ function reply(response, status, body) {
181
189
  // ?presence=1 adds who is connected to the session
182
190
  // ?only=state sends the room's state alone
183
191
  // GET /rooms/<id>/presence who is connected to the session, once
184
- // POST /rooms/<id> { from, data } → { n }
192
+ // POST /rooms/<id> { from, data } → { n }; in <code>.reactions
193
+ // passed on without being kept
185
194
  // POST /rooms/<id>/reset presenter only
186
195
  // POST /rooms/<id>/state { state } — presenter only
187
196
  // POST /rooms/<id>/ink { messages } — presenter only, live ink
188
197
  // Answers carry no cookies and no personal data, so any page may use them.
189
- export function liveHandler({ rooms = createRooms(), canReset = () => false, info = () => ({}), limit = createLimiter(), inkLimit = createLimiter({ burst: 60, perMs: 3000 }), heartbeatMs = 20000 } = {}) {
198
+ export function liveHandler({ rooms = createRooms(), canReset = () => false, info = () => ({}), limit = createLimiter(), inkLimit = createLimiter({ burst: 60, perMs: 3000 }), reactionLimit = createLimiter({ burst: 120, perMs: 10000 }), heartbeatMs = 20000 } = {}) {
190
199
  return async (request, response, next = () => reply(response, 404, { error: 'Not found' })) => {
191
200
  const { pathname, searchParams } = new URL(request.url, 'http://localhost')
192
201
  response.setHeader('Access-Control-Allow-Origin', '*')
@@ -194,7 +203,7 @@ export function liveHandler({ rooms = createRooms(), canReset = () => false, inf
194
203
  response.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
195
204
  if (request.method === 'OPTIONS') { response.writeHead(204); response.end(); return }
196
205
  try {
197
- if (pathname === '/info' && request.method === 'GET') return reply(response, 200, { canReset: !!canReset(request, searchParams.get('session')), ...info(request) })
206
+ if (pathname === '/info' && request.method === 'GET') return reply(response, 200, { canReset: !!canReset(request, searchParams.get('session')), version: VERSION, ...info(request) })
198
207
  const page = pathname.slice(1)
199
208
  if (request.method === 'GET' && (CODE_RE.test(page) || Object.hasOwn(ANSWER_FILES, page))) {
200
209
  const name = CODE_RE.test(page) ? 'answer.html' : page
@@ -240,8 +249,11 @@ export function liveHandler({ rooms = createRooms(), canReset = () => false, inf
240
249
  else if (!rooms.setState(id, (await readBody(request, MAX_STATE)).state)) return reply(response, 200, { ok: true, ignored: true })
241
250
  return reply(response, 200, { ok: true })
242
251
  }
243
- if (!limit(clientAddress(request))) return reply(response, 429, { error: 'Too many answers at once; wait a moment' })
252
+ // Reactions have a limit of their own: a room behind one address (a
253
+ // lecture hall's network) must not use up its answers with them.
254
+ if (!(isReactionRoom(id) ? reactionLimit : limit)(clientAddress(request))) return reply(response, 429, { error: 'Too many answers at once; wait a moment' })
244
255
  const body = await readBody(request)
256
+ if (isReactionRoom(id) && (typeof body.data?.value !== 'string' || body.data.value.length > 16)) return reply(response, 400, { error: 'A reaction is a short text' })
245
257
  if (!('data' in body)) return reply(response, 400, { error: 'Send { data }' })
246
258
  const from = typeof body.from === 'string' ? body.from.slice(0, 64) : null
247
259
  return reply(response, 201, { n: rooms.post(id, { from, data: body.data }).n })