mirror of
https://github.com/stoatchat/javascript-client-sdk.git
synced 2026-07-20 01:23:33 -04:00
feat: add VoiceParticipant and voice state management
This commit is contained in:
@@ -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';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,3 +19,4 @@ export * from "./SystemMessage.js";
|
||||
export * from "./User.js";
|
||||
export * from "./MFA.js";
|
||||
export * from "./UserProfile.js";
|
||||
export * from "./VoiceParticipant.js";
|
||||
|
||||
@@ -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
@@ -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
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user