mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
test(voice): pin voiceStatus transitions, widget indicators, and control freeze
- livekit-session: assert joining→securing→connected on join, idle on leave, connected after auto-reconnect - voice-widget: assert each status label + secured badge visibility, and controls disabled with reason while the socket is down, re-enabled on reconnect - voice-callbacks: assert join/leave/disconnect do not send over a down socket - voice.store: assert join seeds joining, leave resets idle, setter writes status - thread voiceStatus through existing full-state fixtures; keep an active-call socket live in widget fixtures so control-click tests still operate enabled Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -59,6 +59,7 @@ const VOICE_INITIAL: VoiceState = {
|
||||
localScreenshare: false,
|
||||
joinedAt: null,
|
||||
listenOnly: false,
|
||||
voiceStatus: "idle",
|
||||
};
|
||||
|
||||
const UI_INITIAL: UiState = {
|
||||
|
||||
@@ -134,6 +134,7 @@ function resetAllStores(): void {
|
||||
localScreenshare: false,
|
||||
joinedAt: null,
|
||||
listenOnly: false,
|
||||
voiceStatus: "idle",
|
||||
}));
|
||||
authStore.setState(() => ({
|
||||
token: null,
|
||||
|
||||
@@ -59,6 +59,7 @@ function resetStores(): void {
|
||||
localScreenshare: false,
|
||||
joinedAt: null,
|
||||
listenOnly: false,
|
||||
voiceStatus: "idle",
|
||||
}));
|
||||
membersStore.setState(() => ({
|
||||
members: new Map(),
|
||||
@@ -1072,6 +1073,7 @@ describe("ChannelSidebar", () => {
|
||||
localScreenshare: false,
|
||||
joinedAt: null,
|
||||
listenOnly: false,
|
||||
voiceStatus: "idle",
|
||||
}));
|
||||
sidebarWithWatch.mount(container);
|
||||
|
||||
@@ -1116,6 +1118,7 @@ describe("ChannelSidebar", () => {
|
||||
localScreenshare: false,
|
||||
joinedAt: null,
|
||||
listenOnly: false,
|
||||
voiceStatus: "idle",
|
||||
}));
|
||||
sidebarWithWatch.mount(container);
|
||||
|
||||
@@ -1166,6 +1169,7 @@ describe("ChannelSidebar", () => {
|
||||
localScreenshare: false,
|
||||
joinedAt: null,
|
||||
listenOnly: false,
|
||||
voiceStatus: "idle",
|
||||
}));
|
||||
sidebar.mount(container);
|
||||
|
||||
@@ -1207,6 +1211,7 @@ describe("ChannelSidebar", () => {
|
||||
localScreenshare: false,
|
||||
joinedAt: null,
|
||||
listenOnly: false,
|
||||
voiceStatus: "idle",
|
||||
}));
|
||||
sidebarWithWatch.mount(container);
|
||||
|
||||
@@ -1248,6 +1253,7 @@ describe("ChannelSidebar", () => {
|
||||
localScreenshare: false,
|
||||
joinedAt: null,
|
||||
listenOnly: false,
|
||||
voiceStatus: "idle",
|
||||
}));
|
||||
sidebar.mount(container);
|
||||
|
||||
|
||||
@@ -130,6 +130,7 @@ describe("WS Dispatcher", () => {
|
||||
localScreenshare: false,
|
||||
joinedAt: null,
|
||||
listenOnly: false,
|
||||
voiceStatus: "idle",
|
||||
}));
|
||||
dmStore.setState(() => ({ channels: [] }));
|
||||
uiStore.setState((prev) => ({ ...prev, transientError: null }));
|
||||
|
||||
@@ -85,6 +85,7 @@ vi.mock("@stores/voice.store", () => ({
|
||||
setSpeakers: vi.fn(),
|
||||
leaveVoiceChannel: vi.fn(),
|
||||
setListenOnly: vi.fn(),
|
||||
setVoiceStatus: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockInvoke = vi.hoisted(() =>
|
||||
@@ -149,6 +150,7 @@ import {
|
||||
setLocalScreenshare,
|
||||
setListenOnly,
|
||||
leaveVoiceChannel,
|
||||
setVoiceStatus,
|
||||
} from "@stores/voice.store";
|
||||
import {
|
||||
isVoiceConnected,
|
||||
@@ -651,6 +653,59 @@ describe("LiveKitSession", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("voiceStatus transitions (voice-and-e2ee.md §1–2)", () => {
|
||||
function statusCalls(): string[] {
|
||||
return (setVoiceStatus as any).mock.calls.map((c: unknown[]) => c[0] as string);
|
||||
}
|
||||
|
||||
it("writes joining → securing → connected on a successful join", async () => {
|
||||
session.setServerHost("localhost:7880");
|
||||
session.setWsClient({ send: vi.fn() } as any);
|
||||
(setVoiceStatus as any).mockClear();
|
||||
|
||||
await session.handleVoiceToken("test-token", "/livekit", 1, "ws://localhost:7880", true);
|
||||
|
||||
const calls = statusCalls();
|
||||
expect(calls).toContain("joining");
|
||||
expect(calls).toContain("securing");
|
||||
expect(calls).toContain("connected");
|
||||
// Ordering: joining before securing before connected.
|
||||
expect(calls.indexOf("joining")).toBeLessThan(calls.indexOf("securing"));
|
||||
expect(calls.indexOf("securing")).toBeLessThan(calls.indexOf("connected"));
|
||||
});
|
||||
|
||||
it("writes idle on leaveVoice", () => {
|
||||
(setVoiceStatus as any).mockClear();
|
||||
session.leaveVoice(false);
|
||||
expect(setVoiceStatus).toHaveBeenCalledWith("idle");
|
||||
});
|
||||
|
||||
it("writes connected after a successful auto-reconnect", async () => {
|
||||
(session as any)._state = {
|
||||
type: "reconnecting",
|
||||
channelId: 7,
|
||||
latestToken: "reconnect-token",
|
||||
lastUrl: "/livekit",
|
||||
lastDirectUrl: "ws://localhost:7880",
|
||||
ac: new AbortController(),
|
||||
};
|
||||
(setVoiceStatus as any).mockClear();
|
||||
|
||||
const ac = new AbortController();
|
||||
const reconnectPromise = (session as any).attemptAutoReconnect(
|
||||
"reconnect-token",
|
||||
"/livekit",
|
||||
7,
|
||||
"ws://localhost:7880",
|
||||
ac.signal,
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(3100);
|
||||
await reconnectPromise;
|
||||
|
||||
expect(setVoiceStatus).toHaveBeenCalledWith("connected");
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleVoiceTokenRefresh", () => {
|
||||
it("stores the token and restarts the timer", () => {
|
||||
session.handleVoiceTokenRefresh("new-token");
|
||||
|
||||
@@ -36,6 +36,7 @@ import { createVoiceWidget } from "@components/VoiceWidget";
|
||||
import { voiceStore } from "@stores/voice.store";
|
||||
import { channelsStore } from "@stores/channels.store";
|
||||
import { membersStore } from "@stores/members.store";
|
||||
import { setConnectionStatus } from "@stores/ui.store";
|
||||
|
||||
function resetStores(): void {
|
||||
voiceStore.setState(() => ({
|
||||
@@ -48,6 +49,7 @@ function resetStores(): void {
|
||||
localScreenshare: false,
|
||||
joinedAt: null,
|
||||
listenOnly: false,
|
||||
voiceStatus: "idle",
|
||||
}));
|
||||
channelsStore.setState(() => ({
|
||||
channels: new Map(),
|
||||
@@ -58,6 +60,8 @@ function resetStores(): void {
|
||||
members: new Map(),
|
||||
typingUsers: new Map(),
|
||||
}));
|
||||
// Voice controls freeze while the socket is down; an active call is live.
|
||||
setConnectionStatus("connected");
|
||||
}
|
||||
|
||||
function setVoiceConnected(screenshare = false): void {
|
||||
|
||||
@@ -215,6 +215,7 @@ function resetStores(): void {
|
||||
localScreenshare: false,
|
||||
joinedAt: null,
|
||||
listenOnly: false,
|
||||
voiceStatus: "idle",
|
||||
}));
|
||||
localStorage.removeItem("owncord:member-list-height");
|
||||
localStorage.removeItem("owncord:member-list-collapsed");
|
||||
|
||||
@@ -15,6 +15,7 @@ const {
|
||||
mockDisableCamera,
|
||||
mockEnableScreenshare,
|
||||
mockDisableScreenshare,
|
||||
mockUiGetState,
|
||||
} = vi.hoisted(() => ({
|
||||
mockVoiceStoreGetState: vi.fn(),
|
||||
mockJoinVoiceChannel: vi.fn(),
|
||||
@@ -26,6 +27,7 @@ const {
|
||||
mockDisableCamera: vi.fn(() => Promise.resolve()),
|
||||
mockEnableScreenshare: vi.fn(() => Promise.resolve()),
|
||||
mockDisableScreenshare: vi.fn(() => Promise.resolve()),
|
||||
mockUiGetState: vi.fn(() => ({ connectionStatus: "connected" })),
|
||||
}));
|
||||
|
||||
vi.mock("@lib/logger", () => ({
|
||||
@@ -43,6 +45,10 @@ vi.mock("@stores/voice.store", () => ({
|
||||
leaveVoiceChannel: mockLeaveVoiceChannel,
|
||||
}));
|
||||
|
||||
vi.mock("@stores/ui.store", () => ({
|
||||
uiStore: { getState: mockUiGetState },
|
||||
}));
|
||||
|
||||
vi.mock("@lib/livekitSession", () => ({
|
||||
leaveVoice: mockVoiceSessionLeave,
|
||||
setMuted: mockSetMuted,
|
||||
@@ -105,6 +111,7 @@ describe("createVoiceWidgetCallbacks", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockVoiceStoreGetState.mockReturnValue(makeVoiceState());
|
||||
mockUiGetState.mockReturnValue({ connectionStatus: "connected" });
|
||||
});
|
||||
|
||||
describe("onDisconnect", () => {
|
||||
@@ -128,6 +135,17 @@ describe("createVoiceWidgetCallbacks", () => {
|
||||
|
||||
expect(ws.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not send over a down socket while reconnecting", () => {
|
||||
mockUiGetState.mockReturnValue({ connectionStatus: "reconnecting" });
|
||||
const ws = makeWs();
|
||||
const cbs = createVoiceWidgetCallbacks(ws, makeLimiters());
|
||||
|
||||
cbs.onDisconnect();
|
||||
|
||||
expect(mockVoiceSessionLeave).not.toHaveBeenCalled();
|
||||
expect(ws.send).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("onMuteToggle", () => {
|
||||
@@ -276,6 +294,7 @@ describe("createVoiceWidgetCallbacks", () => {
|
||||
describe("createSidebarVoiceCallbacks", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockUiGetState.mockReturnValue({ connectionStatus: "connected" });
|
||||
});
|
||||
|
||||
it("onVoiceJoin sends voice_join and updates store", () => {
|
||||
@@ -301,4 +320,26 @@ describe("createSidebarVoiceCallbacks", () => {
|
||||
expect(mockLeaveVoiceChannel).toHaveBeenCalled();
|
||||
expect(ws.send).toHaveBeenCalledWith({ type: "voice_leave", payload: {} });
|
||||
});
|
||||
|
||||
it("onVoiceJoin does not send over a down socket", () => {
|
||||
mockUiGetState.mockReturnValue({ connectionStatus: "disconnected" });
|
||||
const ws = makeWs();
|
||||
const cbs = createSidebarVoiceCallbacks(ws);
|
||||
|
||||
cbs.onVoiceJoin(42);
|
||||
|
||||
expect(mockJoinVoiceChannel).not.toHaveBeenCalled();
|
||||
expect(ws.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("onVoiceLeave does not send over a down socket", () => {
|
||||
mockUiGetState.mockReturnValue({ connectionStatus: "reconnecting" });
|
||||
const ws = makeWs();
|
||||
const cbs = createSidebarVoiceCallbacks(ws);
|
||||
|
||||
cbs.onVoiceLeave();
|
||||
|
||||
expect(mockVoiceSessionLeave).not.toHaveBeenCalled();
|
||||
expect(ws.send).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,6 +33,7 @@ function resetStores(): void {
|
||||
localScreenshare: false,
|
||||
joinedAt: null,
|
||||
listenOnly: false,
|
||||
voiceStatus: "idle",
|
||||
}));
|
||||
membersStore.setState(() => ({
|
||||
members: new Map(),
|
||||
|
||||
@@ -23,6 +23,7 @@ function resetStores(): void {
|
||||
localScreenshare: false,
|
||||
joinedAt: null,
|
||||
listenOnly: false,
|
||||
voiceStatus: "idle",
|
||||
}));
|
||||
authStore.setState(() => ({
|
||||
token: null,
|
||||
|
||||
@@ -34,9 +34,10 @@ vi.mock("@lib/connectionStats", () => ({
|
||||
}));
|
||||
|
||||
import { createVoiceWidget } from "../../src/components/VoiceWidget";
|
||||
import { voiceStore } from "../../src/stores/voice.store";
|
||||
import { voiceStore, type VoiceStatus } from "../../src/stores/voice.store";
|
||||
import { channelsStore } from "../../src/stores/channels.store";
|
||||
import { membersStore } from "../../src/stores/members.store";
|
||||
import { uiStore, setConnectionStatus } from "../../src/stores/ui.store";
|
||||
import type { VoiceUser } from "../../src/stores/voice.store";
|
||||
|
||||
function resetStores(): void {
|
||||
@@ -50,6 +51,7 @@ function resetStores(): void {
|
||||
localScreenshare: false,
|
||||
joinedAt: null,
|
||||
listenOnly: false,
|
||||
voiceStatus: "idle",
|
||||
}));
|
||||
channelsStore.setState(() => ({
|
||||
channels: new Map(),
|
||||
@@ -60,6 +62,14 @@ function resetStores(): void {
|
||||
members: new Map(),
|
||||
typingUsers: new Map(),
|
||||
}));
|
||||
// Voice controls freeze when the socket is down; keep it live by default so
|
||||
// the interaction tests below operate on enabled controls.
|
||||
setConnectionStatus("connected");
|
||||
}
|
||||
|
||||
function setVoiceStatus(status: VoiceStatus): void {
|
||||
voiceStore.setState((prev) => ({ ...prev, voiceStatus: status }));
|
||||
voiceStore.flush();
|
||||
}
|
||||
|
||||
function setVoiceChannel(channelId: number, users: VoiceUser[]): void {
|
||||
@@ -624,4 +634,170 @@ describe("VoiceWidget", () => {
|
||||
|
||||
widget.destroy?.();
|
||||
});
|
||||
|
||||
// --- E2EE / voice-session status (docs/architecture/ux/voice-and-e2ee.md §2) ---
|
||||
|
||||
it("shows 'Connecting…' during the joining phase, no secured badge", () => {
|
||||
setVoiceChannel(1, []);
|
||||
|
||||
const widget = createVoiceWidget({
|
||||
onDisconnect: vi.fn(),
|
||||
onMuteToggle: vi.fn(),
|
||||
onDeafenToggle: vi.fn(),
|
||||
onCameraToggle: vi.fn(),
|
||||
onScreenshareToggle: vi.fn(),
|
||||
});
|
||||
widget.mount(container);
|
||||
|
||||
setVoiceStatus("joining");
|
||||
|
||||
const status = container.querySelector('[data-testid="vw-status"]');
|
||||
const secured = container.querySelector('[data-testid="vw-secured"]') as HTMLElement;
|
||||
expect(status?.textContent).toBe("Connecting…");
|
||||
expect(secured.style.display).toBe("none");
|
||||
|
||||
widget.destroy?.();
|
||||
});
|
||||
|
||||
it("shows 'Securing…' while E2EE key exchange runs, no secured badge yet", () => {
|
||||
setVoiceChannel(1, []);
|
||||
|
||||
const widget = createVoiceWidget({
|
||||
onDisconnect: vi.fn(),
|
||||
onMuteToggle: vi.fn(),
|
||||
onDeafenToggle: vi.fn(),
|
||||
onCameraToggle: vi.fn(),
|
||||
onScreenshareToggle: vi.fn(),
|
||||
});
|
||||
widget.mount(container);
|
||||
|
||||
setVoiceStatus("securing");
|
||||
|
||||
const status = container.querySelector('[data-testid="vw-status"]') as HTMLElement;
|
||||
const secured = container.querySelector('[data-testid="vw-secured"]') as HTMLElement;
|
||||
expect(status.textContent).toBe("Securing…");
|
||||
expect(status.classList.contains("vw-securing")).toBe(true);
|
||||
expect(secured.style.display).toBe("none");
|
||||
|
||||
widget.destroy?.();
|
||||
});
|
||||
|
||||
it("shows a persistent secured indicator once connected", () => {
|
||||
setVoiceChannel(1, []);
|
||||
|
||||
const widget = createVoiceWidget({
|
||||
onDisconnect: vi.fn(),
|
||||
onMuteToggle: vi.fn(),
|
||||
onDeafenToggle: vi.fn(),
|
||||
onCameraToggle: vi.fn(),
|
||||
onScreenshareToggle: vi.fn(),
|
||||
});
|
||||
widget.mount(container);
|
||||
|
||||
setVoiceStatus("connected");
|
||||
|
||||
const status = container.querySelector('[data-testid="vw-status"]');
|
||||
const secured = container.querySelector('[data-testid="vw-secured"]') as HTMLElement;
|
||||
expect(status?.textContent).toBe("Voice Connected");
|
||||
expect(secured.style.display).toBe("inline-flex");
|
||||
expect(secured.textContent).toContain("Secured");
|
||||
|
||||
widget.destroy?.();
|
||||
});
|
||||
|
||||
it("shows 'Reconnecting voice…' during a voice reconnect", () => {
|
||||
setVoiceChannel(1, []);
|
||||
|
||||
const widget = createVoiceWidget({
|
||||
onDisconnect: vi.fn(),
|
||||
onMuteToggle: vi.fn(),
|
||||
onDeafenToggle: vi.fn(),
|
||||
onCameraToggle: vi.fn(),
|
||||
onScreenshareToggle: vi.fn(),
|
||||
});
|
||||
widget.mount(container);
|
||||
|
||||
setVoiceStatus("reconnecting");
|
||||
|
||||
const status = container.querySelector('[data-testid="vw-status"]') as HTMLElement;
|
||||
const secured = container.querySelector('[data-testid="vw-secured"]') as HTMLElement;
|
||||
expect(status.textContent).toBe("Reconnecting voice…");
|
||||
expect(status.classList.contains("vw-reconnecting")).toBe(true);
|
||||
expect(secured.style.display).toBe("none");
|
||||
|
||||
widget.destroy?.();
|
||||
});
|
||||
|
||||
// --- Freeze controls during WS reconnect (docs/architecture/ux/README.md §3) ---
|
||||
|
||||
it("disables voice controls with a reason while the WS socket is reconnecting", () => {
|
||||
setVoiceChannel(1, []);
|
||||
|
||||
const widget = createVoiceWidget({
|
||||
onDisconnect: vi.fn(),
|
||||
onMuteToggle: vi.fn(),
|
||||
onDeafenToggle: vi.fn(),
|
||||
onCameraToggle: vi.fn(),
|
||||
onScreenshareToggle: vi.fn(),
|
||||
});
|
||||
widget.mount(container);
|
||||
|
||||
setConnectionStatus("reconnecting");
|
||||
uiStore.flush();
|
||||
|
||||
const muteBtn = container.querySelector('[aria-label="Mute"]') as HTMLButtonElement;
|
||||
const disconnectBtn = container.querySelector('[aria-label="Disconnect"]') as HTMLButtonElement;
|
||||
expect(muteBtn.disabled).toBe(true);
|
||||
expect(muteBtn.title).toBe("Reconnecting…");
|
||||
expect(disconnectBtn.disabled).toBe(true);
|
||||
|
||||
widget.destroy?.();
|
||||
});
|
||||
|
||||
it("shows 'Not connected' reason while the WS socket is disconnected", () => {
|
||||
setVoiceChannel(1, []);
|
||||
|
||||
const widget = createVoiceWidget({
|
||||
onDisconnect: vi.fn(),
|
||||
onMuteToggle: vi.fn(),
|
||||
onDeafenToggle: vi.fn(),
|
||||
onCameraToggle: vi.fn(),
|
||||
onScreenshareToggle: vi.fn(),
|
||||
});
|
||||
widget.mount(container);
|
||||
|
||||
setConnectionStatus("disconnected");
|
||||
uiStore.flush();
|
||||
|
||||
const muteBtn = container.querySelector('[aria-label="Mute"]') as HTMLButtonElement;
|
||||
expect(muteBtn.disabled).toBe(true);
|
||||
expect(muteBtn.title).toBe("Not connected");
|
||||
|
||||
widget.destroy?.();
|
||||
});
|
||||
|
||||
it("re-enables voice controls when the WS socket reconnects", () => {
|
||||
setVoiceChannel(1, []);
|
||||
|
||||
const widget = createVoiceWidget({
|
||||
onDisconnect: vi.fn(),
|
||||
onMuteToggle: vi.fn(),
|
||||
onDeafenToggle: vi.fn(),
|
||||
onCameraToggle: vi.fn(),
|
||||
onScreenshareToggle: vi.fn(),
|
||||
});
|
||||
widget.mount(container);
|
||||
|
||||
setConnectionStatus("reconnecting");
|
||||
uiStore.flush();
|
||||
const muteBtn = container.querySelector('[aria-label="Mute"]') as HTMLButtonElement;
|
||||
expect(muteBtn.disabled).toBe(true);
|
||||
|
||||
setConnectionStatus("connected");
|
||||
uiStore.flush();
|
||||
expect(muteBtn.disabled).toBe(false);
|
||||
expect(muteBtn.title).toBe("");
|
||||
|
||||
widget.destroy?.();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
setSpeakers,
|
||||
setVoiceConfig,
|
||||
getChannelVoiceUsers,
|
||||
setVoiceStatus,
|
||||
} from "../../src/stores/voice.store";
|
||||
import type { ReadyVoiceState, VoiceStatePayload, VoiceLeavePayload } from "../../src/lib/types";
|
||||
import { authStore } from "../../src/stores/auth.store";
|
||||
@@ -31,6 +32,7 @@ function resetStore(): void {
|
||||
localScreenshare: false,
|
||||
joinedAt: null,
|
||||
listenOnly: false,
|
||||
voiceStatus: "idle",
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -476,6 +478,28 @@ describe("voice store", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("voiceStatus", () => {
|
||||
it("seeds 'joining' optimistically on a fresh join", () => {
|
||||
expect(voiceStore.getState().voiceStatus).toBe("idle");
|
||||
joinVoiceChannel(42);
|
||||
expect(voiceStore.getState().voiceStatus).toBe("joining");
|
||||
});
|
||||
|
||||
it("resets to 'idle' on leaveVoiceChannel", () => {
|
||||
joinVoiceChannel(42);
|
||||
setVoiceStatus("connected");
|
||||
leaveVoiceChannel();
|
||||
expect(voiceStore.getState().voiceStatus).toBe("idle");
|
||||
});
|
||||
|
||||
it("setVoiceStatus writes the given status", () => {
|
||||
setVoiceStatus("securing");
|
||||
expect(voiceStore.getState().voiceStatus).toBe("securing");
|
||||
setVoiceStatus("reconnecting");
|
||||
expect(voiceStore.getState().voiceStatus).toBe("reconnecting");
|
||||
});
|
||||
});
|
||||
|
||||
describe("leaveVoiceChannel — clears user from voiceUsers", () => {
|
||||
it("removes current user from the channel's voiceUsers map", () => {
|
||||
authStore.setState(() => ({
|
||||
|
||||
Reference in New Issue
Block a user