wireweave 0.3.27 → 0.3.29
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/package.json +1 -1
- package/src/auth.js +17 -5
- package/src/channels.js +4 -2
- package/src/chat.js +27 -4
- package/src/relay-pool.js +55 -15
- package/src/safe-storage.js +17 -0
- package/src/servers.js +7 -5
- package/src/voice.js +22 -2
package/package.json
CHANGED
package/src/auth.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { safeSetItem } from './safe-storage.js';
|
|
2
|
+
|
|
1
3
|
const b2hex = (bytes) => Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
|
|
2
4
|
const hex2b = (hex) => {
|
|
3
5
|
const a = new Uint8Array(hex.length / 2);
|
|
@@ -39,9 +41,14 @@ export class NostrAuth extends EventTarget {
|
|
|
39
41
|
}
|
|
40
42
|
|
|
41
43
|
importKey(input) {
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
44
|
+
const trimmed = (input || '').trim();
|
|
45
|
+
if (!trimmed) throw new Error('Enter a key');
|
|
46
|
+
const sk = trimmed.startsWith('nsec')
|
|
47
|
+
? (() => { const d = this.NT.nip19.decode(trimmed); if (d.type !== 'nsec') throw new Error('not nsec'); return d.data; })()
|
|
48
|
+
: (() => {
|
|
49
|
+
if (!/^[0-9a-fA-F]{64}$/.test(trimmed)) throw new Error('Expected an nsec1… key or a 64-character hex secret key');
|
|
50
|
+
return hex2b(trimmed);
|
|
51
|
+
})();
|
|
45
52
|
const pk = this.NT.getPublicKey(sk);
|
|
46
53
|
this._persist(sk, pk);
|
|
47
54
|
this._emit('login', { pubkey: pk });
|
|
@@ -87,8 +94,13 @@ export class NostrAuth extends EventTarget {
|
|
|
87
94
|
_persist(sk, pk) {
|
|
88
95
|
this.privkey = sk;
|
|
89
96
|
this.pubkey = pk;
|
|
90
|
-
this.storage
|
|
91
|
-
this.storage
|
|
97
|
+
if (!this.storage) return;
|
|
98
|
+
const okSk = safeSetItem(this.storage, this, 'zn_sk', b2hex(sk));
|
|
99
|
+
const okPk = safeSetItem(this.storage, this, 'zn_pk', pk);
|
|
100
|
+
if (!okSk || !okPk) {
|
|
101
|
+
try { this.storage.removeItem('zn_sk'); this.storage.removeItem('zn_pk'); } catch {}
|
|
102
|
+
this._emit('persist-failed', { pubkey: pk });
|
|
103
|
+
}
|
|
92
104
|
}
|
|
93
105
|
|
|
94
106
|
_emit(type, detail) { this.dispatchEvent(new CustomEvent(type, { detail })); }
|
package/src/channels.js
CHANGED
|
@@ -30,12 +30,14 @@ export class Channels extends EventTarget {
|
|
|
30
30
|
this.pool.subscribe('channels-' + serverId,
|
|
31
31
|
[{ kinds: [30078], authors: [ownerPubkey], '#d': [dTag] }],
|
|
32
32
|
(event) => {
|
|
33
|
+
if (event.pubkey !== ownerPubkey) return;
|
|
33
34
|
const hasTag = event.tags?.some(t => t[0] === 'd' && t[1] === dTag);
|
|
34
35
|
if (!hasTag) return;
|
|
35
36
|
try {
|
|
36
37
|
const data = JSON.parse(event.content);
|
|
37
|
-
|
|
38
|
-
this.
|
|
38
|
+
if (!data || typeof data !== 'object' || !Array.isArray(data.channels) || !Array.isArray(data.categories)) return;
|
|
39
|
+
this.channels = data.channels;
|
|
40
|
+
this.categories = data.categories;
|
|
39
41
|
this._emit('updated', { channels: this.channels, categories: this.categories });
|
|
40
42
|
} catch {}
|
|
41
43
|
},
|
package/src/chat.js
CHANGED
|
@@ -12,6 +12,16 @@ export class Chat extends EventTarget {
|
|
|
12
12
|
this.activeChannelId = null;
|
|
13
13
|
this.messages = [];
|
|
14
14
|
this.profiles = new Map(); this.fetching = new Set();
|
|
15
|
+
this._sendTimes = [];
|
|
16
|
+
this.rateLimitMax = 5;
|
|
17
|
+
this.rateLimitWindowMs = 10000;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
rateLimitRetryAfterMs() {
|
|
21
|
+
const now = Date.now();
|
|
22
|
+
this._sendTimes = (this._sendTimes || []).filter(t => now - t < this.rateLimitWindowMs);
|
|
23
|
+
if (this._sendTimes.length < this.rateLimitMax) return 0;
|
|
24
|
+
return this._sendTimes[0] + this.rateLimitWindowMs - now;
|
|
15
25
|
}
|
|
16
26
|
|
|
17
27
|
async send(content, { announcement = false } = {}) {
|
|
@@ -19,6 +29,12 @@ export class Chat extends EventTarget {
|
|
|
19
29
|
if (!this.auth.isLoggedIn() || !channelId) return;
|
|
20
30
|
if (announcement && !this.isAdmin(serverId)) return;
|
|
21
31
|
const trimmed = content.trim(); if (!trimmed) return;
|
|
32
|
+
const retryAfter = this.rateLimitRetryAfterMs();
|
|
33
|
+
if (retryAfter > 0) {
|
|
34
|
+
this._emit('rate-limited', { retryAfterMs: retryAfter });
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
this._sendTimes.push(Date.now());
|
|
22
38
|
const chanHex = await hexChannelId(channelId, serverId);
|
|
23
39
|
const tags = [['e', chanHex, '', 'root']];
|
|
24
40
|
if (announcement) tags.push(['t', 'announcement']);
|
|
@@ -71,7 +87,9 @@ export class Chat extends EventTarget {
|
|
|
71
87
|
|
|
72
88
|
_addMessage(msg) {
|
|
73
89
|
if (this.messages.find(m => m.id === msg.id)) return;
|
|
74
|
-
|
|
90
|
+
let i = this.messages.length;
|
|
91
|
+
while (i > 0 && this.messages[i - 1].timestamp > msg.timestamp) i--;
|
|
92
|
+
this.messages = [...this.messages.slice(0, i), msg, ...this.messages.slice(i)];
|
|
75
93
|
this._emit('message', { message: msg });
|
|
76
94
|
this._emit('messages', { list: this.messages });
|
|
77
95
|
}
|
|
@@ -87,9 +105,14 @@ export class Chat extends EventTarget {
|
|
|
87
105
|
if (this.fetching.has(pubkey)) return;
|
|
88
106
|
this.fetching.add(pubkey);
|
|
89
107
|
this.pool.subscribe('profile-' + pubkey,
|
|
90
|
-
[{ kinds: [0], authors: [pubkey]
|
|
91
|
-
(event) => {
|
|
92
|
-
|
|
108
|
+
[{ kinds: [0], authors: [pubkey] }],
|
|
109
|
+
(event) => {
|
|
110
|
+
const known = this._profileEvents?.get(pubkey);
|
|
111
|
+
if (known && known >= event.created_at) return;
|
|
112
|
+
(this._profileEvents ||= new Map()).set(pubkey, event.created_at);
|
|
113
|
+
try { this.profiles.set(pubkey, JSON.parse(event.content)); this._emit('profile', { pubkey, profile: this.profiles.get(pubkey) }); } catch {}
|
|
114
|
+
},
|
|
115
|
+
() => { this.fetching.delete(pubkey); });
|
|
93
116
|
}
|
|
94
117
|
|
|
95
118
|
updateProfile(pubkey, profile) { this.profiles.set(pubkey, profile); this._emit('profile', { pubkey, profile }); }
|
package/src/relay-pool.js
CHANGED
|
@@ -14,6 +14,7 @@ const DEFAULT_RELAYS = [
|
|
|
14
14
|
const SEEN_MAX = 10000;
|
|
15
15
|
const PENDING_MAX = 500;
|
|
16
16
|
const PENDING_TTL_MS = 120000;
|
|
17
|
+
const CONNECT_TIMEOUT_MS = 10000;
|
|
17
18
|
const jitter = (ms) => Math.round(ms * (0.75 + Math.random() * 0.5));
|
|
18
19
|
|
|
19
20
|
const lruTouch = (map, key) => {
|
|
@@ -60,6 +61,7 @@ export class RelayPool extends EventTarget {
|
|
|
60
61
|
for (const [, rec] of this._acks) { clearTimeout(rec.timer); rec.resolve(false); }
|
|
61
62
|
this._acks.clear();
|
|
62
63
|
for (const [, r] of this.relays) {
|
|
64
|
+
if (r._connectTimer) clearTimeout(r._connectTimer);
|
|
63
65
|
if (r.ws) {
|
|
64
66
|
r.ws.onclose = null; r.ws.onerror = null; r.ws.onopen = null; r.ws.onmessage = null;
|
|
65
67
|
if (typeof r.ws.removeAllListeners === 'function') r.ws.removeAllListeners();
|
|
@@ -83,7 +85,13 @@ export class RelayPool extends EventTarget {
|
|
|
83
85
|
try { ws = new this.WS(url); }
|
|
84
86
|
catch (e) { relay.status = 'error'; this._emit('relay-status', { url, status: 'error' }); return; }
|
|
85
87
|
relay.ws = ws;
|
|
88
|
+
const connectTimer = setTimeout(() => {
|
|
89
|
+
if (relay.ws !== ws || relay.status !== 'connecting') return;
|
|
90
|
+
try { ws.close(); } catch {}
|
|
91
|
+
}, CONNECT_TIMEOUT_MS);
|
|
92
|
+
relay._connectTimer = connectTimer;
|
|
86
93
|
ws.onopen = () => {
|
|
94
|
+
clearTimeout(connectTimer);
|
|
87
95
|
relay.status = 'connected';
|
|
88
96
|
relay._openedAt = Date.now();
|
|
89
97
|
this._emit('relay-status', { url, status: 'connected' });
|
|
@@ -92,7 +100,7 @@ export class RelayPool extends EventTarget {
|
|
|
92
100
|
relay.subIds.add(subId);
|
|
93
101
|
if (!relay._reqSentAt) relay._reqSentAt = Date.now();
|
|
94
102
|
}
|
|
95
|
-
this._drainPending();
|
|
103
|
+
this._drainPending(url, ws);
|
|
96
104
|
};
|
|
97
105
|
ws.onmessage = (e) => {
|
|
98
106
|
if (relay._reqSentAt && relay.latencyMs === null) {
|
|
@@ -101,8 +109,9 @@ export class RelayPool extends EventTarget {
|
|
|
101
109
|
}
|
|
102
110
|
try { this._handle(url, typeof e.data === 'string' ? e.data : e.data.toString()); } catch {}
|
|
103
111
|
};
|
|
104
|
-
ws.onerror = () => { relay.status = 'error'; this._emit('relay-status', { url, status: 'error' }); };
|
|
112
|
+
ws.onerror = () => { clearTimeout(connectTimer); relay.status = 'error'; this._emit('relay-status', { url, status: 'error' }); };
|
|
105
113
|
ws.onclose = () => {
|
|
114
|
+
clearTimeout(connectTimer);
|
|
106
115
|
relay.status = 'closed';
|
|
107
116
|
this._emit('relay-status', { url, status: 'closed' });
|
|
108
117
|
const sustained = relay._openedAt && Date.now() - relay._openedAt > 5000;
|
|
@@ -171,24 +180,32 @@ export class RelayPool extends EventTarget {
|
|
|
171
180
|
|
|
172
181
|
publish(event) {
|
|
173
182
|
let sent = false;
|
|
174
|
-
|
|
183
|
+
let anyDisconnected = false;
|
|
184
|
+
const sentTo = new Set();
|
|
185
|
+
for (const [url, relay] of this.relays) {
|
|
175
186
|
if (relay.ws?.readyState === 1) {
|
|
176
187
|
relay.ws.send(JSON.stringify(['EVENT', event]));
|
|
177
188
|
sent = true;
|
|
189
|
+
sentTo.add(url);
|
|
190
|
+
} else {
|
|
191
|
+
anyDisconnected = true;
|
|
178
192
|
}
|
|
179
193
|
}
|
|
180
|
-
if (sent)
|
|
181
|
-
|
|
182
|
-
} else {
|
|
183
|
-
this._queuePending(event);
|
|
184
|
-
}
|
|
194
|
+
if (anyDisconnected || !sent) this._queuePending(event, sentTo);
|
|
195
|
+
else if (event?.id) this._pendingIds.delete(event.id);
|
|
185
196
|
return sent;
|
|
186
197
|
}
|
|
187
198
|
|
|
188
|
-
|
|
189
|
-
|
|
199
|
+
// Tracks delivery per relay URL, not just "sent to at least one" — a relay
|
|
200
|
+
// mid-reconnect during a partial outage otherwise never gets the event.
|
|
201
|
+
_queuePending(event, sentTo) {
|
|
202
|
+
if (event?.id && this._pendingIds.has(event.id)) {
|
|
203
|
+
const existing = this.pending.find((p) => p.event?.id === event.id);
|
|
204
|
+
if (existing) { for (const url of sentTo) existing.sentTo.add(url); }
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
190
207
|
if (event?.id) this._pendingIds.add(event.id);
|
|
191
|
-
this.pending.push({ event, ts: Date.now() });
|
|
208
|
+
this.pending.push({ event, sentTo, ts: Date.now() });
|
|
192
209
|
while (this.pending.length > PENDING_MAX) {
|
|
193
210
|
const dropped = this.pending.shift();
|
|
194
211
|
if (dropped.event?.id) this._pendingIds.delete(dropped.event.id);
|
|
@@ -218,11 +235,34 @@ export class RelayPool extends EventTarget {
|
|
|
218
235
|
if (rec) rec.resolve(accepted);
|
|
219
236
|
}
|
|
220
237
|
|
|
221
|
-
_drainPending() {
|
|
238
|
+
_drainPending(url, ws) {
|
|
222
239
|
const cutoff = Date.now() - PENDING_TTL_MS;
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
240
|
+
this.pending = this.pending.filter((entry) => {
|
|
241
|
+
const alive = entry.ts >= cutoff;
|
|
242
|
+
if (!alive && entry.event?.id) this._pendingIds.delete(entry.event.id);
|
|
243
|
+
return alive;
|
|
244
|
+
});
|
|
245
|
+
if (ws) {
|
|
246
|
+
for (const entry of this.pending) {
|
|
247
|
+
if (!entry.sentTo) entry.sentTo = new Set();
|
|
248
|
+
if (entry.sentTo.has(url)) continue;
|
|
249
|
+
ws.send(JSON.stringify(['EVENT', entry.event]));
|
|
250
|
+
entry.sentTo.add(url);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
this.pending = this.pending.filter((entry) => {
|
|
254
|
+
let anyConnected = false;
|
|
255
|
+
let allKnownSent = true;
|
|
256
|
+
for (const [u, r] of this.relays) {
|
|
257
|
+
if (r.ws?.readyState === 1) {
|
|
258
|
+
anyConnected = true;
|
|
259
|
+
if (!entry.sentTo?.has(u)) { allKnownSent = false; break; }
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
if (!anyConnected) allKnownSent = false;
|
|
263
|
+
if (allKnownSent && entry.event?.id) this._pendingIds.delete(entry.event.id);
|
|
264
|
+
return !allKnownSent;
|
|
265
|
+
});
|
|
226
266
|
}
|
|
227
267
|
|
|
228
268
|
isConnected() {
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
const isQuotaError = (err) =>
|
|
2
|
+
err && (err.name === 'QuotaExceededError' || err.name === 'NS_ERROR_DOM_QUOTA_REACHED' || err.code === 22 || err.code === 1014);
|
|
3
|
+
|
|
4
|
+
export function safeSetItem(storage, target, key, value) {
|
|
5
|
+
if (!storage) return false;
|
|
6
|
+
try {
|
|
7
|
+
storage.setItem(key, value);
|
|
8
|
+
return true;
|
|
9
|
+
} catch (err) {
|
|
10
|
+
if (isQuotaError(err)) {
|
|
11
|
+
target?.dispatchEvent?.(new CustomEvent('storage-error', { detail: { key, reason: 'quota', message: 'Storage full — some data may not be saved' } }));
|
|
12
|
+
} else {
|
|
13
|
+
target?.dispatchEvent?.(new CustomEvent('storage-error', { detail: { key, reason: 'unknown', message: err?.message || String(err) } }));
|
|
14
|
+
}
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
}
|
package/src/servers.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { safeSetItem } from './safe-storage.js';
|
|
2
|
+
|
|
1
3
|
export class Servers extends EventTarget {
|
|
2
4
|
constructor({ relayPool, auth, storage, onSwitch = null }) {
|
|
3
5
|
super();
|
|
@@ -63,7 +65,7 @@ export class Servers extends EventTarget {
|
|
|
63
65
|
async join(serverId) {
|
|
64
66
|
try {
|
|
65
67
|
const joined = JSON.parse(this.storage.getItem('zn_joined_servers') || '[]');
|
|
66
|
-
if (!joined.includes(serverId)) { joined.push(serverId); this.storage
|
|
68
|
+
if (!joined.includes(serverId)) { joined.push(serverId); safeSetItem(this.storage, this, 'zn_joined_servers', JSON.stringify(joined)); }
|
|
67
69
|
} catch {}
|
|
68
70
|
if (!this.servers.find(s => s.id === serverId)) { this.servers = [...this.servers, { id: serverId, name: serverId.slice(0, 8), iconColor: '#5865F2' }]; this._persist(); }
|
|
69
71
|
await this.switchTo(serverId);
|
|
@@ -74,7 +76,7 @@ export class Servers extends EventTarget {
|
|
|
74
76
|
this._persist();
|
|
75
77
|
try {
|
|
76
78
|
const joined = JSON.parse(this.storage.getItem('zn_joined_servers') || '[]');
|
|
77
|
-
this.storage
|
|
79
|
+
safeSetItem(this.storage, this, 'zn_joined_servers', JSON.stringify(joined.filter(id => id !== serverId)));
|
|
78
80
|
} catch {}
|
|
79
81
|
if (this.currentServerId === serverId) await this.switchTo(null);
|
|
80
82
|
else this._emit('updated', { servers: this.servers });
|
|
@@ -84,7 +86,7 @@ export class Servers extends EventTarget {
|
|
|
84
86
|
|
|
85
87
|
async switchTo(serverId) {
|
|
86
88
|
this.currentServerId = serverId;
|
|
87
|
-
if (serverId) this.storage
|
|
89
|
+
if (serverId) safeSetItem(this.storage, this, 'zn_lastServer', serverId); else this.storage.removeItem('zn_lastServer');
|
|
88
90
|
this._emit('switched', { serverId });
|
|
89
91
|
await this.onSwitch?.(serverId);
|
|
90
92
|
}
|
|
@@ -97,7 +99,7 @@ export class Servers extends EventTarget {
|
|
|
97
99
|
}
|
|
98
100
|
|
|
99
101
|
getOrder() { try { return JSON.parse(this.storage.getItem('zn_serverOrder') || '[]'); } catch { return []; } }
|
|
100
|
-
saveOrder(ids) { this.storage
|
|
102
|
+
saveOrder(ids) { safeSetItem(this.storage, this, 'zn_serverOrder', JSON.stringify(ids)); this._emit('updated', { servers: this.servers }); }
|
|
101
103
|
sorted() {
|
|
102
104
|
const order = this.getOrder();
|
|
103
105
|
if (!order.length) return this.servers;
|
|
@@ -105,7 +107,7 @@ export class Servers extends EventTarget {
|
|
|
105
107
|
return this.servers.slice().sort((a, b) => (idx[a.id] ?? Infinity) - (idx[b.id] ?? Infinity));
|
|
106
108
|
}
|
|
107
109
|
|
|
108
|
-
_persist() { this.storage
|
|
110
|
+
_persist() { safeSetItem(this.storage, this, 'zn_servers', JSON.stringify(this.servers)); }
|
|
109
111
|
_emit(t, d) { this.dispatchEvent(new CustomEvent(t, { detail: d })); }
|
|
110
112
|
}
|
|
111
113
|
|
package/src/voice.js
CHANGED
|
@@ -56,6 +56,7 @@ export class VoiceSession extends EventTarget {
|
|
|
56
56
|
this.muted = false; this.deafened = false;
|
|
57
57
|
this.sfu = { mode: 'mesh', hub: null, hubLostAt: null, rttMatrix: new Map(), electionTimer: null, statsInterval: null, actor: null };
|
|
58
58
|
this.retrySchedule = {};
|
|
59
|
+
this._epoch = 0;
|
|
59
60
|
}
|
|
60
61
|
|
|
61
62
|
_initActor() {
|
|
@@ -64,16 +65,31 @@ export class VoiceSession extends EventTarget {
|
|
|
64
65
|
this.actor.start();
|
|
65
66
|
}
|
|
66
67
|
|
|
68
|
+
// Reentrancy guard: connect()/disconnect() are async and mutate shared
|
|
69
|
+
// instance state (peers, participants, localStream, roomId) across several
|
|
70
|
+
// await points. Rapid join→leave→rejoin (or a double-click join) used to
|
|
71
|
+
// interleave two in-flight calls: a stale connect() resuming after a newer
|
|
72
|
+
// disconnect() had already torn everything down would re-populate roomId/
|
|
73
|
+
// participants and re-send 'connected' into an actor the fresh call had
|
|
74
|
+
// already returned to idle, leaking the stale heartbeat/presence
|
|
75
|
+
// subscriptions and getUserMedia stream. Every call captures the epoch at
|
|
76
|
+
// entry and re-checks it after each await; a superseded call unwinds
|
|
77
|
+
// whatever it acquired instead of mutating shared state.
|
|
67
78
|
async connect(channelName, { displayName = 'Guest' } = {}) {
|
|
68
79
|
if (!this.actor) this._initActor();
|
|
69
80
|
if (!this.actor.getSnapshot().can({ type: 'connect' })) await this.disconnect();
|
|
81
|
+
const epoch = ++this._epoch;
|
|
70
82
|
this.actor.send({ type: 'connect' });
|
|
71
83
|
this.channelName = channelName;
|
|
72
84
|
this.joinTs = Math.floor(Date.now() / 1000);
|
|
73
85
|
this.displayName = displayName;
|
|
74
86
|
try {
|
|
75
|
-
|
|
76
|
-
|
|
87
|
+
const roomId = await deriveRoomId(this.serverId, channelName);
|
|
88
|
+
if (epoch !== this._epoch) return;
|
|
89
|
+
const stream = await this.md.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true } });
|
|
90
|
+
if (epoch !== this._epoch) { stream.getTracks().forEach(t => t.stop()); return; }
|
|
91
|
+
this.roomId = roomId;
|
|
92
|
+
this.localStream = stream;
|
|
77
93
|
// PTT default: gate closed at join. Apps that want always-on call setMuted(false).
|
|
78
94
|
this.muted = true;
|
|
79
95
|
this.localStream.getAudioTracks().forEach(t => t.enabled = false);
|
|
@@ -84,10 +100,12 @@ export class VoiceSession extends EventTarget {
|
|
|
84
100
|
this._subscribeSignals();
|
|
85
101
|
this._subscribePresence();
|
|
86
102
|
await this._publishPresence('join');
|
|
103
|
+
if (epoch !== this._epoch) return;
|
|
87
104
|
this._startHeartbeat();
|
|
88
105
|
this._sfuStart();
|
|
89
106
|
this._emit('connected', { roomId: this.roomId, channelName });
|
|
90
107
|
} catch (e) {
|
|
108
|
+
if (epoch !== this._epoch) return;
|
|
91
109
|
this.actor.send({ type: 'fail' });
|
|
92
110
|
this._emit('error', { message: 'connect failed: ' + e.message });
|
|
93
111
|
throw e;
|
|
@@ -97,6 +115,7 @@ export class VoiceSession extends EventTarget {
|
|
|
97
115
|
async disconnect() {
|
|
98
116
|
if (!this.actor || this.actor.getSnapshot().matches('idle')) return;
|
|
99
117
|
if (!this.actor.getSnapshot().can({ type: 'disconnect' })) return;
|
|
118
|
+
++this._epoch;
|
|
100
119
|
this.actor.send({ type: 'disconnect' });
|
|
101
120
|
await this._publishPresence('leave');
|
|
102
121
|
this._stopHeartbeat();
|
|
@@ -659,6 +678,7 @@ export class VoiceSession extends EventTarget {
|
|
|
659
678
|
try { peer.dc?.close(); } catch {}
|
|
660
679
|
try { peer.pc?.close(); } catch {}
|
|
661
680
|
if (peer.audioEl) { peer.audioEl.srcObject = null; peer.audioEl.remove(); }
|
|
681
|
+
try { peer.fsm?.stop?.(); } catch {}
|
|
662
682
|
this._detachAnalyzer(peerPubkey);
|
|
663
683
|
this.peers.delete(peerPubkey);
|
|
664
684
|
this._emit('peer-closed', { peerPubkey });
|