import { useState } from 'react';
import type { ComAtprotoLabelDefs, ComAtprotoModerationCreateReport } from '@atcute/atproto';
import { ClientResponseError, ok } from '@atcute/client';
import type { AtprotoAudience } from '@atcute/lexicons/syntax';
import { useMutation } from '@tanstack/react-query';
import { OzoneReason } from '#/lib/moderation/report-reasons';
import { profileTarget } from '#/lib/routes/targets';
import { useLabelInfo } from '#/state/moderation/use-label-info';
import { getClients } from '#/state/session';
import { Trans } from '#/locale/Trans';
import * as Dialog from '#/components/Dialog';
import { Spinner } from '#/components/Spinner';
import { Stack } from '#/components/Stack';
import { Text } from '#/components/Text';
import * as TextField from '#/components/TextField';
import * as Toast from '#/components/Toast';
import { Admonition } from '#/components/web/Admonition';
import { Button, ButtonText } from '#/components/web/Button';
import { InlineLinkText } from '#/components/web/Link';
import { m } from '#/paraglide/messages';
import * as styles from './AppealForm.css';
export interface AppealFormProps {
handle: Dialog.DialogHandle;
label: ComAtprotoLabelDefs.Label;
onPressBack: () => void;
}
/** Submits a moderation appeal for a single label, closing {@link handle} on success. */
export function AppealForm({ handle, label, onPressBack }: AppealFormProps) {
const { labeler, strings } = useLabelInfo(label);
const [details, setDetails] = useState('');
// a label on a record carries a cid and appeals as a strong ref; one on an account has none and
// appeals as a repo ref.
const subject = label.cid ? { uri: label.uri, cid: label.cid } : { did: label.uri };
const { pds } = getClients();
const sourceName = labeler ? `@${labeler.creator.handle}` : label.src;
const [error, setError] = useState<string | null>(null);
const { mutate, isPending } = useMutation({
mutationFn: async () => {
if (!pds) {
throw new Error('Not logged in');
}
// the appeal is funnelled to the labeler that applied the label
// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- `AtprotoAudience` pins the DID method; the lexicon doesn't
const reportClient = pds.clone({ proxy: `${label.src}#atproto_labeler` as AtprotoAudience });
// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- already split into a repo ref or a strong ref above
const ref = (
'did' in subject
? { $type: 'com.atproto.admin.defs#repoRef', ...subject }
: { $type: 'com.atproto.repo.strongRef', ...subject }
) as ComAtprotoModerationCreateReport.$input['subject'];
await ok(
reportClient.post('com.atproto.moderation.createReport', {
input: {
reasonType: OzoneReason.REASONAPPEAL,
subject: ref,
reason: details,
},
}),
);
},
onSuccess: () => {
handle.close();
Toast.show(m['common.appeal.submittedToast']());
},
onError: (err) => {
if (err instanceof ClientResponseError && err.error === 'AlreadyAppealed') {
setError(m['components.moderation.appeal.alreadyAppealed']());
} else {
console.error('Failed to submit label appeal', err);
setError(m['common.appeal.submitError']());
}
},
});
const onSubmit = () => mutate();
return (
<Stack gap="xl">
<Stack gap="xs">
<Dialog.TitleRow>
<Dialog.Title>{m['components.moderation.appeal.title']({ name: strings.name })}</Dialog.Title>
<Dialog.Close />
</Dialog.TitleRow>
<Text>
<Trans
message={m['components.moderation.appeal.sentTo']}
inputs={{ sourceName }}
markup={{
t0: ({ children }) => (
<InlineLinkText
label={sourceName}
onPress={() => handle.close()}
size="md"
to={profileTarget(labeler ? labeler.creator.did : label.src)}
>
{children}
</InlineLinkText>
),
}}
/>
</Text>
</Stack>
{error && (
<Admonition className={styles.appealError} type="error">
{error}
</Admonition>
)}
<TextField.Input
autoFocus
label={m['common.a11y.textInput']()}
maxLength={300}
minRows={3}
multiline
onChangeText={setDetails}
placeholder={m['components.moderation.appeal.explainPrompt']({
labeler: labeler ? `@${labeler.creator.handle}` : label.src,
})}
value={details}
/>
<Dialog.Actions align="between" direction="responsive" reverse>
<Button color="secondary" label={m['common.action.back']()} onClick={onPressBack} variant="solid">
<ButtonText>{m['common.action.back']()}</ButtonText>
</Button>
<Button color="primary" label={m['common.action.submit']()} onClick={onSubmit} variant="solid">
<ButtonText>{m['common.action.submit']()}</ButtonText>
{isPending && <Spinner color="white" label={m['common.status.saving']()} size="sm" />}
</Button>
</Dialog.Actions>
</Stack>
);
}