diff --git a/apps/web/app/components/discussion/comment.tsx b/apps/web/app/components/discussion/comment.tsx index aac581f..8dc7ff9 100644 --- a/apps/web/app/components/discussion/comment.tsx +++ b/apps/web/app/components/discussion/comment.tsx @@ -4,6 +4,9 @@ import { authorPath, COMMENT_KIND, toNpub } from "@openspecs/nostr"; import { useEffect, useMemo, useState } from "react"; import { Link } from "react-router"; import { AuthorAvatar } from "~/components/author-avatar"; +import { CHROME } from "~/components/chrome"; +import { More } from "~/components/moderation/more"; +import { hidesComment, useBlocked } from "~/lib/blocked"; import { keyTextColor } from "~/lib/color"; import { NO_RESPONSE, type Response } from "~/lib/discussion"; import { mentionedKeys, mentionResolver } from "~/lib/mention"; @@ -78,6 +81,52 @@ export const CommentThread = ({ const npub = toNpub(comment.pubkey); const author = authors[comment.pubkey] ?? null; + const blocked = useBlocked(); + /** Opened by hand, for this visit: a block is not a lock. */ + const [shownAnyway, setShownAnyway] = useState(false); + + const replies = node.replies.length > 0 && ( +
+ {node.replies.map((reply) => ( + + ))} +
+ ); + + // Collapsed to a line in its place rather than taken out, so what was said + // in answer keeps its thread. The blank on the left is where the mark of the + // key would be: the replies below stay in the column they had. + if (hidesComment(blocked, comment) && !shownAnyway) { + return ( +
+
+
+
+ ); + } + return ( // Addressable, so a notification can land on the comment it is about rather // than on the conversation holding it. The margin is what keeps the header @@ -127,6 +176,7 @@ export const CommentThread = ({ Reply )} + {me !== null && replying && ( @@ -142,21 +192,7 @@ export const CommentThread = ({ )} - {node.replies.length > 0 && ( -
- {node.replies.map((reply) => ( - - ))} -
- )} + {replies} diff --git a/apps/web/app/components/moderation/blocked-notice.tsx b/apps/web/app/components/moderation/blocked-notice.tsx new file mode 100644 index 0000000..74f269b --- /dev/null +++ b/apps/web/app/components/moderation/blocked-notice.tsx @@ -0,0 +1,32 @@ +import { CHROME } from "~/components/chrome"; + +/** + * What stands where a blocked page would be. Dashed, like everything on this + * site that is on offer rather than settled: the page is one click away either + * way, and the sentence says whose choice put it there. + */ +export const BlockedNotice = ({ + line, + detail, + onUnblock, + onShow, +}: { + line: string; + /** Which page this is, in the address's own words, since the title is not shown. */ + detail: string; + onUnblock: () => void; + onShow: () => void; +}) => ( +
+

{line}

+

{detail}

+
+ + +
+
+); diff --git a/apps/web/app/components/moderation/more.tsx b/apps/web/app/components/moderation/more.tsx new file mode 100644 index 0000000..c691d3f --- /dev/null +++ b/apps/web/app/components/moderation/more.tsx @@ -0,0 +1,101 @@ +import { useEffect, useState, useSyncExternalStore } from "react"; +import { CHROME } from "~/components/chrome"; +import { block, unblock, useBlocked } from "~/lib/blocked"; +import { restoreSession, serverSessionState, sessionState, subscribeSession } from "~/lib/session"; +import { ReportForm } from "./report-form"; +import { SUGGESTION } from "./styles"; + +export type MoreTarget = + | { kind: "document"; pubkey: string; id: string; coordinate: string; identifier: string } + | { kind: "comment"; pubkey: string; id: string } + | { kind: "account"; pubkey: string }; + +type Stage = "closed" | "menu" | "report"; + +/** The same panel a withdrawal and a zap hang off their buttons. */ +const PANEL = + "absolute left-0 top-full z-10 mt-1 w-[min(22rem,calc(100vw-3rem))] space-y-3 rounded-sm border border-rule bg-paper p-3 normal-case tracking-normal shadow-sm"; + +const ROW = `${SUGGESTION} text-left`; + +/** + * Report or block, behind one word, on a document, a comment and an account. + * + * Neither needs a key. Blocking is this browser deciding what it shows, and + * the page or the comment flips to the notice that carries the undo, so the + * menu closes on the click and says nothing more. A report from a reader with + * no key is signed by one made for it; the form says what that is worth. + * + * Nothing is offered on the reader's own words: a report on yourself is a + * mistake and a block on yourself is a bug. + */ +export const More = ({ target }: { target: MoreTarget }) => { + useEffect(restoreSession, []); + const session = useSyncExternalStore(subscribeSession, sessionState, serverSessionState); + const blocked = useBlocked(); + const [stage, setStage] = useState("closed"); + + const me = session.pubkey; + if (me !== null && me === target.pubkey) return null; + + const noun = target.kind; + const accountBlocked = blocked.pubkeys.has(target.pubkey); + const thing = + target.kind === "document" + ? { type: "a" as const, value: target.coordinate, held: blocked.coordinates } + : target.kind === "comment" + ? { type: "e" as const, value: target.id, held: blocked.eventIds } + : null; + const thingBlocked = thing?.held.has(thing.value) === true; + + const toggle = (type: "p" | "e" | "a", value: string, held: boolean) => { + (held ? unblock : block)({ type, value }); + setStage("closed"); + }; + + return ( +
+ + + {stage === "menu" && ( +
+
+ + {thing !== null && ( + + )} + +
+
+ )} + + {stage === "report" && ( +
+ setStage("closed")} /> +
+ )} +
+ ); +}; diff --git a/apps/web/app/components/moderation/report-form.tsx b/apps/web/app/components/moderation/report-form.tsx new file mode 100644 index 0000000..eb03660 --- /dev/null +++ b/apps/web/app/components/moderation/report-form.tsx @@ -0,0 +1,199 @@ +import { buildReport, REPORT_TYPES, type ReportType } from "@openspecs/nostr"; +import { useId, useState } from "react"; +import { RelayReport } from "~/components/relay-results"; +import { block, useBlocked } from "~/lib/blocked"; +import { publishAnonymously, type RelayResult, signAndPublish } from "~/lib/publish"; +import { reportRelays } from "~/lib/relays"; +import type { MoreTarget } from "./more"; +import { FIELD, NOTE, SUGGESTION, WRONG } from "./styles"; + +type Stage = "asking" | "sending" | "sent" | "failed"; + +/** NIP-56's words, said the way a reader would say them. */ +const LABELS: Record = { + spam: "Spam", + illegal: "Illegal content", + nudity: "Nudity or sexual content", + profanity: "Hateful or abusive", + malware: "Malware or a scam", + impersonation: "Impersonation", + other: "Something else", +}; + +const said = (reason: unknown): string => + reason instanceof Error && reason.message.trim() !== "" ? reason.message : "that did not work"; + +/** + * One event, signed and sent wide. The two sentences before the form are the + * whole of what a reader is agreeing to, and they are there because a report + * feels like a private word to a moderator and is nothing of the kind. + * + * With no key, or when asked, the event is signed by a key made for it and + * thrown away. That is the only report a reader who fears an answer can send, + * and it is also one most relays weigh at nothing: both are said. + */ +export const ReportForm = ({ + me, + target, + onClose, +}: { + /** Null when nobody is signed in: the report is then sent from a key made for it. */ + me: string | null; + target: MoreTarget; + onClose: () => void; +}) => { + const group = useId(); + const blocked = useBlocked(); + const [type, setType] = useState(null); + const [words, setWords] = useState(""); + const [withoutKey, setWithoutKey] = useState(false); + const [stage, setStage] = useState("asking"); + const [relays, setRelays] = useState([]); + const [results, setResults] = useState([]); + const [error, setError] = useState(null); + + const noun = target.kind; + const nameless = me === null || withoutKey; + const accountBlocked = blocked.pubkeys.has(target.pubkey); + + const send = async () => { + if (type === null) return; + setStage("sending"); + setResults([]); + setError(null); + + const targets = reportRelays(nameless ? null : me, target.pubkey); + targets.then(setRelays).catch(() => {}); + + const reported = + target.kind === "document" + ? { pubkey: target.pubkey, id: target.id, coordinate: target.coordinate } + : target.kind === "comment" + ? { pubkey: target.pubkey, id: target.id } + : { pubkey: target.pubkey }; + const draft = buildReport(reported, type, words); + const heard = (result: RelayResult) => setResults((answered) => [...answered, result]); + + try { + const report = nameless + ? await publishAnonymously(draft, targets, heard) + : await signAndPublish(draft, targets, heard); + if (report.accepted === 0) { + setStage("failed"); + setError("No relay accepted it. Nothing was published."); + return; + } + setStage("sent"); + } catch (reason) { + setStage("failed"); + setError(said(reason)); + } + }; + + if (stage === "asking") { + return ( + <> +

+ {nameless + ? "Sent without your name. Nobody can tie it to you, and it carries less weight for it." + : "A report is public, and it carries your name."} +

+

+ It goes to the people who run the relays. This site hides nothing on the strength of a + report: blocking is what does that, for you alone. +

+ +
+ What is wrong with this {noun} + {REPORT_TYPES.map((option) => ( + + ))} +
+ +