Files
RedditBot/apps/dashboard/src/features/subreddits/subreddit-editor.tsx
T

280 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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}
/>
<SelectField
hint="Comment mode scans reactions on each new image post and contacts only high-confidence matches."
label="Recipient"
name="subreddit-targeting-mode"
onChange={(targetingMode) => update({ targetingMode })}
options={draft.type === 'chat'
? [{ value: 'post-author', label: 'Post author' }]
: [
{ value: 'post-author', label: 'Post author' },
{ value: 'comment-author', label: 'Qualifying commenter' },
]}
value={draft.type === 'chat' ? 'post-author' : draft.targetingMode}
/>
</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}
</>
);
}