Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 51 additions & 15 deletions apps/web/app/components/discussion/comment.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 && (
<div className={depth < MAX_DEPTH ? SPINE : "mt-6 space-y-6"}>
{node.replies.map((reply) => (
<CommentThread
key={reply.comment.id}
node={reply}
authors={authors}
responses={responses}
root={root}
me={me}
depth={Math.min(depth + 1, MAX_DEPTH)}
/>
))}
</div>
);

// 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 (
<article id={comment.id} className="relative scroll-mt-24">
<div className="flex gap-3">
<span aria-hidden="true" className="w-6 shrink-0" />
<div className="min-w-0 flex-1">
<p className="flex flex-wrap items-center gap-3 font-mono text-xs text-muted">
<span>
{blocked.pubkeys.has(comment.pubkey)
? "From an account you blocked."
: "A comment you blocked."}
</span>
<button type="button" onClick={() => setShownAnyway(true)} className={CHROME}>
Show
</button>
</p>
{replies}
</div>
</div>
</article>
);
}

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
Expand Down Expand Up @@ -127,6 +176,7 @@ export const CommentThread = ({
Reply
</button>
)}
<More target={{ kind: "comment", pubkey: comment.pubkey, id: comment.id }} />
</div>

{me !== null && replying && (
Expand All @@ -142,21 +192,7 @@ export const CommentThread = ({
</div>
)}

{node.replies.length > 0 && (
<div className={depth < MAX_DEPTH ? SPINE : "mt-6 space-y-6"}>
{node.replies.map((reply) => (
<CommentThread
key={reply.comment.id}
node={reply}
authors={authors}
responses={responses}
root={root}
me={me}
depth={Math.min(depth + 1, MAX_DEPTH)}
/>
))}
</div>
)}
{replies}
</div>
</div>
</article>
Expand Down
32 changes: 32 additions & 0 deletions apps/web/app/components/moderation/blocked-notice.tsx
Original file line number Diff line number Diff line change
@@ -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;
}) => (
<div className="space-y-3 rounded-sm border border-dashed border-rule px-4 py-3">
<p className="font-serif text-[0.9375rem] leading-snug text-muted">{line}</p>
<p className="min-w-0 break-all font-mono text-xs text-muted">{detail}</p>
<div className="flex flex-wrap items-center gap-2">
<button type="button" onClick={onUnblock} className={CHROME}>
Unblock
</button>
<button type="button" onClick={onShow} className={CHROME}>
Show anyway
</button>
</div>
</div>
);
101 changes: 101 additions & 0 deletions apps/web/app/components/moderation/more.tsx
Original file line number Diff line number Diff line change
@@ -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<Stage>("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 (
<div className="relative">
<button
type="button"
onClick={() => setStage(stage === "closed" ? "menu" : "closed")}
aria-expanded={stage !== "closed"}
title="Report or block"
className={CHROME}
>
More
</button>

{stage === "menu" && (
<div className={PANEL}>
<div className="flex flex-col items-start gap-2">
<button type="button" className={ROW} onClick={() => setStage("report")}>
Report this {noun}
</button>
{thing !== null && (
<button
type="button"
className={ROW}
onClick={() => toggle(thing.type, thing.value, thingBlocked)}
>
{thingBlocked ? `Unblock this ${noun}` : `Block this ${noun}`}
</button>
)}
<button
type="button"
className={ROW}
onClick={() => toggle("p", target.pubkey, accountBlocked)}
>
{accountBlocked ? "Unblock this account" : "Block this account"}
</button>
</div>
</div>
)}

{stage === "report" && (
<div className={PANEL}>
<ReportForm me={me} target={target} onClose={() => setStage("closed")} />
</div>
)}
</div>
);
};
Loading