wireweave 0.1.1 → 0.1.3
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 +46 -35
- package/package.json +14 -4
- package/src/bans.js +45 -0
- package/src/channels.js +107 -0
- package/src/chat.js +95 -0
- package/src/fsm.js +51 -0
- package/src/index.js +12 -0
- package/src/media.js +81 -0
- package/src/message.js +22 -0
- package/src/pages.js +77 -0
- package/src/roles.js +58 -0
- package/src/servers.js +103 -0
- package/src/settings.js +72 -0
- package/src/voice.js +348 -0
- package/src/wireweave.js +89 -0
- package/src/legacy/README.md +0 -71
- package/src/legacy/index.js +0 -49
- package/src/legacy/nostr-auth.js +0 -217
- package/src/legacy/nostr-bans.js +0 -59
- package/src/legacy/nostr-channels.js +0 -145
- package/src/legacy/nostr-chat.js +0 -185
- package/src/legacy/nostr-fsm.js +0 -5
- package/src/legacy/nostr-media.js +0 -97
- package/src/legacy/nostr-message.js +0 -12
- package/src/legacy/nostr-network.js +0 -182
- package/src/legacy/nostr-pages.js +0 -152
- package/src/legacy/nostr-roles.js +0 -74
- package/src/legacy/nostr-servers.js +0 -223
- package/src/legacy/nostr-settings.js +0 -97
- package/src/legacy/nostr-state-patch.js +0 -19
- package/src/legacy/nostr-voice-camera.js +0 -63
- package/src/legacy/nostr-voice-rtc.js +0 -199
- package/src/legacy/nostr-voice-sfu.js +0 -128
- package/src/legacy/nostr-voice.js +0 -257
package/src/roles.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
export class Roles extends EventTarget {
|
|
2
|
+
constructor({ relayPool, auth }) {
|
|
3
|
+
super();
|
|
4
|
+
if (!relayPool || !auth) throw new Error('Roles: deps required');
|
|
5
|
+
this.pool = relayPool; this.auth = auth;
|
|
6
|
+
this.store = new Map();
|
|
7
|
+
this.sub = null;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
_creatorOf(serverId) { return serverId ? serverId.split(':')[0] : null; }
|
|
11
|
+
|
|
12
|
+
isOwner(serverId) { return !!this.auth.pubkey && this._creatorOf(serverId) === this.auth.pubkey; }
|
|
13
|
+
isAdmin(serverId) { if (this.isOwner(serverId)) return true; const r = this.store.get(serverId); return !!(r?.admins || []).includes(this.auth.pubkey); }
|
|
14
|
+
isMod(serverId) { if (this.isAdmin(serverId)) return true; const r = this.store.get(serverId); return !!(r?.mods || []).includes(this.auth.pubkey); }
|
|
15
|
+
|
|
16
|
+
getRole(serverId, pubkey) {
|
|
17
|
+
if (pubkey === this._creatorOf(serverId)) return 'owner';
|
|
18
|
+
const r = this.store.get(serverId) || {};
|
|
19
|
+
if ((r.admins || []).includes(pubkey)) return 'admin';
|
|
20
|
+
if ((r.mods || []).includes(pubkey)) return 'moderator';
|
|
21
|
+
return 'member';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async setRole(serverId, targetPubkey, role) {
|
|
25
|
+
if (!this.isOwner(serverId) && role === 'admin') throw new Error('Only owner can assign admin');
|
|
26
|
+
if (!this.isAdmin(serverId)) throw new Error('Insufficient permissions');
|
|
27
|
+
const existing = this.store.get(serverId) || { admins: [], mods: [] };
|
|
28
|
+
let admins = (existing.admins || []).filter(p => p !== targetPubkey);
|
|
29
|
+
let mods = (existing.mods || []).filter(p => p !== targetPubkey);
|
|
30
|
+
if (role === 'admin') admins = [...admins, targetPubkey];
|
|
31
|
+
else if (role === 'moderator') mods = [...mods, targetPubkey];
|
|
32
|
+
const next = { admins, mods };
|
|
33
|
+
this.store.set(serverId, next);
|
|
34
|
+
const signed = await this.auth.sign({ kind: 30078, created_at: Math.floor(Date.now() / 1000), tags: [['d', 'zellous-roles:' + serverId]], content: JSON.stringify(next) });
|
|
35
|
+
this.pool.publish(signed);
|
|
36
|
+
this.dispatchEvent(new CustomEvent('updated', { detail: { serverId, next } }));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
subscribe(serverId) {
|
|
40
|
+
if (this.sub) { this.pool.unsubscribe(this.sub); this.sub = null; }
|
|
41
|
+
if (!serverId) return;
|
|
42
|
+
const creator = this._creatorOf(serverId);
|
|
43
|
+
if (!creator) return;
|
|
44
|
+
this.sub = 'roles-' + serverId;
|
|
45
|
+
this.pool.subscribe(this.sub,
|
|
46
|
+
[{ kinds: [30078], authors: [creator], '#d': ['zellous-roles:' + serverId] }],
|
|
47
|
+
(event) => {
|
|
48
|
+
if (event.pubkey !== creator) return;
|
|
49
|
+
try {
|
|
50
|
+
const data = JSON.parse(event.content);
|
|
51
|
+
this.store.set(serverId, { admins: data.admins || [], mods: data.mods || [] });
|
|
52
|
+
this.dispatchEvent(new CustomEvent('updated', { detail: { serverId, next: this.store.get(serverId) } }));
|
|
53
|
+
} catch {}
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const createRoles = (opts) => new Roles(opts);
|
package/src/servers.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
export class Servers extends EventTarget {
|
|
2
|
+
constructor({ relayPool, auth, storage, onSwitch = null }) {
|
|
3
|
+
super();
|
|
4
|
+
if (!relayPool || !auth || !storage) throw new Error('Servers: deps required');
|
|
5
|
+
this.pool = relayPool; this.auth = auth; this.storage = storage; this.onSwitch = onSwitch;
|
|
6
|
+
this.servers = []; this.currentServerId = null;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
load() {
|
|
10
|
+
try { this.servers = JSON.parse(this.storage.getItem('zn_servers') || '[]'); } catch { this.servers = []; }
|
|
11
|
+
if (this.auth.pubkey) {
|
|
12
|
+
this.pool.subscribe('my-servers', [{ kinds: [34550], authors: [this.auth.pubkey] }], (ev) => this._handleEvent(ev));
|
|
13
|
+
}
|
|
14
|
+
try {
|
|
15
|
+
const joined = JSON.parse(this.storage.getItem('zn_joined_servers') || '[]');
|
|
16
|
+
joined.forEach(sid => { if (!this.servers.find(s => s.id === sid)) this.servers = [...this.servers, { id: sid, name: sid.slice(0, 8), iconColor: '#5865F2' }]; });
|
|
17
|
+
const unresolved = joined.filter(sid => { const p = sid.split(':'); return p.length === 2 && p[0] !== this.auth.pubkey; });
|
|
18
|
+
const byAuthor = {};
|
|
19
|
+
unresolved.forEach(sid => { const [author, dTag] = sid.split(':'); (byAuthor[author] = byAuthor[author] || []).push(dTag); });
|
|
20
|
+
Object.keys(byAuthor).forEach(author => {
|
|
21
|
+
this.pool.subscribe('joined-server-' + author.slice(0, 8),
|
|
22
|
+
[{ kinds: [34550], authors: [author], '#d': byAuthor[author] }],
|
|
23
|
+
(ev) => this._handleEvent(ev));
|
|
24
|
+
});
|
|
25
|
+
} catch {}
|
|
26
|
+
this._emit('updated', { servers: this.servers });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
_handleEvent(event) {
|
|
30
|
+
const nameTag = event.tags.find(t => t[0] === 'name');
|
|
31
|
+
const colorTag = event.tags.find(t => t[0] === 'color');
|
|
32
|
+
const dTag = event.tags.find(t => t[0] === 'd');
|
|
33
|
+
if (!dTag) return;
|
|
34
|
+
const serverId = event.pubkey + ':' + dTag[1];
|
|
35
|
+
const name = nameTag ? nameTag[1] : serverId.slice(0, 8);
|
|
36
|
+
const iconColor = colorTag ? colorTag[1] : '#5865F2';
|
|
37
|
+
const existing = this.servers.find(s => s.id === serverId);
|
|
38
|
+
if (existing) { existing.name = name; existing.iconColor = iconColor; existing.ownerId = event.pubkey; this.servers = [...this.servers]; }
|
|
39
|
+
else this.servers = [...this.servers, { id: serverId, name, iconColor, ownerId: event.pubkey }];
|
|
40
|
+
this._persist();
|
|
41
|
+
this._emit('updated', { servers: this.servers });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async create(name, iconColor = '#5865F2') {
|
|
45
|
+
const dTag = Math.random().toString(36).slice(2, 10);
|
|
46
|
+
const serverId = this.auth.pubkey + ':' + dTag;
|
|
47
|
+
const signed = await this.auth.sign({ kind: 34550, created_at: Math.floor(Date.now() / 1000), tags: [['d', dTag], ['name', name], ['color', iconColor]], content: '' });
|
|
48
|
+
this.pool.publish(signed);
|
|
49
|
+
this.servers = [...this.servers, { id: serverId, name, iconColor, ownerId: this.auth.pubkey }];
|
|
50
|
+
this._persist();
|
|
51
|
+
await this.switchTo(serverId);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async join(serverId) {
|
|
55
|
+
try {
|
|
56
|
+
const joined = JSON.parse(this.storage.getItem('zn_joined_servers') || '[]');
|
|
57
|
+
if (!joined.includes(serverId)) { joined.push(serverId); this.storage.setItem('zn_joined_servers', JSON.stringify(joined)); }
|
|
58
|
+
} catch {}
|
|
59
|
+
if (!this.servers.find(s => s.id === serverId)) { this.servers = [...this.servers, { id: serverId, name: serverId.slice(0, 8), iconColor: '#5865F2' }]; this._persist(); }
|
|
60
|
+
await this.switchTo(serverId);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async delete(serverId) {
|
|
64
|
+
this.servers = this.servers.filter(s => s.id !== serverId);
|
|
65
|
+
this._persist();
|
|
66
|
+
try {
|
|
67
|
+
const joined = JSON.parse(this.storage.getItem('zn_joined_servers') || '[]');
|
|
68
|
+
this.storage.setItem('zn_joined_servers', JSON.stringify(joined.filter(id => id !== serverId)));
|
|
69
|
+
} catch {}
|
|
70
|
+
if (this.currentServerId === serverId) await this.switchTo(null);
|
|
71
|
+
else this._emit('updated', { servers: this.servers });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async leave(serverId) { return this.delete(serverId); }
|
|
75
|
+
|
|
76
|
+
async switchTo(serverId) {
|
|
77
|
+
this.currentServerId = serverId;
|
|
78
|
+
if (serverId) this.storage.setItem('zn_lastServer', serverId); else this.storage.removeItem('zn_lastServer');
|
|
79
|
+
this._emit('switched', { serverId });
|
|
80
|
+
await this.onSwitch?.(serverId);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
init() {
|
|
84
|
+
this.load();
|
|
85
|
+
const last = this.storage.getItem('zn_lastServer');
|
|
86
|
+
if (last && this.servers.find(s => s.id === last)) this.switchTo(last);
|
|
87
|
+
else if (this.servers.length) this.switchTo(this.servers[0].id);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
getOrder() { try { return JSON.parse(this.storage.getItem('zn_serverOrder') || '[]'); } catch { return []; } }
|
|
91
|
+
saveOrder(ids) { this.storage.setItem('zn_serverOrder', JSON.stringify(ids)); this._emit('updated', { servers: this.servers }); }
|
|
92
|
+
sorted() {
|
|
93
|
+
const order = this.getOrder();
|
|
94
|
+
if (!order.length) return this.servers;
|
|
95
|
+
const idx = {}; order.forEach((id, i) => idx[id] = i);
|
|
96
|
+
return this.servers.slice().sort((a, b) => (idx[a.id] ?? Infinity) - (idx[b.id] ?? Infinity));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
_persist() { this.storage.setItem('zn_servers', JSON.stringify(this.servers)); }
|
|
100
|
+
_emit(t, d) { this.dispatchEvent(new CustomEvent(t, { detail: d })); }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export const createServers = (opts) => new Servers(opts);
|
package/src/settings.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
const VALID_BITRATES = [8000, 16000, 24000, 48000, 96000];
|
|
2
|
+
const clampBitrate = (v) => VALID_BITRATES.reduce((prev, cur) => Math.abs(cur - v) < Math.abs(prev - v) ? cur : prev);
|
|
3
|
+
|
|
4
|
+
export class Settings extends EventTarget {
|
|
5
|
+
constructor({ relayPool, auth, roles }) {
|
|
6
|
+
super();
|
|
7
|
+
if (!relayPool || !auth || !roles) throw new Error('Settings: deps required');
|
|
8
|
+
this.pool = relayPool; this.auth = auth; this.roles = roles;
|
|
9
|
+
this.store = new Map();
|
|
10
|
+
this.sub = null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
getBitrate(serverId) { return this.store.get(serverId)?.opusBitrate || 24000; }
|
|
14
|
+
|
|
15
|
+
async setBitrate(serverId, bitrate) {
|
|
16
|
+
if (!this.roles.isOwner(serverId) && !this.roles.isAdmin(serverId)) throw new Error('Insufficient permissions');
|
|
17
|
+
const clamped = clampBitrate(Number(bitrate));
|
|
18
|
+
const existing = this.store.get(serverId) || {};
|
|
19
|
+
const next = { ...existing, opusBitrate: clamped };
|
|
20
|
+
this.store.set(serverId, next);
|
|
21
|
+
const signed = await this.auth.sign({ kind: 30078, created_at: Math.floor(Date.now() / 1000), tags: [['d', 'zellous-settings:' + serverId]], content: JSON.stringify(next) });
|
|
22
|
+
this.pool.publish(signed);
|
|
23
|
+
this.dispatchEvent(new CustomEvent('updated', { detail: { serverId, next } }));
|
|
24
|
+
return clamped;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
getEmbedAllowlist(serverId) {
|
|
28
|
+
const s = this.store.get(serverId);
|
|
29
|
+
if (!s?.embedAllowlist) return [];
|
|
30
|
+
return typeof s.embedAllowlist === 'string' ? s.embedAllowlist.split(',').map(d => d.trim()).filter(Boolean) : s.embedAllowlist;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async setEmbedAllowlist(serverId, domainsStr) {
|
|
34
|
+
if (!this.roles.isOwner(serverId) && !this.roles.isAdmin(serverId)) throw new Error('Insufficient permissions');
|
|
35
|
+
const domains = domainsStr.split(',').map(d => d.trim()).filter(Boolean);
|
|
36
|
+
const existing = this.store.get(serverId) || {};
|
|
37
|
+
const next = { ...existing, embedAllowlist: domains };
|
|
38
|
+
this.store.set(serverId, next);
|
|
39
|
+
const signed = await this.auth.sign({ kind: 30078, created_at: Math.floor(Date.now() / 1000), tags: [['d', 'zellous-settings:' + serverId]], content: JSON.stringify(next) });
|
|
40
|
+
this.pool.publish(signed);
|
|
41
|
+
this.dispatchEvent(new CustomEvent('updated', { detail: { serverId, next } }));
|
|
42
|
+
return domains;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
isOriginAllowed(serverId, origin) {
|
|
46
|
+
if (!origin) return false;
|
|
47
|
+
const allowlist = this.getEmbedAllowlist(serverId);
|
|
48
|
+
if (!allowlist.length) return true;
|
|
49
|
+
let url; try { url = new URL(origin); } catch { return false; }
|
|
50
|
+
return allowlist.some(p => {
|
|
51
|
+
if (p === '*') return true;
|
|
52
|
+
if (p.startsWith('*.')) { const s = p.slice(2); return url.hostname === s || url.hostname.endsWith('.' + s); }
|
|
53
|
+
return url.hostname === p || url.hostname === p.replace(/^https?:\/\//, '');
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
subscribe(serverId) {
|
|
58
|
+
if (this.sub) { this.pool.unsubscribe(this.sub); this.sub = null; }
|
|
59
|
+
if (!serverId) return;
|
|
60
|
+
const creator = serverId.split(':')[0];
|
|
61
|
+
if (!creator) return;
|
|
62
|
+
this.sub = 'settings-' + serverId;
|
|
63
|
+
this.pool.subscribe(this.sub,
|
|
64
|
+
[{ kinds: [30078], authors: [creator], '#d': ['zellous-settings:' + serverId] }],
|
|
65
|
+
(event) => {
|
|
66
|
+
if (event.pubkey !== creator) return;
|
|
67
|
+
try { this.store.set(serverId, JSON.parse(event.content)); this.dispatchEvent(new CustomEvent('updated', { detail: { serverId, next: this.store.get(serverId) } })); } catch {}
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export const createSettings = (opts) => new Settings(opts);
|
package/src/voice.js
ADDED
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
const ICE_SERVERS = [
|
|
2
|
+
{ urls: 'stun:stun.l.google.com:19302' },
|
|
3
|
+
{ urls: 'stun:stun1.l.google.com:19302' },
|
|
4
|
+
{ urls: 'stun:stun.cloudflare.com:3478' },
|
|
5
|
+
{ urls: 'stun:stun.nextcloud.com:443' },
|
|
6
|
+
{ urls: 'turn:openrelay.metered.ca:80', username: 'openrelayproject', credential: 'openrelayproject' },
|
|
7
|
+
{ urls: 'turn:openrelay.metered.ca:443', username: 'openrelayproject', credential: 'openrelayproject' },
|
|
8
|
+
{ urls: 'turn:openrelay.metered.ca:443?transport=tcp', username: 'openrelayproject', credential: 'openrelayproject' },
|
|
9
|
+
{ urls: 'turns:openrelay.metered.ca:443', username: 'openrelayproject', credential: 'openrelayproject' }
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
const PRESENCE_EXPIRY = 300000;
|
|
13
|
+
const HEARTBEAT = 30000;
|
|
14
|
+
const STALL_CHECK = 5000;
|
|
15
|
+
const DISCONNECT_GRACE = 8000;
|
|
16
|
+
|
|
17
|
+
const deriveRoomId = async (serverId, channel) => {
|
|
18
|
+
const h = await crypto.subtle.digest('SHA-256', new TextEncoder().encode((serverId || 'default') + ':voice:' + channel));
|
|
19
|
+
return 'zellous' + Array.from(new Uint8Array(h)).map(b => b.toString(16).padStart(2, '0')).join('').slice(0, 16);
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export class VoiceSession extends EventTarget {
|
|
23
|
+
constructor({ fsm, xstate, relayPool, auth, mediaDevices, bans = null, serverId = '', onAudioTrack = null, onVideoTrack = null }) {
|
|
24
|
+
super();
|
|
25
|
+
if (!fsm || !xstate || !relayPool || !auth || !mediaDevices) throw new Error('VoiceSession: missing deps');
|
|
26
|
+
this.fsm = fsm; this.xstate = xstate; this.pool = relayPool; this.auth = auth; this.md = mediaDevices; this.bans = bans;
|
|
27
|
+
this.serverId = serverId;
|
|
28
|
+
this.onAudioTrack = onAudioTrack; this.onVideoTrack = onVideoTrack;
|
|
29
|
+
this.actor = null;
|
|
30
|
+
this.channelName = ''; this.roomId = '';
|
|
31
|
+
this.peers = new Map(); this.participants = new Map();
|
|
32
|
+
this.localStream = null; this.cameraStream = null;
|
|
33
|
+
this.heartbeat = null; this.joinTs = 0;
|
|
34
|
+
this.muted = false; this.deafened = false;
|
|
35
|
+
this.sfu = { mode: 'mesh', hub: null, hubLostAt: null, rttMatrix: new Map(), electionTimer: null, statsInterval: null, actor: null };
|
|
36
|
+
this.retrySchedule = {};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
_initActor() {
|
|
40
|
+
this.actor = this.xstate.createActor(this.fsm.voiceMachine);
|
|
41
|
+
this.actor.subscribe((snap) => this.dispatchEvent(new CustomEvent('state', { detail: { value: snap.value } })));
|
|
42
|
+
this.actor.start();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async connect(channelName, { displayName = 'Guest' } = {}) {
|
|
46
|
+
if (!this.actor) this._initActor();
|
|
47
|
+
if (!this.actor.getSnapshot().can({ type: 'connect' })) await this.disconnect();
|
|
48
|
+
this.actor.send({ type: 'connect' });
|
|
49
|
+
this.channelName = channelName;
|
|
50
|
+
this.joinTs = Math.floor(Date.now() / 1000);
|
|
51
|
+
this.displayName = displayName;
|
|
52
|
+
try {
|
|
53
|
+
this.roomId = await deriveRoomId(this.serverId, channelName);
|
|
54
|
+
this.localStream = await this.md.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true } });
|
|
55
|
+
this.participants.clear();
|
|
56
|
+
this.participants.set('local', { identity: displayName, isSpeaking: false, isMuted: false, isLocal: true, hasVideo: false, connectionQuality: 'good' });
|
|
57
|
+
this.actor.send({ type: 'connected' });
|
|
58
|
+
this._subscribeSignals();
|
|
59
|
+
this._subscribePresence();
|
|
60
|
+
await this._publishPresence('join');
|
|
61
|
+
this._startHeartbeat();
|
|
62
|
+
this._sfuStart();
|
|
63
|
+
this._emit('connected', { roomId: this.roomId, channelName });
|
|
64
|
+
} catch (e) {
|
|
65
|
+
this.actor.send({ type: 'fail' });
|
|
66
|
+
this._emit('error', { message: 'connect failed: ' + e.message });
|
|
67
|
+
throw e;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async disconnect() {
|
|
72
|
+
if (!this.actor || this.actor.getSnapshot().matches('idle')) return;
|
|
73
|
+
if (!this.actor.getSnapshot().can({ type: 'disconnect' })) return;
|
|
74
|
+
this.actor.send({ type: 'disconnect' });
|
|
75
|
+
await this._publishPresence('leave');
|
|
76
|
+
this._stopHeartbeat();
|
|
77
|
+
this._sfuStop();
|
|
78
|
+
for (const pk of Array.from(this.peers.keys())) this._closePeer(pk);
|
|
79
|
+
this.peers.clear();
|
|
80
|
+
if (this.cameraStream) { this.cameraStream.getTracks().forEach(t => t.stop()); this.cameraStream = null; }
|
|
81
|
+
if (this.localStream) { this.localStream.getTracks().forEach(t => t.stop()); this.localStream = null; }
|
|
82
|
+
if (this.roomId) { this.pool.unsubscribe('voice-presence-' + this.roomId); this.pool.unsubscribe('voice-signals-' + this.roomId); }
|
|
83
|
+
this.participants.clear();
|
|
84
|
+
this.roomId = ''; this.channelName = '';
|
|
85
|
+
this.muted = false; this.deafened = false;
|
|
86
|
+
this.actor.send({ type: 'done' });
|
|
87
|
+
this._emit('disconnected', {});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
toggleMic() {
|
|
91
|
+
this.muted = !this.muted;
|
|
92
|
+
if (this.localStream) this.localStream.getAudioTracks().forEach(t => t.enabled = !this.muted);
|
|
93
|
+
const local = this.participants.get('local'); if (local) local.isMuted = this.muted;
|
|
94
|
+
this._emit('mic', { muted: this.muted });
|
|
95
|
+
this._emit('participants', { list: this.getParticipants() });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
toggleDeafen() {
|
|
99
|
+
this.deafened = !this.deafened;
|
|
100
|
+
for (const [, peer] of this.peers) if (peer.audioEl) peer.audioEl.muted = this.deafened;
|
|
101
|
+
this._emit('deafen', { deafened: this.deafened });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
getParticipants() { return Array.from(this.participants.values()); }
|
|
105
|
+
|
|
106
|
+
async _publishPresence(action) {
|
|
107
|
+
if (!this.auth.isLoggedIn() || !this.roomId) return;
|
|
108
|
+
const rttScores = action === 'heartbeat' ? this._rttScores() : {};
|
|
109
|
+
const signed = await this.auth.sign({
|
|
110
|
+
kind: 30078, created_at: Math.floor(Date.now() / 1000),
|
|
111
|
+
tags: [['d', 'zellous-voice:' + this.roomId], ['action', action], ['channel', this.channelName], ['server', this.serverId]],
|
|
112
|
+
content: JSON.stringify({ action, name: this.displayName, channel: this.channelName, ts: Date.now(), rttScores })
|
|
113
|
+
});
|
|
114
|
+
this.pool.publish(signed);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
_startHeartbeat() { this._stopHeartbeat(); this.heartbeat = setInterval(() => { if (this.actor?.getSnapshot().matches('connected')) this._publishPresence('heartbeat'); }, HEARTBEAT); }
|
|
118
|
+
_stopHeartbeat() { if (this.heartbeat) { clearInterval(this.heartbeat); this.heartbeat = null; } }
|
|
119
|
+
|
|
120
|
+
_subscribePresence() {
|
|
121
|
+
this.pool.subscribe('voice-presence-' + this.roomId,
|
|
122
|
+
[{ kinds: [30078], '#d': ['zellous-voice:' + this.roomId] }],
|
|
123
|
+
(event) => this._onPresence(event));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
_onPresence(event) {
|
|
127
|
+
if (event.pubkey === this.auth.pubkey) return;
|
|
128
|
+
let data; try { data = JSON.parse(event.content); } catch { return; }
|
|
129
|
+
if (Date.now() - (data.ts || 0) > PRESENCE_EXPIRY) return;
|
|
130
|
+
const shortId = 'nostr-' + event.pubkey.slice(0, 12);
|
|
131
|
+
if (data.rttScores) this.sfu.rttMatrix.set(event.pubkey, data.rttScores);
|
|
132
|
+
if (data.action === 'leave') { this.participants.delete(shortId); this._closePeer(event.pubkey); }
|
|
133
|
+
else if (!this.participants.has(shortId)) {
|
|
134
|
+
this.participants.set(shortId, { identity: data.name || event.pubkey.slice(0, 8), isSpeaking: false, isMuted: false, isLocal: false, hasVideo: false, connectionQuality: 'connecting' });
|
|
135
|
+
this._maybeConnect(event.pubkey);
|
|
136
|
+
} else if (!this.peers.has(event.pubkey)) this._maybeConnect(event.pubkey);
|
|
137
|
+
this._emit('participants', { list: this.getParticipants() });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
_subscribeSignals() {
|
|
141
|
+
this.pool.subscribe('voice-signals-' + this.roomId,
|
|
142
|
+
[{ kinds: [30078], '#p': [this.auth.pubkey], '#r': [this.roomId] }],
|
|
143
|
+
(event) => this._handleSignal(event));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
_maybeConnect(peerPubkey) {
|
|
147
|
+
if (!peerPubkey || peerPubkey === this.auth.pubkey || this.peers.has(peerPubkey)) return;
|
|
148
|
+
if (this.bans && this.serverId && (this.bans.isBanned?.(this.serverId, peerPubkey) || this.bans.isTimedOut?.(this.serverId, peerPubkey))) return;
|
|
149
|
+
this._cancelReconnect(peerPubkey);
|
|
150
|
+
const fsmActor = this.xstate.createActor(this.fsm.peerMachine);
|
|
151
|
+
fsmActor.subscribe((snap) => { const p = this.peers.get(peerPubkey); if (p) p.state = snap.value; });
|
|
152
|
+
fsmActor.start();
|
|
153
|
+
const peer = { pc: null, audioEl: null, pendingCandidates: [], bufferedCandidates: [], iceTimer: null, disconnectTimer: null, failCount: 0, state: 'new', fsm: fsmActor, _stallInterval: null, remoteDescSet: false, trackEndedRestart: false };
|
|
154
|
+
this.peers.set(peerPubkey, peer);
|
|
155
|
+
const pc = new RTCPeerConnection({ iceServers: ICE_SERVERS, bundlePolicy: 'max-bundle' });
|
|
156
|
+
peer.pc = pc;
|
|
157
|
+
const isOfferer = this.auth.pubkey > peerPubkey;
|
|
158
|
+
if (isOfferer) {
|
|
159
|
+
if (this.localStream) this.localStream.getTracks().forEach(t => pc.addTransceiver(t, { direction: 'sendrecv', streams: [this.localStream] }));
|
|
160
|
+
else pc.addTransceiver('audio', { direction: 'recvonly' });
|
|
161
|
+
}
|
|
162
|
+
this._wirePeer(peer, peerPubkey, fsmActor, isOfferer);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
_wirePeer(peer, peerPubkey, fsmActor, isOfferer) {
|
|
166
|
+
const pc = peer.pc;
|
|
167
|
+
pc.ontrack = (ev) => {
|
|
168
|
+
if (ev.track.kind === 'video') { this.onVideoTrack?.({ peerPubkey, track: ev.track, stream: ev.streams[0] }); return; }
|
|
169
|
+
this.onAudioTrack?.({ peerPubkey, track: ev.track, stream: ev.streams[0], peer });
|
|
170
|
+
try { ev.receiver.playoutDelayHint = 0.02; } catch {}
|
|
171
|
+
ev.track.onended = () => { if (peer.fsm.getSnapshot().matches('connected')) this._doIceRestart(peer, peerPubkey, fsmActor); };
|
|
172
|
+
if (!peer._stallInterval) peer._stallInterval = setInterval(() => this._checkStall(peer, peerPubkey, fsmActor), STALL_CHECK);
|
|
173
|
+
};
|
|
174
|
+
pc.onicecandidate = (ev) => {
|
|
175
|
+
if (!ev.candidate) return;
|
|
176
|
+
peer.pendingCandidates.push(ev.candidate.toJSON());
|
|
177
|
+
if (peer.iceTimer) clearTimeout(peer.iceTimer);
|
|
178
|
+
peer.iceTimer = setTimeout(() => { if (peer.pendingCandidates.length) { this._publishSignal(peerPubkey, 'ice', peer.pendingCandidates.splice(0)); peer.iceTimer = null; } }, 500);
|
|
179
|
+
};
|
|
180
|
+
pc.onicegatheringstatechange = () => { if (pc.iceGatheringState === 'complete') { if (peer.iceTimer) { clearTimeout(peer.iceTimer); peer.iceTimer = null; } if (peer.pendingCandidates.length) this._publishSignal(peerPubkey, 'ice', peer.pendingCandidates.splice(0)); } };
|
|
181
|
+
pc.onconnectionstatechange = () => {
|
|
182
|
+
if (pc.connectionState === 'connected') {
|
|
183
|
+
peer.failCount = 0; this._cancelReconnect(peerPubkey);
|
|
184
|
+
if (fsmActor.getSnapshot().can({ type: 'recv_answer' })) fsmActor.send({ type: 'recv_answer' });
|
|
185
|
+
if (peer.disconnectTimer) { clearTimeout(peer.disconnectTimer); peer.disconnectTimer = null; }
|
|
186
|
+
this._applyAudioHints(pc);
|
|
187
|
+
}
|
|
188
|
+
if (pc.connectionState === 'disconnected') { fsmActor.send({ type: 'disconnect' }); peer.disconnectTimer = setTimeout(() => this._doIceRestart(peer, peerPubkey, fsmActor), DISCONNECT_GRACE); }
|
|
189
|
+
if (pc.connectionState === 'failed') this._doIceRestart(peer, peerPubkey, fsmActor);
|
|
190
|
+
if (pc.connectionState === 'closed') { this._closePeer(peerPubkey); if (this.sfu.hub === peerPubkey) { this._sfuDissolve(); setTimeout(() => this._sfuMaybeElect(), 500); } }
|
|
191
|
+
};
|
|
192
|
+
if (isOfferer) {
|
|
193
|
+
fsmActor.send({ type: 'offer' });
|
|
194
|
+
pc.createOffer().then(o => pc.setLocalDescription(o).then(() => this._publishSignal(peerPubkey, 'offer', o))).catch(() => {});
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
_applyAudioHints(pc) {
|
|
199
|
+
try {
|
|
200
|
+
pc.getSenders().forEach(s => { if (!s.track || s.track.kind !== 'audio') return; const p = s.getParameters(); if (!p.encodings?.length) return; p.encodings[0].networkPriority = 'high'; p.encodings[0].maxBitrate = 48000; s.setParameters(p).catch(() => {}); });
|
|
201
|
+
pc.getReceivers().forEach(r => { if (r.track?.kind !== 'audio') return; try { r.playoutDelayHint = 0.02; } catch {} });
|
|
202
|
+
} catch {}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
_checkStall(peer, peerPubkey, fsmActor) {
|
|
206
|
+
if (!peer.audioEl?.srcObject || !peer.fsm.getSnapshot().matches('connected') || peer.trackEndedRestart) return;
|
|
207
|
+
const allEnded = peer.audioEl.srcObject.getTracks().every(t => t.readyState === 'ended');
|
|
208
|
+
if (!allEnded) return;
|
|
209
|
+
peer.trackEndedRestart = true;
|
|
210
|
+
this._doIceRestart(peer, peerPubkey, fsmActor);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
_doIceRestart(peer, peerPubkey, fsmActor) {
|
|
214
|
+
const pc = peer.pc;
|
|
215
|
+
if (peer.disconnectTimer) { clearTimeout(peer.disconnectTimer); peer.disconnectTimer = null; }
|
|
216
|
+
peer.failCount++;
|
|
217
|
+
if (peer.failCount <= 1 && this.auth.pubkey > peerPubkey) {
|
|
218
|
+
fsmActor.send({ type: 'restart' }); pc.restartIce();
|
|
219
|
+
pc.createOffer({ iceRestart: true }).then(o => pc.setLocalDescription(o).then(() => this._publishSignal(peerPubkey, 'offer', o))).catch(() => this._closePeer(peerPubkey));
|
|
220
|
+
} else { this._closePeer(peerPubkey); this._scheduleReconnect(peerPubkey, peer.failCount); }
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
_handleSignal(event) {
|
|
224
|
+
const from = event.pubkey; if (from === this.auth.pubkey) return;
|
|
225
|
+
let data; try { data = JSON.parse(event.content); } catch { return; }
|
|
226
|
+
if (!data?.type) return;
|
|
227
|
+
if (!this.peers.has(from)) this._maybeConnect(from);
|
|
228
|
+
const peer = this.peers.get(from); if (!peer) return;
|
|
229
|
+
const pc = peer.pc; const fsmActor = peer.fsm;
|
|
230
|
+
const addCands = (cands) => cands.forEach(c => pc.addIceCandidate(new RTCIceCandidate(c)).catch(() => {}));
|
|
231
|
+
const drainBuf = () => { addCands(peer.bufferedCandidates); peer.bufferedCandidates = []; };
|
|
232
|
+
const doAnswer = async () => {
|
|
233
|
+
if (fsmActor.getSnapshot().can({ type: 'recv_offer' })) fsmActor.send({ type: 'recv_offer' });
|
|
234
|
+
await pc.setRemoteDescription(new RTCSessionDescription(data.data));
|
|
235
|
+
peer.remoteDescSet = true; drainBuf();
|
|
236
|
+
const hasAudioTx = pc.getTransceivers().some(t => t.receiver.track?.kind === 'audio');
|
|
237
|
+
if (!hasAudioTx) pc.addTransceiver('audio', { direction: this.localStream ? 'sendrecv' : 'recvonly' });
|
|
238
|
+
if (this.localStream) { const hasSender = pc.getSenders().some(s => s.track?.kind === 'audio'); if (!hasSender) this.localStream.getTracks().forEach(t => pc.addTrack(t, this.localStream)); }
|
|
239
|
+
const a = await pc.createAnswer(); await pc.setLocalDescription(a); fsmActor.send({ type: 'sent_answer' }); this._publishSignal(from, 'answer', a);
|
|
240
|
+
};
|
|
241
|
+
if (data.type === 'offer') {
|
|
242
|
+
const polite = this.auth.pubkey < from; const collision = pc.signalingState !== 'stable';
|
|
243
|
+
if (collision && !polite) return;
|
|
244
|
+
if (collision && polite) { pc.setLocalDescription({ type: 'rollback' }).then(doAnswer).catch(() => {}); return; }
|
|
245
|
+
doAnswer().catch(() => {});
|
|
246
|
+
} else if (data.type === 'answer' && pc.signalingState === 'have-local-offer') {
|
|
247
|
+
pc.setRemoteDescription(new RTCSessionDescription(data.data)).then(() => { peer.remoteDescSet = true; drainBuf(); }).catch(() => {});
|
|
248
|
+
} else if (data.type === 'ice') {
|
|
249
|
+
const cands = Array.isArray(data.data) ? data.data : [data.data];
|
|
250
|
+
if (peer.remoteDescSet) addCands(cands); else peer.bufferedCandidates.push(...cands);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async _publishSignal(toPubkey, type, data) {
|
|
255
|
+
if (!this.auth.pubkey || !this.roomId) return;
|
|
256
|
+
const d = 'zellous-rtc:' + this.roomId + ':' + this.auth.pubkey + ':' + toPubkey + ':' + type + ':' + (type === 'ice' ? Date.now() : 'sdp');
|
|
257
|
+
const signed = await this.auth.sign({ kind: 30078, created_at: Math.floor(Date.now() / 1000), tags: [['d', d], ['p', toPubkey], ['r', this.roomId]], content: JSON.stringify({ type, data }) });
|
|
258
|
+
this.pool.publish(signed);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
_closePeer(peerPubkey) {
|
|
262
|
+
const peer = this.peers.get(peerPubkey); if (!peer) return;
|
|
263
|
+
if (peer.iceTimer) clearTimeout(peer.iceTimer);
|
|
264
|
+
if (peer.disconnectTimer) clearTimeout(peer.disconnectTimer);
|
|
265
|
+
if (peer._stallInterval) clearInterval(peer._stallInterval);
|
|
266
|
+
try { peer.pc?.close(); } catch {}
|
|
267
|
+
if (peer.audioEl) { peer.audioEl.srcObject = null; peer.audioEl.remove(); }
|
|
268
|
+
this.peers.delete(peerPubkey);
|
|
269
|
+
this._emit('peer-closed', { peerPubkey });
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
_cancelReconnect(pk) { const e = this.retrySchedule[pk]; if (e) { clearTimeout(e.timer); delete this.retrySchedule[pk]; } }
|
|
273
|
+
_scheduleReconnect(pk, attempt) {
|
|
274
|
+
const a = attempt || 0; if (a >= 6) return;
|
|
275
|
+
this._cancelReconnect(pk);
|
|
276
|
+
const timer = setTimeout(() => { delete this.retrySchedule[pk]; if (!this.peers.has(pk) && this.roomId) this._maybeConnect(pk); }, Math.min(2 ** a * 2000, 30000));
|
|
277
|
+
this.retrySchedule[pk] = { attempt: a, timer };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
_sfuStart() { this.sfu.actor = this.xstate.createActor(this.fsm.sfuMachine); this.sfu.actor.start(); this._sfuStopStats(); this.sfu.statsInterval = setInterval(() => this._sfuPoll(), 5000); }
|
|
281
|
+
_sfuStop() { this._sfuStopStats(); this.sfu.actor?.send({ type: 'dissolve' }); this.sfu.hub = null; this.sfu.rttMatrix.clear(); }
|
|
282
|
+
_sfuStopStats() { if (this.sfu.statsInterval) { clearInterval(this.sfu.statsInterval); this.sfu.statsInterval = null; } if (this.sfu.electionTimer) { clearTimeout(this.sfu.electionTimer); this.sfu.electionTimer = null; } }
|
|
283
|
+
async _sfuPoll() {
|
|
284
|
+
const scores = {}; const tasks = [];
|
|
285
|
+
for (const [pk, peer] of this.peers) {
|
|
286
|
+
if (!peer.pc || peer.pc.connectionState !== 'connected') continue;
|
|
287
|
+
tasks.push(peer.pc.getStats().then(stats => stats.forEach(r => { if (r.type === 'candidate-pair' && r.state === 'succeeded' && r.currentRoundTripTime != null) scores[pk] = Math.round(r.currentRoundTripTime * 1000); })).catch(() => {}));
|
|
288
|
+
}
|
|
289
|
+
await Promise.all(tasks);
|
|
290
|
+
this.sfu.rttMatrix.set(this.auth.pubkey, scores);
|
|
291
|
+
this._sfuMaybeElect();
|
|
292
|
+
}
|
|
293
|
+
_sfuMaybeElect() {
|
|
294
|
+
if (this.peers.size < 3) { if (this.sfu.actor?.getSnapshot().value !== 'mesh') this._sfuDissolve(); return; }
|
|
295
|
+
if (this.sfu.electionTimer) return;
|
|
296
|
+
this.sfu.electionTimer = setTimeout(() => { this.sfu.electionTimer = null; this._sfuElect(); }, 2000);
|
|
297
|
+
}
|
|
298
|
+
_sfuElect() {
|
|
299
|
+
const all = [this.auth.pubkey]; for (const pk of this.peers.keys()) all.push(pk);
|
|
300
|
+
let best = null, bestAvg = Infinity;
|
|
301
|
+
for (const pk of all) {
|
|
302
|
+
const s = this.sfu.rttMatrix.get(pk); if (!s) continue;
|
|
303
|
+
const vals = Object.values(s).filter(v => typeof v === 'number'); if (!vals.length) continue;
|
|
304
|
+
const avg = vals.reduce((a, b) => a + b, 0) / vals.length;
|
|
305
|
+
if (avg < bestAvg || (avg === bestAvg && pk > best)) { bestAvg = avg; best = pk; }
|
|
306
|
+
}
|
|
307
|
+
if (!best || best === this.sfu.hub) return;
|
|
308
|
+
this.sfu.hub = best; this.sfu.actor?.send({ type: 'elected' });
|
|
309
|
+
if (best === this.auth.pubkey) this._sfuBecomeHub(); else this._sfuRouteToHub(best);
|
|
310
|
+
}
|
|
311
|
+
_sfuBecomeHub() {
|
|
312
|
+
for (const [srcPk, srcPeer] of this.peers) {
|
|
313
|
+
if (!srcPeer.pc?.getReceivers) continue;
|
|
314
|
+
srcPeer.pc.getReceivers().forEach(recv => {
|
|
315
|
+
if (recv.track?.kind !== 'audio') return;
|
|
316
|
+
for (const [dstPk, dstPeer] of this.peers) {
|
|
317
|
+
if (dstPk === srcPk) continue;
|
|
318
|
+
const sender = dstPeer.pc?.getSenders().find(s => s.track?.kind === 'audio');
|
|
319
|
+
sender?.replaceTrack(recv.track).catch(() => {});
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
_sfuRouteToHub(hubPk) {
|
|
325
|
+
for (const pk of Array.from(this.peers.keys())) { if (pk === hubPk) continue; this._closePeer(pk); }
|
|
326
|
+
if (!this.peers.has(hubPk)) this._maybeConnect(hubPk);
|
|
327
|
+
}
|
|
328
|
+
_sfuDissolve() { this.sfu.actor?.send({ type: 'dissolve' }); this.sfu.hubLostAt = Date.now(); this.sfu.hub = null; }
|
|
329
|
+
|
|
330
|
+
_rttScores() { return this.sfu.rttMatrix.get(this.auth.pubkey) || {}; }
|
|
331
|
+
|
|
332
|
+
debug() {
|
|
333
|
+
const peers = [];
|
|
334
|
+
for (const [pk, peer] of this.peers) peers.push({ pubkey: pk.slice(0, 12), fsmState: peer.fsm?.getSnapshot().value, iceState: peer.pc?.iceConnectionState, connState: peer.pc?.connectionState, candidates: peer.pendingCandidates.length, buffered: peer.bufferedCandidates.length });
|
|
335
|
+
const rttMatrix = {}; for (const [k, v] of this.sfu.rttMatrix) rttMatrix[k.slice(0, 12)] = v;
|
|
336
|
+
return { fsm: this.actor?.getSnapshot().value, peers, participants: this.getParticipants(), sfu: { mode: this.sfu.actor?.getSnapshot().value || 'mesh', hub: this.sfu.hub?.slice(0, 12) || null, rttMatrix }, retrySchedule: this.retrySchedule };
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
heal() {
|
|
340
|
+
if (!this.roomId) return;
|
|
341
|
+
const bad = { disconnected: 1, failed: 1, closed: 1 };
|
|
342
|
+
for (const [pk, peer] of this.peers) if (bad[peer.pc?.connectionState]) { this._closePeer(pk); this._scheduleReconnect(pk, 0); }
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
_emit(t, d) { this.dispatchEvent(new CustomEvent(t, { detail: d })); }
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
export const createVoiceSession = (opts) => new VoiceSession(opts);
|
package/src/wireweave.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { createRelayPool } from './relay-pool.js';
|
|
2
|
+
import { createAuth } from './auth.js';
|
|
3
|
+
import { createFSM } from './fsm.js';
|
|
4
|
+
import { createVoiceSession } from './voice.js';
|
|
5
|
+
import { createChat } from './chat.js';
|
|
6
|
+
import { createChannels } from './channels.js';
|
|
7
|
+
import { createServers } from './servers.js';
|
|
8
|
+
import { createMessageBus } from './message.js';
|
|
9
|
+
import { createBans } from './bans.js';
|
|
10
|
+
import { createRoles } from './roles.js';
|
|
11
|
+
import { createSettings } from './settings.js';
|
|
12
|
+
import { createMedia } from './media.js';
|
|
13
|
+
import { createPages } from './pages.js';
|
|
14
|
+
import { register } from './debug.js';
|
|
15
|
+
|
|
16
|
+
export const createWireweave = ({
|
|
17
|
+
nostrTools,
|
|
18
|
+
xstate,
|
|
19
|
+
storage = (typeof localStorage !== 'undefined' ? localStorage : null),
|
|
20
|
+
extension = (typeof window !== 'undefined' ? window.nostr : null),
|
|
21
|
+
relays = ['wss://relay.damus.io', 'wss://relay.primal.net', 'wss://nos.lol', 'wss://relay.snort.social'],
|
|
22
|
+
mediaDevices = (typeof navigator !== 'undefined' ? navigator.mediaDevices : null),
|
|
23
|
+
WebSocketImpl = (typeof WebSocket !== 'undefined' ? WebSocket : null)
|
|
24
|
+
} = {}) => {
|
|
25
|
+
if (!nostrTools) throw new Error('wireweave: nostrTools required');
|
|
26
|
+
if (!xstate) throw new Error('wireweave: xstate required');
|
|
27
|
+
|
|
28
|
+
const fsm = createFSM(xstate);
|
|
29
|
+
const pool = createRelayPool({ relays, verifyEvent: nostrTools.verifyEvent, WebSocketImpl });
|
|
30
|
+
const auth = createAuth({ nostrTools, storage, extension });
|
|
31
|
+
const message = createMessageBus();
|
|
32
|
+
const bans = createBans({ relayPool: pool });
|
|
33
|
+
const roles = createRoles({ relayPool: pool, auth });
|
|
34
|
+
const settings = createSettings({ relayPool: pool, auth, roles });
|
|
35
|
+
const pages = createPages({ relayPool: pool, auth, roles });
|
|
36
|
+
const media = createMedia({ relayPool: pool, auth });
|
|
37
|
+
const channels = createChannels({ relayPool: pool, auth });
|
|
38
|
+
|
|
39
|
+
let currentChannelId = null;
|
|
40
|
+
const chat = createChat({
|
|
41
|
+
relayPool: pool, auth,
|
|
42
|
+
getChannelContext: () => ({ channelId: currentChannelId, serverId: servers.currentServerId || '' }),
|
|
43
|
+
isAdmin: (sid) => roles.isAdmin(sid)
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const servers = createServers({
|
|
47
|
+
relayPool: pool, auth, storage,
|
|
48
|
+
onSwitch: async (serverId) => {
|
|
49
|
+
roles.subscribe(serverId);
|
|
50
|
+
settings.subscribe(serverId);
|
|
51
|
+
bans.subscribe(serverId);
|
|
52
|
+
pages.subscribe(serverId);
|
|
53
|
+
if (serverId) {
|
|
54
|
+
await new Promise((resolve) => {
|
|
55
|
+
let done = false;
|
|
56
|
+
const finish = () => { if (done) return; done = true; resolve(); };
|
|
57
|
+
channels.load(serverId, finish);
|
|
58
|
+
setTimeout(finish, 3000);
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
const firstText = channels.channels.find(c => c.type === 'text');
|
|
62
|
+
if (firstText) {
|
|
63
|
+
currentChannelId = firstText.id;
|
|
64
|
+
chat.loadHistory(firstText.id);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
let voice = null;
|
|
70
|
+
const ensureVoice = ({ serverId, displayName, onAudioTrack, onVideoTrack }) => {
|
|
71
|
+
if (!voice) voice = createVoiceSession({ fsm, xstate, relayPool: pool, auth, mediaDevices, bans, serverId, onAudioTrack, onVideoTrack });
|
|
72
|
+
else voice.serverId = serverId;
|
|
73
|
+
return voice;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const setCurrentChannel = (id) => { currentChannelId = id; if (id) chat.loadHistory(id); };
|
|
77
|
+
|
|
78
|
+
const api = {
|
|
79
|
+
pool, auth, fsm, message, bans, roles, settings, pages, media, channels, servers, chat,
|
|
80
|
+
get voice() { return voice; },
|
|
81
|
+
ensureVoice,
|
|
82
|
+
setCurrentChannel,
|
|
83
|
+
get currentChannelId() { return currentChannelId; },
|
|
84
|
+
get currentServerId() { return servers.currentServerId; }
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
register('wireweave', api);
|
|
88
|
+
return api;
|
|
89
|
+
};
|