@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.
- package/LICENSE +202 -0
- package/README.md +32 -2
- package/package.json +49 -3
- package/src/client/SessionBadge.css +39 -0
- package/src/client/SessionBadge.tsx +124 -0
- package/src/client/SessionOverlays.tsx +15 -0
- package/src/client/SessionPanel.css +54 -0
- package/src/client/SessionPanel.tsx +193 -0
- package/src/client/SessionToolbar.css +39 -0
- package/src/client/SessionToolbar.tsx +41 -0
- package/src/client/classroom-connection.ts +398 -0
- package/src/client/composables.ts +8 -0
- package/src/client/env.d.ts +8 -0
- package/src/client/focus.ts +4 -0
- package/src/client/index.ts +4 -0
- package/src/client/membership-storage.ts +75 -0
- package/src/client/plugin.ts +88 -0
- package/src/client/sync-channels.ts +71 -0
- package/src/client/tab-mirror.ts +41 -0
- package/src/client/viewer.ts +6 -0
- package/src/client/ydoc.ts +52 -0
- package/src/client/yjs-store.ts +47 -0
- package/src/domain/config.ts +38 -0
- package/src/domain/index.ts +20 -0
- package/src/domain/links.ts +41 -0
- package/src/domain/messages.ts +137 -0
- package/src/domain/session-record.ts +72 -0
- package/src/domain/session-service.ts +49 -0
- package/src/node/presmium.ts +45 -0
|
@@ -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
|
+
}
|