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('share'); const [editorTarget, setEditorTarget] = useState<{ subreddit: SubredditConfig | null } | null>(null); const [deleteTarget, setDeleteTarget] = useState(null); const [pendingAction, setPendingAction] = useState(null); const [selectedNames, setSelectedNames] = useState([]); const [pendingBulk, setPendingBulk] = useState(null); const [isBulkRemoveOpen, setIsBulkRemoveOpen] = useState(false); const subreddits = settingsQuery.data?.subreddits ?? []; const shares = useMemo(() => computeShares(subreddits), [subreddits]); const statsByName = useMemo(() => { const map = new Map(); 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 (
Targeting

Subreddits

The communities the bot polls, how much attention each one gets, and how it is allowed to talk there.

{settingsQuery.data ? (
) : null}
{settingsQuery.isPending ? (
Loading subreddits…
) : null} {settingsQuery.isError ? (
Subreddits unavailable {errorMessage(settingsQuery.error)}
) : null} {settingsQuery.data ? ( <>
{enabledCount}of {subreddits.length} being polled
{autonomousCount}replying autonomously
{saveMutation.isError ? (

{errorMessage(saveMutation.error)}

) : null}
{visible.length > 0 ? ( ) : null}
{visible.length === 0 ? (
No subreddits match “{search}” Clear the filter to see all {subreddits.length}.
) : (
    {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 (
  • r/{subreddit.name}

    {mode.badge} {subreddit.type === 'image' ? 'Image' : 'Chat'} {subreddit.tone} {subreddit.scripted ? Scripted : null}
    Share
    {subreddit.enabled ? formatShare(share?.share ?? 0) : 'Off'} {subreddit.enabled ? weight {share?.effectiveWeight ?? 1} : null}
    Sent
    {stats ? stats.totalSent : '—'}
    Replies
    {stats ? stats.totalResponses : '—'} {stats && stats.totalSent > 0 ? {formatRate(stats.responseRate)} rate : null}
    {/* A single reversible field, so it commits on change rather than pretending to need a save step. */}
  • ); })}
)} {performanceQuery.isError ? (

Per-subreddit performance could not be loaded, so Sent and Replies are unavailable.

) : null} {selected.length > 0 ? ( 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 ? ( setEditorTarget(null)} onSave={saveFromEditor} saveError={saveMutation.isError ? saveMutation.error : null} subreddit={editorTarget.subreddit} tones={settingsQuery.data.tones} /> ) : null} {pendingBulk ? ( setPendingBulk(null)} onConfirm={() => commit(pendingBulk.subreddits, { kind: 'bulk' }, () => setPendingBulk(null))} reasons={pendingBulk.reasons} /> ) : null} {isBulkRemoveOpen ? ( setIsBulkRemoveOpen(false)} > Remove subreddits

{selected.length === 1 ? 'Stop using 1 subreddit?' : `Stop using ${selected.length} subreddits?`}

They will no longer be polled and their configuration is discarded. Conversations already started there are not affected, and their history stays in reporting.

    {selected.map((subreddit) =>
  • r/{subreddit.name}
  • )}
) : null} {deleteTarget ? ( setDeleteTarget(null)} > Remove subreddit

Stop using r/{deleteTarget.name}?

It will no longer be polled and its configuration is discarded. Conversations already started there are not affected, and its history stays in reporting.

) : null}
); }