import { useEffect, useRef, useState } from 'preact/hooks';
import { isUserInputBatch, UserInputAnswer, UserInputQuestion, UserInputRequestEvent, UserInputResponseBody } from '../api';
import { useT } from '../settings';
import { InteractionDock } from './InteractionDock';
interface UserInputCardProps {
req: UserInputRequestEvent;
onDone: () => void;
submitAnswer: (body: UserInputAnswer) => Promise<{ accepted: boolean }>;
}
const OTHER_SENTINEL = '__other__';
interface AnswerState {
singleSelected: string | null;
multiSelected: Set<string>;
otherChecked: boolean;
freeText: string;
}
const emptyAnswer = (): AnswerState => ({
singleSelected: null,
multiSelected: new Set(),
otherChecked: false,
freeText: '',
});
function buildAnswer(q: UserInputQuestion, s: AnswerState): UserInputResponseBody {
if (q.mode === 'text') {
return { declined: false, selected: [], text: s.freeText || null };
}
if (q.mode === 'single') {
const chosen =
s.singleSelected === OTHER_SENTINEL
? [s.freeText.trim()]
: s.singleSelected
? [s.singleSelected]
: [];
return { declined: false, selected: chosen, text: null };
}
const chosen = Array.from(s.multiSelected);
if (s.otherChecked && s.freeText.trim()) chosen.push(s.freeText.trim());
return { declined: false, selected: chosen, text: null };
}
function answerReady(q: UserInputQuestion, s: AnswerState): boolean {
if (q.mode === 'text') return s.freeText.trim() !== '';
if (q.mode === 'single') {
if (s.singleSelected === null) return false;
if (s.singleSelected === OTHER_SENTINEL) return s.freeText.trim() !== '';
return true;
}
return s.multiSelected.size > 0 || (s.otherChecked && s.freeText.trim() !== '');
}
function hydrate(q: UserInputQuestion, a: UserInputResponseBody): AnswerState {
const s = emptyAnswer();
if (a.declined) return s;
if (q.mode === 'text') {
s.freeText = a.text ?? '';
return s;
}
if (q.mode === 'single') {
const sel = a.selected[0];
if (sel === undefined) return s;
if (q.options.some((o) => o.label === sel)) s.singleSelected = sel;
else {
s.singleSelected = OTHER_SENTINEL;
s.freeText = sel;
}
return s;
}
const known = new Set(q.options.map((o) => o.label));
a.selected.forEach((l) => {
if (known.has(l)) s.multiSelected.add(l);
else {
s.otherChecked = true;
s.freeText = l;
}
});
return s;
}
const declinedAnswer = (): UserInputResponseBody => ({ declined: true, selected: [], text: null });
function QuestionBody({
q,
nameSuffix,
state,
setState,
disabled,
}: {
q: UserInputQuestion;
nameSuffix: string;
state: AnswerState;
setState: (s: AnswerState) => void;
disabled: boolean;
}) {
const t = useT();
const freeTextRef = useRef<HTMLInputElement>(null);
const showOther = q.custom !== false;
const singleOtherActive = q.mode === 'single' && state.singleSelected === OTHER_SENTINEL;
const multiOtherActive = q.mode === 'multiple' && state.otherChecked;
useEffect(() => {
if ((singleOtherActive || multiOtherActive) && freeTextRef.current) freeTextRef.current.focus();
}, [singleOtherActive, multiOtherActive]);
const set = (patch: Partial<AnswerState>) => setState({ ...state, ...patch });
const toggleMulti = (label: string) => {
const next = new Set(state.multiSelected);
if (next.has(label)) next.delete(label);
else next.add(label);
set({ multiSelected: next });
};
return (
<>
<p class="permission-lead">{q.question}</p>
{q.mode === 'single' && q.options.length > 0 && (
<div class="field-group">
{q.options.map((opt) => (
<label key={opt.label} class="user-input-option">
<input
type="radio"
name={`uiq-${nameSuffix}`}
value={opt.label}
checked={state.singleSelected === opt.label}
onChange={() => set({ singleSelected: opt.label })}
disabled={disabled}
/>
<span class="user-input-option-body">
<span class="user-input-option-label">{opt.label}</span>
{opt.description && <span class="user-input-option-desc">{opt.description}</span>}
</span>
</label>
))}
{showOther && (
<label class="user-input-option">
<input
type="radio"
name={`uiq-${nameSuffix}`}
value={OTHER_SENTINEL}
checked={state.singleSelected === OTHER_SENTINEL}
onChange={() => set({ singleSelected: OTHER_SENTINEL })}
disabled={disabled}
/>
<span class="user-input-option-body">
<span class="user-input-option-label">{t('userInput.other')}</span>
</span>
</label>
)}
{singleOtherActive && (
<input
ref={freeTextRef}
type="text"
class="user-input-text"
value={state.freeText}
onInput={(e) => set({ freeText: (e.target as HTMLInputElement).value })}
disabled={disabled}
placeholder="输入自己的答案…"
/>
)}
</div>
)}
{q.mode === 'multiple' && q.options.length > 0 && (
<div class="field-group">
{q.options.map((opt) => (
<label key={opt.label} class="user-input-option">
<input
type="checkbox"
value={opt.label}
checked={state.multiSelected.has(opt.label)}
onChange={() => toggleMulti(opt.label)}
disabled={disabled}
/>
<span class="user-input-option-body">
<span class="user-input-option-label">{opt.label}</span>
{opt.description && <span class="user-input-option-desc">{opt.description}</span>}
</span>
</label>
))}
{showOther && (
<label class="user-input-option">
<input
type="checkbox"
checked={state.otherChecked}
onChange={() => set({ otherChecked: !state.otherChecked })}
disabled={disabled}
/>
<span class="user-input-option-body">
<span class="user-input-option-label">{t('userInput.other')}</span>
</span>
</label>
)}
{multiOtherActive && (
<input
ref={freeTextRef}
type="text"
class="user-input-text"
value={state.freeText}
onInput={(e) => set({ freeText: (e.target as HTMLInputElement).value })}
disabled={disabled}
placeholder="输入自己的答案…"
/>
)}
</div>
)}
{q.mode === 'text' && (
<div class="field-group">
<textarea
data-interaction-autofocus
autoFocus
class="user-input-textarea"
rows={4}
value={state.freeText}
onInput={(e) => set({ freeText: (e.target as HTMLTextAreaElement).value })}
disabled={disabled}
placeholder=""
/>
</div>
)}
</>
);
}
function CardShell({
title,
children,
footer,
closing,
onClose,
}: {
title: string;
children: preact.ComponentChildren;
footer: preact.ComponentChildren;
closing: boolean;
onClose: () => void;
}) {
const t = useT();
return (
<InteractionDock
title={title}
icon={
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="1.8" />
<line x1="12" y1="8" x2="12" y2="13" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
<circle cx="12" cy="16" r="0.9" fill="currentColor" />
</svg>
}
close={{
disabled: closing,
onClick: onClose,
label: t('userInput.close'),
}}
footer={footer}
>
{children}
</InteractionDock>
);
}
function SingleCard({ req, onDone, submitAnswer }: UserInputCardProps) {
const t = useT();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [state, setState] = useState<AnswerState>(emptyAnswer());
const q: UserInputQuestion = {
header: req.header,
question: req.question,
mode: req.mode,
options: req.options,
custom: req.custom,
};
const submitDisabled = loading || !answerReady(q, state);
async function submit() {
if (submitDisabled) return;
setLoading(true);
setError(null);
try {
const result = await submitAnswer({ request_id: req.request_id, ...buildAnswer(q, state) });
if (!result.accepted) throw new Error('user input response was not accepted');
onDone();
} catch {
setLoading(false);
setError(t('userInput.error'));
}
}
async function skip() {
if (loading) return;
setLoading(true);
setError(null);
try {
const result = await submitAnswer({ request_id: req.request_id, ...declinedAnswer() });
if (!result.accepted) throw new Error('user input response was not accepted');
onDone();
} catch {
setLoading(false);
setError(t('userInput.error'));
}
}
return (
<CardShell
title={q.header}
closing={loading}
onClose={() => void skip()}
footer={
<>
<button class="btn" disabled={loading} onClick={skip}>
{t('userInput.skip')}
</button>
<button class="btn btn-primary" disabled={submitDisabled} onClick={submit}>
{t('userInput.submit')}
</button>
</>
}
>
<QuestionBody q={q} nameSuffix={String(req.request_id)} state={state} setState={setState} disabled={loading} />
{error && <p class="user-input-error">{error}</p>}
</CardShell>
);
}
function BatchCard({ req, onDone, submitAnswer }: UserInputCardProps) {
const t = useT();
const qs = req.questions as UserInputQuestion[];
const [step, setStep] = useState(0);
const [answers, setAnswers] = useState<UserInputResponseBody[]>(qs.map(declinedAnswer));
const [state, setState] = useState<AnswerState>(emptyAnswer());
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const q = qs[step];
const isLast = step === qs.length - 1;
function commitCurrent(): UserInputResponseBody[] {
const a = answerReady(q, state) ? buildAnswer(q, state) : declinedAnswer();
const next = answers.slice();
next[step] = a;
setAnswers(next);
return next;
}
function goToStep(committed: UserInputResponseBody[], target: number) {
setStep(target);
setState(hydrate(qs[target], committed[target]));
}
async function postAll(all: UserInputResponseBody[]) {
setLoading(true);
setError(null);
try {
const result = await submitAnswer({ request_id: req.request_id, responses: all });
if (!result.accepted) throw new Error('user input response was not accepted');
onDone();
} catch {
setLoading(false);
setError(t('userInput.error'));
}
}
function next() {
if (loading) return;
const committed = commitCurrent();
if (isLast) void postAll(committed);
else goToStep(committed, step + 1);
}
function back() {
if (loading || step === 0) return;
const committed = commitCurrent();
goToStep(committed, step - 1);
}
async function skipAll() {
if (loading) return;
setLoading(true);
setError(null);
try {
const result = await submitAnswer({ request_id: req.request_id, responses: qs.map(declinedAnswer) });
if (!result.accepted) throw new Error('user input response was not accepted');
onDone();
} catch {
setLoading(false);
setError(t('userInput.error'));
}
}
const nextDisabled = loading || !answerReady(q, state);
return (
<CardShell
title={`${q.header} (${step + 1}/${qs.length})`}
closing={loading}
onClose={() => void skipAll()}
footer={
<>
<button class="btn" disabled={loading} onClick={skipAll}>
{t('userInput.skip')}
</button>
{step > 0 && (
<button class="btn" disabled={loading} onClick={back}>
←
</button>
)}
<button class="btn btn-primary" disabled={nextDisabled} onClick={next}>
{isLast ? t('userInput.submit') : '→'}
</button>
</>
}
>
<QuestionBody q={q} nameSuffix={`${req.request_id}-${step}`} state={state} setState={setState} disabled={loading} />
{error && <p class="user-input-error">{error}</p>}
</CardShell>
);
}
export function UserInputCard({ req, onDone, submitAnswer }: UserInputCardProps) {
const isBatch = isUserInputBatch(req);
return isBatch
? <BatchCard req={req} onDone={onDone} submitAnswer={submitAnswer} />
: <SingleCard req={req} onDone={onDone} submitAnswer={submitAnswer} />;
}