diff --git a/web/src/lib/api/moderation.ts b/web/src/lib/api/moderation.ts new file mode 100644 index 000000000..a616d8762 --- /dev/null +++ b/web/src/lib/api/moderation.ts @@ -0,0 +1,67 @@ +import { ClientResponseError, ok } from "@atcute/client"; +import { mainSchema as createReportSchema } from "@atcute/atproto/types/moderation/createReport"; +import type * as ComAtprotoModerationCreateReport from "@atcute/atproto/types/moderation/createReport"; +import type * as ComAtprotoModerationDefs from "@atcute/atproto/types/moderation/defs"; +import type { AtprotoAudience, Cid, Did, ResourceUri } from "@atcute/lexicons/syntax"; +import type { OAuthUserAgent } from "@atcute/oauth-browser-client"; +import { createClient } from "$lib/auth/agent"; + +export type ReasonType = ComAtprotoModerationDefs.ReasonType; +export type ReportOutput = ComAtprotoModerationCreateReport.$output; + +type KnownOf = T extends string ? (string extends T ? never : T) : never; +export type KnownReasonType = KnownOf; + +export type ReportSubject = + { kind: "account"; did: string } | { kind: "record"; uri: string; cid: string }; + +export interface ReportInput { + subject: ReportSubject; + reasonType: ReasonType; + reason?: string; +} + +type SubjectRef = ComAtprotoModerationCreateReport.$input["subject"]; + +const subjectRef = (subject: ReportSubject): SubjectRef => + subject.kind === "account" + ? { $type: "com.atproto.admin.defs#repoRef", did: subject.did as Did } + : { + $type: "com.atproto.repo.strongRef", + uri: subject.uri as ResourceUri, + cid: subject.cid as Cid + }; + +export const createReport = async ( + agent: OAuthUserAgent, + labelerDid: string, + { subject, reasonType, reason }: ReportInput +): Promise => { + const rpc = createClient(agent, `${labelerDid}#atproto_labeler` as AtprotoAudience); + return ok( + rpc.call(createReportSchema, { + input: { + reasonType, + reason: reason?.trim() || undefined, + subject: subjectRef(subject), + modTool: { name: "tangled-web" } + } + }) + ); +}; + +const isMissingScope = (cause: ClientResponseError): boolean => { + if (cause.status !== 401 && cause.status !== 403) return false; + const text = `${cause.error} ${cause.description ?? ""}`.toLowerCase(); + return text.includes("scope") || text.includes("invalidtoken") || text.includes("permission"); +}; + +export const reportErrorMessage = (cause: unknown): string => { + if (cause instanceof ClientResponseError) { + if (isMissingScope(cause)) { + return "Reporting was added after you signed in. Sign out and back in to enable it."; + } + return cause.description || cause.error || "Failed to send the report."; + } + return cause instanceof Error ? cause.message : "Failed to send the report."; +}; diff --git a/web/src/lib/components/comment/CommentCard.svelte b/web/src/lib/components/comment/CommentCard.svelte index e3c360084..37e464081 100644 --- a/web/src/lib/components/comment/CommentCard.svelte +++ b/web/src/lib/components/comment/CommentCard.svelte @@ -7,6 +7,7 @@ import { getAuth } from "$lib/auth.svelte"; import { type MarkupContext } from "$lib/markup"; import ErrorAlert from "$lib/components/ui/Error.svelte"; + import ReportMenu from "$lib/components/moderation/ReportMenu.svelte"; import ReactionPicker from "$lib/components/reaction/ReactionPicker.svelte"; import { upsertViewerReaction, @@ -122,6 +123,14 @@ > + {:else} + {/if} {/if} diff --git a/web/src/lib/components/moderation/ReportDialog.stories.svelte b/web/src/lib/components/moderation/ReportDialog.stories.svelte new file mode 100644 index 000000000..6d1b48aab --- /dev/null +++ b/web/src/lib/components/moderation/ReportDialog.stories.svelte @@ -0,0 +1,46 @@ + + + + + + + { + const canvas = within(canvasElement); + await userEvent.selectOptions(canvas.getByLabelText(/reason/i), [ + "tools.ozone.report.defs#reasonMisleadingSpam" + ]); + await userEvent.click(canvas.getByRole("button", { name: /^report$/i })); + await waitFor(() => + expect(canvas.getByRole("alert")).toHaveTextContent(/network request failed/i) + ); + }} +/> diff --git a/web/src/lib/components/moderation/ReportDialog.svelte b/web/src/lib/components/moderation/ReportDialog.svelte new file mode 100644 index 000000000..db0121b61 --- /dev/null +++ b/web/src/lib/components/moderation/ReportDialog.svelte @@ -0,0 +1,124 @@ + + + + {#if !sent} + + {#snippet children({ id })} +