diff --git a/docs/tasks/13-release.md b/docs/tasks/13-release.md index b3b52a9..5ec027f 100644 --- a/docs/tasks/13-release.md +++ b/docs/tasks/13-release.md @@ -10,21 +10,21 @@ Cross-platform build, signing, packaging, and distribution targeting GitHub Rele #### Backend -- [x] `ModerationService` in Rust - fetch labeler policies, evaluate labels into `ModerationDecision`, cache in `labeler_cache` table -- [x] Send `atproto-accept-labelers` header with all API requests (built-in Bluesky labeler + user-subscribed labelers) -- [x] Moderation preferences storage - per-account JSON in `app_settings` keyed by `moderation_preferences::{did}` -- [x] `create_report` command - calls `com.atproto.moderation.createReport` -- [x] `get_distribution_channel` command - returns compile-time `DISTRIBUTION_CHANNEL` env var +- [ ] `ModerationService` in Rust - fetch labeler policies, evaluate labels into `ModerationDecision`, cache in `labeler_cache` table +- [ ] Send `atproto-accept-labelers` header with all API requests (built-in Bluesky labeler + user-subscribed labelers) +- [ ] Moderation preferences storage - per-account JSON in `app_settings` keyed by `moderation_preferences::{did}` +- [ ] `create_report` command - calls `com.atproto.moderation.createReport` +- [ ] `get_distribution_channel` command - returns compile-time `DISTRIBUTION_CHANNEL` env var #### Frontend -- [ ] `ModeratedBlurOverlay` component - 14px blur, overlay icon, "Show content" button, label display -- [ ] `ModeratedAvatar` component - shield icon fallback for hidden avatars -- [ ] `ModerationBadgeRow` component - alert (red) and inform (blue) badges with label source -- [ ] `ReportDialog` modal - reason type selector, free-text, submit -- [ ] Wire moderation into `PostCard`, image/video embeds, profile views, notifications -- [ ] Moderation Settings section - adult content toggle (hidden on MAS, link to web settings instead), labeler management, per-label preferences -- [ ] Block user flow via `app.bsky.graph.block` +- [x] `ModeratedBlurOverlay` component - 14px blur, overlay icon, "Show content" button, label display +- [x] `ModeratedAvatar` component - shield icon fallback for hidden avatars +- [x] `ModerationBadgeRow` component - alert (red) and inform (blue) badges with label source +- [x] `ReportDialog` modal - reason type selector, free-text, submit +- [x] Wire moderation into `PostCard`, image/video embeds, profile views, notifications +- [x] Moderation Settings section - adult content toggle (hidden on MAS, link to web settings instead), labeler management, per-label preferences +- [x] Block user flow via `app.bsky.graph.block` ### App Identity & Branding diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 59c82ea..e5cc13f 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -145,6 +145,11 @@ pub async fn follow_actor(did: String, state: State<'_>) -> Result) -> Result { + feed::block_actor(did, &state).await +} + #[tauri::command] pub async fn unfollow_actor(follow_uri: String, state: State<'_>) -> Result<()> { feed::unfollow_actor(follow_uri, &state).await diff --git a/src-tauri/src/feed.rs b/src-tauri/src/feed.rs index 18c2ed6..47d9c04 100644 --- a/src-tauri/src/feed.rs +++ b/src-tauri/src/feed.rs @@ -23,6 +23,7 @@ use jacquard::api::app_bsky::feed::get_timeline::GetTimeline; use jacquard::api::app_bsky::feed::like::Like; use jacquard::api::app_bsky::feed::post::{Post, PostEmbed, ReplyRef}; use jacquard::api::app_bsky::feed::repost::Repost; +use jacquard::api::app_bsky::graph::block::Block; use jacquard::api::app_bsky::graph::follow::Follow; use jacquard::api::app_bsky::graph::get_followers::GetFollowers; use jacquard::api::app_bsky::graph::get_follows::GetFollows; @@ -835,6 +836,43 @@ pub async fn follow_actor(did: String, state: &AppState) -> Result Result { + let session = get_session(state).await?; + let active_did = active_did(state)?; + + let block = Block::new() + .created_at(Datetime::now()) + .subject(Did::new(&did).map_err(|_| AppError::validation("invalid account DID"))?) + .build(); + + let record_json = serde_json::to_value(&block)?; + let record_data = Data::from_json_owned(record_json).map_err(|_| AppError::validation("serialize block"))?; + + let repo = AtIdentifier::Did(Did::new(&active_did)?); + let collection = Nsid::new("app.bsky.graph.block").map_err(|_| AppError::validation("nsid"))?; + + let output = session + .send( + CreateRecord::new() + .repo(repo) + .collection(collection) + .record(record_data) + .build(), + ) + .await + .map_err(|error| { + log::error!("createRecord (block) error: {error}"); + AppError::validation("Could not block this account.") + })? + .into_output() + .map_err(|error| { + log::error!("createRecord (block) output error: {error}"); + AppError::validation("Could not block this account.") + })?; + + Ok(CreateRecordResult { uri: output.uri.to_string(), cid: output.cid.to_string() }) +} + pub async fn unfollow_actor(follow_uri: String, state: &AppState) -> Result<()> { let session = get_session(state).await?; let did = active_did(state)?; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 25c3cda..e9d1998 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -104,6 +104,7 @@ pub fn run() { cmd::bookmark_post, cmd::remove_bookmark, cmd::follow_actor, + cmd::block_actor, cmd::unfollow_actor, cmd::get_followers, cmd::get_follows, diff --git a/src-tauri/src/moderation.rs b/src-tauri/src/moderation.rs index 35fc618..fa48406 100644 --- a/src-tauri/src/moderation.rs +++ b/src-tauri/src/moderation.rs @@ -212,10 +212,7 @@ pub fn accepted_labeler_dids(prefs: &StoredModerationPrefs) -> Vec { /// This must be called after changing labeler subscriptions so that all subsequent API calls /// carry the correct header. pub async fn apply_labeler_headers(session: &LazuriteOAuthSession, prefs: &StoredModerationPrefs) { - let dids: Vec> = accepted_labeler_dids(prefs) - .into_iter() - .map(|did| CowStr::from(did)) - .collect(); + let dids: Vec> = accepted_labeler_dids(prefs).into_iter().map(CowStr::from).collect(); let opts = CallOptions { atproto_accept_labelers: Some(dids), ..Default::default() }; session.set_options(opts).await; log::debug!( diff --git a/src/components/deck/DeckWorkspace.tsx b/src/components/deck/DeckWorkspace.tsx index cf3afee..a0fc29a 100644 --- a/src/components/deck/DeckWorkspace.tsx +++ b/src/components/deck/DeckWorkspace.tsx @@ -134,8 +134,6 @@ export function DeckWorkspace() { const session = useAppSession(); const threadOverlay = useThreadOverlayNavigation(); let feedColumnRequest = 0; - // Module-level variable: WebKit dataTransfer.getData() returns empty string on drop, - // so we track the dragging column ID here instead. let draggingColumnId: string | null = null; const [state, setState] = createStore({ diff --git a/src/components/explorer/views/RecordView.tsx b/src/components/explorer/views/RecordView.tsx index 406496b..03ed5b8 100644 --- a/src/components/explorer/views/RecordView.tsx +++ b/src/components/explorer/views/RecordView.tsx @@ -140,28 +140,6 @@ function KnownRecordPreview(props: { record: Record }) { return null; }; - // return ( - // <> - // - // - //
- // - //
- //
- //
- - // - // {(value) => ( - // - //
- // - //
- //
- // )} - //
- // - // ); - return ( { it("does not show draft count badge when draftCount is zero", () => { render(() => {}} />); - // The drafts button should exist but no badge (the badge span is conditionally rendered) const draftsButton = screen.getByTitle("Drafts (Ctrl+D)"); expect(draftsButton).toBeInTheDocument(); - // Badge is only rendered when count > 0; with count=0 the badge span should be absent expect(draftsButton.textContent?.trim()).toBe(""); }); }); diff --git a/src/components/feeds/PostCard.test.tsx b/src/components/feeds/PostCard.test.tsx index c4e557f..6d85e2e 100644 --- a/src/components/feeds/PostCard.test.tsx +++ b/src/components/feeds/PostCard.test.tsx @@ -6,11 +6,28 @@ import { PostCard } from "./PostCard"; const downloadImageMock = vi.hoisted(() => vi.fn()); const downloadVideoMock = vi.hoisted(() => vi.fn()); const listenMock = vi.hoisted(() => vi.fn()); +const moderateContentMock = vi.hoisted(() => vi.fn()); +const createReportMock = vi.hoisted(() => vi.fn()); +const blockActorMock = vi.hoisted(() => vi.fn()); vi.mock( "$/lib/api/media", () => ({ MediaController: { downloadImage: downloadImageMock, downloadVideo: downloadVideoMock } }), ); +vi.mock( + "$/lib/api/moderation", + () => ({ + MODERATION_REASON_OPTIONS: [{ label: "Spam", value: "com.atproto.moderation.defs#reasonSpam" }, { + label: "Violation", + value: "com.atproto.moderation.defs#reasonViolation", + }], + ModerationController: { + moderateContent: moderateContentMock, + createReport: createReportMock, + blockActor: blockActorMock, + }, + }), +); vi.mock("@tauri-apps/api/event", () => ({ listen: listenMock })); function createPost() { @@ -42,7 +59,19 @@ describe("PostCard", () => { downloadImageMock.mockReset(); downloadVideoMock.mockReset(); listenMock.mockReset(); + moderateContentMock.mockReset(); + createReportMock.mockReset(); + blockActorMock.mockReset(); listenMock.mockResolvedValue(() => {}); + moderateContentMock.mockResolvedValue({ + filter: false, + blur: "none", + alert: false, + inform: false, + noOverride: false, + }); + createReportMock.mockResolvedValue(1); + blockActorMock.mockResolvedValue({ uri: "at://did:plc:test/app.bsky.graph.block/1", cid: "cid-block" }); }); it("renders links, mentions, and hashtags from facets", () => { @@ -247,4 +276,36 @@ describe("PostCard", () => { expect(downloadImageMock).toHaveBeenCalledWith("https://cdn.example.com/post-image-two.jpg", "123_2") ); }); + + it("submits a report for the current post", async () => { + render(() => ); + + fireEvent.click(screen.getByRole("button", { name: "More actions" })); + fireEvent.click(screen.getByRole("menuitem", { name: "Report post" })); + + expect(await screen.findByText("Report content")).toBeInTheDocument(); + fireEvent.input(screen.getByPlaceholderText("Add context for moderators"), { + target: { value: "misleading link" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Submit report" })); + + await waitFor(() => + expect(createReportMock).toHaveBeenCalledWith( + { type: "record", uri: "at://did:plc:alice/app.bsky.feed.post/123", cid: "cid-post" }, + "com.atproto.moderation.defs#reasonSpam", + "misleading link", + ) + ); + }); + + it("blocks the post author from the context menu", async () => { + const confirmSpy = vi.spyOn(globalThis, "confirm").mockReturnValue(true); + render(() => ); + + fireEvent.click(screen.getByRole("button", { name: "More actions" })); + fireEvent.click(screen.getByRole("menuitem", { name: "Block @alice.test" })); + + await waitFor(() => expect(blockActorMock).toHaveBeenCalledWith("did:plc:alice")); + confirmSpy.mockRestore(); + }); }); diff --git a/src/components/feeds/PostCard.tsx b/src/components/feeds/PostCard.tsx index 74c9d0d..534a57e 100644 --- a/src/components/feeds/PostCard.tsx +++ b/src/components/feeds/PostCard.tsx @@ -1,11 +1,17 @@ import { ImageGallery } from "$/components/feeds/ImageGallery"; import { type MediaNotice, MediaNoticeToast } from "$/components/feeds/MediaNoticeToast"; import { VideoEmbed } from "$/components/feeds/VideoEmbed"; +import { ModeratedAvatar } from "$/components/moderation/ModeratedAvatar"; +import { ModeratedBlurOverlay } from "$/components/moderation/ModeratedBlurOverlay"; +import { ModerationBadgeRow } from "$/components/moderation/ModerationBadgeRow"; +import { ReportDialog } from "$/components/moderation/ReportDialog"; +import { useModerationDecision } from "$/components/moderation/useModerationDecision"; import { ContextMenu, type ContextMenuAnchor, type ContextMenuItem } from "$/components/shared/ContextMenu"; import { Icon } from "$/components/shared/Icon"; import { PostRichText } from "$/components/shared/PostRichText"; import { QuotedPostPreview } from "$/components/shared/QuotedPostPreview"; import { MediaController } from "$/lib/api/media"; +import { ModerationController } from "$/lib/api/moderation"; import { buildPublicPostUrl, formatRelativeTime, @@ -19,9 +25,22 @@ import { getQuotedText, isReplyItem, } from "$/lib/feeds"; +import { collectModerationLabels } from "$/lib/moderation"; import { buildProfileRoute, getProfileRouteActor } from "$/lib/profile"; -import type { EmbedView, FeedViewPost, ImagesEmbedView, PostView, ProfileViewBasic, RichTextFacet } from "$/lib/types"; +import type { + EmbedView, + FeedViewPost, + ImagesEmbedView, + ModerationLabel, + ModerationReasonType, + ModerationUiDecision, + PostView, + ProfileViewBasic, + ReportSubjectInput, + RichTextFacet, +} from "$/lib/types"; import { formatCount, formatHandle, normalizeError } from "$/lib/utils/text"; +import * as logger from "@tauri-apps/plugin-log"; import { revealItemInDir } from "@tauri-apps/plugin-opener"; import { createMemo, createSignal, For, Match, onCleanup, type ParentProps, Show, Switch } from "solid-js"; import { Motion } from "solid-motionone"; @@ -46,6 +65,8 @@ type PostCardProps = { showActions?: boolean; }; +type ReportTarget = { subject: ReportSubjectInput; subjectLabel: string }; + export function PostCard(props: PostCardProps) { const authorName = createMemo(() => getDisplayName(props.post.author)); const createdAt = createMemo(() => formatRelativeTime(getPostCreatedAt(props.post))); @@ -58,6 +79,12 @@ export function PostCard(props: PostCardProps) { const repostCount = createMemo(() => formatCount(props.post.repostCount)); const authorHandle = createMemo(() => formatHandle(props.post.author.handle, props.post.author.did)); const profileHref = createMemo(() => buildProfileRoute(getProfileRouteActor(props.post.author))); + const contentLabels = () => collectModerationLabels(props.post); + const mediaLabels = () => collectModerationLabels(props.post, props.post.embed); + const avatarLabels = () => collectModerationLabels(props.post.author); + const contentDecision = useModerationDecision(contentLabels); + const mediaDecision = useModerationDecision(mediaLabels); + const avatarDecision = useModerationDecision(avatarLabels); const reasonLabel = createMemo(() => { const reason = props.item?.reason; if (!reason || reason.$type !== "app.bsky.feed.defs#reasonRepost") { @@ -83,6 +110,8 @@ export function PostCard(props: PostCardProps) { const [menuAnchor, setMenuAnchor] = createSignal(null); const [menuOpen, setMenuOpen] = createSignal(false); + const [reportOpen, setReportOpen] = createSignal(false); + const [reportTarget, setReportTarget] = createSignal(null); let menuTriggerRef: HTMLButtonElement | undefined; const menuItems = createMemo(() => { @@ -130,6 +159,28 @@ export function PostCard(props: PostCardProps) { items.push({ icon: "i-ri-node-tree", label: "Open thread", onSelect: props.onOpenThread }); } + items.push({ + icon: "i-ri-flag-line", + label: "Report post", + onSelect: () => { + setReportTarget({ + subject: { type: "record", uri: props.post.uri, cid: props.post.cid }, + subjectLabel: `Post by @${props.post.author.handle}`, + }); + setReportOpen(true); + }, + }, { + icon: "i-ri-flag-2-line", + label: "Report account", + onSelect: () => { + setReportTarget({ + subject: { type: "repo", did: props.post.author.did }, + subjectLabel: `Account @${props.post.author.handle}`, + }); + setReportOpen(true); + }, + }, { icon: "i-ri-forbid-2-line", label: `Block @${props.post.author.handle}`, onSelect: () => void blockAuthor() }); + return items; }); @@ -149,6 +200,35 @@ export function PostCard(props: PostCardProps) { setMenuOpen(true); } + async function submitReport(input: { reasonType: ModerationReasonType; reason: string }) { + const target = reportTarget(); + if (!target) { + return; + } + + try { + await ModerationController.createReport(target.subject, input.reasonType, input.reason); + } catch (error) { + logger.error("failed to submit report", { keyValues: { error: normalizeError(error) } }); + } + } + + async function blockAuthor() { + const confirmed = globalThis.confirm + ? globalThis.confirm(`Block @${props.post.author.handle}? You can unblock from Bluesky settings.`) + : true; + + if (!confirmed) { + return; + } + + try { + await ModerationController.blockActor(props.post.author.did); + } catch (error) { + logger.error("failed to block account", { keyValues: { error: normalizeError(error) } }); + } + } + return (
props.registerRef?.(element)} @@ -180,7 +260,12 @@ export function PostCard(props: PostCardProps) {
event.stopPropagation()}> - +
@@ -191,9 +276,15 @@ export function PostCard(props: PostCardProps) { createdAt={createdAt()} profileHref={profileHref()} /> - + - + + + @@ -231,6 +322,12 @@ export function PostCard(props: PostCardProps) { open={menuOpen()} returnFocusTo={menuTriggerRef} onClose={closeMenu} /> + + setReportOpen(false)} + onSubmit={submitReport} />
); } @@ -350,26 +447,22 @@ function PostActions( ); } -function AuthorAvatar(props: { avatar?: string | null; label: string }) { +function PostBodyText(props: { facets: RichTextFacet[]; text: string }) { return ( -
- - {props.label} -
- }> - {(avatar) => } -
- + 0}> + + ); } -function PostBodyText(props: { facets: RichTextFacet[]; text: string }) { +function ModeratedPostBody( + props: { decision: ModerationUiDecision; labels: ModerationLabel[]; post: PostView; text: string }, +) { return ( 0}> - + + + ); } @@ -407,13 +500,13 @@ function ActionButton( ); } -function PostEmbeds(props: { post: PostView }) { +function PostEmbeds(props: { decision: ModerationUiDecision; labels: ModerationLabel[]; post: PostView }) { return ( {(current) => ( -
+ -
+ )}
); diff --git a/src/components/feeds/useFeedWorkspaceController.test.tsx b/src/components/feeds/useFeedWorkspaceController.test.tsx index c612293..ec589a5 100644 --- a/src/components/feeds/useFeedWorkspaceController.test.tsx +++ b/src/components/feeds/useFeedWorkspaceController.test.tsx @@ -235,7 +235,6 @@ describe("useFeedWorkspaceController", () => { render(() => ); await screen.findByText("following"); - // Load a draft to set draftId fireEvent.click(screen.getByRole("button", { name: "Load draft" })); expect(screen.getByTestId("draft-id")).toHaveTextContent(SAMPLE_DRAFT.id); diff --git a/src/components/moderation/ModeratedAvatar.tsx b/src/components/moderation/ModeratedAvatar.tsx new file mode 100644 index 0000000..8004720 --- /dev/null +++ b/src/components/moderation/ModeratedAvatar.tsx @@ -0,0 +1,33 @@ +import { Show } from "solid-js"; +import { Icon } from "../shared/Icon"; + +type ModeratedAvatarProps = { + avatar?: string | null; + class: string; + hidden: boolean; + label: string; + fallbackClass?: string; + imageClass?: string; +}; + +export function ModeratedAvatar(props: ModeratedAvatarProps) { + return ( +
+ + + + }> + {(avatar) => } + +
+ ); +} diff --git a/src/components/moderation/ModeratedBlurOverlay.test.tsx b/src/components/moderation/ModeratedBlurOverlay.test.tsx new file mode 100644 index 0000000..297d2cc --- /dev/null +++ b/src/components/moderation/ModeratedBlurOverlay.test.tsx @@ -0,0 +1,32 @@ +import { fireEvent, render, screen } from "@solidjs/testing-library"; +import { describe, expect, it } from "vitest"; +import { ModeratedBlurOverlay } from "./ModeratedBlurOverlay"; + +describe("ModeratedBlurOverlay", () => { + it("shows overlay and allows reveal when decision is blur-only", () => { + render(() => ( + +

Hidden body

+
+ )); + + expect(screen.getByText("Content blurred")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Show content" })); + expect(screen.queryByText("Content blurred")).not.toBeInTheDocument(); + }); + + it("hides reveal button when content is fully filtered", () => { + render(() => ( + +

Hidden body

+
+ )); + + expect(screen.getByText("Hidden by your moderation settings.")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Show content" })).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/moderation/ModeratedBlurOverlay.tsx b/src/components/moderation/ModeratedBlurOverlay.tsx new file mode 100644 index 0000000..6d1d3fb --- /dev/null +++ b/src/components/moderation/ModeratedBlurOverlay.tsx @@ -0,0 +1,80 @@ +import { summarizeModerationLabels } from "$/lib/moderation"; +import type { ModerationLabel, ModerationUiDecision } from "$/lib/types"; +import { createMemo, createSignal, type ParentProps, Show } from "solid-js"; +import { Icon } from "../shared/Icon"; + +type ModeratedBlurOverlayProps = ParentProps< + { decision: ModerationUiDecision; labels: ModerationLabel[]; class?: string; revealLabel?: string } +>; + +export function ModeratedBlurOverlay(props: ModeratedBlurOverlayProps) { + const [revealed, setRevealed] = createSignal(false); + const hidden = createMemo(() => (props.decision.filter || props.decision.blur !== "none") && !revealed()); + const revealable = createMemo(() => !props.decision.filter && !props.decision.noOverride); + const summaryText = createMemo(() => + summarizeModerationLabels(props.labels, 2).map((summary) => `${summary.value} (${summary.source})`).join(", ") + ); + + return ( +
+ + + + setRevealed(true)} /> + + +
+ ); +} + +function BlurredChildren(props: ParentProps<{ hidden: boolean }>) { + return
{props.children}
; +} + +function OverlayMask(props: ParentProps) { + return ( +
+ {props.children} +
+ ); +} + +function OverlayCard(props: { revealLabel: string; revealable: boolean; summaryText: string; onReveal: () => void }) { + return ( +
+ +

Content blurred

+ 0}> +

{props.summaryText}

+
+ +
+ ); +} + +function OverlayIcon() { + return ( +
+
+ ); +} + +function OverlayAction(props: { revealLabel: string; revealable: boolean; onReveal: () => void }) { + return ( + Hidden by your moderation settings.

}> + +
+ ); +} diff --git a/src/components/moderation/ModerationBadgeRow.tsx b/src/components/moderation/ModerationBadgeRow.tsx new file mode 100644 index 0000000..7554a3a --- /dev/null +++ b/src/components/moderation/ModerationBadgeRow.tsx @@ -0,0 +1,37 @@ +import { summarizeModerationLabels } from "$/lib/moderation"; +import type { ModerationLabel, ModerationUiDecision } from "$/lib/types"; +import { createMemo, Show } from "solid-js"; +import { Icon } from "../shared/Icon"; + +type ModerationBadgeRowProps = { decision: ModerationUiDecision; labels: ModerationLabel[]; class?: string }; + +export function ModerationBadgeRow(props: ModerationBadgeRowProps) { + const summaries = createMemo(() => summarizeModerationLabels(props.labels, 3)); + const sourceText = createMemo(() => { + if (summaries().length === 0) { + return null; + } + + return summaries().map((summary) => `${summary.value} ยท ${summary.source}`).join(" | "); + }); + + return ( + +
+ + + + + + + + + {(text) => {text()}} +
+
+ ); +} diff --git a/src/components/moderation/ReportDialog.tsx b/src/components/moderation/ReportDialog.tsx new file mode 100644 index 0000000..3f2fe8c --- /dev/null +++ b/src/components/moderation/ReportDialog.tsx @@ -0,0 +1,144 @@ +import { MODERATION_REASON_OPTIONS } from "$/lib/api/moderation"; +import type { ModerationReasonType } from "$/lib/types"; +import { createEffect, createSignal, For, type ParentProps, Show } from "solid-js"; +import { Motion, Presence } from "solid-motionone"; + +type ReportDialogProps = { + open: boolean; + subjectLabel: string; + onClose: () => void; + onSubmit: (input: { reasonType: ModerationReasonType; reason: string }) => Promise | void; +}; + +export function ReportDialog(props: ReportDialogProps) { + const [reasonType, setReasonType] = createSignal(MODERATION_REASON_OPTIONS[0].value); + const [reason, setReason] = createSignal(""); + const [submitting, setSubmitting] = createSignal(false); + + createEffect(() => { + if (!props.open) { + return; + } + + setReasonType(MODERATION_REASON_OPTIONS[0].value); + setReason(""); + setSubmitting(false); + }); + + async function submit() { + if (submitting()) { + return; + } + + setSubmitting(true); + try { + await props.onSubmit({ reason: reason().trim(), reasonType: reasonType() }); + props.onClose(); + } finally { + setSubmitting(false); + } + } + + return ( + + + + + + + + void submit()} /> + + + + + ); +} + +function DialogBackdrop(props: ParentProps<{ onClose: () => void }>) { + return ( + +