@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,72 @@
|
|
|
1
|
+
import type { Participant, SessionStatus } from '@lincyaw/kernel'
|
|
2
|
+
|
|
3
|
+
export interface Position {
|
|
4
|
+
slide: number
|
|
5
|
+
step: number
|
|
6
|
+
view?: string
|
|
7
|
+
section?: string
|
|
8
|
+
trail?: string[]
|
|
9
|
+
back?: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type FollowMode = 'follow' | 'free'
|
|
13
|
+
|
|
14
|
+
export interface SessionRecord {
|
|
15
|
+
status?: 'active' | 'ended'
|
|
16
|
+
presenter?: string
|
|
17
|
+
presenterName?: string
|
|
18
|
+
cursor?: Position
|
|
19
|
+
startedAt?: number
|
|
20
|
+
endedAt?: number
|
|
21
|
+
archive?: string
|
|
22
|
+
archivedAt?: number
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function sessionStatus(record: SessionRecord): Exclude<SessionStatus, 'idle'> {
|
|
26
|
+
return record.status === 'ended' ? 'ended' : 'active'
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function startRecord(presenter: Participant, now: number): SessionRecord {
|
|
30
|
+
return { status: 'active', presenter: presenter.id, presenterName: presenter.name, startedAt: now }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function endRecord(now: number): SessionRecord {
|
|
34
|
+
return { status: 'ended', endedAt: now }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function samePosition(a: Position | undefined, b: Position | undefined): boolean {
|
|
38
|
+
return a?.slide === b?.slide && a?.step === b?.step && a?.view === b?.view && a?.section === b?.section
|
|
39
|
+
&& a?.trail?.join('\n') === b?.trail?.join('\n') && a?.back === b?.back
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function clonePosition(position: Position): Position {
|
|
43
|
+
const { slide, step, view, section, trail, back } = position
|
|
44
|
+
return { slide, step, ...view ? { view } : {}, ...section ? { section } : {}, ...trail ? { trail: [...trail] } : {}, ...back ? { back } : {} }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function followTarget(me: Participant | undefined, follow: FollowMode, record: SessionRecord): Position | undefined {
|
|
48
|
+
if (me?.role !== 'learner' || follow !== 'follow' || sessionStatus(record) !== 'active') return undefined
|
|
49
|
+
return record.cursor
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function jsonClone<T>(value: T): T {
|
|
53
|
+
return value === undefined ? value : JSON.parse(JSON.stringify(value)) as T
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function changedEntries(current: (key: string) => unknown, next: Readonly<Record<string, unknown>>): [string, unknown][] {
|
|
57
|
+
return Object.entries(jsonClone(next)).filter(([key, value]) => value !== undefined && JSON.stringify(current(key)) !== JSON.stringify(value))
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function isParticipant(value: unknown): value is Participant {
|
|
61
|
+
const p = value as Partial<Participant> | null | undefined
|
|
62
|
+
return typeof p?.id === 'string' && typeof p.name === 'string' && (p.role === 'presenter' || p.role === 'learner')
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function participantsOf(states: Iterable<unknown>): Participant[] {
|
|
66
|
+
const byId = new Map<string, Participant>()
|
|
67
|
+
for (const state of states) {
|
|
68
|
+
const participant = (state as { participant?: unknown } | null)?.participant
|
|
69
|
+
if (isParticipant(participant)) byId.set(participant.id, { id: participant.id, name: participant.name, role: participant.role })
|
|
70
|
+
}
|
|
71
|
+
return [...byId.values()].sort((a, b) => (a.role === b.role ? a.name.localeCompare(b.name, 'zh') : a.role === 'presenter' ? -1 : 1))
|
|
72
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { ClassroomStore, SessionService, SessionSnapshot, Unsubscribe } from '@lincyaw/kernel'
|
|
2
|
+
import type { SessionMessages } from './messages.ts'
|
|
3
|
+
import type { FollowMode } from './session-record.ts'
|
|
4
|
+
|
|
5
|
+
export interface ClassroomSnapshot extends SessionSnapshot {
|
|
6
|
+
server?: string
|
|
7
|
+
connection: 'offline' | 'connecting' | 'connected'
|
|
8
|
+
follow: FollowMode
|
|
9
|
+
joinUrl?: string
|
|
10
|
+
invitation?: string
|
|
11
|
+
rejoin?: string
|
|
12
|
+
error?: string
|
|
13
|
+
refused?: boolean
|
|
14
|
+
archive?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ClassroomSession extends SessionService {
|
|
18
|
+
snapshot(): ClassroomSnapshot
|
|
19
|
+
subscribe(listener: (snapshot: ClassroomSnapshot) => void): Unsubscribe
|
|
20
|
+
setFollow(mode: FollowMode): void
|
|
21
|
+
rejoin(): Promise<void>
|
|
22
|
+
archive(): Promise<string>
|
|
23
|
+
readonly personal: ClassroomStore
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function isClassroomSession(service: SessionService | undefined): service is ClassroomSession {
|
|
27
|
+
return typeof (service as Partial<ClassroomSession> | undefined)?.setFollow === 'function'
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function statusText(s: ClassroomSnapshot, t: SessionMessages): string {
|
|
31
|
+
if (!s.server) return t.status.noServer
|
|
32
|
+
if (s.status === 'idle') {
|
|
33
|
+
if (s.error) return t.status.failed(s.error)
|
|
34
|
+
return s.invitation ? t.status.invited(s.invitation) : t.status.idle
|
|
35
|
+
}
|
|
36
|
+
if (s.status === 'ended') return s.archive ? t.status.archived(s.archive) : t.status.ended
|
|
37
|
+
if (s.connection !== 'connected') return t.status.connecting
|
|
38
|
+
if (s.me?.role === 'presenter') return t.status.presenting
|
|
39
|
+
return s.follow === 'follow' ? t.status.following : t.status.browsing
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function shortStatus(s: ClassroomSnapshot, t: SessionMessages): string {
|
|
43
|
+
if (!s.server) return t.short.noServer
|
|
44
|
+
if (s.status === 'idle') return t.short.idle
|
|
45
|
+
if (s.status === 'ended') return s.archive ? t.short.archived : t.short.ended
|
|
46
|
+
if (s.connection !== 'connected') return t.short.connecting
|
|
47
|
+
if (s.me?.role === 'presenter') return t.short.presenter
|
|
48
|
+
return s.follow === 'follow' ? t.short.follow : t.short.free
|
|
49
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { createRequire } from 'node:module'
|
|
2
|
+
import { startClassroomServer } from '@lincyaw/classroom-server'
|
|
3
|
+
import { isRecord, jsonResponse } from '@lincyaw/kernel'
|
|
4
|
+
import type { Check, PresmiumNodePlugin } from '@lincyaw/kernel'
|
|
5
|
+
import { SESSION_ID, SYNC_PATH, toSessionConfig } from '../domain/index.ts'
|
|
6
|
+
|
|
7
|
+
const { version } = createRequire(import.meta.url)('../../package.json') as { version: string }
|
|
8
|
+
|
|
9
|
+
const serverCheck: Check = {
|
|
10
|
+
id: SESSION_ID,
|
|
11
|
+
async run(ctx) {
|
|
12
|
+
const config = ctx.ir.config.presmium[SESSION_ID]
|
|
13
|
+
if (toSessionConfig(isRecord(config) ? config : {}).server) return []
|
|
14
|
+
return [{
|
|
15
|
+
checkId: SESSION_ID,
|
|
16
|
+
severity: 'info',
|
|
17
|
+
message: 'no classroom server: presmium dev and presmium serve sync the classroom on their own port; a build shows no classroom',
|
|
18
|
+
hint: 'for a published deck, start presmium-classroom-server and set presmium: { presmium.session: { server: ws://<host>:<port> } } in the frontmatter',
|
|
19
|
+
}]
|
|
20
|
+
},
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const plugin: PresmiumNodePlugin = {
|
|
24
|
+
id: SESSION_ID,
|
|
25
|
+
version,
|
|
26
|
+
kernel: '^1.0.0',
|
|
27
|
+
checks: [serverCheck],
|
|
28
|
+
async server(ctx) {
|
|
29
|
+
const classroom = await startClassroomServer({
|
|
30
|
+
port: 0,
|
|
31
|
+
listenAddress: '127.0.0.1',
|
|
32
|
+
deck: ctx.deckDir,
|
|
33
|
+
onDropped: (room, writer, reason) => ctx.log.warn(`classroom ${room}: dropped ${reason} from ${writer.presenter ? 'the presenter' : writer.participant ? `participant ${writer.participant}` : 'an unidentified participant'}`),
|
|
34
|
+
})
|
|
35
|
+
return {
|
|
36
|
+
routes: {
|
|
37
|
+
classroom: { role: 'reader', handle: (_request, { document }) => jsonResponse({ path: `/__presmium/${SESSION_ID}/${SYNC_PATH}`, document }) },
|
|
38
|
+
},
|
|
39
|
+
proxies: [{ path: SYNC_PATH, target: `http://127.0.0.1:${classroom.port}`, role: 'reader' }],
|
|
40
|
+
close: () => classroom.close(),
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export default plugin
|