@pmate/chat-sdk 0.6.3 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/index.d.ts +2 -0
- package/lib/index.js +2 -0
- package/lib/runtime/chatClient.d.ts +10 -0
- package/lib/runtime/chatClient.js +22 -0
- package/lib/runtime/index.d.ts +2 -0
- package/lib/runtime/index.js +2 -0
- package/lib/runtime/meeting.d.ts +54 -0
- package/lib/runtime/meeting.js +262 -0
- package/lib/runtime/presence.d.ts +44 -0
- package/lib/runtime/presence.js +132 -0
- package/package.json +1 -1
package/lib/index.d.ts
CHANGED
package/lib/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type ChatPresence } from "./presence.js";
|
|
1
2
|
import { ChatActivityPublisher, type ActivityTarget, type ChatActivity, type ChatActivityState, type ChatActivityUpdate } from "./activity.js";
|
|
2
3
|
import type { ChatMention } from "../mentions/model";
|
|
3
4
|
import { type BotLoginSession } from "./botAuth.js";
|
|
@@ -14,10 +15,17 @@ export type ChatClientOptions = {
|
|
|
14
15
|
realtimeClient?: RealtimeClient;
|
|
15
16
|
fetchImpl?: typeof fetch;
|
|
16
17
|
onActivityError?: (error: unknown) => void;
|
|
18
|
+
presence?: {
|
|
19
|
+
baseUrl?: string;
|
|
20
|
+
};
|
|
17
21
|
ackTimeoutMs?: number;
|
|
18
22
|
};
|
|
19
23
|
export declare class ChatClient {
|
|
20
24
|
private readonly options;
|
|
25
|
+
private readonly instanceId;
|
|
26
|
+
private presence?;
|
|
27
|
+
private presenceProfiles;
|
|
28
|
+
private presenceHandlers;
|
|
21
29
|
private connection;
|
|
22
30
|
private readonly connectionHandlers;
|
|
23
31
|
private subscriptions;
|
|
@@ -40,6 +48,8 @@ export declare class ChatClient {
|
|
|
40
48
|
private setConnection;
|
|
41
49
|
private rejectPending;
|
|
42
50
|
private authenticate;
|
|
51
|
+
watchPresenceProfiles(ids: string[]): void;
|
|
52
|
+
watchPresence(handler: (items: ChatPresence[]) => void): () => void;
|
|
43
53
|
watchMessages(handler: (message: ChatRuntimeMessage) => void): () => boolean;
|
|
44
54
|
watchActivities(handler: (activities: ChatActivity[]) => void): () => void;
|
|
45
55
|
sendActivity(input: ActivityTarget & {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ChatPresenceClient } from "./presence.js";
|
|
1
2
|
import { ChatActivityStore, ChatActivityPublisher, createActivityMessage, parseChatActivity } from "./activity.js";
|
|
2
3
|
import { loginPmateBot } from "./botAuth.js";
|
|
3
4
|
import { parsePmateBotKeyJson } from "./botKey.js";
|
|
@@ -7,6 +8,10 @@ const DEFAULT_AUTH_API_BASE_URL = "https://auth-api-v2.pmate.chat";
|
|
|
7
8
|
const DEFAULT_ACK_TIMEOUT_MS = 5_000;
|
|
8
9
|
export class ChatClient {
|
|
9
10
|
options;
|
|
11
|
+
instanceId = crypto.randomUUID();
|
|
12
|
+
presence;
|
|
13
|
+
presenceProfiles = [];
|
|
14
|
+
presenceHandlers = new Set();
|
|
10
15
|
connection = "closed";
|
|
11
16
|
connectionHandlers = new Set();
|
|
12
17
|
subscriptions = [];
|
|
@@ -55,6 +60,12 @@ export class ChatClient {
|
|
|
55
60
|
const profileId = await this.resolveProfileId();
|
|
56
61
|
if (generation !== this.generation)
|
|
57
62
|
throw new Error("ChatClient closed");
|
|
63
|
+
if (this.options.presence && !this.presence) {
|
|
64
|
+
this.presence = new ChatPresenceClient({ appId: this.options.appId, profileId, instanceId: this.instanceId, token: () => this.session.token, baseUrl: this.options.presence.baseUrl, fetchImpl: this.options.fetchImpl });
|
|
65
|
+
this.presence.watchProfiles(this.presenceProfiles);
|
|
66
|
+
this.presence.watch(items => { for (const handler of this.presenceHandlers)
|
|
67
|
+
handler(items); });
|
|
68
|
+
}
|
|
58
69
|
if (!this.client) {
|
|
59
70
|
const hubUrl = normalizeWsUrl(this.options.hubUrl ?? DEFAULT_HUB_URL, profileId);
|
|
60
71
|
this.client = new RuntimeWebsocketClient(hubUrl);
|
|
@@ -102,6 +113,10 @@ export class ChatClient {
|
|
|
102
113
|
if (this.connection === state)
|
|
103
114
|
return;
|
|
104
115
|
this.connection = state;
|
|
116
|
+
if (state === "connected")
|
|
117
|
+
this.presence?.start();
|
|
118
|
+
else
|
|
119
|
+
this.presence?.stop();
|
|
105
120
|
for (const handler of this.connectionHandlers)
|
|
106
121
|
handler(state);
|
|
107
122
|
}
|
|
@@ -134,6 +149,12 @@ export class ChatClient {
|
|
|
134
149
|
}
|
|
135
150
|
}
|
|
136
151
|
}
|
|
152
|
+
watchPresenceProfiles(ids) { this.presenceProfiles = ids; this.presence?.watchProfiles(ids); }
|
|
153
|
+
watchPresence(handler) {
|
|
154
|
+
this.presenceHandlers.add(handler);
|
|
155
|
+
handler(this.presence?.snapshot() ?? []);
|
|
156
|
+
return () => { this.presenceHandlers.delete(handler); };
|
|
157
|
+
}
|
|
137
158
|
watchMessages(handler) {
|
|
138
159
|
this.messageHandlers.add(handler);
|
|
139
160
|
return () => this.messageHandlers.delete(handler);
|
|
@@ -230,6 +251,7 @@ export class ChatClient {
|
|
|
230
251
|
token,
|
|
231
252
|
as: "user",
|
|
232
253
|
profileId,
|
|
254
|
+
clientInstanceId: this.instanceId,
|
|
233
255
|
}, ChatMsgKind.DM);
|
|
234
256
|
await this.sendAndWaitForAck(auth);
|
|
235
257
|
}
|
package/lib/runtime/index.d.ts
CHANGED
package/lib/runtime/index.js
CHANGED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { CallClientOptions } from "./call";
|
|
2
|
+
export type MeetingParticipant = {
|
|
3
|
+
id: string;
|
|
4
|
+
profileId: string;
|
|
5
|
+
muted: boolean;
|
|
6
|
+
connection: "connecting" | "connected" | "failed";
|
|
7
|
+
stream?: MediaStream;
|
|
8
|
+
};
|
|
9
|
+
export type MeetingSnapshot = {
|
|
10
|
+
phase: "idle" | "joining" | "joined" | "ended";
|
|
11
|
+
groupId?: string;
|
|
12
|
+
meetingId?: string;
|
|
13
|
+
selfId?: string;
|
|
14
|
+
participants: MeetingParticipant[];
|
|
15
|
+
muted: boolean;
|
|
16
|
+
sharingId?: string;
|
|
17
|
+
localScreen?: MediaStream;
|
|
18
|
+
error?: string;
|
|
19
|
+
};
|
|
20
|
+
/** Small-group mesh (six people maximum), media capture only after explicit join/share. */
|
|
21
|
+
export declare class GroupMeetingClient {
|
|
22
|
+
private options;
|
|
23
|
+
private state;
|
|
24
|
+
private listeners;
|
|
25
|
+
private ws?;
|
|
26
|
+
private microphone?;
|
|
27
|
+
private screen?;
|
|
28
|
+
private links;
|
|
29
|
+
private generation;
|
|
30
|
+
private pendingCapture;
|
|
31
|
+
private timer?;
|
|
32
|
+
private heartbeat?;
|
|
33
|
+
private lastSeen;
|
|
34
|
+
private iceServers;
|
|
35
|
+
private queue;
|
|
36
|
+
constructor(options: CallClientOptions);
|
|
37
|
+
snapshot(): MeetingSnapshot;
|
|
38
|
+
subscribe(fn: (s: MeetingSnapshot) => void): () => void;
|
|
39
|
+
join(groupId: string): Promise<void>;
|
|
40
|
+
leave(): void;
|
|
41
|
+
dismiss(): void;
|
|
42
|
+
close(): void;
|
|
43
|
+
mute(): void;
|
|
44
|
+
shareScreen(): Promise<void>;
|
|
45
|
+
stopSharing(): Promise<void>;
|
|
46
|
+
private update;
|
|
47
|
+
private send;
|
|
48
|
+
private fail;
|
|
49
|
+
private release;
|
|
50
|
+
private participant;
|
|
51
|
+
private link;
|
|
52
|
+
private syncScreen;
|
|
53
|
+
private receive;
|
|
54
|
+
}
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/** Small-group mesh (six people maximum), media capture only after explicit join/share. */
|
|
2
|
+
export class GroupMeetingClient {
|
|
3
|
+
options;
|
|
4
|
+
state = { phase: "idle", participants: [], muted: false };
|
|
5
|
+
listeners = new Set();
|
|
6
|
+
ws;
|
|
7
|
+
microphone;
|
|
8
|
+
screen;
|
|
9
|
+
links = new Map();
|
|
10
|
+
generation = 0;
|
|
11
|
+
pendingCapture = false;
|
|
12
|
+
timer;
|
|
13
|
+
heartbeat;
|
|
14
|
+
lastSeen = 0;
|
|
15
|
+
iceServers = [];
|
|
16
|
+
queue = Promise.resolve();
|
|
17
|
+
constructor(options) {
|
|
18
|
+
this.options = options;
|
|
19
|
+
}
|
|
20
|
+
snapshot() { return this.state; }
|
|
21
|
+
subscribe(fn) { this.listeners.add(fn); fn(this.state); return () => { this.listeners.delete(fn); }; }
|
|
22
|
+
async join(groupId) {
|
|
23
|
+
if (!["idle", "ended"].includes(this.state.phase))
|
|
24
|
+
return;
|
|
25
|
+
this.release();
|
|
26
|
+
const gen = this.generation;
|
|
27
|
+
this.update({ phase: "joining", groupId, error: undefined });
|
|
28
|
+
this.timer = setTimeout(() => this.fail("CONNECTION_TIMEOUT"), 30000);
|
|
29
|
+
try {
|
|
30
|
+
const stream = await (this.options.mediaDevices ?? navigator.mediaDevices).getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true }, video: false });
|
|
31
|
+
if (gen !== this.generation) {
|
|
32
|
+
stream.getTracks().forEach(t => t.stop());
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
this.microphone = stream;
|
|
36
|
+
for (const t of stream.getAudioTracks())
|
|
37
|
+
t.onended = () => { if (gen === this.generation)
|
|
38
|
+
this.fail("MICROPHONE_DENIED"); };
|
|
39
|
+
const ws = (this.options.createWebSocket ?? (url => new WebSocket(url)))(this.options.url ?? "wss://calls.pmate.chat/call");
|
|
40
|
+
this.ws = ws;
|
|
41
|
+
ws.onopen = () => { if (gen === this.generation)
|
|
42
|
+
this.send({ type: "auth", mode: "meeting", profileId: this.options.profileId, token: this.options.token }); };
|
|
43
|
+
ws.onmessage = e => { this.lastSeen = Date.now(); this.queue = this.queue.then(async () => { if (gen === this.generation)
|
|
44
|
+
await this.receive(JSON.parse(String(e.data)), gen); }).catch(() => { if (gen === this.generation)
|
|
45
|
+
this.fail("CONNECTION_FAILED"); }); };
|
|
46
|
+
ws.onerror = () => { };
|
|
47
|
+
ws.onclose = () => { if (gen === this.generation)
|
|
48
|
+
this.fail("DISCONNECTED"); };
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
if (gen === this.generation)
|
|
52
|
+
this.fail(this.microphone ? "CONNECTION_FAILED" : "MICROPHONE_DENIED");
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
leave() { this.send({ type: "room.leave", meetingId: this.state.meetingId }); this.release(); this.update({ phase: "ended" }); }
|
|
56
|
+
dismiss() { if (this.state.phase === "ended")
|
|
57
|
+
this.update({ phase: "idle", error: undefined }); }
|
|
58
|
+
close() { this.leave(); this.listeners.clear(); }
|
|
59
|
+
mute() { if (this.state.phase !== "joined")
|
|
60
|
+
return; const muted = !this.state.muted; this.microphone?.getAudioTracks().forEach(t => t.enabled = !muted); this.update({ muted }); this.send({ type: "room.mute", meetingId: this.state.meetingId, muted }); }
|
|
61
|
+
async shareScreen() {
|
|
62
|
+
if (this.state.phase !== "joined" || this.pendingCapture)
|
|
63
|
+
return;
|
|
64
|
+
if (this.screen)
|
|
65
|
+
return this.stopSharing();
|
|
66
|
+
if (this.state.sharingId && this.state.sharingId !== this.state.selfId) {
|
|
67
|
+
this.update({ error: "SCREEN_BUSY" });
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const gen = this.generation;
|
|
71
|
+
this.pendingCapture = true;
|
|
72
|
+
try {
|
|
73
|
+
const stream = await (this.options.mediaDevices ?? navigator.mediaDevices).getDisplayMedia({ video: { width: { ideal: 1280, max: 1920 }, height: { ideal: 720, max: 1080 }, frameRate: { ideal: 10, max: 15 } }, audio: false });
|
|
74
|
+
if (gen !== this.generation) {
|
|
75
|
+
stream.getTracks().forEach(t => t.stop());
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
this.screen = stream;
|
|
79
|
+
const track = stream.getVideoTracks()[0];
|
|
80
|
+
if (!track)
|
|
81
|
+
throw new Error("No video");
|
|
82
|
+
track.contentHint = "detail";
|
|
83
|
+
track.onended = () => { if (gen === this.generation)
|
|
84
|
+
void this.stopSharing(); };
|
|
85
|
+
this.send({ type: "room.sharing", meetingId: this.state.meetingId, active: true });
|
|
86
|
+
this.update({ localScreen: stream, error: undefined });
|
|
87
|
+
}
|
|
88
|
+
catch (e) {
|
|
89
|
+
if (gen === this.generation) {
|
|
90
|
+
await this.stopSharing();
|
|
91
|
+
if (!(e instanceof DOMException && e.name === "NotAllowedError"))
|
|
92
|
+
this.update({ error: "SCREEN_UNAVAILABLE" });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
finally {
|
|
96
|
+
if (gen === this.generation)
|
|
97
|
+
this.pendingCapture = false;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
async stopSharing() {
|
|
101
|
+
const gen = this.generation;
|
|
102
|
+
const stream = this.screen;
|
|
103
|
+
this.screen = undefined;
|
|
104
|
+
stream?.getTracks().forEach(t => { t.onended = null; t.stop(); });
|
|
105
|
+
await Promise.all([...this.links.values()].map(l => l.video.sender.replaceTrack(null).catch(() => { })));
|
|
106
|
+
if (gen !== this.generation)
|
|
107
|
+
return;
|
|
108
|
+
this.send({ type: "room.sharing", meetingId: this.state.meetingId, active: false });
|
|
109
|
+
this.update({ localScreen: undefined });
|
|
110
|
+
}
|
|
111
|
+
update(p) { this.state = { ...this.state, ...p }; for (const fn of this.listeners)
|
|
112
|
+
fn(this.state); }
|
|
113
|
+
send(m) { if (this.ws?.readyState === 1)
|
|
114
|
+
this.ws.send(JSON.stringify(m)); }
|
|
115
|
+
fail(error) { this.leave(); this.update({ error }); }
|
|
116
|
+
release() {
|
|
117
|
+
this.generation++;
|
|
118
|
+
clearTimeout(this.timer);
|
|
119
|
+
clearInterval(this.heartbeat);
|
|
120
|
+
this.pendingCapture = false;
|
|
121
|
+
const ws = this.ws;
|
|
122
|
+
this.ws = undefined;
|
|
123
|
+
ws?.close();
|
|
124
|
+
for (const link of this.links.values()) {
|
|
125
|
+
clearTimeout(link.timer);
|
|
126
|
+
link.pc.close();
|
|
127
|
+
}
|
|
128
|
+
this.links.clear();
|
|
129
|
+
for (const stream of [this.microphone, this.screen])
|
|
130
|
+
stream?.getTracks().forEach(t => { t.onended = null; t.stop(); });
|
|
131
|
+
this.microphone = undefined;
|
|
132
|
+
this.screen = undefined;
|
|
133
|
+
this.update({ participants: [], meetingId: undefined, selfId: undefined, sharingId: undefined, localScreen: undefined, muted: false });
|
|
134
|
+
}
|
|
135
|
+
participant(id, patch) { this.update({ participants: this.state.participants.map(p => p.id === id ? { ...p, ...patch } : p) }); }
|
|
136
|
+
link(id, gen) {
|
|
137
|
+
const previous = this.links.get(id);
|
|
138
|
+
if (previous)
|
|
139
|
+
return previous;
|
|
140
|
+
const pc = (this.options.createPeerConnection ?? (c => new RTCPeerConnection(c)))({ bundlePolicy: "max-bundle", iceServers: this.iceServers, iceTransportPolicy: this.options.iceTransportPolicy ?? "all" });
|
|
141
|
+
for (const track of this.microphone.getAudioTracks())
|
|
142
|
+
pc.addTrack(track, this.microphone);
|
|
143
|
+
const video = pc.addTransceiver("video", { direction: "sendrecv", sendEncodings: [{ maxBitrate: 800000, maxFramerate: 15 }] });
|
|
144
|
+
const link = { pc, video, remote: new MediaStream(), candidates: [] };
|
|
145
|
+
this.links.set(id, link);
|
|
146
|
+
const current = () => gen === this.generation && this.links.get(id) === link;
|
|
147
|
+
link.timer = setTimeout(() => { if (current())
|
|
148
|
+
this.participant(id, { connection: "failed" }); }, 30000);
|
|
149
|
+
pc.ontrack = e => { if (current()) {
|
|
150
|
+
link.remote.addTrack(e.track);
|
|
151
|
+
this.participant(id, { stream: link.remote });
|
|
152
|
+
} };
|
|
153
|
+
pc.onicecandidate = e => { if (current() && e.candidate)
|
|
154
|
+
this.send({ type: "room.ice", meetingId: this.state.meetingId, peerId: id, candidate: e.candidate.toJSON() }); };
|
|
155
|
+
pc.onconnectionstatechange = () => {
|
|
156
|
+
if (!current())
|
|
157
|
+
return;
|
|
158
|
+
if (pc.connectionState === "connected") {
|
|
159
|
+
clearTimeout(link.timer);
|
|
160
|
+
this.participant(id, { connection: "connected" });
|
|
161
|
+
}
|
|
162
|
+
else if (["failed", "disconnected", "closed"].includes(pc.connectionState))
|
|
163
|
+
this.participant(id, { connection: pc.connectionState === "disconnected" ? "connecting" : "failed" });
|
|
164
|
+
};
|
|
165
|
+
return link;
|
|
166
|
+
}
|
|
167
|
+
async syncScreen() {
|
|
168
|
+
const track = this.state.sharingId === this.state.selfId ? this.screen?.getVideoTracks()[0] ?? null : null;
|
|
169
|
+
await Promise.all([...this.links.values()].map(l => l.video.sender.replaceTrack(track)));
|
|
170
|
+
}
|
|
171
|
+
async receive(m, gen) {
|
|
172
|
+
if (m.type === "ready") {
|
|
173
|
+
this.send({ type: "room.join", groupId: this.state.groupId, requestId: String(gen) });
|
|
174
|
+
this.lastSeen = Date.now();
|
|
175
|
+
this.heartbeat = setInterval(() => { if (Date.now() - this.lastSeen > 60000)
|
|
176
|
+
this.fail("DISCONNECTED");
|
|
177
|
+
else
|
|
178
|
+
this.send({ type: "ping" }); }, 20000);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
if (m.type === "pong")
|
|
182
|
+
return;
|
|
183
|
+
if (m.type === "error") {
|
|
184
|
+
if (m.code === "SCREEN_BUSY") {
|
|
185
|
+
await this.stopSharing();
|
|
186
|
+
this.update({ error: "SCREEN_BUSY" });
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
this.fail(m.code);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
if (m.type === "room.joined") {
|
|
193
|
+
if (m.requestId !== String(gen))
|
|
194
|
+
return;
|
|
195
|
+
clearTimeout(this.timer);
|
|
196
|
+
this.iceServers = m.iceServers;
|
|
197
|
+
this.update({ phase: "joined", meetingId: m.meetingId, selfId: m.selfId });
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
if (m.meetingId !== this.state.meetingId || this.state.phase !== "joined")
|
|
201
|
+
return;
|
|
202
|
+
if (m.type === "room.state") {
|
|
203
|
+
const old = new Map(this.state.participants.map(p => [p.id, p]));
|
|
204
|
+
this.update({ sharingId: m.sharingId ?? undefined, participants: m.participants.map((p) => ({ ...old.get(p.id), ...p, connection: p.id === this.state.selfId ? "connected" : old.get(p.id)?.connection ?? "connecting" })) });
|
|
205
|
+
for (const [id, l] of this.links)
|
|
206
|
+
if (!this.state.participants.some(p => p.id === id)) {
|
|
207
|
+
this.links.delete(id);
|
|
208
|
+
clearTimeout(l.timer);
|
|
209
|
+
l.pc.close();
|
|
210
|
+
}
|
|
211
|
+
for (const p of this.state.participants)
|
|
212
|
+
if (p.id !== this.state.selfId)
|
|
213
|
+
this.link(p.id, gen);
|
|
214
|
+
await this.syncScreen();
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
if (!this.state.participants.some(p => p.id === m.peerId) || m.peerId === this.state.selfId)
|
|
218
|
+
return;
|
|
219
|
+
const l = this.link(m.peerId, gen), pc = l.pc;
|
|
220
|
+
if (m.type === "room.offer") {
|
|
221
|
+
await pc.setLocalDescription(await pc.createOffer());
|
|
222
|
+
if (gen === this.generation)
|
|
223
|
+
this.send({ type: "room.sdp", meetingId: this.state.meetingId, peerId: m.peerId, description: pc.localDescription });
|
|
224
|
+
}
|
|
225
|
+
else if (m.type === "room.sdp") {
|
|
226
|
+
await pc.setRemoteDescription(m.description);
|
|
227
|
+
if (gen !== this.generation)
|
|
228
|
+
return;
|
|
229
|
+
for (const c of l.candidates.splice(0))
|
|
230
|
+
await pc.addIceCandidate(c);
|
|
231
|
+
if (m.description.type === "offer") {
|
|
232
|
+
// setRemoteDescription may create the answerer's receiving transceiver;
|
|
233
|
+
// attach sharing to that negotiated m-line, not an unassociated local one.
|
|
234
|
+
const negotiated = pc.getTransceivers().find(t => t.mid !== null && t.receiver.track.kind === "video");
|
|
235
|
+
if (negotiated) {
|
|
236
|
+
if (l.video !== negotiated && l.video.mid === null)
|
|
237
|
+
l.video.stop();
|
|
238
|
+
l.video = negotiated;
|
|
239
|
+
l.video.direction = "sendrecv";
|
|
240
|
+
await l.video.sender.replaceTrack(this.state.sharingId === this.state.selfId ? this.screen?.getVideoTracks()[0] ?? null : null);
|
|
241
|
+
}
|
|
242
|
+
await pc.setLocalDescription(await pc.createAnswer());
|
|
243
|
+
if (gen === this.generation)
|
|
244
|
+
this.send({ type: "room.sdp", meetingId: this.state.meetingId, peerId: m.peerId, description: pc.localDescription });
|
|
245
|
+
}
|
|
246
|
+
for (const sender of pc.getSenders()) {
|
|
247
|
+
const params = sender.getParameters();
|
|
248
|
+
if (params.encodings?.length) {
|
|
249
|
+
for (const encoding of params.encodings)
|
|
250
|
+
encoding.maxBitrate = sender === l.video.sender ? 800000 : 64000;
|
|
251
|
+
await sender.setParameters(params);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
else if (m.type === "room.ice") {
|
|
256
|
+
if (pc.remoteDescription)
|
|
257
|
+
await pc.addIceCandidate(m.candidate);
|
|
258
|
+
else if (l.candidates.length < 128)
|
|
259
|
+
l.candidates.push(m.candidate);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export type PresenceStatus = "online" | "offline" | "unknown";
|
|
2
|
+
export type ChatPresence = {
|
|
3
|
+
appId: string;
|
|
4
|
+
profileId: string;
|
|
5
|
+
status: PresenceStatus;
|
|
6
|
+
availability: "normal" | "degraded";
|
|
7
|
+
};
|
|
8
|
+
export type PresenceOptions = {
|
|
9
|
+
appId: string;
|
|
10
|
+
profileId: string;
|
|
11
|
+
instanceId?: string;
|
|
12
|
+
token: () => Promise<string> | string;
|
|
13
|
+
baseUrl?: string;
|
|
14
|
+
fetchImpl?: typeof fetch;
|
|
15
|
+
onError?: (error: unknown) => void;
|
|
16
|
+
};
|
|
17
|
+
/** One instance is one authenticated client/runtime lease, never a whole profile. */
|
|
18
|
+
export declare class ChatPresenceClient {
|
|
19
|
+
private options;
|
|
20
|
+
readonly instanceId: string;
|
|
21
|
+
private seq;
|
|
22
|
+
private active;
|
|
23
|
+
private generation;
|
|
24
|
+
private beatTimer?;
|
|
25
|
+
private queryTimer?;
|
|
26
|
+
private expiryTimer?;
|
|
27
|
+
private beating;
|
|
28
|
+
private querying;
|
|
29
|
+
private profiles;
|
|
30
|
+
private entries;
|
|
31
|
+
private handlers;
|
|
32
|
+
private availability;
|
|
33
|
+
constructor(options: PresenceOptions);
|
|
34
|
+
snapshot(): ChatPresence[];
|
|
35
|
+
watch(handler: (items: ChatPresence[]) => void): () => void;
|
|
36
|
+
watchProfiles(ids: string[]): void;
|
|
37
|
+
setAvailability(value: "normal" | "degraded"): void;
|
|
38
|
+
start(): void;
|
|
39
|
+
stop(): void;
|
|
40
|
+
private request;
|
|
41
|
+
private heartbeat;
|
|
42
|
+
private query;
|
|
43
|
+
private emit;
|
|
44
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/** One instance is one authenticated client/runtime lease, never a whole profile. */
|
|
2
|
+
export class ChatPresenceClient {
|
|
3
|
+
options;
|
|
4
|
+
instanceId;
|
|
5
|
+
seq = 0;
|
|
6
|
+
active = false;
|
|
7
|
+
generation = 0;
|
|
8
|
+
beatTimer;
|
|
9
|
+
queryTimer;
|
|
10
|
+
expiryTimer;
|
|
11
|
+
beating = false;
|
|
12
|
+
querying = false;
|
|
13
|
+
profiles = [];
|
|
14
|
+
entries = new Map();
|
|
15
|
+
handlers = new Set();
|
|
16
|
+
availability = "normal";
|
|
17
|
+
constructor(options) {
|
|
18
|
+
this.options = options;
|
|
19
|
+
this.instanceId = options.instanceId ?? crypto.randomUUID();
|
|
20
|
+
}
|
|
21
|
+
snapshot() {
|
|
22
|
+
return this.profiles.map(profileId => {
|
|
23
|
+
const entry = this.entries.get(profileId);
|
|
24
|
+
return entry && entry.expires > Date.now() ? { appId: entry.appId, profileId, status: entry.status, availability: entry.availability }
|
|
25
|
+
: { appId: this.options.appId, profileId, status: "unknown", availability: "normal" };
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
watch(handler) { this.handlers.add(handler); handler(this.snapshot()); return () => { this.handlers.delete(handler); }; }
|
|
29
|
+
watchProfiles(ids) {
|
|
30
|
+
const profiles = [...new Set(ids)].sort().slice(0, 50);
|
|
31
|
+
if (JSON.stringify(profiles) === JSON.stringify(this.profiles))
|
|
32
|
+
return;
|
|
33
|
+
this.profiles = profiles;
|
|
34
|
+
this.generation++;
|
|
35
|
+
this.entries.clear();
|
|
36
|
+
this.emit();
|
|
37
|
+
if (this.active)
|
|
38
|
+
void this.query();
|
|
39
|
+
}
|
|
40
|
+
setAvailability(value) { this.availability = value; }
|
|
41
|
+
start() {
|
|
42
|
+
if (this.active)
|
|
43
|
+
return;
|
|
44
|
+
this.active = true;
|
|
45
|
+
this.generation++;
|
|
46
|
+
void this.heartbeat();
|
|
47
|
+
void this.query();
|
|
48
|
+
this.beatTimer = setInterval(() => void this.heartbeat(), 20_000);
|
|
49
|
+
this.queryTimer = setInterval(() => void this.query(), 5_000);
|
|
50
|
+
this.expiryTimer = setInterval(() => {
|
|
51
|
+
if ([...this.entries.values()].some(e => e.expires <= Date.now())) {
|
|
52
|
+
for (const [id, e] of this.entries)
|
|
53
|
+
if (e.expires <= Date.now())
|
|
54
|
+
this.entries.delete(id);
|
|
55
|
+
this.emit();
|
|
56
|
+
}
|
|
57
|
+
}, 1000);
|
|
58
|
+
}
|
|
59
|
+
stop() {
|
|
60
|
+
const wasActive = this.active;
|
|
61
|
+
this.active = false;
|
|
62
|
+
this.generation++;
|
|
63
|
+
clearInterval(this.beatTimer);
|
|
64
|
+
clearInterval(this.queryTimer);
|
|
65
|
+
clearInterval(this.expiryTimer);
|
|
66
|
+
this.entries.clear();
|
|
67
|
+
this.emit();
|
|
68
|
+
// Sequence tombstones prevent an in-flight heartbeat from undoing release.
|
|
69
|
+
if (wasActive)
|
|
70
|
+
void this.request("release", { instanceId: this.instanceId, seq: ++this.seq }).catch(e => this.options.onError?.(e));
|
|
71
|
+
}
|
|
72
|
+
async request(action, params) {
|
|
73
|
+
const token = await this.options.token();
|
|
74
|
+
const response = await (this.options.fetchImpl ?? fetch)(`${(this.options.baseUrl ?? "https://hub.pmate.chat").replace(/\/$/, "")}/api/chat-presence/${action}`, {
|
|
75
|
+
method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
|
76
|
+
body: JSON.stringify({ appId: this.options.appId, profileId: this.options.profileId, ...params }), signal: AbortSignal.timeout(12_000), keepalive: action === "release",
|
|
77
|
+
});
|
|
78
|
+
if (!response.ok)
|
|
79
|
+
throw new Error(`Presence request failed (${response.status})`);
|
|
80
|
+
const result = await response.json();
|
|
81
|
+
if (!result.success)
|
|
82
|
+
throw new Error("Presence unavailable");
|
|
83
|
+
return result.data;
|
|
84
|
+
}
|
|
85
|
+
async heartbeat() {
|
|
86
|
+
if (!this.active || this.beating)
|
|
87
|
+
return;
|
|
88
|
+
this.beating = true;
|
|
89
|
+
try {
|
|
90
|
+
await this.request("heartbeat", { instanceId: this.instanceId, seq: ++this.seq, availability: this.availability });
|
|
91
|
+
}
|
|
92
|
+
catch (e) {
|
|
93
|
+
this.options.onError?.(e);
|
|
94
|
+
}
|
|
95
|
+
finally {
|
|
96
|
+
this.beating = false;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
async query() {
|
|
100
|
+
if (!this.active || !this.profiles.length || this.querying)
|
|
101
|
+
return;
|
|
102
|
+
this.querying = true;
|
|
103
|
+
const generation = this.generation, requested = this.profiles.slice(), started = Date.now();
|
|
104
|
+
try {
|
|
105
|
+
const result = await this.request("query", { profileIds: requested });
|
|
106
|
+
if (!this.active || generation !== this.generation)
|
|
107
|
+
return;
|
|
108
|
+
if (!Array.isArray(result?.items))
|
|
109
|
+
throw new Error("Invalid presence snapshot");
|
|
110
|
+
const entries = new Map();
|
|
111
|
+
for (const item of result.items) {
|
|
112
|
+
if (item.appId !== this.options.appId || !requested.includes(item.profileId) || !["online", "offline", "unknown"].includes(item.status) || !Number.isFinite(item.validForMs) || item.validForMs < 0)
|
|
113
|
+
throw new Error("Invalid presence item");
|
|
114
|
+
entries.set(item.profileId, { ...item, availability: item.availability === "degraded" ? "degraded" : "normal", expires: started + Math.min(item.validForMs, 10_000) });
|
|
115
|
+
}
|
|
116
|
+
this.entries = entries;
|
|
117
|
+
this.emit();
|
|
118
|
+
}
|
|
119
|
+
catch (e) {
|
|
120
|
+
if (generation === this.generation) {
|
|
121
|
+
this.entries.clear();
|
|
122
|
+
this.emit();
|
|
123
|
+
}
|
|
124
|
+
this.options.onError?.(e);
|
|
125
|
+
}
|
|
126
|
+
finally {
|
|
127
|
+
this.querying = false;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
emit() { const items = this.snapshot(); for (const h of this.handlers)
|
|
131
|
+
h(items); }
|
|
132
|
+
}
|