mirror of
https://github.com/block/buzz.git
synced 2026-11-01 06:51:17 +00:00
Add Board discussion and reference replay
Co-authored-by: Larry <loganj+sbot-larry@squareup.com> Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
@@ -59,6 +59,8 @@ import { SentFromThreadLine } from "./SentFromThreadLine";
|
||||
import { WaveMessageAttachment } from "./WaveMessageAttachment";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
||||
|
||||
import { BoardReferenceSet } from "@/features/workstream-board/ui/BoardReferenceSet";
|
||||
|
||||
const DiffMessage = React.lazy(() => import("./DiffMessage"));
|
||||
const DiffMessageExpanded = React.lazy(() => import("./DiffMessageExpanded"));
|
||||
|
||||
@@ -892,6 +894,7 @@ export const MessageRow = React.memo(
|
||||
{headerNode}
|
||||
<div className={bodyContainerClass} data-testid="message-body">
|
||||
{messageBodyNode}
|
||||
<BoardReferenceSet tags={message.tags} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
@@ -902,6 +905,7 @@ export const MessageRow = React.memo(
|
||||
{headerNode}
|
||||
<div className={bodyContainerClass} data-testid="message-body">
|
||||
{messageBodyNode}
|
||||
<BoardReferenceSet tags={message.tags} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -155,3 +155,9 @@ export function resolveBoardReferences(
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function boardReferenceKey(
|
||||
reference: Pick<BoardReference, "kind" | "identity">,
|
||||
): string {
|
||||
return `${reference.kind}:${reference.identity}`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
|
||||
import { parseBoardReferences } from "@/features/workstream-board/lib/boardReferences";
|
||||
|
||||
export const BOARD_REPLAY_STORAGE_KEY = "buzz:board-replay:v1";
|
||||
|
||||
export function BoardReferenceSet({
|
||||
tags,
|
||||
}: {
|
||||
tags?: readonly (readonly string[])[];
|
||||
}) {
|
||||
const references = parseBoardReferences(tags);
|
||||
const { goWorkstreams } = useAppNavigation();
|
||||
if (references.length === 0) return null;
|
||||
return (
|
||||
<button
|
||||
className="mt-2 flex w-full flex-wrap gap-1.5 rounded-lg border bg-muted/40 p-2 text-left"
|
||||
data-testid="message-board-references"
|
||||
onClick={() => {
|
||||
sessionStorage.setItem(
|
||||
BOARD_REPLAY_STORAGE_KEY,
|
||||
JSON.stringify(references),
|
||||
);
|
||||
void goWorkstreams();
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{references.map((reference, index) => (
|
||||
<span
|
||||
className="rounded-full border bg-background px-2 py-1 text-3xs"
|
||||
key={`${reference.kind}:${reference.identity}`}
|
||||
>
|
||||
{index + 1}. {reference.kind}: {reference.snapshot.label}
|
||||
</span>
|
||||
))}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,16 @@ import * as React from "react";
|
||||
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
|
||||
import { useActiveAgentTurnsByChannel } from "@/features/agents/activeAgentTurnsStore";
|
||||
import { useChannelsQuery } from "@/features/channels/hooks";
|
||||
import {
|
||||
useChannelMessagesQuery,
|
||||
useSendMessageMutation,
|
||||
} from "@/features/messages/hooks";
|
||||
import {
|
||||
parseBoardReferences,
|
||||
boardReferenceKey,
|
||||
resolveBoardReferences,
|
||||
type BoardReference,
|
||||
} from "@/features/workstream-board/lib/boardReferences";
|
||||
import { useUsersBatchQuery } from "@/features/profile/hooks";
|
||||
import { filterWorkstreamChannels } from "@/features/workstream-board/lib/discoverWorkstreamChannels";
|
||||
import { getActiveWorkstreamTurns } from "@/features/workstream-board/lib/activeWorkstreamTurns";
|
||||
@@ -11,6 +21,7 @@ import {
|
||||
type WorkstreamWait,
|
||||
} from "@/features/workstream-board/lib/workstreamWaits";
|
||||
import { WorkstreamCard } from "@/features/workstream-board/ui/WorkstreamCard";
|
||||
import { BOARD_REPLAY_STORAGE_KEY } from "@/features/workstream-board/ui/BoardReferenceSet";
|
||||
import { useIdentityQuery } from "@/shared/api/hooks";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { PageHeader } from "@/shared/ui/PageHeader";
|
||||
@@ -36,7 +47,95 @@ export function WorkstreamBoardScreen() {
|
||||
const { goChannel } = useAppNavigation();
|
||||
const identityQuery = useIdentityQuery();
|
||||
const channelsQuery = useChannelsQuery();
|
||||
const [discussionMode, setDiscussionMode] = React.useState(false);
|
||||
const [selectedReferences, setSelectedReferences] = React.useState<
|
||||
BoardReference[]
|
||||
>([]);
|
||||
const [discussionChannelId, setDiscussionChannelId] = React.useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [draft, setDraft] = React.useState("");
|
||||
const [replayReferences, setReplayReferences] = React.useState<
|
||||
BoardReference[]
|
||||
>([]);
|
||||
const [liveReferencesByChannel, setLiveReferencesByChannel] = React.useState<
|
||||
ReadonlyMap<string, readonly BoardReference[]>
|
||||
>(() => new Map());
|
||||
React.useEffect(() => {
|
||||
const stored = sessionStorage.getItem(BOARD_REPLAY_STORAGE_KEY);
|
||||
if (!stored) return;
|
||||
sessionStorage.removeItem(BOARD_REPLAY_STORAGE_KEY);
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(stored);
|
||||
if (Array.isArray(parsed)) {
|
||||
setReplayReferences(
|
||||
parsed.flatMap((value) => {
|
||||
const references = parseBoardReferences([
|
||||
["buzz:board-ref", "1", JSON.stringify(value)],
|
||||
]);
|
||||
return references;
|
||||
}),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Malformed cross-route replay state fails closed.
|
||||
}
|
||||
}, []);
|
||||
const workstreamChannels = filterWorkstreamChannels(channelsQuery.data ?? []);
|
||||
const discussionChannel =
|
||||
workstreamChannels.find((channel) => channel.id === discussionChannelId) ??
|
||||
null;
|
||||
const discussionMessages = useChannelMessagesQuery(discussionChannel);
|
||||
const sendMessage = useSendMessageMutation(
|
||||
discussionChannel,
|
||||
identityQuery.data,
|
||||
);
|
||||
const selectedKeys = React.useMemo(
|
||||
() => new Set(selectedReferences.map(boardReferenceKey)),
|
||||
[selectedReferences],
|
||||
);
|
||||
const replayKeys = React.useMemo(
|
||||
() => new Set(replayReferences.map(boardReferenceKey)),
|
||||
[replayReferences],
|
||||
);
|
||||
const liveReferences = React.useMemo(
|
||||
() =>
|
||||
new Map(
|
||||
[...liveReferencesByChannel.values()]
|
||||
.flat()
|
||||
.map((reference) => [reference.identity, reference]),
|
||||
),
|
||||
[liveReferencesByChannel],
|
||||
);
|
||||
const onReferencesChange = React.useCallback(
|
||||
(channelId: string, references: readonly BoardReference[]) => {
|
||||
setLiveReferencesByChannel((current) => {
|
||||
const previous = current.get(channelId);
|
||||
if (JSON.stringify(previous) === JSON.stringify(references))
|
||||
return current;
|
||||
const next = new Map(current);
|
||||
next.set(channelId, references);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
const toggleReference = React.useCallback((reference: BoardReference) => {
|
||||
setDiscussionChannelId(
|
||||
(current) => current ?? reference.placement.workstreamId,
|
||||
);
|
||||
setSelectedReferences((current) => {
|
||||
if (
|
||||
current.length > 0 &&
|
||||
current[0].placement.workstreamId !== reference.placement.workstreamId
|
||||
)
|
||||
return current;
|
||||
const key = boardReferenceKey(reference);
|
||||
return current.some((item) => boardReferenceKey(item) === key)
|
||||
? current.filter((item) => boardReferenceKey(item) !== key)
|
||||
: [...current, reference];
|
||||
});
|
||||
}, []);
|
||||
const activeTurnsByChannel = useActiveAgentTurnsByChannel();
|
||||
const activeWorkstreamTurns = React.useMemo(
|
||||
() => getActiveWorkstreamTurns(workstreamChannels, activeTurnsByChannel),
|
||||
@@ -105,6 +204,162 @@ export function WorkstreamBoardScreen() {
|
||||
description="Live canvases for active workstream channels."
|
||||
title="Workstream Board"
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (
|
||||
discussionMode &&
|
||||
selectedReferences.length > 0 &&
|
||||
!window.confirm("Discard the unsent reference tray?")
|
||||
)
|
||||
return;
|
||||
setDiscussionMode(!discussionMode);
|
||||
setSelectedReferences([]);
|
||||
setReplayReferences([]);
|
||||
setDiscussionChannelId(null);
|
||||
}}
|
||||
variant={discussionMode ? "secondary" : "default"}
|
||||
>
|
||||
{discussionMode ? "Exit discussion" : "Discuss Board"}
|
||||
</Button>
|
||||
{replayReferences.length > 0 ? (
|
||||
<Button onClick={() => setReplayReferences([])} variant="outline">
|
||||
End replay
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{discussionMode ? (
|
||||
<section
|
||||
aria-label="Board discussion"
|
||||
className="space-y-3 rounded-xl border bg-muted/30 p-4"
|
||||
data-testid="board-discussion"
|
||||
>
|
||||
<p className="text-sm font-semibold">
|
||||
Reference tray ({selectedReferences.length})
|
||||
</p>
|
||||
<ol className="space-y-1">
|
||||
{selectedReferences.map((reference, index) => (
|
||||
<li
|
||||
className="flex items-center gap-2 text-xs"
|
||||
key={boardReferenceKey(reference)}
|
||||
>
|
||||
<span>
|
||||
{index + 1}. {reference.kind}: {reference.snapshot.label}
|
||||
</span>
|
||||
<button
|
||||
className="underline"
|
||||
onClick={() => toggleReference(reference)}
|
||||
type="button"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
<textarea
|
||||
aria-label="Discussion message"
|
||||
className="min-h-20 w-full rounded-md border bg-background p-2 text-sm"
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
placeholder="Discuss these Board references…"
|
||||
value={draft}
|
||||
/>
|
||||
<Button
|
||||
disabled={
|
||||
!discussionChannel ||
|
||||
(!draft.trim() && selectedReferences.length === 0) ||
|
||||
sendMessage.isPending
|
||||
}
|
||||
onClick={async () => {
|
||||
if (!discussionChannel) return;
|
||||
await sendMessage.mutateAsync({
|
||||
content: draft || "Board references",
|
||||
boardReferences: selectedReferences,
|
||||
});
|
||||
setDraft("");
|
||||
setSelectedReferences([]);
|
||||
}}
|
||||
>
|
||||
Send
|
||||
</Button>
|
||||
{discussionChannel ? (
|
||||
<div className="space-y-2 border-t pt-3">
|
||||
<p className="text-xs font-semibold">
|
||||
#{discussionChannel.name} messages
|
||||
</p>
|
||||
{(discussionMessages.data ?? [])
|
||||
.filter(
|
||||
(event) => parseBoardReferences(event.tags).length > 0,
|
||||
)
|
||||
.map((event) => {
|
||||
const refs = parseBoardReferences(event.tags);
|
||||
return (
|
||||
<button
|
||||
className="block w-full rounded-md border bg-background p-2 text-left text-xs"
|
||||
key={event.id}
|
||||
onClick={() => setReplayReferences(refs)}
|
||||
type="button"
|
||||
>
|
||||
<span className="block">{event.content}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{refs
|
||||
.map((reference) => reference.snapshot.label)
|
||||
.join(" · ")}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Select an object in one workstream to choose the discussion.
|
||||
</p>
|
||||
)}
|
||||
{replayReferences.length > 0
|
||||
? (() => {
|
||||
const resolved = resolveBoardReferences(
|
||||
replayReferences,
|
||||
liveReferences,
|
||||
);
|
||||
return (
|
||||
<div className="text-xs">
|
||||
<strong>Replay:</strong>{" "}
|
||||
{
|
||||
resolved.filter((item) => item.state === "live")
|
||||
.length
|
||||
}{" "}
|
||||
live ·{" "}
|
||||
{
|
||||
resolved.filter((item) => item.state === "changed")
|
||||
.length
|
||||
}{" "}
|
||||
changed ·{" "}
|
||||
{
|
||||
resolved.filter((item) => item.state === "historical")
|
||||
.length
|
||||
}{" "}
|
||||
historical
|
||||
{resolved.some(
|
||||
(item) => item.state === "historical",
|
||||
) ? (
|
||||
<div className="mt-2 rounded-md border border-dashed p-2">
|
||||
<strong>Historical references</strong>
|
||||
{resolved
|
||||
.filter((item) => item.state === "historical")
|
||||
.map((item) => (
|
||||
<p key={boardReferenceKey(item.reference)}>
|
||||
{item.reference.snapshot.label} · formerly{" "}
|
||||
{item.reference.placement.workstreamLabel} /{" "}
|
||||
{item.reference.placement.sectionLabel}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
: null}
|
||||
</section>
|
||||
) : null}
|
||||
{channelsQuery.isLoading ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Loading workstreams…
|
||||
@@ -131,6 +386,13 @@ export function WorkstreamBoardScreen() {
|
||||
<WorkstreamCard
|
||||
activeWorking={activeTurnsByChannelId.get(channel.id)}
|
||||
channel={channel}
|
||||
discussionMode={discussionMode}
|
||||
onToggleReference={toggleReference}
|
||||
onReferencesChange={(references) =>
|
||||
onReferencesChange(channel.id, references)
|
||||
}
|
||||
replayReferenceKeys={replayKeys}
|
||||
selectedReferenceKeys={selectedKeys}
|
||||
currentOwnerPubkey={identityQuery.data?.pubkey}
|
||||
key={channel.id}
|
||||
onSelect={(channelId) => void goChannel(channelId)}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import * as React from "react";
|
||||
import { Hash } from "lucide-react";
|
||||
import type { BoardReference } from "@/features/workstream-board/lib/boardReferences";
|
||||
import { boardReferenceKey } from "@/features/workstream-board/lib/boardReferences";
|
||||
|
||||
import type { ActiveChannelTurnSummary } from "@/features/agents/activeAgentTurnsStore";
|
||||
import {
|
||||
@@ -12,6 +14,7 @@ import {
|
||||
useWorkstreamPullRequestStatuses,
|
||||
} from "@/features/workstream-board/lib/workstreamPullRequestStatus";
|
||||
import {
|
||||
parseWorkstreamWaitMessageLink,
|
||||
waitsForCard,
|
||||
type WorkstreamWait,
|
||||
} from "@/features/workstream-board/lib/workstreamWaits";
|
||||
@@ -34,15 +37,25 @@ type WorkstreamCardProps = {
|
||||
createdAt: string | null | undefined,
|
||||
) => void;
|
||||
onSelect: (channelId: string) => void;
|
||||
discussionMode?: boolean;
|
||||
selectedReferenceKeys?: ReadonlySet<string>;
|
||||
replayReferenceKeys?: ReadonlySet<string>;
|
||||
onToggleReference?: (reference: BoardReference) => void;
|
||||
onReferencesChange?: (references: readonly BoardReference[]) => void;
|
||||
};
|
||||
|
||||
export function WorkstreamCard({
|
||||
activeWorking,
|
||||
channel,
|
||||
discussionMode = false,
|
||||
currentOwnerPubkey,
|
||||
onSelect,
|
||||
onWaitsChange,
|
||||
onToggleReference,
|
||||
onReferencesChange,
|
||||
profiles,
|
||||
replayReferenceKeys,
|
||||
selectedReferenceKeys,
|
||||
}: WorkstreamCardProps) {
|
||||
const canvasQuery = useCanvasQuery(channel.id);
|
||||
const detailsQuery = useChannelDetailsQuery(channel.id);
|
||||
@@ -87,18 +100,102 @@ export function WorkstreamCard({
|
||||
normalizePubkey(wait.actor.pubkey) ===
|
||||
normalizePubkey(currentOwnerPubkey),
|
||||
);
|
||||
const discussionReferences = React.useMemo<BoardReference[]>(() => {
|
||||
if (viewModel.status !== "ready") return [];
|
||||
const placement = {
|
||||
workstreamId: channel.id,
|
||||
workstreamLabel: channel.name,
|
||||
sectionId: "workstream",
|
||||
sectionLabel: "Workstream",
|
||||
};
|
||||
const result: BoardReference[] = [
|
||||
{
|
||||
kind: "workstream",
|
||||
identity: channel.id,
|
||||
snapshot: { label: channel.name, detail: viewModel.card.synopsis },
|
||||
placement,
|
||||
},
|
||||
];
|
||||
for (const reference of references) {
|
||||
result.push({
|
||||
kind: "pull-request",
|
||||
identity: reference.identity,
|
||||
snapshot: { label: reference.rawUrl },
|
||||
placement: {
|
||||
...placement,
|
||||
sectionId: "pull-requests",
|
||||
sectionLabel: "Pull requests",
|
||||
},
|
||||
});
|
||||
}
|
||||
for (const assignee of viewModel.card.assignees) {
|
||||
const identity = normalizePubkey(assignee.pubkey);
|
||||
if (/^[0-9a-f]{64}$/.test(identity))
|
||||
result.push({
|
||||
kind: "agent",
|
||||
identity,
|
||||
snapshot: { label: assignee.name },
|
||||
placement: {
|
||||
...placement,
|
||||
sectionId: "agents",
|
||||
sectionLabel: "Agents",
|
||||
},
|
||||
});
|
||||
}
|
||||
result.push({
|
||||
kind: "agent-group",
|
||||
identity: `orchestrator:${normalizePubkey(viewModel.card.orchestrator.pubkey)}`,
|
||||
snapshot: {
|
||||
label: `${viewModel.card.orchestrator.name}'s team`,
|
||||
detail: "Orchestrator group",
|
||||
},
|
||||
placement: { ...placement, sectionId: "agents", sectionLabel: "Agents" },
|
||||
});
|
||||
for (const wait of waits) {
|
||||
const destination = parseWorkstreamWaitMessageLink(wait.message);
|
||||
if (!destination) continue;
|
||||
result.push({
|
||||
kind: "thread-blocker",
|
||||
identity: wait.key,
|
||||
destination: {
|
||||
channelId: destination.channelId,
|
||||
messageId: destination.messageId,
|
||||
...(destination.threadRootId
|
||||
? { threadRootId: destination.threadRootId }
|
||||
: {}),
|
||||
},
|
||||
snapshot: { label: wait.reason, detail: wait.actor.name },
|
||||
placement: {
|
||||
...placement,
|
||||
sectionId: "blockers",
|
||||
sectionLabel: "Blockers",
|
||||
},
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}, [channel.id, channel.name, references, viewModel, waits]);
|
||||
React.useEffect(() => {
|
||||
onReferencesChange?.(discussionReferences);
|
||||
}, [discussionReferences, onReferencesChange]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group relative w-full self-start overflow-hidden rounded-2xl border border-border/70 bg-muted/50 p-5 text-left text-foreground shadow-xs transition-all hover:-translate-y-0.5 hover:border-border hover:bg-muted/65 hover:shadow-md",
|
||||
waitingOnPrincipal && "border-t-4 border-t-amber-400/70",
|
||||
discussionReferences.some((reference) =>
|
||||
replayReferenceKeys?.has(boardReferenceKey(reference)),
|
||||
) && "ring-2 ring-primary ring-offset-2 ring-offset-background",
|
||||
)}
|
||||
data-testid={`workstream-card-${channel.id}`}
|
||||
>
|
||||
<button
|
||||
className="absolute inset-0 z-0 rounded-2xl focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
|
||||
onClick={() => onSelect(channel.id)}
|
||||
onClick={() =>
|
||||
discussionMode
|
||||
? onToggleReference?.(discussionReferences[0])
|
||||
: onSelect(channel.id)
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<span className="sr-only">Open #{channel.name}</span>
|
||||
@@ -111,12 +208,50 @@ export function WorkstreamCard({
|
||||
<div className="pointer-events-none relative z-10 flex flex-col">
|
||||
<button
|
||||
className="pointer-events-auto flex max-w-[calc(100%-4rem)] items-center gap-1.5 text-xs font-semibold text-muted-foreground hover:text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={() => onSelect(channel.id)}
|
||||
onClick={() =>
|
||||
discussionMode
|
||||
? onToggleReference?.(discussionReferences[0])
|
||||
: onSelect(channel.id)
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<Hash className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate">{channel.name}</span>
|
||||
</button>
|
||||
{discussionMode ? (
|
||||
<div
|
||||
className="pointer-events-auto mt-3 flex flex-wrap gap-1.5"
|
||||
data-testid={`board-reference-options-${channel.id}`}
|
||||
>
|
||||
{discussionReferences.map((reference) => {
|
||||
const key = boardReferenceKey(reference);
|
||||
const selected = selectedReferenceKeys?.has(key) ?? false;
|
||||
const replayed = replayReferenceKeys?.has(key) ?? false;
|
||||
return (
|
||||
<button
|
||||
aria-pressed={selected}
|
||||
className={cn(
|
||||
"rounded-full border px-2 py-1 text-3xs",
|
||||
selected && "border-primary bg-primary/15",
|
||||
replayed && "ring-2 ring-primary",
|
||||
)}
|
||||
key={key}
|
||||
onClick={() => onToggleReference?.(reference)}
|
||||
type="button"
|
||||
>
|
||||
{reference.kind}: {reference.snapshot.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
className="rounded-full border px-2 py-1 text-3xs font-semibold"
|
||||
onClick={() => onSelect(channel.id)}
|
||||
type="button"
|
||||
>
|
||||
Open
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{viewModel.status === "ready" ? (
|
||||
<>
|
||||
<p className="mt-3 line-clamp-3 text-sm leading-relaxed text-foreground">
|
||||
|
||||
Reference in New Issue
Block a user