refactor(frontend): isolate audio meters and apply settings on save
This commit is contained in:
@@ -9,6 +9,9 @@ the implementation at the recorded date; current operating instructions live in
|
||||
|
||||
### Fixed
|
||||
|
||||
- Isolate microphone meter subscriptions from the application tree. Apply draft settings only
|
||||
on Save, preserve explicitly live noise calibration, and persist/send only relevant changes.
|
||||
|
||||
- Keep the voice UI speaking until buffered playback finishes, flush short final audio chunks,
|
||||
and exclude response processing from the user inactivity timeout.
|
||||
|
||||
|
||||
@@ -62,8 +62,9 @@ describe("React application", () => {
|
||||
await user.click(await screen.findByRole("button", { name: "Configuración" }));
|
||||
expect(document.documentElement.dataset.theme).toBe("white");
|
||||
await user.selectOptions(screen.getByRole("combobox"), "Serena");
|
||||
expect(localStorage.getItem("s2s.ws.voice")).toBe("Serena");
|
||||
expect(localStorage.getItem("s2s.ws.voice")).toBeNull();
|
||||
await user.click(screen.getByRole("button", { name: "Guardar" }));
|
||||
expect(localStorage.getItem("s2s.ws.voice")).toBe("Serena");
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
view.unmount();
|
||||
render(
|
||||
@@ -74,6 +75,19 @@ describe("React application", () => {
|
||||
await user.click(await screen.findByRole("button", { name: "Configuración" }));
|
||||
expect(screen.getByRole("combobox")).toHaveValue("Serena");
|
||||
});
|
||||
it("discards unsaved settings on cancel while keeping live noise-gate calibration", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<App />);
|
||||
await user.click(await screen.findByRole("button", { name: "Configuración" }));
|
||||
await user.selectOptions(screen.getByRole("combobox"), "Serena");
|
||||
fireEvent.change(screen.getByRole("slider"), { target: { value: "-35" } });
|
||||
await user.click(screen.getByRole("button", { name: "Cancelar" }));
|
||||
await user.click(screen.getByRole("button", { name: "Configuración" }));
|
||||
expect(screen.getByRole("combobox")).toHaveValue("Aiden");
|
||||
expect(screen.getByRole("slider")).toHaveValue("-35");
|
||||
expect(localStorage.getItem("s2s.ws.voice")).toBeNull();
|
||||
expect(localStorage.getItem("s2s.ws.noiseGate")).toBe("-35");
|
||||
});
|
||||
it("saves and clears session-only RAG context and supports keyboard dialog dismissal", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<App />);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useState, useSyncExternalStore } from "react";
|
||||
|
||||
import { initials, profileName } from "../lib/context";
|
||||
import { profileRows } from "../lib/profile";
|
||||
@@ -13,15 +13,17 @@ interface Props {
|
||||
}
|
||||
export function SettingsDialog({ state, controller, onClose }: Props) {
|
||||
const { config, settings } = state;
|
||||
const meter = Math.max(
|
||||
0,
|
||||
Math.min(100, ((20 * Math.log10(state.level || 0.00001) - GATE_OFF) * 100) / (GATE_MAX - GATE_OFF)),
|
||||
);
|
||||
const [draft, setDraft] = useState(() => ({
|
||||
directUrl: settings.directUrl,
|
||||
voice: settings.voice,
|
||||
instructions: settings.instructions,
|
||||
}));
|
||||
return (
|
||||
<Dialog title="Configuración" onClose={onClose}>
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
controller.changeSettings(draft);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
@@ -35,10 +37,10 @@ export function SettingsDialog({ state, controller, onClose }: Props) {
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
placeholder="http://localhost:8080"
|
||||
value={config.s2sUrl || settings.directUrl}
|
||||
value={config.s2sUrl || draft.directUrl}
|
||||
readOnly={!!config.s2sUrl}
|
||||
onChange={(event) => {
|
||||
controller.changeSettings({ directUrl: event.target.value });
|
||||
setDraft({ ...draft, directUrl: event.target.value });
|
||||
}}
|
||||
/>
|
||||
<small>
|
||||
@@ -52,9 +54,9 @@ export function SettingsDialog({ state, controller, onClose }: Props) {
|
||||
<label className="field">
|
||||
<span>Voz</span>
|
||||
<select
|
||||
value={settings.voice}
|
||||
value={draft.voice}
|
||||
onChange={(event) => {
|
||||
controller.changeSettings({ voice: event.target.value });
|
||||
setDraft({ ...draft, voice: event.target.value });
|
||||
}}
|
||||
>
|
||||
{VOICES.map((voice) => (
|
||||
@@ -72,7 +74,7 @@ export function SettingsDialog({ state, controller, onClose }: Props) {
|
||||
</label>
|
||||
<div className="gate">
|
||||
<div className="gate-track">
|
||||
<div className="gate-meter-fill" style={{ width: `${String(meter)}%` }} aria-hidden="true" />
|
||||
<GateMeter controller={controller} />
|
||||
<input
|
||||
id="noise-gate"
|
||||
aria-label="Umbral de la puerta de ruido"
|
||||
@@ -92,18 +94,18 @@ export function SettingsDialog({ state, controller, onClose }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
<small>
|
||||
Silencia el micrófono por debajo del umbral para evitar enviar ruido. Desliza hasta la izquierda para
|
||||
desactivarla.
|
||||
Se aplica y guarda al instante. Silencia el micrófono por debajo del umbral. Desliza hasta la izquierda
|
||||
para desactivarla.
|
||||
</small>
|
||||
</div>
|
||||
<label className="field">
|
||||
<span>Instrucciones</span>
|
||||
<textarea
|
||||
rows={5}
|
||||
value={config.primaryPrompt || settings.instructions}
|
||||
value={config.primaryPrompt || draft.instructions}
|
||||
readOnly={!!config.primaryPrompt}
|
||||
onChange={(event) => {
|
||||
controller.changeSettings({ instructions: event.target.value });
|
||||
setDraft({ ...draft, instructions: event.target.value });
|
||||
}}
|
||||
/>
|
||||
{config.primaryPrompt && <small>Las instrucciones están definidas por el servidor.</small>}
|
||||
@@ -114,17 +116,24 @@ export function SettingsDialog({ state, controller, onClose }: Props) {
|
||||
className="btn primary wide"
|
||||
disabled={!LIVE_STATES.has(state.status)}
|
||||
onClick={() => {
|
||||
controller.changeSettings(draft);
|
||||
onClose();
|
||||
void controller.restart();
|
||||
}}
|
||||
>
|
||||
Reiniciar conversación con esta configuración
|
||||
</button>
|
||||
<small>La voz, las instrucciones y la puerta de ruido se actualizan en la sesión actual.</small>
|
||||
<small>
|
||||
Al guardar, la voz y las instrucciones se aplican a la sesión actual. Los cambios de servidor requieren
|
||||
una nueva conversación.
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<footer className="modal-footer">
|
||||
<button className="btn secondary" type="button" onClick={onClose}>
|
||||
Cancelar
|
||||
</button>
|
||||
<button className="btn primary" type="submit">
|
||||
Guardar
|
||||
</button>
|
||||
@@ -134,6 +143,15 @@ export function SettingsDialog({ state, controller, onClose }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
function GateMeter({ controller }: { controller: VoiceController }) {
|
||||
const level = useSyncExternalStore(controller.subscribeLevel, controller.getLevel);
|
||||
const meter = Math.max(
|
||||
0,
|
||||
Math.min(100, ((20 * Math.log10(level || 0.00001) - GATE_OFF) * 100) / (GATE_MAX - GATE_OFF)),
|
||||
);
|
||||
return <div className="gate-meter-fill" style={{ width: `${String(meter)}%` }} aria-hidden="true" />;
|
||||
}
|
||||
|
||||
export function RagDialog({ state, controller, onClose }: Props) {
|
||||
const [text, setText] = useState(state.rag);
|
||||
const [saved, setSaved] = useState(false);
|
||||
@@ -193,69 +211,89 @@ export function RagDialog({ state, controller, onClose }: Props) {
|
||||
}
|
||||
|
||||
export function ToolsDialog({ state, controller, onClose }: Props) {
|
||||
const [draft, setDraft] = useState(() => ({
|
||||
webSearch: state.settings.webSearch,
|
||||
searchKey: state.settings.searchKey,
|
||||
}));
|
||||
return (
|
||||
<Dialog title="Herramientas" onClose={onClose}>
|
||||
<p className="tools-intro">
|
||||
Permite que el asistente actúe durante la conversación. Los cambios se aplican de inmediato.
|
||||
Permite que el asistente actúe durante la conversación. Los cambios se aplican al guardar.
|
||||
</p>
|
||||
<div className="tool-list">
|
||||
<div className="tool-row">
|
||||
<div className="tool-info">
|
||||
<span className="tool-name">Búsqueda web</span>
|
||||
<span className="tool-desc">Busca información en Google mediante Serper.</span>
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
controller.changeSettings(draft);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<div className="tool-list">
|
||||
<div className="tool-row">
|
||||
<div className="tool-info">
|
||||
<span className="tool-name">Búsqueda web</span>
|
||||
<span className="tool-desc">Busca información en Google mediante Serper.</span>
|
||||
</div>
|
||||
<label className="switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label="Búsqueda web"
|
||||
checked={draft.webSearch}
|
||||
onChange={(event) => {
|
||||
setDraft({ ...draft, webSearch: event.target.checked });
|
||||
}}
|
||||
/>
|
||||
<span className="switch-track" aria-hidden="true" />
|
||||
</label>
|
||||
</div>
|
||||
<label className="switch">
|
||||
<label className="field tools-key">
|
||||
<span>Clave de la API de búsqueda</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label="Búsqueda web"
|
||||
checked={state.settings.webSearch}
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={draft.searchKey}
|
||||
disabled={state.config.search}
|
||||
onChange={(event) => {
|
||||
controller.changeSettings({ webSearch: event.target.checked });
|
||||
setDraft({ ...draft, searchKey: event.target.value });
|
||||
}}
|
||||
/>
|
||||
<span className="switch-track" aria-hidden="true" />
|
||||
<small>
|
||||
{state.config.search
|
||||
? "La búsqueda está configurada en el servidor."
|
||||
: "Introduce tu clave de Serper para activar la búsqueda. Se guarda en este navegador."}
|
||||
</small>
|
||||
</label>
|
||||
</div>
|
||||
<label className="field tools-key">
|
||||
<span>Clave de la API de búsqueda</span>
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={state.settings.searchKey}
|
||||
disabled={state.config.search}
|
||||
onChange={(event) => {
|
||||
controller.changeSettings({ searchKey: event.target.value });
|
||||
}}
|
||||
/>
|
||||
<small>
|
||||
{state.config.search
|
||||
? "La búsqueda está configurada en el servidor."
|
||||
: "Introduce tu clave de Serper para activar la búsqueda. Se guarda en este navegador."}
|
||||
</small>
|
||||
</label>
|
||||
<div className="tool-row tool-row-sep">
|
||||
<div className="tool-info">
|
||||
<span className="tool-name">Herramientas MCP cargadas</span>
|
||||
<span className="tool-desc">Herramientas publicadas por el perfil remoto del agente.</span>
|
||||
<div className="tool-row tool-row-sep">
|
||||
<div className="tool-info">
|
||||
<span className="tool-name">Herramientas MCP cargadas</span>
|
||||
<span className="tool-desc">Herramientas publicadas por el perfil remoto del agente.</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="tool-hint" role="status">
|
||||
{state.toolsLoading
|
||||
? "Cargando herramientas…"
|
||||
: state.toolsError ||
|
||||
(state.tools.length
|
||||
? `${String(state.tools.length)} herramientas disponibles.`
|
||||
: "No hay herramientas remotas disponibles.")}
|
||||
</p>
|
||||
<div className="agent-tools-list">
|
||||
{state.tools.map((tool) => (
|
||||
<article key={tool.name} className="agent-tool-item">
|
||||
<strong className="block text-sm">{tool.name}</strong>
|
||||
<p className="mt-1 text-sm opacity-75">{tool.description}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<p className="tool-hint" role="status">
|
||||
{state.toolsLoading
|
||||
? "Cargando herramientas…"
|
||||
: state.toolsError ||
|
||||
(state.tools.length
|
||||
? `${String(state.tools.length)} herramientas disponibles.`
|
||||
: "No hay herramientas remotas disponibles.")}
|
||||
</p>
|
||||
<div className="agent-tools-list">
|
||||
{state.tools.map((tool) => (
|
||||
<article key={tool.name} className="agent-tool-item">
|
||||
<strong className="block text-sm">{tool.name}</strong>
|
||||
<p className="mt-1 text-sm opacity-75">{tool.description}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<footer className="modal-footer">
|
||||
<button className="btn secondary" type="button" onClick={onClose}>
|
||||
Cancelar
|
||||
</button>
|
||||
<button className="btn primary" type="submit">
|
||||
Guardar
|
||||
</button>
|
||||
</footer>
|
||||
</form>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { CircleAlert, Mic, MicOff, Square, Volume2 } from "lucide-react";
|
||||
import { useEffect, useState, type CSSProperties, type PointerEvent } from "react";
|
||||
import { useEffect, useState, useSyncExternalStore, type CSSProperties, type PointerEvent } from "react";
|
||||
|
||||
import type { ConnectionStatus } from "../lib/contracts";
|
||||
import { GATE_MAX, GATE_OFF } from "../lib/settings";
|
||||
@@ -98,6 +98,45 @@ function Waveform({ side }: { side: "left" | "right" }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function MicrophoneGate({
|
||||
controller,
|
||||
threshold,
|
||||
muted,
|
||||
live,
|
||||
}: {
|
||||
controller: VoiceController;
|
||||
threshold: number;
|
||||
muted: boolean;
|
||||
live: boolean;
|
||||
}) {
|
||||
const level = useSyncExternalStore(controller.subscribeLevel, controller.getLevel);
|
||||
return (
|
||||
<div className={`mic-gate ${20 * Math.log10(level || 0.00001) >= threshold ? "gate-open" : ""}`}>
|
||||
{live && (
|
||||
<GateArc
|
||||
threshold={threshold}
|
||||
level={level}
|
||||
onChange={(noiseGate) => {
|
||||
controller.changeSettings({ noiseGate });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
className={`side-btn ${muted ? "muted" : ""}`}
|
||||
type="button"
|
||||
disabled={!live}
|
||||
tabIndex={live ? 0 : -1}
|
||||
aria-label={muted ? "Activar micrófono" : "Silenciar micrófono"}
|
||||
aria-pressed={muted}
|
||||
onClick={() => {
|
||||
controller.toggleMuted();
|
||||
}}
|
||||
>
|
||||
{muted ? <MicOff /> : <Mic />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function JoinCountdown({ deadline }: { deadline: number }) {
|
||||
const [now, setNow] = useState(Date.now);
|
||||
useEffect(() => {
|
||||
@@ -195,32 +234,12 @@ export function VoiceStage({ state, controller }: { state: AppState; controller:
|
||||
) : (
|
||||
<>
|
||||
<div className={`orb-wrap ${active ? "live" : ""}`}>
|
||||
<div
|
||||
className={`mic-gate ${20 * Math.log10(state.level || 0.00001) >= state.settings.noiseGate ? "gate-open" : ""}`}
|
||||
>
|
||||
{live && (
|
||||
<GateArc
|
||||
threshold={state.settings.noiseGate}
|
||||
level={state.level}
|
||||
onChange={(noiseGate) => {
|
||||
controller.changeSettings({ noiseGate });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
className={`side-btn ${state.muted ? "muted" : ""}`}
|
||||
type="button"
|
||||
disabled={!live}
|
||||
tabIndex={live ? 0 : -1}
|
||||
aria-label={muteLabel}
|
||||
aria-pressed={state.muted}
|
||||
onClick={() => {
|
||||
controller.toggleMuted();
|
||||
}}
|
||||
>
|
||||
{state.muted ? <MicOff /> : <Mic />}
|
||||
</button>
|
||||
</div>
|
||||
<MicrophoneGate
|
||||
controller={controller}
|
||||
threshold={state.settings.noiseGate}
|
||||
muted={state.muted}
|
||||
live={live}
|
||||
/>
|
||||
<button
|
||||
id="main-circle"
|
||||
className={`circle state-${status}`}
|
||||
|
||||
@@ -40,12 +40,16 @@ export function loadSettings(): Settings {
|
||||
searchKey: read("searchKey") ?? "",
|
||||
};
|
||||
}
|
||||
export function saveSettings(settings: Settings) {
|
||||
export function saveSettings(settings: Partial<Settings>) {
|
||||
try {
|
||||
for (const key of ["directUrl", "voice", "instructions", "noiseGate", "searchKey"] as const) {
|
||||
localStorage.setItem(`s2s.ws.${key}`, String(settings[key]));
|
||||
if (settings[key] !== undefined) {
|
||||
localStorage.setItem(`s2s.ws.${key}`, String(settings[key]));
|
||||
}
|
||||
}
|
||||
if (settings.webSearch !== undefined) {
|
||||
localStorage.setItem("s2s.ws.tools", JSON.stringify({ web_search: settings.webSearch }));
|
||||
}
|
||||
localStorage.setItem("s2s.ws.tools", JSON.stringify({ web_search: settings.webSearch }));
|
||||
} catch {
|
||||
/* The current session still works when browser storage is disabled. */
|
||||
}
|
||||
|
||||
@@ -87,6 +87,32 @@ async function start() {
|
||||
return client;
|
||||
}
|
||||
describe("voice application lifecycle", () => {
|
||||
it("notifies only meter subscribers when microphone level changes", async () => {
|
||||
const client = await start();
|
||||
const app = vi.fn();
|
||||
const meter = vi.fn();
|
||||
controller.subscribe(app);
|
||||
controller.subscribeLevel(meter);
|
||||
const snapshot = controller.getSnapshot();
|
||||
client.emit("level", 0.2);
|
||||
expect(controller.getLevel()).toBe(0.2);
|
||||
expect(meter).toHaveBeenCalledOnce();
|
||||
expect(app).not.toHaveBeenCalled();
|
||||
expect(controller.getSnapshot()).toBe(snapshot);
|
||||
});
|
||||
it("saves only changed preferences and keeps noise calibration out of session updates", async () => {
|
||||
const client = await start();
|
||||
const save = vi.spyOn(Storage.prototype, "setItem");
|
||||
client.updateSession.mockClear();
|
||||
controller.changeSettings({ noiseGate: -35 });
|
||||
expect(save).toHaveBeenCalledExactlyOnceWith("s2s.ws.noiseGate", "-35");
|
||||
expect(client.setGate).toHaveBeenCalledExactlyOnceWith({ enabled: true, thresholdDb: -35 });
|
||||
expect(client.updateSession).not.toHaveBeenCalled();
|
||||
controller.changeSettings({ voice: "Serena", instructions: "New instructions" });
|
||||
expect(client.updateSession).toHaveBeenCalledOnce();
|
||||
controller.changeSettings({ voice: "Serena", instructions: "New instructions" });
|
||||
expect(client.updateSession).toHaveBeenCalledOnce();
|
||||
});
|
||||
it("renders configuration and starts without waiting for optional reports", async () => {
|
||||
const original = fetchMock.getMockImplementation();
|
||||
if (!original) {
|
||||
|
||||
@@ -53,7 +53,6 @@ export interface AppState {
|
||||
notice: string;
|
||||
messages: Message[];
|
||||
muted: boolean;
|
||||
level: number;
|
||||
rag: string;
|
||||
queuePosition: number;
|
||||
joinDeadline: number;
|
||||
@@ -65,6 +64,8 @@ export class VoiceController {
|
||||
readonly caller: Caller;
|
||||
private state: AppState;
|
||||
private readonly listeners = new Set<() => void>();
|
||||
private readonly levelListeners = new Set<() => void>();
|
||||
private level = 0;
|
||||
private client: RealtimeClient | null = null;
|
||||
private subscriptions: (() => void)[] = [];
|
||||
private requests = new AbortController();
|
||||
@@ -98,7 +99,6 @@ export class VoiceController {
|
||||
notice: "",
|
||||
messages: [],
|
||||
muted: false,
|
||||
level: 0,
|
||||
rag: "",
|
||||
queuePosition: 0,
|
||||
joinDeadline: 0,
|
||||
@@ -106,6 +106,22 @@ export class VoiceController {
|
||||
};
|
||||
}
|
||||
getSnapshot = () => this.state;
|
||||
getLevel = () => this.level;
|
||||
subscribeLevel = (listener: () => void) => {
|
||||
this.levelListeners.add(listener);
|
||||
return () => {
|
||||
this.levelListeners.delete(listener);
|
||||
};
|
||||
};
|
||||
private updateLevel(level: number) {
|
||||
if (this.level === level) {
|
||||
return;
|
||||
}
|
||||
this.level = level;
|
||||
this.levelListeners.forEach((listener) => {
|
||||
listener();
|
||||
});
|
||||
}
|
||||
subscribe = (listener: () => void) => {
|
||||
this.listeners.add(listener);
|
||||
return () => {
|
||||
@@ -215,13 +231,28 @@ export class VoiceController {
|
||||
this.state.settings.voice,
|
||||
this.tools(),
|
||||
);
|
||||
this.client?.setGate(this.gate());
|
||||
}
|
||||
changeSettings(patch: Partial<Settings>) {
|
||||
const settings = { ...this.state.settings, ...patch };
|
||||
const changed = Object.fromEntries(
|
||||
Object.entries(patch).filter(([key, value]) => this.state.settings[key as keyof Settings] !== value),
|
||||
) as Partial<Settings>;
|
||||
if (!Object.keys(changed).length) {
|
||||
return;
|
||||
}
|
||||
const settings = { ...this.state.settings, ...changed };
|
||||
this.update({ settings });
|
||||
saveSettings(settings);
|
||||
this.pushSettings();
|
||||
saveSettings(changed);
|
||||
if (changed.noiseGate !== undefined) {
|
||||
this.client?.setGate(this.gate());
|
||||
}
|
||||
if (
|
||||
changed.voice !== undefined ||
|
||||
changed.instructions !== undefined ||
|
||||
changed.webSearch !== undefined ||
|
||||
changed.searchKey !== undefined
|
||||
) {
|
||||
this.pushSettings();
|
||||
}
|
||||
}
|
||||
setRag(rag: string) {
|
||||
this.update({ rag: rag.trim() });
|
||||
@@ -338,7 +369,7 @@ export class VoiceController {
|
||||
}
|
||||
if (Date.now() - this.lastMeter > 150) {
|
||||
this.lastMeter = Date.now();
|
||||
this.update({ level });
|
||||
this.updateLevel(level);
|
||||
}
|
||||
}),
|
||||
client.on("queue", ({ position }) => {
|
||||
@@ -574,10 +605,10 @@ export class VoiceController {
|
||||
this.requests.abort();
|
||||
this.cancelReasoning();
|
||||
window.speechSynthesis?.cancel();
|
||||
this.updateLevel(0);
|
||||
this.update({
|
||||
status,
|
||||
muted: false,
|
||||
level: 0,
|
||||
joinDeadline: 0,
|
||||
unlock: false,
|
||||
messages: this.state.messages.map((message) =>
|
||||
|
||||
Reference in New Issue
Block a user