@lincyaw/addon-session 0.0.0-stage → 1.0.0-rc.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.
@@ -0,0 +1,193 @@
1
+ import { Fragment, useState, useRef, useEffect, useMemo, useId } from 'react'
2
+ import type { ReactNode } from 'react'
3
+ import { useMessages } from '@lincyaw/host'
4
+ import { errorMessage } from '@lincyaw/kernel'
5
+ import { renderSVG } from 'uqr'
6
+ import { isLoopbackLink, MESSAGES, statusText } from '../domain/index.ts'
7
+ import { useClassroomSession } from './composables.ts'
8
+ import { releaseFocus } from './focus.ts'
9
+ import './SessionPanel.css'
10
+ export default function SessionPanel(props: {
11
+ children?: ReactNode
12
+ className?: string
13
+ onDone?(): void
14
+ onCited?(): void
15
+ }) {
16
+ const { session, snapshot } = useClassroomSession()
17
+ const t = useMessages(MESSAGES)
18
+ const [name, setName] = useState('')
19
+ const [busy, setBusy] = useState(false)
20
+ const [failure, setFailure] = useState('')
21
+
22
+ const presenter = snapshot?.me?.role === 'presenter'
23
+ const qr = snapshot?.joinUrl ? renderSVG(snapshot.joinUrl, { border: 1 }) : ''
24
+
25
+ async function run(action: (s: NonNullable<typeof session>) => unknown): Promise<void> {
26
+ if (!session || busy) return
27
+ setBusy(true)
28
+ setFailure('')
29
+ try {
30
+ await action(session)
31
+ } catch (error) {
32
+ setFailure(errorMessage(error))
33
+ } finally {
34
+ setBusy(false)
35
+ }
36
+ }
37
+
38
+ function submit(): void {
39
+ const room = snapshot?.invitation
40
+ releaseFocus()
41
+ void run((s) => (room ? s.join(room, name) : s.create(name)))
42
+ }
43
+
44
+ return (
45
+ <>
46
+ {snapshot ? (
47
+ <div className="pm-session" data-status={snapshot.status} data-connection={snapshot.connection}>
48
+ <p className="pm-session__status" role="status">
49
+ {statusText(snapshot, t)}
50
+ </p>
51
+ {!snapshot.server ? (
52
+ <p className="pm-session__hint">
53
+ {t.hint.beforeCommand}
54
+ <code>presmium-classroom-server</code>
55
+ {t.hint.beforeKey}
56
+ <code>presmium.session.server</code>
57
+ {t.hint.end}
58
+ </p>
59
+ ) : snapshot.status === 'idle' ? (
60
+ <>
61
+ {snapshot.rejoin ? (
62
+ <p className="pm-session__actions">
63
+ <button
64
+ type="button"
65
+ className="pm-ui-button pm-ui-button--primary pm-ui-button--small"
66
+ disabled={busy}
67
+ title={t.rejoinTitle}
68
+ onClick={(event) => {
69
+ run((s) => s.rejoin())
70
+ }}
71
+ >
72
+ {t.rejoin(snapshot.rejoin)}
73
+ </button>
74
+ </p>
75
+ ) : null}
76
+ <form
77
+ className="pm-session__form"
78
+ onSubmit={(event) => {
79
+ event.preventDefault()
80
+ submit()
81
+ }}
82
+ >
83
+ <input
84
+ value={name}
85
+ onChange={(event) => setName(event.target.value)}
86
+ className="pm-ui-input"
87
+ aria-label={t.nickname}
88
+ placeholder={t.nickname}
89
+ maxLength={40}
90
+ required
91
+ ></input>
92
+ <button
93
+ type="submit"
94
+ className="pm-ui-button pm-ui-button--primary pm-ui-button--small"
95
+ disabled={busy}
96
+ >
97
+ {snapshot.invitation ? t.join : t.create}
98
+ </button>
99
+ </form>
100
+ </>
101
+ ) : (
102
+ <>
103
+ <p>
104
+ {t.classroom}
105
+ <code className="pm-session__room pm-ui-code">{snapshot.sessionId}</code> ·{' '}
106
+ {snapshot.me?.name}
107
+ {presenter ? t.role.presenter : t.role.learner}
108
+ </p>
109
+ {presenter && snapshot.status === 'active' && snapshot.joinUrl ? (
110
+ <>
111
+ <p className="pm-session__join">
112
+ {t.joinLink}
113
+ <a
114
+ className="pm-session__link pm-ui-link"
115
+ href={snapshot.joinUrl}
116
+ target="_blank"
117
+ rel="noopener"
118
+ >
119
+ {snapshot.joinUrl}
120
+ </a>
121
+ </p>
122
+ {isLoopbackLink(snapshot.joinUrl) ? (
123
+ <p className="pm-session__warning" role="note">
124
+ {t.loopbackLink}
125
+ </p>
126
+ ) : null}
127
+ <div
128
+ className="pm-session__qr"
129
+ role="img"
130
+ aria-label={t.qr}
131
+ dangerouslySetInnerHTML={{ __html: qr }}
132
+ ></div>
133
+ </>
134
+ ) : null}
135
+ <p className="pm-session__label pm-ui-heading">
136
+ {t.participantCount(snapshot.participants.length)}
137
+ </p>
138
+ <ul className="pm-session__participants" aria-label={t.participants}>
139
+ {snapshot.participants.map((p) => (
140
+ <li key={p.id} data-role={p.role}>
141
+ {p.name}
142
+ {p.role === 'presenter' ? <span>{t.role.presenter}</span> : null}
143
+ </li>
144
+ ))}
145
+ </ul>
146
+ <p className="pm-session__actions">
147
+ {presenter && snapshot.status === 'active' ? (
148
+ <button
149
+ type="button"
150
+ className="pm-ui-button pm-ui-button--danger pm-ui-button--small"
151
+ disabled={busy}
152
+ onClick={(event) => {
153
+ run((s) => s.end())
154
+ }}
155
+ >
156
+ {t.end}
157
+ </button>
158
+ ) : null}
159
+ {presenter && snapshot.status === 'ended' && !snapshot.archive ? (
160
+ <button
161
+ type="button"
162
+ className="pm-ui-button pm-ui-button--small"
163
+ disabled={busy}
164
+ title={t.archiveTitle}
165
+ onClick={(event) => {
166
+ run((s) => s.archive())
167
+ }}
168
+ >
169
+ {t.archive}
170
+ </button>
171
+ ) : null}
172
+ <button
173
+ type="button"
174
+ className="pm-ui-button pm-ui-button--small"
175
+ onClick={(event) => {
176
+ session?.leave()
177
+ }}
178
+ >
179
+ {t.leaveClassroom}
180
+ </button>
181
+ </p>
182
+ </>
183
+ )}
184
+ {failure ? (
185
+ <p className="pm-session__error" role="alert">
186
+ {failure}
187
+ </p>
188
+ ) : null}
189
+ </div>
190
+ ) : null}
191
+ </>
192
+ )
193
+ }
@@ -0,0 +1,39 @@
1
+ .pm-session-toolbar {
2
+ display: inline-flex;
3
+ align-items: center;
4
+ }
5
+ .pm-session-toolbar__button {
6
+ gap: 0.375rem;
7
+ }
8
+ .pm-session-toolbar__state {
9
+ color: var(--pm-color-muted);
10
+ }
11
+ .pm-session-toolbar__dot {
12
+ background: var(--pm-color-line-strong);
13
+ }
14
+ .pm-session-toolbar__dot[data-connection='connected'] {
15
+ background: var(--pm-color-ok);
16
+ }
17
+ .pm-session-toolbar__dot[data-connection='connecting'] {
18
+ background: var(--pm-color-warn);
19
+ }
20
+ .pm-session-popover {
21
+ box-sizing: border-box;
22
+ width: min(22rem, calc(100vw - 2rem));
23
+ max-height: 75vh;
24
+ margin: 0;
25
+ overflow: auto;
26
+ padding: var(--pm-space-3) var(--pm-space-4);
27
+ border: var(--pm-size-hairline) solid var(--pm-color-line);
28
+ border-radius: var(--pm-radius-card);
29
+ background: var(--pm-color-surface);
30
+ color: var(--pm-color-ink);
31
+ box-shadow: var(--pm-shadow-float);
32
+ font-family: var(--pm-font-sans);
33
+ text-align: left;
34
+ white-space: normal;
35
+ overflow-wrap: anywhere;
36
+ }
37
+ .pm-session-popover__close {
38
+ margin-top: 0.75rem;
39
+ }
@@ -0,0 +1,41 @@
1
+ import { useState } from 'react'
2
+ import { PmButton, PmPopover, useMessages } from '@lincyaw/host'
3
+ import { MESSAGES, shortStatus, statusText } from '../domain/index.ts'
4
+ import { useClassroomSession } from './composables.ts'
5
+ import SessionOverlays from './SessionOverlays.tsx'
6
+ import SessionPanel from './SessionPanel.tsx'
7
+ import './SessionToolbar.css'
8
+ export default function SessionToolbar() {
9
+ const t = useMessages(MESSAGES)
10
+ const [open, setOpen] = useState(false)
11
+ const { snapshot } = useClassroomSession()
12
+ return (
13
+ <span className="pm-session-toolbar">
14
+ {snapshot && (
15
+ <PmPopover
16
+ open={open}
17
+ onOpenChange={setOpen}
18
+ label={t.classroom}
19
+ name="pm-session-popover"
20
+ trigger={
21
+ <PmButton variant="ghost" small title={statusText(snapshot, t)} aria-expanded={open}>
22
+ <span
23
+ className="pm-session-toolbar__dot pm-ui-dot"
24
+ data-connection={snapshot.server ? snapshot.connection : 'none'}
25
+ aria-hidden="true"
26
+ />
27
+ {t.classroom}
28
+ <span className="pm-session-toolbar__state">{shortStatus(snapshot, t)}</span>
29
+ </PmButton>
30
+ }
31
+ >
32
+ <SessionPanel />
33
+ <PmButton small className="pm-session-popover__close" onClick={() => setOpen(false)}>
34
+ {t.close}
35
+ </PmButton>
36
+ </PmPopover>
37
+ )}
38
+ <SessionOverlays />
39
+ </span>
40
+ )
41
+ }
@@ -0,0 +1,398 @@
1
+ import { currentLocale } from '@lincyaw/host'
2
+ import type { Navigation, SyncTransport } from '@lincyaw/host'
3
+ import { errorMessage } from '@lincyaw/kernel'
4
+ import type { ClassroomStore, ParticipantRole, Unsubscribe } from '@lincyaw/kernel'
5
+ import { WebsocketProvider } from 'y-websocket'
6
+ import * as Y from 'yjs'
7
+ import { archiveUrl, followTarget, joinRequest, joinUrl, MESSAGES, participantsOf, readableString, samePosition, sessionStatus } from '../domain/index.ts'
8
+ import type { ClassroomSession, ClassroomSnapshot, FollowMode, JoinRequest, Position, SessionMessages } from '../domain/index.ts'
9
+ import { createMembershipStorage } from './membership-storage.ts'
10
+ import type { Membership } from './membership-storage.ts'
11
+ import { createSyncChannels } from './sync-channels.ts'
12
+ import { connectTabs } from './tab-mirror.ts'
13
+ import { observe, publishCursor, sessionMap, sessionRecordOf, writeSessionEnd, writeSessionStart } from './ydoc.ts'
14
+ import { createYjsStore } from './yjs-store.ts'
15
+
16
+ export interface ClassroomConnectionOptions {
17
+ server?: string
18
+ params?: Record<string, string>
19
+ navigation?: Navigation
20
+ base: string
21
+ storage?: Storage
22
+ tabStorage?: Storage
23
+ timeoutMs?: number
24
+ }
25
+
26
+ /** The connection of this window to a classroom server: the session service, the shared store and the sync transport. */
27
+ export interface ClassroomConnection {
28
+ session: ClassroomSession
29
+ store: ClassroomStore
30
+ transport: SyncTransport
31
+ resume(search: string): Promise<void>
32
+ setServer(url: string, params?: Record<string, string>): void
33
+ dispose(): void
34
+ }
35
+
36
+ const ARCHIVED_CLOSE_CODE = 4410
37
+ const REFUSED_CLOSE_CODE = 4403
38
+
39
+ const messages = (): SessionMessages => MESSAGES[currentLocale()]
40
+
41
+ function randomString(length: number): string {
42
+ return readableString(crypto.getRandomValues(new Uint8Array(length)))
43
+ }
44
+
45
+ export function createClassroomConnection(options: ClassroomConnectionOptions): ClassroomConnection {
46
+ const { navigation } = options
47
+ let server = options.server
48
+ let params = options.params ?? {}
49
+ let doc = new Y.Doc()
50
+ let personalDoc = new Y.Doc()
51
+ let provider: WebsocketProvider | undefined
52
+ let personalProvider: WebsocketProvider | undefined
53
+ let entering: AbortController | undefined
54
+ let membership: Membership | undefined
55
+ let isMirrorTab = false
56
+ let connection: ClassroomSnapshot['connection'] = 'offline'
57
+ let follow: FollowMode = 'follow'
58
+ let invitation: string | undefined
59
+ let error: string | undefined
60
+ let refused = false
61
+ let preferred: string | undefined
62
+ let followed: Position | undefined
63
+
64
+ const listeners = new Set<(snapshot: ClassroomSnapshot) => void>()
65
+ let detach: (() => void)[] = []
66
+
67
+ const prefix = `presmium.session:${new URL(options.base).pathname}`
68
+ const memberships = createMembershipStorage({ prefix, storage: options.storage, tabStorage: options.tabStorage, server: () => server })
69
+
70
+ function remember(): void {
71
+ if (membership && !isMirrorTab) memberships.remember({ ...membership, follow, at: Date.now() })
72
+ }
73
+
74
+ function snapshot(): ClassroomSnapshot {
75
+ const record = sessionRecordOf(doc)
76
+ return {
77
+ server,
78
+ connection,
79
+ follow,
80
+ invitation,
81
+ rejoin: membership || invitation ? undefined : memberships.findLastPresenterMembership(preferred)?.room,
82
+ error,
83
+ refused,
84
+ sessionId: membership?.room,
85
+ status: membership ? sessionStatus(record) : 'idle',
86
+ me: membership?.me,
87
+ participants: provider ? participantsOf(provider.awareness.getStates().values()) : [],
88
+ presenterCursor: record.cursor && { slide: record.cursor.slide, step: record.cursor.step },
89
+ joinUrl: membership && joinUrl(options.base, membership.room),
90
+ archive: membership ? record.archive : undefined,
91
+ }
92
+ }
93
+
94
+ function notify(): void {
95
+ const next = snapshot()
96
+ listeners.forEach(listener => listener(next))
97
+ }
98
+
99
+ const role = (): ParticipantRole | undefined => membership?.me.role
100
+ const active = () => membership !== undefined && sessionStatus(sessionRecordOf(doc)) === 'active'
101
+ const presenting = () => role() === 'presenter' && !isMirrorTab
102
+ const following = () => role() === 'learner' && follow === 'follow' && active()
103
+
104
+ const shared = createYjsStore(() => doc, () => {
105
+ if (membership && !active()) throw new Error(messages().errors.ended)
106
+ if (refused) {
107
+ refused = false
108
+ notify()
109
+ }
110
+ })
111
+ const personal = createYjsStore(() => {
112
+ if (membership && server && !personalProvider) personalProvider = new WebsocketProvider(server, `${membership.room}.${membership.key}`, personalDoc, { params })
113
+ return personalDoc
114
+ }, () => {})
115
+ const channels = createSyncChannels(() => doc, { following, publishing: () => presenting() && active() })
116
+
117
+ function followPresenter(force = false): void {
118
+ const target = followTarget(membership?.me, follow, sessionRecordOf(doc))
119
+ if (!target || !navigation || (!force && samePosition(target, followed))) return
120
+ followed = target
121
+ navigation.go(target)
122
+ }
123
+
124
+ function publishPosition(): void {
125
+ if (navigation && presenting() && active()) publishCursor(doc, navigation.position.getSnapshot())
126
+ }
127
+
128
+ function bind(next: Y.Doc): void {
129
+ const previous = doc
130
+ detach.forEach(stop => stop())
131
+ detach = []
132
+ doc = next
133
+ detach.push(observe(sessionMap(doc), () => {
134
+ notify()
135
+ followPresenter()
136
+ }))
137
+ channels.rebind()
138
+ shared.rebind()
139
+ if (previous !== next) previous.destroy()
140
+ }
141
+
142
+ function bindPersonal(next: Y.Doc): void {
143
+ const previous = personalDoc
144
+ if (previous !== next) {
145
+ personalProvider?.destroy()
146
+ personalProvider = undefined
147
+ }
148
+ personalDoc = next
149
+ personal.rebind()
150
+ if (previous !== next) previous.destroy()
151
+ }
152
+
153
+ function disconnect(): void {
154
+ entering?.abort()
155
+ entering = undefined
156
+ provider?.destroy()
157
+ personalProvider?.destroy()
158
+ provider = undefined
159
+ personalProvider = undefined
160
+ membership = undefined
161
+ isMirrorTab = false
162
+ connection = 'offline'
163
+ followed = undefined
164
+ refused = false
165
+ bind(new Y.Doc())
166
+ bindPersonal(new Y.Doc())
167
+ }
168
+
169
+ function waitForSync(connecting: WebsocketProvider, signal: AbortSignal): Promise<void> {
170
+ if (connecting.synced) return Promise.resolve()
171
+ return new Promise((resolve, reject) => {
172
+ const stop = () => {
173
+ clearTimeout(timer)
174
+ connecting.off('sync', done)
175
+ connecting.off('closed', closed)
176
+ signal.removeEventListener('abort', aborted)
177
+ }
178
+ const done = (isSynced: boolean) => {
179
+ if (!isSynced) return
180
+ stop()
181
+ resolve()
182
+ }
183
+ const closed = ({ code }: { code: number }) => {
184
+ stop()
185
+ reject(new Error(code === ARCHIVED_CLOSE_CODE ? messages().errors.archived : messages().errors.closed(code)))
186
+ }
187
+ const aborted = () => {
188
+ stop()
189
+ reject(new Error('superseded'))
190
+ }
191
+ const timer = setTimeout(() => {
192
+ stop()
193
+ reject(new Error(messages().errors.unreachable(server ?? '')))
194
+ }, options.timeoutMs ?? 10_000)
195
+ connecting.on('sync', done)
196
+ connecting.on('closed', closed)
197
+ signal.addEventListener('abort', aborted)
198
+ })
199
+ }
200
+
201
+ async function enter(next: Membership, start = false, asMirror = false): Promise<void> {
202
+ if (!server) throw new Error(messages().errors.noServer)
203
+ disconnect()
204
+ const attempt = entering = new AbortController()
205
+ const { room, me, key, claim } = next
206
+ const fresh = new Y.Doc()
207
+ const connecting = new WebsocketProvider(server, room, fresh, { disableBc: true, params: { ...params, participant: me.id, key, ...claim ? { claim } : {} } })
208
+ const current = () => provider === connecting
209
+ provider = connecting
210
+ membership = { server, room, me, key, ...claim ? { claim } : {} }
211
+ isMirrorTab = asMirror
212
+ connection = 'connecting'
213
+ follow = next.follow ?? 'follow'
214
+ error = undefined
215
+ invitation = undefined
216
+ bind(fresh)
217
+ bindPersonal(new Y.Doc())
218
+ connecting.awareness.setLocalStateField('participant', me)
219
+ connecting.awareness.on('change', () => current() && notify())
220
+ connecting.on('status', ({ status }: { status: string }) => {
221
+ if (!current()) return
222
+ connection = status === 'connected' && connecting.synced ? 'connected' : 'connecting'
223
+ notify()
224
+ })
225
+ connecting.on('sync', (isSynced: boolean) => {
226
+ if (!current()) return
227
+ connection = isSynced ? 'connected' : 'connecting'
228
+ notify()
229
+ })
230
+ connecting.on('closed', ({ code }: { code: number }) => {
231
+ if (!current() || !membership) return
232
+ if (code === REFUSED_CLOSE_CODE) {
233
+ void enter({ ...membership, follow }, false, isMirrorTab).then(() => {
234
+ if (membership?.room !== room || membership.me.id !== me.id) return
235
+ refused = true
236
+ notify()
237
+ }, () => {})
238
+ return
239
+ }
240
+ connection = 'offline'
241
+ if (!isMirrorTab) memberships.forget(room)
242
+ notify()
243
+ })
244
+ notify()
245
+ try {
246
+ await waitForSync(connecting, attempt.signal)
247
+ } catch (failure) {
248
+ if (!current()) return
249
+ disconnect()
250
+ error = errorMessage(failure)
251
+ if (me.role === 'learner') invitation = room
252
+ notify()
253
+ throw failure
254
+ }
255
+ if (!current()) return
256
+ entering = undefined
257
+ if (start) writeSessionStart(doc, me, Date.now())
258
+ remember()
259
+ if (!isMirrorTab && me.role === 'presenter') {
260
+ tabs.announce(membership!)
261
+ publishPosition()
262
+ channels.publishAll()
263
+ } else if (!isMirrorTab) {
264
+ followPresenter(true)
265
+ channels.deliver()
266
+ }
267
+ notify()
268
+ }
269
+
270
+ const session: ClassroomSession = {
271
+ snapshot,
272
+ subscribe(listener) {
273
+ listeners.add(listener)
274
+ return () => {
275
+ listeners.delete(listener)
276
+ }
277
+ },
278
+ async create(name) {
279
+ const room = randomString(8)
280
+ await enter({ server: server ?? '', room, me: { id: randomString(16), name: name.trim() || messages().defaultName.presenter, role: 'presenter' }, key: randomString(24), claim: randomString(24) }, true)
281
+ return room
282
+ },
283
+ async join(room, name) {
284
+ const nickname = name.trim() || messages().defaultName.learner
285
+ const before = memberships.find(room)
286
+ if (before?.me.role === 'learner' && before.me.name === nickname) return enter(before)
287
+ await enter({ server: server ?? '', room, me: { id: randomString(16), name: nickname, role: 'learner' }, key: randomString(24) })
288
+ },
289
+ async rejoin() {
290
+ const before = memberships.findLastPresenterMembership(preferred)
291
+ if (!before) throw new Error(messages().errors.noRoom)
292
+ await enter(before)
293
+ if (membership?.room === before.room && sessionRecordOf(doc).status === undefined) {
294
+ memberships.forget(before.room)
295
+ disconnect()
296
+ error = messages().errors.noRoom
297
+ notify()
298
+ throw new Error(error)
299
+ }
300
+ },
301
+ async end() {
302
+ if (role() !== 'presenter') throw new Error(messages().errors.onlyPresenterEnds)
303
+ writeSessionEnd(doc, Date.now())
304
+ },
305
+ async archive() {
306
+ const { errors } = messages()
307
+ if (!membership || role() !== 'presenter') throw new Error(errors.onlyPresenterArchives)
308
+ const { server: at, room, claim } = membership
309
+ const response = await fetch(archiveUrl(at, room, params), { method: 'POST', body: claim ?? '' })
310
+ .catch(() => { throw new Error(errors.unreachable(at)) })
311
+ const result = await response.json().catch(() => ({})) as { file?: string }
312
+ const reasons: Record<number, string> = { 403: errors.notCreator, 404: errors.noRoom, 410: errors.alreadyArchived }
313
+ if (!response.ok || !result.file) throw new Error(reasons[response.status] ?? errors.archiveFailed(response.status))
314
+ memberships.forget(room)
315
+ return result.file
316
+ },
317
+ leave() {
318
+ if (membership && !isMirrorTab) memberships.forget(membership.room)
319
+ if (presenting()) tabs.leave(membership!.room)
320
+ disconnect()
321
+ notify()
322
+ },
323
+ setFollow(mode) {
324
+ follow = mode
325
+ remember()
326
+ if (mode === 'follow') {
327
+ followPresenter(true)
328
+ channels.deliver()
329
+ }
330
+ notify()
331
+ },
332
+ personal: personal.store,
333
+ }
334
+
335
+ async function resumeFrom(request: JoinRequest | undefined): Promise<void> {
336
+ if (!request) {
337
+ const before = memberships.findInTab()
338
+ if (before) await enter(before)
339
+ else tabs.ask()
340
+ return
341
+ }
342
+ const before = memberships.find(request.room)
343
+ if (before?.me.role === 'presenter') {
344
+ preferred = request.room
345
+ tabs.ask()
346
+ notify()
347
+ } else if (before && (!request.name || request.name === before.me.name)) {
348
+ await enter(before)
349
+ } else if (request.name) {
350
+ await session.join(request.room, request.name)
351
+ } else {
352
+ invitation = request.room
353
+ notify()
354
+ }
355
+ }
356
+
357
+ const stopPublishing: Unsubscribe = navigation ? navigation.position.subscribe(publishPosition) : () => {}
358
+
359
+ const tabs = connectTabs(prefix, {
360
+ asked() {
361
+ if (presenting()) tabs.announce(membership!)
362
+ },
363
+ presenting(other) {
364
+ if (!membership && !invitation && memberships.isUsable(other)) void enter(other, false, true).catch(() => {})
365
+ },
366
+ left(room) {
367
+ if (!isMirrorTab || membership?.room !== room) return
368
+ disconnect()
369
+ notify()
370
+ },
371
+ })
372
+
373
+ return {
374
+ session,
375
+ store: shared.store,
376
+ transport: channels.transport,
377
+ async resume(search) {
378
+ // A failed entry shows in the snapshot's error; resuming does not throw it.
379
+ await resumeFrom(joinRequest(search)).catch(() => {})
380
+ },
381
+ setServer(url, given = {}) {
382
+ if (membership) return
383
+ server = url
384
+ params = given
385
+ notify()
386
+ },
387
+ dispose() {
388
+ stopPublishing()
389
+ tabs.close()
390
+ disconnect()
391
+ detach.forEach(stop => stop())
392
+ detach = []
393
+ doc.destroy()
394
+ personalDoc.destroy()
395
+ listeners.clear()
396
+ },
397
+ }
398
+ }
@@ -0,0 +1,8 @@
1
+ import { useSessionSnapshot } from '@lincyaw/host'
2
+ import { isClassroomSession } from '../domain/index.ts'
3
+ import type { ClassroomSession, ClassroomSnapshot } from '../domain/index.ts'
4
+ export function useClassroomSession(): { session: ClassroomSession | undefined, snapshot: ClassroomSnapshot | undefined } {
5
+ const host = useSessionSnapshot()
6
+ const session = isClassroomSession(host.session) ? host.session : undefined
7
+ return { session, snapshot: session ? host.snapshot as ClassroomSnapshot | undefined : undefined }
8
+ }
@@ -0,0 +1,8 @@
1
+ interface ImportMetaEnv {
2
+ BASE_URL: string
3
+ DEV: boolean
4
+ }
5
+
6
+ interface ImportMeta {
7
+ readonly env: ImportMetaEnv
8
+ }
@@ -0,0 +1,4 @@
1
+ export function releaseFocus(): void {
2
+ const active = document.activeElement
3
+ if (active instanceof HTMLElement) active.blur()
4
+ }