refactor(dashboard): rethink settings, split out subreddits and accounts
The settings page had accumulated five tabs whose names no longer matched their contents, three different save models, and the whole subreddit configuration buried under a tab named after a checkbox. Structure: - Subreddits becomes its own page: a list showing each community's mode, tone, computed share of polling, and live sent/reply stats, with one subreddit edited at a time in a drawer. - Accounts becomes its own page. It is session lifecycle, not config. - Settings collapses to one scrolling page: Safety, Pacing (Monitoring and Rate limits merged), Targeting, Notifications, Advanced. - The sidebar is grouped, having gone from 9 to 11 destinations. Saving: - Every section owns its own draft, validation, risk gate and save via useConfigCard, submitting only the keys it owns. A syntax error in the advanced JSON can no longer block a rate-limit change, and enabling an autonomous policy is its own deliberate commit. - UnsavedChangesProvider restores the leave-page guard across cards that no longer share a draft. Subreddit editing: - Errors are keyed by field name instead of array index, removing the defensive invalidation that existed because row indices shifted on add and remove. Confirming an autonomous mode can no longer be attributed to the wrong row. - Weight is shown as the share it actually produces, live while editing. - Bulk actions cover start/pause polling, tone, policy and removal, because production runs 30-40 subreddits and per-row editing does not scale. Bulk policy changes name every subreddit turning autonomous, and normalise the hybrid mode's turn budget, which has no form to catch it. Physique targeting moves into its own Targeting card. It sends only reddit.physiqueTargeting, which the config route applies independently, so it cannot clobber the pacing card's fields. Turning screening on now warns that it will reduce how many posts qualify. Also surfaces the effective sending rate, since the hourly limit and the minimum delay both throttle and only the binding one matters. Legacy ?section= links redirect or resolve to anchors. Fixes found while verifying in a browser: a radio-card rule was shrinking nested number inputs; .share-preview had no styles; the throughput readout reported a rate from a blank field; and the reconcile logic warned about a server change whenever a sibling card saved. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
11b6300113
commit
c43e534cf1
36 files changed
+4414
-1632
No files matched your search
@@ -0,0 +1,499 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import type { ConfigResponse } from '../../../../../packages/api-contracts/src/index.js';
|
||||
import { Modal } from '../../components/modal.js';
|
||||
import { useSubredditPerformanceQuery } from '../performance/performance-queries.js';
|
||||
import { RiskDialog } from '../settings/risk-dialog.js';
|
||||
import { useSaveSettingsMutation, useSettingsQuery } from '../settings/settings-queries.js';
|
||||
import { SubredditBulkBar } from './subreddit-bulk-bar.js';
|
||||
import { SubredditEditor } from './subreddit-editor.js';
|
||||
import {
|
||||
computeShares,
|
||||
conversationModeMeta,
|
||||
formatShare,
|
||||
withConversationMode,
|
||||
type ConversationMode,
|
||||
type SubredditConfig,
|
||||
} from './subreddit-model.js';
|
||||
import './subreddits.css';
|
||||
|
||||
type SortKey = 'share' | 'name' | 'sent';
|
||||
|
||||
const sortOptions: ReadonlyArray<{ value: SortKey; label: string }> = [
|
||||
{ value: 'share', label: 'Share of polling' },
|
||||
{ value: 'name', label: 'Name' },
|
||||
{ value: 'sent', label: 'Messages sent' },
|
||||
];
|
||||
|
||||
interface SubredditStats {
|
||||
totalSent: number;
|
||||
totalResponses: number;
|
||||
responseRate: number;
|
||||
}
|
||||
|
||||
function errorMessage(error: Error | null): string {
|
||||
return error?.message ?? 'Something went wrong.';
|
||||
}
|
||||
|
||||
function formatRate(rate: number): string {
|
||||
return `${Math.round(rate * 100)}%`;
|
||||
}
|
||||
|
||||
/** Which row a pending mutation belongs to, so only that row shows as busy. */
|
||||
type PendingAction =
|
||||
| { kind: 'toggle' | 'delete'; name: string }
|
||||
| { kind: 'editor' }
|
||||
| { kind: 'bulk' }
|
||||
| null;
|
||||
|
||||
/** A bulk change held back until the operator confirms its consequences. */
|
||||
interface PendingBulk {
|
||||
subreddits: SubredditConfig[];
|
||||
reasons: string[];
|
||||
}
|
||||
|
||||
export function SubredditsPage() {
|
||||
const settingsQuery = useSettingsQuery();
|
||||
const performanceQuery = useSubredditPerformanceQuery();
|
||||
const saveMutation = useSaveSettingsMutation();
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const [sortKey, setSortKey] = useState<SortKey>('share');
|
||||
const [editorTarget, setEditorTarget] = useState<{ subreddit: SubredditConfig | null } | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<SubredditConfig | null>(null);
|
||||
const [pendingAction, setPendingAction] = useState<PendingAction>(null);
|
||||
const [selectedNames, setSelectedNames] = useState<readonly string[]>([]);
|
||||
const [pendingBulk, setPendingBulk] = useState<PendingBulk | null>(null);
|
||||
const [isBulkRemoveOpen, setIsBulkRemoveOpen] = useState(false);
|
||||
|
||||
const subreddits = settingsQuery.data?.subreddits ?? [];
|
||||
const shares = useMemo(() => computeShares(subreddits), [subreddits]);
|
||||
|
||||
const statsByName = useMemo(() => {
|
||||
const map = new Map<string, SubredditStats>();
|
||||
for (const row of performanceQuery.data ?? []) {
|
||||
map.set(row.subreddit.toLowerCase(), {
|
||||
totalSent: row.totalSent,
|
||||
totalResponses: row.totalResponses,
|
||||
responseRate: row.responseRate,
|
||||
});
|
||||
}
|
||||
return map;
|
||||
}, [performanceQuery.data]);
|
||||
|
||||
const visible = useMemo(() => {
|
||||
const term = search.trim().toLowerCase();
|
||||
const filtered = term === ''
|
||||
? [...subreddits]
|
||||
: subreddits.filter((subreddit) => subreddit.name.toLowerCase().includes(term));
|
||||
return filtered.sort((left, right) => {
|
||||
if (sortKey === 'name') return left.name.localeCompare(right.name);
|
||||
if (sortKey === 'sent') {
|
||||
const leftSent = statsByName.get(left.name.toLowerCase())?.totalSent ?? 0;
|
||||
const rightSent = statsByName.get(right.name.toLowerCase())?.totalSent ?? 0;
|
||||
return rightSent - leftSent || left.name.localeCompare(right.name);
|
||||
}
|
||||
const leftShare = shares.get(left.name)?.share ?? 0;
|
||||
const rightShare = shares.get(right.name)?.share ?? 0;
|
||||
return rightShare - leftShare || left.name.localeCompare(right.name);
|
||||
});
|
||||
}, [search, sortKey, shares, statsByName, subreddits]);
|
||||
|
||||
const enabledCount = subreddits.filter((subreddit) => subreddit.enabled).length;
|
||||
const autonomousCount = subreddits.filter((subreddit) =>
|
||||
conversationModeMeta(subreddit.conversation.mode).isAutonomous && subreddit.enabled).length;
|
||||
|
||||
/** Every write replaces the whole array, rebuilt from the persisted config. */
|
||||
function commit(nextSubreddits: SubredditConfig[], action: PendingAction, onDone?: () => void) {
|
||||
setPendingAction(action);
|
||||
saveMutation.mutate({ subreddits: nextSubreddits }, {
|
||||
onSuccess() {
|
||||
setPendingAction(null);
|
||||
onDone?.();
|
||||
},
|
||||
onError() {
|
||||
setPendingAction(null);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function toggleEnabled(target: SubredditConfig, enabled: boolean) {
|
||||
commit(
|
||||
subreddits.map((subreddit) => (subreddit.name === target.name ? { ...subreddit, enabled } : subreddit)),
|
||||
{ kind: 'toggle', name: target.name },
|
||||
);
|
||||
}
|
||||
|
||||
function saveFromEditor(config: SubredditConfig) {
|
||||
const editing = editorTarget?.subreddit ?? null;
|
||||
const next = editing
|
||||
? subreddits.map((subreddit) => (subreddit.name === editing.name ? config : subreddit))
|
||||
: [...subreddits, config];
|
||||
commit(next, { kind: 'editor' }, () => setEditorTarget(null));
|
||||
}
|
||||
|
||||
function confirmDelete(target: SubredditConfig) {
|
||||
commit(
|
||||
subreddits.filter((subreddit) => subreddit.name !== target.name),
|
||||
{ kind: 'delete', name: target.name },
|
||||
() => setDeleteTarget(null),
|
||||
);
|
||||
}
|
||||
|
||||
const isLastSubreddit = subreddits.length <= 1;
|
||||
|
||||
// --- selection and bulk actions ------------------------------------------
|
||||
|
||||
const selected = useMemo(
|
||||
() => subreddits.filter((subreddit) => selectedNames.includes(subreddit.name)),
|
||||
[selectedNames, subreddits],
|
||||
);
|
||||
|
||||
// Drop names that no longer exist, so a removal cannot leave the bulk bar
|
||||
// acting on ghosts.
|
||||
useEffect(() => {
|
||||
setSelectedNames((current) => {
|
||||
const live = current.filter((name) => subreddits.some((subreddit) => subreddit.name === name));
|
||||
return live.length === current.length ? current : live;
|
||||
});
|
||||
}, [subreddits]);
|
||||
|
||||
const visibleNames = visible.map((subreddit) => subreddit.name);
|
||||
const areAllVisibleSelected = visibleNames.length > 0
|
||||
&& visibleNames.every((name) => selectedNames.includes(name));
|
||||
|
||||
function toggleSelected(name: string, isSelected: boolean) {
|
||||
setSelectedNames((current) => (isSelected
|
||||
? current.includes(name) ? current : [...current, name]
|
||||
: current.filter((item) => item !== name)));
|
||||
}
|
||||
|
||||
/** Selects or clears every row the current filter shows, not the whole list. */
|
||||
function toggleAllVisible(isSelected: boolean) {
|
||||
setSelectedNames((current) => (isSelected
|
||||
? [...new Set([...current, ...visibleNames])]
|
||||
: current.filter((name) => !visibleNames.includes(name))));
|
||||
}
|
||||
|
||||
/** Applies `change` to the selection, confirming first when `reasons` apply. */
|
||||
function applyBulk(change: (subreddit: SubredditConfig) => SubredditConfig, reasons: string[] = []) {
|
||||
const next = subreddits.map((subreddit) =>
|
||||
(selectedNames.includes(subreddit.name) ? change(subreddit) : subreddit));
|
||||
if (reasons.length > 0) {
|
||||
setPendingBulk({ subreddits: next, reasons });
|
||||
return;
|
||||
}
|
||||
commit(next, { kind: 'bulk' });
|
||||
}
|
||||
|
||||
function bulkSetPolicy(mode: ConversationMode) {
|
||||
const meta = conversationModeMeta(mode);
|
||||
// Only rows actually changing into an autonomous mode need approval.
|
||||
const newlyAutonomous = selected.filter((subreddit) =>
|
||||
meta.isAutonomous && subreddit.conversation.mode !== mode);
|
||||
const reasons = newlyAutonomous.length === 0 ? [] : [
|
||||
`${newlyAutonomous.length === 1 ? '1 subreddit' : `${newlyAutonomous.length} subreddits`} will reply autonomously, without approval for each message: ${newlyAutonomous.map((subreddit) => `r/${subreddit.name}`).join(', ')}.`,
|
||||
'Conversations already in progress keep the policy they started with.',
|
||||
];
|
||||
applyBulk((subreddit) => withConversationMode(subreddit, mode), reasons);
|
||||
}
|
||||
|
||||
function bulkRemove() {
|
||||
const next = subreddits.filter((subreddit) => !selectedNames.includes(subreddit.name));
|
||||
commit(next, { kind: 'bulk' }, () => {
|
||||
setIsBulkRemoveOpen(false);
|
||||
setSelectedNames([]);
|
||||
});
|
||||
}
|
||||
|
||||
const isBulkBusy = pendingAction?.kind === 'bulk' && saveMutation.isPending;
|
||||
const removeBlockedReason = selected.length >= subreddits.length
|
||||
? 'At least one subreddit must stay configured.'
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className="page subreddits-page">
|
||||
<header className="page-header">
|
||||
<div>
|
||||
<span className="eyebrow">Targeting</span>
|
||||
<h1>Subreddits</h1>
|
||||
<p>The communities the bot polls, how much attention each one gets, and how it is allowed to talk there.</p>
|
||||
</div>
|
||||
{settingsQuery.data ? (
|
||||
<div className="header-actions">
|
||||
<button className="button primary" onClick={() => setEditorTarget({ subreddit: null })} type="button">
|
||||
Add subreddit
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
{settingsQuery.isPending ? (
|
||||
<div aria-label="Loading subreddits" className="page-state" role="status">
|
||||
<span className="spinner" />Loading subreddits…
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{settingsQuery.isError ? (
|
||||
<div className="page-state error-state" role="alert">
|
||||
<strong>Subreddits unavailable</strong>
|
||||
<span>{errorMessage(settingsQuery.error)}</span>
|
||||
<button className="button secondary" onClick={() => void settingsQuery.refetch()} type="button">Retry</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{settingsQuery.data ? (
|
||||
<>
|
||||
<div className="subreddit-summary">
|
||||
<div><strong>{enabledCount}</strong><span>of {subreddits.length} being polled</span></div>
|
||||
<div><strong>{autonomousCount}</strong><span>replying autonomously</span></div>
|
||||
{saveMutation.isError ? (
|
||||
<p className="save-error" role="alert">{errorMessage(saveMutation.error)}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="subreddit-toolbar">
|
||||
<label className="field inline-field">
|
||||
<span className="field-label">Filter</span>
|
||||
<span className="field-input has-prefix">
|
||||
<span aria-hidden="true" className="field-prefix">r/</span>
|
||||
<input
|
||||
aria-label="Filter subreddits by name"
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder="Search"
|
||||
type="search"
|
||||
value={search}
|
||||
/>
|
||||
</span>
|
||||
</label>
|
||||
<label className="field inline-field">
|
||||
<span className="field-label">Sort by</span>
|
||||
<select aria-label="Sort subreddits" onChange={(event) => setSortKey(event.target.value as SortKey)} value={sortKey}>
|
||||
{sortOptions.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
{visible.length > 0 ? (
|
||||
<label className="inline-switch select-all">
|
||||
<input
|
||||
aria-label={search.trim() === '' ? 'Select all subreddits' : `Select all ${visible.length} matching subreddits`}
|
||||
checked={areAllVisibleSelected}
|
||||
onChange={(event) => toggleAllVisible(event.target.checked)}
|
||||
type="checkbox"
|
||||
/>
|
||||
<span>{search.trim() === '' ? 'Select all' : `Select all ${visible.length} matching`}</span>
|
||||
</label>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{visible.length === 0 ? (
|
||||
<div className="panel empty-state">
|
||||
<strong>No subreddits match “{search}”</strong>
|
||||
<span>Clear the filter to see all {subreddits.length}.</span>
|
||||
</div>
|
||||
) : (
|
||||
<ul aria-label="Configured subreddits" className="subreddit-cards">
|
||||
{visible.map((subreddit) => {
|
||||
const mode = conversationModeMeta(subreddit.conversation.mode);
|
||||
const share = shares.get(subreddit.name);
|
||||
const stats = statsByName.get(subreddit.name.toLowerCase());
|
||||
const isToggling = pendingAction?.kind === 'toggle' && pendingAction.name === subreddit.name;
|
||||
return (
|
||||
<li
|
||||
className={[
|
||||
'subreddit-card',
|
||||
subreddit.enabled ? '' : 'is-disabled',
|
||||
selectedNames.includes(subreddit.name) ? 'is-selected' : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
key={subreddit.name}
|
||||
>
|
||||
<label className="subreddit-select">
|
||||
<input
|
||||
aria-label={`Select r/${subreddit.name}`}
|
||||
checked={selectedNames.includes(subreddit.name)}
|
||||
onChange={(event) => toggleSelected(subreddit.name, event.target.checked)}
|
||||
type="checkbox"
|
||||
/>
|
||||
</label>
|
||||
<div className="subreddit-card-main">
|
||||
<div className="subreddit-identity">
|
||||
<h3>r/{subreddit.name}</h3>
|
||||
<span className="subreddit-tags">
|
||||
<span className={mode.isAutonomous ? 'pill warning' : 'pill neutral'}>{mode.badge}</span>
|
||||
<span className="pill neutral">{subreddit.type === 'image' ? 'Image' : 'Chat'}</span>
|
||||
<span className="pill neutral">{subreddit.tone}</span>
|
||||
{subreddit.scripted ? <span className="pill neutral">Scripted</span> : null}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<dl className="subreddit-metrics">
|
||||
<div>
|
||||
<dt>Share</dt>
|
||||
<dd>
|
||||
{subreddit.enabled ? formatShare(share?.share ?? 0) : 'Off'}
|
||||
{subreddit.enabled ? <small>weight {share?.effectiveWeight ?? 1}</small> : null}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Sent</dt>
|
||||
<dd>{stats ? stats.totalSent : '—'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Replies</dt>
|
||||
<dd>
|
||||
{stats ? stats.totalResponses : '—'}
|
||||
{stats && stats.totalSent > 0 ? <small>{formatRate(stats.responseRate)} rate</small> : null}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div className="subreddit-card-actions">
|
||||
{/* A single reversible field, so it commits on change
|
||||
rather than pretending to need a save step. */}
|
||||
<label className="inline-switch">
|
||||
<input
|
||||
aria-label={`Poll r/${subreddit.name}`}
|
||||
checked={subreddit.enabled}
|
||||
disabled={isToggling}
|
||||
onChange={(event) => toggleEnabled(subreddit, event.target.checked)}
|
||||
type="checkbox"
|
||||
/>
|
||||
<span>{isToggling ? 'Saving…' : subreddit.enabled ? 'Polling' : 'Paused'}</span>
|
||||
</label>
|
||||
<button
|
||||
className="button secondary"
|
||||
onClick={() => setEditorTarget({ subreddit })}
|
||||
type="button"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
className="button secondary"
|
||||
disabled={isLastSubreddit}
|
||||
onClick={() => setDeleteTarget(subreddit)}
|
||||
title={isLastSubreddit ? 'At least one subreddit must stay configured.' : undefined}
|
||||
type="button"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{performanceQuery.isError ? (
|
||||
<p className="field-hint" role="status">
|
||||
Per-subreddit performance could not be loaded, so Sent and Replies are unavailable.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{selected.length > 0 ? (
|
||||
<SubredditBulkBar
|
||||
isBusy={isBulkBusy}
|
||||
onClear={() => setSelectedNames([])}
|
||||
onRemove={() => setIsBulkRemoveOpen(true)}
|
||||
onSetEnabled={(enabled) => applyBulk((subreddit) => ({ ...subreddit, enabled }))}
|
||||
onSetPolicy={bulkSetPolicy}
|
||||
onSetTone={(tone) => applyBulk((subreddit) => ({ ...subreddit, tone }))}
|
||||
removeBlockedReason={removeBlockedReason}
|
||||
selected={selected}
|
||||
tones={settingsQuery.data.tones}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{editorTarget && settingsQuery.data ? (
|
||||
<SubredditEditor
|
||||
allSubreddits={subreddits}
|
||||
isSaving={pendingAction?.kind === 'editor' && saveMutation.isPending}
|
||||
onCancel={() => setEditorTarget(null)}
|
||||
onSave={saveFromEditor}
|
||||
saveError={saveMutation.isError ? saveMutation.error : null}
|
||||
subreddit={editorTarget.subreddit}
|
||||
tones={settingsQuery.data.tones}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{pendingBulk ? (
|
||||
<RiskDialog
|
||||
isPending={isBulkBusy}
|
||||
onCancel={() => setPendingBulk(null)}
|
||||
onConfirm={() => commit(pendingBulk.subreddits, { kind: 'bulk' }, () => setPendingBulk(null))}
|
||||
reasons={pendingBulk.reasons}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{isBulkRemoveOpen ? (
|
||||
<Modal
|
||||
className="dialog-card"
|
||||
isDismissLocked={isBulkBusy}
|
||||
labelledBy="bulk-remove-title"
|
||||
onClose={() => setIsBulkRemoveOpen(false)}
|
||||
>
|
||||
<span className="eyebrow">Remove subreddits</span>
|
||||
<h2 id="bulk-remove-title">
|
||||
{selected.length === 1 ? 'Stop using 1 subreddit?' : `Stop using ${selected.length} subreddits?`}
|
||||
</h2>
|
||||
<p>
|
||||
They will no longer be polled and their configuration is discarded. Conversations already started there
|
||||
are not affected, and their history stays in reporting.
|
||||
</p>
|
||||
<ul className="risk-list">
|
||||
{selected.map((subreddit) => <li key={subreddit.name}>r/{subreddit.name}</li>)}
|
||||
</ul>
|
||||
<div className="dialog-actions">
|
||||
<button
|
||||
autoFocus
|
||||
className="button secondary"
|
||||
disabled={isBulkBusy}
|
||||
onClick={() => setIsBulkRemoveOpen(false)}
|
||||
type="button"
|
||||
>
|
||||
Keep them
|
||||
</button>
|
||||
<button className="button danger" disabled={isBulkBusy} onClick={bulkRemove} type="button">
|
||||
{isBulkBusy ? 'Removing…' : 'Remove subreddits'}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
) : null}
|
||||
|
||||
{deleteTarget ? (
|
||||
<Modal
|
||||
className="dialog-card"
|
||||
isDismissLocked={saveMutation.isPending}
|
||||
labelledBy="remove-subreddit-title"
|
||||
onClose={() => setDeleteTarget(null)}
|
||||
>
|
||||
<span className="eyebrow">Remove subreddit</span>
|
||||
<h2 id="remove-subreddit-title">Stop using r/{deleteTarget.name}?</h2>
|
||||
<p>
|
||||
It will no longer be polled and its configuration is discarded. Conversations already started there are
|
||||
not affected, and its history stays in reporting.
|
||||
</p>
|
||||
<div className="dialog-actions">
|
||||
<button
|
||||
autoFocus
|
||||
className="button secondary"
|
||||
disabled={saveMutation.isPending}
|
||||
onClick={() => setDeleteTarget(null)}
|
||||
type="button"
|
||||
>
|
||||
Keep it
|
||||
</button>
|
||||
<button
|
||||
className="button danger"
|
||||
disabled={saveMutation.isPending}
|
||||
onClick={() => confirmDelete(deleteTarget)}
|
||||
type="button"
|
||||
>
|
||||
{saveMutation.isPending ? 'Removing…' : 'Remove subreddit'}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user