From ee48b71e91ef37e45fa5221df1a747834d22f06b Mon Sep 17 00:00:00 2001 From: eti Date: Mon, 24 Aug 2026 14:46:03 +0200 Subject: [PATCH] web/reporting: report user from user's profile page Signed-off-by: eti --- .../comment/CommentCard.stories.svelte | 2 +- .../lib/components/comment/CommentCard.svelte | 25 ++-- .../moderation/ReportDialog.stories.svelte | 33 +++--- ...ries.svelte => ReportItems.stories.svelte} | 35 ++++-- .../components/moderation/ReportItems.svelte | 76 ++++++++++++ .../components/moderation/ReportMenu.svelte | 109 ------------------ .../components/moderation/reporting.svelte.ts | 23 ++++ .../lib/components/profile/ProfileCard.svelte | 18 ++- .../repo/issues/IssueThreadView.svelte | 66 +++++++---- .../repo/tickets/Ticket.stories.svelte | 2 +- .../lib/components/repo/tickets/Ticket.svelte | 29 +++-- web/src/lib/components/ui/Dropdown.svelte | 41 +++---- web/src/lib/components/ui/Modal.svelte | 9 ++ .../lib/components/ui/MoreMenu.stories.svelte | 38 ++++++ web/src/lib/components/ui/MoreMenu.svelte | 30 +++++ 15 files changed, 336 insertions(+), 200 deletions(-) rename web/src/lib/components/moderation/{ReportMenu.stories.svelte => ReportItems.stories.svelte} (60%) create mode 100644 web/src/lib/components/moderation/ReportItems.svelte delete mode 100644 web/src/lib/components/moderation/ReportMenu.svelte create mode 100644 web/src/lib/components/moderation/reporting.svelte.ts create mode 100644 web/src/lib/components/ui/MoreMenu.stories.svelte create mode 100644 web/src/lib/components/ui/MoreMenu.svelte diff --git a/web/src/lib/components/comment/CommentCard.stories.svelte b/web/src/lib/components/comment/CommentCard.stories.svelte index 419721286..3fadebeae 100644 --- a/web/src/lib/components/comment/CommentCard.stories.svelte +++ b/web/src/lib/components/comment/CommentCard.stories.svelte @@ -89,7 +89,7 @@ }} play={async ({ canvasElement }) => { const canvas = within(canvasElement); - await userEvent.click(canvas.getByRole("button", { name: "More actions" })); + await userEvent.click(canvas.getByRole("button", { name: /more actions for/i })); // the actions row fades in on real hover, which a synthetic pointer cannot do, so // assert the menu is in the tree rather than painted await expect( diff --git a/web/src/lib/components/comment/CommentCard.svelte b/web/src/lib/components/comment/CommentCard.svelte index 43fb045db..f40a74380 100644 --- a/web/src/lib/components/comment/CommentCard.svelte +++ b/web/src/lib/components/comment/CommentCard.svelte @@ -7,7 +7,9 @@ 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 ReportItems from "$lib/components/moderation/ReportItems.svelte"; + import { createReporting } from "$lib/components/moderation/reporting.svelte"; + import MoreMenu from "$lib/components/ui/MoreMenu.svelte"; import ReactionPicker from "$lib/components/reaction/ReactionPicker.svelte"; import { upsertViewerReaction, @@ -36,6 +38,7 @@ const auth = getAuth(); const currentUser = $derived(auth?.currentUser ?? null); + const reporting = createReporting(); let replying = $state(false); let editingUri = $state(null); @@ -123,15 +126,19 @@ > - {:else} - + label={`More actions for ${comment.authorHandle}'s reply`} + > + + {/if} {/if} diff --git a/web/src/lib/components/moderation/ReportDialog.stories.svelte b/web/src/lib/components/moderation/ReportDialog.stories.svelte index f25dc2910..8697e1a37 100644 --- a/web/src/lib/components/moderation/ReportDialog.stories.svelte +++ b/web/src/lib/components/moderation/ReportDialog.stories.svelte @@ -1,6 +1,6 @@ + { - const canvas = within(canvasElement); - await userEvent.selectOptions(canvas.getByLabelText(/reason/i), [spam]); - await userEvent.click(canvas.getByRole("button", { name: /^submit report$/i })); + play={async () => { + await pickReason(); + await userEvent.click(screen.getByRole("button", { name: /^submit report$/i })); await waitFor(() => - expect(canvas.getByRole("alert")).toHaveTextContent(/network request failed/i) + expect(screen.getByRole("alert")).toHaveTextContent(/network request failed/i) ); }} /> @@ -66,13 +72,12 @@ { - const canvas = within(canvasElement); - await userEvent.selectOptions(canvas.getByLabelText(/reason/i), [spam]); - await userEvent.type(canvas.getByLabelText(/details/i), details); - await userEvent.click(canvas.getByRole("button", { name: /^submit report$/i })); - await waitFor(() => expect(canvas.getByText(/report sent/i)).toBeVisible()); - await expect(canvas.getByRole("button", { name: /done/i })).toBeVisible(); - await expect(canvas.queryByLabelText(/reason/i)).toBeNull(); + play={async () => { + await pickReason(); + await userEvent.type(screen.getByLabelText(/details/i), details); + await userEvent.click(screen.getByRole("button", { name: /^submit report$/i })); + await waitFor(() => expect(screen.getByText(/report sent/i)).toBeVisible()); + await expect(screen.getByRole("button", { name: /done/i })).toBeVisible(); + await expect(screen.queryByLabelText(/reason/i)).toBeNull(); }} /> diff --git a/web/src/lib/components/moderation/ReportMenu.stories.svelte b/web/src/lib/components/moderation/ReportItems.stories.svelte similarity index 60% rename from web/src/lib/components/moderation/ReportMenu.stories.svelte rename to web/src/lib/components/moderation/ReportItems.stories.svelte index be1c998fb..4c9b1014f 100644 --- a/web/src/lib/components/moderation/ReportMenu.stories.svelte +++ b/web/src/lib/components/moderation/ReportItems.stories.svelte @@ -1,11 +1,13 @@ - + +{#snippet template(args: ComponentProps)} + + + +{/snippet} + + { const canvas = within(canvasElement); await userEvent.click(canvas.getByRole("button", { name: /more actions/i })); await userEvent.click(canvas.getByRole("menuitem", { name: /report comment/i })); + // Modal portals the dialog onto , out of the story canvas const dialog = await waitFor(() => { - const el = canvas.getByRole("dialog", { hidden: true }); + const el = screen.getByRole("dialog", { hidden: true }); expect(el).toBeVisible(); return el; }); @@ -38,10 +49,11 @@ /> - + - + + - + - + diff --git a/web/src/lib/components/moderation/ReportItems.svelte b/web/src/lib/components/moderation/ReportItems.svelte new file mode 100644 index 000000000..8a8618084 --- /dev/null +++ b/web/src/lib/components/moderation/ReportItems.svelte @@ -0,0 +1,76 @@ + + +{#if reporting.canReport(authorDid)} + {#if record} + openWith(record, `this ${subjectNoun}`)} danger={true}> + Report {subjectNoun} + + {/if} + openWith({ kind: "account", did: authorDid }, `@${authorHandle}`)} + danger={true} + > + Report user + + + {#if subject} + + {/if} +{/if} diff --git a/web/src/lib/components/moderation/ReportMenu.svelte b/web/src/lib/components/moderation/ReportMenu.svelte deleted file mode 100644 index 144df315d..000000000 --- a/web/src/lib/components/moderation/ReportMenu.svelte +++ /dev/null @@ -1,109 +0,0 @@ - - -{#if visible} - - {#snippet trigger()} - - {/snippet} - {#if subjectCid} - - openWith( - { kind: "record", uri: subjectUri, cid: subjectCid }, - `this ${subjectNoun}` - )} - danger={true} - > - Report {subjectNoun} - - {/if} - openWith({ kind: "account", did: authorDid }, `@${authorHandle}`)} - danger={true} - > - Report user - - - - {#if subject} - - {/if} -{/if} diff --git a/web/src/lib/components/moderation/reporting.svelte.ts b/web/src/lib/components/moderation/reporting.svelte.ts new file mode 100644 index 000000000..6cb2479e3 --- /dev/null +++ b/web/src/lib/components/moderation/reporting.svelte.ts @@ -0,0 +1,23 @@ +import { page } from "$app/state"; +import { getAuth } from "$lib/auth.svelte"; + +export interface Reporting { + readonly labelerDid: string; + canReport(authorDid: string): boolean; +} + +/** Call at component init: reads the auth context. */ +export const createReporting = (labelerDid?: () => string | undefined): Reporting => { + const auth = getAuth(); + const labeler = $derived( + labelerDid?.() ?? (page.data.publicConfig?.moderationServiceDid as string | undefined) ?? "" + ); + + return { + get labelerDid() { + return labeler; + }, + canReport: (authorDid: string) => + Boolean(labeler) && Boolean(auth?.currentDid) && !auth.hasAccount(authorDid) + }; +}; diff --git a/web/src/lib/components/profile/ProfileCard.svelte b/web/src/lib/components/profile/ProfileCard.svelte index ffc47c405..df1e668e0 100644 --- a/web/src/lib/components/profile/ProfileCard.svelte +++ b/web/src/lib/components/profile/ProfileCard.svelte @@ -8,8 +8,11 @@ import Link from "$icon/link"; import Pencil from "$icon/pencil"; import Rss from "$icon/rss"; + import ReportItems from "$lib/components/moderation/ReportItems.svelte"; import Avatar from "$lib/components/ui/Avatar.svelte"; import Button from "$lib/components/ui/Button.svelte"; + import DropdownItem from "$lib/components/ui/DropdownItem.svelte"; + import MoreMenu from "$lib/components/ui/MoreMenu.svelte"; import { getAuth } from "$lib/auth.svelte"; import type { ProfileRecord } from "$lib/api/records"; import type { DirectVouch, NetworkVouch } from "./types"; @@ -155,13 +158,16 @@ /> {/if} - + + Atom feed + + + {#if (!isSelf && signedIn) || networkVouches.length > 0} diff --git a/web/src/lib/components/repo/issues/IssueThreadView.svelte b/web/src/lib/components/repo/issues/IssueThreadView.svelte index 5b0749d07..fc1e5bfa0 100644 --- a/web/src/lib/components/repo/issues/IssueThreadView.svelte +++ b/web/src/lib/components/repo/issues/IssueThreadView.svelte @@ -26,7 +26,11 @@ import EyeOff from "$icon/eye-off"; import { createAction } from "$lib/action.svelte"; import { createBobbinClient } from "$lib/api/client"; - import { createEntitySubscription, deleteSubscription, getSubscriptionRkey } from "$lib/api/graph"; + import { + createEntitySubscription, + deleteSubscription, + getSubscriptionRkey + } from "$lib/api/graph"; import { deleteIssue, type IssueRecord } from "$lib/api/issue"; import type { RecordView } from "$lib/api/records"; import { getAuth } from "$lib/auth.svelte"; @@ -34,7 +38,9 @@ import CommentBox from "$lib/components/comment/CommentBox.svelte"; import CommentList from "$lib/components/comment/CommentList.svelte"; import type { CommentThread, ThreadInput } from "$lib/components/comment/comments"; - import ReportMenu from "$lib/components/moderation/ReportMenu.svelte"; + import ReportItems from "$lib/components/moderation/ReportItems.svelte"; + import { createReporting } from "$lib/components/moderation/reporting.svelte"; + import MoreMenu from "$lib/components/ui/MoreMenu.svelte"; import Reactions from "$lib/components/reaction/Reactions.svelte"; import IssueBody from "$lib/components/repo/issues/IssueBody.svelte"; import IssueForm from "$lib/components/repo/issues/IssueForm.svelte"; @@ -60,6 +66,7 @@ let props: Props = $props(); const auth = getAuth(); + const reporting = createReporting(); const notifications = getNotifications(); // local state for optimistic replies, re-synced when streamed props land @@ -85,20 +92,32 @@ $effect(() => { const did = auth.currentDid; - if (!props.bobbinUrl || !did) { subscribed = false; subRkey = null; subLoading = false; return; } + if (!props.bobbinUrl || !did) { + subscribed = false; + subRkey = null; + subLoading = false; + return; + } let cancelled = false; subLoading = true; subError = null; const ctx = createBobbinClient({ serviceUrl: props.bobbinUrl }); - getSubscriptionRkey(ctx, did, entityUri).then((rkey) => { - if (cancelled) return; - subRkey = rkey; - subscribed = !!rkey; - subLoading = false; - }).catch(() => { - if (!cancelled) { subLoading = false; subError = "Could not load subscription state"; } - }); - return () => { cancelled = true; }; + getSubscriptionRkey(ctx, did, entityUri) + .then((rkey) => { + if (cancelled) return; + subRkey = rkey; + subscribed = !!rkey; + subLoading = false; + }) + .catch(() => { + if (!cancelled) { + subLoading = false; + subError = "Could not load subscription state"; + } + }); + return () => { + cancelled = true; + }; }); const toggleSubscription = async () => { @@ -188,7 +207,9 @@ {#if auth.currentDid}