mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
refactor(equalizer): co-locate equalizer UI into features/equalizer (eqStore/eqCurve stay audio-core)
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Search, X, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { useAutoEq } from '@/features/equalizer/hooks/useAutoEq';
|
||||
|
||||
/** Collapsible AutoEQ search panel inside the Equalizer. Owns its own search
|
||||
* state via {@link useAutoEq}. */
|
||||
export default function AutoEqSection() {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
autoEqOpen, autoEqQuery, autoEqResults, autoEqLoading, autoEqError, autoEqApplied,
|
||||
entriesLoading, setAutoEqQuery, setAutoEqError, setAutoEqResults,
|
||||
applyAutoEqResult, toggleOpen,
|
||||
} = useAutoEq();
|
||||
|
||||
return (
|
||||
<div className="eq-autoeq-section">
|
||||
<button className="eq-autoeq-toggle" onClick={toggleOpen}>
|
||||
<Search size={13} />
|
||||
<span>{t('settings.eqAutoEqTitle')}</span>
|
||||
{autoEqOpen ? <ChevronUp size={13} /> : <ChevronDown size={13} />}
|
||||
</button>
|
||||
|
||||
{autoEqOpen && (
|
||||
<div className="eq-autoeq-body">
|
||||
<div className="eq-autoeq-search-row">
|
||||
<input
|
||||
className="input"
|
||||
placeholder={t('settings.eqAutoEqPlaceholder')}
|
||||
value={autoEqQuery}
|
||||
onChange={e => { setAutoEqQuery(e.target.value); setAutoEqError(null); }}
|
||||
autoFocus
|
||||
style={{ flex: 1, padding: '6px 12px', fontSize: 13 }}
|
||||
/>
|
||||
{autoEqQuery && (
|
||||
<button className="eq-ctrl-btn" onClick={() => { setAutoEqQuery(''); setAutoEqResults([]); }}>
|
||||
<X size={13} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(entriesLoading || autoEqLoading) && (
|
||||
<div className="eq-autoeq-status">{t('settings.eqAutoEqSearching')}</div>
|
||||
)}
|
||||
{autoEqError && (
|
||||
<div className="eq-autoeq-status eq-autoeq-error">{autoEqError}</div>
|
||||
)}
|
||||
{autoEqApplied && (
|
||||
<div className="eq-autoeq-status eq-autoeq-applied">✓ {autoEqApplied}</div>
|
||||
)}
|
||||
|
||||
{autoEqResults.length > 0 && (
|
||||
<div className="eq-autoeq-results">
|
||||
{autoEqResults.map((r, i) => (
|
||||
<button
|
||||
key={`${r.name}|${r.source}|${i}`}
|
||||
className="eq-autoeq-result-btn"
|
||||
onClick={() => applyAutoEqResult(r)}
|
||||
>
|
||||
<span>{r.name}</span>
|
||||
<span className="eq-autoeq-result-source">{r.source}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!entriesLoading && !autoEqLoading && !autoEqError && autoEqQuery.length >= 2 && autoEqResults.length === 0 && (
|
||||
<div className="eq-autoeq-status">{t('settings.eqAutoEqNoResults')}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { cleanup, screen } from '@testing-library/react';
|
||||
import { renderWithProviders } from '@/test/helpers/renderWithProviders';
|
||||
import Equalizer from '@/features/equalizer/components/Equalizer';
|
||||
import { useEqStore } from '@/store/eqStore';
|
||||
|
||||
const FLAT = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
|
||||
|
||||
function resetEq(over: Record<string, unknown> = {}): void {
|
||||
useEqStore.setState({
|
||||
gains: [...FLAT],
|
||||
enabled: true,
|
||||
preGain: 0,
|
||||
activePreset: 'Flat',
|
||||
customPresets: [],
|
||||
...over,
|
||||
});
|
||||
}
|
||||
|
||||
describe('Equalizer preset picker', () => {
|
||||
beforeEach(() => resetEq());
|
||||
afterEach(() => cleanup());
|
||||
|
||||
it('shows the active AutoEQ profile name in the picker', () => {
|
||||
// AutoEQ sets activePreset to the headphone name — neither a built-in nor a
|
||||
// saved custom preset. It must still be visible in the picker.
|
||||
resetEq({ activePreset: 'Sennheiser HD 600', customPresets: [] });
|
||||
const { container } = renderWithProviders(<Equalizer />);
|
||||
|
||||
expect(screen.getByText('Sennheiser HD 600')).toBeInTheDocument();
|
||||
// AutoEQ profiles are not deletable custom presets → no delete button.
|
||||
expect(container.querySelector('[data-tooltip="Delete preset"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows the delete button only for a saved custom preset', () => {
|
||||
resetEq({
|
||||
activePreset: 'My Mix',
|
||||
customPresets: [{ name: 'My Mix', gains: [...FLAT], builtin: false }],
|
||||
});
|
||||
const { container } = renderWithProviders(<Equalizer />);
|
||||
|
||||
expect(screen.getByText('My Mix')).toBeInTheDocument();
|
||||
expect(container.querySelector('[data-tooltip="Delete preset"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('shows no delete button for a built-in preset', () => {
|
||||
resetEq({ activePreset: 'Rock' });
|
||||
const { container } = renderWithProviders(<Equalizer />);
|
||||
|
||||
expect(container.querySelector('[data-tooltip="Delete preset"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Save, Trash2, RotateCcw } from 'lucide-react';
|
||||
import CustomSelect from '@/ui/CustomSelect';
|
||||
import { useEqStore, EQ_BANDS, BUILTIN_PRESETS } from '@/store/eqStore';
|
||||
import { useThemeStore } from '@/store/themeStore';
|
||||
import { drawCurve } from '@/features/playback/utils/audio/eqCurve';
|
||||
import VerticalFader from '@/features/equalizer/components/VerticalFader';
|
||||
import AutoEqSection from '@/features/equalizer/components/AutoEqSection';
|
||||
|
||||
export default function Equalizer() {
|
||||
const { t } = useTranslation();
|
||||
const gains = useEqStore(s => s.gains);
|
||||
const enabled = useEqStore(s => s.enabled);
|
||||
const preGain = useEqStore(s => s.preGain);
|
||||
const activePreset = useEqStore(s => s.activePreset);
|
||||
const customPresets = useEqStore(s => s.customPresets);
|
||||
const { setBandGain, setEnabled, setPreGain, applyPreset, saveCustomPreset, deleteCustomPreset } = useEqStore();
|
||||
|
||||
const [saveName, setSaveName] = useState('');
|
||||
const [showSave, setShowSave] = useState(false);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
const theme = useThemeStore(s => s.theme);
|
||||
|
||||
const redraw = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const style = getComputedStyle(document.documentElement);
|
||||
const accent = style.getPropertyValue('--accent').trim() || 'rgb(203, 166, 247)';
|
||||
const bg = style.getPropertyValue('--bg-app').trim() || '#1e1e2e';
|
||||
const text = style.getPropertyValue('--text-muted').trim() || 'rgba(255,255,255,0.4)';
|
||||
drawCurve(canvas, gains, accent, bg, text);
|
||||
// theme is an intentional re-create trigger: redraw reads the live CSS custom
|
||||
// properties via getComputedStyle, so it must re-run when the theme changes
|
||||
// even though the `theme` value itself is not referenced here.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [gains, theme]);
|
||||
|
||||
useEffect(() => { redraw(); }, [redraw]);
|
||||
|
||||
useEffect(() => {
|
||||
const ro = new ResizeObserver(redraw);
|
||||
if (canvasRef.current) ro.observe(canvasRef.current);
|
||||
return () => ro.disconnect();
|
||||
}, [redraw]);
|
||||
|
||||
// ResizeObserver does not always fire for the `display: none → block`
|
||||
// transition the surrounding <details> causes when toggled, leaving the
|
||||
// canvas blank on the second open. Redraw explicitly when the parent
|
||||
// <details> opens; rAF waits one frame for the canvas to take its size.
|
||||
useEffect(() => {
|
||||
const details = canvasRef.current?.closest('details');
|
||||
if (!details) return;
|
||||
const onToggle = () => {
|
||||
if (details.open) requestAnimationFrame(redraw);
|
||||
};
|
||||
details.addEventListener('toggle', onToggle);
|
||||
return () => details.removeEventListener('toggle', onToggle);
|
||||
}, [redraw]);
|
||||
|
||||
const isCustomSaved = activePreset !== null && customPresets.some(p => p.name === activePreset);
|
||||
const isBuiltin = activePreset !== null && BUILTIN_PRESETS.some(p => p.name === activePreset);
|
||||
// AutoEQ profiles store the headphone name in activePreset, which is neither a
|
||||
// built-in nor a saved custom preset — surface it in the picker so the active
|
||||
// profile name stays visible after the lookup clears.
|
||||
const isAutoEq = activePreset !== null && !isBuiltin && !isCustomSaved;
|
||||
const selectValue = activePreset ?? '__custom__';
|
||||
|
||||
const handleSave = () => {
|
||||
const name = saveName.trim();
|
||||
if (!name) return;
|
||||
saveCustomPreset(name);
|
||||
setSaveName('');
|
||||
setShowSave(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="eq-wrap">
|
||||
{/* Controls bar */}
|
||||
<div className="eq-controls-bar">
|
||||
<label className="eq-toggle-label">
|
||||
<span>{t('settings.eqEnabled')}</span>
|
||||
<label className="toggle-switch" style={{ marginLeft: 8 }}>
|
||||
<input type="checkbox" checked={enabled} onChange={e => setEnabled(e.target.checked)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</label>
|
||||
|
||||
<div className="eq-preset-row">
|
||||
<CustomSelect
|
||||
className="eq-preset-select"
|
||||
value={selectValue}
|
||||
onChange={v => applyPreset(v)}
|
||||
options={[
|
||||
...(activePreset === null ? [{ value: '__custom__', label: t('settings.eqPresetCustom'), disabled: true }] : []),
|
||||
...(isAutoEq ? [{ value: activePreset!, label: activePreset!, group: t('settings.eqPresetAutoEqGroup') }] : []),
|
||||
...BUILTIN_PRESETS.map(p => ({ value: p.name, label: p.name, group: t('settings.eqPresetBuiltin') })),
|
||||
...customPresets.map(p => ({ value: p.name, label: p.name, group: t('settings.eqPresetCustomGroup') })),
|
||||
]}
|
||||
/>
|
||||
|
||||
{isCustomSaved && (
|
||||
<button className="eq-ctrl-btn" onClick={() => deleteCustomPreset(activePreset!)} data-tooltip={t('settings.eqDeletePreset')}>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
)}
|
||||
<button className="eq-ctrl-btn" onClick={() => applyPreset('Flat')} data-tooltip={t('settings.eqResetBands')}>
|
||||
<RotateCcw size={13} />
|
||||
</button>
|
||||
<button className="eq-ctrl-btn" onClick={() => setShowSave(v => !v)} data-tooltip={t('settings.eqSavePreset')}>
|
||||
<Save size={13} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showSave && (
|
||||
<div className="eq-save-row">
|
||||
<input
|
||||
type="text" className="input" placeholder={t('settings.eqPresetName')}
|
||||
value={saveName} onChange={e => setSaveName(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleSave()}
|
||||
autoFocus style={{ flex: 1, padding: '6px 12px', fontSize: 13 }}
|
||||
/>
|
||||
<button className="btn btn-primary" onClick={handleSave} disabled={!saveName.trim()}>{t('common.save')}</button>
|
||||
<button className="btn btn-ghost" onClick={() => { setShowSave(false); setSaveName(''); }}>{t('common.cancel')}</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AutoEqSection />
|
||||
|
||||
{/* EQ panel */}
|
||||
<div className={`eq-panel ${!enabled ? 'eq-panel--off' : ''}`}>
|
||||
{/* Frequency response */}
|
||||
<canvas ref={canvasRef} className="eq-canvas" />
|
||||
|
||||
{/* Fader area */}
|
||||
<div className="eq-faders">
|
||||
{/* dB scale */}
|
||||
<div className="eq-db-scale">
|
||||
{[12, 6, 0, -6, -12].map(db => (
|
||||
<span key={db} className="eq-db-tick">
|
||||
{db > 0 ? `+${db}` : db}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Bands */}
|
||||
{EQ_BANDS.map((band, i) => (
|
||||
<div key={band.freq} className="eq-band">
|
||||
<span className="eq-gain-val">
|
||||
{gains[i] > 0 ? '+' : ''}{gains[i].toFixed(1)}
|
||||
</span>
|
||||
<div className="eq-fader-track">
|
||||
<div className="eq-zero-mark" />
|
||||
<VerticalFader
|
||||
value={gains[i]}
|
||||
disabled={!enabled}
|
||||
onChange={v => setBandGain(i, v)}
|
||||
/>
|
||||
</div>
|
||||
<span className="eq-freq-label">{band.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pre-gain row */}
|
||||
<div className="eq-pregain-row">
|
||||
<span className="eq-pregain-label">{t('settings.eqPreGain')}</span>
|
||||
<input
|
||||
type="range"
|
||||
className="eq-pregain-slider"
|
||||
min={-30} max={6} step={0.1}
|
||||
value={preGain}
|
||||
disabled={!enabled}
|
||||
onChange={e => setPreGain(parseFloat(e.target.value))}
|
||||
/>
|
||||
<span className="eq-pregain-val">
|
||||
{preGain > 0 ? '+' : ''}{preGain.toFixed(1)} dB
|
||||
</span>
|
||||
{preGain !== 0 && (
|
||||
<button className="eq-ctrl-btn" onClick={() => setPreGain(0)} data-tooltip={t('settings.eqResetPreGain')} style={{ marginLeft: 4 }}>
|
||||
<RotateCcw size={11} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useRef, useCallback } from 'react';
|
||||
|
||||
// ─── Custom vertical fader (no native range input) ────────────────────────────
|
||||
|
||||
const GAIN_MIN = -12, GAIN_MAX = 12;
|
||||
|
||||
interface FaderProps {
|
||||
value: number;
|
||||
disabled: boolean;
|
||||
onChange: (v: number) => void;
|
||||
}
|
||||
|
||||
export default function VerticalFader({ value, disabled, onChange }: FaderProps) {
|
||||
const trackRef = useRef<HTMLDivElement>(null);
|
||||
const dragging = useRef(false);
|
||||
|
||||
const gainToPct = (g: number) => (GAIN_MAX - g) / (GAIN_MAX - GAIN_MIN); // 0=top, 1=bottom
|
||||
const pctToGain = (p: number) => GAIN_MAX - p * (GAIN_MAX - GAIN_MIN);
|
||||
|
||||
const updateFromY = useCallback((clientY: number) => {
|
||||
const el = trackRef.current;
|
||||
if (!el) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const pct = Math.max(0, Math.min(1, (clientY - rect.top) / rect.height));
|
||||
const gain = parseFloat((Math.round(pctToGain(pct) / 0.1) * 0.1).toFixed(1)); // snap to 0.1 dB
|
||||
onChange(Math.max(GAIN_MIN, Math.min(GAIN_MAX, gain)));
|
||||
}, [onChange]);
|
||||
|
||||
const onPointerDown = (e: React.PointerEvent) => {
|
||||
if (disabled) return;
|
||||
dragging.current = true;
|
||||
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
|
||||
updateFromY(e.clientY);
|
||||
};
|
||||
|
||||
const onPointerMove = (e: React.PointerEvent) => {
|
||||
if (!dragging.current || disabled) return;
|
||||
updateFromY(e.clientY);
|
||||
};
|
||||
|
||||
const onPointerUp = () => { dragging.current = false; };
|
||||
|
||||
const thumbPct = gainToPct(value) * 100;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={trackRef}
|
||||
className="eq-fader-custom"
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
style={{ cursor: disabled ? 'default' : 'pointer' }}
|
||||
>
|
||||
<div className="eq-track-line" />
|
||||
<div className="eq-thumb" style={{ top: `${thumbPct}%`, opacity: disabled ? 0.3 : 1 }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { useEqStore } from '@/store/eqStore';
|
||||
import { parseFixedBandEqString, type AutoEqVariant, type AutoEqResult } from '@/features/playback/utils/audio/autoEqParse';
|
||||
|
||||
/** AutoEQ search/apply state for the Equalizer. Loads the entries index lazily
|
||||
* when the section opens, filters client-side, and applies fetched profiles via
|
||||
* the EQ store. */
|
||||
export function useAutoEq() {
|
||||
const { t } = useTranslation();
|
||||
const applyAutoEq = useEqStore(s => s.applyAutoEq);
|
||||
|
||||
const [autoEqOpen, setAutoEqOpen] = useState(false);
|
||||
const [autoEqQuery, setAutoEqQuery] = useState('');
|
||||
const [autoEqResults, setAutoEqResults] = useState<AutoEqResult[]>([]);
|
||||
const [autoEqLoading, setAutoEqLoading] = useState(false);
|
||||
const [autoEqError, setAutoEqError] = useState<string | null>(null);
|
||||
const [autoEqApplied, setAutoEqApplied] = useState<string | null>(null);
|
||||
const [entriesLoading, setEntriesLoading] = useState(false);
|
||||
const entriesCacheRef = useRef<Record<string, AutoEqVariant[]> | null>(null);
|
||||
|
||||
// AutoEQ: load entries index lazily when section opens, then filter client-side
|
||||
async function ensureEntries() {
|
||||
if (entriesCacheRef.current) return;
|
||||
setEntriesLoading(true);
|
||||
setAutoEqError(null);
|
||||
try {
|
||||
const json = await invoke<string>('autoeq_entries');
|
||||
entriesCacheRef.current = JSON.parse(json);
|
||||
} catch (e: unknown) {
|
||||
setAutoEqError(e instanceof Error ? e.message : t('settings.eqAutoEqError'));
|
||||
} finally {
|
||||
setEntriesLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const q = autoEqQuery.trim().toLowerCase();
|
||||
if (!entriesCacheRef.current || q.length < 1) { setAutoEqResults([]); return; }
|
||||
const flat: AutoEqResult[] = [];
|
||||
for (const [name, variants] of Object.entries(entriesCacheRef.current)) {
|
||||
if (!name.toLowerCase().includes(q)) continue;
|
||||
for (const v of variants) {
|
||||
flat.push({ name, source: v.source, rig: v.rig, form: v.form });
|
||||
if (flat.length >= 20) break;
|
||||
}
|
||||
if (flat.length >= 20) break;
|
||||
}
|
||||
setAutoEqResults(flat);
|
||||
// entriesLoading in deps: re-runs after entries finish loading so a query typed
|
||||
// during loading produces results immediately without needing a re-type.
|
||||
}, [autoEqQuery, entriesLoading]);
|
||||
|
||||
async function applyAutoEqResult(result: AutoEqResult) {
|
||||
setAutoEqLoading(true);
|
||||
setAutoEqError(null);
|
||||
try {
|
||||
const text = await invoke<string>('autoeq_fetch_profile', {
|
||||
name: result.name,
|
||||
source: result.source,
|
||||
rig: result.rig ?? null,
|
||||
form: result.form,
|
||||
});
|
||||
if (!text) throw new Error(t('settings.eqAutoEqFetchError'));
|
||||
const { gains: newGains, preamp } = parseFixedBandEqString(text);
|
||||
applyAutoEq(result.name, newGains, preamp);
|
||||
setAutoEqApplied(result.name);
|
||||
setAutoEqQuery('');
|
||||
setAutoEqResults([]);
|
||||
setTimeout(() => setAutoEqApplied(null), 3000);
|
||||
} catch (e: unknown) {
|
||||
setAutoEqError(e instanceof Error ? e.message : t('settings.eqAutoEqFetchError'));
|
||||
} finally {
|
||||
setAutoEqLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const toggleOpen = () => {
|
||||
const opening = !autoEqOpen;
|
||||
setAutoEqOpen(opening);
|
||||
setAutoEqQuery('');
|
||||
setAutoEqResults([]);
|
||||
setAutoEqError(null);
|
||||
if (opening) ensureEntries();
|
||||
};
|
||||
|
||||
return {
|
||||
autoEqOpen, autoEqQuery, autoEqResults, autoEqLoading, autoEqError, autoEqApplied,
|
||||
entriesLoading, setAutoEqQuery, setAutoEqError, setAutoEqResults,
|
||||
applyAutoEqResult, toggleOpen,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Equalizer feature — the graphic EQ panel (band faders, preset picker, EQ
|
||||
* curve canvas) plus the auto-EQ section and its hook. Mounted from the
|
||||
* settings audio tab and the player bar.
|
||||
*
|
||||
* Stays OUT (audio-core global state, consumed by the playback engine, not
|
||||
* owned): `store/eqStore` (drives the audio graph; read by `eqDeviceSync` /
|
||||
* `eqCurve` in the playback core) and the `eqCurve` renderer.
|
||||
*/
|
||||
export { default as Equalizer } from './components/Equalizer';
|
||||
@@ -2,7 +2,7 @@ import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Blend, Gauge, Sliders, Volume2, Waves } from 'lucide-react';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import Equalizer from '@/components/Equalizer';
|
||||
import { Equalizer } from '@/features/equalizer';
|
||||
import SettingsSubSection from '@/features/settings/components/SettingsSubSection';
|
||||
import { SettingsGroup } from '@/features/settings/components/SettingsGroup';
|
||||
import { SettingsToggle } from '@/features/settings/components/SettingsToggle';
|
||||
|
||||
Reference in New Issue
Block a user