@nopeek/chat 0.1.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/README.md +17 -0
- package/dist/crypto.d.ts +80 -0
- package/dist/crypto.d.ts.map +1 -0
- package/dist/crypto.js +161 -0
- package/dist/crypto.js.map +1 -0
- package/dist/index.d.ts +471 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1258 -0
- package/dist/index.js.map +1 -0
- package/package.json +29 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @nopeek/chat — NoPeek client SDK.
|
|
3
|
+
*
|
|
4
|
+
* const np = await NoPeek.connect({ apiUrl, sessionToken });
|
|
5
|
+
* const ch = await np.channels.create({ channelTypeKey: "direct", memberIds: ["usr_…"] });
|
|
6
|
+
* await ch.send({ text: "encrypted before it leaves this device" });
|
|
7
|
+
* ch.on("message", (m) => console.log(m.body?.text));
|
|
8
|
+
*/
|
|
9
|
+
import { b64, decryptBytes, decryptPayload, deriveBackupKey, encryptBytes, encryptPayload, exportChannelKey, exportRawKeyB64, importRawKeyB64, generateChannelKey, generateContentKey, generateDeviceKeys, importChannelKey, keyPackageData, rsaOaepWrapB64, sha256hex, unwrapChannelKey, wrapChannelKey, } from "./crypto.js";
|
|
10
|
+
class MemoryStore {
|
|
11
|
+
m = new Map();
|
|
12
|
+
get(k) {
|
|
13
|
+
return this.m.get(k) ?? null;
|
|
14
|
+
}
|
|
15
|
+
set(k, v) {
|
|
16
|
+
this.m.set(k, v);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
const defaultStore = () => {
|
|
20
|
+
try {
|
|
21
|
+
if (typeof localStorage !== "undefined") {
|
|
22
|
+
return { get: (k) => localStorage.getItem(k), set: (k, v) => localStorage.setItem(k, v) };
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
/* SSR / node */
|
|
27
|
+
}
|
|
28
|
+
return new MemoryStore();
|
|
29
|
+
};
|
|
30
|
+
class Emitter {
|
|
31
|
+
listeners = new Map();
|
|
32
|
+
on(event, fn) {
|
|
33
|
+
if (!this.listeners.has(event))
|
|
34
|
+
this.listeners.set(event, new Set());
|
|
35
|
+
this.listeners.get(event).add(fn);
|
|
36
|
+
return () => this.off(event, fn);
|
|
37
|
+
}
|
|
38
|
+
off(event, fn) {
|
|
39
|
+
this.listeners.get(event)?.delete(fn);
|
|
40
|
+
}
|
|
41
|
+
emit(event, ...args) {
|
|
42
|
+
for (const fn of this.listeners.get(event) ?? [])
|
|
43
|
+
fn(...args);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
export class NoPeek extends Emitter {
|
|
47
|
+
apiUrl;
|
|
48
|
+
appId;
|
|
49
|
+
userId;
|
|
50
|
+
deviceId;
|
|
51
|
+
token;
|
|
52
|
+
store;
|
|
53
|
+
keys;
|
|
54
|
+
ws = null;
|
|
55
|
+
reqCounter = 0;
|
|
56
|
+
pending = new Map();
|
|
57
|
+
channelKeys = new Map(); // channelId -> AES key
|
|
58
|
+
mlsGroupToChannel = new Map();
|
|
59
|
+
/** Latest known presence per user, updated on every presence frame (incl. the
|
|
60
|
+
* connect-time snapshot that may arrive before app listeners attach). */
|
|
61
|
+
presenceState = new Map();
|
|
62
|
+
reconnectDelay = 1000;
|
|
63
|
+
closed = false;
|
|
64
|
+
/** Set while the app is backgrounded: the WS is intentionally dropped so the
|
|
65
|
+
* server clears presence and delivers pushes instead. Distinct from `closed`
|
|
66
|
+
* (permanent) — a suspended client reconnects on resume(). */
|
|
67
|
+
suspended = false;
|
|
68
|
+
pingTimer = null;
|
|
69
|
+
platform;
|
|
70
|
+
autoWs;
|
|
71
|
+
deferRegistration;
|
|
72
|
+
/** Set when connect() had to register a brand-new device — restore() revokes it
|
|
73
|
+
* if the backup turns out to hold the user's real device identity. */
|
|
74
|
+
justRegisteredDeviceId = null;
|
|
75
|
+
channels;
|
|
76
|
+
deriveChannelKey;
|
|
77
|
+
static async connect(opts) {
|
|
78
|
+
const np = new NoPeek(opts);
|
|
79
|
+
await np.ensureDevice();
|
|
80
|
+
// With deferDeviceRegistration and no locally saved identity there is no
|
|
81
|
+
// deviceId yet — key packages and the WS wait for restore()/registerDevice().
|
|
82
|
+
if (np.deviceId) {
|
|
83
|
+
await np.ensureKeyPackages();
|
|
84
|
+
if (np.autoWs)
|
|
85
|
+
await np.connectWs();
|
|
86
|
+
}
|
|
87
|
+
return np;
|
|
88
|
+
}
|
|
89
|
+
constructor(opts) {
|
|
90
|
+
super();
|
|
91
|
+
this.apiUrl = opts.apiUrl.replace(/\/$/, "");
|
|
92
|
+
this.token = opts.sessionToken;
|
|
93
|
+
this.appId = opts.appId;
|
|
94
|
+
this.userId = opts.userId;
|
|
95
|
+
this.store = opts.storage ?? defaultStore();
|
|
96
|
+
this.deriveChannelKey = opts.deriveChannelKey;
|
|
97
|
+
this.platform = opts.platform ?? "web";
|
|
98
|
+
this.autoWs = opts.autoConnectWs !== false;
|
|
99
|
+
this.deferRegistration = opts.deferDeviceRegistration === true;
|
|
100
|
+
this.channels = new Channels(this);
|
|
101
|
+
}
|
|
102
|
+
/** Ensure a channel key exists via the preshared-key hook; returns false if no hook. */
|
|
103
|
+
async deriveAndSet(channelId) {
|
|
104
|
+
if (!this.deriveChannelKey)
|
|
105
|
+
return false;
|
|
106
|
+
if (this.channelKeys.has(channelId))
|
|
107
|
+
return true;
|
|
108
|
+
this.channelKeys.set(channelId, await this.deriveChannelKey(channelId));
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
// ------------------------------------------------------------- REST ----
|
|
112
|
+
async api(method, path, body) {
|
|
113
|
+
const res = await fetch(`${this.apiUrl}${path}`, {
|
|
114
|
+
method,
|
|
115
|
+
headers: {
|
|
116
|
+
authorization: `Bearer ${this.token}`,
|
|
117
|
+
...(body !== undefined ? { "content-type": "application/json" } : {}),
|
|
118
|
+
...(this.deviceId ? { "x-nopeek-device": this.deviceId } : {}),
|
|
119
|
+
},
|
|
120
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
121
|
+
});
|
|
122
|
+
const text = await res.text();
|
|
123
|
+
if (!text) {
|
|
124
|
+
if (!res.ok)
|
|
125
|
+
throw new Error(`HTTP ${res.status}`);
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
128
|
+
const json = JSON.parse(text);
|
|
129
|
+
if (!res.ok)
|
|
130
|
+
throw new Error(json.error ? `${json.error.code}: ${json.error.message}` : `HTTP ${res.status}`);
|
|
131
|
+
return json;
|
|
132
|
+
}
|
|
133
|
+
// ----------------------------------------------------------- device ----
|
|
134
|
+
async ensureDevice() {
|
|
135
|
+
const saved = await this.store.get(`nopeek:${this.userId}:device`);
|
|
136
|
+
if (saved) {
|
|
137
|
+
const parsed = JSON.parse(saved);
|
|
138
|
+
this.deviceId = parsed.deviceId;
|
|
139
|
+
this.keys = parsed.keys;
|
|
140
|
+
const savedKeys = await this.store.get(`nopeek:${this.userId}:channelKeys`);
|
|
141
|
+
if (savedKeys) {
|
|
142
|
+
for (const [chId, raw] of Object.entries(JSON.parse(savedKeys))) {
|
|
143
|
+
this.channelKeys.set(chId, await importChannelKey(raw));
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
this.keys = await generateDeviceKeys();
|
|
149
|
+
// Deferred: the caller decides whether to restore() the real identity from a
|
|
150
|
+
// backup or registerDevice() a genuinely new one. Registering here would
|
|
151
|
+
// orphan a device on every fresh login of a returning user.
|
|
152
|
+
if (this.deferRegistration)
|
|
153
|
+
return;
|
|
154
|
+
await this.registerDeviceNow();
|
|
155
|
+
this.justRegisteredDeviceId = this.deviceId;
|
|
156
|
+
}
|
|
157
|
+
/** Whether this client has a server-registered device identity yet. False only
|
|
158
|
+
* during a deferred connect, before restore()/registerDevice() completes. */
|
|
159
|
+
get deviceRegistered() {
|
|
160
|
+
return Boolean(this.deviceId);
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Complete a deferred connect by registering a brand-new device. Call this
|
|
164
|
+
* only when the user has NO restorable backup (genuine new user/device) —
|
|
165
|
+
* otherwise call restore(), which adopts the backed-up device identity.
|
|
166
|
+
* No-op when a device identity already exists. Publishes key packages and
|
|
167
|
+
* opens the WebSocket (unless autoConnectWs was disabled).
|
|
168
|
+
*/
|
|
169
|
+
async registerDevice() {
|
|
170
|
+
if (this.deviceId)
|
|
171
|
+
return;
|
|
172
|
+
if (!this.keys)
|
|
173
|
+
this.keys = await generateDeviceKeys();
|
|
174
|
+
await this.registerDeviceNow();
|
|
175
|
+
await this.ensureKeyPackages();
|
|
176
|
+
if (this.autoWs && !this.ws)
|
|
177
|
+
await this.connectWs();
|
|
178
|
+
}
|
|
179
|
+
/** POST the current keypair as a new device and persist the identity locally. */
|
|
180
|
+
async registerDeviceNow() {
|
|
181
|
+
const device = await this.api("POST", `/v1/apps/${this.appId}/users/${this.userId}/devices`, {
|
|
182
|
+
credentialKey: b64.enc(new TextEncoder().encode(JSON.stringify(this.keys.publicKeyJwk))),
|
|
183
|
+
platform: this.platform,
|
|
184
|
+
displayName: `${this.platform} device`,
|
|
185
|
+
});
|
|
186
|
+
this.deviceId = device.deviceId;
|
|
187
|
+
await this.persistDevice();
|
|
188
|
+
}
|
|
189
|
+
async persistDevice() {
|
|
190
|
+
await this.store.set(`nopeek:${this.userId}:device`, JSON.stringify({ deviceId: this.deviceId, keys: this.keys }));
|
|
191
|
+
}
|
|
192
|
+
async persistChannelKeys() {
|
|
193
|
+
const out = {};
|
|
194
|
+
for (const [chId, key] of this.channelKeys)
|
|
195
|
+
out[chId] = await exportChannelKey(key);
|
|
196
|
+
await this.store.set(`nopeek:${this.userId}:channelKeys`, JSON.stringify(out));
|
|
197
|
+
}
|
|
198
|
+
async ensureKeyPackages() {
|
|
199
|
+
const { available } = await this.api("GET", `/v1/apps/${this.appId}/users/${this.userId}/devices/${this.deviceId}/key-packages`);
|
|
200
|
+
if (available >= 10)
|
|
201
|
+
return;
|
|
202
|
+
const data = keyPackageData(this.deviceId, this.keys.publicKeyJwk);
|
|
203
|
+
const expiresAt = new Date(Date.now() + 90 * 86_400_000).toISOString();
|
|
204
|
+
await this.api("POST", `/v1/apps/${this.appId}/users/${this.userId}/devices/${this.deviceId}/key-packages`, {
|
|
205
|
+
keyPackages: await Promise.all(Array.from({ length: 20 }, async () => ({
|
|
206
|
+
cipherSuite: 1,
|
|
207
|
+
data,
|
|
208
|
+
dataHash: await sha256hex(data),
|
|
209
|
+
expiresAt,
|
|
210
|
+
}))),
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
// --------------------------------------------------------------- WS ----
|
|
214
|
+
connectWs() {
|
|
215
|
+
return new Promise((resolve, reject) => {
|
|
216
|
+
const wsUrl = `${this.apiUrl.replace(/^http/, "ws")}/v1/ws?token=${encodeURIComponent(this.token)}&deviceId=${this.deviceId}`;
|
|
217
|
+
const ws = new WebSocket(wsUrl);
|
|
218
|
+
this.ws = ws;
|
|
219
|
+
const authTimeout = setTimeout(() => reject(new Error("WS auth timeout")), 10_000);
|
|
220
|
+
ws.onmessage = (ev) => void this.handleFrame(JSON.parse(String(ev.data)), resolve, authTimeout);
|
|
221
|
+
ws.onclose = () => {
|
|
222
|
+
this.stopPing();
|
|
223
|
+
this.emit("disconnected");
|
|
224
|
+
// Don't reconnect while permanently closed OR intentionally suspended
|
|
225
|
+
// (app backgrounded — we WANT the socket down so pushes take over).
|
|
226
|
+
if (!this.closed && !this.suspended) {
|
|
227
|
+
setTimeout(() => void this.connectWs().catch(() => { }), this.reconnectDelay);
|
|
228
|
+
this.reconnectDelay = Math.min(this.reconnectDelay * 2, 30_000);
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
ws.onerror = () => {
|
|
232
|
+
clearTimeout(authTimeout);
|
|
233
|
+
reject(new Error("WS connection failed"));
|
|
234
|
+
};
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
/** Heartbeat: keep the socket alive while foregrounded so the server's idle
|
|
238
|
+
* timeout only fires once the client actually stops pinging (backgrounded /
|
|
239
|
+
* crashed / lost network), at which point presence clears and pushes resume. */
|
|
240
|
+
startPing() {
|
|
241
|
+
this.stopPing();
|
|
242
|
+
this.pingTimer = setInterval(() => {
|
|
243
|
+
try {
|
|
244
|
+
if (this.ws?.readyState === WebSocket.OPEN)
|
|
245
|
+
this.ws.send(JSON.stringify({ type: "ping" }));
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
/* next reconnect handles it */
|
|
249
|
+
}
|
|
250
|
+
}, 25_000);
|
|
251
|
+
}
|
|
252
|
+
stopPing() {
|
|
253
|
+
if (this.pingTimer) {
|
|
254
|
+
clearInterval(this.pingTimer);
|
|
255
|
+
this.pingTimer = null;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
/** Drop the WS when the app goes to the background (native): the server clears
|
|
259
|
+
* presence immediately and routes new messages to APNs/FCM push instead of a
|
|
260
|
+
* socket that iOS is about to freeze. Safe to call repeatedly. */
|
|
261
|
+
suspend() {
|
|
262
|
+
if (this.suspended)
|
|
263
|
+
return;
|
|
264
|
+
this.suspended = true;
|
|
265
|
+
this.stopPing();
|
|
266
|
+
try {
|
|
267
|
+
this.ws?.close();
|
|
268
|
+
}
|
|
269
|
+
catch {
|
|
270
|
+
/* already closing */
|
|
271
|
+
}
|
|
272
|
+
this.ws = null;
|
|
273
|
+
}
|
|
274
|
+
/** Reconnect when the app returns to the foreground. */
|
|
275
|
+
resume() {
|
|
276
|
+
if (!this.suspended)
|
|
277
|
+
return;
|
|
278
|
+
this.suspended = false;
|
|
279
|
+
this.reconnectDelay = 1000;
|
|
280
|
+
if (!this.closed && (!this.ws || this.ws.readyState > WebSocket.OPEN)) {
|
|
281
|
+
void this.connectWs().catch(() => { });
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
close() {
|
|
285
|
+
this.closed = true;
|
|
286
|
+
this.stopPing();
|
|
287
|
+
this.ws?.close();
|
|
288
|
+
}
|
|
289
|
+
sendFrame(frame) {
|
|
290
|
+
const reqId = `r${++this.reqCounter}`;
|
|
291
|
+
return new Promise((resolve, reject) => {
|
|
292
|
+
this.pending.set(reqId, { resolve, reject });
|
|
293
|
+
this.ws?.send(JSON.stringify({ ...frame, reqId }));
|
|
294
|
+
setTimeout(() => {
|
|
295
|
+
if (this.pending.delete(reqId))
|
|
296
|
+
reject(new Error(`timeout waiting for ack of ${String(frame.type)}`));
|
|
297
|
+
}, 15_000);
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
async handleFrame(frame, authResolve, authTimeout) {
|
|
301
|
+
switch (frame.type) {
|
|
302
|
+
case "auth.ok":
|
|
303
|
+
clearTimeout(authTimeout);
|
|
304
|
+
this.reconnectDelay = 1000;
|
|
305
|
+
this.startPing();
|
|
306
|
+
this.emit("connected", frame);
|
|
307
|
+
authResolve?.();
|
|
308
|
+
void this.ensureKeyPackages().catch(() => { });
|
|
309
|
+
break;
|
|
310
|
+
case "ack": {
|
|
311
|
+
const p = this.pending.get(String(frame.reqId));
|
|
312
|
+
if (p) {
|
|
313
|
+
this.pending.delete(String(frame.reqId));
|
|
314
|
+
p.resolve(frame);
|
|
315
|
+
}
|
|
316
|
+
break;
|
|
317
|
+
}
|
|
318
|
+
case "error": {
|
|
319
|
+
const p = frame.reqId ? this.pending.get(String(frame.reqId)) : undefined;
|
|
320
|
+
if (p) {
|
|
321
|
+
this.pending.delete(String(frame.reqId));
|
|
322
|
+
p.reject(new Error(`${String(frame.code)}: ${String(frame.message)}`));
|
|
323
|
+
}
|
|
324
|
+
else {
|
|
325
|
+
this.emit("error", frame);
|
|
326
|
+
}
|
|
327
|
+
break;
|
|
328
|
+
}
|
|
329
|
+
case "message.new":
|
|
330
|
+
case "message.updated": {
|
|
331
|
+
const decrypted = await this.decryptEnvelope(frame.message);
|
|
332
|
+
// E2EE reactions arrive as carrier messages; surface as reaction events
|
|
333
|
+
if (decrypted.body?.type === "reaction" && decrypted.body.targetMessageId) {
|
|
334
|
+
this.emit("reaction", {
|
|
335
|
+
channelId: decrypted.channelId,
|
|
336
|
+
messageId: decrypted.body.targetMessageId,
|
|
337
|
+
userId: decrypted.senderUserId,
|
|
338
|
+
emoji: decrypted.body.emoji,
|
|
339
|
+
op: decrypted.body.op ?? "add",
|
|
340
|
+
});
|
|
341
|
+
break;
|
|
342
|
+
}
|
|
343
|
+
// poll votes and RSVPs are carrier events aggregated client-side
|
|
344
|
+
if (decrypted.body?.type === "poll_vote" && decrypted.body.targetMessageId) {
|
|
345
|
+
const pollMessageId = String(decrypted.body.targetMessageId);
|
|
346
|
+
this.recordPollVote(pollMessageId, decrypted.senderUserId, decrypted.body.pollOptionIds ?? []);
|
|
347
|
+
this.emit("pollVote", {
|
|
348
|
+
channelId: decrypted.channelId,
|
|
349
|
+
pollMessageId,
|
|
350
|
+
userId: decrypted.senderUserId,
|
|
351
|
+
optionIds: decrypted.body.pollOptionIds ?? [],
|
|
352
|
+
results: this.pollResults(pollMessageId),
|
|
353
|
+
});
|
|
354
|
+
break;
|
|
355
|
+
}
|
|
356
|
+
if (decrypted.body?.type === "rsvp" && decrypted.body.targetMessageId) {
|
|
357
|
+
const eventMessageId = String(decrypted.body.targetMessageId);
|
|
358
|
+
const response = decrypted.body.rsvp;
|
|
359
|
+
this.recordRsvp(eventMessageId, decrypted.senderUserId, response);
|
|
360
|
+
this.emit("rsvp", {
|
|
361
|
+
channelId: decrypted.channelId,
|
|
362
|
+
eventMessageId,
|
|
363
|
+
userId: decrypted.senderUserId,
|
|
364
|
+
response,
|
|
365
|
+
results: this.rsvpResults(eventMessageId),
|
|
366
|
+
});
|
|
367
|
+
break;
|
|
368
|
+
}
|
|
369
|
+
this.emit(frame.type === "message.new" ? "message" : "messageUpdated", decrypted);
|
|
370
|
+
break;
|
|
371
|
+
}
|
|
372
|
+
case "message.recalled":
|
|
373
|
+
this.emit("messageRecalled", frame);
|
|
374
|
+
break;
|
|
375
|
+
case "reaction":
|
|
376
|
+
this.emit("reaction", frame);
|
|
377
|
+
break;
|
|
378
|
+
case "typing":
|
|
379
|
+
this.emit("typing", frame);
|
|
380
|
+
break;
|
|
381
|
+
case "signal":
|
|
382
|
+
// Call-signaling relay (offer/answer/ICE). Surfacing it here lets
|
|
383
|
+
// integrators receive signals on their existing connection instead of
|
|
384
|
+
// opening a second WebSocket to the same endpoint.
|
|
385
|
+
this.emit("signal", frame);
|
|
386
|
+
break;
|
|
387
|
+
case "receipt":
|
|
388
|
+
this.emit("receipt", frame);
|
|
389
|
+
break;
|
|
390
|
+
case "presence":
|
|
391
|
+
this.presenceState.set(String(frame.userId), {
|
|
392
|
+
status: frame.status,
|
|
393
|
+
lastSeenAt: frame.lastSeenAt ?? null,
|
|
394
|
+
});
|
|
395
|
+
this.emit("presence", frame);
|
|
396
|
+
break;
|
|
397
|
+
case "member.joined":
|
|
398
|
+
case "member.left":
|
|
399
|
+
case "channel.updated":
|
|
400
|
+
this.emit(frame.type, frame);
|
|
401
|
+
break;
|
|
402
|
+
case "mls.handshake": {
|
|
403
|
+
const hs = frame.handshake;
|
|
404
|
+
if (hs.kind === "welcome")
|
|
405
|
+
await this.handleWelcome(hs);
|
|
406
|
+
this.emit("mlsHandshake", hs);
|
|
407
|
+
break;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
// ------------------------------------------------------------- E2EE ----
|
|
412
|
+
async handleWelcome(hs) {
|
|
413
|
+
try {
|
|
414
|
+
const welcome = JSON.parse(new TextDecoder().decode(b64.dec(hs.payload)));
|
|
415
|
+
const mine = welcome.wrapped.find((w) => w.deviceId === this.deviceId);
|
|
416
|
+
if (!mine)
|
|
417
|
+
return;
|
|
418
|
+
const key = await unwrapChannelKey(mine, this.keys.privateKeyJwk);
|
|
419
|
+
this.channelKeys.set(welcome.channelId, key);
|
|
420
|
+
this.mlsGroupToChannel.set(hs.mlsGroupId, welcome.channelId);
|
|
421
|
+
await this.persistChannelKeys();
|
|
422
|
+
this.emit("channelKeyReceived", { channelId: welcome.channelId });
|
|
423
|
+
}
|
|
424
|
+
catch {
|
|
425
|
+
/* not a dev-suite welcome or not for us */
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
/** Fetch any welcomes we missed while offline. */
|
|
429
|
+
async syncChannelKey(channelId, mlsGroupId) {
|
|
430
|
+
if (this.channelKeys.has(channelId))
|
|
431
|
+
return true;
|
|
432
|
+
const { handshakes } = await this.api("GET", `/v1/apps/${this.appId}/mls/groups/${mlsGroupId}/handshakes?sinceEpoch=0`);
|
|
433
|
+
for (const hs of handshakes.reverse()) {
|
|
434
|
+
if (hs.kind === "welcome" && hs.recipientDeviceIds?.includes(this.deviceId)) {
|
|
435
|
+
await this.handleWelcome({ mlsGroupId, payload: hs.payload });
|
|
436
|
+
if (this.channelKeys.has(channelId))
|
|
437
|
+
return true;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
return false;
|
|
441
|
+
}
|
|
442
|
+
getChannelKey(channelId) {
|
|
443
|
+
return this.channelKeys.get(channelId) ?? null;
|
|
444
|
+
}
|
|
445
|
+
/** How many channel keys this device currently holds (for backup-freshness checks). */
|
|
446
|
+
get channelKeyCount() {
|
|
447
|
+
return this.channelKeys.size;
|
|
448
|
+
}
|
|
449
|
+
// ---------------------------------------------- encrypted attachments ----
|
|
450
|
+
/** Encrypt bytes under a fresh content key and upload the ciphertext to S3. */
|
|
451
|
+
async uploadEncrypted(bytes) {
|
|
452
|
+
const key = await generateContentKey();
|
|
453
|
+
const { iv, data } = await encryptBytes(key, bytes);
|
|
454
|
+
const { blobUrl, uploadUrl } = await this.api("POST", `/v1/apps/${this.appId}/uploads`, { sizeBytes: data.byteLength });
|
|
455
|
+
const put = await fetch(uploadUrl, { method: "PUT", body: data, headers: { "content-type": "application/octet-stream" } });
|
|
456
|
+
if (!put.ok)
|
|
457
|
+
throw new Error(`attachment upload failed: HTTP ${put.status}`);
|
|
458
|
+
return { blobUrl, contentKey: await exportRawKeyB64(key), iv, size: data.byteLength };
|
|
459
|
+
}
|
|
460
|
+
// ------------------------------------------- WhatsApp-style key backup ----
|
|
461
|
+
/**
|
|
462
|
+
* Enable/refresh an encrypted key backup so the user can restore their chats
|
|
463
|
+
* on a new device. Back up your channel keys + device identity under a key
|
|
464
|
+
* derived from `recoveryCode` (which the user keeps; NoPeek never sees it).
|
|
465
|
+
* Call `generateRecoveryCode()` to make one, show it to the user ONCE, then
|
|
466
|
+
* pass it here. Returns the backupId.
|
|
467
|
+
*/
|
|
468
|
+
/**
|
|
469
|
+
* Encrypted history backup. The chat payload is encrypted with a random data
|
|
470
|
+
* key, which is then WRAPPED separately by each provided secret — so the same
|
|
471
|
+
* backup can be unlocked by the account **password** OR the **recovery code**.
|
|
472
|
+
* This is what lets a user restore on a new device by just signing in with
|
|
473
|
+
* their password; the recovery code is only needed as a last resort (e.g. the
|
|
474
|
+
* password was reset). Accepts a string (treated as a recovery code, legacy)
|
|
475
|
+
* or `{ password?, recoveryCode? }`.
|
|
476
|
+
*/
|
|
477
|
+
async backup(secrets) {
|
|
478
|
+
const s = typeof secrets === "string" ? { recoveryCode: secrets } : secrets;
|
|
479
|
+
if (!s.password && !s.recoveryCode)
|
|
480
|
+
throw new Error("backup requires a password or a recovery code");
|
|
481
|
+
// WRAP PRESERVATION: a refresh that only knows ONE secret (the common case —
|
|
482
|
+
// after login only the password is in memory) must NOT drop the other unlock
|
|
483
|
+
// path. If we can unwrap the existing envelope's data key, reuse it and carry
|
|
484
|
+
// forward any wraps we can't recreate; otherwise start a fresh envelope.
|
|
485
|
+
let dataKey = await generateChannelKey();
|
|
486
|
+
let rawDK = await exportRawKeyB64(dataKey);
|
|
487
|
+
const carried = {};
|
|
488
|
+
try {
|
|
489
|
+
const { backups } = await this.api("GET", `/v1/apps/${this.appId}/users/${this.userId}/history-backups`);
|
|
490
|
+
const kd = backups[0]?.keyDerivation;
|
|
491
|
+
if (kd?.scheme === "wrapped-v2" && kd.wraps) {
|
|
492
|
+
let oldDK = null;
|
|
493
|
+
const unlockers = [
|
|
494
|
+
["password", s.password],
|
|
495
|
+
["password", s.previousPassword],
|
|
496
|
+
["recovery", s.recoveryCode],
|
|
497
|
+
];
|
|
498
|
+
for (const [kind, secret] of unlockers) {
|
|
499
|
+
const w = secret ? kd.wraps[kind] : undefined;
|
|
500
|
+
if (!w)
|
|
501
|
+
continue;
|
|
502
|
+
try {
|
|
503
|
+
const kek = await deriveBackupKey(secret, w.salt, w.iterations);
|
|
504
|
+
oldDK = (await decryptPayload(kek, w.wrapped)).dk;
|
|
505
|
+
break;
|
|
506
|
+
}
|
|
507
|
+
catch {
|
|
508
|
+
/* stale wrap (rotated secret) — try the next unlocker */
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
// Escrow-restored session: this device just recovered the data key via
|
|
512
|
+
// the server escrow endpoint (restoreViaEscrow) — the old password wrap
|
|
513
|
+
// is stale (password was reset) and no recovery code is in memory, but
|
|
514
|
+
// we still KNOW the envelope's data key, so reuse it and carry the
|
|
515
|
+
// recovery wrap forward instead of orphaning it under a fresh dk.
|
|
516
|
+
if (!oldDK && this.escrowRestoredDK)
|
|
517
|
+
oldDK = this.escrowRestoredDK;
|
|
518
|
+
if (oldDK) {
|
|
519
|
+
rawDK = oldDK;
|
|
520
|
+
dataKey = await importRawKeyB64(oldDK);
|
|
521
|
+
for (const kind of ["password", "recovery"]) {
|
|
522
|
+
const provided = kind === "password" ? s.password : s.recoveryCode;
|
|
523
|
+
if (!provided && kd.wraps[kind])
|
|
524
|
+
carried[kind] = kd.wraps[kind];
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
catch {
|
|
530
|
+
/* no existing backup / fetch failed — fresh envelope below */
|
|
531
|
+
}
|
|
532
|
+
const channelKeys = {};
|
|
533
|
+
for (const [chId, k] of this.channelKeys)
|
|
534
|
+
channelKeys[chId] = await exportChannelKey(k);
|
|
535
|
+
const ciphertext = await encryptPayload(dataKey, { v: 2, device: { deviceId: this.deviceId, keys: this.keys }, channelKeys });
|
|
536
|
+
const iterations = 210_000;
|
|
537
|
+
const wraps = { ...carried };
|
|
538
|
+
for (const [kind, secret] of [["password", s.password], ["recovery", s.recoveryCode]]) {
|
|
539
|
+
if (!secret)
|
|
540
|
+
continue;
|
|
541
|
+
const salt = b64.enc(globalThis.crypto.getRandomValues(new Uint8Array(16)));
|
|
542
|
+
const kek = await deriveBackupKey(secret, salt, iterations);
|
|
543
|
+
wraps[kind] = { salt, iterations, wrapped: await encryptPayload(kek, { dk: rawDK }) };
|
|
544
|
+
}
|
|
545
|
+
// OPTIONAL server-side key escrow (per-workspace opt-in; strict workspaces
|
|
546
|
+
// never get this wrap). The data key is ADDITIONALLY wrapped under the
|
|
547
|
+
// server's escrow PUBLIC key (RSA-OAEP-SHA256), so backups stay
|
|
548
|
+
// ciphertext-only server-side at backup time; only the escrow endpoint —
|
|
549
|
+
// gated on an authenticated session in a key_escrow workspace — can unwrap
|
|
550
|
+
// with the private key held in Secrets Manager. This is what lets a user
|
|
551
|
+
// recover ALL messages with only email + password after a password reset
|
|
552
|
+
// (see restoreViaEscrow). Best-effort: escrow being off/unreachable never
|
|
553
|
+
// blocks a backup.
|
|
554
|
+
try {
|
|
555
|
+
const esc = await this.escrowConfig();
|
|
556
|
+
if (esc?.enabled && esc.escrowPublicKey) {
|
|
557
|
+
wraps.escrow = { alg: "RSA-OAEP-256", wrapped: await rsaOaepWrapB64(esc.escrowPublicKey, b64.dec(rawDK)) };
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
catch {
|
|
561
|
+
/* escrow unavailable — password/recovery wraps still protect the backup */
|
|
562
|
+
}
|
|
563
|
+
const bytes = b64.dec(ciphertext);
|
|
564
|
+
const manifestHash = await sha256hex(ciphertext);
|
|
565
|
+
const { backup: rec, uploadUrl } = await this.api("POST", `/v1/apps/${this.appId}/users/${this.userId}/history-backups`, { keyDerivation: { scheme: "wrapped-v2", wraps }, sizeBytes: bytes.byteLength, manifestHash });
|
|
566
|
+
const put = await fetch(uploadUrl, { method: "PUT", body: bytes, headers: { "content-type": "application/octet-stream" } });
|
|
567
|
+
if (!put.ok)
|
|
568
|
+
throw new Error(`backup upload failed: HTTP ${put.status}`);
|
|
569
|
+
return rec.backupId;
|
|
570
|
+
}
|
|
571
|
+
/**
|
|
572
|
+
* Restore chats on a NEW device from the latest encrypted backup. Pass the
|
|
573
|
+
* account password (preferred) and/or the recovery code — whichever unlocks
|
|
574
|
+
* the backup is used. Rehydrates channel keys so server-stored history
|
|
575
|
+
* decrypts. Returns the number of channels restored.
|
|
576
|
+
*/
|
|
577
|
+
async restore(secrets) {
|
|
578
|
+
const s = typeof secrets === "string" ? { recoveryCode: secrets } : secrets;
|
|
579
|
+
const { backups } = await this.api("GET", `/v1/apps/${this.appId}/users/${this.userId}/history-backups`);
|
|
580
|
+
if (!backups.length)
|
|
581
|
+
throw new Error("No backup found for this user.");
|
|
582
|
+
const b = backups[0]; // newest first
|
|
583
|
+
const kd = b.keyDerivation || {};
|
|
584
|
+
const res = await fetch(b.downloadUrl);
|
|
585
|
+
if (!res.ok)
|
|
586
|
+
throw new Error(`backup download failed: HTTP ${res.status}`);
|
|
587
|
+
const ct = b64.enc(new Uint8Array(await res.arrayBuffer()));
|
|
588
|
+
let payload;
|
|
589
|
+
if (kd.scheme === "wrapped-v2") {
|
|
590
|
+
let dataKey = null;
|
|
591
|
+
for (const [kind, secret] of [["password", s.password], ["recovery", s.recoveryCode]]) {
|
|
592
|
+
const w = secret && kd.wraps?.[kind];
|
|
593
|
+
if (!w)
|
|
594
|
+
continue;
|
|
595
|
+
try {
|
|
596
|
+
const kek = await deriveBackupKey(secret, w.salt, w.iterations);
|
|
597
|
+
const { dk } = await decryptPayload(kek, w.wrapped);
|
|
598
|
+
dataKey = await importRawKeyB64(dk);
|
|
599
|
+
break;
|
|
600
|
+
}
|
|
601
|
+
catch {
|
|
602
|
+
/* wrong secret for this wrap — try the next */
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
if (!dataKey)
|
|
606
|
+
throw new Error("Couldn't unlock the backup with the provided password or recovery code.");
|
|
607
|
+
payload = await decryptPayload(dataKey, ct);
|
|
608
|
+
}
|
|
609
|
+
else {
|
|
610
|
+
// legacy single-layer backup: payload keyed directly off the recovery code
|
|
611
|
+
const code = s.recoveryCode ?? s.password;
|
|
612
|
+
if (!code)
|
|
613
|
+
throw new Error("This backup requires a recovery code.");
|
|
614
|
+
const key = await deriveBackupKey(code, kd.salt, kd.iterations);
|
|
615
|
+
try {
|
|
616
|
+
payload = await decryptPayload(key, ct);
|
|
617
|
+
}
|
|
618
|
+
catch {
|
|
619
|
+
throw new Error("Recovery code is incorrect — could not decrypt the backup.");
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
await this.adoptBackedUpDevice(payload.device);
|
|
623
|
+
for (const [chId, raw] of Object.entries(payload.channelKeys)) {
|
|
624
|
+
this.channelKeys.set(chId, await importChannelKey(raw));
|
|
625
|
+
}
|
|
626
|
+
await this.persistChannelKeys();
|
|
627
|
+
return Object.keys(payload.channelKeys).length;
|
|
628
|
+
}
|
|
629
|
+
// ------------------------------------------------- server-side escrow ----
|
|
630
|
+
/** Cached escrow availability for THIS caller ({enabled, escrowPublicKey}). */
|
|
631
|
+
escrowCfg;
|
|
632
|
+
/** Set by restoreViaEscrow(): the recovered raw data key (b64), so a follow-up
|
|
633
|
+
* backup() can preserve wraps it can't unlock (e.g. after a password reset). */
|
|
634
|
+
escrowRestoredDK = null;
|
|
635
|
+
/** Whether the server escrows backup keys for this user's workspace (cached). */
|
|
636
|
+
async escrowConfig() {
|
|
637
|
+
if (this.escrowCfg !== undefined)
|
|
638
|
+
return this.escrowCfg;
|
|
639
|
+
try {
|
|
640
|
+
this.escrowCfg = await this.api("GET", `/v1/apps/${this.appId}/escrow-public-key`);
|
|
641
|
+
}
|
|
642
|
+
catch {
|
|
643
|
+
this.escrowCfg = null; // older server / escrow off — cache the miss
|
|
644
|
+
}
|
|
645
|
+
return this.escrowCfg;
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* Recover chats with NO client-held secret, via server-side key escrow
|
|
649
|
+
* (workspace opt-in). The newest backup's `wraps.escrow` blob — the data key
|
|
650
|
+
* wrapped under the server's escrow PUBLIC key at backup time — is posted to
|
|
651
|
+
* the authenticated escrow-unwrap endpoint; the server decrypts it with the
|
|
652
|
+
* escrow PRIVATE key (Secrets Manager, never the DB) and returns the raw
|
|
653
|
+
* data key. The rest is identical to the password restore path.
|
|
654
|
+
*
|
|
655
|
+
* SECURITY: possession of a valid session is the gate. After a password
|
|
656
|
+
* reset, a session means the user proved email ownership via the emailed
|
|
657
|
+
* reset code — so "email owner → full recovery" is the EXPLICIT tradeoff of
|
|
658
|
+
* escrow mode. Strict (zero-custody) workspaces have no escrow wrap and the
|
|
659
|
+
* endpoint refuses them. Returns the number of channels restored.
|
|
660
|
+
*/
|
|
661
|
+
async restoreViaEscrow() {
|
|
662
|
+
const { backups } = await this.api("GET", `/v1/apps/${this.appId}/users/${this.userId}/history-backups`);
|
|
663
|
+
if (!backups.length)
|
|
664
|
+
throw new Error("No backup found for this user.");
|
|
665
|
+
const b = backups[0]; // newest first
|
|
666
|
+
const kd = b.keyDerivation || {};
|
|
667
|
+
const esc = kd.scheme === "wrapped-v2" ? kd.wraps?.escrow : undefined;
|
|
668
|
+
if (!esc?.wrapped)
|
|
669
|
+
throw new Error("This backup has no escrow wrap.");
|
|
670
|
+
const { dk } = await this.api("POST", `/v1/apps/${this.appId}/users/${this.userId}/history-backups/escrow-unwrap`, { wrapped: esc.wrapped });
|
|
671
|
+
const dataKey = await importRawKeyB64(dk);
|
|
672
|
+
const res = await fetch(b.downloadUrl);
|
|
673
|
+
if (!res.ok)
|
|
674
|
+
throw new Error(`backup download failed: HTTP ${res.status}`);
|
|
675
|
+
const ct = b64.enc(new Uint8Array(await res.arrayBuffer()));
|
|
676
|
+
const payload = await decryptPayload(dataKey, ct);
|
|
677
|
+
this.escrowRestoredDK = dk; // lets the next backup() carry old wraps forward
|
|
678
|
+
await this.adoptBackedUpDevice(payload.device);
|
|
679
|
+
for (const [chId, raw] of Object.entries(payload.channelKeys)) {
|
|
680
|
+
this.channelKeys.set(chId, await importChannelKey(raw));
|
|
681
|
+
}
|
|
682
|
+
await this.persistChannelKeys();
|
|
683
|
+
return Object.keys(payload.channelKeys).length;
|
|
684
|
+
}
|
|
685
|
+
/**
|
|
686
|
+
* Adopt the device identity stored in a backup so a returning user on a fresh
|
|
687
|
+
* install ends up with EXACTLY their original deviceId — not a freshly minted
|
|
688
|
+
* one. Only applies when this client has no established local identity (a
|
|
689
|
+
* deferred connect, or an identity registered moments ago during this very
|
|
690
|
+
* connect, which is then revoked as an orphan). A long-standing local device
|
|
691
|
+
* identity is never hijacked — for those, restore() only rehydrates channel
|
|
692
|
+
* keys, exactly as before.
|
|
693
|
+
*/
|
|
694
|
+
async adoptBackedUpDevice(device) {
|
|
695
|
+
if (!device?.deviceId || !device.keys)
|
|
696
|
+
return;
|
|
697
|
+
if (this.deviceId === device.deviceId)
|
|
698
|
+
return; // already the real device
|
|
699
|
+
const established = this.deviceId && this.deviceId !== this.justRegisteredDeviceId;
|
|
700
|
+
if (established)
|
|
701
|
+
return;
|
|
702
|
+
const orphan = this.justRegisteredDeviceId;
|
|
703
|
+
this.justRegisteredDeviceId = null;
|
|
704
|
+
this.deviceId = device.deviceId;
|
|
705
|
+
this.keys = device.keys;
|
|
706
|
+
await this.persistDevice();
|
|
707
|
+
// Revoke the device connect() just registered — it would otherwise linger
|
|
708
|
+
// forever and poison peers' MLS key-package claims. Best-effort.
|
|
709
|
+
if (orphan && orphan !== device.deviceId) {
|
|
710
|
+
await this.api("DELETE", `/v1/apps/${this.appId}/users/${this.userId}/devices/${orphan}`).catch(() => { });
|
|
711
|
+
}
|
|
712
|
+
// Replenish key packages for the RESTORED device. If the server no longer
|
|
713
|
+
// knows it (revoked/pruned), fall back to re-registering the restored keys
|
|
714
|
+
// under a fresh deviceId so the account stays usable.
|
|
715
|
+
try {
|
|
716
|
+
await this.ensureKeyPackages();
|
|
717
|
+
}
|
|
718
|
+
catch {
|
|
719
|
+
this.deviceId = undefined;
|
|
720
|
+
await this.registerDeviceNow();
|
|
721
|
+
await this.ensureKeyPackages().catch(() => { });
|
|
722
|
+
}
|
|
723
|
+
// The WS session is bound to a deviceId: connect it now (deferred path), or
|
|
724
|
+
// reconnect if it was opened under the orphan identity.
|
|
725
|
+
if (this.ws) {
|
|
726
|
+
const old = this.ws;
|
|
727
|
+
this.ws = null;
|
|
728
|
+
old.onclose = null; // don't let the old socket's close trigger a reconnect race
|
|
729
|
+
try {
|
|
730
|
+
old.close();
|
|
731
|
+
}
|
|
732
|
+
catch {
|
|
733
|
+
/* ignore */
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
// Swallow WS errors here: a transient socket failure must not make restore()
|
|
737
|
+
// look like a wrong password. The onclose handler keeps retrying anyway.
|
|
738
|
+
if (this.autoWs && !this.closed)
|
|
739
|
+
await this.connectWs().catch(() => { });
|
|
740
|
+
}
|
|
741
|
+
/** Whether this user has any restorable backup on the server. */
|
|
742
|
+
async hasBackup() {
|
|
743
|
+
const { backups } = await this.api("GET", `/v1/apps/${this.appId}/users/${this.userId}/history-backups`);
|
|
744
|
+
return backups.length > 0;
|
|
745
|
+
}
|
|
746
|
+
/** Fetch an encrypted attachment blob and decrypt it back to bytes. */
|
|
747
|
+
async downloadDecrypt(ref) {
|
|
748
|
+
const rest = ref.blobUrl.replace(/^nopeek-blob:\/\//, "");
|
|
749
|
+
const path = rest.slice(rest.indexOf("/") + 1); // strip the leading appId/
|
|
750
|
+
const res = await fetch(`${this.apiUrl}/v1/apps/${this.appId}/blobs/${path}`, {
|
|
751
|
+
headers: { authorization: `Bearer ${this.token}` },
|
|
752
|
+
});
|
|
753
|
+
if (!res.ok)
|
|
754
|
+
throw new Error(`attachment download failed: HTTP ${res.status}`);
|
|
755
|
+
return decryptBytes(ref.contentKey, ref.iv, await res.arrayBuffer());
|
|
756
|
+
}
|
|
757
|
+
/** App branding/theme tokens (colors, font, radius…) set by the customer. Null until loaded. */
|
|
758
|
+
branding = null;
|
|
759
|
+
/** Fetch the app's branding tokens (customer white-label theme). */
|
|
760
|
+
async loadBranding() {
|
|
761
|
+
try {
|
|
762
|
+
const res = await this.api("GET", `/v1/apps/${this.appId}/branding`);
|
|
763
|
+
this.branding = res.branding ?? null;
|
|
764
|
+
}
|
|
765
|
+
catch {
|
|
766
|
+
this.branding = null;
|
|
767
|
+
}
|
|
768
|
+
return this.branding;
|
|
769
|
+
}
|
|
770
|
+
/** Current known presence for a user (survives listener-attach timing). */
|
|
771
|
+
getPresence(userId) {
|
|
772
|
+
return this.presenceState.get(userId) ?? null;
|
|
773
|
+
}
|
|
774
|
+
isOnline(userId) {
|
|
775
|
+
return this.presenceState.get(userId)?.status === "online";
|
|
776
|
+
}
|
|
777
|
+
// ------------------------------------------------------ poll tallies ----
|
|
778
|
+
/** pollMessageId -> (userId -> selected optionIds). Latest vote wins. */
|
|
779
|
+
pollVotes = new Map();
|
|
780
|
+
recordPollVote(pollMessageId, userId, optionIds) {
|
|
781
|
+
if (!this.pollVotes.has(pollMessageId))
|
|
782
|
+
this.pollVotes.set(pollMessageId, new Map());
|
|
783
|
+
const byUser = this.pollVotes.get(pollMessageId);
|
|
784
|
+
if (optionIds.length === 0)
|
|
785
|
+
byUser.delete(userId);
|
|
786
|
+
else
|
|
787
|
+
byUser.set(userId, optionIds);
|
|
788
|
+
}
|
|
789
|
+
/** Public entry so Channel.history() can fold in historical votes. */
|
|
790
|
+
applyPollVote(pollMessageId, userId, optionIds) {
|
|
791
|
+
this.recordPollVote(pollMessageId, userId, optionIds);
|
|
792
|
+
}
|
|
793
|
+
/** Aggregate results for a poll: { optionId -> count } plus total voters. */
|
|
794
|
+
pollResults(pollMessageId) {
|
|
795
|
+
const byUser = this.pollVotes.get(pollMessageId);
|
|
796
|
+
const counts = {};
|
|
797
|
+
if (!byUser)
|
|
798
|
+
return { counts, voters: 0 };
|
|
799
|
+
for (const opts of byUser.values())
|
|
800
|
+
for (const o of opts)
|
|
801
|
+
counts[o] = (counts[o] ?? 0) + 1;
|
|
802
|
+
return { counts, voters: byUser.size };
|
|
803
|
+
}
|
|
804
|
+
myPollVote(pollMessageId) {
|
|
805
|
+
return this.pollVotes.get(pollMessageId)?.get(this.userId) ?? [];
|
|
806
|
+
}
|
|
807
|
+
// ------------------------------------------------------ event RSVPs ----
|
|
808
|
+
/** eventMessageId -> (userId -> "yes" | "no" | "maybe"). Latest response wins. */
|
|
809
|
+
rsvps = new Map();
|
|
810
|
+
recordRsvp(eventMessageId, userId, response) {
|
|
811
|
+
if (!this.rsvps.has(eventMessageId))
|
|
812
|
+
this.rsvps.set(eventMessageId, new Map());
|
|
813
|
+
this.rsvps.get(eventMessageId).set(userId, response);
|
|
814
|
+
}
|
|
815
|
+
/** Public entry so Channel.history() can fold in historical RSVPs. */
|
|
816
|
+
applyRsvp(eventMessageId, userId, response) {
|
|
817
|
+
this.recordRsvp(eventMessageId, userId, response);
|
|
818
|
+
}
|
|
819
|
+
/** Tally for an event: counts by response + the responding userIds per bucket. */
|
|
820
|
+
rsvpResults(eventMessageId) {
|
|
821
|
+
const byUser = this.rsvps.get(eventMessageId);
|
|
822
|
+
const voters = { yes: [], no: [], maybe: [] };
|
|
823
|
+
if (byUser)
|
|
824
|
+
for (const [uid, r] of byUser)
|
|
825
|
+
voters[r].push(uid);
|
|
826
|
+
return { counts: { yes: voters.yes.length, no: voters.no.length, maybe: voters.maybe.length }, voters };
|
|
827
|
+
}
|
|
828
|
+
myRsvp(eventMessageId) {
|
|
829
|
+
return this.rsvps.get(eventMessageId)?.get(this.userId) ?? null;
|
|
830
|
+
}
|
|
831
|
+
setChannelKey(channelId, key) {
|
|
832
|
+
this.channelKeys.set(channelId, key);
|
|
833
|
+
void this.persistChannelKeys();
|
|
834
|
+
}
|
|
835
|
+
async decryptEnvelope(env) {
|
|
836
|
+
const channelId = String(env.channelId);
|
|
837
|
+
const base = {
|
|
838
|
+
messageId: String(env.messageId),
|
|
839
|
+
channelId,
|
|
840
|
+
senderUserId: String(env.senderUserId),
|
|
841
|
+
seq: Number(env.seq),
|
|
842
|
+
threadParentId: env.threadParentId ?? null,
|
|
843
|
+
createdAt: String(env.createdAt),
|
|
844
|
+
editedAt: env.editedAt ?? null,
|
|
845
|
+
recalledAt: env.recalledAt ?? null,
|
|
846
|
+
body: null,
|
|
847
|
+
encrypted: Boolean(env.ciphertext),
|
|
848
|
+
};
|
|
849
|
+
if (env.recalledAt)
|
|
850
|
+
return base;
|
|
851
|
+
if (env.plaintext) {
|
|
852
|
+
base.body = env.plaintext;
|
|
853
|
+
return base;
|
|
854
|
+
}
|
|
855
|
+
if (env.ciphertext) {
|
|
856
|
+
if (!this.channelKeys.has(channelId))
|
|
857
|
+
await this.deriveAndSet(channelId);
|
|
858
|
+
const key = this.channelKeys.get(channelId);
|
|
859
|
+
if (!key) {
|
|
860
|
+
base.decryptionFailed = true;
|
|
861
|
+
return base;
|
|
862
|
+
}
|
|
863
|
+
try {
|
|
864
|
+
base.body = await decryptPayload(key, String(env.ciphertext));
|
|
865
|
+
}
|
|
866
|
+
catch {
|
|
867
|
+
base.decryptionFailed = true;
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
return base;
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
class Channels {
|
|
874
|
+
np;
|
|
875
|
+
constructor(np) {
|
|
876
|
+
this.np = np;
|
|
877
|
+
}
|
|
878
|
+
async list() {
|
|
879
|
+
const { channels } = await this.np.api("GET", `/v1/apps/${this.np.appId}/channels`);
|
|
880
|
+
return channels.map((c) => new Channel(this.np, c));
|
|
881
|
+
}
|
|
882
|
+
async get(channelId) {
|
|
883
|
+
const rec = await this.np.api("GET", `/v1/apps/${this.np.appId}/channels/${channelId}`);
|
|
884
|
+
return new Channel(this.np, rec);
|
|
885
|
+
}
|
|
886
|
+
/**
|
|
887
|
+
* Create a channel. For E2EE channels this performs the full key ceremony:
|
|
888
|
+
* generate channel key → claim key packages for every member device →
|
|
889
|
+
* commit epoch 0→1 → send Welcome with the wrapped key to each device.
|
|
890
|
+
*/
|
|
891
|
+
async create(opts) {
|
|
892
|
+
const rec = await this.np.api("POST", `/v1/apps/${this.np.appId}/channels`, opts);
|
|
893
|
+
const channel = new Channel(this.np, rec);
|
|
894
|
+
if (rec.e2ee && !this.np.getChannelKey(rec.channelId)) {
|
|
895
|
+
// preshared-key mode derives locally; otherwise run the MLS ceremony
|
|
896
|
+
if (!(await this.np.deriveAndSet(rec.channelId)) && rec.mlsGroupId) {
|
|
897
|
+
await channel.establishKeys(opts.memberIds ?? []);
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
return channel;
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
export class Channel {
|
|
904
|
+
np;
|
|
905
|
+
record;
|
|
906
|
+
constructor(np, record) {
|
|
907
|
+
this.np = np;
|
|
908
|
+
this.record = record;
|
|
909
|
+
}
|
|
910
|
+
get channelId() {
|
|
911
|
+
return this.record.channelId;
|
|
912
|
+
}
|
|
913
|
+
get e2ee() {
|
|
914
|
+
return this.record.e2ee;
|
|
915
|
+
}
|
|
916
|
+
/** E2EE key ceremony (creator side). */
|
|
917
|
+
async establishKeys(memberIds) {
|
|
918
|
+
const key = await generateChannelKey();
|
|
919
|
+
this.np.setChannelKey(this.channelId, key);
|
|
920
|
+
const recipients = [];
|
|
921
|
+
const others = memberIds.filter((m) => m !== this.np.userId);
|
|
922
|
+
for (const userId of others) {
|
|
923
|
+
const { keyPackages } = await this.np.api("POST", `/v1/apps/${this.np.appId}/users/${userId}/key-packages/claim`);
|
|
924
|
+
for (const kp of keyPackages) {
|
|
925
|
+
const parsed = JSON.parse(new TextDecoder().decode(b64.dec(kp.data)));
|
|
926
|
+
recipients.push({ deviceId: kp.deviceId, publicKeyJwk: parsed.pub });
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
// advance epoch 0 -> 1 (server-enforced single writer)
|
|
930
|
+
await this.np.api("POST", `/v1/apps/${this.np.appId}/mls/groups/${this.record.mlsGroupId}/handshakes`, {
|
|
931
|
+
epoch: 0,
|
|
932
|
+
kind: "commit",
|
|
933
|
+
senderDeviceId: this.np.deviceId,
|
|
934
|
+
payload: b64.enc(new TextEncoder().encode(JSON.stringify({ suite: "npdev-1", op: "init" }))),
|
|
935
|
+
});
|
|
936
|
+
if (recipients.length) {
|
|
937
|
+
const wrapped = await wrapChannelKey(key, recipients);
|
|
938
|
+
const welcome = { suite: "npdev-1", channelId: this.channelId, wrapped };
|
|
939
|
+
await this.np.api("POST", `/v1/apps/${this.np.appId}/mls/groups/${this.record.mlsGroupId}/handshakes`, {
|
|
940
|
+
epoch: 1,
|
|
941
|
+
kind: "welcome",
|
|
942
|
+
senderDeviceId: this.np.deviceId,
|
|
943
|
+
recipientDeviceIds: wrapped.map((w) => w.deviceId),
|
|
944
|
+
payload: b64.enc(new TextEncoder().encode(JSON.stringify(welcome))),
|
|
945
|
+
});
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
/** Joiner side: derive (preshared mode) or pull the welcome if we lack the key. */
|
|
949
|
+
async ensureKey() {
|
|
950
|
+
if (!this.e2ee)
|
|
951
|
+
return true;
|
|
952
|
+
if (this.np.getChannelKey(this.channelId))
|
|
953
|
+
return true;
|
|
954
|
+
if (await this.np.deriveAndSet(this.channelId))
|
|
955
|
+
return true;
|
|
956
|
+
if (!this.record.mlsGroupId)
|
|
957
|
+
return false;
|
|
958
|
+
if (await this.np.syncChannelKey(this.channelId, this.record.mlsGroupId))
|
|
959
|
+
return true;
|
|
960
|
+
// No key and no welcome yet — e.g. the channel was created server-side, or the
|
|
961
|
+
// original creator's key was lost. Bootstrap the ceremony as the originator:
|
|
962
|
+
// the server enforces a single epoch-0→1 writer, so if a peer races us we lose
|
|
963
|
+
// the commit and fall back to pulling their welcome.
|
|
964
|
+
try {
|
|
965
|
+
const { members } = await this.np.api("GET", `/v1/apps/${this.np.appId}/channels/${this.channelId}/members`);
|
|
966
|
+
await this.establishKeys(members.map((m) => m.userId));
|
|
967
|
+
if (this.np.getChannelKey(this.channelId))
|
|
968
|
+
return true;
|
|
969
|
+
}
|
|
970
|
+
catch {
|
|
971
|
+
/* lost the epoch race, or a member has no key package — fall through */
|
|
972
|
+
}
|
|
973
|
+
return this.np.syncChannelKey(this.channelId, this.record.mlsGroupId);
|
|
974
|
+
}
|
|
975
|
+
/** Low-level: send an arbitrary structured payload (image, poll, contact…). */
|
|
976
|
+
async sendPayload(payload, opts = {}) {
|
|
977
|
+
const clientMessageId = opts.clientMessageId ?? `c${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
|
|
978
|
+
if (this.e2ee) {
|
|
979
|
+
if (!(await this.ensureKey()))
|
|
980
|
+
throw new Error("No channel key — has the key ceremony completed?");
|
|
981
|
+
const ciphertext = await encryptPayload(this.np.getChannelKey(this.channelId), { v: 1, ...payload });
|
|
982
|
+
const ack = (await this.np.sendFrame({
|
|
983
|
+
type: "message.send",
|
|
984
|
+
channelId: this.channelId,
|
|
985
|
+
clientMessageId,
|
|
986
|
+
threadParentId: opts.threadParentId,
|
|
987
|
+
mlsEpoch: 1,
|
|
988
|
+
ciphertext,
|
|
989
|
+
}));
|
|
990
|
+
return this.np.decryptEnvelope(ack.message);
|
|
991
|
+
}
|
|
992
|
+
const ack = (await this.np.sendFrame({
|
|
993
|
+
type: "message.send",
|
|
994
|
+
channelId: this.channelId,
|
|
995
|
+
clientMessageId,
|
|
996
|
+
threadParentId: opts.threadParentId,
|
|
997
|
+
plaintext: { type: payload.type ?? "text", ...payload },
|
|
998
|
+
}));
|
|
999
|
+
return this.np.decryptEnvelope(ack.message);
|
|
1000
|
+
}
|
|
1001
|
+
async send(body) {
|
|
1002
|
+
return this.sendPayload({ type: body.type ?? "text", text: body.text, metadata: body.metadata, linkPreview: body.linkPreview }, { threadParentId: body.threadParentId });
|
|
1003
|
+
}
|
|
1004
|
+
// ---------------------------------------------------- rich content ----
|
|
1005
|
+
/** Encrypt + upload a file and send it as an image/video/audio/file message. */
|
|
1006
|
+
async sendAttachment(a, opts = {}) {
|
|
1007
|
+
const up = await this.np.uploadEncrypted(a.bytes);
|
|
1008
|
+
let thumb = {};
|
|
1009
|
+
if (a.thumbnail) {
|
|
1010
|
+
const t = await this.np.uploadEncrypted(a.thumbnail.bytes);
|
|
1011
|
+
thumb = { thumbBlobUrl: t.blobUrl, thumbContentKey: t.contentKey, thumbIv: t.iv };
|
|
1012
|
+
}
|
|
1013
|
+
const type = a.contentType.startsWith("image/")
|
|
1014
|
+
? "image"
|
|
1015
|
+
: a.contentType.startsWith("video/")
|
|
1016
|
+
? "video"
|
|
1017
|
+
: a.contentType.startsWith("audio/")
|
|
1018
|
+
? "audio"
|
|
1019
|
+
: "file";
|
|
1020
|
+
return this.sendPayload({
|
|
1021
|
+
type,
|
|
1022
|
+
text: opts.caption,
|
|
1023
|
+
attachments: [
|
|
1024
|
+
{
|
|
1025
|
+
blobUrl: up.blobUrl,
|
|
1026
|
+
contentKey: up.contentKey,
|
|
1027
|
+
iv: up.iv,
|
|
1028
|
+
size: up.size,
|
|
1029
|
+
name: a.name,
|
|
1030
|
+
contentType: a.contentType,
|
|
1031
|
+
width: a.width,
|
|
1032
|
+
height: a.height,
|
|
1033
|
+
durationSec: a.durationSec,
|
|
1034
|
+
...thumb,
|
|
1035
|
+
},
|
|
1036
|
+
],
|
|
1037
|
+
}, { threadParentId: opts.threadParentId });
|
|
1038
|
+
}
|
|
1039
|
+
/** Convenience wrapper for a browser File/Blob (uses .name/.type). */
|
|
1040
|
+
async sendFile(file, opts) {
|
|
1041
|
+
return this.sendAttachment({
|
|
1042
|
+
bytes: await file.arrayBuffer(),
|
|
1043
|
+
name: file.name ?? "file",
|
|
1044
|
+
contentType: file.type || "application/octet-stream",
|
|
1045
|
+
thumbnail: opts?.thumbnail,
|
|
1046
|
+
width: opts?.width,
|
|
1047
|
+
height: opts?.height,
|
|
1048
|
+
durationSec: opts?.durationSec,
|
|
1049
|
+
}, { caption: opts?.caption });
|
|
1050
|
+
}
|
|
1051
|
+
/** Download + decrypt an attachment; returns a Blob you can objectURL. */
|
|
1052
|
+
async fetchAttachment(att) {
|
|
1053
|
+
const buf = await this.np.downloadDecrypt(att);
|
|
1054
|
+
return new Blob([buf], { type: att.contentType || "application/octet-stream" });
|
|
1055
|
+
}
|
|
1056
|
+
async sendPoll(poll) {
|
|
1057
|
+
const id = `poll_${Math.random().toString(36).slice(2, 10)}`;
|
|
1058
|
+
return this.sendPayload({
|
|
1059
|
+
type: "poll",
|
|
1060
|
+
poll: {
|
|
1061
|
+
id,
|
|
1062
|
+
question: poll.question,
|
|
1063
|
+
options: poll.options.map((label, i) => ({ id: `o${i}`, label })),
|
|
1064
|
+
multiple: poll.multiple ?? false,
|
|
1065
|
+
anonymous: poll.anonymous ?? false,
|
|
1066
|
+
closesAt: poll.closesAt,
|
|
1067
|
+
},
|
|
1068
|
+
});
|
|
1069
|
+
}
|
|
1070
|
+
/** Cast (or, with an empty array, retract) a vote on a poll message. */
|
|
1071
|
+
async votePoll(pollMessageId, optionIds) {
|
|
1072
|
+
await this.sendPayload({ type: "poll_vote", targetMessageId: pollMessageId, pollOptionIds: optionIds });
|
|
1073
|
+
}
|
|
1074
|
+
async sendContact(contact) {
|
|
1075
|
+
return this.sendPayload({ type: "contact", contact });
|
|
1076
|
+
}
|
|
1077
|
+
async sendCalendarEvent(event) {
|
|
1078
|
+
return this.sendPayload({ type: "calendar_event", calendarEvent: { allDay: false, ...event } });
|
|
1079
|
+
}
|
|
1080
|
+
async rsvp(eventMessageId, response) {
|
|
1081
|
+
await this.sendPayload({ type: "rsvp", targetMessageId: eventMessageId, rsvp: response });
|
|
1082
|
+
}
|
|
1083
|
+
async sendLocation(lat, lng, label) {
|
|
1084
|
+
return this.sendPayload({ type: "location", location: { lat, lng, label } });
|
|
1085
|
+
}
|
|
1086
|
+
// ------------------------------------------------------- bot actions ----
|
|
1087
|
+
// Structured E2EE bodies for the bots feature — no new crypto, no new
|
|
1088
|
+
// events: they ride the normal encrypted message path and surface through
|
|
1089
|
+
// the generic `message` event like any other typed body.
|
|
1090
|
+
/**
|
|
1091
|
+
* Bot → humans: a message with tappable action buttons under the text
|
|
1092
|
+
* (Telegram-style inline keyboard). Humans respond via invokeAction().
|
|
1093
|
+
*/
|
|
1094
|
+
async sendActions(opts) {
|
|
1095
|
+
return this.sendPayload({ type: "actions", text: opts.text, actions: opts.actions });
|
|
1096
|
+
}
|
|
1097
|
+
/**
|
|
1098
|
+
* Bot → humans: advertise (or replace) the bot's persistent quick-action
|
|
1099
|
+
* bar for this chat. Clients render the LATEST action_bar per bot as chips
|
|
1100
|
+
* above the composer; sending a new one supersedes the previous.
|
|
1101
|
+
*/
|
|
1102
|
+
async sendActionBar(opts) {
|
|
1103
|
+
return this.sendPayload({ type: "action_bar", actions: opts.actions });
|
|
1104
|
+
}
|
|
1105
|
+
/**
|
|
1106
|
+
* Human → bot: what a tapped action button sends. `label` is optional
|
|
1107
|
+
* display sugar so clients can echo "You ran <label>" without a lookup.
|
|
1108
|
+
*/
|
|
1109
|
+
async invokeAction(opts) {
|
|
1110
|
+
return this.sendPayload({
|
|
1111
|
+
type: "action_invoke",
|
|
1112
|
+
actionId: opts.actionId,
|
|
1113
|
+
...(opts.label !== undefined ? { label: opts.label } : {}),
|
|
1114
|
+
...(opts.params !== undefined ? { params: opts.params } : {}),
|
|
1115
|
+
});
|
|
1116
|
+
}
|
|
1117
|
+
async edit(messageId, body) {
|
|
1118
|
+
if (this.e2ee) {
|
|
1119
|
+
const key = this.np.getChannelKey(this.channelId);
|
|
1120
|
+
if (!key)
|
|
1121
|
+
throw new Error("No channel key");
|
|
1122
|
+
// the edited body must keep the message's real type — writing a synthetic
|
|
1123
|
+
// "edit" type made every consumer treat the message as non-text afterwards
|
|
1124
|
+
const ciphertext = await encryptPayload(key, { v: 1, type: "text", text: body.text });
|
|
1125
|
+
await this.np.api("PATCH", `/v1/apps/${this.np.appId}/channels/${this.channelId}/messages/${messageId}`, {
|
|
1126
|
+
mlsEpoch: 1,
|
|
1127
|
+
ciphertext,
|
|
1128
|
+
});
|
|
1129
|
+
}
|
|
1130
|
+
else {
|
|
1131
|
+
await this.np.api("PATCH", `/v1/apps/${this.np.appId}/channels/${this.channelId}/messages/${messageId}`, {
|
|
1132
|
+
plaintext: { type: "text", text: body.text },
|
|
1133
|
+
});
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
async recall(messageId) {
|
|
1137
|
+
await this.np.api("DELETE", `/v1/apps/${this.np.appId}/channels/${this.channelId}/messages/${messageId}`);
|
|
1138
|
+
}
|
|
1139
|
+
async react(messageId, emoji, op = "add") {
|
|
1140
|
+
if (this.e2ee) {
|
|
1141
|
+
const key = this.np.getChannelKey(this.channelId);
|
|
1142
|
+
if (!key)
|
|
1143
|
+
throw new Error("No channel key");
|
|
1144
|
+
const ciphertext = await encryptPayload(key, { v: 1, type: "reaction", targetMessageId: messageId, emoji, op });
|
|
1145
|
+
await this.np.sendFrame({
|
|
1146
|
+
type: "message.send",
|
|
1147
|
+
channelId: this.channelId,
|
|
1148
|
+
clientMessageId: `rx${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`,
|
|
1149
|
+
mlsEpoch: 1,
|
|
1150
|
+
ciphertext,
|
|
1151
|
+
});
|
|
1152
|
+
}
|
|
1153
|
+
else if (op === "add") {
|
|
1154
|
+
await this.np.api("POST", `/v1/apps/${this.np.appId}/channels/${this.channelId}/messages/${messageId}/reactions`, { emoji });
|
|
1155
|
+
}
|
|
1156
|
+
else {
|
|
1157
|
+
await this.np.api("DELETE", `/v1/apps/${this.np.appId}/channels/${this.channelId}/messages/${messageId}/reactions?emoji=${encodeURIComponent(emoji)}`);
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
typing(isTyping) {
|
|
1161
|
+
this.np["ws"]?.send(JSON.stringify({ type: isTyping ? "typing.start" : "typing.stop", channelId: this.channelId }));
|
|
1162
|
+
}
|
|
1163
|
+
async markRead(messageId) {
|
|
1164
|
+
await this.np.sendFrame({ type: "receipt.mark", channelId: this.channelId, kind: "read", messageId });
|
|
1165
|
+
}
|
|
1166
|
+
async history(opts = {}) {
|
|
1167
|
+
await this.ensureKey();
|
|
1168
|
+
const params = new URLSearchParams();
|
|
1169
|
+
if (opts.beforeSeq)
|
|
1170
|
+
params.set("beforeSeq", String(opts.beforeSeq));
|
|
1171
|
+
if (opts.limit)
|
|
1172
|
+
params.set("limit", String(opts.limit));
|
|
1173
|
+
const { messages } = await this.np.api("GET", `/v1/apps/${this.np.appId}/channels/${this.channelId}/messages?${params}`);
|
|
1174
|
+
const out = [];
|
|
1175
|
+
for (const env of messages) {
|
|
1176
|
+
const m = await this.np.decryptEnvelope(env);
|
|
1177
|
+
// carrier messages (reactions, poll votes, rsvps) aggregate client-side
|
|
1178
|
+
if (m.body?.type === "reaction")
|
|
1179
|
+
continue;
|
|
1180
|
+
if (m.body?.type === "rsvp") {
|
|
1181
|
+
this.np.applyRsvp(m.body.targetMessageId, m.senderUserId, m.body.rsvp);
|
|
1182
|
+
continue;
|
|
1183
|
+
}
|
|
1184
|
+
if (m.body?.type === "poll_vote") {
|
|
1185
|
+
this.np.applyPollVote(m.body.targetMessageId, m.senderUserId, m.body.pollOptionIds ?? []);
|
|
1186
|
+
continue;
|
|
1187
|
+
}
|
|
1188
|
+
out.push(m);
|
|
1189
|
+
}
|
|
1190
|
+
return out;
|
|
1191
|
+
}
|
|
1192
|
+
pollResults(pollMessageId) {
|
|
1193
|
+
return this.np.pollResults(pollMessageId);
|
|
1194
|
+
}
|
|
1195
|
+
myPollVote(pollMessageId) {
|
|
1196
|
+
return this.np.myPollVote(pollMessageId);
|
|
1197
|
+
}
|
|
1198
|
+
rsvpResults(eventMessageId) {
|
|
1199
|
+
return this.np.rsvpResults(eventMessageId);
|
|
1200
|
+
}
|
|
1201
|
+
myRsvp(eventMessageId) {
|
|
1202
|
+
return this.np.myRsvp(eventMessageId);
|
|
1203
|
+
}
|
|
1204
|
+
on(event, fn) {
|
|
1205
|
+
return this.np.on(event, ((payload) => {
|
|
1206
|
+
if (!payload.channelId || payload.channelId === this.channelId)
|
|
1207
|
+
fn(payload);
|
|
1208
|
+
}));
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
export { b64, deriveSharedChannelKey, generateRecoveryCode } from "./crypto.js";
|
|
1212
|
+
// ---------------------------------------------------------------------------
|
|
1213
|
+
// Browser helpers for attaching media. No-ops outside a DOM. Native apps
|
|
1214
|
+
// (Capacitor/React Native) should use their platform camera/file plugins and
|
|
1215
|
+
// pass the resulting bytes to channel.sendAttachment().
|
|
1216
|
+
// ---------------------------------------------------------------------------
|
|
1217
|
+
/** Open the OS file picker. `accept` e.g. "image/*", "video/*", "*/*". */
|
|
1218
|
+
export function pickFiles(opts = {}) {
|
|
1219
|
+
return new Promise((resolve) => {
|
|
1220
|
+
const input = document.createElement("input");
|
|
1221
|
+
input.type = "file";
|
|
1222
|
+
if (opts.accept)
|
|
1223
|
+
input.accept = opts.accept;
|
|
1224
|
+
if (opts.multiple)
|
|
1225
|
+
input.multiple = true;
|
|
1226
|
+
if (opts.capture)
|
|
1227
|
+
input.setAttribute("capture", opts.capture);
|
|
1228
|
+
input.style.display = "none";
|
|
1229
|
+
input.addEventListener("change", () => {
|
|
1230
|
+
resolve(input.files ? Array.from(input.files) : []);
|
|
1231
|
+
input.remove();
|
|
1232
|
+
});
|
|
1233
|
+
document.body.appendChild(input);
|
|
1234
|
+
input.click();
|
|
1235
|
+
});
|
|
1236
|
+
}
|
|
1237
|
+
/** Pick images from the gallery. */
|
|
1238
|
+
export const pickImages = (multiple = false) => pickFiles({ accept: "image/*", multiple });
|
|
1239
|
+
/** Pick a video. */
|
|
1240
|
+
export const pickVideo = () => pickFiles({ accept: "video/*" });
|
|
1241
|
+
/** Open the rear camera to take a photo (mobile web). */
|
|
1242
|
+
export const capturePhoto = () => pickFiles({ accept: "image/*", capture: "environment" });
|
|
1243
|
+
/**
|
|
1244
|
+
* Downscale an image File to a JPEG thumbnail (browser only). Returns bytes +
|
|
1245
|
+
* intrinsic dimensions, suitable for channel.sendFile({ thumbnail, width, height }).
|
|
1246
|
+
*/
|
|
1247
|
+
export async function makeImageThumbnail(file, max = 480) {
|
|
1248
|
+
const bitmap = await createImageBitmap(file);
|
|
1249
|
+
const { width, height } = bitmap;
|
|
1250
|
+
const scale = Math.min(1, max / Math.max(width, height));
|
|
1251
|
+
const canvas = document.createElement("canvas");
|
|
1252
|
+
canvas.width = Math.round(width * scale);
|
|
1253
|
+
canvas.height = Math.round(height * scale);
|
|
1254
|
+
canvas.getContext("2d").drawImage(bitmap, 0, 0, canvas.width, canvas.height);
|
|
1255
|
+
const blob = await new Promise((r) => canvas.toBlob((b) => r(b), "image/jpeg", 0.8));
|
|
1256
|
+
return { thumbnail: { bytes: await blob.arrayBuffer(), contentType: "image/jpeg" }, width, height };
|
|
1257
|
+
}
|
|
1258
|
+
//# sourceMappingURL=index.js.map
|