diff --git a/src/classes/Channel.ts b/src/classes/Channel.ts index bf55f25a..1ff18d38 100644 --- a/src/classes/Channel.ts +++ b/src/classes/Channel.ts @@ -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 = {}; + voiceParticipants = new ReactiveMap(); + /** * 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'; } /** diff --git a/src/classes/VoiceParticipant.ts b/src/classes/VoiceParticipant.ts new file mode 100644 index 00000000..b97172ac --- /dev/null +++ b/src/classes/VoiceParticipant.ts @@ -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; + readonly isPublishing: Accessor; + readonly isScreensharing: Accessor; + readonly isCamera: Accessor; + + #setReceiving: Setter; + #setPublishing: Setter; + #setScreensharing: Setter; + #setCamera: Setter; + + /** + * 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) { + 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); + } + } +} diff --git a/src/classes/index.ts b/src/classes/index.ts index 9bd7e417..07c8af39 100644 --- a/src/classes/index.ts +++ b/src/classes/index.ts @@ -19,3 +19,4 @@ export * from "./SystemMessage.js"; export * from "./User.js"; export * from "./MFA.js"; export * from "./UserProfile.js"; +export * from "./VoiceParticipant.js"; diff --git a/src/events/EventClient.ts b/src/events/EventClient.ts index 93deebaa..e6f37f12 100644 --- a/src/events/EventClient.ts +++ b/src/events/EventClient.ts @@ -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(() => { diff --git a/src/events/v1.ts b/src/events/v1.ts index 282cb4ca..9f4239e1 100644 --- a/src/events/v1.ts +++ b/src/events/v1.ts @@ -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; + } + | { + 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; + 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; + } } } diff --git a/src/hydration/channel.ts b/src/hydration/channel.ts index 26895d35..ed7c2a92 100644 --- a/src/hydration/channel.ts +++ b/src/hydration/channel.ts @@ -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; + defaultPermissions?: { a: bigint; d: bigint }; + rolePermissions?: Record; nsfw: boolean; lastMessageId?: string; - voice: boolean; + voice?: { maxUsers?: number }; }; export const channelHydration: Hydrate, HydratedChannel> = { @@ -62,19 +64,22 @@ export const channelHydration: Hydrate, 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(),