mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
feat(voice): render the voice control from a presenter
createVoiceControl now returns a presenter instead of building the control with innerHTML: the session, cost, input, card and panel owners write its state by part and listen for interactions, and VoiceControl.vue renders it in the command dock, between the location bar and the visual presets as before. Focus moves wait for the render; the waveform's bars publish apart from the control and only when they change; the minutes field keeps a draft until its owner writes it. Owner tests use a real presenter on their own store, and view tests render the control.
This commit is contained in:
1 parent
2ffd7a4afd
commit
1de4f635ca
47 files changed
+1691
-1645
No files matched your search
+161
-86
@@ -1,12 +1,54 @@
|
||||
import '../../testSupport/registerDom.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createPinia } from 'pinia';
|
||||
import { createVoiceControl } from '../control.js';
|
||||
import { useVoiceStore } from '../../ui/stores/voice.js';
|
||||
import { createGevActionRunner } from '../gevActions.js';
|
||||
import { createVoiceCommands as bind } from '../sessionCommands.js';
|
||||
import { createVoiceCommands as compose } from '../commands.js';
|
||||
import { createProviderCommands } from '../providerCommands.js';
|
||||
import { createVoicePanelHost } from './host.js';
|
||||
import { installPanelDom, controlledClock } from './testDom.mjs';
|
||||
import { createTestVoiceUi } from '../../testSupport/voiceUi.mjs';
|
||||
import { mountVoiceView } from '../../testSupport/voiceView.mjs';
|
||||
|
||||
const $ = (selector) => globalThis.document.querySelector(selector);
|
||||
const exists = (selector) => $(selector) !== null;
|
||||
const active = () => globalThis.document.activeElement?.id ?? null;
|
||||
const key = (node, name) =>
|
||||
node.dispatchEvent(
|
||||
new KeyboardEvent('keydown', {
|
||||
key: name,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
);
|
||||
|
||||
function controlledClock() {
|
||||
let now = 0,
|
||||
next = 0;
|
||||
const tasks = new Map();
|
||||
return {
|
||||
now: () => now,
|
||||
setTimer(fn, ms) {
|
||||
const id = ++next;
|
||||
tasks.set(id, { fn, at: now + ms });
|
||||
return id;
|
||||
},
|
||||
clearTimer: (id) => tasks.delete(id),
|
||||
advance(ms) {
|
||||
now += ms;
|
||||
for (const [id, task] of [...tasks])
|
||||
if (task.at <= now && tasks.delete(id)) task.fn();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// The custom inactivity minutes, then the select's Custom choice.
|
||||
function chooseCustomTimeout(ui, minutes) {
|
||||
ui.update('preferences', { timeoutMinutes: minutes });
|
||||
ui.emit('timeout-select', 'custom');
|
||||
}
|
||||
|
||||
function factory(instances, { stopError = null, native = false } = {}) {
|
||||
return (hooks) => {
|
||||
@@ -78,8 +120,10 @@ function factory(instances, { stopError = null, native = false } = {}) {
|
||||
return adapter;
|
||||
};
|
||||
}
|
||||
// With `view`, the real VoiceControl renders the control from its store.
|
||||
function mounted(t, options = {}) {
|
||||
const doc = installPanelDom(t);
|
||||
const view = options.view ? mountVoiceView(t) : null;
|
||||
const pinia = view?.pinia ?? createPinia();
|
||||
const clock = controlledClock();
|
||||
const host = createVoicePanelHost({
|
||||
storage: options.storage ?? null,
|
||||
@@ -91,6 +135,7 @@ function mounted(t, options = {}) {
|
||||
runner: options.runner ?? (async () => ({ ok: true })),
|
||||
dataManager: { layers: new Map() },
|
||||
createSession: factory(instances, options),
|
||||
createControl: (control) => createTestVoiceUi({ ...control, pinia }),
|
||||
panelHost: host,
|
||||
nativePanelProvider: options.native
|
||||
? (options.nativeProvider ?? 'gemini')
|
||||
@@ -102,42 +147,66 @@ function mounted(t, options = {}) {
|
||||
} catch {}
|
||||
host.destroy();
|
||||
});
|
||||
return { doc, clock, host, instances, commands, ui: commands.ui };
|
||||
return {
|
||||
clock,
|
||||
host,
|
||||
instances,
|
||||
commands,
|
||||
ui: commands.ui,
|
||||
settle: view?.settle,
|
||||
};
|
||||
}
|
||||
|
||||
test('actual control default remains opt-out; panel markup has unique controlled panes and an outside alert', (t) => {
|
||||
const doc = installPanelDom(t);
|
||||
const baseline = createVoiceControl();
|
||||
assert.equal(baseline.voicePanel, null);
|
||||
const ui = createVoiceControl({ reset: true, panel: true });
|
||||
assert.equal(doc.getElementById('gev-voice-panel'), ui.voicePanel);
|
||||
test('actual control default remains opt-out; panel markup has unique controlled panes and an outside alert', async (t) => {
|
||||
const view = mountVoiceView(t);
|
||||
const store = useVoiceStore(view.pinia);
|
||||
const baseline = createVoiceControl({ store });
|
||||
t.after(() => baseline.remove());
|
||||
assert.equal(baseline.panel, false);
|
||||
await view.settle();
|
||||
assert.equal(exists('#gev-voice-control'), true);
|
||||
assert.equal(exists('#gev-voice-panel'), false);
|
||||
const ui = createVoiceControl({ panel: true, store });
|
||||
t.after(() => ui.remove());
|
||||
await view.settle();
|
||||
const panel = $('#gev-voice-panel');
|
||||
assert.equal(panel !== null, true);
|
||||
assert.equal(
|
||||
ui.voiceSettingsButton.getAttribute('aria-controls'),
|
||||
ui.voicePanel.id,
|
||||
$('#gev-voice-settings-button').getAttribute('aria-controls'),
|
||||
panel.id,
|
||||
);
|
||||
assert.equal(ui.previewTab.getAttribute('aria-controls'), ui.previewPanel.id);
|
||||
for (const [tab, pane] of [
|
||||
['#gev-voice-preview-tab', 'gev-voice-preview-panel'],
|
||||
['#gev-voice-settings-tab', 'gev-voice-settings-panel'],
|
||||
]) {
|
||||
assert.equal($(tab).getAttribute('aria-controls'), pane);
|
||||
assert.equal(exists(`#${pane}`), true);
|
||||
}
|
||||
assert.equal(
|
||||
ui.settingsTab.getAttribute('aria-controls'),
|
||||
ui.voiceSettingsPanel.id,
|
||||
$('#gev-voice-preview-panel').contains($('#gev-voice-card')),
|
||||
true,
|
||||
);
|
||||
assert.equal(ui.previewPanel.contains(ui.card), true);
|
||||
const tray = $('#gev-voice-control .gev-voice-error-tray');
|
||||
assert.equal(tray !== null, true);
|
||||
assert.equal(panel.contains(tray), false);
|
||||
assert.equal(
|
||||
ui.voicePanel.contains(ui.root.querySelector('.gev-voice-error-tray')),
|
||||
false,
|
||||
$('#gev-voice-control').querySelectorAll('#gev-voice-card').length,
|
||||
1,
|
||||
);
|
||||
assert.equal(ui.root.querySelectorAll('#gev-voice-card').length, 1);
|
||||
});
|
||||
|
||||
test('actual binder/card has launcher toggle, idempotent tabs, focus and disabled Preview', (t) => {
|
||||
const { ui, commands, doc } = mounted(t);
|
||||
ui.voiceSettingsButton.fire('click');
|
||||
test('actual binder/card has launcher toggle, idempotent tabs, focus and disabled Preview', async (t) => {
|
||||
const { ui, commands, settle } = mounted(t, { view: true });
|
||||
await settle();
|
||||
$('#gev-voice-settings-button').click();
|
||||
assert.equal(commands.voicePanel.snapshot().settingsOpen, true);
|
||||
ui.voiceSettingsButton.fire('click');
|
||||
$('#gev-voice-settings-button').click();
|
||||
assert.equal(commands.voicePanel.snapshot().panelOpen, false);
|
||||
ui.settingsTab.fire('click');
|
||||
ui.settingsTab.fire('keydown', { key: 'End' });
|
||||
await settle();
|
||||
$('#gev-voice-settings-tab').click();
|
||||
key($('#gev-voice-settings-tab'), 'End');
|
||||
assert.equal(commands.voicePanel.snapshot().settingsOpen, true);
|
||||
ui.settingsTab.fire('click');
|
||||
$('#gev-voice-settings-tab').click();
|
||||
assert.equal(commands.voicePanel.snapshot().settingsOpen, true);
|
||||
commands.voicePanel.select('preview');
|
||||
commands.voiceCard.handle({
|
||||
@@ -146,17 +215,19 @@ test('actual binder/card has launcher toggle, idempotent tabs, focus and disable
|
||||
final: true,
|
||||
text: 'A visible reply',
|
||||
});
|
||||
const dismiss = ui.root.querySelector('#gev-voice-card-dismiss');
|
||||
await settle();
|
||||
const dismiss = $('#gev-voice-card-dismiss');
|
||||
dismiss.focus();
|
||||
dismiss.fire('click');
|
||||
assert.equal(ui.card.hidden, true);
|
||||
dismiss.click();
|
||||
await settle();
|
||||
assert.equal(ui.state.card.visible, false);
|
||||
assert.equal($('#gev-voice-card').hidden, true);
|
||||
assert.equal(commands.voicePanel.snapshot().panelOpen, true);
|
||||
assert.equal(doc.activeElement?.id, ui.previewTab.id);
|
||||
ui.voicePanel.fire('keydown', { key: 'Escape', target: doc.activeElement });
|
||||
assert.equal(active(), 'gev-voice-preview-tab');
|
||||
key(globalThis.document.activeElement, 'Escape');
|
||||
assert.equal(commands.voicePanel.snapshot().panelOpen, false);
|
||||
commands.voicePanel.select('settings');
|
||||
ui.voicePreviewInput.checked = false;
|
||||
ui.voicePreviewInput.fire('change');
|
||||
ui.emit('preview-change', false);
|
||||
commands.voicePanel.select('preview');
|
||||
commands.voiceCard.handle({
|
||||
type: 'transcript',
|
||||
@@ -164,11 +235,13 @@ test('actual binder/card has launcher toggle, idempotent tabs, focus and disable
|
||||
final: true,
|
||||
text: 'A hidden reply',
|
||||
});
|
||||
assert.equal(ui.card.hidden, true);
|
||||
assert.equal(ui.previewEmpty.textContent, 'Preview is off.');
|
||||
ui.previewTab.focus();
|
||||
ui.voicePanel.fire('keydown', { key: 'Escape' });
|
||||
assert.equal(doc.activeElement, ui.voiceSettingsButton);
|
||||
assert.equal(ui.state.card.visible, false);
|
||||
assert.equal(ui.state.previewEmpty.text, 'Preview is off.');
|
||||
await settle();
|
||||
$('#gev-voice-preview-tab').focus();
|
||||
key(globalThis.document.activeElement, 'Escape');
|
||||
await settle();
|
||||
assert.equal(active(), 'gev-voice-settings-button');
|
||||
commands.voiceCard.handle({
|
||||
type: 'transcript',
|
||||
role: 'assistant',
|
||||
@@ -179,7 +252,7 @@ test('actual binder/card has launcher toggle, idempotent tabs, focus and disable
|
||||
});
|
||||
|
||||
test('actual facade remount keeps failed-save live preferences/view and manual close, with no restore writes or focus theft', async (t) => {
|
||||
const doc = installPanelDom(t);
|
||||
const view = mountVoiceView(t);
|
||||
const writes = [];
|
||||
const storage = {
|
||||
getItem: () => null,
|
||||
@@ -197,7 +270,13 @@ test('actual facade remount keeps failed-save live preferences/view and manual c
|
||||
supportedProviders: ['openai', 'gemini'],
|
||||
},
|
||||
{
|
||||
bind: (options) => bind({ ...options, panelHost: host }),
|
||||
bind: (options) =>
|
||||
bind({
|
||||
...options,
|
||||
panelHost: host,
|
||||
createControl: (control) =>
|
||||
createTestVoiceUi({ ...control, pinia: view.pinia }),
|
||||
}),
|
||||
factories: { openai: factory(instances), gemini: factory(instances) },
|
||||
storage,
|
||||
},
|
||||
@@ -208,18 +287,17 @@ test('actual facade remount keeps failed-save live preferences/view and manual c
|
||||
});
|
||||
const old = commands.ui;
|
||||
commands.voicePanel.select('settings');
|
||||
old.voicePreviewInput.checked = false;
|
||||
old.voicePreviewInput.fire('change');
|
||||
old.voiceTimeoutInput.value = '7';
|
||||
old.voiceTimeoutSelect.value = 'custom';
|
||||
old.voiceTimeoutSelect.fire('change');
|
||||
old.emit('preview-change', false);
|
||||
chooseCustomTimeout(old, '7');
|
||||
const preferenceWrites = () =>
|
||||
writes.filter(([key]) => key.startsWith('godsEyeView.voice.')).length;
|
||||
assert.equal(preferenceWrites(), 2);
|
||||
old.providerSelect.value = 'gemini';
|
||||
old.providerSelect.fire('change');
|
||||
assert.notEqual(commands.ui, old);
|
||||
assert.equal(old.root.isConnected, false);
|
||||
await view.settle();
|
||||
const oldRoot = $('#gev-voice-control');
|
||||
old.emit('provider-change', 'gemini');
|
||||
assert.equal(commands.ui !== old, true);
|
||||
await view.settle();
|
||||
assert.equal(oldRoot.isConnected, false);
|
||||
assert.equal(commands.voicePanel.snapshot().settingsOpen, true);
|
||||
assert.deepEqual(commands.getVoicePreferences(), {
|
||||
previewEnabled: false,
|
||||
@@ -227,17 +305,16 @@ test('actual facade remount keeps failed-save live preferences/view and manual c
|
||||
previewPersisted: false,
|
||||
timeoutPersisted: false,
|
||||
});
|
||||
assert.equal(commands.ui.voicePreviewInput.checked, false);
|
||||
assert.match(commands.ui.voiceTimeoutNote.textContent, /could not be saved/);
|
||||
assert.equal(commands.ui.state.preferences.previewChecked, false);
|
||||
assert.equal($('#gev-voice-preview-enabled').checked, false);
|
||||
assert.match(commands.ui.state.preferences.timeoutNote, /could not be saved/);
|
||||
assert.equal(preferenceWrites(), 2);
|
||||
assert.equal(doc.activeElement, commands.ui.providerSelect);
|
||||
commands.ui.voicePreviewInput.checked = true;
|
||||
commands.ui.voicePreviewInput.fire('change');
|
||||
assert.equal(active(), 'gev-voice-provider');
|
||||
commands.ui.emit('preview-change', true);
|
||||
commands.voicePanel.close();
|
||||
await commands.start();
|
||||
const retired = instances[1];
|
||||
commands.ui.providerSelect.value = 'openai';
|
||||
commands.ui.providerSelect.fire('change');
|
||||
commands.ui.emit('provider-change', 'openai');
|
||||
await commands.start();
|
||||
assert.equal(commands.session.isActive(), true);
|
||||
retired.hooks.emit({
|
||||
@@ -268,14 +345,14 @@ test('explicit successful mic Stop closes; failed Stop idle and startup error do
|
||||
const good = mounted(t);
|
||||
await good.commands.session.start();
|
||||
good.commands.voicePanel.select('settings');
|
||||
good.ui.button.fire('click');
|
||||
good.ui.emit('button');
|
||||
assert.equal(good.commands.voicePanel.snapshot().panelOpen, false);
|
||||
const error = Error('stop failed');
|
||||
const bad = mounted(t, { stopError: error });
|
||||
await bad.commands.session.start();
|
||||
bad.commands.voicePanel.select('settings');
|
||||
assert.throws(
|
||||
() => bad.ui.button.fire('click'),
|
||||
() => bad.ui.emit('button'),
|
||||
(value) => value === error,
|
||||
);
|
||||
assert.equal(bad.commands.session.state, 'idle');
|
||||
@@ -296,7 +373,7 @@ test('reentrant manual choice/new start during Stop cannot close the successor c
|
||||
if (event.type === 'state' && event.state === 'idle')
|
||||
f.commands.voicePanel.select('preview');
|
||||
});
|
||||
f.ui.button.fire('click');
|
||||
f.ui.emit('button');
|
||||
assert.equal(f.commands.voicePanel.snapshot().panelOpen, true);
|
||||
assert.equal(f.commands.voicePanel.snapshot().view, 'preview');
|
||||
unsubscribe();
|
||||
@@ -306,9 +383,7 @@ test('integrated inactivity rechecks held PTT at expiry, then normal stop closes
|
||||
const f = mounted(t, { native: true });
|
||||
await f.commands.session.start();
|
||||
f.commands.voicePanel.select('settings');
|
||||
f.ui.voiceTimeoutInput.value = '1';
|
||||
f.ui.voiceTimeoutSelect.value = 'custom';
|
||||
f.ui.voiceTimeoutSelect.fire('change');
|
||||
chooseCustomTimeout(f.ui, '1');
|
||||
f.instances[0].input.pushToTalkKeyHeld = true;
|
||||
f.clock.advance(60000);
|
||||
assert.equal(f.instances[0].stops, 0);
|
||||
@@ -321,8 +396,7 @@ test('integrated inactivity rechecks held PTT at expiry, then normal stop closes
|
||||
test('None cancels integrated app timer and unknown custom hosts never guess expiry', async (t) => {
|
||||
const native = mounted(t, { native: true });
|
||||
await native.commands.session.start();
|
||||
native.ui.voiceTimeoutSelect.value = '0';
|
||||
native.ui.voiceTimeoutSelect.fire('change');
|
||||
native.ui.emit('timeout-select', '0');
|
||||
native.clock.advance(600000);
|
||||
assert.equal(native.instances[0].stops, 0);
|
||||
assert.equal(native.commands.getVoicePreferences().minutes, 0);
|
||||
@@ -330,17 +404,15 @@ test('None cancels integrated app timer and unknown custom hosts never guess exp
|
||||
await unknown.commands.session.start();
|
||||
unknown.clock.advance(600000);
|
||||
assert.equal(unknown.instances[0].stops, 0);
|
||||
assert.match(unknown.ui.voiceTimeoutStatus.textContent, /unavailable/);
|
||||
assert.match(unknown.ui.state.preferences.timeoutStatus, /unavailable/);
|
||||
});
|
||||
|
||||
test('transient native OpenAI response activity does not announce connection unavailability', async (t) => {
|
||||
const f = mounted(t, { native: true, nativeProvider: 'openai' });
|
||||
await f.commands.session.start();
|
||||
f.ui.voiceTimeoutInput.value = '1';
|
||||
f.ui.voiceTimeoutSelect.value = 'custom';
|
||||
f.ui.voiceTimeoutSelect.fire('change');
|
||||
chooseCustomTimeout(f.ui, '1');
|
||||
f.instances[0].controller._turns.observeSpeech({ type: 'response.created' });
|
||||
assert.equal(f.ui.voiceTimeoutStatus.textContent, '');
|
||||
assert.equal(f.ui.state.preferences.timeoutStatus, '');
|
||||
f.clock.advance(60000);
|
||||
assert.equal(f.instances[0].stops, 0, 'unknown output still prevents expiry');
|
||||
f.instances[0].controller._turns.observeSpeech({
|
||||
@@ -351,7 +423,6 @@ test('transient native OpenAI response activity does not announce connection una
|
||||
});
|
||||
|
||||
test('native factory with custom controller remains uninstrumented despite panel opt-in', (t) => {
|
||||
installPanelDom(t);
|
||||
let inspected = 0;
|
||||
const commands = compose({
|
||||
panel: true,
|
||||
@@ -404,7 +475,6 @@ async function directActions(
|
||||
t,
|
||||
{ storage = null, delayExit = false, exposePanel = true } = {},
|
||||
) {
|
||||
installPanelDom(t);
|
||||
let f, releaseExit;
|
||||
const state = { active: true, exits: 0, visual: [] };
|
||||
const styleManager = {
|
||||
@@ -479,8 +549,7 @@ test('named voice settings exits Cockpit directly; Preview stays disabled and vi
|
||||
});
|
||||
assert.equal(f.state.exits, 1);
|
||||
assert.equal(result.settingsOpen, true);
|
||||
f.ui.voicePreviewInput.checked = false;
|
||||
f.ui.voicePreviewInput.fire('change');
|
||||
f.ui.emit('preview-change', false);
|
||||
const preview = await f.run('set_panel_open', {
|
||||
panelId: 'voice control',
|
||||
open: true,
|
||||
@@ -575,7 +644,7 @@ test('voice timeout change preserves a pending voice reveal', async (t) => {
|
||||
});
|
||||
|
||||
test('active provider output reveals Preview after done; manual choice wins until genuine input, replays do not reset idle', async (t) => {
|
||||
const f = mounted(t, { native: true, nativeProvider: 'openai' });
|
||||
const f = mounted(t, { native: true, nativeProvider: 'openai', view: true });
|
||||
await f.commands.session.start();
|
||||
const emit = f.instances[0].hooks.emit;
|
||||
const reply = (text, responseId = 'r1', extra = {}) =>
|
||||
@@ -638,6 +707,7 @@ test('Preview off and actual Cockpit state suppress automatic presentation witho
|
||||
let cockpit = false;
|
||||
const f = mounted(t, {
|
||||
native: true,
|
||||
view: true,
|
||||
readCockpitState: () => ({ active: cockpit }),
|
||||
});
|
||||
await f.commands.session.start();
|
||||
@@ -645,32 +715,37 @@ test('Preview off and actual Cockpit state suppress automatic presentation witho
|
||||
const reply = (text) =>
|
||||
emit({ type: 'transcript', role: 'assistant', text, final: true });
|
||||
reply('Visible');
|
||||
f.ui.previewTab.focus();
|
||||
await f.settle();
|
||||
$('#gev-voice-preview-tab').focus();
|
||||
cockpit = true;
|
||||
window.dispatchEvent(new Event('gev:cockpit-mode-changed'));
|
||||
assert.equal(f.ui.voicePanel.hidden, true);
|
||||
assert.equal(f.doc.activeElement, f.ui.voiceSettingsButton);
|
||||
assert.equal(f.ui.state.voicePanel.hidden, true);
|
||||
await f.settle();
|
||||
assert.equal(active(), 'gev-voice-settings-button');
|
||||
f.commands.voicePanel.select('settings', { focus: true });
|
||||
assert.equal(f.doc.activeElement, f.ui.settingsTab);
|
||||
await f.settle();
|
||||
assert.equal(active(), 'gev-voice-settings-tab');
|
||||
f.commands.voicePanel.select('preview', { focus: true });
|
||||
assert.equal(f.doc.activeElement, f.ui.voiceSettingsButton);
|
||||
const outside = f.doc.createElement('button');
|
||||
f.doc.body.appendChild(outside);
|
||||
await f.settle();
|
||||
assert.equal(active(), 'gev-voice-settings-button');
|
||||
const outside = document.createElement('button');
|
||||
outside.id = 'unrelated';
|
||||
document.body.appendChild(outside);
|
||||
outside.focus();
|
||||
window.dispatchEvent(new Event('gev:cockpit-mode-changed'));
|
||||
assert.equal(f.doc.activeElement, outside);
|
||||
await f.settle();
|
||||
assert.equal(active(), 'unrelated');
|
||||
assert.equal(f.commands.voicePanel.snapshot().previewVisible, false);
|
||||
reply('Suppressed in Cockpit');
|
||||
assert.equal(f.ui.voicePanel.hidden, true);
|
||||
assert.equal(f.ui.state.voicePanel.hidden, true);
|
||||
f.commands.voicePanel.close();
|
||||
cockpit = false;
|
||||
window.dispatchEvent(new Event('gev:cockpit-mode-changed'));
|
||||
assert.equal(f.ui.voicePanel.hidden, true);
|
||||
f.ui.voicePreviewInput.checked = false;
|
||||
f.ui.voicePreviewInput.fire('change');
|
||||
assert.equal(f.ui.state.voicePanel.hidden, true);
|
||||
f.ui.emit('preview-change', false);
|
||||
emit({ type: 'interruption', reason: 'user-speech' });
|
||||
reply('Preference stays off');
|
||||
assert.equal(f.ui.voicePanel.hidden, true);
|
||||
assert.equal(f.ui.state.voicePanel.hidden, true);
|
||||
assert.equal(f.commands.getVoicePreferences().previewEnabled, false);
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user