mdeck 3.11.0 → 3.12.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 +28 -0
- package/README.md +3 -3
- package/assets/base.css +2 -0
- package/docs/reference/authoring.md +48 -7
- package/docs/site/content/appearance.md +1 -1
- package/docs/site/content/audience.md +36 -8
- package/docs/site/content/commands.md +4 -2
- package/docs/site/content/drawing.md +4 -4
- package/docs/site/content/getting-started.md +2 -32
- package/docs/site/content/install.md +3 -1
- package/docs/site/content/presenting.md +29 -8
- package/docs/site/content/writing.md +27 -0
- package/examples/showcase/slides.ar.md +5 -5
- package/examples/showcase/slides.de.md +5 -5
- package/examples/showcase/slides.de.results.json +27 -0
- package/examples/showcase/slides.en.md +5 -5
- package/package.json +1 -1
- package/skills/write-slides/SKILL.md +4 -1
- package/src/build/check.js +27 -6
- package/src/build/components.js +5 -3
- package/src/build/homePlugin.js +64 -14
- package/src/build/include.js +249 -0
- package/src/build/inkPlugin.js +12 -2
- package/src/build/relayAccess.js +3 -1
- package/src/build/renderCheck.js +23 -3
- package/src/build/slidesPlugin.js +64 -16
- package/src/build/timingPlugin.js +34 -18
- package/src/cli/args.js +3 -3
- package/src/cli/index.js +49 -10
- package/src/components/Bibliography.jsx +7 -2
- package/src/components/Poll.jsx +2 -2
- package/src/components/QrCodeTag.jsx +2 -2
- package/src/components/WordCloud.jsx +7 -6
- package/src/components/activity.jsx +11 -8
- package/src/components/poll.css +2 -0
- package/src/core/icons.js +6 -1
- package/src/core/labels.js +3 -0
- package/src/core/parseSlides.js +14 -4
- package/src/core/reactions.js +27 -0
- package/src/core/resultsReport.js +153 -0
- package/src/core/source.js +8 -1
- package/src/core/validateDeck.js +6 -2
- package/src/home/Home.jsx +38 -93
- package/src/home/home.css +5 -17
- package/src/layouts/renderSlide.jsx +13 -1
- package/src/live/answer/answer.css +12 -1
- package/src/live/answer/answer.js +32 -4
- package/src/live/client.js +23 -4
- package/src/live/rooms.js +29 -7
- package/src/live/server.js +15 -1
- package/src/runtime/KeyHelp.jsx +115 -0
- package/src/runtime/PresenterChrome.jsx +168 -0
- package/src/runtime/Reactions.jsx +56 -0
- package/src/runtime/deck-stage.js +322 -17
- package/src/runtime/helpKeys.js +75 -0
- package/src/runtime/ink/room.js +6 -6
- package/src/runtime/main.jsx +396 -303
- package/src/runtime/slideControls.js +60 -0
- package/src/runtime/useTalkTimer.js +4 -2
package/src/core/validateDeck.js
CHANGED
|
@@ -6,6 +6,7 @@ import { resolveLayoutProps, propertyErrors } from '../layouts/layoutProps.js'
|
|
|
6
6
|
import { movedHint } from '../extensions/moved.js'
|
|
7
7
|
import { paramValue } from '../extensions/params.js'
|
|
8
8
|
import { parseTime } from './timing.js'
|
|
9
|
+
import { reactionsProblem } from './reactions.js'
|
|
9
10
|
|
|
10
11
|
// Hotspots (runtime/hotspots.js): the deck's setting and a slide's own.
|
|
11
12
|
const HOTSPOT_SETTINGS = ['auto', 'marked', 'off', true, false]
|
|
@@ -56,6 +57,7 @@ export function validateDeck(deck, { layouts = null, themes = null, palettes = n
|
|
|
56
57
|
}
|
|
57
58
|
if (config.hotspots != null && !HOTSPOT_SETTINGS.includes(config.hotspots)) add('invalid-config', 'hotspots must be auto, marked, off, true or false', deck.configSource?.start)
|
|
58
59
|
if (config.time != null && parseTime(config.time) == null) add('invalid-config', TIME_HINT(config.time), deck.configSource?.start)
|
|
60
|
+
if (reactionsProblem(config.reactions)) add('invalid-config', reactionsProblem(config.reactions), deck.configSource?.start)
|
|
59
61
|
const enums = { organization: ['title', 'all', 'none'], author: ['title', 'all', 'none'], numbers: ['slides', 'all', 'none'], sections: ['all', 'none'] }
|
|
60
62
|
const switches = ['citations']
|
|
61
63
|
if (isPlainObject(config.show)) {
|
|
@@ -102,6 +104,7 @@ export function validateDeck(deck, { layouts = null, themes = null, palettes = n
|
|
|
102
104
|
if (slide.meta.hotspots != null && !HOTSPOT_SETTINGS.includes(slide.meta.hotspots)) add('invalid-metadata', 'hotspots must be auto, marked, off, true or false', offset)
|
|
103
105
|
if (slide.meta.time != null && parseTime(slide.meta.time) == null) add('invalid-metadata', TIME_HINT(slide.meta.time), offset)
|
|
104
106
|
for (const node of directivesIn(deck.source.slice(slide.source.start, slide.source.end))) {
|
|
107
|
+
if (node.name === 'include' && !slide.include) add('misplaced-include', ':::include stands alone on its slide, with no settings or other content: :::include ./part.md, or :::include ./part.md slides=2-5', slide.source.start + node.start, 'warning')
|
|
105
108
|
if (DIRECTIVES.includes(node.name) || calloutTypes.includes(node.name.toLowerCase())) continue
|
|
106
109
|
const near = closest(node.name.toLowerCase(), [...DIRECTIVES, ...calloutTypes])
|
|
107
110
|
add('unknown-callout', `Unknown block "${node.name}"${near ? `; did you mean "${near}"?` : ''} It shows as a plain callout. Known: ${CALLOUT_TYPES.join(', ')}; name your own under callouts: in the settings`, slide.source.start + node.start, 'warning')
|
|
@@ -134,7 +137,7 @@ export function validateDeck(deck, { layouts = null, themes = null, palettes = n
|
|
|
134
137
|
}
|
|
135
138
|
|
|
136
139
|
// The `:::` blocks mdeck reads itself; any other name is a callout type.
|
|
137
|
-
const DIRECTIVES = ['meta', 'notes', 'slot', 'steps', 'columns']
|
|
140
|
+
const DIRECTIVES = ['meta', 'notes', 'slot', 'steps', 'columns', 'include']
|
|
138
141
|
|
|
139
142
|
function directivesIn(source) {
|
|
140
143
|
const all = []
|
|
@@ -159,6 +162,7 @@ function closest(name, names) {
|
|
|
159
162
|
return best && best[1] <= 2 ? best[0] : null
|
|
160
163
|
}
|
|
161
164
|
|
|
165
|
+
// A diagnostic with a `file` of its own (one in an included deck) names it.
|
|
162
166
|
export function formatDiagnostics(diagnostics, filename = 'slides.md') {
|
|
163
|
-
return diagnostics.map(d => `${filename}:${d.line}:${d.column ?? 1}: ${d.severity} [${d.code}] ${d.message}`).join('\n')
|
|
167
|
+
return diagnostics.map(d => `${d.file ?? filename}:${d.line}:${d.column ?? 1}: ${d.severity} [${d.code}] ${d.message}`).join('\n')
|
|
164
168
|
}
|
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
|
|
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
|
-
//
|
|
187
|
-
//
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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>
|
|
195
|
-
<button class="home-btn home-btn--small" onClick={
|
|
182
|
+
<h2>Answers from the talk</h2>
|
|
183
|
+
<button class="home-btn home-btn--small" onClick={onRefresh}>Refresh</button>
|
|
196
184
|
</div>
|
|
197
|
-
<
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
</
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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,14 +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.
|
|
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')} />
|
|
334
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}`} />
|
|
335
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')} />
|
|
336
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`} />
|
|
337
282
|
</div>
|
|
338
283
|
</section>
|
|
339
284
|
|
|
340
|
-
<section class=
|
|
285
|
+
<section class="home-section home-section--wide">
|
|
341
286
|
<h2>Write and learn</h2>
|
|
342
287
|
<div class="home-tiles">
|
|
343
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} />
|
|
@@ -346,9 +291,9 @@ export function Home() {
|
|
|
346
291
|
</div>
|
|
347
292
|
</section>
|
|
348
293
|
|
|
349
|
-
{info.live && <LivePolls live={info.live} presence={presence} onRecheck={load} />}
|
|
350
294
|
|
|
351
|
-
{info.
|
|
295
|
+
{info.results?.length > 0 && <Answers results={info.results} file={info.name.replace(/\.md$/i, '') + '.results.json'} onRefresh={load} />}
|
|
296
|
+
|
|
352
297
|
|
|
353
298
|
<section class="home-section home-section--wide">
|
|
354
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; }
|
|
@@ -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
|
-
|
|
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)
|
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
}
|
|
7
7
|
* { box-sizing: border-box; }
|
|
8
8
|
html, body { margin: 0; min-height: 100%; background: var(--bg); color: var(--ink); }
|
|
9
|
-
|
|
9
|
+
/* The visible height (dvh): on a phone, 100vh is taller than what shows beside
|
|
10
|
+
the browser's bars, and the reactions at the bottom slid out of sight. */
|
|
11
|
+
body { min-height: 100vh; min-height: 100dvh; padding-bottom: env(safe-area-inset-bottom); box-sizing: border-box; display: flex; flex-direction: column; font: 18px/1.45 var(--font-body); -webkit-font-smoothing: antialiased; }
|
|
10
12
|
#answer { flex: 1; display: flex; flex-direction: column; }
|
|
11
13
|
.answer { flex: 1; width: 100%; max-width: 520px; margin: 0 auto; padding: 32px 20px; }
|
|
12
14
|
.answer h1 {
|
|
@@ -52,3 +54,12 @@ footer { padding: 16px 20px; text-align: center; color: var(--muted); font-size:
|
|
|
52
54
|
.answer-number { display: grid; grid-template-columns: 1fr auto; gap: 12px; }
|
|
53
55
|
.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
56
|
.answer-number button[type=submit] { background: var(--accent); border-color: var(--accent); color: var(--on-accent, var(--bg)); }
|
|
57
|
+
/* Reactions: a row of emoji that float up on the presenter's screen. */
|
|
58
|
+
.answer-reactions { display: flex; flex-wrap: wrap; justify-content: center; gap: 8px; width: 100%; max-width: 520px; margin: 0 auto; padding: 0 20px 8px; }
|
|
59
|
+
.answer-reactions button {
|
|
60
|
+
min-width: 46px; height: 46px; padding: 0 10px; border: 1px solid var(--rule); border-radius: 23px; background: var(--bg); color: var(--ink);
|
|
61
|
+
font: inherit; font-size: 24px; line-height: 1; cursor: pointer; touch-action: manipulation; -webkit-tap-highlight-color: transparent;
|
|
62
|
+
}
|
|
63
|
+
.answer-reactions button.is-sent { animation: answer-react .5s ease-out; }
|
|
64
|
+
@keyframes answer-react { 30% { transform: scale(1.25); border-color: var(--accent); } 100% { transform: scale(1); } }
|
|
65
|
+
@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
|
|
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
|
|
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 {
|
|
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,
|
package/src/live/client.js
CHANGED
|
@@ -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
|
-
|
|
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']
|
|
@@ -37,18 +43,30 @@ export function createRooms({ maxMessages = 5000, maxInk = 5000, maxRooms = 500,
|
|
|
37
43
|
const broadcast = (room, event) => { for (const listener of room.listeners) listener(event) }
|
|
38
44
|
// A session is a room and the rooms named after it (`<code>.stage`, …).
|
|
39
45
|
const sessionRooms = id => { const session = id.split('.')[0]; return [...rooms].filter(([name]) => name === session || name.startsWith(`${session}.`)).map(([, room]) => room) }
|
|
46
|
+
// Phones connected elsewhere, by session: mdeck run keeps the stage room
|
|
47
|
+
// here but the phones join a standalone server (src/live/server.js).
|
|
48
|
+
const awayPhones = new Map()
|
|
40
49
|
const presence = id => {
|
|
41
50
|
const present = sessionRooms(id).flatMap(room => [...room.present.values()])
|
|
42
51
|
const views = present.filter(entry => entry.view !== 'phone').sort((a, b) => VIEWS.indexOf(a.view) - VIEWS.indexOf(b.view))
|
|
43
|
-
return { views, phones: present.length - views.length }
|
|
52
|
+
return { views, phones: present.length - views.length + (awayPhones.get(id.split('.')[0]) ?? 0) }
|
|
44
53
|
}
|
|
45
54
|
const announcePresence = id => { const event = { type: 'presence', presence: presence(id) }; for (const room of sessionRooms(id)) broadcast(room, event) }
|
|
46
55
|
return {
|
|
56
|
+
/** Phones of session `id` that joined another server; announced when the number changes. */
|
|
57
|
+
setAwayPhones(id, n) {
|
|
58
|
+
const session = id.split('.')[0]
|
|
59
|
+
if ((awayPhones.get(session) ?? 0) === n) return
|
|
60
|
+
awayPhones.set(session, n)
|
|
61
|
+
announcePresence(session)
|
|
62
|
+
},
|
|
47
63
|
post(id, { from = null, data }) {
|
|
48
64
|
const room = get(id)
|
|
49
|
-
|
|
65
|
+
// Reactions (src/core/reactions.js) are passed on and forgotten.
|
|
66
|
+
const keep = !isReactionRoom(id)
|
|
67
|
+
if (keep && room.messages.length >= maxMessages) throw Object.assign(new Error('This room is full'), { status: 429 })
|
|
50
68
|
const message = { n: room.next++, at: new Date(now()).toISOString(), from, data }
|
|
51
|
-
room.messages.push(message)
|
|
69
|
+
if (keep) room.messages.push(message)
|
|
52
70
|
broadcast(room, { type: 'message', message })
|
|
53
71
|
return message
|
|
54
72
|
},
|
|
@@ -181,12 +199,13 @@ function reply(response, status, body) {
|
|
|
181
199
|
// ?presence=1 adds who is connected to the session
|
|
182
200
|
// ?only=state sends the room's state alone
|
|
183
201
|
// GET /rooms/<id>/presence who is connected to the session, once
|
|
184
|
-
// POST /rooms/<id> { from, data } → { n }
|
|
202
|
+
// POST /rooms/<id> { from, data } → { n }; in <code>.reactions
|
|
203
|
+
// passed on without being kept
|
|
185
204
|
// POST /rooms/<id>/reset presenter only
|
|
186
205
|
// POST /rooms/<id>/state { state } — presenter only
|
|
187
206
|
// POST /rooms/<id>/ink { messages } — presenter only, live ink
|
|
188
207
|
// 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 } = {}) {
|
|
208
|
+
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
209
|
return async (request, response, next = () => reply(response, 404, { error: 'Not found' })) => {
|
|
191
210
|
const { pathname, searchParams } = new URL(request.url, 'http://localhost')
|
|
192
211
|
response.setHeader('Access-Control-Allow-Origin', '*')
|
|
@@ -194,7 +213,7 @@ export function liveHandler({ rooms = createRooms(), canReset = () => false, inf
|
|
|
194
213
|
response.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
|
|
195
214
|
if (request.method === 'OPTIONS') { response.writeHead(204); response.end(); return }
|
|
196
215
|
try {
|
|
197
|
-
if (pathname === '/info' && request.method === 'GET') return reply(response, 200, { canReset: !!canReset(request, searchParams.get('session')), ...info(request) })
|
|
216
|
+
if (pathname === '/info' && request.method === 'GET') return reply(response, 200, { canReset: !!canReset(request, searchParams.get('session')), version: VERSION, ...info(request) })
|
|
198
217
|
const page = pathname.slice(1)
|
|
199
218
|
if (request.method === 'GET' && (CODE_RE.test(page) || Object.hasOwn(ANSWER_FILES, page))) {
|
|
200
219
|
const name = CODE_RE.test(page) ? 'answer.html' : page
|
|
@@ -240,8 +259,11 @@ export function liveHandler({ rooms = createRooms(), canReset = () => false, inf
|
|
|
240
259
|
else if (!rooms.setState(id, (await readBody(request, MAX_STATE)).state)) return reply(response, 200, { ok: true, ignored: true })
|
|
241
260
|
return reply(response, 200, { ok: true })
|
|
242
261
|
}
|
|
243
|
-
|
|
262
|
+
// Reactions have a limit of their own: a room behind one address (a
|
|
263
|
+
// lecture hall's network) must not use up its answers with them.
|
|
264
|
+
if (!(isReactionRoom(id) ? reactionLimit : limit)(clientAddress(request))) return reply(response, 429, { error: 'Too many answers at once; wait a moment' })
|
|
244
265
|
const body = await readBody(request)
|
|
266
|
+
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
267
|
if (!('data' in body)) return reply(response, 400, { error: 'Send { data }' })
|
|
246
268
|
const from = typeof body.from === 'string' ? body.from.slice(0, 64) : null
|
|
247
269
|
return reply(response, 201, { n: rooms.post(id, { from, data: body.data }).n })
|
package/src/live/server.js
CHANGED
|
@@ -14,11 +14,25 @@ export function livePlugin({ pairing = null, upstream = null, key = null, sessio
|
|
|
14
14
|
return {
|
|
15
15
|
name: 'vite-plugin-mdeck-live',
|
|
16
16
|
configureServer(server) {
|
|
17
|
+
const rooms = createRooms()
|
|
17
18
|
const handler = liveHandler({
|
|
18
|
-
rooms
|
|
19
|
+
rooms,
|
|
19
20
|
canReset: (request, room) => isAllowedRequest(request) || (!!pairing?.allows(request) && (room == null || room === session() || room.startsWith(`${session()}.`))),
|
|
20
21
|
info: () => ({ network: server.resolvedUrls?.network?.[0] ?? null }),
|
|
21
22
|
})
|
|
23
|
+
// With polls on a standalone server, the phones join there: its count
|
|
24
|
+
// joins the local stage room's, so the presenter sees them.
|
|
25
|
+
const awayTimer = setInterval(async () => {
|
|
26
|
+
const address = typeof upstream === 'function' ? upstream() : upstream
|
|
27
|
+
const code = session()
|
|
28
|
+
if (!address || !code) return
|
|
29
|
+
try {
|
|
30
|
+
const response = await fetch(`${address.replace(/\/+$/, '')}/rooms/${encodeURIComponent(code)}.stage/presence`, { signal: AbortSignal.timeout(3000) })
|
|
31
|
+
if (response.ok) rooms.setAwayPhones(code, Number((await response.json()).phones) || 0)
|
|
32
|
+
} catch {}
|
|
33
|
+
}, 4000)
|
|
34
|
+
awayTimer.unref?.()
|
|
35
|
+
server.httpServer?.once('close', () => clearInterval(awayTimer))
|
|
22
36
|
server.middlewares.use('/__mdeck/live', (request, response, next) => {
|
|
23
37
|
const address = typeof upstream === 'function' ? upstream() : upstream
|
|
24
38
|
// The stage room (position and live ink between the presenter's
|