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:
Jamie BruntonandClaude Opus 5 committed 2026-08-11 09:07:31 +01:00
1 parent 11b6300113
commit c43e534cf1
36 files changed
+4379 -1597

No files matched your search

+7 -2
View File
@@ -1,6 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { NavLink, Outlet } from 'react-router-dom'; import { NavLink, Outlet } from 'react-router-dom';
import { navigationItems } from './navigation.js'; import { navigationGroups, navigationItemsByGroup } from './navigation.js';
import { ShellStatusPanel } from './shell-status-panel.js'; import { ShellStatusPanel } from './shell-status-panel.js';
import { NotificationBell, NotificationProvider, NotificationViewport } from './notifications.js'; import { NotificationBell, NotificationProvider, NotificationViewport } from './notifications.js';
@@ -20,7 +20,10 @@ export function AppShell() {
<NotificationBell /> <NotificationBell />
</div> </div>
<nav aria-label="Primary navigation" id="primary-navigation"> <nav aria-label="Primary navigation" id="primary-navigation">
{navigationItems.map((item) => ( {navigationGroups.map((group) => (
<div className="nav-group" key={group}>
<span className="nav-group-label">{group}</span>
{navigationItemsByGroup(group).map((item) => (
<NavLink <NavLink
aria-label={item.label} aria-label={item.label}
className={({ isActive }) => (isActive ? 'nav-link is-active' : 'nav-link')} className={({ isActive }) => (isActive ? 'nav-link is-active' : 'nav-link')}
@@ -33,6 +36,8 @@ export function AppShell() {
<small>{item.description}</small> <small>{item.description}</small>
</NavLink> </NavLink>
))} ))}
</div>
))}
</nav> </nav>
<ShellStatusPanel /> <ShellStatusPanel />
</aside> </aside>
+21 -9
View File
@@ -2,16 +2,28 @@ export interface NavigationItem {
label: string; label: string;
path: string; path: string;
description: string; description: string;
group: NavigationGroup;
} }
export type NavigationGroup = 'Monitor' | 'Content' | 'Analyse' | 'Configure';
/** Render order for the sidebar's group headings. */
export const navigationGroups: readonly NavigationGroup[] = ['Monitor', 'Content', 'Analyse', 'Configure'];
export const navigationItems: readonly NavigationItem[] = [ export const navigationItems: readonly NavigationItem[] = [
{ label: 'Overview', path: '/', description: 'System health and current activity' }, { label: 'Overview', path: '/', description: 'System health and current activity', group: 'Monitor' },
{ label: 'Needs attention', path: '/attention', description: 'Handoffs, failures, and account risks' }, { label: 'Needs attention', path: '/attention', description: 'Handoffs, failures, and account risks', group: 'Monitor' },
{ label: 'Outreach funnel', path: '/outreach', description: 'Active posts moving toward delivery' }, { label: 'Outreach funnel', path: '/outreach', description: 'Active posts moving toward delivery', group: 'Monitor' },
{ label: 'Conversations', path: '/conversations', description: 'Inbox and message history' }, { label: 'Conversations', path: '/conversations', description: 'Inbox and message history', group: 'Monitor' },
{ label: 'Media', path: '/media', description: 'Private media and sync controls' }, { label: 'Media', path: '/media', description: 'Private media and sync controls', group: 'Content' },
{ label: 'Exchange library', path: '/media-library', description: 'Images the bot may send when asked' }, { label: 'Exchange library', path: '/media-library', description: 'Images the bot may send when asked', group: 'Content' },
{ label: 'Performance', path: '/performance', description: 'Response and strategy trends' }, { label: 'Performance', path: '/performance', description: 'Response and strategy trends', group: 'Analyse' },
{ label: 'Operations', path: '/operations', description: 'Jobs, events, and diagnostics' }, { label: 'Operations', path: '/operations', description: 'Jobs, events, and diagnostics', group: 'Analyse' },
{ label: 'Settings', path: '/settings', description: 'Runtime and access configuration' }, { label: 'Subreddits', path: '/subreddits', description: 'Where the bot posts and how it talks', group: 'Configure' },
{ label: 'Accounts', path: '/accounts', description: 'Reddit sessions and access', group: 'Configure' },
{ label: 'Settings', path: '/settings', description: 'Pacing, safety, and notifications', group: 'Configure' },
]; ];
export function navigationItemsByGroup(group: NavigationGroup): readonly NavigationItem[] {
return navigationItems.filter((item) => item.group === group);
}
+1 -1
View File
@@ -158,7 +158,7 @@ export function notificationForEvent(event: DashboardEvent): NotificationDraft |
? `Signed in as u/${event.payload.username}.` ? `Signed in as u/${event.payload.username}.`
: `Session status changed to ${event.payload.status}.`), : `Session status changed to ${event.payload.status}.`),
tone: event.payload.status === 'authenticated' ? 'success' : 'error', tone: event.payload.status === 'authenticated' ? 'success' : 'error',
href: '/settings', href: '/accounts',
}; };
case 'conversation.updated': case 'conversation.updated':
case 'sync.started': case 'sync.started':
+4
View File
@@ -10,6 +10,8 @@ import { RouteErrorPage } from './route-error-page.js';
import { AttentionPage } from '../features/attention/attention-page.js'; import { AttentionPage } from '../features/attention/attention-page.js';
import { OutreachPage } from '../features/outreach/outreach-page.js'; import { OutreachPage } from '../features/outreach/outreach-page.js';
import { MediaLibraryPage } from '../features/media-library/media-library-page.js'; import { MediaLibraryPage } from '../features/media-library/media-library-page.js';
import { SubredditsPage } from '../features/subreddits/subreddits-page.js';
import { AccountsPage } from '../features/accounts/accounts-page.js';
export function createDashboardRoutes(): RouteObject[] { export function createDashboardRoutes(): RouteObject[] {
return [ return [
@@ -27,6 +29,8 @@ export function createDashboardRoutes(): RouteObject[] {
{ path: 'media-library', element: <MediaLibraryPage /> }, { path: 'media-library', element: <MediaLibraryPage /> },
{ path: 'performance', element: <PerformancePage /> }, { path: 'performance', element: <PerformancePage /> },
{ path: 'operations', element: <OperationsPage /> }, { path: 'operations', element: <OperationsPage /> },
{ path: 'subreddits', element: <SubredditsPage /> },
{ path: 'accounts', element: <AccountsPage /> },
{ path: 'settings', element: <SettingsRoute /> }, { path: 'settings', element: <SettingsRoute /> },
], ],
}, },
@@ -0,0 +1,225 @@
import type { ReactNode } from 'react';
interface FieldShellProps {
error?: string;
/** Guidance shown when there is no error to show in its place. */
hint?: ReactNode;
label: string;
name: string;
}
function fieldIds(name: string) {
return { errorId: `${name}-error`, hintId: `${name}-hint` };
}
function FieldMessage({ error, hint, name }: FieldShellProps) {
const { errorId, hintId } = fieldIds(name);
if (error) return <small className="field-error" id={errorId}>{error}</small>;
if (hint) return <small className="field-hint" id={hintId}>{hint}</small>;
return null;
}
function describedBy({ error, hint, name }: FieldShellProps): string | undefined {
const { errorId, hintId } = fieldIds(name);
if (error) return errorId;
return hint ? hintId : undefined;
}
export interface NumberFieldProps extends FieldShellProps {
max?: number;
min?: number;
step?: number | 'any';
/** Rendered inside the input as a trailing unit, e.g. "min" or "/hr". */
suffix?: string;
value: string;
onChange(value: string): void;
}
export function NumberField({ error, hint, label, max, min, name, onChange, step = 1, suffix, value }: NumberFieldProps) {
const shell = { error, hint, label, name };
return (
<label className="field">
<span className="field-label">{label}</span>
<span className={suffix ? 'field-input has-suffix' : 'field-input'}>
<input
aria-describedby={describedBy(shell)}
aria-invalid={error ? 'true' : undefined}
aria-label={label}
max={max}
min={min}
name={name}
onChange={(event) => onChange(event.target.value)}
step={step}
type="number"
value={value}
/>
{suffix ? <span aria-hidden="true" className="field-suffix">{suffix}</span> : null}
</span>
<FieldMessage {...shell} />
</label>
);
}
export interface TextFieldProps extends FieldShellProps {
placeholder?: string;
/** Rendered as a non-editable prefix inside the input, e.g. "r/". */
prefix?: string;
type?: 'text' | 'url';
value: string;
onChange(value: string): void;
}
export function TextField({ error, hint, label, name, onChange, placeholder, prefix, type = 'text', value }: TextFieldProps) {
const shell = { error, hint, label, name };
return (
<label className="field">
<span className="field-label">{label}</span>
<span className={prefix ? 'field-input has-prefix' : 'field-input'}>
{prefix ? <span aria-hidden="true" className="field-prefix">{prefix}</span> : null}
<input
aria-describedby={describedBy(shell)}
aria-invalid={error ? 'true' : undefined}
aria-label={label}
name={name}
onChange={(event) => onChange(event.target.value)}
placeholder={placeholder}
type={type}
value={value}
/>
</span>
<FieldMessage {...shell} />
</label>
);
}
export interface SelectFieldProps<Value extends string> extends FieldShellProps {
options: ReadonlyArray<{ value: Value; label: string }>;
value: Value;
onChange(value: Value): void;
}
export function SelectField<Value extends string>({ error, hint, label, name, onChange, options, value }: SelectFieldProps<Value>) {
const shell = { error, hint, label, name };
return (
<label className="field">
<span className="field-label">{label}</span>
<select
aria-describedby={describedBy(shell)}
aria-invalid={error ? 'true' : undefined}
aria-label={label}
name={name}
onChange={(event) => onChange(event.target.value as Value)}
value={value}
>
{options.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
<FieldMessage {...shell} />
</label>
);
}
export interface TextAreaFieldProps extends FieldShellProps {
monospace?: boolean;
placeholder?: string;
rows?: number;
value: string;
onChange(value: string): void;
}
export function TextAreaField({ error, hint, label, monospace, name, onChange, placeholder, rows = 3, value }: TextAreaFieldProps) {
const shell = { error, hint, label, name };
return (
<label className="field">
<span className="field-label">{label}</span>
<textarea
aria-describedby={describedBy(shell)}
aria-invalid={error ? 'true' : undefined}
aria-label={label}
className={monospace ? 'is-monospace' : undefined}
name={name}
onChange={(event) => onChange(event.target.value)}
placeholder={placeholder}
rows={rows}
spellCheck={monospace ? false : undefined}
value={value}
/>
<FieldMessage {...shell} />
</label>
);
}
export interface SwitchFieldProps {
checked: boolean;
description?: ReactNode;
label: string;
/** Emphasises the field as a consequential control. */
tone?: 'default' | 'critical';
onChange(checked: boolean): void;
}
export function SwitchField({ checked, description, label, onChange, tone = 'default' }: SwitchFieldProps) {
return (
<label className={tone === 'critical' ? 'switch-field is-critical' : 'switch-field'}>
<input aria-label={label} checked={checked} onChange={(event) => onChange(event.target.checked)} type="checkbox" />
<span className="switch-copy">
<strong>{label}</strong>
{description ? <small>{description}</small> : null}
</span>
</label>
);
}
export interface RadioCardOption<Value extends string> {
value: Value;
label: string;
description: string;
/** Extra controls revealed only while this option is selected. */
detail?: ReactNode;
/** Flags the option as operationally risky. */
isRisky?: boolean;
}
export interface RadioCardsProps<Value extends string> {
legend: string;
name: string;
options: ReadonlyArray<RadioCardOption<Value>>;
value: Value;
onChange(value: Value): void;
}
/**
* One-of choice where each option's explanation is body copy rather than a
* footnote, and its dependent settings are nested inside the chosen card so
* revealing them does not reflow the options above.
*/
export function RadioCards<Value extends string>({ legend, name, onChange, options, value }: RadioCardsProps<Value>) {
return (
<fieldset className="radio-cards">
<legend>{legend}</legend>
{options.map((option) => {
const isSelected = option.value === value;
return (
<div className={isSelected ? 'radio-card is-selected' : 'radio-card'} key={option.value}>
<label>
<input
checked={isSelected}
name={name}
onChange={() => onChange(option.value)}
type="radio"
value={option.value}
/>
<span className="radio-copy">
<strong>
{option.label}
{option.isRisky ? <span className="pill warning">Autonomous</span> : null}
</strong>
<small>{option.description}</small>
</span>
</label>
{isSelected && option.detail ? <div className="radio-detail">{option.detail}</div> : null}
</div>
);
})}
</fieldset>
);
}
@@ -0,0 +1,94 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type PropsWithChildren,
type ReactNode,
} from 'react';
import { useBlocker } from 'react-router-dom';
import { Modal } from './modal.js';
interface UnsavedChangesContextValue {
register(id: string, isDirty: boolean): void;
release(id: string): void;
}
const UnsavedChangesContext = createContext<UnsavedChangesContextValue | null>(null);
/**
* Page-level guard for a screen made of independently saved cards.
*
* Each card reports its own dirty state by id, so the guard can warn about
* leaving while *any* card has unsaved edits without the cards needing to
* share a draft. Section switches within a page do not trip it: only a
* pathname change is blocked.
*/
export function UnsavedChangesProvider({ children }: PropsWithChildren): ReactNode {
const [dirtyIds, setDirtyIds] = useState<readonly string[]>([]);
const register = useCallback((id: string, isDirty: boolean) => {
setDirtyIds((current) => {
const isTracked = current.includes(id);
if (isDirty === isTracked) return current;
return isDirty ? [...current, id] : current.filter((item) => item !== id);
});
}, []);
const release = useCallback((id: string) => {
setDirtyIds((current) => (current.includes(id) ? current.filter((item) => item !== id) : current));
}, []);
const value = useMemo(() => ({ register, release }), [register, release]);
const hasUnsavedChanges = dirtyIds.length > 0;
useEffect(() => {
if (!hasUnsavedChanges) return;
function warnBeforeUnload(event: BeforeUnloadEvent) {
event.preventDefault();
event.returnValue = '';
}
window.addEventListener('beforeunload', warnBeforeUnload);
return () => window.removeEventListener('beforeunload', warnBeforeUnload);
}, [hasUnsavedChanges]);
const blocker = useBlocker(({ currentLocation, nextLocation }) =>
hasUnsavedChanges && currentLocation.pathname !== nextLocation.pathname);
return (
<UnsavedChangesContext.Provider value={value}>
{children}
{blocker.state === 'blocked' ? (
<Modal className="dialog-card" labelledBy="unsaved-changes-title" onClose={() => blocker.reset()}>
<span className="eyebrow">Unsaved changes</span>
<h2 id="unsaved-changes-title">Leave without saving?</h2>
<p>
{dirtyIds.length === 1
? 'One section has edits that have not been saved. Leaving will discard them.'
: `${dirtyIds.length} sections have edits that have not been saved. Leaving will discard them.`}
</p>
<div className="dialog-actions">
<button autoFocus className="button secondary" onClick={() => blocker.reset()} type="button">Stay</button>
<button className="button danger" onClick={() => blocker.proceed()} type="button">Discard and leave</button>
</div>
</Modal>
) : null}
</UnsavedChangesContext.Provider>
);
}
/**
* Reports a card's dirty state to the surrounding {@link UnsavedChangesProvider}.
* Safe to call with no provider mounted, which keeps cards usable in isolation.
*/
export function useUnsavedChangesFlag(id: string, isDirty: boolean): void {
const context = useContext(UnsavedChangesContext);
useEffect(() => {
context?.register(id, isDirty);
}, [context, id, isDirty]);
useEffect(() => () => context?.release(id), [context, id]);
}
@@ -0,0 +1,213 @@
import { useEffect, useRef, useState } from 'react';
import { RedditUsernameLink } from '../../components/reddit-username-link.js';
import {
useImportRedditSessionMutation,
useLoginStatusQuery,
useRedditAccountsQuery,
} from '../settings/settings-queries.js';
import './accounts.css';
function errorMessage(error: Error | null): string {
return error?.message ?? 'Something went wrong.';
}
function sessionLabel(status: ReturnType<typeof useLoginStatusQuery>['data']): string {
if (!status) return 'Unknown';
if (!status.ready) return 'Browser unavailable';
if ('error' in status) return 'Check failed';
return {
unknown: 'Unknown',
authenticated: 'Authenticated',
'login-required': 'Login required',
unreachable: 'Reddit unreachable',
}[status.status];
}
export function AccountsPage() {
const statusQuery = useLoginStatusQuery();
const accountsQuery = useRedditAccountsQuery();
const importMutation = useImportRedditSessionMutation();
const importInput = useRef<HTMLInputElement>(null);
const [importFileError, setImportFileError] = useState<string | null>(null);
const resetImportMutation = importMutation.reset;
useEffect(() => {
if (!importMutation.isSuccess) return;
const timer = globalThis.setTimeout(() => resetImportMutation(), 5_000);
return () => globalThis.clearTimeout(timer);
}, [importMutation.isSuccess, resetImportMutation]);
async function importSession(file: File | undefined) {
if (!file) return;
setImportFileError(null);
let exported: unknown;
try {
exported = JSON.parse(await file.text());
} catch {
importMutation.reset();
setImportFileError("That file isn't valid JSON. Export a fresh session dump from the extension and try again.");
return;
}
importMutation.mutate(exported);
}
const session = statusQuery.data;
const verifiedUsername = session?.ready && 'loggedIn' in session && session.loggedIn
? session.username ?? null
: null;
const accounts = accountsQuery.data ?? [];
const pausedCount = accounts.filter((account) => !account.isRunning).length;
return (
<div className="page accounts-page">
<header className="page-header">
<div>
<span className="eyebrow">Reddit access</span>
<h1>Accounts</h1>
<p>Each Reddit account runs on its own imported browser session. Content and sync state stay owned by the account that produced them.</p>
</div>
{session ? (
<span className={session.ready && 'status' in session && session.loggedIn ? 'pill success' : 'pill warning'}>
{sessionLabel(session)}
</span>
) : null}
</header>
{pausedCount > 0 ? (
<div className="state-banner is-warning" role="status">
<strong>
{pausedCount === 1 ? 'One account is paused.' : `${pausedCount} accounts are paused.`}
</strong>
<span>A paused account is waiting for a fresh session dump. Other accounts keep running normally.</span>
</div>
) : null}
{statusQuery.isPending ? (
<div aria-label="Loading access" className="page-state" role="status">
<span className="spinner" />Loading Reddit access…
</div>
) : null}
{statusQuery.isError ? (
<div className="page-state error-state" role="alert">
<strong>Reddit access unavailable</strong>
<span>{errorMessage(statusQuery.error)}</span>
<button className="button secondary" onClick={() => void statusQuery.refetch()} type="button">Retry access</button>
</div>
) : null}
{session ? (
<>
<section aria-labelledby="accounts-heading" className="panel settings-card">
<div className="settings-card-heading">
<div>
<span className="eyebrow">Connected</span>
<h2 id="accounts-heading">Reddit accounts</h2>
{verifiedUsername ? (
<p>Verified as <strong><RedditUsernameLink username={verifiedUsername} /></strong>.</p>
) : <p>No account is currently verified as logged in.</p>}
</div>
</div>
<div className="settings-card-body">
<div aria-label="Reddit accounts" className="account-list">
{accountsQuery.isPending ? (
<p className="field-hint" role="status"><span className="spinner" /> Loading Reddit accounts…</p>
) : null}
{accountsQuery.isError ? (
<div className="state-banner is-error" role="alert">
<strong>Could not load Reddit accounts</strong>
<span>{errorMessage(accountsQuery.error)}</span>
<button className="button secondary" onClick={() => void accountsQuery.refetch()} type="button">Retry accounts</button>
</div>
) : null}
{accounts.map((account) => (
<div className={account.isRunning ? 'account-row is-active' : 'account-row'} key={account.accountId}>
<div>
<strong><RedditUsernameLink username={account.redditUsername} /></strong>
<span>{account.isRunning ? 'Running independently' : 'Waiting for a refreshed session'}</span>
</div>
<span className={account.isRunning ? 'pill success' : 'pill warning'}>
{account.isRunning ? 'Running' : 'Paused'}
</span>
</div>
))}
{accountsQuery.isSuccess && accounts.length === 0 ? (
<div className="empty-state">
<strong>No accounts yet</strong>
<span>Import a session dump below to add the first one.</span>
</div>
) : null}
</div>
</div>
<footer className="settings-card-footer">
<div aria-live="polite" className="settings-card-status">
{importFileError ? <strong className="save-error" role="alert">{importFileError}</strong> : null}
{importMutation.isSuccess ? (
<strong className="save-success" role="status">Session imported and verified — the account is running.</strong>
) : null}
{importMutation.isError ? (
<strong className="save-error" role="alert">{errorMessage(importMutation.error)}</strong>
) : null}
</div>
<div className="header-actions">
<input
accept="application/json"
aria-label="Reddit session export"
className="sr-only"
onChange={(event) => {
const file = event.target.files?.[0];
// Reset the input so picking the same file again re-triggers change.
event.target.value = '';
void importSession(file);
}}
ref={importInput}
type="file"
/>
<button
aria-busy={statusQuery.isFetching ? 'true' : undefined}
className="button secondary"
disabled={statusQuery.isFetching}
onClick={() => void statusQuery.refetch()}
type="button"
>
{statusQuery.isFetching ? 'Checking sessions…' : 'Recheck accounts'}
</button>
<button
className="button primary"
disabled={importMutation.isPending}
onClick={() => importInput.current?.click()}
type="button"
>
{importMutation.isPending ? 'Adding account…' : 'Add or refresh account'}
</button>
</div>
</footer>
</section>
<section aria-labelledby="import-help-heading" className="panel settings-card">
<div className="settings-card-heading">
<div>
<span className="eyebrow">How to import</span>
<h2 id="import-help-heading">Getting a session dump</h2>
</div>
</div>
<div className="settings-card-body">
<ol className="numbered-steps">
<li>Open Reddit Chat in Chrome, signed in as the account you want to add.</li>
<li>Wait for the extension badge to show <strong>OK</strong>.</li>
<li>Export the JSON dump from the extension.</li>
<li>Upload it with <strong>Add or refresh account</strong> above.</li>
</ol>
<p className="state-banner is-warning">
<strong>A session dump contains login credentials.</strong>
<span>Anyone holding the file can act as that Reddit account. Do not share it or commit it anywhere.</span>
</p>
</div>
</section>
</>
) : null}
</div>
);
}
@@ -0,0 +1,10 @@
.accounts-page { max-width: 58rem; }
.accounts-page .state-banner { margin-bottom: 1rem; }
.accounts-page .settings-card + .settings-card { margin-top: 1rem; }
.account-list { display: grid; gap: .6rem; }
.account-row { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 1rem; padding: .85rem; background: var(--surface-raised); border: 1px solid var(--border); border-radius: .75rem; }
.account-row.is-active { border-color: var(--accent); }
.account-row > div { display: grid; gap: .2rem; min-width: 0; }
.account-row span { color: var(--muted); font-size: .82rem; }
.account-row .pill { font-size: .7rem; }
@@ -37,7 +37,7 @@ export function StartConversationForm({ onStarted }: StartConversationFormProps)
if (runningAccounts.length === 0) { if (runningAccounts.length === 0) {
return ( return (
<p className="conversation-form-hint"> <p className="conversation-form-hint">
No running accounts — start one in <Link to="/settings">Settings</Link>. No running accounts — start one in <Link to="/accounts">Accounts</Link>.
</p> </p>
); );
} }
@@ -1,112 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import { RedditUsernameLink } from '../../components/reddit-username-link.js';
import {
useImportRedditSessionMutation,
useLoginStatusQuery,
useRedditAccountsQuery,
} from './settings-queries.js';
function errorMessage(error: Error | null): string {
return error?.message ?? 'Something went wrong.';
}
function sessionLabel(status: ReturnType<typeof useLoginStatusQuery>['data']): string {
if (!status) return 'Unknown';
if (!status.ready) return 'Browser unavailable';
if ('error' in status) return 'Check failed';
return {
unknown: 'Unknown',
authenticated: 'Authenticated',
'login-required': 'Login required',
unreachable: 'Reddit unreachable',
}[status.status];
}
export function AccessPanel() {
const statusQuery = useLoginStatusQuery();
const accountsQuery = useRedditAccountsQuery();
const importMutation = useImportRedditSessionMutation();
const importInput = useRef<HTMLInputElement>(null);
const [importFileError, setImportFileError] = useState<string | null>(null);
const resetImportMutation = importMutation.reset;
useEffect(() => {
if (!importMutation.isSuccess) return;
const timer = globalThis.setTimeout(() => resetImportMutation(), 5_000);
return () => globalThis.clearTimeout(timer);
}, [importMutation.isSuccess, resetImportMutation]);
async function importSession(file: File | undefined) {
if (!file) return;
setImportFileError(null);
let exported: unknown;
try {
exported = JSON.parse(await file.text());
} catch {
importMutation.reset();
setImportFileError("That file isn't valid JSON. Export a fresh session dump from the extension and try again.");
return;
}
importMutation.mutate(exported);
}
if (statusQuery.isPending) {
return <section aria-label="Loading access" className="panel panel-loading" role="status"><span className="spinner" />Loading Reddit access...</section>;
}
if (statusQuery.isError) {
return (
<section className="panel error-panel" role="alert">
<strong>Reddit access unavailable</strong><span>{errorMessage(statusQuery.error)}</span>
<button className="button secondary" onClick={() => void statusQuery.refetch()} type="button">Retry access</button>
</section>
);
}
const session = statusQuery.data;
const username = session.ready && 'loggedIn' in session && session.loggedIn ? session.username ?? null : null;
return (
<section aria-labelledby="access-heading" className="panel settings-section">
<div className="panel-heading"><div><span className="eyebrow">Reddit accounts</span><h2 id="access-heading">Access</h2></div><span className={session.ready && 'status' in session && session.loggedIn ? 'pill success' : 'pill warning'}>{sessionLabel(session)}</span></div>
<p className="settings-copy">Add each Reddit account with its own extension session dump. Content and sync state stay owned by that account in the database. Any account with an expired login pauses while waiting for a fresh session dump; other accounts keep running.</p>
{username ? <p className="settings-copy">Verified account: <strong><RedditUsernameLink username={username} /></strong>.</p> : null}
<div aria-label="Reddit accounts" className="reddit-account-list">
{accountsQuery.isPending ? <p className="settings-copy" role="status"><span className="spinner" /> Loading Reddit accounts…</p> : null}
{accountsQuery.isError ? (
<div className="settings-notice error" role="alert">
<span>Could not load Reddit accounts: {errorMessage(accountsQuery.error)}</span>{' '}
<button className="button secondary" onClick={() => void accountsQuery.refetch()} type="button">Retry accounts</button>
</div>
) : null}
{(accountsQuery.data ?? []).map((account) => (
<div className={account.isRunning ? 'reddit-account-row is-active' : 'reddit-account-row'} key={account.accountId}>
<div><strong><RedditUsernameLink username={account.redditUsername} /></strong><span>{account.isRunning ? 'Running independently' : 'Waiting for a refreshed session'}</span></div>
<span className={account.isRunning ? 'pill success' : 'pill warning'}>{account.isRunning ? 'Running' : 'Paused'}</span>
</div>
))}
{accountsQuery.isSuccess && accountsQuery.data.length === 0 ? <p className="settings-copy">No Reddit accounts have been added yet.</p> : null}
</div>
<div className="access-actions">
<input
accept="application/json"
aria-label="Reddit session export"
className="sr-only"
onChange={(event) => {
const file = event.target.files?.[0];
// Reset the input so picking the same file again re-triggers change.
event.target.value = '';
void importSession(file);
}}
ref={importInput}
type="file"
/>
<button className="button primary" disabled={importMutation.isPending} onClick={() => importInput.current?.click()} type="button">{importMutation.isPending ? 'Adding account...' : 'Add or refresh account'}</button>
<button aria-busy={statusQuery.isFetching ? 'true' : undefined} className="button secondary" disabled={statusQuery.isFetching} onClick={() => void statusQuery.refetch()} type="button">{statusQuery.isFetching ? 'Checking sessions...' : 'Recheck accounts'}</button>
</div>
<p className="settings-copy">Open Reddit Chat in Chrome for the account you want to add, wait for the extension badge to show OK, then upload its JSON dump. It contains login credentials; do not share it.</p>
{importFileError ? <div className="settings-notice error" role="alert">{importFileError}</div> : null}
{importMutation.isSuccess ? <div className="settings-notice success" role="status"><span>Reddit session imported and verified.</span> <span>Account runtime is running independently.</span></div> : null}
{importMutation.isError ? <div className="settings-notice error" role="alert">{errorMessage(importMutation.error)}</div> : null}
</section>
);
}
@@ -0,0 +1,85 @@
import { useState } from 'react';
import type { ConfigResponse } from '../../../../../packages/api-contracts/src/index.js';
import { TextAreaField } from '../../components/form-fields.js';
import { SettingsCard } from './settings-card.js';
import { createAdvancedDraft, validateAdvancedDraft, type AdvancedDraft } from './settings-model.js';
import { useConfigCard } from './use-config-card.js';
/** Top-level keys of the advanced blob, so the card says what it holds while locked. */
function summariseKeys(advanced: Record<string, unknown>): string[] {
return Object.keys(advanced).sort((left, right) => left.localeCompare(right));
}
export function AdvancedCard({ config }: { config: ConfigResponse }) {
const [isUnlocked, setIsUnlocked] = useState(false);
const card = useConfigCard<AdvancedDraft>(config, {
id: 'advanced',
toDraft: createAdvancedDraft,
validate: validateAdvancedDraft,
});
const keys = config.advanced ? summariseKeys(config.advanced) : [];
// Locked state is a plain section: no draft, so no save footer to show.
if (!isUnlocked) {
return (
<section aria-labelledby="advanced-heading" className="panel settings-card" id="advanced">
<div className="settings-card-heading">
<div>
<span className="eyebrow">Raw configuration</span>
<h2 id="advanced-heading">Advanced</h2>
<p>Models, prompts, filtering, media sync and strategy settings, edited as raw JSON. Some changes take effect only after a restart.</p>
</div>
<span className="pill neutral">Locked</span>
</div>
<div className="settings-card-body">
{keys.length > 0 ? (
<div className="advanced-keys">
<span className="field-label">Currently configured</span>
<ul>{keys.map((key) => <li key={key}><code>{key}</code></li>)}</ul>
</div>
) : null}
<p className="state-banner is-warning">
<strong>There is no validation beyond JSON shape here.</strong>
<span>A wrong value can stop the bot from running or change what it says to people. Edit this only if you know the schema.</span>
</p>
<button className="button secondary" onClick={() => setIsUnlocked(true)} type="button">
Edit raw configuration
</button>
</div>
</section>
);
}
return (
<SettingsCard
anchor="advanced"
card={card}
description="Edited as raw JSON and saved on its own, so a syntax error here cannot block any other section."
eyebrow="Raw configuration"
saveLabel="Save advanced"
status={
<button
className="button secondary"
disabled={card.isSaving}
onClick={() => { card.reset(); setIsUnlocked(false); }}
type="button"
>
Done editing
</button>
}
title="Advanced"
>
<TextAreaField
error={card.errors.advancedConfigJson}
hint="A single JSON object. Unknown keys are preserved as-is."
label="Advanced configuration (JSON)"
monospace
name="advanced-config"
onChange={(advancedConfigJson) => card.update({ advancedConfigJson })}
rows={22}
value={card.draft.advancedConfigJson}
/>
</SettingsCard>
);
}
@@ -0,0 +1,120 @@
import type { ConfigResponse } from '../../../../../packages/api-contracts/src/index.js';
import { NumberField, SwitchField } from '../../components/form-fields.js';
import { SettingsCard } from './settings-card.js';
import {
createPacingDraft,
estimateThroughput,
pacingRisks,
validatePacingDraft,
type PacingDraft,
} from './settings-model.js';
import { useConfigCard } from './use-config-card.js';
function ThroughputReadout({ draft }: { draft: PacingDraft }) {
const estimate = estimateThroughput(draft);
if (!estimate) {
return <p className="throughput-readout is-unknown">Enter valid numbers to see the effective sending rate.</p>;
}
return (
<p aria-live="polite" className="throughput-readout">
<strong>{estimate.effectivePerHour} messages per hour</strong>
<span>
{estimate.spacingLabel}
{estimate.isDelayBound
? ' — the minimum delay is the binding limit, so raising the hourly limit alone changes nothing.'
: ' — the hourly limit is the binding limit.'}
</span>
</p>
);
}
export function PacingCard({ config }: { config: ConfigResponse }) {
const card = useConfigCard<PacingDraft>(config, {
id: 'pacing',
toDraft: createPacingDraft,
validate: validatePacingDraft,
risks: pacingRisks,
});
const { draft, errors } = card;
return (
<SettingsCard
anchor="pacing"
card={card}
description="How fast the bot sends, and how it picks which posts to act on. Both limits apply at once."
eyebrow="Throughput"
saveLabel="Save pacing"
title="Pacing"
>
<fieldset className="field-group">
<legend>Sending pace</legend>
<div className="field-row">
<NumberField
error={errors.minDelayMinutes}
hint="Shortest gap between two messages."
label="Minimum delay"
min={0}
name="minimum-delay"
onChange={(minDelayMinutes) => card.update({ ...draft, minDelayMinutes })}
suffix="min"
value={draft.minDelayMinutes}
/>
<NumberField
error={errors.maxMessagesPerHour}
hint="Hard ceiling per rolling hour."
label="Hourly limit"
min={1}
name="hourly-limit"
onChange={(maxMessagesPerHour) => card.update({ ...draft, maxMessagesPerHour })}
suffix="msg/hr"
value={draft.maxMessagesPerHour}
/>
<NumberField
error={errors.jitterSeconds}
hint="Random padding so timing looks human."
label="Random jitter"
min={0}
name="jitter"
onChange={(jitterSeconds) => card.update({ ...draft, jitterSeconds })}
suffix="sec"
value={draft.jitterSeconds}
/>
</div>
<ThroughputReadout draft={draft} />
</fieldset>
<fieldset className="field-group">
<legend>Post discovery</legend>
<SwitchField
checked={draft.prioritizeRecentPosts}
description="Act on posts inside the recent window before older ones."
label="Prioritise recent posts"
onChange={(prioritizeRecentPosts) => card.update({ ...draft, prioritizeRecentPosts })}
/>
<div className="field-row">
<NumberField
error={errors.recentWindowMinutes}
hint="How new a post must be to count as recent."
label="Recent window"
min={1}
name="recent-window"
onChange={(recentWindowMinutes) => card.update({ ...draft, recentWindowMinutes })}
suffix="min"
value={draft.recentWindowMinutes}
/>
<NumberField
error={errors.maxReadyCandidates}
hint="Queue depth of posts waiting to be actioned (1–500)."
label="Maximum ready posts"
max={500}
min={1}
name="maximum-ready-posts"
onChange={(maxReadyCandidates) => card.update({ ...draft, maxReadyCandidates })}
suffix="posts"
value={draft.maxReadyCandidates}
/>
</div>
</fieldset>
</SettingsCard>
);
}
@@ -5,6 +5,8 @@ import {
type PushNotificationEventKey, type PushNotificationEventKey,
type PushNotificationSettingsResponse, type PushNotificationSettingsResponse,
} from '../../../../../packages/api-contracts/src/index.js'; } from '../../../../../packages/api-contracts/src/index.js';
import { SelectField, SwitchField, TextField } from '../../components/form-fields.js';
import { useUnsavedChangesFlag } from '../../components/unsaved-changes.js';
import { import {
usePushNotificationsQuery, usePushNotificationsQuery,
useSavePushNotificationsMutation, useSavePushNotificationsMutation,
@@ -81,7 +83,7 @@ function isDirty(draft: PushDraft, persisted: PushDraft): boolean {
return JSON.stringify(draft) !== JSON.stringify(persisted); return JSON.stringify(draft) !== JSON.stringify(persisted);
} }
export function PushNotificationsPanel() { export function PushNotificationsCard() {
const settingsQuery = usePushNotificationsQuery(); const settingsQuery = usePushNotificationsQuery();
const saveMutation = useSavePushNotificationsMutation(); const saveMutation = useSavePushNotificationsMutation();
const testMutation = useTestPushNotificationMutation(); const testMutation = useTestPushNotificationMutation();
@@ -89,6 +91,9 @@ export function PushNotificationsPanel() {
const [state, setState] = useState<{ persisted: PushDraft; draft: PushDraft } | null>(null); const [state, setState] = useState<{ persisted: PushDraft; draft: PushDraft } | null>(null);
const [errors, setErrors] = useState<Record<string, string>>({}); const [errors, setErrors] = useState<Record<string, string>>({});
const dirty = state ? isDirty(state.draft, state.persisted) : false;
useUnsavedChangesFlag('notifications', dirty);
// Seed on first load, and reconcile server refetches: adopt them into a // Seed on first load, and reconcile server refetches: adopt them into a
// clean editor, keep the operator's edits in a dirty one. // clean editor, keep the operator's edits in a dirty one.
useEffect(() => { useEffect(() => {
@@ -105,22 +110,31 @@ export function PushNotificationsPanel() {
if (settingsQuery.isError) { if (settingsQuery.isError) {
return ( return (
<section className="panel error-panel" role="alert"> <section aria-labelledby="notifications-heading" className="panel settings-card" id="notifications">
<strong>Notification settings unavailable</strong><span>{errorMessage(settingsQuery.error)}</span> <div className="settings-card-heading">
<div>
<span className="eyebrow">Phone push</span>
<h2 id="notifications-heading">Notifications</h2>
</div>
</div>
<div className="error-panel" role="alert">
<strong>Notification settings unavailable</strong>
<span>{errorMessage(settingsQuery.error)}</span>
<button className="button secondary" onClick={() => void settingsQuery.refetch()} type="button">Retry notifications</button> <button className="button secondary" onClick={() => void settingsQuery.refetch()} type="button">Retry notifications</button>
</div>
</section> </section>
); );
} }
if (!state) { if (!state) {
return ( return (
<section aria-label="Loading notifications" className="panel panel-loading" role="status"> <section aria-label="Loading notifications" className="panel settings-card panel-loading" id="notifications" role="status">
<span className="spinner" />Loading notification settings… <span className="spinner" />Loading notification settings…
</section> </section>
); );
} }
const { draft, persisted } = state; const { draft } = state;
function update(next: Partial<PushDraft>) { function update(next: Partial<PushDraft>) {
saveMutation.reset(); saveMutation.reset();
@@ -129,8 +143,11 @@ export function PushNotificationsPanel() {
setState((current) => (current ? { ...current, draft: { ...current.draft, ...next } } : current)); setState((current) => (current ? { ...current, draft: { ...current.draft, ...next } } : current));
} }
function toggleEvent(key: PushNotificationEventKey, value: boolean) { function reset() {
update({ events: { ...draft.events, [key]: value } }); saveMutation.reset();
testMutation.reset();
setErrors({});
setState((current) => (current ? { ...current, draft: current.persisted } : current));
} }
function save() { function save() {
@@ -151,90 +168,94 @@ export function PushNotificationsPanel() {
}); });
} }
const dirty = isDirty(draft, persisted);
const selectedCount = PUSH_NOTIFICATION_EVENT_KEYS.filter((key) => draft.events[key]).length; const selectedCount = PUSH_NOTIFICATION_EVENT_KEYS.filter((key) => draft.events[key]).length;
const errorCount = Object.keys(errors).length;
return ( return (
<section aria-labelledby="push-notifications-heading" className="panel settings-section"> <section aria-labelledby="notifications-heading" className="panel settings-card" id="notifications">
<div className="panel-heading"> <div className="settings-card-heading">
<div> <div>
<span className="eyebrow">Phone push</span> <span className="eyebrow">Phone push</span>
<h2 id="push-notifications-heading">Notifications</h2> <h2 id="notifications-heading">Notifications</h2>
</div> <p>
<span className={draft.enabled ? 'pill success' : 'pill warning'}>{draft.enabled ? 'Enabled' : 'Disabled'}</span> Selected events are published to one ntfy topic. Subscribe to the same topic in the ntfy app to receive
</div> them. Anyone who knows the topic name can read it, so treat it as a secret.
<p className="settings-copy">
Selected events are published to one ntfy topic. Subscribe to the same topic in the ntfy app on your
phone to receive them. Anyone who knows the topic name can read it, so treat it as a secret.
</p> </p>
</div>
<span className={draft.enabled ? 'pill success' : 'pill neutral'}>{draft.enabled ? 'Enabled' : 'Disabled'}</span>
</div>
<label className="toggle-field"> <div className="settings-card-body">
<input <SwitchField
checked={draft.enabled} checked={draft.enabled}
onChange={(event) => update({ enabled: event.target.checked })} description="Turn off to silence every event without losing your selection."
type="checkbox" label="Send push notifications"
onChange={(enabled) => update({ enabled })}
/> />
<span><strong>Send push notifications</strong><small>Turn off to silence every event without losing your selection.</small></span>
</label>
<div className="settings-grid"> <fieldset className="field-group">
<label className="settings-field"> <legend>Delivery</legend>
<span>Server URL</span> <div className="field-row">
<input <TextField
aria-invalid={errors.serverUrl ? 'true' : undefined} error={errors.serverUrl}
onChange={(event) => update({ serverUrl: event.target.value })} hint="Your self-hosted ntfy instance."
label="Server URL"
name="ntfy-server-url"
onChange={(serverUrl) => update({ serverUrl })}
placeholder="https://ntfy.example.com" placeholder="https://ntfy.example.com"
type="url" type="url"
value={draft.serverUrl} value={draft.serverUrl}
/> />
{errors.serverUrl ? <small className="field-error">{errors.serverUrl}</small> : <small>Your self-hosted ntfy instance.</small>} <TextField
</label> error={errors.topic}
<label className="settings-field"> hint="Letters, numbers, hyphens and underscores."
<span>Topic</span> label="Topic"
<input name="ntfy-topic"
aria-invalid={errors.topic ? 'true' : undefined} onChange={(topic) => update({ topic })}
onChange={(event) => update({ topic: event.target.value })}
placeholder="redditbot-7f3a9c21" placeholder="redditbot-7f3a9c21"
type="text"
value={draft.topic} value={draft.topic}
/> />
{errors.topic ? <small className="field-error">{errors.topic}</small> : <small>Letters, numbers, hyphens and underscores.</small>} <SelectField
</label> hint="How insistently the ntfy app alerts you."
<label className="settings-field"> label="Priority"
<span>Priority</span> name="ntfy-priority"
<select onChange={(event) => update({ priority: event.target.value as Priority })} value={draft.priority}> onChange={(priority) => update({ priority })}
{priorities.map((priority) => ( options={priorities}
<option key={priority.value} value={priority.value}>{priority.label}</option> value={draft.priority}
))} />
</select>
<small>How insistently the ntfy app alerts you.</small>
</label>
</div> </div>
<p className="field-hint">
<p className="settings-copy">
{settings?.hasAuthToken {settings?.hasAuthToken
? 'An access token is configured, so protected topics will accept these notifications.' ? 'An access token is configured, so protected topics will accept these notifications.'
: 'No access token is configured. Set NTFY_AUTH_TOKEN in the environment if your topic requires one.'} : 'No access token is configured. Set NTFY_AUTH_TOKEN in the environment if your topic requires one.'}
</p> </p>
<fieldset className="settings-events">
<legend>Events to notify me about ({selectedCount} of {PUSH_NOTIFICATION_EVENT_KEYS.length})</legend>
{PUSH_NOTIFICATION_EVENT_KEYS.map((key) => (
<label className="toggle-field" key={key}>
<input
checked={draft.events[key]}
onChange={(event) => toggleEvent(key, event.target.checked)}
type="checkbox"
/>
<span><strong>{eventLabels[key].label}</strong><small>{eventLabels[key].description}</small></span>
</label>
))}
</fieldset> </fieldset>
<div className="settings-save-bar"> <fieldset className="field-group">
<div aria-live="polite"> <legend>Events to notify me about ({selectedCount} of {PUSH_NOTIFICATION_EVENT_KEYS.length})</legend>
{dirty ? <span role="status">You have unsaved changes.</span> : <span>All changes saved.</span>} <div className="switch-list">
{saveMutation.isSuccess && !dirty ? <strong className="save-success">Notification settings saved.</strong> : null} {PUSH_NOTIFICATION_EVENT_KEYS.map((key) => (
<SwitchField
checked={draft.events[key]}
description={eventLabels[key].description}
key={key}
label={eventLabels[key].label}
onChange={(checked) => update({ events: { ...draft.events, [key]: checked } })}
/>
))}
</div>
</fieldset>
</div>
<footer className="settings-card-footer">
<div aria-live="polite" className="settings-card-status">
{dirty ? <span role="status">Unsaved changes in this section.</span> : <span>Saved.</span>}
{errorCount > 0 ? (
<strong className="save-error" role="alert">
{errorCount === 1 ? 'Resolve 1 problem before saving.' : `Resolve ${errorCount} problems before saving.`}
</strong>
) : null}
{saveMutation.isSuccess && !dirty ? <strong className="save-success">Notifications saved.</strong> : null}
{saveMutation.isError ? <strong className="save-error" role="alert">{errorMessage(saveMutation.error)}</strong> : null} {saveMutation.isError ? <strong className="save-error" role="alert">{errorMessage(saveMutation.error)}</strong> : null}
{testMutation.isSuccess ? ( {testMutation.isSuccess ? (
<strong className={testMutation.data.delivered ? 'save-success' : 'save-error'} role="status"> <strong className={testMutation.data.delivered ? 'save-success' : 'save-error'} role="status">
@@ -253,7 +274,10 @@ export function PushNotificationsPanel() {
title={dirty ? 'Save your changes before sending a test.' : undefined} title={dirty ? 'Save your changes before sending a test.' : undefined}
type="button" type="button"
> >
{testMutation.isPending ? 'Sending test…' : 'Send test notification'} {testMutation.isPending ? 'Sending test…' : 'Send test'}
</button>
<button className="button secondary" disabled={!dirty || saveMutation.isPending} onClick={reset} type="button">
Reset
</button> </button>
<button <button
aria-busy={saveMutation.isPending ? 'true' : undefined} aria-busy={saveMutation.isPending ? 'true' : undefined}
@@ -265,7 +289,7 @@ export function PushNotificationsPanel() {
{saveMutation.isPending ? 'Saving…' : 'Save notifications'} {saveMutation.isPending ? 'Saving…' : 'Save notifications'}
</button> </button>
</div> </div>
</div> </footer>
</section> </section>
); );
} }
@@ -0,0 +1,46 @@
import type { ConfigResponse } from '../../../../../packages/api-contracts/src/index.js';
import { SwitchField } from '../../components/form-fields.js';
import { SettingsCard } from './settings-card.js';
import { createSafetyDraft, safetyRisks, validateSafetyDraft, type SafetyDraft } from './settings-model.js';
import { useConfigCard } from './use-config-card.js';
export function SafetyCard({ config }: { config: ConfigResponse }) {
const card = useConfigCard<SafetyDraft>(config, {
id: 'safety',
toDraft: createSafetyDraft,
validate: validateSafetyDraft,
risks: safetyRisks,
});
const isLive = !card.persisted.dryRun;
return (
<SettingsCard
anchor="safety"
card={card}
description="The single switch that decides whether anything actually reaches Reddit."
eyebrow="Send guard"
saveLabel="Save safety"
status={<span className={isLive ? 'pill danger' : 'pill success'}>{isLive ? 'Sending live' : 'Dry run'}</span>}
title="Safety"
>
{/* State first: the operator should read what is happening now before
being shown the control that changes it. */}
<p className={isLive ? 'state-banner is-live' : 'state-banner is-safe'} role="status">
<strong>{isLive ? 'Messages are being sent to real people.' : 'No messages are being sent.'}</strong>
<span>
{isLive
? 'Every evaluated post can produce a real outbound message, subject to the pacing limits.'
: 'Posts are evaluated and replies are drafted, but nothing is delivered to Reddit.'}
</span>
</p>
<SwitchField
checked={card.draft.dryRun}
description="Evaluate posts and draft messages without delivering anything. Turn this off only when you intend to send."
label="Dry run mode"
onChange={(dryRun) => card.update({ ...card.draft, dryRun })}
tone="critical"
/>
</SettingsCard>
);
}
@@ -0,0 +1,97 @@
import type { ReactNode } from 'react';
import { RiskDialog } from './risk-dialog.js';
import type { ConfigCard } from './use-config-card.js';
interface SettingsCardProps<Draft> {
/** Anchor target and heading id stem, e.g. "pacing". */
anchor: string;
card: ConfigCard<Draft>;
children: ReactNode;
description: string;
eyebrow: string;
/** Rendered in the heading, opposite the title — usually a status pill. */
status?: ReactNode;
saveLabel: string;
title: string;
}
function errorMessage(error: Error | null): string {
return error?.message ?? 'Something went wrong.';
}
/**
* A self-contained settings section: its own draft, its own validation, and its
* own save. Nothing in this card can block or be blocked by another card.
*/
export function SettingsCard<Draft>({
anchor,
card,
children,
description,
eyebrow,
saveLabel,
status,
title,
}: SettingsCardProps<Draft>) {
const headingId = `${anchor}-heading`;
return (
<section aria-labelledby={headingId} className="panel settings-card" id={anchor}>
<div className="settings-card-heading">
<div>
<span className="eyebrow">{eyebrow}</span>
<h2 id={headingId}>{title}</h2>
<p>{description}</p>
</div>
{status}
</div>
<div className="settings-card-body">{children}</div>
<footer className="settings-card-footer">
<div aria-live="polite" className="settings-card-status">
{card.isDirty ? <span role="status">Unsaved changes in this section.</span> : <span>Saved.</span>}
{card.hasServerChanged ? (
<strong className="save-warning" role="status">Changed on the server — reset to load the new values.</strong>
) : null}
{card.errorCount > 0 ? (
<strong className="save-error" role="alert">
{card.errorCount === 1
? 'Resolve 1 problem before saving.'
: `Resolve ${card.errorCount} problems before saving.`}
</strong>
) : null}
{card.isSaved ? <strong className="save-success" role="status">{title} saved.</strong> : null}
{card.saveError ? <strong className="save-error" role="alert">{errorMessage(card.saveError)}</strong> : null}
</div>
<div className="header-actions">
<button
className="button secondary"
disabled={(!card.isDirty && !card.hasServerChanged) || card.isSaving}
onClick={card.reset}
type="button"
>
Reset
</button>
<button
aria-busy={card.isSaving ? 'true' : undefined}
className="button primary"
disabled={!card.isDirty || card.isSaving}
onClick={card.save}
type="button"
>
{card.isSaving ? 'Saving…' : saveLabel}
</button>
</div>
</footer>
{card.pendingRisks ? (
<RiskDialog
isPending={card.isSaving}
onCancel={card.cancelRisks}
onConfirm={card.confirmRisks}
reasons={card.pendingRisks}
/>
) : null}
</section>
);
}
@@ -1,234 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import { useBlocker } from 'react-router-dom';
import type { ConfigResponse, ConfigUpdateRequest } from '../../../../../packages/api-contracts/src/index.js';
import { Modal } from '../../components/modal.js';
import { AccessPanel } from './access-panel.js';
import { PushNotificationsPanel } from './push-notifications-panel.js';
import { RiskDialog } from './risk-dialog.js';
import { useSaveSettingsMutation } from './settings-queries.js';
import {
changedDraftFields,
createSettingsDraft,
isDraftDirty,
validateSettingsDraft,
type SettingsDraft,
} from './settings-model.js';
import { BehaviourSection, MonitoringSection, RateLimitsSection } from './settings-sections.js';
import type { SettingsSection } from './settings-page.js';
interface SettingsEditorProps {
config: ConfigResponse;
section: SettingsSection;
}
interface PendingRiskySave {
payload: ConfigUpdateRequest;
reasons: string[];
}
function errorMessage(error: Error | null): string {
return error?.message ?? 'Something went wrong.';
}
function DiscardDraftDialog({ title, body, onDiscard, onStay }: {
title: string;
body: string;
onDiscard(): void;
onStay(): void;
}) {
return (
<Modal className="dialog-card" labelledBy="discard-draft-title" onClose={onStay}>
<span className="eyebrow">Unsaved changes</span>
<h2 id="discard-draft-title">{title}</h2>
<p>{body}</p>
<div className="dialog-actions">
<button autoFocus className="button secondary" onClick={onStay} type="button">Stay</button>
<button className="button danger" onClick={onDiscard} type="button">Discard changes</button>
</div>
</Modal>
);
}
export function SettingsEditor({ config, section }: SettingsEditorProps) {
const [persistedDraft, setPersistedDraft] = useState<SettingsDraft>(() => createSettingsDraft(config));
const [draft, setDraft] = useState<SettingsDraft>(persistedDraft);
const [errors, setErrors] = useState<Record<string, string>>({});
const [pendingRiskySave, setPendingRiskySave] = useState<PendingRiskySave | null>(null);
const [isResetConfirmOpen, setIsResetConfirmOpen] = useState(false);
const [serverChangedNotice, setServerChangedNotice] = useState(false);
const [focusFirstErrorToken, setFocusFirstErrorToken] = useState(0);
const saveMutation = useSaveSettingsMutation();
const isDirty = isDraftDirty(draft, persistedDraft);
const seededConfig = useRef(config);
// Reconcile server refetches: re-seed a clean editor, warn a dirty one.
useEffect(() => {
if (seededConfig.current === config) return;
seededConfig.current = config;
const serverDraft = createSettingsDraft(config);
if (!isDraftDirty(serverDraft, persistedDraft)) return;
setPersistedDraft(serverDraft);
if (isDraftDirty(draft, persistedDraft)) {
setServerChangedNotice(true);
} else {
setDraft(serverDraft);
setServerChangedNotice(false);
}
}, [config, draft, persistedDraft]);
useEffect(() => {
function warnBeforeUnload(event: BeforeUnloadEvent) {
if (!isDirty) return;
event.preventDefault();
event.returnValue = '';
}
window.addEventListener('beforeunload', warnBeforeUnload);
return () => window.removeEventListener('beforeunload', warnBeforeUnload);
}, [isDirty]);
// Guard in-app navigation away from settings while the draft is dirty.
// Section switches only change the query string, so they stay unblocked.
const navigationBlocker = useBlocker(({ currentLocation, nextLocation }) =>
isDirty && currentLocation.pathname !== nextLocation.pathname);
// After a failed save attempt, move focus to the first invalid control on screen.
useEffect(() => {
if (focusFirstErrorToken === 0) return;
const firstInvalid = document.querySelector<HTMLElement>('[aria-invalid="true"]');
firstInvalid?.focus();
}, [focusFirstErrorToken]);
function applySavedResponse(response: Awaited<ReturnType<typeof saveMutation.mutateAsync>>) {
const nextConfig: ConfigResponse = {
...response.config,
subreddits: response.subreddits,
tones: config.tones,
advanced: response.advanced ?? config.advanced,
};
const nextDraft = createSettingsDraft(nextConfig);
setPersistedDraft(nextDraft);
setDraft(nextDraft);
setPendingRiskySave(null);
setServerChangedNotice(false);
}
function submit(payload: ConfigUpdateRequest) {
saveMutation.mutate(payload, { onSuccess: applySavedResponse });
}
function updateDraft(nextDraft: SettingsDraft) {
saveMutation.reset();
const editedFields = changedDraftFields(draft, nextDraft);
setErrors((currentErrors) => {
if (editedFields.length === 0) return currentErrors;
const next = { ...currentErrors };
for (const field of editedFields) delete next[field];
return next;
});
setDraft(nextDraft);
}
function resetDraft() {
setDraft(persistedDraft);
setErrors({});
setServerChangedNotice(false);
setIsResetConfirmOpen(false);
saveMutation.reset();
}
function prepareSave() {
saveMutation.reset();
const validation = validateSettingsDraft(draft);
setErrors(validation.errors);
if (!validation.data) {
setFocusFirstErrorToken((token) => token + 1);
return;
}
const reasons: string[] = [];
if (persistedDraft.dryRun && !validation.data.dryRun) {
reasons.push('Automated messages will be sent to Reddit because dry run mode is being disabled.');
}
const persistedLimit = Number(persistedDraft.maxMessagesPerHour);
if (validation.data.maxMessagesPerHour > 20 && validation.data.maxMessagesPerHour > persistedLimit) {
reasons.push(`The limit will allow ${validation.data.maxMessagesPerHour} messages per hour.`);
}
// Match persisted rows BY NAME: row insertion or reordering must never make
// an autonomous mode look pre-approved because of a shifted index.
const persistedModeByName = new Map(persistedDraft.subreddits.map((subreddit) => [
subreddit.name.trim().toLowerCase(),
subreddit.conversationMode,
]));
const enablesAutonomousR4R = validation.data.payload.subreddits?.some((subreddit) => (
(subreddit.conversation.mode === 'r4r' || subreddit.conversation.mode === 'r4r-media')
&& persistedModeByName.get(subreddit.name.trim().toLowerCase()) !== subreddit.conversation.mode
));
if (enablesAutonomousR4R) {
reasons.push('Autonomous R4R will send conversational replies without approval for the selected subreddit. The hybrid mode can make one optional image request after rapport. Existing conversations keep their snapshotted policy.');
}
if (reasons.length > 0) {
setPendingRiskySave({ payload: validation.data.payload, reasons });
return;
}
submit(validation.data.payload);
}
const errorCount = Object.keys(errors).length;
return (
<>
{section === 'monitoring' ? <MonitoringSection draft={draft} errors={errors} onChange={updateDraft} /> : null}
{section === 'rate-limits' ? <RateLimitsSection draft={draft} errors={errors} onChange={updateDraft} /> : null}
{section === 'behaviour' ? <BehaviourSection config={config} draft={draft} errors={errors} onChange={updateDraft} /> : null}
{section === 'notifications' ? <PushNotificationsPanel /> : null}
{section === 'access' ? <AccessPanel /> : null}
{/* Both panels own their own persistence, so the shared save bar is theirs to skip. */}
{section !== 'access' && section !== 'notifications' ? (
<div className="settings-save-bar">
<div aria-live="polite">
{isDirty ? <span role="status">You have unsaved changes.</span> : <span>All changes saved.</span>}
{serverChangedNotice ? <strong className="save-warning" role="status">Settings changed on the server — Reset to load them.</strong> : null}
{errorCount > 0 ? (
<strong className="save-error" role="alert">
{errorCount === 1 ? 'Resolve 1 validation error before saving.' : `Resolve ${errorCount} validation errors before saving.`}
</strong>
) : null}
{saveMutation.isSuccess ? <strong className="save-success">Settings saved.</strong> : null}
{saveMutation.isError ? <strong className="save-error" role="alert">{errorMessage(saveMutation.error)}</strong> : null}
</div>
<div className="header-actions">
<button className="button secondary" disabled={(!isDirty && !serverChangedNotice) || saveMutation.isPending} onClick={() => { if (isDirty) setIsResetConfirmOpen(true); else resetDraft(); }} type="button">Reset changes</button>
<button aria-busy={saveMutation.isPending ? 'true' : undefined} aria-label={saveMutation.isPending ? 'Saving settings' : 'Save settings'} className="button primary" disabled={!isDirty || saveMutation.isPending} onClick={prepareSave} type="button">{saveMutation.isPending ? 'Saving…' : 'Save settings'}</button>
</div>
</div>
) : null}
{pendingRiskySave ? (
<RiskDialog
isPending={saveMutation.isPending}
onCancel={() => setPendingRiskySave(null)}
onConfirm={() => submit(pendingRiskySave.payload)}
reasons={pendingRiskySave.reasons}
/>
) : null}
{isResetConfirmOpen ? (
<DiscardDraftDialog
body="Your unsaved edits will be replaced with the last saved settings."
onDiscard={resetDraft}
onStay={() => setIsResetConfirmOpen(false)}
title="Discard unsaved settings?"
/>
) : null}
{navigationBlocker.state === 'blocked' ? (
<DiscardDraftDialog
body="Leaving this page will discard your unsaved settings."
onDiscard={() => navigationBlocker.proceed()}
onStay={() => navigationBlocker.reset()}
title="Discard unsaved settings?"
/>
) : null}
</>
);
}
@@ -1,74 +1,155 @@
import { z } from 'zod'; import { z } from 'zod';
import { import {
ConfigUpdateRequestSchema, ConfigUpdateRequestSchema,
SubredditConfigSchema,
ToneKeySchema,
type ConfigResponse, type ConfigResponse,
type ConfigUpdateRequest,
} from '../../../../../packages/api-contracts/src/index.js'; } from '../../../../../packages/api-contracts/src/index.js';
import type { ConfigCardValidation } from './use-config-card.js';
export interface SubredditDraft { /** Above this hourly ceiling a save is treated as operationally risky. */
/** Stable client-side row identity for React keys; excluded from dirty comparison. */ export const RISKY_MESSAGES_PER_HOUR = 20;
rowId: string;
name: string;
enabled: boolean;
type: 'image' | 'chat';
weight: string;
tone: z.infer<typeof ToneKeySchema>;
scripted: string;
conversationMode: 'legacy' | 'r4r' | 'r4r-media' | 'manual';
maxAutomatedTurns: string;
rapportTurnsBeforeMediaAsk: string;
}
export interface SettingsDraft { export interface PacingDraft {
minDelayMinutes: string; minDelayMinutes: string;
maxMessagesPerHour: string; maxMessagesPerHour: string;
jitterSeconds: string; jitterSeconds: string;
dryRun: boolean;
prioritizeRecentPosts: boolean; prioritizeRecentPosts: boolean;
recentWindowMinutes: string; recentWindowMinutes: string;
maxReadyCandidates: string; maxReadyCandidates: string;
}
export interface SafetyDraft {
dryRun: boolean;
}
export interface TargetingDraft {
physiqueTargetingEnabled: boolean; physiqueTargetingEnabled: boolean;
physiqueMinScore: string; physiqueMinScore: string;
physiqueMaxScore: string; physiqueMaxScore: string;
subreddits: SubredditDraft[]; }
export interface AdvancedDraft {
advancedConfigJson: string; advancedConfigJson: string;
} }
export interface ValidatedSettings { export function createPacingDraft(config: ConfigResponse): PacingDraft {
payload: ConfigUpdateRequest; return {
dryRun: boolean; minDelayMinutes: String(config.rateLimit.minDelayBetweenMessagesMs / 60_000),
maxMessagesPerHour: number; maxMessagesPerHour: String(config.rateLimit.maxMessagesPerHour),
jitterSeconds: String(config.rateLimit.jitterMs / 1_000),
prioritizeRecentPosts: config.reddit.prioritizeRecentPosts,
recentWindowMinutes: String(config.reddit.recentWindowMinutes),
maxReadyCandidates: String(config.reddit.maxReadyCandidates),
};
} }
export interface SettingsValidationResult { export function createSafetyDraft(config: ConfigResponse): SafetyDraft {
data?: ValidatedSettings; return { dryRun: config.browser.dryRun };
errors: Record<string, string>;
} }
const EditableSubredditSchema = SubredditConfigSchema.extend({ export function createTargetingDraft(config: ConfigResponse): TargetingDraft {
name: z.string().trim().min(1, 'Subreddit name is required.'), return {
weight: z.number().positive('Subreddit weight must be greater than 0.').optional(), physiqueTargetingEnabled: config.reddit.physiqueTargeting.enabled,
}); physiqueMinScore: String(config.reddit.physiqueTargeting.minScore),
physiqueMaxScore: String(config.reddit.physiqueTargeting.maxScore),
};
}
const ValidatedSettingsSchema = z.strictObject({ export function createAdvancedDraft(config: ConfigResponse): AdvancedDraft {
return { advancedConfigJson: config.advanced ? JSON.stringify(config.advanced, null, 2) : '' };
}
const PacingSchema = z.strictObject({
minDelayMinutes: z.number().finite().min(0, 'Minimum delay cannot be negative.'), minDelayMinutes: z.number().finite().min(0, 'Minimum delay cannot be negative.'),
maxMessagesPerHour: z.number().int().min(1, 'Maximum messages per hour must be at least 1.'), maxMessagesPerHour: z.number().int('Hourly limit must be a whole number.').min(1, 'Hourly limit must be at least 1.'),
jitterSeconds: z.number().finite().min(0, 'Jitter cannot be negative.'), jitterSeconds: z.number().finite().min(0, 'Jitter cannot be negative.'),
dryRun: z.boolean(),
prioritizeRecentPosts: z.boolean(), prioritizeRecentPosts: z.boolean(),
recentWindowMinutes: z.number().finite().positive('Recent window must be greater than 0 minutes.'), recentWindowMinutes: z.number().finite().positive('Recent window must be greater than 0 minutes.'),
maxReadyCandidates: z.number().int().min(1, 'Ready-post limit must be at least 1.').max(500, 'Ready-post limit cannot exceed 500.'), maxReadyCandidates: z.number()
physiqueTargetingEnabled: z.boolean(), .int('Ready-post limit must be a whole number.')
physiqueMinScore: z.number().int().min(1, 'Minimum physique score must be between 1 and 10.').max(10, 'Minimum physique score must be between 1 and 10.'), .min(1, 'Ready-post limit must be at least 1.')
physiqueMaxScore: z.number().int().min(1, 'Maximum physique score must be between 1 and 10.').max(10, 'Maximum physique score must be between 1 and 10.'), .max(500, 'Ready-post limit cannot exceed 500.'),
subreddits: z.array(EditableSubredditSchema).min(1, 'At least one subreddit is required.'), });
}).superRefine((value, context) => {
const names = value.subreddits.map((subreddit) => subreddit.name.trim().toLowerCase()); const requiredPacingFields: ReadonlyArray<{ key: keyof PacingDraft; message: string }> = [
if (new Set(names).size !== names.length) { { key: 'minDelayMinutes', message: 'Minimum delay is required.' },
context.addIssue({ code: 'custom', path: ['subreddits'], message: 'Subreddit names must be unique.' }); { key: 'maxMessagesPerHour', message: 'Hourly limit is required.' },
{ key: 'jitterSeconds', message: 'Jitter is required.' },
{ key: 'recentWindowMinutes', message: 'Recent window is required.' },
{ key: 'maxReadyCandidates', message: 'Ready-post limit is required.' },
];
export function validatePacingDraft(draft: PacingDraft): ConfigCardValidation {
const errors: Record<string, string> = {};
for (const field of requiredPacingFields) {
if (String(draft[field.key]).trim() === '') errors[field.key] = field.message;
} }
const parsed = PacingSchema.safeParse({
minDelayMinutes: Number(draft.minDelayMinutes),
maxMessagesPerHour: Number(draft.maxMessagesPerHour),
jitterSeconds: Number(draft.jitterSeconds),
prioritizeRecentPosts: draft.prioritizeRecentPosts,
recentWindowMinutes: Number(draft.recentWindowMinutes),
maxReadyCandidates: Number(draft.maxReadyCandidates),
});
if (!parsed.success) {
for (const issue of parsed.error.issues) {
const path = issue.path.join('.');
if (!(path in errors)) errors[path] = issue.message;
}
}
if (!parsed.success || Object.keys(errors).length > 0) return { errors };
return {
payload: ConfigUpdateRequestSchema.parse({
rateLimit: {
minDelayBetweenMessagesMs: parsed.data.minDelayMinutes * 60_000,
maxMessagesPerHour: parsed.data.maxMessagesPerHour,
jitterMs: parsed.data.jitterSeconds * 1_000,
},
reddit: {
prioritizeRecentPosts: parsed.data.prioritizeRecentPosts,
recentWindowMinutes: parsed.data.recentWindowMinutes,
maxReadyCandidates: parsed.data.maxReadyCandidates,
},
}),
errors: {},
};
}
export function pacingRisks(draft: PacingDraft, persisted: PacingDraft): string[] {
const next = Number(draft.maxMessagesPerHour);
const current = Number(persisted.maxMessagesPerHour);
if (next > RISKY_MESSAGES_PER_HOUR && next > current) {
return [`The limit will allow ${next} messages per hour, which raises the risk of Reddit rate limiting or banning the account.`];
}
return [];
}
export function validateSafetyDraft(draft: SafetyDraft): ConfigCardValidation {
return {
payload: ConfigUpdateRequestSchema.parse({ browser: { dryRun: draft.dryRun } }),
errors: {},
};
}
export function safetyRisks(draft: SafetyDraft, persisted: SafetyDraft): string[] {
if (persisted.dryRun && !draft.dryRun) {
return ['Dry run is being turned off. Automated messages will be sent to real Reddit users as soon as this is saved.'];
}
return [];
}
const PhysiqueScoreSchema = z.number()
.int('Physique scores must be whole numbers.')
.min(1, 'Physique scores must be between 1 and 10.')
.max(10, 'Physique scores must be between 1 and 10.');
const TargetingSchema = z.strictObject({
physiqueTargetingEnabled: z.boolean(),
physiqueMinScore: PhysiqueScoreSchema,
physiqueMaxScore: PhysiqueScoreSchema,
}).superRefine((value, context) => {
if (value.physiqueMinScore > value.physiqueMaxScore) { if (value.physiqueMinScore > value.physiqueMaxScore) {
context.addIssue({ context.addIssue({
code: 'custom', code: 'custom',
@@ -78,194 +159,105 @@ const ValidatedSettingsSchema = z.strictObject({
} }
}); });
let nextRowId = 0; export function validateTargetingDraft(draft: TargetingDraft): ConfigCardValidation {
const errors: Record<string, string> = {};
if (draft.physiqueMinScore.trim() === '') errors.physiqueMinScore = 'Minimum physique score is required.';
if (draft.physiqueMaxScore.trim() === '') errors.physiqueMaxScore = 'Maximum physique score is required.';
export function createSubredditRowId(): string { const parsed = TargetingSchema.safeParse({
nextRowId += 1;
return `subreddit-row-${nextRowId}`;
}
export function createSettingsDraft(config: ConfigResponse): SettingsDraft {
return {
minDelayMinutes: String(config.rateLimit.minDelayBetweenMessagesMs / 60_000),
maxMessagesPerHour: String(config.rateLimit.maxMessagesPerHour),
jitterSeconds: String(config.rateLimit.jitterMs / 1_000),
dryRun: config.browser.dryRun,
prioritizeRecentPosts: config.reddit.prioritizeRecentPosts,
recentWindowMinutes: String(config.reddit.recentWindowMinutes),
maxReadyCandidates: String(config.reddit.maxReadyCandidates),
physiqueTargetingEnabled: config.reddit.physiqueTargeting.enabled,
physiqueMinScore: String(config.reddit.physiqueTargeting.minScore),
physiqueMaxScore: String(config.reddit.physiqueTargeting.maxScore),
subreddits: config.subreddits.map((subreddit) => ({
rowId: createSubredditRowId(),
name: subreddit.name,
enabled: subreddit.enabled,
type: subreddit.type,
weight: subreddit.weight === undefined ? '' : String(subreddit.weight),
tone: subreddit.tone,
scripted: subreddit.scripted ?? '',
conversationMode: subreddit.conversation.mode,
maxAutomatedTurns: String(subreddit.conversation.maxAutomatedTurns),
rapportTurnsBeforeMediaAsk: String(subreddit.conversation.rapportTurnsBeforeMediaAsk),
})),
advancedConfigJson: config.advanced ? JSON.stringify(config.advanced, null, 2) : '',
};
}
export function validateSettingsDraft(draft: SettingsDraft): SettingsValidationResult {
const requiredErrors: Record<string, string> = {};
if (draft.minDelayMinutes.trim() === '') requiredErrors.minDelayMinutes = 'Minimum delay is required.';
if (draft.maxMessagesPerHour.trim() === '') requiredErrors.maxMessagesPerHour = 'Maximum messages per hour is required.';
if (draft.jitterSeconds.trim() === '') requiredErrors.jitterSeconds = 'Jitter is required.';
if (draft.recentWindowMinutes.trim() === '') requiredErrors.recentWindowMinutes = 'Recent window is required.';
if (draft.maxReadyCandidates.trim() === '') requiredErrors.maxReadyCandidates = 'Ready-post limit is required.';
if (draft.physiqueMinScore.trim() === '') requiredErrors.physiqueMinScore = 'Minimum physique score is required.';
if (draft.physiqueMaxScore.trim() === '') requiredErrors.physiqueMaxScore = 'Maximum physique score is required.';
draft.subreddits.forEach((subreddit, index) => {
if (subreddit.name.trim() === '') requiredErrors[`subreddits.${index}.name`] = 'Subreddit name is required.';
if (subreddit.weight.trim() !== '' && Number(subreddit.weight) <= 0) {
requiredErrors[`subreddits.${index}.weight`] = 'Subreddit weight must be greater than 0.';
}
if ((subreddit.conversationMode === 'r4r' || subreddit.conversationMode === 'r4r-media')
&& subreddit.maxAutomatedTurns.trim() === '') {
requiredErrors[`subreddits.${index}.maxAutomatedTurns`] = 'Maximum automated turns is required.';
}
if (subreddit.conversationMode === 'r4r-media' && subreddit.rapportTurnsBeforeMediaAsk.trim() === '') {
requiredErrors[`subreddits.${index}.rapportTurnsBeforeMediaAsk`] = 'Rapport turns is required.';
}
});
let advanced: Record<string, unknown> | undefined;
let advancedError: string | undefined;
if (draft.advancedConfigJson.trim() !== '') {
try {
const value: unknown = JSON.parse(draft.advancedConfigJson);
if (!value || typeof value !== 'object' || Array.isArray(value)) {
advancedError = 'Advanced configuration must be a JSON object.';
} else {
advanced = value as Record<string, unknown>;
}
} catch {
advancedError = 'Advanced configuration is not valid JSON.';
}
}
const parsed = ValidatedSettingsSchema.safeParse({
minDelayMinutes: Number(draft.minDelayMinutes),
maxMessagesPerHour: Number(draft.maxMessagesPerHour),
jitterSeconds: Number(draft.jitterSeconds),
dryRun: draft.dryRun,
prioritizeRecentPosts: draft.prioritizeRecentPosts,
recentWindowMinutes: Number(draft.recentWindowMinutes),
maxReadyCandidates: Number(draft.maxReadyCandidates),
physiqueTargetingEnabled: draft.physiqueTargetingEnabled, physiqueTargetingEnabled: draft.physiqueTargetingEnabled,
physiqueMinScore: Number(draft.physiqueMinScore), physiqueMinScore: Number(draft.physiqueMinScore),
physiqueMaxScore: Number(draft.physiqueMaxScore), physiqueMaxScore: Number(draft.physiqueMaxScore),
subreddits: draft.subreddits.map((subreddit) => ({
name: subreddit.name.trim(),
enabled: subreddit.enabled,
type: subreddit.type,
weight: subreddit.weight.trim() === '' ? undefined : Number(subreddit.weight),
tone: subreddit.tone,
scripted: subreddit.scripted.trim() === '' ? undefined : subreddit.scripted,
conversation: {
mode: subreddit.conversationMode,
maxAutomatedTurns: subreddit.conversationMode === 'r4r' || subreddit.conversationMode === 'r4r-media'
? Number(subreddit.maxAutomatedTurns)
: 3,
rapportTurnsBeforeMediaAsk: subreddit.conversationMode === 'r4r-media'
? Number(subreddit.rapportTurnsBeforeMediaAsk)
: 3,
},
})),
}); });
// Run required-field and schema validation in one pass so every problem
// surfaces on the first save attempt. Required-field messages win per field.
const errors: Record<string, string> = {};
if (!parsed.success) { if (!parsed.success) {
for (const issue of parsed.error.issues) { for (const issue of parsed.error.issues) {
const pathParts = [...issue.path]; const path = issue.path.join('.');
if (pathParts[0] === 'subreddits' && pathParts[2] === 'conversation') pathParts.splice(2, 1);
const path = pathParts.join('.');
if (!(path in errors)) errors[path] = issue.message; if (!(path in errors)) errors[path] = issue.message;
} }
} }
if (advancedError) errors.advancedConfigJson = advancedError;
Object.assign(errors, requiredErrors);
if (!parsed.success || Object.keys(errors).length > 0) return { errors }; if (!parsed.success || Object.keys(errors).length > 0) return { errors };
const payload = ConfigUpdateRequestSchema.parse({ // Only `physiqueTargeting` is sent: the route applies each `reddit` field
rateLimit: { // independently, so the pacing card's values are left untouched.
minDelayBetweenMessagesMs: parsed.data.minDelayMinutes * 60_000, return {
maxMessagesPerHour: parsed.data.maxMessagesPerHour, payload: ConfigUpdateRequestSchema.parse({
jitterMs: parsed.data.jitterSeconds * 1_000,
},
browser: { dryRun: parsed.data.dryRun },
reddit: { reddit: {
prioritizeRecentPosts: parsed.data.prioritizeRecentPosts,
recentWindowMinutes: parsed.data.recentWindowMinutes,
maxReadyCandidates: parsed.data.maxReadyCandidates,
physiqueTargeting: { physiqueTargeting: {
enabled: parsed.data.physiqueTargetingEnabled, enabled: parsed.data.physiqueTargetingEnabled,
minScore: parsed.data.physiqueMinScore, minScore: parsed.data.physiqueMinScore,
maxScore: parsed.data.physiqueMaxScore, maxScore: parsed.data.physiqueMaxScore,
}, },
}, },
subreddits: parsed.data.subreddits, }),
...(advanced ? { advanced } : {}),
});
return {
data: {
payload,
dryRun: parsed.data.dryRun,
maxMessagesPerHour: parsed.data.maxMessagesPerHour,
},
errors: {}, errors: {},
}; };
} }
function comparableDraft(draft: SettingsDraft): unknown { export function targetingRisks(draft: TargetingDraft, persisted: TargetingDraft): string[] {
if (draft.physiqueTargetingEnabled && !persisted.physiqueTargetingEnabled) {
return [
`Image posts will be screened to a physique score of ${draft.physiqueMinScore}–${draft.physiqueMaxScore}, and posts without enough visible body evidence will be skipped entirely. This can sharply reduce how many posts qualify.`,
];
}
return [];
}
export function validateAdvancedDraft(draft: AdvancedDraft): ConfigCardValidation {
const raw = draft.advancedConfigJson.trim();
if (raw === '') return { errors: { advancedConfigJson: 'Advanced configuration cannot be emptied here.' } };
let value: unknown;
try {
value = JSON.parse(raw);
} catch (error) {
const detail = error instanceof Error ? error.message : 'Unparseable JSON.';
return { errors: { advancedConfigJson: `Not valid JSON — ${detail}` } };
}
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return { errors: { advancedConfigJson: 'Advanced configuration must be a JSON object, not an array or a bare value.' } };
}
return { return {
...draft, payload: ConfigUpdateRequestSchema.parse({ advanced: value as Record<string, unknown> }),
subreddits: draft.subreddits.map(({ rowId: _rowId, ...subreddit }) => subreddit), errors: {},
}; };
} }
export function isDraftDirty(draft: SettingsDraft, persisted: SettingsDraft): boolean { export interface ThroughputEstimate {
return JSON.stringify(comparableDraft(draft)) !== JSON.stringify(comparableDraft(persisted)); /** Messages per hour actually achievable, given both the cap and the delay. */
effectivePerHour: number;
/** True when the minimum delay, not the hourly cap, is the binding limit. */
isDelayBound: boolean;
/** Human-readable spacing between messages including average jitter. */
spacingLabel: string;
} }
/** /**
* Field keys (matching validation error keys) whose values differ between two drafts. * The hourly cap and the minimum delay both throttle sending, so the real
* Used to clear validation errors only for the fields the user edited. * ceiling is whichever binds first. Surfacing it stops operators from raising
* one number and seeing no change because the other one governs.
*/ */
export function changedDraftFields(previous: SettingsDraft, next: SettingsDraft): string[] { export function estimateThroughput(draft: PacingDraft): ThroughputEstimate | null {
const changed: string[] = []; // A blank field coerces to 0, which would produce a confident but wrong
const topLevelKeys = ['minDelayMinutes', 'maxMessagesPerHour', 'jitterSeconds', 'dryRun', 'prioritizeRecentPosts', 'recentWindowMinutes', 'maxReadyCandidates', 'physiqueTargetingEnabled', 'physiqueMinScore', 'physiqueMaxScore', 'advancedConfigJson'] as const; // readout, so treat anything unfilled as no estimate at all.
for (const key of topLevelKeys) { const inputs = [draft.maxMessagesPerHour, draft.minDelayMinutes, draft.jitterSeconds];
if (previous[key] !== next[key]) changed.push(key); if (inputs.some((value) => value.trim() === '')) return null;
}
if (previous.subreddits.length !== next.subreddits.length) { const perHourCap = Number(draft.maxMessagesPerHour);
changed.push('subreddits'); const minDelayMinutes = Number(draft.minDelayMinutes);
// Row indices shift on add/remove, so any indexed subreddit error may be stale. const jitterSeconds = Number(draft.jitterSeconds);
for (let index = 0; index < Math.max(previous.subreddits.length, next.subreddits.length); index += 1) { if (!Number.isFinite(perHourCap) || !Number.isFinite(minDelayMinutes) || !Number.isFinite(jitterSeconds)) return null;
for (const field of ['name', 'weight', 'maxAutomatedTurns', 'rapportTurnsBeforeMediaAsk'] as const) { if (perHourCap < 1 || minDelayMinutes < 0 || jitterSeconds < 0) return null;
changed.push(`subreddits.${index}.${field}`);
} const averageSpacingMinutes = minDelayMinutes + jitterSeconds / 120;
} const delayCap = averageSpacingMinutes > 0 ? 60 / averageSpacingMinutes : Number.POSITIVE_INFINITY;
return changed; const effectivePerHour = Math.min(perHourCap, delayCap);
}
next.subreddits.forEach((subreddit, index) => { return {
const previousSubreddit = previous.subreddits[index]; effectivePerHour: Math.round(effectivePerHour * 10) / 10,
if (!previousSubreddit) return; isDelayBound: delayCap < perHourCap,
for (const field of ['name', 'enabled', 'type', 'weight', 'tone', 'scripted', 'conversationMode', 'maxAutomatedTurns', 'rapportTurnsBeforeMediaAsk'] as const) { spacingLabel: averageSpacingMinutes === 0
if (previousSubreddit[field] !== subreddit[field]) { ? 'back to back'
changed.push(`subreddits.${index}.${field}`); : `about one every ${Math.round(averageSpacingMinutes * 10) / 10} min`,
if (field === 'name') changed.push('subreddits'); };
}
}
});
return changed;
} }
@@ -1,45 +1,87 @@
import { SettingsEditor } from './settings-editor.js'; import { useEffect } from 'react';
import { UnsavedChangesProvider } from '../../components/unsaved-changes.js';
import { AdvancedCard } from './advanced-card.js';
import { PacingCard } from './pacing-card.js';
import { PushNotificationsCard } from './push-notifications-panel.js';
import { SafetyCard } from './safety-card.js';
import { TargetingCard } from './targeting-card.js';
import { useSettingsQuery } from './settings-queries.js'; import { useSettingsQuery } from './settings-queries.js';
import './settings.css'; import './settings.css';
export type SettingsSection = 'monitoring' | 'rate-limits' | 'behaviour' | 'notifications' | 'access'; /** In-page section anchors. Every section is on one page; these only jump. */
const sections: ReadonlyArray<{ anchor: string; label: string }> = [
interface SettingsPageProps { { anchor: 'safety', label: 'Safety' },
section: SettingsSection; { anchor: 'pacing', label: 'Pacing' },
onSectionChange(section: SettingsSection): void; { anchor: 'targeting', label: 'Targeting' },
} { anchor: 'notifications', label: 'Notifications' },
{ anchor: 'advanced', label: 'Advanced' },
const sections: ReadonlyArray<{ id: SettingsSection; label: string; description: string }> = [
{ id: 'monitoring', label: 'Monitoring', description: 'Post discovery and recency' },
{ id: 'rate-limits', label: 'Rate limits', description: 'Sending pace and safeguards' },
{ id: 'behaviour', label: 'Behaviour', description: 'Dry run and subreddits' },
{ id: 'notifications', label: 'Notifications', description: 'Phone push via ntfy' },
{ id: 'access', label: 'Access', description: 'Extension session dump' },
]; ];
function errorMessage(error: Error | null): string { function errorMessage(error: Error | null): string {
return error?.message ?? 'Something went wrong.'; return error?.message ?? 'Something went wrong.';
} }
export function SettingsPage({ section, onSectionChange }: SettingsPageProps) { interface SettingsPageProps {
/** Section to scroll to on mount, from the legacy `?section=` link format. */
initialAnchor?: string;
}
export function SettingsPage({ initialAnchor }: SettingsPageProps) {
const settingsQuery = useSettingsQuery(); const settingsQuery = useSettingsQuery();
const hasConfig = Boolean(settingsQuery.data);
// Deep links land before the cards exist, so wait for the config to render.
// Scrolling is a nicety: environments without scrollIntoView still land on
// the right page, just at the top.
useEffect(() => {
if (!initialAnchor || !hasConfig) return;
const target = document.getElementById(initialAnchor);
if (typeof target?.scrollIntoView === 'function') target.scrollIntoView({ block: 'start' });
}, [initialAnchor, hasConfig]);
return ( return (
<UnsavedChangesProvider>
<div className="page settings-page"> <div className="page settings-page">
<header className="page-header"> <header className="page-header">
<div><span className="eyebrow">Platform configuration</span><h1>Settings</h1><p>Manage supported runtime behaviour and Reddit account access.</p></div> <div>
<span className="eyebrow">Platform configuration</span>
<h1>Settings</h1>
<p>Runtime behaviour for every account. Each section saves on its own, so one change never waits on another.</p>
</div>
</header> </header>
<nav aria-label="Settings sections" className="settings-navigation">
{sections.map((item) => ( {hasConfig ? (
<button aria-current={section === item.id ? 'page' : undefined} aria-label={item.label} className={section === item.id ? 'is-active' : ''} key={item.id} onClick={() => onSectionChange(item.id)} type="button"><strong>{item.label}</strong><small>{item.description}</small></button> <nav aria-label="Settings sections" className="section-rail">
{sections.map((section) => (
<a href={`#${section.anchor}`} key={section.anchor}>{section.label}</a>
))} ))}
</nav> </nav>
{settingsQuery.isPending ? <div aria-label="Loading settings" className="page-state settings-loading" role="status"><span className="spinner" />Loading settings…</div> : null}
{settingsQuery.isError ? (
<div className="page-state error-state settings-loading" role="alert"><strong>Settings unavailable</strong><span>{errorMessage(settingsQuery.error)}</span><button className="button secondary" onClick={() => void settingsQuery.refetch()} type="button">Retry settings</button></div>
) : null} ) : null}
{settingsQuery.data ? <SettingsEditor config={settingsQuery.data} section={section} /> : null}
{settingsQuery.isPending ? (
<div aria-label="Loading settings" className="page-state" role="status">
<span className="spinner" />Loading settings…
</div> </div>
) : null}
{settingsQuery.isError ? (
<div className="page-state error-state" role="alert">
<strong>Settings unavailable</strong>
<span>{errorMessage(settingsQuery.error)}</span>
<button className="button secondary" onClick={() => void settingsQuery.refetch()} type="button">Retry settings</button>
</div>
) : null}
{settingsQuery.data ? (
<div className="settings-stack">
<SafetyCard config={settingsQuery.data} />
<PacingCard config={settingsQuery.data} />
<TargetingCard config={settingsQuery.data} />
<PushNotificationsCard />
<AdvancedCard config={settingsQuery.data} />
</div>
) : null}
</div>
</UnsavedChangesProvider>
); );
} }
@@ -1,19 +1,28 @@
import { useSearchParams } from 'react-router-dom'; import { Navigate, useSearchParams } from 'react-router-dom';
import { SettingsPage, type SettingsSection } from './settings-page.js'; import { SettingsPage } from './settings-page.js';
const validSections: readonly SettingsSection[] = ['monitoring', 'rate-limits', 'behaviour', 'notifications', 'access']; /**
* Sections that used to be settings tabs and are now their own pages. Old
* bookmarks and in-app links keep working by redirecting.
*/
const relocatedSections: Record<string, string> = {
behaviour: '/subreddits',
access: '/accounts',
};
function parseSection(value: string | null): SettingsSection { /** Former tab names that are now in-page anchors on the settings page. */
return validSections.find((section) => section === value) ?? 'monitoring'; const anchorSections: Record<string, string> = {
} monitoring: 'pacing',
'rate-limits': 'pacing',
notifications: 'notifications',
};
export function SettingsRoute() { export function SettingsRoute() {
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const section = parseSection(searchParams.get('section')); const section = searchParams.get('section');
function selectSection(nextSection: SettingsSection) { const relocated = section ? relocatedSections[section] : undefined;
setSearchParams({ section: nextSection }, { replace: true }); if (relocated) return <Navigate replace to={relocated} />;
}
return <SettingsPage onSectionChange={selectSection} section={section} />; return <SettingsPage initialAnchor={section ? anchorSections[section] : undefined} />;
} }
@@ -1,337 +0,0 @@
import type { ConfigResponse } from '../../../../../packages/api-contracts/src/index.js';
import { createSubredditRowId, type SettingsDraft, type SubredditDraft } from './settings-model.js';
interface SectionProps {
draft: SettingsDraft;
errors: Record<string, string>;
onChange(next: SettingsDraft): void;
}
interface NumberFieldProps {
error?: string;
label: string;
name: string;
min?: number;
max?: number;
value: string;
onChange(value: string): void;
}
function NumberField({ error, label, name, min, max, value, onChange }: NumberFieldProps) {
const errorId = `${name}-error`;
return (
<label className="settings-field">
<span>{label}</span>
<input
aria-describedby={error ? errorId : undefined}
aria-invalid={error ? 'true' : undefined}
aria-label={label}
min={min}
max={max}
name={name}
onChange={(event) => onChange(event.target.value)}
step="1"
type="number"
value={value}
/>
{error ? <small className="field-error" id={errorId}>{error}</small> : null}
</label>
);
}
export function MonitoringSection({ draft, errors, onChange }: SectionProps) {
return (
<section aria-labelledby="monitoring-heading" className="panel settings-section">
<div className="panel-heading"><div><span className="eyebrow">Discovery</span><h2 id="monitoring-heading">Monitoring</h2></div></div>
<label className="toggle-field">
<input
aria-label="Prioritise recent posts"
checked={draft.prioritizeRecentPosts}
onChange={(event) => onChange({ ...draft, prioritizeRecentPosts: event.target.checked })}
type="checkbox"
/>
<span><strong>Prioritise recent posts</strong><small>Prefer posts inside the configured recent window.</small></span>
</label>
<NumberField
error={errors.recentWindowMinutes}
label="Recent window in minutes"
min={1}
name="recent-window"
onChange={(recentWindowMinutes) => onChange({ ...draft, recentWindowMinutes })}
value={draft.recentWindowMinutes}
/>
<NumberField
error={errors.maxReadyCandidates}
label="Maximum ready posts"
min={1}
name="maximum-ready-posts"
onChange={(maxReadyCandidates) => onChange({ ...draft, maxReadyCandidates })}
value={draft.maxReadyCandidates}
/>
<div className="advanced-settings">
<div>
<h3>Physique targeting</h3>
<p>Apply a subjective 1–10 visible-physique range to image posts. Images without enough visible body evidence are screened out while this is enabled.</p>
</div>
<label className="toggle-field">
<input
aria-label="Enable physique targeting"
checked={draft.physiqueTargetingEnabled}
onChange={(event) => onChange({ ...draft, physiqueTargetingEnabled: event.target.checked })}
type="checkbox"
/>
<span><strong>Enable physique targeting</strong><small>Chat posts are unaffected.</small></span>
</label>
<div className="settings-grid">
<NumberField
error={errors.physiqueMinScore}
label="Minimum physique score"
min={1}
max={10}
name="minimum-physique-score"
onChange={(physiqueMinScore) => onChange({ ...draft, physiqueMinScore })}
value={draft.physiqueMinScore}
/>
<NumberField
error={errors.physiqueMaxScore}
label="Maximum physique score"
min={1}
max={10}
name="maximum-physique-score"
onChange={(physiqueMaxScore) => onChange({ ...draft, physiqueMaxScore })}
value={draft.physiqueMaxScore}
/>
</div>
</div>
</section>
);
}
export function RateLimitsSection({ draft, errors, onChange }: SectionProps) {
return (
<section aria-labelledby="rate-limits-heading" className="panel settings-section">
<div className="panel-heading"><div><span className="eyebrow">Sending safeguards</span><h2 id="rate-limits-heading">Rate limits</h2></div></div>
<div className="settings-grid">
<NumberField
error={errors.minDelayMinutes}
label="Minimum delay in minutes"
min={0}
name="minimum-delay"
onChange={(minDelayMinutes) => onChange({ ...draft, minDelayMinutes })}
value={draft.minDelayMinutes}
/>
<NumberField
error={errors.maxMessagesPerHour}
label="Maximum messages per hour"
min={1}
name="hourly-limit"
onChange={(maxMessagesPerHour) => onChange({ ...draft, maxMessagesPerHour })}
value={draft.maxMessagesPerHour}
/>
<NumberField
error={errors.jitterSeconds}
label="Random jitter in seconds"
min={0}
name="jitter"
onChange={(jitterSeconds) => onChange({ ...draft, jitterSeconds })}
value={draft.jitterSeconds}
/>
</div>
</section>
);
}
function updateSubreddit(draft: SettingsDraft, index: number, next: SubredditDraft): SettingsDraft {
return { ...draft, subreddits: draft.subreddits.map((item, itemIndex) => itemIndex === index ? next : item) };
}
export function BehaviourSection({ config, draft, errors, onChange }: SectionProps & { config: ConfigResponse }) {
return (
<section aria-labelledby="behaviour-heading" className="panel settings-section">
<div className="panel-heading"><div><span className="eyebrow">Runtime behaviour</span><h2 id="behaviour-heading">Behaviour</h2></div></div>
<label className="toggle-field">
<input
aria-label="Dry run mode"
checked={draft.dryRun}
onChange={(event) => onChange({ ...draft, dryRun: event.target.checked })}
type="checkbox"
/>
<span><strong>Dry run mode</strong><small>Evaluate posts without sending messages.</small></span>
</label>
<div className="subreddit-heading">
<div><h3>Subreddits</h3><p>Configure monitored communities and their supported message style.</p></div>
<button
className="button secondary"
onClick={() => onChange({
...draft,
subreddits: [
...draft.subreddits,
{ rowId: createSubredditRowId(), name: '', enabled: true, type: 'image', weight: '1', tone: 'default', scripted: '', conversationMode: 'legacy', maxAutomatedTurns: '3', rapportTurnsBeforeMediaAsk: '3' },
],
})}
type="button"
>
Add subreddit
</button>
</div>
{errors.subreddits ? <p className="field-error" role="alert">{errors.subreddits}</p> : null}
<div className="subreddit-list">
{draft.subreddits.map((subreddit, index) => (
<fieldset className="subreddit-row" key={subreddit.rowId}>
<legend>Subreddit {index + 1}</legend>
<label className="settings-field">
<span>Name</span>
<input
aria-describedby={errors[`subreddits.${index}.name`] ? `subreddit-${index}-name-error` : undefined}
aria-invalid={errors[`subreddits.${index}.name`] ? 'true' : undefined}
aria-label={`Subreddit ${index + 1} name`}
onChange={(event) => onChange(updateSubreddit(draft, index, { ...subreddit, name: event.target.value }))}
type="text"
value={subreddit.name}
/>
{errors[`subreddits.${index}.name`] ? <small className="field-error" id={`subreddit-${index}-name-error`}>{errors[`subreddits.${index}.name`]}</small> : null}
</label>
<label className="settings-field">
<span>Type</span>
<select
aria-label={`Subreddit ${index + 1} type`}
onChange={(event) => onChange(updateSubreddit(draft, index, { ...subreddit, type: event.target.value as SubredditDraft['type'] }))}
value={subreddit.type}
>
<option value="image">Image</option><option value="chat">Chat</option>
</select>
</label>
<label className="toggle-field">
<input
aria-label={`Subreddit ${index + 1} enabled`}
checked={subreddit.enabled}
onChange={(event) => onChange(updateSubreddit(draft, index, { ...subreddit, enabled: event.target.checked }))}
type="checkbox"
/>
<span><strong>Enabled</strong><small>Disabled subreddits are not polled.</small></span>
</label>
<label className="settings-field">
<span>Weight</span>
<input
aria-describedby={errors[`subreddits.${index}.weight`] ? `subreddit-${index}-weight-error` : undefined}
aria-invalid={errors[`subreddits.${index}.weight`] ? 'true' : undefined}
aria-label={`Subreddit ${index + 1} weight`}
min="0.01"
onChange={(event) => onChange(updateSubreddit(draft, index, { ...subreddit, weight: event.target.value }))}
step="0.01"
type="number"
value={subreddit.weight}
/>
{errors[`subreddits.${index}.weight`] ? <small className="field-error" id={`subreddit-${index}-weight-error`}>{errors[`subreddits.${index}.weight`]}</small> : null}
</label>
<label className="settings-field">
<span>Tone</span>
<select
aria-label={`Subreddit ${index + 1} tone`}
onChange={(event) => onChange(updateSubreddit(draft, index, { ...subreddit, tone: event.target.value as SubredditDraft['tone'] }))}
value={subreddit.tone}
>
{config.tones.map((tone) => <option key={tone} value={tone}>{tone}</option>)}
</select>
</label>
<label className="settings-field">
<span>Conversation behaviour</span>
<select
aria-label={`Subreddit ${index + 1} conversation behaviour`}
onChange={(event) => onChange(updateSubreddit(draft, index, {
...subreddit,
conversationMode: event.target.value as SubredditDraft['conversationMode'],
}))}
value={subreddit.conversationMode}
>
<option value="legacy">Legacy media asks</option>
<option value="r4r">Autonomous R4R conversation</option>
<option value="r4r-media">R4R then one media ask</option>
<option value="manual">Manual only</option>
</select>
<small>{subreddit.conversationMode === 'r4r'
? 'Continues general conversation autonomously. It will not request sexual media.'
: subreddit.conversationMode === 'r4r-media'
? 'Builds rapport autonomously, then makes one optional, non-explicit image request.'
: subreddit.conversationMode === 'manual'
? 'Incoming replies are marked for human handling and are never answered automatically.'
: 'Preserves the existing three-turn media-request flow.'}</small>
</label>
{subreddit.conversationMode === 'r4r' || subreddit.conversationMode === 'r4r-media' ? (
<label className="settings-field">
<span>Maximum automated turns</span>
<input
aria-describedby={errors[`subreddits.${index}.maxAutomatedTurns`] ? `subreddit-${index}-max-turns-error` : undefined}
aria-invalid={errors[`subreddits.${index}.maxAutomatedTurns`] ? 'true' : undefined}
aria-label={`Subreddit ${index + 1} maximum automated turns`}
max="20"
min="1"
onChange={(event) => onChange(updateSubreddit(draft, index, { ...subreddit, maxAutomatedTurns: event.target.value }))}
step="1"
type="number"
value={subreddit.maxAutomatedTurns}
/>
{errors[`subreddits.${index}.maxAutomatedTurns`] ? <small className="field-error" id={`subreddit-${index}-max-turns-error`}>{errors[`subreddits.${index}.maxAutomatedTurns`]}</small> : null}
</label>
) : null}
{subreddit.conversationMode === 'r4r-media' ? (
<label className="settings-field">
<span>Rapport turns before media ask</span>
<input
aria-describedby={errors[`subreddits.${index}.rapportTurnsBeforeMediaAsk`] ? `subreddit-${index}-rapport-turns-error` : undefined}
aria-invalid={errors[`subreddits.${index}.rapportTurnsBeforeMediaAsk`] ? 'true' : undefined}
aria-label={`Subreddit ${index + 1} rapport turns before media ask`}
max="19"
min="1"
onChange={(event) => onChange(updateSubreddit(draft, index, { ...subreddit, rapportTurnsBeforeMediaAsk: event.target.value }))}
step="1"
type="number"
value={subreddit.rapportTurnsBeforeMediaAsk}
/>
{errors[`subreddits.${index}.rapportTurnsBeforeMediaAsk`] ? <small className="field-error" id={`subreddit-${index}-rapport-turns-error`}>{errors[`subreddits.${index}.rapportTurnsBeforeMediaAsk`]}</small> : null}
</label>
) : null}
<label className="settings-field scripted-field">
<span>Scripted message (optional)</span>
<textarea
aria-label={`Subreddit ${index + 1} scripted message`}
onChange={(event) => onChange(updateSubreddit(draft, index, { ...subreddit, scripted: event.target.value }))}
rows={2}
value={subreddit.scripted}
/>
</label>
<button
className="button secondary remove-subreddit"
disabled={draft.subreddits.length === 1}
onClick={() => onChange({
...draft,
subreddits: draft.subreddits.filter((_, itemIndex) => itemIndex !== index),
})}
type="button"
>
Remove subreddit {index + 1}
</button>
</fieldset>
))}
</div>
{config.advanced ? (
<div className="advanced-settings">
<div><h3>Advanced application configuration</h3><p>Models, prompts, filtering, media sync, and strategy settings. Some changes take effect after a restart.</p></div>
<label className="settings-field">
<span>Advanced configuration (JSON)</span>
<textarea
aria-invalid={errors.advancedConfigJson ? 'true' : undefined}
aria-label="Advanced configuration JSON"
onChange={(event) => onChange({ ...draft, advancedConfigJson: event.target.value })}
rows={24}
spellCheck={false}
value={draft.advancedConfigJson}
/>
{errors.advancedConfigJson ? <small className="field-error">{errors.advancedConfigJson}</small> : null}
</label>
</div>
) : null}
</section>
);
}
@@ -1,75 +1,12 @@
.settings-page { max-width: 72rem; } .settings-page { max-width: 62rem; }
.settings-navigation { display: grid; grid-template-columns: 1fr; gap: .5rem; margin-bottom: 1rem; } .settings-stack { display: grid; gap: 1rem; }
.settings-navigation button { display: grid; gap: .15rem; padding: .8rem; color: var(--muted); text-align: left; background: var(--surface); border: 1px solid var(--border); border-radius: .75rem; }
.settings-navigation button strong { color: #e9f1ec; }
.settings-navigation button small { font-size: .72rem; }
.settings-navigation button.is-active { background: var(--accent-dark); border-color: #28593d; }
.settings-navigation button:focus-visible, .settings-field input:focus-visible, .settings-field select:focus-visible, .settings-field textarea:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.settings-loading { min-height: 24rem; }
.settings-section { display: grid; gap: 1rem; }
.settings-copy { color: var(--muted); }
.settings-grid { display: grid; gap: 1rem; }
.settings-field { display: grid; gap: .4rem; color: var(--muted); font-size: .8rem; font-weight: 750; }
.settings-field input, .settings-field select, .settings-field textarea { width: 100%; min-height: 2.7rem; padding: .55rem .7rem; color: #e9f1ec; background: var(--surface-raised); border: 1px solid var(--border); border-radius: .6rem; }
.settings-field textarea { resize: vertical; }
.settings-field [aria-invalid="true"] { border-color: var(--danger); }
.field-error, .save-error { color: var(--danger); }
.toggle-field { display: flex; align-items: flex-start; gap: .8rem; padding: .9rem; background: var(--surface-raised); border: 1px solid var(--border); border-radius: .75rem; }
.toggle-field input { width: 1.15rem; height: 1.15rem; margin-top: .15rem; accent-color: var(--accent); }
.toggle-field span { display: grid; gap: .2rem; }
.toggle-field small { color: var(--muted); }
.settings-events { display: grid; gap: .6rem; padding: 1rem; border: 1px solid var(--border); border-radius: .75rem; }
.settings-events legend { padding: 0 .4rem; color: var(--accent); font-weight: 800; }
.subreddit-heading { display: flex; flex-wrap: wrap; align-items: flex-end; justify-content: space-between; gap: 1rem; padding-top: .5rem; } /* Effective-throughput readout under the pacing fields. */
.subreddit-heading h3 { margin: 0 0 .25rem; } .throughput-readout { display: grid; gap: .2rem; padding: .8rem 1rem; margin: 0; color: var(--success-text); background: var(--success-bg); border: 1px solid var(--success-border); border-radius: .7rem; }
.subreddit-heading p { margin: 0; color: var(--muted); } .throughput-readout strong { font-size: 1.05rem; }
.subreddit-list { display: grid; gap: .75rem; } .throughput-readout span { color: inherit; font-size: .82rem; opacity: .85; }
.subreddit-row { display: grid; gap: .75rem; min-width: 0; padding: 1rem; border: 1px solid var(--border); border-radius: .75rem; } .throughput-readout.is-unknown { color: var(--muted); background: var(--surface-raised); border-color: var(--border); font-size: .85rem; }
.subreddit-row legend { padding: 0 .4rem; color: var(--accent); font-weight: 800; }
.remove-subreddit { align-self: end; }
.settings-save-bar { position: sticky; bottom: 1rem; display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 1rem; padding: .9rem 1rem; margin-top: 1rem; background: rgba(23, 35, 30, .97); border: 1px solid var(--border); border-radius: .8rem; box-shadow: 0 1rem 3rem rgba(0, 0, 0, .3); }
.settings-save-bar > div:first-child { display: grid; gap: .15rem; color: var(--muted); }
.save-success { color: var(--accent); }
.save-warning { color: var(--warning); }
.risk-list { display: grid; gap: .5rem; padding-left: 1.2rem; color: var(--warning); }
.access-actions { display: flex; flex-wrap: wrap; gap: .75rem; }
.access-actions a { display: inline-flex; align-items: center; text-decoration: none; }
.settings-notice { padding: .75rem; border-radius: .65rem; }
.settings-notice.success { color: var(--success-text); background: var(--success-bg); }
.settings-notice.warning { color: var(--warning-text); background: var(--warning-bg); }
.settings-notice.error { color: var(--danger-text); background: var(--danger-bg); }
@media (min-width: 42rem) { .advanced-keys { display: grid; gap: .45rem; }
.settings-navigation { grid-template-columns: repeat(2, minmax(0, 1fr)); } .advanced-keys ul { display: flex; flex-wrap: wrap; gap: .4rem; padding: 0; margin: 0; list-style: none; }
.settings-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } .advanced-keys code { display: inline-block; padding: .2rem .45rem; color: var(--accent); background: var(--surface-raised); border: 1px solid var(--border); border-radius: .4rem; font-size: .78rem; }
.subreddit-row { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.scripted-field { grid-column: 1 / -1; }
}
@media (min-width: 70rem) {
.settings-navigation { grid-template-columns: repeat(5, minmax(0, 1fr)); }
}
.reddit-account-list {
display: grid;
gap: .65rem;
}
.reddit-account-row {
align-items: center;
border: 1px solid var(--border);
border-radius: .75rem;
display: flex;
gap: 1rem;
justify-content: space-between;
padding: .75rem;
}
.reddit-account-row.is-active {
border-color: var(--accent);
}
.reddit-account-row > div {
display: grid;
gap: .2rem;
}
@@ -0,0 +1,70 @@
import type { ConfigResponse } from '../../../../../packages/api-contracts/src/index.js';
import { NumberField, SwitchField } from '../../components/form-fields.js';
import { SettingsCard } from './settings-card.js';
import {
createTargetingDraft,
targetingRisks,
validateTargetingDraft,
type TargetingDraft,
} from './settings-model.js';
import { useConfigCard } from './use-config-card.js';
export function TargetingCard({ config }: { config: ConfigResponse }) {
const card = useConfigCard<TargetingDraft>(config, {
id: 'targeting',
toDraft: createTargetingDraft,
validate: validateTargetingDraft,
risks: targetingRisks,
});
const { draft, errors } = card;
const isActive = card.persisted.physiqueTargetingEnabled;
return (
<SettingsCard
anchor="targeting"
card={card}
description="An optional screen on image posts. Chat and text posts are never affected by it."
eyebrow="Post filtering"
saveLabel="Save targeting"
status={<span className={isActive ? 'pill warning' : 'pill neutral'}>{isActive ? 'Screening on' : 'Off'}</span>}
title="Targeting"
>
<SwitchField
checked={draft.physiqueTargetingEnabled}
description="Applies a subjective 1–10 visible-physique range to image posts. While this is on, images without enough visible body evidence are screened out."
label="Enable physique targeting"
onChange={(physiqueTargetingEnabled) => card.update({ ...draft, physiqueTargetingEnabled })}
/>
{/* The range still saves while disabled, so it is ready when turned on. */}
<fieldset className="field-group">
<legend>Accepted score range</legend>
<div className="field-row">
<NumberField
error={errors.physiqueMinScore}
hint="Lowest score that still qualifies."
label="Minimum score"
max={10}
min={1}
name="minimum-physique-score"
onChange={(physiqueMinScore) => card.update({ ...draft, physiqueMinScore })}
value={draft.physiqueMinScore}
/>
<NumberField
error={errors.physiqueMaxScore}
hint="Highest score that still qualifies."
label="Maximum score"
max={10}
min={1}
name="maximum-physique-score"
onChange={(physiqueMaxScore) => card.update({ ...draft, physiqueMaxScore })}
value={draft.physiqueMaxScore}
/>
</div>
{!draft.physiqueTargetingEnabled ? (
<p className="field-hint">This range is saved but not applied while physique targeting is off.</p>
) : null}
</fieldset>
</SettingsCard>
);
}
@@ -0,0 +1,155 @@
import { useEffect, useRef, useState } from 'react';
import type { ConfigResponse, ConfigUpdateRequest } from '../../../../../packages/api-contracts/src/index.js';
import { useUnsavedChangesFlag } from '../../components/unsaved-changes.js';
import { useSaveSettingsMutation } from './settings-queries.js';
export interface ConfigCardValidation {
/** Absent when the draft is invalid; `errors` explains why. */
payload?: ConfigUpdateRequest;
errors: Record<string, string>;
}
export interface ConfigCardSpec<Draft> {
/** Stable id used for the page-level unsaved-changes guard. */
id: string;
toDraft(config: ConfigResponse): Draft;
validate(draft: Draft): ConfigCardValidation;
/**
* Consequences the operator must confirm before this save is applied.
* Compared against the last persisted draft so re-saving an already-live
* risky value does not prompt again.
*/
risks?(draft: Draft, persisted: Draft): string[];
}
export interface ConfigCard<Draft> {
draft: Draft;
persisted: Draft;
errors: Record<string, string>;
errorCount: number;
isDirty: boolean;
/** The server changed this card's values while the operator had edits open. */
hasServerChanged: boolean;
isSaving: boolean;
isSaved: boolean;
saveError: Error | null;
/** Non-null while a risky save waits on confirmation. */
pendingRisks: string[] | null;
update(next: Draft): void;
reset(): void;
save(): void;
confirmRisks(): void;
cancelRisks(): void;
}
function isSame(left: unknown, right: unknown): boolean {
return JSON.stringify(left) === JSON.stringify(right);
}
interface CardState<Draft> {
persisted: Draft;
draft: Draft;
hasServerChanged: boolean;
}
/**
* Owns one card's draft lifecycle: seed from the server config, track dirty
* state, validate, confirm risk, and save a *partial* config update.
*
* Cards save independently, so a card only ever submits the keys it owns. An
* invalid value in one card can never block another card's save.
*/
export function useConfigCard<Draft>(config: ConfigResponse, spec: ConfigCardSpec<Draft>): ConfigCard<Draft> {
const specRef = useRef(spec);
specRef.current = spec;
const [state, setState] = useState<CardState<Draft>>(() => {
const draft = spec.toDraft(config);
return { persisted: draft, draft, hasServerChanged: false };
});
const [errors, setErrors] = useState<Record<string, string>>({});
const [pendingRisks, setPendingRisks] = useState<{ payload: ConfigUpdateRequest; reasons: string[] } | null>(null);
const saveMutation = useSaveSettingsMutation();
const seededConfig = useRef(config);
const isDirty = !isSame(state.draft, state.persisted);
useUnsavedChangesFlag(spec.id, isDirty);
// Reconcile server refetches. A sibling card's save also produces a new
// config object, so adopt silently whenever the server's values for *this*
// card match what is on screen; only warn when they genuinely diverge.
useEffect(() => {
if (seededConfig.current === config) return;
seededConfig.current = config;
const serverDraft = specRef.current.toDraft(config);
setState((current) => {
// Nothing this card owns moved — a sibling card saved, or an unrelated
// refetch landed. Leave the operator's edits completely alone.
if (isSame(serverDraft, current.persisted)) return current;
// The server now agrees with what is on screen, so there is no conflict.
if (isSame(serverDraft, current.draft)) {
return { persisted: serverDraft, draft: current.draft, hasServerChanged: false };
}
// No local edits to protect: adopt the server's values silently.
if (isSame(current.draft, current.persisted)) {
return { persisted: serverDraft, draft: serverDraft, hasServerChanged: false };
}
return { ...current, persisted: serverDraft, hasServerChanged: true };
});
}, [config]);
function submit(payload: ConfigUpdateRequest) {
saveMutation.mutate(payload, {
onSuccess() {
setPendingRisks(null);
setState((current) => ({ persisted: current.draft, draft: current.draft, hasServerChanged: false }));
},
});
}
return {
draft: state.draft,
persisted: state.persisted,
errors,
errorCount: Object.keys(errors).length,
isDirty,
hasServerChanged: state.hasServerChanged,
isSaving: saveMutation.isPending,
isSaved: saveMutation.isSuccess && !isDirty,
saveError: saveMutation.isError ? saveMutation.error : null,
pendingRisks: pendingRisks?.reasons ?? null,
update(next: Draft) {
saveMutation.reset();
setErrors({});
setState((current) => ({ ...current, draft: next }));
},
reset() {
saveMutation.reset();
setErrors({});
setState((current) => ({ ...current, draft: current.persisted, hasServerChanged: false }));
},
save() {
saveMutation.reset();
const validation = specRef.current.validate(state.draft);
setErrors(validation.errors);
if (!validation.payload) return;
const reasons = specRef.current.risks?.(state.draft, state.persisted) ?? [];
if (reasons.length > 0) {
setPendingRisks({ payload: validation.payload, reasons });
return;
}
submit(validation.payload);
},
confirmRisks() {
if (pendingRisks) submit(pendingRisks.payload);
},
cancelRisks() {
setPendingRisks(null);
},
};
}
@@ -0,0 +1,114 @@
import type { ConfigResponse } from '../../../../../packages/api-contracts/src/index.js';
import { conversationModes, type ConversationMode, type SubredditConfig } from './subreddit-model.js';
interface SubredditBulkBarProps {
selected: readonly SubredditConfig[];
/** True when a bulk write is in flight. */
isBusy: boolean;
/** Set when removing the selection is not allowed, explaining why. */
removeBlockedReason?: string;
tones: ConfigResponse['tones'];
onClear(): void;
onRemove(): void;
onSetEnabled(enabled: boolean): void;
onSetPolicy(mode: ConversationMode): void;
onSetTone(tone: ConfigResponse['tones'][number]): void;
}
/**
* Acts on many subreddits at once. With dozens configured, opening each row to
* change one shared field is the slowest possible path.
*/
export function SubredditBulkBar({
isBusy,
onClear,
onRemove,
onSetEnabled,
onSetPolicy,
onSetTone,
removeBlockedReason,
selected,
tones,
}: SubredditBulkBarProps) {
const pausedCount = selected.filter((subreddit) => !subreddit.enabled).length;
const pollingCount = selected.length - pausedCount;
return (
<div aria-label="Bulk actions" className="bulk-bar" role="region">
<div className="bulk-bar-count" aria-live="polite">
<strong>{selected.length} selected</strong>
<small>{pollingCount} polling, {pausedCount} paused</small>
</div>
<div className="bulk-bar-actions">
<button
className="button secondary"
disabled={isBusy || pausedCount === 0}
onClick={() => onSetEnabled(true)}
type="button"
>
Start polling
</button>
<button
className="button secondary"
disabled={isBusy || pollingCount === 0}
onClick={() => onSetEnabled(false)}
type="button"
>
Pause polling
</button>
{/* Value resets to the placeholder each render: these are actions, not
a field showing the current shared value (there may not be one). */}
<label className="bulk-select">
<span className="sr-only">Set tone for the selected subreddits</span>
<select
aria-label="Set tone for the selected subreddits"
disabled={isBusy}
onChange={(event) => {
if (event.target.value === '') return;
onSetTone(event.target.value as ConfigResponse['tones'][number]);
event.target.value = '';
}}
value=""
>
<option value="">Set tone…</option>
{tones.map((tone) => <option key={tone} value={tone}>{tone}</option>)}
</select>
</label>
<label className="bulk-select">
<span className="sr-only">Set conversation policy for the selected subreddits</span>
<select
aria-label="Set conversation policy for the selected subreddits"
disabled={isBusy}
onChange={(event) => {
if (event.target.value === '') return;
onSetPolicy(event.target.value as ConversationMode);
event.target.value = '';
}}
value=""
>
<option value="">Set policy…</option>
{conversationModes.map((mode) => (
<option key={mode.value} value={mode.value}>{mode.label}</option>
))}
</select>
</label>
<button
className="button danger"
disabled={isBusy || Boolean(removeBlockedReason)}
onClick={onRemove}
title={removeBlockedReason}
type="button"
>
Remove
</button>
<button className="button secondary" disabled={isBusy} onClick={onClear} type="button">
Clear
</button>
</div>
</div>
);
}
@@ -0,0 +1,266 @@
import { useState } from 'react';
import type { ConfigResponse } from '../../../../../packages/api-contracts/src/index.js';
import {
NumberField,
RadioCards,
SelectField,
SwitchField,
TextAreaField,
TextField,
type RadioCardOption,
} from '../../components/form-fields.js';
import { Modal } from '../../components/modal.js';
import { RiskDialog } from '../settings/risk-dialog.js';
import {
conversationModes,
createEmptySubredditDraft,
formatShare,
MAX_AUTOMATED_TURNS,
previewShare,
toSubredditDraft,
usesAutomatedTurns,
validateSubredditDraft,
type ConversationMode,
type SubredditConfig,
type SubredditDraft,
} from './subreddit-model.js';
interface SubredditEditorProps {
/** The subreddit being edited, or null to create a new one. */
subreddit: SubredditConfig | null;
allSubreddits: readonly SubredditConfig[];
tones: ConfigResponse['tones'];
isSaving: boolean;
saveError: Error | null;
onCancel(): void;
onSave(config: SubredditConfig): void;
}
function errorMessage(error: Error | null): string {
return error?.message ?? 'Something went wrong.';
}
/**
* Edits exactly one subreddit. Because the draft is a single row, validation
* errors are keyed by plain field name — there are no array indices to shift
* when rows are added or removed elsewhere.
*/
export function SubredditEditor({
allSubreddits,
isSaving,
onCancel,
onSave,
saveError,
subreddit,
tones,
}: SubredditEditorProps) {
const isNew = subreddit === null;
const [draft, setDraft] = useState<SubredditDraft>(() =>
subreddit ? toSubredditDraft(subreddit) : createEmptySubredditDraft());
const [errors, setErrors] = useState<Record<string, string>>({});
const [pendingRisks, setPendingRisks] = useState<{ config: SubredditConfig; reasons: string[] } | null>(null);
const takenNames = allSubreddits
.filter((item) => item.name !== subreddit?.name)
.map((item) => item.name);
function update(next: Partial<SubredditDraft>) {
setErrors({});
setDraft((current) => ({ ...current, ...next }));
}
function submit() {
const validation = validateSubredditDraft(draft, takenNames);
setErrors(validation.errors);
if (!validation.config) return;
const reasons: string[] = [];
const previousMode = subreddit?.conversation.mode;
const nextMeta = conversationModes.find((mode) => mode.value === validation.config!.conversation.mode);
if (nextMeta?.isAutonomous && previousMode !== validation.config.conversation.mode) {
reasons.push(
`r/${validation.config.name} will reply autonomously, without approval for each message.`
+ (validation.config.conversation.mode === 'r4r-media'
? ' After rapport it can make one optional, non-explicit image request.'
: ' It will not request sexual media.'),
);
reasons.push('Conversations already in progress keep the policy they started with.');
}
if (reasons.length > 0) {
setPendingRisks({ config: validation.config, reasons });
return;
}
onSave(validation.config);
}
const share = previewShare(draft, allSubreddits, subreddit?.name ?? null);
const turnOptions: RadioCardOption<ConversationMode>[] = conversationModes.map((mode) => ({
value: mode.value,
label: mode.label,
description: mode.description,
isRisky: mode.isAutonomous,
detail: usesAutomatedTurns(mode.value) ? (
<div className="field-row">
<NumberField
error={errors.maxAutomatedTurns}
hint={`Replies it may send before handing over (1–${MAX_AUTOMATED_TURNS}).`}
label="Maximum automated turns"
max={MAX_AUTOMATED_TURNS}
min={1}
name="max-automated-turns"
onChange={(maxAutomatedTurns) => update({ maxAutomatedTurns })}
suffix="turns"
value={draft.maxAutomatedTurns}
/>
{mode.value === 'r4r-media' ? (
<NumberField
error={errors.rapportTurnsBeforeMediaAsk}
hint="Must be lower than the maximum automated turns."
label="Rapport turns before the ask"
max={MAX_AUTOMATED_TURNS - 1}
min={1}
name="rapport-turns"
onChange={(rapportTurnsBeforeMediaAsk) => update({ rapportTurnsBeforeMediaAsk })}
suffix="turns"
value={draft.rapportTurnsBeforeMediaAsk}
/>
) : null}
</div>
) : undefined,
}));
return (
<>
<Modal className="drawer-card" isDismissLocked={isSaving} labelledBy="subreddit-editor-title" onClose={onCancel}>
<header className="drawer-heading">
<div>
<span className="eyebrow">{isNew ? 'New subreddit' : 'Edit subreddit'}</span>
<h2 id="subreddit-editor-title">{isNew ? 'Add a subreddit' : `r/${subreddit.name}`}</h2>
</div>
<button aria-label="Close editor" className="icon-button" disabled={isSaving} onClick={onCancel} type="button">
<span aria-hidden="true">×</span>
</button>
</header>
<div className="drawer-body">
<fieldset className="field-group">
<legend>Community</legend>
<div className="field-row">
<TextField
error={errors.name}
hint="Without the r/ prefix."
label="Subreddit name"
name="subreddit-name"
onChange={(name) => update({ name })}
placeholder="casualconversation"
prefix="r/"
value={draft.name}
/>
<SelectField
label="Post type"
name="subreddit-type"
onChange={(type) => update({ type })}
options={[{ value: 'image', label: 'Image posts' }, { value: 'chat', label: 'Chat and text posts' }]}
value={draft.type}
/>
</div>
</fieldset>
<fieldset className="field-group">
<legend>Targeting</legend>
<SwitchField
checked={draft.enabled}
description="Disabled subreddits are not polled at all and take no share of attention."
label="Poll this subreddit"
onChange={(enabled) => update({ enabled })}
/>
<div className="field-row">
<NumberField
error={errors.weight}
hint="Relative to other enabled subreddits. Leave blank for the default of 1."
label="Weight"
min={0.01}
name="subreddit-weight"
onChange={(weight) => update({ weight })}
step="any"
value={draft.weight}
/>
{/* Weight only means something as a proportion, so show the
proportion it will actually produce. */}
<div className="share-preview">
<span className="field-label">Share of polling</span>
<strong aria-live="polite">{formatShare(share)}</strong>
<small>{draft.enabled ? 'Of all enabled subreddits, once saved.' : 'Nothing while polling is off.'}</small>
</div>
</div>
</fieldset>
<fieldset className="field-group">
<legend>Voice</legend>
<div className="field-row">
<SelectField
hint="Which tone profile writes the messages."
label="Tone"
name="subreddit-tone"
onChange={(tone) => update({ tone })}
options={tones.map((tone) => ({ value: tone, label: tone }))}
value={draft.tone}
/>
</div>
<TextAreaField
hint="Sent verbatim instead of a generated opener. Leave blank to let the model write it."
label="Scripted opening message (optional)"
name="subreddit-scripted"
onChange={(scripted) => update({ scripted })}
rows={3}
value={draft.scripted}
/>
</fieldset>
<RadioCards
legend="Conversation policy"
name="conversation-mode"
onChange={(conversationMode) => update({ conversationMode })}
options={turnOptions}
value={draft.conversationMode}
/>
</div>
<footer className="drawer-footer">
<div aria-live="polite" className="settings-card-status">
{Object.keys(errors).length > 0 ? (
<strong className="save-error" role="alert">
{Object.keys(errors).length === 1
? 'Resolve 1 problem before saving.'
: `Resolve ${Object.keys(errors).length} problems before saving.`}
</strong>
) : null}
{saveError ? <strong className="save-error" role="alert">{errorMessage(saveError)}</strong> : null}
</div>
<div className="header-actions">
<button className="button secondary" disabled={isSaving} onClick={onCancel} type="button">Cancel</button>
<button
aria-busy={isSaving ? 'true' : undefined}
className="button primary"
disabled={isSaving}
onClick={submit}
type="button"
>
{isSaving ? 'Saving…' : isNew ? 'Add subreddit' : 'Save subreddit'}
</button>
</div>
</footer>
</Modal>
{pendingRisks ? (
<RiskDialog
isPending={isSaving}
onCancel={() => setPendingRisks(null)}
onConfirm={() => onSave(pendingRisks.config)}
reasons={pendingRisks.reasons}
/>
) : null}
</>
);
}
@@ -0,0 +1,248 @@
import {
MAX_CONFIGURED_AUTOMATED_CONVERSATION_TURNS,
SubredditConfigSchema,
type ConfigResponse,
type ToneKey,
} from '../../../../../packages/api-contracts/src/index.js';
export type SubredditConfig = ConfigResponse['subreddits'][number];
export type ConversationMode = SubredditConfig['conversation']['mode'];
/** Weight the runtime assumes when a subreddit does not set one. */
export const DEFAULT_SUBREDDIT_WEIGHT = 1;
export interface SubredditDraft {
name: string;
enabled: boolean;
type: 'image' | 'chat';
weight: string;
tone: ToneKey;
scripted: string;
conversationMode: ConversationMode;
maxAutomatedTurns: string;
rapportTurnsBeforeMediaAsk: string;
}
export interface SubredditValidation {
/** Absent when the draft is invalid; `errors` is keyed by draft field name. */
config?: SubredditConfig;
errors: Record<string, string>;
}
export interface ConversationModeMeta {
value: ConversationMode;
label: string;
/** Short form for list badges. */
badge: string;
description: string;
/** Sends without per-message approval. */
isAutonomous: boolean;
}
export const conversationModes: ReadonlyArray<ConversationModeMeta> = [
{
value: 'legacy',
label: 'Media asks',
badge: 'Media asks',
description: 'The existing three-turn flow that works toward a media request.',
isAutonomous: false,
},
{
value: 'r4r',
label: 'Autonomous conversation',
badge: 'Autonomous',
description: 'Holds a general conversation on its own, without approval per message. It will never request sexual media.',
isAutonomous: true,
},
{
value: 'r4r-media',
label: 'Autonomous, then one media ask',
badge: 'Autonomous + ask',
description: 'Builds rapport on its own, then makes a single optional, non-explicit image request.',
isAutonomous: true,
},
{
value: 'manual',
label: 'Manual only',
badge: 'Manual',
description: 'Incoming replies are flagged for you to handle. Nothing is ever answered automatically.',
isAutonomous: false,
},
];
export function conversationModeMeta(mode: ConversationMode): ConversationModeMeta {
return conversationModes.find((item) => item.value === mode) ?? conversationModes[0]!;
}
/** True for modes whose turn budget the operator can set. */
export function usesAutomatedTurns(mode: ConversationMode): boolean {
return mode === 'r4r' || mode === 'r4r-media';
}
export function toSubredditDraft(subreddit: SubredditConfig): SubredditDraft {
return {
name: subreddit.name,
enabled: subreddit.enabled,
type: subreddit.type,
weight: subreddit.weight === undefined ? '' : String(subreddit.weight),
tone: subreddit.tone,
scripted: subreddit.scripted ?? '',
conversationMode: subreddit.conversation.mode,
maxAutomatedTurns: String(subreddit.conversation.maxAutomatedTurns),
rapportTurnsBeforeMediaAsk: String(subreddit.conversation.rapportTurnsBeforeMediaAsk),
};
}
export function createEmptySubredditDraft(): SubredditDraft {
return {
name: '',
enabled: true,
type: 'image',
weight: '',
tone: 'default',
scripted: '',
// New subreddits default to the safest policy: nothing sends without review.
conversationMode: 'manual',
maxAutomatedTurns: '3',
rapportTurnsBeforeMediaAsk: '3',
};
}
/** Reddit's own rule set for community names, minus the optional `r/` prefix. */
const namePattern = /^[A-Za-z0-9][A-Za-z0-9_]{1,20}$/;
export function normaliseSubredditName(value: string): string {
return value.trim().replace(/^\/?r\//i, '').trim();
}
export function validateSubredditDraft(draft: SubredditDraft, takenNames: readonly string[]): SubredditValidation {
const errors: Record<string, string> = {};
const name = normaliseSubredditName(draft.name);
if (name === '') {
errors.name = 'Subreddit name is required.';
} else if (!namePattern.test(name)) {
errors.name = 'Use 2–21 letters, numbers or underscores, starting with a letter or number.';
} else if (takenNames.some((taken) => taken.toLowerCase() === name.toLowerCase())) {
errors.name = `r/${name} is already configured.`;
}
if (draft.weight.trim() !== '') {
const weight = Number(draft.weight);
if (!Number.isFinite(weight) || weight <= 0) errors.weight = 'Weight must be a number greater than 0.';
}
if (usesAutomatedTurns(draft.conversationMode)) {
if (draft.maxAutomatedTurns.trim() === '') {
errors.maxAutomatedTurns = 'Maximum automated turns is required.';
}
if (draft.conversationMode === 'r4r-media' && draft.rapportTurnsBeforeMediaAsk.trim() === '') {
errors.rapportTurnsBeforeMediaAsk = 'Rapport turns is required.';
}
}
// Optional fields are omitted rather than set to undefined, so the saved
// payload contains exactly the keys the operator actually configured.
const parsed = SubredditConfigSchema.safeParse({
name,
enabled: draft.enabled,
type: draft.type,
...(draft.weight.trim() === '' ? {} : { weight: Number(draft.weight) }),
tone: draft.tone,
...(draft.scripted.trim() === '' ? {} : { scripted: draft.scripted }),
conversation: {
mode: draft.conversationMode,
maxAutomatedTurns: usesAutomatedTurns(draft.conversationMode) ? Number(draft.maxAutomatedTurns) : 3,
rapportTurnsBeforeMediaAsk: draft.conversationMode === 'r4r-media'
? Number(draft.rapportTurnsBeforeMediaAsk)
: 3,
},
});
if (!parsed.success) {
for (const issue of parsed.error.issues) {
// Conversation fields are flat in the draft, so drop the nesting to keep
// error keys aligned with the controls that produced them.
const path = issue.path.filter((part) => part !== 'conversation').join('.');
const key = path === '' ? 'name' : path;
if (!(key in errors)) errors[key] = issue.message;
}
}
if (Object.keys(errors).length > 0) return { errors };
return { config: parsed.data as SubredditConfig, errors: {} };
}
export const MAX_AUTOMATED_TURNS = MAX_CONFIGURED_AUTOMATED_CONVERSATION_TURNS;
/**
* Switches one subreddit to a conversation mode, keeping its existing turn
* budget where that is still valid. Used by bulk edits, where there is no
* per-row form to catch the hybrid mode's rapport-below-total rule.
*/
export function withConversationMode(subreddit: SubredditConfig, mode: ConversationMode): SubredditConfig {
const current = subreddit.conversation;
if (mode !== 'r4r-media') {
return { ...subreddit, conversation: { ...current, mode } };
}
const maxAutomatedTurns = Math.min(Math.max(current.maxAutomatedTurns, 2), MAX_AUTOMATED_TURNS);
return {
...subreddit,
conversation: {
mode,
maxAutomatedTurns,
rapportTurnsBeforeMediaAsk: Math.min(current.rapportTurnsBeforeMediaAsk, maxAutomatedTurns - 1),
},
};
}
export interface SubredditShare {
/** Fraction of polling attention this subreddit receives, 0–1. */
share: number;
/** Its weight, with the runtime default filled in. */
effectiveWeight: number;
}
/**
* Weight is meaningless in isolation — it only sets a subreddit's share
* relative to the others that are enabled. This turns the raw numbers into the
* proportion the operator is actually choosing.
*/
export function computeShares(subreddits: readonly SubredditConfig[]): Map<string, SubredditShare> {
const enabled = subreddits.filter((subreddit) => subreddit.enabled);
const total = enabled.reduce((sum, subreddit) => sum + (subreddit.weight ?? DEFAULT_SUBREDDIT_WEIGHT), 0);
const shares = new Map<string, SubredditShare>();
for (const subreddit of subreddits) {
const effectiveWeight = subreddit.weight ?? DEFAULT_SUBREDDIT_WEIGHT;
shares.set(subreddit.name, {
effectiveWeight,
share: subreddit.enabled && total > 0 ? effectiveWeight / total : 0,
});
}
return shares;
}
export function formatShare(share: number): string {
if (share <= 0) return '—';
const percent = share * 100;
return percent < 1 ? '<1%' : `${Math.round(percent)}%`;
}
/**
* Share this draft would take if saved, used for the live preview in the
* editor. Excludes the row being edited from the baseline so its own old
* weight does not count twice.
*/
export function previewShare(
draft: SubredditDraft,
allSubreddits: readonly SubredditConfig[],
editingName: string | null,
): number {
const weight = draft.weight.trim() === '' ? DEFAULT_SUBREDDIT_WEIGHT : Number(draft.weight);
if (!Number.isFinite(weight) || weight <= 0 || !draft.enabled) return 0;
const others = allSubreddits
.filter((subreddit) => subreddit.name !== editingName && subreddit.enabled)
.reduce((sum, subreddit) => sum + (subreddit.weight ?? DEFAULT_SUBREDDIT_WEIGHT), 0);
const total = others + weight;
return total > 0 ? weight / total : 0;
}
@@ -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>
);
}
@@ -0,0 +1,62 @@
.subreddits-page { max-width: 68rem; }
.subreddit-summary { display: flex; flex-wrap: wrap; align-items: center; gap: 2rem; padding: 1rem 1.1rem; margin-bottom: 1rem; background: linear-gradient(110deg, #13221b, #0f1915); border: 1px solid var(--border); border-radius: .9rem; }
.subreddit-summary > div { display: flex; align-items: baseline; gap: .5rem; }
.subreddit-summary strong { font-size: 1.6rem; letter-spacing: -.04em; }
.subreddit-summary span { color: var(--muted); font-size: .85rem; }
.subreddit-summary .save-error { flex: 1 0 100%; margin: 0; font-size: .85rem; font-weight: 700; }
.subreddit-toolbar { display: flex; flex-wrap: wrap; align-items: flex-end; gap: .75rem; margin-bottom: 1rem; }
.inline-field { flex: 1 1 12rem; max-width: 18rem; }
/* Live share readout beside the weight input in the editor. */
.share-preview { display: grid; align-content: start; gap: .15rem; padding: .5rem .7rem; background: var(--surface-raised); border: 1px solid var(--border); border-radius: .6rem; }
.share-preview strong { color: var(--accent); font-size: 1.35rem; line-height: 1.1; letter-spacing: -.03em; }
.share-preview small { color: var(--muted); font-size: .72rem; }
.subreddit-cards { display: grid; gap: .65rem; padding: 0; margin: 0; list-style: none; }
.subreddit-card { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: .85rem; padding: 1rem; background: var(--surface); border: 1px solid var(--border); border-radius: .85rem; }
.subreddit-card.is-disabled { background: #0d1512; border-style: dashed; }
.subreddit-card.is-disabled .subreddit-identity h3 { color: var(--muted); }
.subreddit-card.is-selected { border-color: var(--accent); background: var(--accent-dark); }
.subreddit-select { display: flex; align-self: start; align-items: center; padding-top: .15rem; cursor: pointer; }
.subreddit-select input { width: 1.15rem; height: 1.15rem; accent-color: var(--accent); }
.subreddit-select:focus-within { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: .3rem; }
.select-all { align-self: center; }
/* Sticks to the bottom so the selection stays actionable while scrolling a
long list. */
.bulk-bar { position: sticky; bottom: 1rem; z-index: 5; display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 1rem; padding: .8rem 1rem; margin-top: 1rem; background: rgba(23, 35, 30, .97); border: 1px solid var(--accent); border-radius: .8rem; box-shadow: 0 1rem 3rem rgba(0, 0, 0, .45); backdrop-filter: blur(8px); }
.bulk-bar-count { display: grid; gap: .1rem; }
.bulk-bar-count small { color: var(--muted); font-size: .75rem; }
/* Two columns while narrow keeps the bar to three rows instead of five, so it
does not swallow the list it acts on. */
.bulk-bar-actions { display: grid; grid-template-columns: 1fr 1fr; align-items: center; gap: .5rem; width: 100%; }
.bulk-bar-actions .button { min-height: 2.3rem; padding: .4rem .75rem; font-size: .82rem; }
.bulk-select select { width: 100%; min-height: 2.3rem; padding: .35rem .6rem; color: #e9f1ec; background: var(--surface-raised); border: 1px solid var(--border); border-radius: .65rem; font-size: .82rem; font-weight: 700; }
.bulk-select select:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.subreddit-card-main { display: grid; gap: .85rem; }
.subreddit-identity { display: grid; gap: .45rem; min-width: 0; }
.subreddit-identity h3 { margin: 0; font-size: 1.05rem; overflow-wrap: anywhere; }
.subreddit-tags { display: flex; flex-wrap: wrap; gap: .35rem; }
.subreddit-tags .pill { font-size: .68rem; }
.subreddit-metrics { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: .5rem 1rem; margin: 0; }
.subreddit-metrics dt { color: var(--muted); font-size: .68rem; font-weight: 800; letter-spacing: .05em; text-transform: uppercase; }
.subreddit-metrics dd { display: grid; gap: .05rem; margin: 0; font-weight: 750; }
.subreddit-metrics dd small { color: var(--muted); font-size: .7rem; font-weight: 650; }
.subreddit-card-actions { grid-column: 1 / -1; display: flex; flex-wrap: wrap; align-items: center; gap: .5rem; padding-top: .85rem; border-top: 1px solid var(--border); }
.subreddit-card-actions .button { min-height: 2.3rem; padding: .4rem .75rem; font-size: .82rem; }
.subreddit-card-actions .inline-switch { margin-right: auto; }
@media (min-width: 52rem) {
.bulk-bar-actions { display: flex; flex-wrap: wrap; width: auto; }
.bulk-select select { width: auto; }
.subreddit-card { grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; }
.subreddit-card-main { grid-template-columns: minmax(11rem, 1fr) minmax(0, 1.1fr); align-items: center; gap: 1.5rem; }
.subreddit-card-actions { grid-column: 3; padding-top: 0; padding-left: 1.25rem; border-top: 0; border-left: 1px solid var(--border); }
.subreddit-card-actions .inline-switch { margin-right: 0; }
}
+98
View File
@@ -44,6 +44,9 @@ a { color: inherit; }
.brand div { display: grid; gap: .1rem; } .brand div { display: grid; gap: .1rem; }
.brand span:last-child, .brand div span { color: var(--muted); font-size: .75rem; } .brand span:last-child, .brand div span { color: var(--muted); font-size: .75rem; }
nav { display: grid; gap: .35rem; } nav { display: grid; gap: .35rem; }
.nav-group { display: grid; gap: .2rem; }
.nav-group + .nav-group { margin-top: 1rem; }
.nav-group-label { padding: 0 .75rem .3rem; color: var(--muted); font-size: .65rem; font-weight: 800; letter-spacing: .12em; text-transform: uppercase; opacity: .75; }
.nav-link { display: grid; gap: .2rem; padding: .8rem .75rem; color: var(--muted); text-decoration: none; border-radius: .75rem; border: 1px solid transparent; } .nav-link { display: grid; gap: .2rem; padding: .8rem .75rem; color: var(--muted); text-decoration: none; border-radius: .75rem; border: 1px solid transparent; }
.nav-link span { color: #dfe9e3; font-weight: 650; } .nav-link span { color: #dfe9e3; font-weight: 650; }
.nav-link small { font-size: .72rem; line-height: 1.3; } .nav-link small { font-size: .72rem; line-height: 1.3; }
@@ -181,6 +184,101 @@ dialog.dialog-card { width: min(28rem, calc(100% - 2rem)); padding: 1.4rem; back
border-color: color-mix(in srgb, #d99a24 40%, transparent); border-color: color-mix(in srgb, #d99a24 40%, transparent);
} }
/* ---------------------------------------------------------------------------
Forms
Shared field primitives (components/form-fields.tsx). Every editable surface
uses these so labels, hints and error placement stay identical everywhere.
--------------------------------------------------------------------------- */
.field { display: grid; gap: .35rem; min-width: 0; }
.field-label { color: var(--muted); font-size: .72rem; font-weight: 800; letter-spacing: .04em; text-transform: uppercase; }
.field-hint { color: var(--muted); font-size: .78rem; }
.field-error { color: var(--danger); font-size: .78rem; font-weight: 700; }
.field-input { position: relative; display: flex; align-items: center; }
.field input, .field select, .field textarea, .field-input input {
width: 100%; min-height: 2.7rem; padding: .55rem .7rem; color: #e9f1ec;
background: var(--surface-raised); border: 1px solid var(--border); border-radius: .6rem;
}
.field textarea { resize: vertical; line-height: 1.5; }
.field textarea.is-monospace { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .8rem; }
.field input:focus-visible, .field select:focus-visible, .field textarea:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.field [aria-invalid="true"] { border-color: var(--danger); }
.field-prefix, .field-suffix { position: absolute; color: var(--muted); font-size: .82rem; font-weight: 700; pointer-events: none; }
.field-prefix { left: .7rem; }
.field-suffix { right: .7rem; }
.field-input.has-prefix input { padding-left: 2rem; }
.field-input.has-suffix input { padding-right: 3.6rem; }
.field-row { display: grid; gap: .9rem; }
.field-group { display: grid; gap: .9rem; padding: 1rem; margin: 0; border: 1px solid var(--border); border-radius: .75rem; }
.field-group legend { padding: 0 .4rem; color: var(--accent); font-size: .75rem; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; }
.switch-field { display: flex; align-items: flex-start; gap: .8rem; padding: .9rem; background: var(--surface-raised); border: 1px solid var(--border); border-radius: .75rem; cursor: pointer; }
.switch-field input { flex: 0 0 auto; width: 1.15rem; height: 1.15rem; margin-top: .15rem; accent-color: var(--accent); }
.switch-field:focus-within { outline: 2px solid var(--accent); outline-offset: 2px; }
.switch-field.is-critical { border-color: var(--warning-border); background: var(--warning-bg); }
.switch-field.is-critical .switch-copy strong { color: var(--warning-text); }
.switch-copy { display: grid; gap: .2rem; }
.switch-copy small { color: var(--muted); }
.switch-list { display: grid; gap: .5rem; }
.radio-cards { display: grid; gap: .5rem; padding: 1rem; margin: 0; border: 1px solid var(--border); border-radius: .75rem; }
.radio-cards legend { padding: 0 .4rem; color: var(--accent); font-size: .75rem; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; }
.radio-card { background: var(--surface-raised); border: 1px solid var(--border); border-radius: .7rem; }
.radio-card.is-selected { border-color: var(--accent); background: var(--accent-dark); }
.radio-card:focus-within { outline: 2px solid var(--accent); outline-offset: 2px; }
.radio-card > label { display: flex; align-items: flex-start; gap: .8rem; padding: .85rem; cursor: pointer; }
/* Scoped to the option's own radio: a bare `.radio-card input` also matches
the number inputs nested in .radio-detail and shrinks them to 1.1rem. */
.radio-card > label > input[type="radio"] { flex: 0 0 auto; width: 1.1rem; height: 1.1rem; margin-top: .15rem; accent-color: var(--accent); }
.radio-copy { display: grid; gap: .25rem; }
.radio-copy strong { display: flex; flex-wrap: wrap; align-items: center; gap: .5rem; }
.radio-copy small { color: var(--muted); line-height: 1.45; }
.radio-detail { padding: 0 .85rem .85rem 3.05rem; }
.inline-switch { display: inline-flex; align-items: center; gap: .5rem; padding: .45rem .7rem; color: var(--muted); background: var(--surface-raised); border: 1px solid var(--border); border-radius: 999px; font-size: .78rem; font-weight: 750; cursor: pointer; }
.inline-switch input { width: 1.05rem; height: 1.05rem; accent-color: var(--accent); }
.inline-switch:focus-within { outline: 2px solid var(--accent); outline-offset: 2px; }
/* ---------------------------------------------------------------------------
Cards that own their own save
--------------------------------------------------------------------------- */
.settings-card { display: grid; gap: 1rem; padding: 0; overflow: hidden; }
.settings-card-heading { display: flex; flex-wrap: wrap; align-items: flex-start; justify-content: space-between; gap: 1rem; padding: 1.1rem 1.1rem 0; }
.settings-card-heading h2 { margin-bottom: .35rem; }
.settings-card-heading p { max-width: 44rem; margin: 0; color: var(--muted); font-size: .88rem; line-height: 1.5; }
.settings-card-body { display: grid; gap: .9rem; padding: 0 1.1rem; }
.settings-card-footer { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 1rem; padding: .85rem 1.1rem; background: var(--surface-raised); border-top: 1px solid var(--border); }
.settings-card-status { display: grid; gap: .15rem; color: var(--muted); font-size: .82rem; }
.save-success { color: var(--accent); }
.save-warning { color: var(--warning); }
.save-error { color: var(--danger); }
.state-banner { display: grid; gap: .25rem; padding: .85rem 1rem; margin: 0; border: 1px solid var(--border); border-radius: .7rem; }
.state-banner span { font-size: .85rem; opacity: .9; }
.state-banner.is-safe { color: var(--success-text); background: var(--success-bg); border-color: var(--success-border); }
.state-banner.is-live, .state-banner.is-error { color: var(--danger-text); background: var(--danger-bg); border-color: var(--danger-border); }
.state-banner.is-warning { color: var(--warning-text); background: var(--warning-bg); border-color: var(--warning-border); }
.state-banner .button { justify-self: start; margin-top: .35rem; }
.numbered-steps { display: grid; gap: .5rem; padding-left: 1.3rem; margin: 0; color: var(--muted); line-height: 1.5; }
.numbered-steps strong { color: #e9f1ec; }
/* In-page section jump list, for pages built from stacked cards. */
.section-rail { display: flex; flex-wrap: wrap; gap: .4rem; margin-bottom: 1.25rem; }
.section-rail a { padding: .4rem .8rem; color: var(--muted); background: var(--surface); border: 1px solid var(--border); border-radius: 999px; font-size: .8rem; font-weight: 750; text-decoration: none; }
.section-rail a:hover { color: #e9f1ec; border-color: #3b5147; }
.section-rail a:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
/* Wide modal used as an editing surface rather than a confirmation. */
dialog.drawer-card { display: grid; grid-template-rows: auto minmax(0, 1fr) auto; width: min(46rem, calc(100% - 2rem)); max-height: calc(100vh - 3rem); padding: 0; color: inherit; background: var(--surface); border: 1px solid var(--border); border-radius: 1rem; box-shadow: 0 2rem 5rem rgba(0, 0, 0, .5); }
.drawer-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; padding: 1.1rem 1.1rem .25rem; }
.drawer-heading h2 { margin: 0; font-size: 1.35rem; }
.drawer-body { display: grid; gap: .9rem; padding: .85rem 1.1rem; overflow-y: auto; }
.drawer-footer { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 1rem; padding: .85rem 1.1rem; background: var(--surface-raised); border-top: 1px solid var(--border); }
@media (min-width: 42rem) {
.field-row { grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr)); }
}
@media (min-width: 48rem) { @media (min-width: 48rem) {
.sidebar { visibility: visible; transform: none; } .sidebar { visibility: visible; transform: none; }
.workspace { margin-left: 17.5rem; } .workspace { margin-left: 17.5rem; }
+1
View File
@@ -77,6 +77,7 @@ export const ConfigUpdateResponseSchema = z.strictObject({
advanced: z.record(z.string(), z.unknown()).optional(), advanced: z.record(z.string(), z.unknown()).optional(),
}); });
export type ToneKey = z.infer<typeof ToneKeySchema>;
export type RuntimeConfig = z.infer<typeof RuntimeConfigSchema>; export type RuntimeConfig = z.infer<typeof RuntimeConfigSchema>;
export type ConfigResponse = z.infer<typeof ConfigResponseSchema>; export type ConfigResponse = z.infer<typeof ConfigResponseSchema>;
export type ConfigUpdateRequest = z.infer<typeof ConfigUpdateRequestSchema>; export type ConfigUpdateRequest = z.infer<typeof ConfigUpdateRequestSchema>;
+184
View File
@@ -0,0 +1,184 @@
// @vitest-environment jsdom
import '@testing-library/jest-dom/vitest';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { cleanup, render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { HttpResponse, http } from 'msw';
import { setupServer } from 'msw/node';
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import { createMemoryRouter, RouterProvider } from 'react-router-dom';
import { AccountsPage } from '../../apps/dashboard/src/features/accounts/accounts-page.js';
const account = {
accountId: '@t2_bot:reddit.com',
redditUsername: 'example-account',
matrixUserId: '@t2_bot:reddit.com',
isRunning: true,
status: 'authenticated',
createdAt: 1,
updatedAt: 2,
lastImportedAt: 2,
};
const server = setupServer(
http.get('/api/login/status', () => HttpResponse.json({ ready: true, loggedIn: false, status: 'login-required' })),
http.get('/api/accounts', () => HttpResponse.json([])),
);
function renderAccounts() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
const router = createMemoryRouter([{ path: '/accounts', element: <AccountsPage /> }], {
initialEntries: ['/accounts'],
});
return render(
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>,
);
}
function sessionDump() {
return new File([JSON.stringify({
cookies: [{ name: 'reddit_session', value: 'secret', domain: '.reddit.com', path: '/' }],
matrix: {
homeserver: 'https://matrix.redditspace.com',
accessToken: 'matrix-secret',
userId: '@t2_bot:reddit.com',
capturedAt: 1_700_000_000_000,
},
})], 'redditbot-session.json', { type: 'application/json' });
}
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => {
cleanup();
server.resetHandlers();
vi.restoreAllMocks();
});
afterAll(() => server.close());
describe('accounts page', () => {
it('reports the session state and how to fix it, without asking for credentials', async () => {
renderAccounts();
expect(await screen.findByText('Login required')).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Getting a session dump' })).toBeInTheDocument();
expect(screen.getByText(/contains login credentials/i)).toBeInTheDocument();
// The dashboard never collects Reddit credentials itself.
expect(screen.queryByRole('textbox', { name: /password/i })).not.toBeInTheDocument();
expect(screen.queryByText(/noVNC/i)).not.toBeInTheDocument();
});
it('calls out paused accounts as needing a refreshed session', async () => {
server.use(http.get('/api/accounts', () => HttpResponse.json([{ ...account, isRunning: false, status: 'login-required' }])));
renderAccounts();
expect(await screen.findByText('One account is paused.')).toBeInTheDocument();
expect(screen.getByText(/Other accounts keep running normally/)).toBeInTheDocument();
expect(screen.getByText('Waiting for a refreshed session')).toBeInTheDocument();
});
it('says plainly when no accounts have been added', async () => {
renderAccounts();
expect(await screen.findByText('No accounts yet')).toBeInTheDocument();
expect(screen.queryByText(/accounts? is paused/)).not.toBeInTheDocument();
});
it('rejects a non-JSON session dump inline without sending a request', async () => {
let importRequests = 0;
server.use(
http.post('/api/login/session-import', () => {
importRequests += 1;
return HttpResponse.json({ ok: true });
}),
);
const user = userEvent.setup();
renderAccounts();
const broken = new File(['definitely not json'], 'broken.json', { type: 'application/json' });
await user.upload(await screen.findByLabelText('Reddit session export'), broken);
expect(await screen.findByText(/isn't valid JSON/)).toBeInTheDocument();
expect(importRequests).toBe(0);
});
it('imports an extension dump and reflects the verified account', async () => {
let statusRequests = 0;
let importRequests = 0;
server.use(
http.get('/api/login/status', () => {
statusRequests += 1;
return HttpResponse.json(statusRequests === 1
? { ready: true, loggedIn: false, status: 'login-required' }
: { ready: true, loggedIn: true, status: 'authenticated', username: 'example-account' });
}),
http.get('/api/accounts', () => HttpResponse.json(statusRequests === 1 ? [] : [account])),
http.post('/api/login/session-import', async ({ request }) => {
expect(request.credentials).toBe('same-origin');
const body = await request.json() as { cookies: unknown[]; matrix: { accessToken: string } };
expect(body.cookies).toHaveLength(1);
expect(body.matrix.accessToken).toBe('matrix-secret');
importRequests += 1;
return HttpResponse.json({ ok: true, status: 'authenticated', account });
}),
);
const user = userEvent.setup();
renderAccounts();
await user.upload(await screen.findByLabelText('Reddit session export'), sessionDump());
expect(await screen.findByText(/Session imported and verified/)).toBeInTheDocument();
expect(importRequests).toBe(1);
expect(await screen.findByText('Authenticated')).toBeInTheDocument();
const link = screen.getAllByRole('link', { name: 'u/example-account' })[0];
expect(link).toHaveAttribute('href', 'https://www.reddit.com/u/example-account');
});
it('rechecks sessions on request', async () => {
let statusRequests = 0;
server.use(
http.get('/api/login/status', () => {
statusRequests += 1;
return HttpResponse.json({ ready: true, loggedIn: false, status: 'login-required' });
}),
);
const user = userEvent.setup();
renderAccounts();
await user.click(await screen.findByRole('button', { name: 'Recheck accounts' }));
expect(statusRequests).toBeGreaterThanOrEqual(2);
});
it('shows a recoverable error when access cannot be checked', async () => {
let requests = 0;
server.use(
http.get('/api/login/status', () => {
requests += 1;
return requests === 1
? HttpResponse.json({ error: 'nope' }, { status: 500 })
: HttpResponse.json({ ready: true, loggedIn: false, status: 'login-required' });
}),
);
const user = userEvent.setup();
renderAccounts();
expect(await screen.findByRole('alert')).toHaveTextContent('Reddit access unavailable');
await user.click(screen.getByRole('button', { name: 'Retry access' }));
expect(await screen.findByRole('region', { name: 'Reddit accounts' })).toBeInTheDocument();
});
it('reports an account list failure separately from the session check', async () => {
server.use(http.get('/api/accounts', () => HttpResponse.json({ error: 'nope' }, { status: 500 })));
renderAccounts();
const region = await screen.findByRole('region', { name: 'Reddit accounts' });
expect(within(region).getByRole('alert')).toHaveTextContent('Could not load Reddit accounts');
// The page itself still renders, so the import button stays available.
expect(screen.getByRole('button', { name: 'Add or refresh account' })).toBeEnabled();
});
});
+1 -1
View File
@@ -491,7 +491,7 @@ describe('ConversationsPage', () => {
await user.click(screen.getByRole('button', { name: 'New conversation' })); await user.click(screen.getByRole('button', { name: 'New conversation' }));
expect(await screen.findByText(/No running accounts/)).toBeInTheDocument(); expect(await screen.findByText(/No running accounts/)).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Settings' })).toHaveAttribute('href', '/settings'); expect(screen.getByRole('link', { name: 'Accounts' })).toHaveAttribute('href', '/accounts');
expect(screen.queryByRole('button', { name: 'Start conversation' })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Start conversation' })).not.toBeInTheDocument();
}); });
@@ -13,7 +13,7 @@ import { SettingsRoute } from '../../apps/dashboard/src/features/settings/settin
const config = { const config = {
rateLimit: { minDelayBetweenMessagesMs: 300_000, maxMessagesPerHour: 8, jitterMs: 60_000 }, rateLimit: { minDelayBetweenMessagesMs: 300_000, maxMessagesPerHour: 8, jitterMs: 60_000 },
browser: { dryRun: true }, browser: { dryRun: true },
reddit: { prioritizeRecentPosts: false, recentWindowMinutes: 60, maxReadyCandidates: 25 }, reddit: { prioritizeRecentPosts: false, recentWindowMinutes: 60, maxReadyCandidates: 25, physiqueTargeting: { enabled: false, minScore: 1, maxScore: 10 } },
subreddits: [{ name: 'casualconversation', type: 'chat', weight: 2, tone: 'default' }], subreddits: [{ name: 'casualconversation', type: 'chat', weight: 2, tone: 'default' }],
tones: ['default', 'dominant', 'submissive', 'playful', 'direct', 'gentle', 'kink-friendly', 'teasing'], tones: ['default', 'dominant', 'submissive', 'playful', 'direct', 'gentle', 'kink-friendly', 'teasing'],
}; };
@@ -111,7 +111,7 @@ describe('push notification settings', () => {
topic: 'redditbot-7f3a9c21', topic: 'redditbot-7f3a9c21',
events: { imageReceived: true, replyReceived: true }, events: { imageReceived: true, replyReceived: true },
}); });
expect(await within(panel()).findByText('Notification settings saved.')).toBeInTheDocument(); expect(await within(panel()).findByText('Notifications saved.')).toBeInTheDocument();
}); });
it('refuses to save an enabled configuration with no topic', async () => { it('refuses to save an enabled configuration with no topic', async () => {
@@ -148,7 +148,7 @@ describe('push notification settings', () => {
renderSettings(); renderSettings();
await screen.findByRole('region', { name: 'Notifications' }); await screen.findByRole('region', { name: 'Notifications' });
await user.click(within(panel()).getByRole('button', { name: /Send test notification/ })); await user.click(within(panel()).getByRole('button', { name: /Send test/ }));
expect(await within(panel()).findByText(/ntfy responded 403 Forbidden/)).toBeInTheDocument(); expect(await within(panel()).findByText(/ntfy responded 403 Forbidden/)).toBeInTheDocument();
}); });
@@ -161,7 +161,7 @@ describe('push notification settings', () => {
await user.click(within(panel()).getByRole('checkbox', { name: /Sync failed/ })); await user.click(within(panel()).getByRole('checkbox', { name: /Sync failed/ }));
expect(within(panel()).getByRole('button', { name: /Send test notification/ })).toBeDisabled(); expect(within(panel()).getByRole('button', { name: /Send test/ })).toBeDisabled();
}); });
it('reports that an access token is configured without revealing it', async () => { it('reports that an access token is configured without revealing it', async () => {
+346
View File
@@ -0,0 +1,346 @@
import { describe, expect, it } from 'vitest';
import {
createPacingDraft,
createTargetingDraft,
targetingRisks,
validateTargetingDraft,
estimateThroughput,
pacingRisks,
safetyRisks,
validateAdvancedDraft,
validatePacingDraft,
type PacingDraft,
} from '../../apps/dashboard/src/features/settings/settings-model.js';
import {
computeShares,
formatShare,
normaliseSubredditName,
previewShare,
validateSubredditDraft,
createEmptySubredditDraft,
type SubredditConfig,
type SubredditDraft,
} from '../../apps/dashboard/src/features/subreddits/subreddit-model.js';
function pacing(overrides: Partial<PacingDraft> = {}): PacingDraft {
return {
minDelayMinutes: '5',
maxMessagesPerHour: '8',
jitterSeconds: '60',
prioritizeRecentPosts: false,
recentWindowMinutes: '60',
maxReadyCandidates: '25',
...overrides,
};
}
function subreddit(overrides: Partial<SubredditConfig> = {}): SubredditConfig {
return {
name: 'example',
enabled: true,
type: 'chat',
tone: 'default',
conversation: { mode: 'legacy', maxAutomatedTurns: 3, rapportTurnsBeforeMediaAsk: 3 },
...overrides,
} as SubredditConfig;
}
function draft(overrides: Partial<SubredditDraft> = {}): SubredditDraft {
return { ...createEmptySubredditDraft(), name: 'example', ...overrides };
}
describe('createPacingDraft', () => {
it('converts stored milliseconds into the units the operator types', () => {
const result = createPacingDraft({
rateLimit: { minDelayBetweenMessagesMs: 300_000, maxMessagesPerHour: 8, jitterMs: 90_000 },
browser: { dryRun: true },
reddit: { prioritizeRecentPosts: true, recentWindowMinutes: 45, maxReadyCandidates: 30, physiqueTargeting: { enabled: false, minScore: 1, maxScore: 10 } },
subreddits: [],
tones: [],
});
expect(result).toEqual({
minDelayMinutes: '5',
maxMessagesPerHour: '8',
jitterSeconds: '90',
prioritizeRecentPosts: true,
recentWindowMinutes: '45',
maxReadyCandidates: '30',
});
});
});
describe('validatePacingDraft', () => {
it('converts back into milliseconds and omits keys it does not own', () => {
const { payload, errors } = validatePacingDraft(pacing({ jitterSeconds: '30' }));
expect(errors).toEqual({});
expect(payload).toEqual({
rateLimit: { minDelayBetweenMessagesMs: 300_000, maxMessagesPerHour: 8, jitterMs: 30_000 },
reddit: { prioritizeRecentPosts: false, recentWindowMinutes: 60, maxReadyCandidates: 25, physiqueTargeting: { enabled: false, minScore: 1, maxScore: 10 } },
});
});
it('collects every problem in one pass rather than one at a time', () => {
const { payload, errors } = validatePacingDraft(pacing({
minDelayMinutes: '',
jitterSeconds: '-1',
maxReadyCandidates: '0',
}));
expect(payload).toBeUndefined();
expect(Object.keys(errors).sort()).toEqual(['jitterSeconds', 'maxReadyCandidates', 'minDelayMinutes']);
});
it('rejects a fractional hourly limit', () => {
expect(validatePacingDraft(pacing({ maxMessagesPerHour: '2.5' })).errors)
.toHaveProperty('maxMessagesPerHour');
});
});
describe('pacingRisks', () => {
it('warns only when the limit both rises and clears the safe ceiling', () => {
expect(pacingRisks(pacing({ maxMessagesPerHour: '21' }), pacing())).toHaveLength(1);
expect(pacingRisks(pacing({ maxMessagesPerHour: '20' }), pacing())).toEqual([]);
// Lowering an already-high limit is a de-escalation, not a risk.
expect(pacingRisks(pacing({ maxMessagesPerHour: '30' }), pacing({ maxMessagesPerHour: '40' }))).toEqual([]);
});
});
describe('safetyRisks', () => {
it('warns only on the transition from dry run into live sending', () => {
expect(safetyRisks({ dryRun: false }, { dryRun: true })).toHaveLength(1);
expect(safetyRisks({ dryRun: true }, { dryRun: false })).toEqual([]);
expect(safetyRisks({ dryRun: false }, { dryRun: false })).toEqual([]);
});
});
describe('estimateThroughput', () => {
it('reports the hourly cap when it binds before the delay', () => {
const estimate = estimateThroughput(pacing({ minDelayMinutes: '5', jitterSeconds: '0' }));
expect(estimate).toMatchObject({ effectivePerHour: 8, isDelayBound: false });
});
it('reports the delay when it binds before the hourly cap', () => {
const estimate = estimateThroughput(pacing({ minDelayMinutes: '30', jitterSeconds: '0' }));
expect(estimate).toMatchObject({ effectivePerHour: 2, isDelayBound: true });
});
it('counts average jitter as part of the spacing', () => {
// 10 min delay plus 120s average jitter (half of 240) is 12 min spacing.
const estimate = estimateThroughput(pacing({ minDelayMinutes: '10', jitterSeconds: '240' }));
expect(estimate?.spacingLabel).toBe('about one every 12 min');
expect(estimate?.effectivePerHour).toBe(5);
});
it('gives up on unusable numbers rather than reporting nonsense', () => {
expect(estimateThroughput(pacing({ minDelayMinutes: '' }))).toBeNull();
expect(estimateThroughput(pacing({ maxMessagesPerHour: '0' }))).toBeNull();
expect(estimateThroughput(pacing({ jitterSeconds: '-5' }))).toBeNull();
});
});
describe('validateTargetingDraft', () => {
const base = { physiqueTargetingEnabled: true, physiqueMinScore: '3', physiqueMaxScore: '8' };
it('sends only the physique range, so sibling reddit fields survive', () => {
expect(validateTargetingDraft(base).payload).toEqual({
reddit: { physiqueTargeting: { enabled: true, minScore: 3, maxScore: 8 } },
});
});
it('allows a single-point range', () => {
expect(validateTargetingDraft({ ...base, physiqueMinScore: '5', physiqueMaxScore: '5' }).errors).toEqual({});
});
it('rejects an inverted range against the minimum field', () => {
expect(validateTargetingDraft({ ...base, physiqueMinScore: '9', physiqueMaxScore: '2' }).errors)
.toHaveProperty('physiqueMinScore', 'Minimum physique score cannot exceed maximum physique score.');
});
it('rejects scores outside 1 to 10 and fractional scores', () => {
expect(validateTargetingDraft({ ...base, physiqueMinScore: '0' }).errors).toHaveProperty('physiqueMinScore');
expect(validateTargetingDraft({ ...base, physiqueMaxScore: '11' }).errors).toHaveProperty('physiqueMaxScore');
expect(validateTargetingDraft({ ...base, physiqueMaxScore: '7.5' }).errors).toHaveProperty('physiqueMaxScore');
});
it('requires both bounds', () => {
const { errors } = validateTargetingDraft({ ...base, physiqueMinScore: '', physiqueMaxScore: '' });
expect(Object.keys(errors).sort()).toEqual(['physiqueMaxScore', 'physiqueMinScore']);
});
it('still validates the range while screening is disabled', () => {
// The range persists while off, so a bad one must not be storable.
expect(validateTargetingDraft({ physiqueTargetingEnabled: false, physiqueMinScore: '9', physiqueMaxScore: '2' }).payload)
.toBeUndefined();
});
});
describe('targetingRisks', () => {
it('warns only when screening is being switched on', () => {
const off = { physiqueTargetingEnabled: false, physiqueMinScore: '3', physiqueMaxScore: '8' };
const on = { ...off, physiqueTargetingEnabled: true };
expect(targetingRisks(on, off)).toHaveLength(1);
expect(targetingRisks(off, on)).toEqual([]);
// Editing the range of an already-active screen is not a new risk.
expect(targetingRisks({ ...on, physiqueMinScore: '5' }, on)).toEqual([]);
});
});
describe('createTargetingDraft', () => {
it('reads the nested physique contract into flat form fields', () => {
const draft = createTargetingDraft({
rateLimit: { minDelayBetweenMessagesMs: 0, maxMessagesPerHour: 1, jitterMs: 0 },
browser: { dryRun: true },
reddit: {
prioritizeRecentPosts: false, recentWindowMinutes: 60, maxReadyCandidates: 25,
physiqueTargeting: { enabled: true, minScore: 4, maxScore: 9 },
},
subreddits: [],
tones: [],
});
expect(draft).toEqual({ physiqueTargetingEnabled: true, physiqueMinScore: '4', physiqueMaxScore: '9' });
});
});
describe('validateAdvancedDraft', () => {
it('accepts a JSON object', () => {
expect(validateAdvancedDraft({ advancedConfigJson: '{"a":1}' }).payload).toEqual({ advanced: { a: 1 } });
});
it('names the syntax problem rather than just failing', () => {
const { errors } = validateAdvancedDraft({ advancedConfigJson: '{"a":' });
expect(errors.advancedConfigJson).toMatch(/^Not valid JSON — /);
});
it('refuses arrays, bare values and an emptied editor', () => {
expect(validateAdvancedDraft({ advancedConfigJson: '[1]' }).errors.advancedConfigJson).toMatch(/JSON object/);
expect(validateAdvancedDraft({ advancedConfigJson: '"text"' }).errors.advancedConfigJson).toMatch(/JSON object/);
expect(validateAdvancedDraft({ advancedConfigJson: ' ' }).errors.advancedConfigJson).toMatch(/cannot be emptied/);
});
});
describe('computeShares', () => {
it('splits attention across enabled subreddits in proportion to weight', () => {
const shares = computeShares([
subreddit({ name: 'a', weight: 3 }),
subreddit({ name: 'b', weight: 1 }),
]);
expect(shares.get('a')?.share).toBeCloseTo(0.75);
expect(shares.get('b')?.share).toBeCloseTo(0.25);
});
it('treats a missing weight as the runtime default of 1', () => {
const shares = computeShares([subreddit({ name: 'a' }), subreddit({ name: 'b' })]);
expect(shares.get('a')).toEqual({ effectiveWeight: 1, share: 0.5 });
});
it('gives disabled subreddits no share and excludes them from the total', () => {
const shares = computeShares([
subreddit({ name: 'a', weight: 1 }),
subreddit({ name: 'off', weight: 99, enabled: false }),
]);
expect(shares.get('off')?.share).toBe(0);
expect(shares.get('a')?.share).toBe(1);
});
it('reports zero rather than dividing by zero when everything is disabled', () => {
const shares = computeShares([subreddit({ name: 'a', enabled: false })]);
expect(shares.get('a')?.share).toBe(0);
});
});
describe('formatShare', () => {
it('keeps tiny but non-zero shares visible', () => {
expect(formatShare(0.004)).toBe('<1%');
expect(formatShare(0)).toBe('—');
expect(formatShare(0.256)).toBe('26%');
});
});
describe('previewShare', () => {
const existing = [subreddit({ name: 'a', weight: 1 }), subreddit({ name: 'b', weight: 1 })];
it('excludes the edited row from the baseline so its old weight is not counted twice', () => {
// Editing 'a' up to weight 3 leaves 'b' at 1, so 'a' takes three quarters.
expect(previewShare(draft({ name: 'a', weight: '3' }), existing, 'a')).toBeCloseTo(0.75);
});
it('adds a brand new row on top of the existing total', () => {
expect(previewShare(draft({ name: 'c', weight: '2' }), existing, null)).toBeCloseTo(0.5);
});
it('is zero for a disabled or unusable weight', () => {
expect(previewShare(draft({ name: 'a', weight: '1', enabled: false }), existing, 'a')).toBe(0);
expect(previewShare(draft({ name: 'a', weight: '-2' }), existing, 'a')).toBe(0);
});
});
describe('normaliseSubredditName', () => {
it('strips the prefix operators paste in', () => {
expect(normaliseSubredditName(' r/Example ')).toBe('Example');
expect(normaliseSubredditName('/r/example')).toBe('example');
expect(normaliseSubredditName('example')).toBe('example');
});
});
describe('validateSubredditDraft', () => {
it('keys errors by field name, with no array index to go stale', () => {
const { errors } = validateSubredditDraft(draft({ name: '', weight: '0' }), []);
expect(Object.keys(errors).sort()).toEqual(['name', 'weight']);
});
it('rejects a duplicate name regardless of case', () => {
expect(validateSubredditDraft(draft({ name: 'Example' }), ['example']).errors)
.toHaveProperty('name', 'r/Example is already configured.');
});
it('allows a name that only collides with the row being edited', () => {
// The caller excludes the edited row from `takenNames`, so saving a
// subreddit without renaming it is not a conflict.
expect(validateSubredditDraft(draft({ name: 'example' }), []).errors).toEqual({});
});
it('rejects names Reddit itself would not accept', () => {
for (const name of ['a', 'has spaces', '_leading', 'way-too-long-for-reddit-to-accept']) {
expect(validateSubredditDraft(draft({ name }), []).errors, name).toHaveProperty('name');
}
});
it('omits an empty weight and scripted message instead of sending blanks', () => {
const { config } = validateSubredditDraft(draft({ weight: '', scripted: ' ' }), []);
expect(config).not.toHaveProperty('weight');
expect(config).not.toHaveProperty('scripted');
});
it('normalises turn settings away for modes that do not use them', () => {
const { config } = validateSubredditDraft(
draft({ conversationMode: 'legacy', maxAutomatedTurns: '19', rapportTurnsBeforeMediaAsk: '17' }),
[],
);
expect(config?.conversation).toEqual({ mode: 'legacy', maxAutomatedTurns: 3, rapportTurnsBeforeMediaAsk: 3 });
});
it('requires rapport turns below the total budget for the hybrid mode', () => {
const { errors } = validateSubredditDraft(
draft({ conversationMode: 'r4r-media', maxAutomatedTurns: '4', rapportTurnsBeforeMediaAsk: '4' }),
[],
);
expect(errors).toHaveProperty('rapportTurnsBeforeMediaAsk');
});
it('rejects a turn budget above the configured maximum', () => {
const { errors } = validateSubredditDraft(
draft({ conversationMode: 'r4r', maxAutomatedTurns: '21' }),
[],
);
expect(errors).toHaveProperty('maxAutomatedTurns');
});
it('defaults a new subreddit to the mode that cannot send unattended', () => {
expect(createEmptySubredditDraft().conversationMode).toBe('manual');
});
});
File diff suppressed because it is too large. Load diff
+600
View File
@@ -0,0 +1,600 @@
// @vitest-environment jsdom
import '@testing-library/jest-dom/vitest';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { HttpResponse, http } from 'msw';
import { setupServer } from 'msw/node';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { createMemoryRouter, RouterProvider } from 'react-router-dom';
import { SubredditsPage } from '../../apps/dashboard/src/features/subreddits/subreddits-page.js';
const baseSubreddits = [
{ name: 'casualconversation', type: 'chat', weight: 1, tone: 'default', enabled: true },
{ name: 'r4r', type: 'chat', weight: 3, tone: 'playful', enabled: true, conversation: { mode: 'r4r', maxAutomatedTurns: 6, rapportTurnsBeforeMediaAsk: 3 } },
{ name: 'sleepy', type: 'image', weight: 4, tone: 'gentle', enabled: false },
];
const baseConfig = {
rateLimit: { minDelayBetweenMessagesMs: 300_000, maxMessagesPerHour: 8, jitterMs: 60_000 },
browser: { dryRun: true },
reddit: { prioritizeRecentPosts: false, recentWindowMinutes: 60, maxReadyCandidates: 25, physiqueTargeting: { enabled: false, minScore: 1, maxScore: 10 } },
subreddits: baseSubreddits,
tones: ['default', 'dominant', 'submissive', 'playful', 'direct', 'gentle', 'kink-friendly', 'teasing'],
};
const performance = [
{ subreddit: 'casualconversation', totalSent: 40, totalResponses: 10, responseRate: 0.25, totalImages: 2, imageRate: 0.05 },
{ subreddit: 'r4r', totalSent: 5, totalResponses: 4, responseRate: 0.8, totalImages: 0, imageRate: 0 },
];
let config: Record<string, any> = structuredClone(baseConfig);
/** Subreddit arrays received by PUT /api/config, in order. */
let saved: any[][] = [];
const server = setupServer(
http.get('/api/config', () => HttpResponse.json(config)),
http.get('/api/analytics/by-subreddit', () => HttpResponse.json(performance)),
http.put('/api/config', async ({ request }) => {
const body = await request.json() as Record<string, any>;
saved.push(body.subreddits);
config = { ...config, subreddits: body.subreddits };
return HttpResponse.json({
success: true,
config: { rateLimit: config.rateLimit, browser: config.browser, reddit: config.reddit },
subreddits: config.subreddits,
});
}),
);
function renderSubreddits() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
const router = createMemoryRouter([{ path: '/subreddits', element: <SubredditsPage /> }], {
initialEntries: ['/subreddits'],
});
return render(
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>,
);
}
async function findRows() {
const list = await screen.findByRole('list', { name: 'Configured subreddits' });
return within(list).getAllByRole('listitem');
}
async function row(name: string) {
const rows = await findRows();
const match = rows.find((item) => rowName(item) === `r/${name}`);
if (!match) throw new Error(`No row for r/${name}`);
return match;
}
/** The row's own name element, not its concatenated text content. */
function rowName(item: HTMLElement): string {
return within(item).getByRole('heading', { level: 3 }).textContent ?? '';
}
async function rowOrder(): Promise<string[]> {
return (await findRows()).map(rowName);
}
/** Reads one metric from a row's definition list by its label. */
function metric(item: HTMLElement, label: string): string {
const term = within(item).getAllByRole('term').find((node) => node.textContent === label);
if (!term) throw new Error(`No ${label} metric in row`);
return term.parentElement?.querySelector('dd')?.textContent ?? '';
}
async function openEditor(name: string) {
await userEvent.setup().click(within(await row(name)).getByRole('button', { name: 'Edit' }));
return screen.findByRole('dialog', { name: `r/${name}` });
}
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
beforeEach(() => {
config = structuredClone(baseConfig);
saved = [];
});
afterEach(() => {
cleanup();
server.resetHandlers();
vi.restoreAllMocks();
});
afterAll(() => server.close());
describe('subreddits page', () => {
it('lists each subreddit with its share of polling, mode, and performance', async () => {
renderSubreddits();
const rows = await findRows();
expect(rows).toHaveLength(3);
// Enabled weights are 1 and 3, so the shares are 25% and 75%. The disabled
// row takes none of it despite having the largest weight.
const casual = await row('casualconversation');
expect(metric(casual, 'Share')).toContain('25%');
expect(metric(casual, 'Sent')).toBe('40');
expect(metric(casual, 'Replies')).toContain('25% rate');
expect(metric(await row('r4r'), 'Share')).toContain('75%');
const sleepy = await row('sleepy');
expect(metric(sleepy, 'Share')).toBe('Off');
expect(metric(sleepy, 'Sent')).toBe('—');
expect(within(sleepy).getByText('Paused')).toBeInTheDocument();
});
it('summarises how many are polled and how many reply autonomously', async () => {
renderSubreddits();
await findRows();
expect(screen.getByText('of 3 being polled')).toBeInTheDocument();
expect(screen.getByText('replying autonomously')).toBeInTheDocument();
expect(await row('r4r')).toHaveTextContent('Autonomous');
});
it('orders by share by default and can be re-sorted', async () => {
renderSubreddits();
const user = userEvent.setup();
// Share order leads with the heaviest; name order is alphabetical. The
// fixture makes the two orders differ so this proves the sort runs.
expect(await rowOrder()).toEqual(['r/r4r', 'r/casualconversation', 'r/sleepy']);
await user.selectOptions(screen.getByRole('combobox', { name: 'Sort subreddits' }), 'Name');
expect(await rowOrder()).toEqual(['r/casualconversation', 'r/r4r', 'r/sleepy']);
await user.selectOptions(screen.getByRole('combobox', { name: 'Sort subreddits' }), 'Messages sent');
expect(await rowOrder()).toEqual(['r/casualconversation', 'r/r4r', 'r/sleepy']);
});
it('filters the list by name', async () => {
renderSubreddits();
const user = userEvent.setup();
await findRows();
await user.type(screen.getByRole('searchbox', { name: 'Filter subreddits by name' }), 'sleep');
expect(await findRows()).toHaveLength(1);
expect(screen.getByText('r/sleepy')).toBeInTheDocument();
await user.clear(screen.getByRole('searchbox', { name: 'Filter subreddits by name' }));
await user.type(screen.getByRole('searchbox', { name: 'Filter subreddits by name' }), 'nothing');
expect(await screen.findByText(/No subreddits match/)).toBeInTheDocument();
});
it('commits the polling toggle immediately, without a save step', async () => {
renderSubreddits();
const user = userEvent.setup();
const sleepy = await row('sleepy');
await user.click(within(sleepy).getByRole('checkbox', { name: 'Poll r/sleepy' }));
await waitFor(() => expect(saved).toHaveLength(1));
expect(saved[0].find((item: any) => item.name === 'sleepy').enabled).toBe(true);
// Enabling weight 4 alongside 3 and 1 rebalances every share.
expect(metric(await row('sleepy'), 'Share')).toContain('50%');
});
describe('editing one subreddit', () => {
it('previews the share the chosen weight will actually produce', async () => {
renderSubreddits();
const dialog = await openEditor('casualconversation');
const user = userEvent.setup();
expect(within(dialog).getByText('25%')).toBeInTheDocument();
const weight = within(dialog).getByRole('spinbutton', { name: 'Weight' });
await user.clear(weight);
await user.type(weight, '3');
expect(within(dialog).getByText('50%')).toBeInTheDocument();
});
it('saves only that subreddit and leaves the others untouched', async () => {
renderSubreddits();
const dialog = await openEditor('casualconversation');
const user = userEvent.setup();
await user.selectOptions(within(dialog).getByRole('combobox', { name: 'Tone' }), 'direct');
await user.click(within(dialog).getByRole('button', { name: 'Save subreddit' }));
await waitFor(() => expect(saved).toHaveLength(1));
expect(saved[0]).toHaveLength(3);
expect(saved[0][0]).toMatchObject({ name: 'casualconversation', tone: 'direct' });
expect(saved[0][1]).toMatchObject({ name: 'r4r', tone: 'playful' });
expect(saved[0][2]).toMatchObject({ name: 'sleepy', tone: 'gentle' });
});
it('rejects a name that is already configured', async () => {
renderSubreddits();
const dialog = await openEditor('sleepy');
const user = userEvent.setup();
const name = within(dialog).getByRole('textbox', { name: 'Subreddit name' });
await user.clear(name);
await user.type(name, 'R4R');
await user.click(within(dialog).getByRole('button', { name: 'Save subreddit' }));
expect(within(dialog).getByText('r/R4R is already configured.')).toBeInTheDocument();
expect(saved).toHaveLength(0);
});
it('rejects a name Reddit would not accept', async () => {
renderSubreddits();
const dialog = await openEditor('sleepy');
const user = userEvent.setup();
const name = within(dialog).getByRole('textbox', { name: 'Subreddit name' });
await user.clear(name);
await user.type(name, 'has spaces');
await user.click(within(dialog).getByRole('button', { name: 'Save subreddit' }));
expect(within(dialog).getByText(/2–21 letters, numbers or underscores/)).toBeInTheDocument();
expect(saved).toHaveLength(0);
});
it('accepts a name pasted with its r/ prefix', async () => {
renderSubreddits();
const dialog = await openEditor('sleepy');
const user = userEvent.setup();
const name = within(dialog).getByRole('textbox', { name: 'Subreddit name' });
await user.clear(name);
await user.type(name, 'r/quietplace');
await user.click(within(dialog).getByRole('button', { name: 'Save subreddit' }));
await waitFor(() => expect(saved).toHaveLength(1));
expect(saved[0].map((item: any) => item.name)).toContain('quietplace');
});
it('rejects a weight of zero', async () => {
renderSubreddits();
const dialog = await openEditor('sleepy');
const user = userEvent.setup();
const weight = within(dialog).getByRole('spinbutton', { name: 'Weight' });
await user.clear(weight);
await user.type(weight, '0');
await user.click(within(dialog).getByRole('button', { name: 'Save subreddit' }));
expect(within(dialog).getByText('Weight must be a number greater than 0.')).toBeInTheDocument();
expect(saved).toHaveLength(0);
});
});
describe('conversation policy', () => {
it('reveals the turn budget only for the autonomous modes', async () => {
renderSubreddits();
const dialog = await openEditor('casualconversation');
const user = userEvent.setup();
expect(within(dialog).queryByRole('spinbutton', { name: 'Maximum automated turns' })).not.toBeInTheDocument();
await user.click(within(dialog).getByRole('radio', { name: /Autonomous conversation/ }));
expect(within(dialog).getByRole('spinbutton', { name: 'Maximum automated turns' })).toBeInTheDocument();
expect(within(dialog).queryByRole('spinbutton', { name: 'Rapport turns before the ask' })).not.toBeInTheDocument();
await user.click(within(dialog).getByRole('radio', { name: /then one media ask/ }));
expect(within(dialog).getByRole('spinbutton', { name: 'Rapport turns before the ask' })).toBeInTheDocument();
});
it('requires explicit confirmation before a subreddit replies autonomously', async () => {
renderSubreddits();
const dialog = await openEditor('casualconversation');
const user = userEvent.setup();
await user.click(within(dialog).getByRole('radio', { name: /Autonomous conversation/ }));
await user.click(within(dialog).getByRole('button', { name: 'Save subreddit' }));
expect(await screen.findByText(/r\/casualconversation will reply autonomously/)).toBeInTheDocument();
expect(saved).toHaveLength(0);
await user.click(screen.getByRole('button', { name: 'Apply risky settings' }));
await waitFor(() => expect(saved).toHaveLength(1));
expect(saved[0][0].conversation.mode).toBe('r4r');
});
it('rejects rapport turns that are not lower than the total turn budget', async () => {
renderSubreddits();
const dialog = await openEditor('casualconversation');
const user = userEvent.setup();
await user.click(within(dialog).getByRole('radio', { name: /then one media ask/ }));
const total = within(dialog).getByRole('spinbutton', { name: 'Maximum automated turns' });
await user.clear(total);
await user.type(total, '4');
const rapport = within(dialog).getByRole('spinbutton', { name: 'Rapport turns before the ask' });
await user.clear(rapport);
await user.type(rapport, '4');
await user.click(within(dialog).getByRole('button', { name: 'Save subreddit' }));
expect(within(dialog).getByText('Rapport turns must be lower than the maximum automated turns.')).toBeInTheDocument();
expect(saved).toHaveLength(0);
});
it('does not re-confirm a subreddit that was already autonomous', async () => {
renderSubreddits();
const dialog = await openEditor('r4r');
const user = userEvent.setup();
const total = within(dialog).getByRole('spinbutton', { name: 'Maximum automated turns' });
await user.clear(total);
await user.type(total, '8');
await user.click(within(dialog).getByRole('button', { name: 'Save subreddit' }));
await waitFor(() => expect(saved).toHaveLength(1));
expect(screen.queryByText('Confirm risky settings')).not.toBeInTheDocument();
expect(saved[0][1].conversation.maxAutomatedTurns).toBe(8);
});
it('never prompts about a subreddit other than the one being edited', async () => {
renderSubreddits();
// r/r4r is autonomous. Editing an unrelated row must not surface its
// policy for re-approval — the old array form confirmed by row index.
const dialog = await openEditor('sleepy');
const user = userEvent.setup();
await user.selectOptions(within(dialog).getByRole('combobox', { name: 'Tone' }), 'direct');
await user.click(within(dialog).getByRole('button', { name: 'Save subreddit' }));
await waitFor(() => expect(saved).toHaveLength(1));
expect(screen.queryByText('Confirm risky settings')).not.toBeInTheDocument();
});
});
describe('adding and removing', () => {
it('adds a subreddit through a dialog, defaulting to the safest policy', async () => {
renderSubreddits();
const user = userEvent.setup();
await findRows();
await user.click(screen.getByRole('button', { name: 'Add subreddit' }));
const dialog = await screen.findByRole('dialog', { name: 'Add a subreddit' });
expect(within(dialog).getByRole('radio', { name: /Manual only/ })).toBeChecked();
await user.type(within(dialog).getByRole('textbox', { name: 'Subreddit name' }), 'newplace');
await user.click(within(dialog).getByRole('button', { name: 'Add subreddit' }));
await waitFor(() => expect(saved).toHaveLength(1));
expect(saved[0]).toHaveLength(4);
expect(saved[0][3]).toMatchObject({ name: 'newplace', conversation: { mode: 'manual' } });
expect(await row('newplace')).toBeInTheDocument();
});
it('will not add a subreddit with no name', async () => {
renderSubreddits();
const user = userEvent.setup();
await findRows();
await user.click(screen.getByRole('button', { name: 'Add subreddit' }));
const dialog = await screen.findByRole('dialog', { name: 'Add a subreddit' });
await user.click(within(dialog).getByRole('button', { name: 'Add subreddit' }));
expect(within(dialog).getByText('Subreddit name is required.')).toBeInTheDocument();
expect(saved).toHaveLength(0);
});
it('confirms a removal before dropping the subreddit', async () => {
renderSubreddits();
const user = userEvent.setup();
await user.click(within(await row('sleepy')).getByRole('button', { name: 'Remove' }));
expect(await screen.findByRole('heading', { name: 'Stop using r/sleepy?' })).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Keep it' }));
expect(saved).toHaveLength(0);
await user.click(within(await row('sleepy')).getByRole('button', { name: 'Remove' }));
await user.click(await screen.findByRole('button', { name: 'Remove subreddit' }));
await waitFor(() => expect(saved).toHaveLength(1));
expect(saved[0].map((item: any) => item.name)).toEqual(['casualconversation', 'r4r']);
});
it('refuses to remove the last remaining subreddit', async () => {
config = { ...structuredClone(baseConfig), subreddits: [baseSubreddits[0]] };
renderSubreddits();
const only = await row('casualconversation');
expect(within(only).getByRole('button', { name: 'Remove' })).toBeDisabled();
});
});
describe('bulk actions', () => {
async function select(...names: string[]) {
const user = userEvent.setup();
for (const name of names) {
await user.click(within(await row(name)).getByRole('checkbox', { name: `Select r/${name}` }));
}
return screen.getByRole('region', { name: 'Bulk actions' });
}
it('stays hidden until something is selected', async () => {
renderSubreddits();
await findRows();
expect(screen.queryByRole('region', { name: 'Bulk actions' })).not.toBeInTheDocument();
const bar = await select('sleepy');
expect(within(bar).getByText('1 selected')).toBeInTheDocument();
expect(within(bar).getByText('0 polling, 1 paused')).toBeInTheDocument();
});
it('pauses every selected subreddit in one write', async () => {
renderSubreddits();
const bar = await select('casualconversation', 'r4r');
await userEvent.setup().click(within(bar).getByRole('button', { name: 'Pause polling' }));
await waitFor(() => expect(saved).toHaveLength(1));
expect(saved[0].map((item: any) => [item.name, item.enabled])).toEqual([
['casualconversation', false],
['r4r', false],
['sleepy', false],
]);
});
it('offers only the action that would change something', async () => {
renderSubreddits();
const bar = await select('casualconversation');
// Already polling, so there is nothing for "Start polling" to do.
expect(within(bar).getByRole('button', { name: 'Start polling' })).toBeDisabled();
expect(within(bar).getByRole('button', { name: 'Pause polling' })).toBeEnabled();
});
it('sets one tone across the selection', async () => {
renderSubreddits();
const bar = await select('casualconversation', 'sleepy');
await userEvent.setup().selectOptions(
within(bar).getByRole('combobox', { name: 'Set tone for the selected subreddits' }),
'direct',
);
await waitFor(() => expect(saved).toHaveLength(1));
expect(saved[0].map((item: any) => item.tone)).toEqual(['direct', 'playful', 'direct']);
});
it('names every subreddit turning autonomous before applying it', async () => {
renderSubreddits();
const user = userEvent.setup();
const bar = await select('casualconversation', 'r4r', 'sleepy');
await user.selectOptions(
within(bar).getByRole('combobox', { name: 'Set conversation policy for the selected subreddits' }),
'Autonomous conversation',
);
// r/r4r is already autonomous, so only the other two are called out.
const dialog = await screen.findByRole('dialog', { name: 'Confirm risky settings' });
expect(within(dialog).getByText(/2 subreddits will reply autonomously/)).toBeInTheDocument();
expect(within(dialog).getByText(/r\/casualconversation, r\/sleepy/)).toBeInTheDocument();
expect(saved).toHaveLength(0);
await user.click(within(dialog).getByRole('button', { name: 'Apply risky settings' }));
await waitFor(() => expect(saved).toHaveLength(1));
expect(saved[0].every((item: any) => item.conversation.mode === 'r4r')).toBe(true);
});
it('applies a non-autonomous policy without a confirmation step', async () => {
renderSubreddits();
const bar = await select('r4r');
await userEvent.setup().selectOptions(
within(bar).getByRole('combobox', { name: 'Set conversation policy for the selected subreddits' }),
'Manual only',
);
await waitFor(() => expect(saved).toHaveLength(1));
expect(screen.queryByText('Confirm risky settings')).not.toBeInTheDocument();
expect(saved[0][1].conversation.mode).toBe('manual');
});
it('keeps the hybrid mode valid when bulk-applied to a short turn budget', async () => {
// r/casualconversation defaults to 3 total / 3 rapport, which the hybrid
// mode rejects. A bulk change has no form to fix it, so it must normalise.
renderSubreddits();
const user = userEvent.setup();
const bar = await select('casualconversation');
await user.selectOptions(
within(bar).getByRole('combobox', { name: 'Set conversation policy for the selected subreddits' }),
'Autonomous, then one media ask',
);
await user.click(await screen.findByRole('button', { name: 'Apply risky settings' }));
await waitFor(() => expect(saved).toHaveLength(1));
const { maxAutomatedTurns, rapportTurnsBeforeMediaAsk } = saved[0][0].conversation;
expect(rapportTurnsBeforeMediaAsk).toBeLessThan(maxAutomatedTurns);
});
it('confirms a bulk removal and lists what will go', async () => {
renderSubreddits();
const user = userEvent.setup();
const bar = await select('r4r', 'sleepy');
await user.click(within(bar).getByRole('button', { name: 'Remove' }));
const dialog = await screen.findByRole('dialog', { name: 'Stop using 2 subreddits?' });
expect(within(dialog).getByText('r/r4r')).toBeInTheDocument();
expect(within(dialog).getByText('r/sleepy')).toBeInTheDocument();
await user.click(within(dialog).getByRole('button', { name: 'Remove subreddits' }));
await waitFor(() => expect(saved).toHaveLength(1));
expect(saved[0].map((item: any) => item.name)).toEqual(['casualconversation']);
expect(screen.queryByRole('region', { name: 'Bulk actions' })).not.toBeInTheDocument();
});
it('refuses to remove every subreddit at once', async () => {
renderSubreddits();
const bar = await select('casualconversation', 'r4r', 'sleepy');
const remove = within(bar).getByRole('button', { name: 'Remove' });
expect(remove).toBeDisabled();
expect(remove).toHaveAttribute('title', 'At least one subreddit must stay configured.');
});
it('selects only the rows the filter is showing', async () => {
renderSubreddits();
const user = userEvent.setup();
await findRows();
await user.type(screen.getByRole('searchbox', { name: 'Filter subreddits by name' }), 'r4r');
await user.click(screen.getByRole('checkbox', { name: 'Select all 1 matching subreddits' }));
await user.clear(screen.getByRole('searchbox', { name: 'Filter subreddits by name' }));
const bar = await screen.findByRole('region', { name: 'Bulk actions' });
expect(within(bar).getByText('1 selected')).toBeInTheDocument();
});
it('selects and clears the whole list', async () => {
renderSubreddits();
const user = userEvent.setup();
await findRows();
await user.click(screen.getByRole('checkbox', { name: 'Select all subreddits' }));
const bar = await screen.findByRole('region', { name: 'Bulk actions' });
expect(within(bar).getByText('3 selected')).toBeInTheDocument();
await user.click(within(bar).getByRole('button', { name: 'Clear' }));
expect(screen.queryByRole('region', { name: 'Bulk actions' })).not.toBeInTheDocument();
});
it('drops a removed subreddit from the selection', async () => {
renderSubreddits();
const user = userEvent.setup();
await select('casualconversation', 'sleepy');
// Remove one of the selected rows through its own row control.
await user.click(within(await row('sleepy')).getByRole('button', { name: 'Remove' }));
await user.click(await screen.findByRole('button', { name: 'Remove subreddit' }));
await waitFor(() => expect(saved).toHaveLength(1));
const bar = await screen.findByRole('region', { name: 'Bulk actions' });
expect(within(bar).getByText('1 selected')).toBeInTheDocument();
});
});
it('still lists subreddits when performance data is unavailable', async () => {
server.use(http.get('/api/analytics/by-subreddit', () => HttpResponse.json({ error: 'nope' }, { status: 500 })));
renderSubreddits();
expect(await findRows()).toHaveLength(3);
expect(await screen.findByText(/performance could not be loaded/)).toBeInTheDocument();
});
it('shows a recoverable error when the configuration cannot be loaded', async () => {
let requests = 0;
server.use(
http.get('/api/config', () => {
requests += 1;
return requests === 1 ? HttpResponse.json({ error: 'nope' }, { status: 500 }) : HttpResponse.json(config);
}),
);
renderSubreddits();
expect(await screen.findByRole('alert')).toHaveTextContent('Subreddits unavailable');
await userEvent.setup().click(screen.getByRole('button', { name: 'Retry' }));
expect(await findRows()).toHaveLength(3);
});
});