mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
feat(video): CEO can preview a video authoring task's frames before approving (#608)
A source=video authoring task reaches awaiting_ceo_approval with no MP4
yet — rendering only happens after it completes — so the CEO had nothing
to review. Two CEO-gated routes now serve the request_render preview
frames: GET /video/preview-frames/{task_id} lists them per orientation
(parsed from the self-describing .previews/{task8}/{orientation}/ filenames
rather than the render_preview marker, which only holds the last call's
single orientation), and .../{orientation}/{filename} streams a frame's
PNG behind the existing path-confinement guard. The task-detail Overview
gains a Video preview card — a 9:16/1:1 toggle + prev/next/scrubber frame
stepper with composition id, duration, and a dirty badge — shown for a
video task with preview frames or awaiting CEO approval.
Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ReactNode } from "react";
|
||||
import { TaskStatus, Team, TaskType, type Task } from "@/types";
|
||||
import type { VideoPreviewFrames } from "@/lib/api/video";
|
||||
|
||||
const { getPreviewFrames, getPreviewFrameBlob } = vi.hoisted(() => ({
|
||||
getPreviewFrames: vi.fn<() => Promise<VideoPreviewFrames>>(),
|
||||
getPreviewFrameBlob: vi.fn(
|
||||
async () => new Blob(["fake-png-bytes"], { type: "image/png" }),
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
videoApi: { getPreviewFrames, getPreviewFrameBlob },
|
||||
}));
|
||||
|
||||
import { VideoPreviewCard } from "../video-preview-card";
|
||||
|
||||
function withQueryClient(ui: ReactNode) {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
return <QueryClientProvider client={client}>{ui}</QueryClientProvider>;
|
||||
}
|
||||
|
||||
function buildTask(overrides: Partial<Task> = {}): Task {
|
||||
return {
|
||||
id: "task-1",
|
||||
title: "Video: launch teaser",
|
||||
description: "d",
|
||||
status: TaskStatus.AWAITING_CEO_APPROVAL,
|
||||
team: Team.UX_UI,
|
||||
task_type: TaskType.CODE,
|
||||
acceptance_criteria: [],
|
||||
source: "video",
|
||||
...overrides,
|
||||
} as unknown as Task;
|
||||
}
|
||||
|
||||
const FRAMES: VideoPreviewFrames = {
|
||||
task_id: "task-1",
|
||||
composition_id: "Intro",
|
||||
duration_seconds: 6.4,
|
||||
head_sha: "abc1234",
|
||||
dirty: false,
|
||||
rendered_at: "2026-07-19T12:00:00Z",
|
||||
frames: {
|
||||
vertical: [
|
||||
{ index: 1, file: "frame-01-of-2-at-1.5s.png", timestamp_seconds: 1.5 },
|
||||
{ index: 2, file: "frame-02-of-2-at-4.5s.png", timestamp_seconds: 4.5 },
|
||||
],
|
||||
square: [
|
||||
{ index: 1, file: "frame-01-of-1-at-3.0s.png", timestamp_seconds: 3.0 },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
describe("VideoPreviewCard", () => {
|
||||
beforeEach(() => {
|
||||
getPreviewFrames.mockReset();
|
||||
getPreviewFrameBlob.mockClear();
|
||||
let objectUrlCount = 0;
|
||||
globalThis.URL.createObjectURL = vi.fn(
|
||||
() => `blob:mock-url-${++objectUrlCount}`,
|
||||
);
|
||||
globalThis.URL.revokeObjectURL = vi.fn();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders composition metadata and blob-fetches the first frame of the default cut", async () => {
|
||||
getPreviewFrames.mockResolvedValueOnce(FRAMES);
|
||||
render(withQueryClient(<VideoPreviewCard task={buildTask()} />));
|
||||
|
||||
expect(await screen.findByText("Intro")).toBeInTheDocument();
|
||||
expect(screen.getByText("6.4s clip")).toBeInTheDocument();
|
||||
await waitFor(() =>
|
||||
expect(getPreviewFrameBlob).toHaveBeenCalledWith(
|
||||
"task-1",
|
||||
"vertical",
|
||||
"frame-01-of-2-at-1.5s.png",
|
||||
),
|
||||
);
|
||||
expect(screen.getByText(/Frame 1\/2/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/1\.5s into the clip/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("steps to the next frame within a cut", async () => {
|
||||
getPreviewFrames.mockResolvedValueOnce(FRAMES);
|
||||
render(withQueryClient(<VideoPreviewCard task={buildTask()} />));
|
||||
await screen.findByText("Intro");
|
||||
await waitFor(() => expect(getPreviewFrameBlob).toHaveBeenCalledTimes(1));
|
||||
|
||||
expect(screen.getByLabelText("Previous frame")).toBeDisabled();
|
||||
fireEvent.click(screen.getByLabelText("Next frame"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(getPreviewFrameBlob).toHaveBeenCalledWith(
|
||||
"task-1",
|
||||
"vertical",
|
||||
"frame-02-of-2-at-4.5s.png",
|
||||
),
|
||||
);
|
||||
expect(screen.getByText(/Frame 2\/2/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/4\.5s into the clip/)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Next frame")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("switches cuts via the 9:16/1:1 toggle and re-fetches that orientation's first frame", async () => {
|
||||
getPreviewFrames.mockResolvedValueOnce(FRAMES);
|
||||
render(withQueryClient(<VideoPreviewCard task={buildTask()} />));
|
||||
await screen.findByText("Intro");
|
||||
await waitFor(() =>
|
||||
expect(getPreviewFrameBlob).toHaveBeenCalledWith(
|
||||
"task-1",
|
||||
"vertical",
|
||||
"frame-01-of-2-at-1.5s.png",
|
||||
),
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "1:1" }));
|
||||
await waitFor(() =>
|
||||
expect(getPreviewFrameBlob).toHaveBeenCalledWith(
|
||||
"task-1",
|
||||
"square",
|
||||
"frame-01-of-1-at-3.0s.png",
|
||||
),
|
||||
);
|
||||
expect(screen.getByText(/Frame 1\/1/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("flags an orientation with no rendered frames as missing and disables its toggle", async () => {
|
||||
getPreviewFrames.mockResolvedValueOnce({
|
||||
...FRAMES,
|
||||
frames: { vertical: FRAMES.frames.vertical },
|
||||
});
|
||||
render(withQueryClient(<VideoPreviewCard task={buildTask()} />));
|
||||
await screen.findByText("Intro");
|
||||
|
||||
const squareButton = screen.getByRole("button", { name: /1:1/ });
|
||||
expect(squareButton).toHaveTextContent("(missing)");
|
||||
expect(squareButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it("shows an uncommitted-changes badge when the render was dirty", async () => {
|
||||
getPreviewFrames.mockResolvedValueOnce({ ...FRAMES, dirty: true });
|
||||
render(withQueryClient(<VideoPreviewCard task={buildTask()} />));
|
||||
expect(await screen.findByText("uncommitted changes")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a muted empty state when nothing has been rendered yet (404)", async () => {
|
||||
getPreviewFrames.mockRejectedValueOnce(new Error("not found"));
|
||||
render(withQueryClient(<VideoPreviewCard task={buildTask()} />));
|
||||
expect(
|
||||
await screen.findByText(/No render preview yet/),
|
||||
).toBeInTheDocument();
|
||||
expect(getPreviewFrameBlob).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { Task } from "@/types";
|
||||
import { Task, TaskStatus } from "@/types";
|
||||
import { TaskDescription } from "./task-description";
|
||||
import { AcceptanceCriteria } from "./acceptance-criteria";
|
||||
import { SubtasksList } from "./subtasks-list";
|
||||
import { WorkSessionCard } from "./work-session-card";
|
||||
import { VideoPreviewCard } from "./video-preview-card";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Markdown } from "@/components/ui/markdown";
|
||||
@@ -50,6 +51,15 @@ export function TabOverview({ task }: TabOverviewProps) {
|
||||
{/* Work Session / Git Info */}
|
||||
<WorkSessionCard taskId={task.id} />
|
||||
|
||||
{/* Video preview (source=video authoring tasks with a captured
|
||||
render_preview, or already awaiting the CEO's approval — the state
|
||||
with nothing else to show) */}
|
||||
{task.source === "video" &&
|
||||
(!!task.orchestration_markers?.render_preview ||
|
||||
task.status === TaskStatus.AWAITING_CEO_APPROVAL) && (
|
||||
<VideoPreviewCard task={task} />
|
||||
)}
|
||||
|
||||
{/* Quick Context (for resumption) */}
|
||||
{task.quick_context && (
|
||||
<Card>
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { videoApi } from "@/lib/api";
|
||||
import type { PreviewFrame, VideoCut } from "@/lib/api/video";
|
||||
import { Task } from "@/types";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { HelpTip } from "@/components/ui/help-tip";
|
||||
import { formatAbsoluteTimestamp } from "@/lib/utils";
|
||||
import { ChevronLeft, ChevronRight, Clapperboard } from "lucide-react";
|
||||
|
||||
const CUT_LABELS: Record<VideoCut, string> = {
|
||||
vertical: "9:16",
|
||||
square: "1:1",
|
||||
};
|
||||
|
||||
// One orientation's frame strip: blob-fetches the current frame (same
|
||||
// auth-header workaround as the MP4 CutPlayer) and steps through the rest
|
||||
// with a native range input — no scrubber library needed for N still frames.
|
||||
// The caller remounts this via `key={cut}` on cut change, so `index`/`src`
|
||||
// reset for free — no effect needed to clamp a stale index back to 0.
|
||||
function FrameStepper({
|
||||
taskId,
|
||||
cut,
|
||||
frames,
|
||||
}: {
|
||||
taskId: string;
|
||||
cut: VideoCut;
|
||||
frames: PreviewFrame[];
|
||||
}) {
|
||||
const [index, setIndex] = useState(0);
|
||||
const [src, setSrc] = useState<string | null>(null);
|
||||
const frame: PreviewFrame | undefined = frames[index];
|
||||
|
||||
useEffect(() => {
|
||||
if (!frame) return; // src stays null (its initial value) — nothing to fetch
|
||||
let cancelled = false;
|
||||
let objectUrl: string | null = null;
|
||||
videoApi
|
||||
.getPreviewFrameBlob(taskId, cut, frame.file)
|
||||
.then((blob) => {
|
||||
if (cancelled) return;
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
setSrc(objectUrl);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setSrc(null);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||
};
|
||||
}, [taskId, cut, frame]);
|
||||
|
||||
if (frames.length === 0) {
|
||||
return (
|
||||
<div className="flex h-48 items-center justify-center rounded-md border border-dashed text-sm text-muted-foreground">
|
||||
This cut hasn't rendered a preview yet
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{src ? (
|
||||
<img
|
||||
src={src}
|
||||
alt={`Frame ${frame?.index} of ${cut} cut, at ${frame?.timestamp_seconds}s`}
|
||||
className="mx-auto max-h-96 w-full rounded-md border bg-black object-contain"
|
||||
/>
|
||||
) : (
|
||||
<Skeleton className="mx-auto h-96 w-full rounded-md" />
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="outline"
|
||||
disabled={index === 0}
|
||||
onClick={() => setIndex((i) => Math.max(0, i - 1))}
|
||||
aria-label="Previous frame"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<input
|
||||
type="range"
|
||||
aria-label="Frame scrubber"
|
||||
min={0}
|
||||
max={frames.length - 1}
|
||||
value={index}
|
||||
onChange={(e) => setIndex(Number(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="outline"
|
||||
disabled={index === frames.length - 1}
|
||||
onClick={() => setIndex((i) => Math.min(frames.length - 1, i + 1))}
|
||||
aria-label="Next frame"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
Frame {index + 1}/{frames.length} —{" "}
|
||||
{frame?.timestamp_seconds.toFixed(1)}s into the clip
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// The CEO's only look at a video-authoring task's rendered artifact before
|
||||
// the post-completion render loop produces the real MP4 — awaiting_ceo_
|
||||
// approval otherwise has nothing to watch. Assumes the caller already gated
|
||||
// on task.source === "video"; a task that never called request_render (or
|
||||
// whose frames 404 for any other reason) renders a muted empty state rather
|
||||
// than nothing, since a CEO reviewing a video task with no preview at all is
|
||||
// itself worth surfacing.
|
||||
export function VideoPreviewCard({ task }: { task: Task }) {
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ["video", "preview-frames", task.id],
|
||||
queryFn: () => videoApi.getPreviewFrames(task.id),
|
||||
enabled: !!task.id,
|
||||
retry: false, // a 404 (nothing rendered yet) is an expected outcome, not a transient failure
|
||||
});
|
||||
|
||||
const [selectedCut, setSelectedCut] = useState<VideoCut | null>(null);
|
||||
const availableCuts = (Object.keys(data?.frames ?? {}) as VideoCut[]).filter(
|
||||
(c) => (data?.frames[c]?.length ?? 0) > 0,
|
||||
);
|
||||
const cut =
|
||||
selectedCut && availableCuts.includes(selectedCut)
|
||||
? selectedCut
|
||||
: availableCuts[0];
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<HelpTip label="Preview frames from request_render — extracted before the real MP4 renders, so there's something to look at while this task awaits your approval">
|
||||
<CardTitle className="flex w-fit items-center gap-2 text-lg">
|
||||
<Clapperboard className="h-5 w-5" />
|
||||
Video preview
|
||||
</CardTitle>
|
||||
</HelpTip>
|
||||
{data && (
|
||||
<CardDescription className="flex flex-wrap items-center gap-2">
|
||||
{data.composition_id && (
|
||||
<code className="text-xs">{data.composition_id}</code>
|
||||
)}
|
||||
{data.duration_seconds != null && (
|
||||
<span>{data.duration_seconds.toFixed(1)}s clip</span>
|
||||
)}
|
||||
{data.rendered_at && (
|
||||
<span>rendered {formatAbsoluteTimestamp(data.rendered_at)}</span>
|
||||
)}
|
||||
{data.dirty && (
|
||||
<HelpTip label="The working tree had uncommitted changes when this was rendered — it may not exactly match what's pushed">
|
||||
<Badge variant="outline" className="text-amber-700">
|
||||
uncommitted changes
|
||||
</Badge>
|
||||
</HelpTip>
|
||||
)}
|
||||
</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-96 w-full rounded-md" />
|
||||
) : isError || !data || !cut ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No render preview yet — the developer hasn't called
|
||||
request_render on this task.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-2">
|
||||
{(Object.keys(CUT_LABELS) as VideoCut[]).map((c) => (
|
||||
<Button
|
||||
key={c}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={cut === c ? "default" : "outline"}
|
||||
disabled={!availableCuts.includes(c)}
|
||||
onClick={() => setSelectedCut(c)}
|
||||
>
|
||||
{CUT_LABELS[c]}
|
||||
{!availableCuts.includes(c) && " (missing)"}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<FrameStepper
|
||||
key={cut}
|
||||
taskId={task.id}
|
||||
cut={cut}
|
||||
frames={data.frames[cut] ?? []}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -48,4 +48,6 @@ export type {
|
||||
VideoPostHistoryEntry,
|
||||
VideoRequestResult,
|
||||
TikTokCredentialsStatus,
|
||||
PreviewFrame,
|
||||
VideoPreviewFrames,
|
||||
} from "./video";
|
||||
|
||||
@@ -87,6 +87,29 @@ export interface TikTokCredentialsStatus {
|
||||
has_credentials: boolean;
|
||||
}
|
||||
|
||||
// One extracted request_render preview frame — GET /video/preview-frames/
|
||||
// {task_id} (roboco/api/routes/video.py). index/timestamp decoded server-side
|
||||
// from the sidecar's self-describing filename.
|
||||
export interface PreviewFrame {
|
||||
index: number;
|
||||
file: string;
|
||||
timestamp_seconds: number;
|
||||
}
|
||||
|
||||
// A video-authoring task's request_render preview — the CEO's only look at
|
||||
// the rendered artifact before the post-completion render loop produces the
|
||||
// real MP4 (an awaiting_ceo_approval task otherwise has nothing to watch).
|
||||
// frames keyed by orientation; an absent/empty key was never rendered.
|
||||
export interface VideoPreviewFrames {
|
||||
task_id: string;
|
||||
composition_id: string | null;
|
||||
duration_seconds: number | null;
|
||||
head_sha: string | null;
|
||||
dirty: boolean | null;
|
||||
rendered_at: string | null;
|
||||
frames: Partial<Record<VideoCut, PreviewFrame[]>>;
|
||||
}
|
||||
|
||||
// GET /video/posts/{id}/media (roboco/api/routes/video.py) serves one
|
||||
// rendered MP4 cut; VideoPost.mp4_paths (above) carries the server-side
|
||||
// paths per cut. This builds that route's URL — but a native
|
||||
@@ -112,6 +135,18 @@ export function compositionPreviewUrl(
|
||||
return `${API_URL}/video/preview/${authoringTaskId}/${filePath}`;
|
||||
}
|
||||
|
||||
// GET /video/preview-frames/{task_id}/{orientation}/{filename} streams one
|
||||
// frame's PNG bytes. Same auth-header problem as the MP4 media route (a
|
||||
// plain <img src> GET carries none of axios's headers), so the panel fetches
|
||||
// via videoApi.getPreviewFrameBlob instead — kept for direct-link use.
|
||||
export function previewFrameUrl(
|
||||
taskId: string,
|
||||
cut: VideoCut,
|
||||
file: string,
|
||||
): string {
|
||||
return `${API_URL}/video/preview-frames/${taskId}/${cut}/${encodeURIComponent(file)}`;
|
||||
}
|
||||
|
||||
export const videoApi = {
|
||||
listPosts: async (): Promise<VideoPost[]> => {
|
||||
const { data } = await api.get<VideoPost[]>("/video/posts");
|
||||
@@ -174,6 +209,27 @@ export const videoApi = {
|
||||
rerender: async (authoringTaskId: string): Promise<void> => {
|
||||
await api.post(`/video/pipeline/${authoringTaskId}/rerender`);
|
||||
},
|
||||
// The task's request_render preview frames, per orientation. Callers
|
||||
// treat a 404 (nothing rendered yet) as "no preview" rather than an error.
|
||||
getPreviewFrames: async (taskId: string): Promise<VideoPreviewFrames> => {
|
||||
const { data } = await api.get<VideoPreviewFrames>(
|
||||
`/video/preview-frames/${taskId}`,
|
||||
);
|
||||
return data;
|
||||
},
|
||||
// Fetches one frame's PNG bytes as a Blob (carrying the auth headers a
|
||||
// plain <img src> GET can't) — mirrors getMediaBlob's object-URL pattern.
|
||||
getPreviewFrameBlob: async (
|
||||
taskId: string,
|
||||
cut: VideoCut,
|
||||
file: string,
|
||||
): Promise<Blob> => {
|
||||
const { data } = await api.get<Blob>(
|
||||
`/video/preview-frames/${taskId}/${cut}/${encodeURIComponent(file)}`,
|
||||
{ responseType: "blob" },
|
||||
);
|
||||
return data;
|
||||
},
|
||||
getCredentialsStatus: async (): Promise<TikTokCredentialsStatus> => {
|
||||
const { data } = await api.get<TikTokCredentialsStatus>(
|
||||
"/tiktok/credentials",
|
||||
|
||||
@@ -234,6 +234,9 @@ export interface Task {
|
||||
// MegaTask grouping: set on the umbrella (parent_task_id null) and every
|
||||
// root-subtask of a batch. null on ordinary tasks.
|
||||
batch_id?: string | null;
|
||||
// Origin tag ("manual", "video", "video_post", "roadmap", ...) — gates the
|
||||
// task-detail Video preview section for source=video authoring tasks.
|
||||
source?: string;
|
||||
created_at: string;
|
||||
updated_at: string | null;
|
||||
claimed_at: string | null;
|
||||
@@ -878,12 +881,7 @@ export interface CodeReviewResponse {
|
||||
export interface LearningRecordRequest {
|
||||
content: string;
|
||||
category:
|
||||
| "error_handling"
|
||||
| "performance"
|
||||
| "testing"
|
||||
| "pattern"
|
||||
| "tool"
|
||||
| "other";
|
||||
"error_handling" | "performance" | "testing" | "pattern" | "tool" | "other";
|
||||
team?: "backend" | "frontend" | "ux_ui";
|
||||
shareable?: boolean;
|
||||
tags?: string[];
|
||||
|
||||
Reference in New Issue
Block a user