Skip to content

Commit 2a4dac5

Browse files
committed
feat: add VoiceParticipant and voice state management
1 parent f89ed33 commit 2a4dac5

File tree

6 files changed

+229
-23
lines changed

6 files changed

+229
-23
lines changed

src/classes/Channel.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ import type { Message } from "./Message.js";
2929
import type { Server } from "./Server.js";
3030
import type { ServerMember } from "./ServerMember.js";
3131
import type { User } from "./User.js";
32+
import { ReactiveMap } from "@solid-primitives/map";
33+
import { VoiceParticipant } from "./VoiceParticipant.js";
3234

3335
/**
3436
* Channel Class
@@ -39,6 +41,8 @@ export class Channel {
3941

4042
_typingTimers: Record<string, number> = {};
4143

44+
voiceParticipants = new ReactiveMap<string, VoiceParticipant>();
45+
4246
/**
4347
* Construct Channel
4448
* @param collection Collection
@@ -326,7 +330,7 @@ export class Channel {
326330
* NB. subject to change as vc(2) goes to production
327331
*/
328332
get isVoice(): boolean {
329-
return this.type === 'Group' || this.type === 'DirectMessage' || this.#collection.getUnderlyingObject(this.id).voice;
333+
return typeof this.#collection.getUnderlyingObject(this.id).voice === 'object';
330334
}
331335

332336
/**

src/classes/VoiceParticipant.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
2+
import { Accessor, createSignal, Setter } from "solid-js";
3+
import type { Client } from "../Client.js";
4+
import { UserVoiceState } from "../events/v1.js";
5+
6+
/**
7+
* Voice Participant
8+
*/
9+
export class VoiceParticipant {
10+
protected client: Client;
11+
readonly userId: string;
12+
readonly joinedAt: Date;
13+
14+
readonly isReceiving: Accessor<boolean>;
15+
readonly isPublishing: Accessor<boolean>;
16+
readonly isScreensharing: Accessor<boolean>;
17+
readonly isCamera: Accessor<boolean>;
18+
19+
#setReceiving: Setter<boolean>;
20+
#setPublishing: Setter<boolean>;
21+
#setScreensharing: Setter<boolean>;
22+
#setCamera: Setter<boolean>;
23+
24+
/**
25+
* Construct Server Ban
26+
* @param client Client
27+
* @param data Data
28+
*/
29+
constructor(client: Client, data: UserVoiceState) {
30+
this.client = client;
31+
this.userId = data.id;
32+
this.joinedAt = new Date(data.joined_at);
33+
34+
const [isReceiving, setReceiving] = createSignal(data.is_receiving);
35+
this.isReceiving = isReceiving;
36+
this.#setReceiving = setReceiving;
37+
38+
const [isPublishing, setPublishing] = createSignal(data.is_publishing);
39+
this.isPublishing = isPublishing;
40+
this.#setPublishing = setPublishing;
41+
42+
const [isScreensharing, setScreensharing] = createSignal(data.screensharing);
43+
this.isScreensharing = isScreensharing;
44+
this.#setScreensharing = setScreensharing;
45+
46+
const [isCamera, setCamera] = createSignal(data.camera);
47+
this.isCamera = isCamera;
48+
this.#setCamera = setCamera;
49+
}
50+
51+
/**
52+
* Update the state
53+
* @param data Data
54+
*/
55+
update(data: Partial<UserVoiceState>) {
56+
if (typeof data.is_receiving === 'boolean') {
57+
this.#setReceiving(data.is_receiving);
58+
}
59+
60+
if (typeof data.is_publishing === 'boolean') {
61+
this.#setPublishing(data.is_publishing);
62+
}
63+
64+
if (typeof data.screensharing === 'boolean') {
65+
this.#setScreensharing(data.screensharing);
66+
}
67+
68+
if (typeof data.camera === 'boolean') {
69+
this.#setCamera(data.camera);
70+
}
71+
}
72+
}

src/classes/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,4 @@ export * from "./SystemMessage.js";
1919
export * from "./User.js";
2020
export * from "./MFA.js";
2121
export * from "./UserProfile.js";
22+
export * from "./VoiceParticipant.js";

src/events/EventClient.ts

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,11 @@ import type { Accessor, Setter } from "solid-js";
22
import { createSignal } from "solid-js";
33

44
import { AsyncEventEmitter } from "@vladfrangu/async_event_emitter";
5+
import { JSONParse, JSONStringify } from "json-with-bigint";
56
import type { Error } from "stoat-api";
67

78
import type { ProtocolV1 } from "./v1.js";
89

