feat: add VoiceParticipant and voice state management

This commit is contained in:
izzy
2025-10-22 20:25:58 +01:00
parent f89ed331d2
commit 2a4dac552c
6 changed files with 229 additions and 23 deletions
+5 -1
View File
@@ -29,6 +29,8 @@ import type { Message } from "./Message.js";
import type { Server } from "./Server.js";
import type { ServerMember } from "./ServerMember.js";
import type { User } from "./User.js";
import { ReactiveMap } from "@solid-primitives/map";
import { VoiceParticipant } from "./VoiceParticipant.js";
/**
* Channel Class
@@ -39,6 +41,8 @@ export class Channel {
_typingTimers: Record<string, number> = {};
voiceParticipants = new ReactiveMap<string, VoiceParticipant>();
/**
* Construct Channel
* @param collection Collection
@@ -326,7 +330,7 @@ export class Channel {
* NB. subject to change as vc(2) goes to production
*/
get isVoice(): boolean {
return this.type === 'Group' || this.type === 'DirectMessage' || this.#collection.getUnderlyingObject(this.id).voice;
return typeof this.#collection.getUnderlyingObject(this.id).voice === 'object';
}
/**
+72
View File
@@ -0,0 +1,72 @@
import { Accessor, createSignal, Setter } from "solid-js";
import type { Client } from "../Client.js";
import { UserVoiceState } from "../events/v1.js";
/**
* Voice Participant
*/
export class VoiceParticipant {
protected client: Client;
readonly userId: string;
readonly joinedAt: Date;
readonly isReceiving: Accessor<boolean>;
readonly isPublishing: Accessor<boolean>;
readonly isScreensharing: Accessor<boolean>;
readonly isCamera: Accessor<boolean>;
#setReceiving: Setter<boolean>;
#setPublishing: Setter<boolean>;
#setScreensharing: Setter<boolean>;
#setCamera: Setter<boolean>;
/**
* Construct Server Ban
* @param client Client
* @param data Data
*/
constructor(client: Client, data: UserVoiceState) {
this.client = client;
this.userId = data.id;
this.joinedAt = new Date(data.joined_at);
const [isReceiving, setReceiving] = createSignal(data.is_receiving);
this.isReceiving = isReceiving;
this.#setReceiving = setReceiving;
const [isPublishing, setPublishing] = createSignal(data.is_publishing);
this.isPublishing = isPublishing;
this.#setPublishing = setPublishing;
const [isScreensharing, setScreensharing] = createSignal(data.screensharing);
this.isScreensharing = isScreensharing;
this.#setScreensharing = setScreensharing;
const [isCamera, setCamera] = createSignal(data.camera);
this.isCamera = isCamera;
this.#setCamera = setCamera;
}
/**
* Update the state
* @param data Data
*/
update(data: Partial<UserVoiceState>) {
if (typeof data.is_receiving === 'boolean') {
this.#setReceiving(data.is_receiving);
}
if (typeof data.is_publishing === 'boolean') {
this.#setPublishing(data.is_publishing);
}
if (typeof data.screensharing === 'boolean') {
this.#setScreensharing(data.screensharing);
}
if (typeof data.camera === 'boolean') {
this.#setCamera(data.camera);
}
}
}
+1
View File
@@ -19,3 +19,4 @@ export * from "./SystemMessage.js";
export * from "./User.js";
export * from "./MFA.js";
export * from "./UserProfile.js";
export * from "./VoiceParticipant.js";
+21 -5
View File
@@ -2,12 +2,11 @@ import type { Accessor, Setter } from "solid-js";
import { createSignal } from "solid-js";
import { AsyncEventEmitter } from "@vladfrangu/async_event_emitter";
import { JSONParse, JSONStringify } from "json-with-bigint";
import type { Error } from "stoat-api";
import type { ProtocolV1 } from "./v1.js";
import { JSONParse, JSONStringify } from 'json-with-bigint';
/**
* Available protocols to connect with
*/
@@ -157,9 +156,26 @@ export class EventClient<
this.options.pongTimeout * 1e3,
) as never;
this.#socket = new WebSocket(
`${uri}?version=${this.#protocolVersion}&format=${this.#transportFormat}&token=${token}`,
);
const url = new URL(uri);
url.searchParams.set("version", this.#protocolVersion.toString());
url.searchParams.set("format", this.#transportFormat);
url.searchParams.set("token", token);
// todo: pass-through ts as a configuration option
// todo: then remove /settings/fetch from web client
// todo: do the same for unreads
// url.searchParams.append("ready", "users");
// url.searchParams.append("ready", "servers");
// url.searchParams.append("ready", "channels");
// url.searchParams.append("ready", "members");
// url.searchParams.append("ready", "emojis");
// url.searchParams.append("ready", "voice_states");
// url.searchParams.append("ready", "user_settings[ordering]");
// url.searchParams.append("ready", "user_settings[notifications]");
// url.searchParams.append("ready", "unreads or something");
// url.searchParams.append("ready", "policy_changes");
this.#socket = new WebSocket(url);
this.#socket.onopen = () => {
this.#heartbeatIntervalReference = setInterval(() => {
+110 -2
View File
@@ -4,6 +4,7 @@ import { batch } from "solid-js";
import { ReactiveSet } from "@solid-primitives/set";
import type {
Channel,
ChannelUnread,
Emoji,
Error,
FieldsChannel,
@@ -22,6 +23,7 @@ import type {
import type { Client } from "../Client.js";
import { MessageEmbed } from "../classes/MessageEmbed.js";
import { ServerRole } from "../classes/ServerRole.js";
import { VoiceParticipant } from "../classes/VoiceParticipant.js";
import { hydrate } from "../hydration/index.js";
/**
@@ -173,7 +175,35 @@ type ServerMessage =
user_id: string;
exclude_session_id: string;
}
));
))
| {
type: "VoiceChannelJoin";
id: string;
state: UserVoiceState;
}
| {
type: "VoiceChannelLeave";
id: string;
user: string;
}
| {
type: "VoiceChannelMove";
user: string;
from: string;
to: string;
state: UserVoiceState;
}
| {
type: "UserVoiceStateUpdate";
id: string;
channel_id: string;
data: Partial<UserVoiceState>;
}
| {
type: "UserMoveVoiceChannel";
node: string;
token: string;
};
/**
* Policy change type
@@ -185,6 +215,26 @@ type PolicyChange = {
url: string;
};
/**
* Voice state for a user
*/
export type UserVoiceState = {
id: string;
joined_at: number;
is_receiving: boolean;
is_publishing: boolean;
screensharing: boolean;
camera: boolean;
};
/**
* Voice state for a channel
*/
type ChannelVoiceState = {
id: string;
participants: UserVoiceState[];
};
/**
* Initial synchronisation packet
*/
@@ -194,6 +244,11 @@ type ReadyData = {
channels: Channel[];
members: Member[];
emojis: Emoji[];
voice_states: ChannelVoiceState[];
user_settings: Record<string, unknown>;
channel_unreads: ChannelUnread[];
policy_changes: PolicyChange[];
};
@@ -241,6 +296,20 @@ export async function handleEvent(
client.channels.getOrCreate(channel._id, channel);
}
for (const state of event.voice_states) {
const channel = client.channels.get(state.id);
if (channel) {
channel.voiceParticipants.clear();
for (const participant of state.participants) {
channel.voiceParticipants.set(
participant.id,
new VoiceParticipant(client, participant),
);
}
}
}
for (const emoji of event.emojis) {
client.emojis.getOrCreate(emoji._id, emoji);
}
@@ -280,7 +349,11 @@ export async function handleEvent(
const channel = client.channels.get(event.channel);
if (!channel) return;
client.channels.updateUnderlyingObject(channel.id, "lastMessageId", event._id);
client.channels.updateUnderlyingObject(
channel.id,
"lastMessageId",
event._id,
);
if (
event.mentions?.includes(client.user!.id) &&
@@ -865,5 +938,40 @@ export async function handleEvent(
// TODO: implement DeleteSession and DeleteAllSessions
break;
}
case "VoiceChannelJoin": {
const channel = client.channels.getOrPartial(event.id);
if (channel) {
channel.voiceParticipants.set(
event.state.id,
new VoiceParticipant(client, event.state),
);
// todo: event
}
break;
}
case "VoiceChannelLeave": {
const channel = client.channels.getOrPartial(event.id);
if (channel) {
channel.voiceParticipants.delete(event.user);
// todo: event
}
break;
}
case "VoiceChannelMove": {
// todo
break;
}
case "UserVoiceStateUpdate": {
const channel = client.channels.getOrPartial(event.channel_id);
if (channel) {
channel.voiceParticipants.get(event.id)?.update(event.data);
// todo: event
}
break;
}
case "UserMoveVoiceChannel": {
// todo
break;
}
}
}
+20 -15
View File
@@ -1,11 +1,13 @@
import { ReactiveSet } from "@solid-primitives/set";
import type { Channel as APIChannel, OverrideField } from "stoat-api";
import type { Channel as APIChannel } from "stoat-api";
import type { Client } from "../Client.js";
import { File } from "../classes/File.js";
import type { Merge } from "../lib/merge.js";
import type { Hydrate } from "./index.js";
import { VoiceParticipant } from "../classes/VoiceParticipant.js";
import { ReactiveMap } from "@solid-primitives/map";
export type HydratedChannel = {
id: string;
@@ -24,13 +26,13 @@ export type HydratedChannel = {
serverId?: string;
permissions?: bigint;
defaultPermissions?: { a: bigint, d: bigint };
rolePermissions?: Record<string, { a: bigint, d: bigint }>;
defaultPermissions?: { a: bigint; d: bigint };
rolePermissions?: Record<string, { a: bigint; d: bigint }>;
nsfw: boolean;
lastMessageId?: string;
voice: boolean;
voice?: { maxUsers?: number };
};
export const channelHydration: Hydrate<Merge<APIChannel>, HydratedChannel> = {
@@ -62,19 +64,22 @@ export const channelHydration: Hydrate<Merge<APIChannel>, HydratedChannel> = {
a: BigInt(channel.default_permissions?.a ?? 0),
d: BigInt(channel.default_permissions?.d ?? 0),
}),
rolePermissions: (channel) => Object.fromEntries(
Object.entries(channel.role_permissions ?? {})
.map(([k, v]) => [k, {
a: BigInt(v.a),
d: BigInt(v.d)
}])
),
rolePermissions: (channel) =>
Object.fromEntries(
Object.entries(channel.role_permissions ?? {}).map(([k, v]) => [
k,
{
a: BigInt(v.a),
d: BigInt(v.d),
},
]),
),
nsfw: (channel) => channel.nsfw || false,
lastMessageId: (channel) => channel.last_message_id!,
voice: (channel) => {
console.info(channel);
return typeof (channel as never as { voice: object }).voice === "object";
},
voice: (channel) =>
!!channel.voice || channel.channel_type === 'DirectMessage' || channel.channel_type === 'Group' ? ({
maxUsers: channel.voice?.max_users || undefined,
}) : undefined,
},
initialHydration: () => ({
typingIds: new ReactiveSet(),