fix(voice): consistent state on DM-call ↔ space-channel transitions
Two mirror-image bugs from voice/DM-call transitions leaving stale state. DM call → space channel (stuck "Connecting…"): The last participant to leave a DM call for a space channel receives a `dm_call_ended` echo (server empties the DM room on their `voice_join`). The handlers called `disconnectFn()` unconditionally, tearing down the space room they had just connected to. Route `dm_call_ended` / `dm_call_rejected` / terminal `dm_call_undeliverable` through a new `teardownDmCall()` that only disconnects LiveKit when not in a space channel (`currentVoiceChannelId` null). Space channel → DM call (still shown as "in" the voice channel): 1. Entering a DM call never cleared `currentVoiceChannelId`, so `VoiceChannel` mapped the DM call's live LiveKit participants onto the old space channel. Add `clearSpaceVoiceForDmCall()`, called in `connect()` when `isDm`, restoring the invariant that a DM call has no `currentVoiceChannelId`. 2. `dm_call_accepted` gated the caller's connect on `!isLiveKitConnected`, so a caller already in a space channel was never connected to the DM room. Gate on `wasOutgoingCall` only (connect() de-dupes same-room). Tests: teardownDmCall.test.ts, clearSpaceVoiceForDmCall.test.ts. Docs: docs/systems/voice.md.
This commit is contained in:
@@ -169,6 +169,12 @@ When `findOrCreateDmChannel` creates a local DM channel during an active federat
|
|||||||
|
|
||||||
**Passive ready handler:** On page refresh/restart, the ready payload includes active calls but the client does NOT auto-connect to LiveKit. Users must re-accept. This prevents identity slot wars when the same user has multiple sessions.
|
**Passive ready handler:** On page refresh/restart, the ready payload includes active calls but the client does NOT auto-connect to LiveKit. Users must re-accept. This prevents identity slot wars when the same user has multiple sessions.
|
||||||
|
|
||||||
|
**A DM call has no `currentVoiceChannelId` (space↔DM are mutually exclusive).** Entering a space channel clears `activeDmCall` (`setCurrentVoiceChannel`); entering a DM call must clear `currentVoiceChannelId`. The latter is done by `clearSpaceVoiceForDmCall()` (`utils/voice.ts`), invoked synchronously at the top of `connect()` when `isDm`. Without it, `VoiceChannel` renders the occupant list for `currentVoiceChannelId` from the **live LiveKit participants**, so a lingering space `currentVoiceChannelId` maps the DM call's participants onto the old space channel — the caller/acceptor appears to still be sitting in it. The server already drops the user from the space room (`dm_call_start` / `dm_call_accept` → `leaveCurrentRoom` → `broadcastRoomLeave`), so this is a client-state fix; it also optimistically removes self from the old channel's `voiceUsers` for an immediate sidebar update. Regression test: `utils/clearSpaceVoiceForDmCall.test.ts`.
|
||||||
|
|
||||||
|
**Caller connect guard.** In `dm_call_accepted`, the caller connects to the DM room gated on `wasOutgoingCall` (only the initiating session ever sets `outgoingCall`) — **not** on `!isLiveKitConnected`. A caller already sitting in a space voice channel is LiveKit-connected; gating on that would skip the DM connect and strand them in the space channel. `connect()` de-dupes an already-connected same room, so `wasOutgoingCall` alone is sufficient.
|
||||||
|
|
||||||
|
**DM-call teardown never disconnects a space connection (`teardownDmCall`).** The `dm_call_ended` / `dm_call_rejected` / terminal `dm_call_undeliverable` handlers all route through `teardownDmCall()` (`useWebSocket.ts`), which clears the call UI/federation state and tears down LiveKit **only when `currentVoiceChannelId` is null**. `disconnectFn()` tears down whatever room is active, and a space channel and a DM call are mutually exclusive (`setCurrentVoiceChannel` clears `activeDmCall`). The load-bearing case: when the **last** participant in a DM call joins a space voice channel, their post-connect `voice_join` empties the server-side DM room, so `broadcastRoomLeave` (`events.ts`) broadcasts `dm_call_ended` back to every DM member — including them. Without the guard, that echo would `disconnectFn()` the space room they just connected to, stranding the UI on "Connecting…" until a manual rejoin. The first participant to leave is unaffected (room still occupied → no `dm_call_ended`). Regression test: `hooks/teardownDmCall.test.ts`.
|
||||||
|
|
||||||
### SoundController Federation Awareness
|
### SoundController Federation Awareness
|
||||||
|
|
||||||
The `SoundController` uses `isSelf(id)` which checks against BOTH `currentUser.id` (local snowflake) and `currentUser.homeUserId` (federated home ID). In federated calls, `updateParticipants` resolves identity to the local snowflake when `activeDmCall` is set, but reverts to raw `homeUserId` when it's cleared during disconnect. Both formats must be recognized as "self" to prevent phantom join/leave sounds.
|
The `SoundController` uses `isSelf(id)` which checks against BOTH `currentUser.id` (local snowflake) and `currentUser.homeUserId` (federated home ID). In federated calls, `updateParticipants` resolves identity to the local snowflake when `activeDmCall` is set, but reverts to raw `homeUserId` when it's cleared during disconnect. Both formats must be recognized as "self" to prevent phantom join/leave sounds.
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
|
||||||
|
// Stub heavy import leaves so useWebSocket can be imported in jsdom without
|
||||||
|
// pulling in the LiveKit SDK / AudioWorklet graph. teardownDmCall only touches
|
||||||
|
// voiceStore, which we exercise for real.
|
||||||
|
vi.mock('../audio/AudioManager', () => ({
|
||||||
|
AudioManager: { getInstance: () => ({}) },
|
||||||
|
}));
|
||||||
|
vi.mock('./useLiveKit', () => ({
|
||||||
|
getActiveRoom: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { teardownDmCall } from './useWebSocket';
|
||||||
|
import { useVoiceStore } from '../stores/voiceStore';
|
||||||
|
|
||||||
|
describe('teardownDmCall', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useVoiceStore.setState({
|
||||||
|
currentVoiceChannelId: null,
|
||||||
|
activeDmCall: null,
|
||||||
|
incomingCall: null,
|
||||||
|
outgoingCall: null,
|
||||||
|
federatedCallToken: null,
|
||||||
|
federatedCallUrl: null,
|
||||||
|
federatedCallId: null,
|
||||||
|
callOrigin: null,
|
||||||
|
disconnectFn: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Regression: the last participant to leave a DM call for a space voice
|
||||||
|
// channel receives a `dm_call_ended` echo (the server emptied the DM room).
|
||||||
|
// teardownDmCall must NOT tear down the space connection that was just
|
||||||
|
// established — otherwise the UI is stranded on "Connecting…".
|
||||||
|
it('does NOT disconnect when the user has joined a space voice channel', () => {
|
||||||
|
const disconnectFn = vi.fn().mockResolvedValue(undefined);
|
||||||
|
useVoiceStore.setState({
|
||||||
|
currentVoiceChannelId: 'space-voice-1',
|
||||||
|
activeDmCall: null,
|
||||||
|
disconnectFn,
|
||||||
|
});
|
||||||
|
|
||||||
|
teardownDmCall();
|
||||||
|
|
||||||
|
expect(disconnectFn).not.toHaveBeenCalled();
|
||||||
|
// The space voice intent is preserved.
|
||||||
|
expect(useVoiceStore.getState().currentVoiceChannelId).toBe('space-voice-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DOES disconnect when still in the DM call (no space channel)', () => {
|
||||||
|
const disconnectFn = vi.fn().mockResolvedValue(undefined);
|
||||||
|
useVoiceStore.setState({
|
||||||
|
currentVoiceChannelId: null,
|
||||||
|
activeDmCall: { dmChannelId: 'dm-1' },
|
||||||
|
disconnectFn,
|
||||||
|
});
|
||||||
|
|
||||||
|
teardownDmCall();
|
||||||
|
|
||||||
|
expect(disconnectFn).toHaveBeenCalledTimes(1);
|
||||||
|
// DM call state is cleared.
|
||||||
|
expect(useVoiceStore.getState().activeDmCall).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears residual incoming/outgoing/federated call state regardless', () => {
|
||||||
|
const disconnectFn = vi.fn().mockResolvedValue(undefined);
|
||||||
|
useVoiceStore.setState({
|
||||||
|
currentVoiceChannelId: 'space-voice-1',
|
||||||
|
incomingCall: { dmChannelId: 'dm-2', callerId: 'u9', callerName: 'Nine' },
|
||||||
|
outgoingCall: { dmChannelId: 'dm-3' },
|
||||||
|
federatedCallId: 'fed-1',
|
||||||
|
disconnectFn,
|
||||||
|
});
|
||||||
|
|
||||||
|
teardownDmCall();
|
||||||
|
|
||||||
|
const s = useVoiceStore.getState();
|
||||||
|
expect(s.incomingCall).toBeNull();
|
||||||
|
expect(s.outgoingCall).toBeNull();
|
||||||
|
expect(s.federatedCallId).toBeNull();
|
||||||
|
expect(disconnectFn).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -19,7 +19,7 @@ import { useVoiceStore } from '../stores/voiceStore';
|
|||||||
import { useAuthStore } from '../stores/authStore';
|
import { useAuthStore } from '../stores/authStore';
|
||||||
import { useUIStore } from '../stores/uiStore';
|
import { useUIStore } from '../stores/uiStore';
|
||||||
import type { User } from '@backspace/shared';
|
import type { User } from '@backspace/shared';
|
||||||
import { broadcastVoiceStatus } from '../utils/voice';
|
import { broadcastVoiceStatus, clearSpaceVoiceForDmCall } from '../utils/voice';
|
||||||
import { consumeIntentionalCameraOff, markIntentionalCameraOff } from '../utils/voiceActions';
|
import { consumeIntentionalCameraOff, markIntentionalCameraOff } from '../utils/voiceActions';
|
||||||
import { AudioManager } from '../audio/AudioManager';
|
import { AudioManager } from '../audio/AudioManager';
|
||||||
import { SpeakingDetector } from '../audio/SpeakingDetector';
|
import { SpeakingDetector } from '../audio/SpeakingDetector';
|
||||||
@@ -573,6 +573,13 @@ export function useLiveKit() {
|
|||||||
const storedId = isDm ? `dm-${channelId}` : channelId;
|
const storedId = isDm ? `dm-${channelId}` : channelId;
|
||||||
if (connectedChannelRef.current === storedId && roomRef.current?.state === ConnectionState.Connected) return;
|
if (connectedChannelRef.current === storedId && roomRef.current?.state === ConnectionState.Connected) return;
|
||||||
|
|
||||||
|
// Entering a DM call: drop any space voice channel we're still "in" on the
|
||||||
|
// client. Done synchronously (before any await) so the sidebar updates
|
||||||
|
// immediately. See clearSpaceVoiceForDmCall for why currentVoiceChannelId
|
||||||
|
// must be cleared here — otherwise the DM call's participants render against
|
||||||
|
// the old space channel and we appear to still be sitting in it.
|
||||||
|
if (isDm) clearSpaceVoiceForDmCall();
|
||||||
|
|
||||||
// Register voice state with the WS server after LiveKit connects (not for DM calls)
|
// Register voice state with the WS server after LiveKit connects (not for DM calls)
|
||||||
const registerWithServer = () => {
|
const registerWithServer = () => {
|
||||||
if (isDm) return;
|
if (isDm) return;
|
||||||
|
|||||||
@@ -126,6 +126,35 @@ export { buildCallUndeliverableToast };
|
|||||||
|
|
||||||
const HOME_ORIGIN = '';
|
const HOME_ORIGIN = '';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tear down local state for a DM call that ended, was rejected, or became
|
||||||
|
* terminally undeliverable. Clears the call UI/federation state, and tears
|
||||||
|
* down the LiveKit session **only when the active voice connection still
|
||||||
|
* belongs to the DM call**.
|
||||||
|
*
|
||||||
|
* The guard is load-bearing: `disconnectFn()` tears down whatever LiveKit room
|
||||||
|
* is currently active, regardless of which channel it is. Once the user has
|
||||||
|
* joined a *space* voice channel, `currentVoiceChannelId` is set and the active
|
||||||
|
* room is the space channel — NOT the DM call (the two are mutually exclusive;
|
||||||
|
* `setCurrentVoiceChannel` clears `activeDmCall`). A stale `dm_call_ended` echo
|
||||||
|
* must never disconnect that space connection.
|
||||||
|
*
|
||||||
|
* This is exactly what happens to the **last** participant to leave a DM call
|
||||||
|
* for a space channel: their `voice_join` empties the server-side DM room, the
|
||||||
|
* server broadcasts `dm_call_ended` back to every DM member (including them),
|
||||||
|
* and an unguarded `disconnectFn()` would tear down the space room they just
|
||||||
|
* connected to — stranding the UI on "Connecting…" until a manual rejoin.
|
||||||
|
*/
|
||||||
|
export function teardownDmCall(): void {
|
||||||
|
const voice = useVoiceStore.getState();
|
||||||
|
voice.setIncomingCall(null);
|
||||||
|
voice.setOutgoingCall(null);
|
||||||
|
voice.setActiveDmCall(null);
|
||||||
|
voice.clearFederatedCallData();
|
||||||
|
// Never tear down a space voice connection in response to a DM-call signal.
|
||||||
|
if (voice.disconnectFn && !voice.currentVoiceChannelId) voice.disconnectFn();
|
||||||
|
}
|
||||||
|
|
||||||
function handleEvent(origin: string, event: ServerEvent): void {
|
function handleEvent(origin: string, event: ServerEvent): void {
|
||||||
const isHome = origin === HOME_ORIGIN;
|
const isHome = origin === HOME_ORIGIN;
|
||||||
const { setUser } = useAuthStore.getState();
|
const { setUser } = useAuthStore.getState();
|
||||||
@@ -1027,7 +1056,13 @@ function handleEvent(origin: string, event: ServerEvent): void {
|
|||||||
if (wasOutgoingCall || isLiveKitConnected) {
|
if (wasOutgoingCall || isLiveKitConnected) {
|
||||||
setActiveDmCall({ dmChannelId: callDmId });
|
setActiveDmCall({ dmChannelId: callDmId });
|
||||||
}
|
}
|
||||||
if (connectFn && !isLiveKitConnected && wasOutgoingCall && callDmId) {
|
// The caller connects to the DM room. `wasOutgoingCall` alone identifies
|
||||||
|
// the caller session (other sessions/tabs never set outgoingCall), and
|
||||||
|
// `connect()` de-dupes an already-connected same room — so we must NOT
|
||||||
|
// also gate on `!isLiveKitConnected`: a caller who is currently sitting in
|
||||||
|
// a space voice channel is LiveKit-connected, and gating on it would skip
|
||||||
|
// the DM connect entirely, stranding them in the space channel.
|
||||||
|
if (connectFn && wasOutgoingCall && callDmId) {
|
||||||
connectFn(callDmId, true).catch((err: unknown) => {
|
connectFn(callDmId, true).catch((err: unknown) => {
|
||||||
console.error('[WS] DM call connect failed:', err);
|
console.error('[WS] DM call connect failed:', err);
|
||||||
});
|
});
|
||||||
@@ -1037,39 +1072,24 @@ function handleEvent(origin: string, event: ServerEvent): void {
|
|||||||
|
|
||||||
case 'dm_call_rejected': {
|
case 'dm_call_rejected': {
|
||||||
if (!isHome && !activePeerOrigins.has(origin)) break;
|
if (!isHome && !activePeerOrigins.has(origin)) break;
|
||||||
const { setIncomingCall, setOutgoingCall, setActiveDmCall, disconnectFn, clearFederatedCallData } = useVoiceStore.getState();
|
teardownDmCall();
|
||||||
setIncomingCall(null);
|
|
||||||
setOutgoingCall(null);
|
|
||||||
setActiveDmCall(null);
|
|
||||||
clearFederatedCallData();
|
|
||||||
if (disconnectFn) disconnectFn();
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'dm_call_ended': {
|
case 'dm_call_ended': {
|
||||||
if (!isHome && !activePeerOrigins.has(origin)) break;
|
if (!isHome && !activePeerOrigins.has(origin)) break;
|
||||||
const { setIncomingCall, setOutgoingCall, setActiveDmCall, disconnectFn, clearFederatedCallData } = useVoiceStore.getState();
|
teardownDmCall();
|
||||||
setIncomingCall(null);
|
|
||||||
setOutgoingCall(null);
|
|
||||||
setActiveDmCall(null);
|
|
||||||
clearFederatedCallData();
|
|
||||||
if (disconnectFn) disconnectFn();
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'dm_call_undeliverable': {
|
case 'dm_call_undeliverable': {
|
||||||
if (!isHome && !activePeerOrigins.has(origin)) break;
|
if (!isHome && !activePeerOrigins.has(origin)) break;
|
||||||
|
|
||||||
const { setIncomingCall, setOutgoingCall, setActiveDmCall, disconnectFn, clearFederatedCallData } = useVoiceStore.getState();
|
|
||||||
const { addToast } = useUIStore.getState();
|
const { addToast } = useUIStore.getState();
|
||||||
|
|
||||||
if (event.terminal) {
|
if (event.terminal) {
|
||||||
// Tear down local outbound call state — mirrors dm_call_ended.
|
// Tear down local outbound call state — mirrors dm_call_ended.
|
||||||
setIncomingCall(null);
|
teardownDmCall();
|
||||||
setOutgoingCall(null);
|
|
||||||
setActiveDmCall(null);
|
|
||||||
clearFederatedCallData();
|
|
||||||
if (disconnectFn) disconnectFn();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const msg = buildCallUndeliverableToast(event.failures, event.terminal, event.phase);
|
const msg = buildCallUndeliverableToast(event.failures, event.terminal, event.phase);
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
|
||||||
|
// Stub heavy / side-effectful imports pulled in transitively by utils/voice.
|
||||||
|
vi.mock('../audio/AudioManager', () => ({
|
||||||
|
AudioManager: { getInstance: () => ({}) },
|
||||||
|
}));
|
||||||
|
vi.mock('../hooks/useWebSocket', () => ({
|
||||||
|
wsSend: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock('../stores/instanceStore', async () => {
|
||||||
|
const { create } = await import('zustand');
|
||||||
|
const store = create<{ instances: unknown[] }>()(() => ({ instances: [] }));
|
||||||
|
return { useInstanceStore: store };
|
||||||
|
});
|
||||||
|
|
||||||
|
import { clearSpaceVoiceForDmCall } from './voice';
|
||||||
|
import { useVoiceStore } from '../stores/voiceStore';
|
||||||
|
import { useAuthStore } from '../stores/authStore';
|
||||||
|
|
||||||
|
describe('clearSpaceVoiceForDmCall', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useAuthStore.setState({ user: { id: 'me' } as never });
|
||||||
|
useVoiceStore.setState({
|
||||||
|
currentVoiceChannelId: null,
|
||||||
|
activeDmCall: null,
|
||||||
|
voiceUsers: new Map(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears currentVoiceChannelId and removes self from the space channel', () => {
|
||||||
|
useVoiceStore.setState({
|
||||||
|
currentVoiceChannelId: 'space-1',
|
||||||
|
voiceUsers: new Map([['space-1', ['me', 'other']]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
clearSpaceVoiceForDmCall();
|
||||||
|
|
||||||
|
const s = useVoiceStore.getState();
|
||||||
|
expect(s.currentVoiceChannelId).toBeNull();
|
||||||
|
expect(s.voiceUsers.get('space-1')).toEqual(['other']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves activeDmCall (the caller/acceptor just set it)', () => {
|
||||||
|
useVoiceStore.setState({
|
||||||
|
currentVoiceChannelId: 'space-1',
|
||||||
|
activeDmCall: { dmChannelId: 'dm-9' },
|
||||||
|
voiceUsers: new Map([['space-1', ['me']]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
clearSpaceVoiceForDmCall();
|
||||||
|
|
||||||
|
const s = useVoiceStore.getState();
|
||||||
|
expect(s.currentVoiceChannelId).toBeNull();
|
||||||
|
expect(s.activeDmCall).toEqual({ dmChannelId: 'dm-9' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is a no-op when not in a space voice channel', () => {
|
||||||
|
useVoiceStore.setState({ currentVoiceChannelId: null, activeDmCall: { dmChannelId: 'dm-1' } });
|
||||||
|
|
||||||
|
clearSpaceVoiceForDmCall();
|
||||||
|
|
||||||
|
expect(useVoiceStore.getState().activeDmCall).toEqual({ dmChannelId: 'dm-1' });
|
||||||
|
expect(useVoiceStore.getState().currentVoiceChannelId).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -58,6 +58,41 @@ export function broadcastDeafenViaLiveKit(): void {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the client-side presence of a *space* voice channel when the local user
|
||||||
|
* transitions into a DM call.
|
||||||
|
*
|
||||||
|
* A DM call and a space voice channel are mutually exclusive: the invariant is
|
||||||
|
* that a DM call has **no** `currentVoiceChannelId` (only `activeDmCall`). The
|
||||||
|
* space→DM transition must therefore drop the space channel's client state, the
|
||||||
|
* mirror of `joinVoiceChannel` clearing `activeDmCall` on the DM→space
|
||||||
|
* transition.
|
||||||
|
*
|
||||||
|
* Why this is necessary even though the server already drops us from the space
|
||||||
|
* room (`dm_call_start` / `dm_call_accept` → `leaveCurrentRoom` →
|
||||||
|
* `broadcastRoomLeave`): `VoiceChannel` renders the occupant list for the
|
||||||
|
* channel equal to `currentVoiceChannelId` from the *live LiveKit participants*
|
||||||
|
* (its "our channel is the source of truth" branch). If `currentVoiceChannelId`
|
||||||
|
* still points at the old space channel, the DM call's participants get mapped
|
||||||
|
* onto it and the local user appears to still be sitting in the space channel.
|
||||||
|
*
|
||||||
|
* Clears `currentVoiceChannelId` directly (not via `setCurrentVoiceChannel`,
|
||||||
|
* which would also wipe the `activeDmCall` the caller/acceptor just set) and
|
||||||
|
* optimistically removes self from the old channel's `voiceUsers` so the
|
||||||
|
* sidebar updates immediately, without waiting for the server's leave
|
||||||
|
* broadcast.
|
||||||
|
*/
|
||||||
|
export function clearSpaceVoiceForDmCall(): void {
|
||||||
|
const { currentVoiceChannelId, removeVoiceUser } = useVoiceStore.getState();
|
||||||
|
if (!currentVoiceChannelId) return;
|
||||||
|
|
||||||
|
const origin = getChannelOrigin(currentVoiceChannelId);
|
||||||
|
const myId = getMyUserIdForOrigin(origin);
|
||||||
|
if (myId) removeVoiceUser(currentVoiceChannelId, myId);
|
||||||
|
|
||||||
|
useVoiceStore.setState({ currentVoiceChannelId: null });
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Voice channel join
|
// Voice channel join
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user