280 lines
10 KiB
TypeScript
280 lines
10 KiB
TypeScript
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}
|
||
</>
|
||
);
|
||
}
|