9-
import { JSONParse, JSONStringify } from 'json-with-bigint';
10-
1110
/**
1211
* Available protocols to connect with
1312
*/
@@ -157,9 +156,26 @@ export class EventClient<
157156
this.options.pongTimeout * 1e3,
158157
) as never;
159158

160-
this.#socket = new WebSocket(
161-
`${uri}?version=${this.#protocolVersion}&format=${this.#transportFormat}&token=${token}`,
162-
);
159+
const url = new URL(uri);
160+
url.searchParams.set("version", this.#protocolVersion.toString());
161+
url.searchParams.set("format", this.#transportFormat);
162+
url.searchParams.set("token", token);
163+
164+
// todo: pass-through ts as a configuration option
165+
// todo: then remove /settings/fetch from web client
166+
// todo: do the same for unreads
167+
// url.searchParams.append("ready", "users");
168+
// url.searchParams.append("ready", "servers");
169+
// url.searchParams.append("ready", "channels");
170+
// url.searchParams.append("ready", "members");
171+
// url.searchParams.append("ready", "emojis");
172+
// url.searchParams.append("ready", "voice_states");
173+
// url.searchParams.append("ready", "user_settings[ordering]");
174+
// url.searchParams.append("ready", "user_settings[notifications]");
175+
// url.searchParams.append("ready", "unreads or something");
176+
// url.searchParams.append("ready", "policy_changes");
177+
178+
this.#socket = new WebSocket(url);
163179

164180
this.#socket.onopen = () => {
165181
this.#heartbeatIntervalReference = setInterval(() => {

src/events/v1.ts

Lines changed: 110 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { batch } from "solid-js";
44
import { ReactiveSet } from "@solid-primitives/set";
55
import type {
66
Channel,
7+
ChannelUnread,
78
Emoji,
89
Error,
910
FieldsChannel,
@@ -22,6 +23,7 @@ import type {
2223
import type { Client } from "../Client.js";
2324
import { MessageEmbed } from "../classes/MessageEmbed.js";
2425
import { ServerRole } from "../classes/ServerRole.js";
26+
import { VoiceParticipant } from "../classes/VoiceParticipant.js";
2527
import { hydrate } from "../hydration/index.js";
2628

2729
/**
@@ -173,7 +175,35 @@ type ServerMessage =
173175
user_id: string;
174176
exclude_session_id: string;
175177
}
176-
));
178+
))
179+
| {
180+
type: "VoiceChannelJoin";
181+
id: string;
182+
state: UserVoiceState;
183+
}
184+
| {
185+
type: "VoiceChannelLeave";
186+
id: string;
187+
user: string;
188+
}
189+
| {
190+
type: "VoiceChannelMove";
191+
user: string;
192+
from: string;
193+
to: string;
194+
state: UserVoiceState;
195+
}
196+
| {
197+
type: "UserVoiceStateUpdate";
198+
id: string;
199+
channel_id: string;
200+
data: Partial<UserVoiceState>;
201+
}
202+
| {
203+
type: "UserMoveVoiceChannel";
204+
node: string;
205+
token: string;
206+
};
177207

178208
/**
179209
* Policy change type
@@ -185,6 +215,26 @@ type PolicyChange = {
185215
url: string;
186216
};
187217

218+
/**
219+
* Voice state for a user
220+
*/
221+
export type UserVoiceState = {
222+
id: string;
223+
joined_at: number;
224+
is_receiving: boolean;
225+
is_publishing: boolean;
226+
screensharing: boolean;
227+
camera: boolean;
228+
};
229+
230+
/**
231+
* Voice state for a channel
232+
*/
233+
type ChannelVoiceState = {
234+
id: string;
235+
participants: UserVoiceState[];
236+
};
237+
188238
/**
189239
* Initial synchronisation packet
190240
*/
@@ -194,6 +244,11 @@ type ReadyData = {
194244
channels: Channel[];
195245
members: Member[];
196246
emojis: Emoji[];
247+
voice_states: ChannelVoiceState[];
248+
249+
user_settings: Record<string, unknown>;
250+
channel_unreads: ChannelUnread[];
251+
197252
policy_changes: PolicyChange[];
198253
};
199254

@@ -241,6 +296,20 @@ export async function handleEvent(
241296
client.channels.getOrCreate(channel._id, channel);
242297
}
243298

299+
for (const state of event.voice_states) {
300+
const channel = client.channels.get(state.id);
301+
if (channel) {
302+
channel.voiceParticipants.clear();
303+
304+
for (const participant of state.participants) {
305+
channel.voiceParticipants.set(
306+
participant.id,
307+
new VoiceParticipant(client, participant),
308+
);
309+
}
310+
}
311+
}
312+
244313
for (const emoji of event.emojis) {
245314
client.emojis.getOrCreate(emoji._id, emoji);
246315
}
@@ -280,7 +349,11 @@ export async function handleEvent(
280349
const channel = client.channels.get(event.channel);
281350
if (!channel) return;
282351

283-
client.channels.updateUnderlyingObject(channel.id, "lastMessageId", event._id);
352+
client.channels.updateUnderlyingObject(
353+
channel.id,
354+
"lastMessageId",
355+
event._id,
356+
);
284357

285358
if (
286359
event.mentions?.includes(client.user!.id) &&
@@ -865,5 +938,40 @@ export async function handleEvent(
865938
// TODO: implement DeleteSession and DeleteAllSessions
866939
break;
867940
}
941+
case "VoiceChannelJoin": {
942+
const channel = client.channels.getOrPartial(event.id);
943+
if (channel) {
944+
channel.voiceParticipants.set(
945+
event.state.id,
946+
new VoiceParticipant(client, event.state),
947+
);
948+
// todo: event
949+
}
950+
break;
951+
}
952+
case "VoiceChannelLeave": {
953+
const channel = client.channels.getOrPartial(event.id);
954+
if (channel) {
955+
channel.voiceParticipants.delete(event.user);
956+
// todo: event
957+
}
958+
break;
959+
}
960+
case "VoiceChannelMove": {
961+
// todo
962+
break;
963+
}
964+
case "UserVoiceStateUpdate": {
965+
const channel = client.channels.getOrPartial(event.channel_id);
966+
if (channel) {
967+
channel.voiceParticipants.get(event.id)?.update(event.data);
968+
// todo: event
969+
}
970+
break;
971+
}
972+
case "UserMoveVoiceChannel": {
973+
// todo
974+
break;
975+
}
868976
}
869977
}

src/hydration/channel.ts

Lines changed: 20 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import { ReactiveSet } from "@solid-primitives/set";
2-
import type { Channel as APIChannel, OverrideField } from "stoat-api";
2+
import type { Channel as APIChannel } from "stoat-api";
33

44
import type { Client } from "../Client.js";
55
import { File } from "../classes/File.js";
66
import type { Merge } from "../lib/merge.js";
77

88
import type { Hydrate } from "./index.js";
9+
import { VoiceParticipant } from "../classes/VoiceParticipant.js";
10+
import { ReactiveMap } from "@solid-primitives/map";
911

1012
export type HydratedChannel = {
1113
id: string;
@@ -24,13 +26,13 @@ export type HydratedChannel = {
2426
serverId?: string;
2527

2628
permissions?: bigint;
27-
defaultPermissions?: { a: bigint, d: bigint };
28-
rolePermissions?: Record<string, { a: bigint, d: bigint }>;
29+
defaultPermissions?: { a: bigint; d: bigint };
30+
rolePermissions?: Record<string, { a: bigint; d: bigint }>;
2931
nsfw: boolean;
3032

3133
lastMessageId?: string;
3234

33-
voice: boolean;
35+
voice?: { maxUsers?: number };
3436
};
3537

3638
export const channelHydration: Hydrate<Merge<APIChannel>, HydratedChannel> = {
@@ -62,19 +64,22 @@ export const channelHydration: Hydrate<Merge<APIChannel>, HydratedChannel> = {
6264
a: BigInt(channel.default_permissions?.a ?? 0),
6365
d: BigInt(channel.default_permissions?.d ?? 0),
6466
}),
65-
rolePermissions: (channel) => Object.fromEntries(
66-
Object.entries(channel.role_permissions ?? {})
67-
.map(([k, v]) => [k, {
68-
a: BigInt(v.a),
69-
d: BigInt(v.d)
70-
}])
71-
),
67+
rolePermissions: (channel) =>
68+
Object.fromEntries(
69+
Object.entries(channel.role_permissions ?? {}).map(([k, v]) => [
70+
k,
71+
{
72+
a: BigInt(v.a),
73+
d: BigInt(v.d),
74+
},
75+
]),
76+
),
7277
nsfw: (channel) => channel.nsfw || false,
7378
lastMessageId: (channel) => channel.last_message_id!,
74-
voice: (channel) => {
75-
console.info(channel);
76-
return typeof (channel as never as { voice: object }).voice === "object";
77-
},
79+
voice: (channel) =>
80+
!!channel.voice || channel.channel_type === 'DirectMessage' || channel.channel_type === 'Group' ? ({
81+
maxUsers: channel.voice?.max_users || undefined,
82+
}) : undefined,
7883
},
7984
initialHydration: () => ({
8085
typingIds: new ReactiveSet(),

0 commit comments

Comments
 (0)