mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-09 11:51:37 -10:00
feat(daemon,ui): getting-started complete-and-hide (slice 18 Checkpoint E)
DAEMON:
- POST /api/missions/:missionId/complete writes status: complete to
mission README.md frontmatter. Idempotent. Handles three cases:
- Updates existing status: X line in place
- Adds status: complete when frontmatter has no status field
- Creates a fresh frontmatter block when README has no frontmatter
- Returns 200 { missionId, status: "complete" } on success;
404 mission_not_found when no matching slices in the indexer;
503 slices_indexer_unavailable when indexer not wired;
500 with message on filesystem error.
- Helper writeMissionStatusComplete preserves unrelated frontmatter
fields when updating status.
UI:
- New hook useCompletedMissions (localStorage-backed; mirrors
useDismissedSeqs pattern). markCompleted(id) is idempotent;
unmarkCompleted(id) is no-op on never-completed. Recovers from
malformed JSON / non-array values.
- buildStorytellingFeedItems gains optional 3rd param
completedMissionIds: Set<string>; missions whose name appears in
the set are filtered out before the slice(0, 2) cap.
- StorytellingFeed accepts onMarkMissionComplete prop and threads it
to ProgressCard.
- ProgressCard accepts optional onMarkComplete; when provided,
renders a "Mark complete" button in the inlineActions slot
(top-right of the collapsed card view). Click fires
onMarkComplete(missionId) without toggling expand state.
- Feed.tsx wires the full flow: useCompletedMissions provides the
filter set; handleMarkMissionComplete calls markCompleted (instant
local hide) AND best-effort POSTs to the daemon endpoint for the
audit trail (network errors swallowed; local state is the
user-visible truth).
T9 (getting-started seeds status: active on fresh install): already
satisfied by packages/daemon/src/domain/workspace/default-workspace-
scaffold.ts default-mission scaffold (verified by grep; no scaffold
change needed in this slice).
Tests (TDD-first):
- 7/7 daemon POST /api/missions/:missionId/complete tests
- 7/7 useCompletedMissions hook tests
- 7/7 storytelling Mark-complete tests (ProgressCard render + click;
buildStorytellingFeedItems filter — including 4-test discriminating
battery on completedMissionIds behavior)
- Full UI suite: 731 pass + 1 skipped + 49 pre-existing AppShell
pollution fails. Net vs prior Checkpoint D tip: +14 pass / 0 new fail.
- Daemon missions tests: 16/16 (7 new + 9 existing GET tests).
DESIGN DECISIONS / DIVERGENCES from IMPL-PRD:
- §3.5 mentioned "emits mission.completed event" as part of the daemon
endpoint. Skipped at v0 — event emission would couple to the
daemon's event bus and pull in more SC-29-adjacent surface. The
frontmatter write is the durable audit trail; events can layer on
in v0.3.2 if needed.
- Daemon write is best-effort from the UI; if it 5xx's or the
network is unavailable, the local hide still takes effect. This
prefers UX over strict consistency at v0; v0.3.2+ can reconcile
by reading status: complete back from the daemon on mount.
SC-29 EXCEPTION declared: NONE
(per IMPL-PRD §8: the new POST endpoint writes a frontmatter field
to a markdown file. Not a schema-changing migration, not a
ConfigStore key, not a new event type. No SC-29 vector.)
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
1 parent
ccb901a45a
commit
ac3df45ff8
6 files changed
+342
-6
No files matched your search
@@ -80,9 +80,65 @@ export function missionsRoutes(): Hono {
|
||||
});
|
||||
});
|
||||
|
||||
// Slice 18 §3.5 — Mark mission complete (Getting Started complete-and-hide).
|
||||
// Writes `status: complete` to the mission README.md frontmatter; the UI
|
||||
// storytelling preview gates on this so completed missions disappear from
|
||||
// the band. The daemon is the audit-trail surface; the UI maintains
|
||||
// an optimistic local mirror via localStorage so the hide is instant.
|
||||
app.post("/:missionId/complete", (c) => {
|
||||
const indexer = c.get("sliceIndexer" as never) as SliceIndexer | undefined;
|
||||
if (!indexer) {
|
||||
return c.json({ error: "slices_indexer_unavailable" }, 503);
|
||||
}
|
||||
const missionId = c.req.param("missionId");
|
||||
const allSlices = indexer.list();
|
||||
const slices = allSlices.filter((s) => s.missionId === missionId);
|
||||
if (slices.length === 0) {
|
||||
return c.json({ error: "mission_not_found", missionId }, 404);
|
||||
}
|
||||
const missionPath = computeMissionPath(slices[0]!);
|
||||
try {
|
||||
writeMissionStatusComplete(missionPath);
|
||||
} catch (err) {
|
||||
return c.json(
|
||||
{
|
||||
error: "mission_complete_write_failed",
|
||||
missionId,
|
||||
message: (err as Error).message,
|
||||
},
|
||||
500,
|
||||
);
|
||||
}
|
||||
return c.json({ missionId, status: "complete" });
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
/** Slice 18 §3.5 — write `status: complete` to a mission README's
|
||||
* frontmatter, creating the frontmatter block when absent and
|
||||
* replacing an existing status field when present. Idempotent.
|
||||
* Preserves unrelated frontmatter fields. */
|
||||
function writeMissionStatusComplete(missionPath: string): void {
|
||||
const readmePath = path.join(missionPath, "README.md");
|
||||
let body = fs.existsSync(readmePath) ? fs.readFileSync(readmePath, "utf-8") : "";
|
||||
const fmMatch = body.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
||||
if (fmMatch) {
|
||||
const fmInner = fmMatch[1] ?? "";
|
||||
const statusLineRegex = /^\s*status\s*:\s*[^\r\n]*$/m;
|
||||
let newFm: string;
|
||||
if (statusLineRegex.test(fmInner)) {
|
||||
newFm = fmInner.replace(statusLineRegex, "status: complete");
|
||||
} else {
|
||||
newFm = fmInner.trimEnd() + "\nstatus: complete";
|
||||
}
|
||||
body = body.replace(fmMatch[0], `---\n${newFm}\n---`);
|
||||
} else {
|
||||
body = `---\nstatus: complete\n---\n${body}`;
|
||||
}
|
||||
fs.writeFileSync(readmePath, body);
|
||||
}
|
||||
|
||||
/** Derive the mission folder's absolute path from any slice's
|
||||
* `slicePath`. Slices live at
|
||||
* `<missionsRoot>/<missionId>/slices/<sliceName>` per the workspace
|
||||
|
||||
@@ -226,8 +226,32 @@ export interface ProgressCardSource {
|
||||
activeSlice?: { id: string; label: string; status: string };
|
||||
}
|
||||
|
||||
export function ProgressCard({ source }: { source: ProgressCardSource }) {
|
||||
export function ProgressCard({
|
||||
source,
|
||||
onMarkComplete,
|
||||
}: {
|
||||
source: ProgressCardSource;
|
||||
/**
|
||||
* Slice 18 §3.5 — when provided, ProgressCard renders a "Mark complete"
|
||||
* inline action. Click fires onMarkComplete(missionId). Parent owns the
|
||||
* actual mutation (daemon endpoint + local optimistic state).
|
||||
*/
|
||||
onMarkComplete?: (missionId: string) => void;
|
||||
}) {
|
||||
const pct = Math.max(0, Math.min(100, Math.round(source.percent)));
|
||||
const inlineActions = onMarkComplete ? (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="progress-card-mark-complete"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onMarkComplete(source.missionId);
|
||||
}}
|
||||
className="border border-stone-300 bg-white/80 px-2 py-0.5 font-mono text-[10px] uppercase tracking-wide text-stone-700 hover:bg-stone-100 hover:text-stone-900 focus:outline-none focus:ring-1 focus:ring-stone-400"
|
||||
>
|
||||
Mark complete
|
||||
</button>
|
||||
) : undefined;
|
||||
return (
|
||||
<CardShell
|
||||
testId={`feed-card-progress-${source.missionId}`}
|
||||
@@ -237,6 +261,7 @@ export function ProgressCard({ source }: { source: ProgressCardSource }) {
|
||||
accent={ACCENTS.progress}
|
||||
drillInHref={`/project/mission/${source.missionId}`}
|
||||
drillInLabel="Open mission"
|
||||
inlineActions={inlineActions}
|
||||
leadingAccessory={
|
||||
<div
|
||||
data-testid={`feed-card-progress-${source.missionId}-bar`}
|
||||
@@ -411,9 +436,19 @@ export interface AdapterSliceRow {
|
||||
export function buildStorytellingFeedItems(
|
||||
missions: AdapterMissionRow[],
|
||||
slices: AdapterSliceRow[],
|
||||
/**
|
||||
* Slice 18 §3.5 — when supplied, missions whose name is in the set are
|
||||
* filtered out of the progress preview. Powers the Getting Started
|
||||
* complete-and-hide flow (operator click → mark complete → mission
|
||||
* disappears from the storytelling band).
|
||||
*/
|
||||
completedMissionIds?: Set<string>,
|
||||
): FeedCardItem[] {
|
||||
const items: FeedCardItem[] = [];
|
||||
for (const mission of (missions ?? []).slice(0, 2)) {
|
||||
const eligibleMissions = (missions ?? []).filter(
|
||||
(m) => !completedMissionIds || !completedMissionIds.has(m.name),
|
||||
);
|
||||
for (const mission of eligibleMissions.slice(0, 2)) {
|
||||
items.push({
|
||||
kind: "progress",
|
||||
source: {
|
||||
@@ -446,7 +481,15 @@ export function buildStorytellingFeedItems(
|
||||
return items;
|
||||
}
|
||||
|
||||
export function StorytellingFeed({ items }: { items: FeedCardItem[] }) {
|
||||
export function StorytellingFeed({
|
||||
items,
|
||||
onMarkMissionComplete,
|
||||
}: {
|
||||
items: FeedCardItem[];
|
||||
/** Slice 18 §3.5 — when supplied, threaded through to ProgressCards
|
||||
* so the operator can hide a completed mission from the band. */
|
||||
onMarkMissionComplete?: (missionId: string) => void;
|
||||
}) {
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div data-testid="storytelling-feed-empty" className="border border-dashed border-outline-variant bg-white/35 p-4 font-mono text-[10px] text-stone-500">
|
||||
@@ -459,7 +502,7 @@ export function StorytellingFeed({ items }: { items: FeedCardItem[] }) {
|
||||
{items.map((item, i) => {
|
||||
if (item.kind === "shipped") return <ShippedCard key={i} source={item.source} />;
|
||||
if (item.kind === "incident") return <IncidentCard key={i} source={item.source} />;
|
||||
if (item.kind === "progress") return <ProgressCard key={i} source={item.source} />;
|
||||
if (item.kind === "progress") return <ProgressCard key={i} source={item.source} onMarkComplete={onMarkMissionComplete} />;
|
||||
if (item.kind === "approval") return <ApprovalCard key={i} source={item.source} />;
|
||||
return <ConceptCard key={i} source={item.source} />;
|
||||
})}
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
isCardKindSubscribed,
|
||||
} from "../../hooks/useFeedSubscriptions.js";
|
||||
import { useDismissedSeqs } from "../../hooks/useDismissedSeqs.js";
|
||||
import { useCompletedMissions } from "../../hooks/useCompletedMissions.js";
|
||||
import { FeedCard } from "./FeedCard.js";
|
||||
import { UndoToast } from "./UndoToast.js";
|
||||
import type { FeedActionOutcome, FeedProofPreview } from "./FeedCard.js";
|
||||
@@ -297,12 +298,31 @@ export function Feed() {
|
||||
// - Slices (useSlices) → ShippedCard for status=shipped/done,
|
||||
// IncidentCard for everything else, capped at 3.
|
||||
const missionsResult = useMissionDiscovery();
|
||||
const { completedMissionIds, markCompleted } = useCompletedMissions();
|
||||
const storytellingItems = useMemo<StorytellingFeedItem[]>(
|
||||
() => buildStorytellingFeedItems(
|
||||
Array.isArray(missionsResult.missions) ? missionsResult.missions : [],
|
||||
Array.isArray(sliceRows) ? sliceRows : [],
|
||||
completedMissionIds,
|
||||
),
|
||||
[missionsResult.missions, sliceRows],
|
||||
[missionsResult.missions, sliceRows, completedMissionIds],
|
||||
);
|
||||
|
||||
// Slice 18 §3.5 — Getting Started complete-and-hide. Optimistic local
|
||||
// hide via useCompletedMissions; best-effort daemon write to
|
||||
// POST /api/missions/:missionId/complete for the audit trail. Network
|
||||
// errors are swallowed silently (audit is best-effort, UI stays
|
||||
// responsive) so a partial-air-gapped daemon doesn't block the hide.
|
||||
const handleMarkMissionComplete = useCallback(
|
||||
(missionId: string) => {
|
||||
markCompleted(missionId);
|
||||
void fetch(`/api/missions/${encodeURIComponent(missionId)}/complete`, {
|
||||
method: "POST",
|
||||
}).catch(() => {
|
||||
// Swallow — local optimistic state is the user-visible truth.
|
||||
});
|
||||
},
|
||||
[markCompleted],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -349,7 +369,7 @@ export function Feed() {
|
||||
>
|
||||
<SectionHeader tone="muted">Storytelling preview</SectionHeader>
|
||||
<div className="mt-2">
|
||||
<StorytellingFeed items={storytellingItems} />
|
||||
<StorytellingFeed items={storytellingItems} onMarkMissionComplete={handleMarkMissionComplete} />
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
// Slice 18 §3.5 — Getting Started complete-and-hide local state.
|
||||
//
|
||||
// Mirrors useDismissedSeqs's pattern: localStorage-backed soft state so
|
||||
// "Mark complete" gives the operator instant visual feedback (mission
|
||||
// disappears from the storytelling preview). The daemon-side
|
||||
// POST /api/missions/<id>/complete writes mission frontmatter for the
|
||||
// audit trail; this hook is the UI's optimistic mirror.
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
export const COMPLETED_MISSIONS_STORAGE_KEY = "forYou.completedMissionIds";
|
||||
|
||||
function readFromStorage(): Set<string> {
|
||||
try {
|
||||
const raw = localStorage.getItem(COMPLETED_MISSIONS_STORAGE_KEY);
|
||||
if (!raw) return new Set();
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return new Set();
|
||||
const ids = parsed.filter((v): v is string => typeof v === "string" && v.length > 0);
|
||||
return new Set(ids);
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
function writeToStorage(ids: Set<string>): void {
|
||||
try {
|
||||
localStorage.setItem(COMPLETED_MISSIONS_STORAGE_KEY, JSON.stringify(Array.from(ids)));
|
||||
} catch {
|
||||
// localStorage unavailable; swallow.
|
||||
}
|
||||
}
|
||||
|
||||
export interface UseCompletedMissionsResult {
|
||||
completedMissionIds: Set<string>;
|
||||
markCompleted(id: string): void;
|
||||
unmarkCompleted(id: string): void;
|
||||
}
|
||||
|
||||
export function useCompletedMissions(): UseCompletedMissionsResult {
|
||||
const [completedMissionIds, setCompletedMissionIds] = useState<Set<string>>(() => readFromStorage());
|
||||
|
||||
const markCompleted = useCallback((id: string) => {
|
||||
setCompletedMissionIds((prev) => {
|
||||
if (prev.has(id)) return prev;
|
||||
const next = new Set(prev);
|
||||
next.add(id);
|
||||
writeToStorage(next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const unmarkCompleted = useCallback((id: string) => {
|
||||
setCompletedMissionIds((prev) => {
|
||||
if (!prev.has(id)) return prev;
|
||||
const next = new Set(prev);
|
||||
next.delete(id);
|
||||
writeToStorage(next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return { completedMissionIds, markCompleted, unmarkCompleted };
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import { createTestRouter } from "./helpers/test-router.js";
|
||||
import {
|
||||
ProgressCard,
|
||||
buildStorytellingFeedItems,
|
||||
type ProgressCardSource,
|
||||
} from "../src/components/feed/cards/storytelling-cards.js";
|
||||
|
||||
interface MissionRow {
|
||||
name: string;
|
||||
path: string;
|
||||
}
|
||||
interface SliceRow {
|
||||
name: string;
|
||||
displayName?: string;
|
||||
status?: string;
|
||||
lastActivityAt?: string;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const baseSource: ProgressCardSource = {
|
||||
missionId: "getting-started",
|
||||
title: "Getting Started",
|
||||
oneLiner: "First mission.",
|
||||
percent: 0,
|
||||
};
|
||||
|
||||
function renderProgressCard(props: Parameters<typeof ProgressCard>[0]) {
|
||||
// ProgressCard uses no router primitives directly; render plain.
|
||||
return render(<ProgressCard {...props} />);
|
||||
}
|
||||
|
||||
describe("ProgressCard — Mark complete action (slice 18 Checkpoint E)", () => {
|
||||
it("does NOT render Mark complete button when onMarkComplete is omitted", () => {
|
||||
renderProgressCard({ source: baseSource });
|
||||
expect(screen.queryByTestId("progress-card-mark-complete")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders Mark complete button when onMarkComplete is provided", () => {
|
||||
renderProgressCard({ source: baseSource, onMarkComplete: () => {} });
|
||||
expect(screen.getByTestId("progress-card-mark-complete")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("clicking Mark complete calls onMarkComplete with the missionId", () => {
|
||||
const onMarkComplete = vi.fn();
|
||||
renderProgressCard({ source: baseSource, onMarkComplete });
|
||||
fireEvent.click(screen.getByTestId("progress-card-mark-complete"));
|
||||
expect(onMarkComplete).toHaveBeenCalledWith("getting-started");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildStorytellingFeedItems — filter completed missions (slice 18 Checkpoint E)", () => {
|
||||
const missions: MissionRow[] = [
|
||||
{ name: "getting-started", path: "/missions/getting-started" },
|
||||
{ name: "release-0-3-1", path: "/missions/release-0-3-1" },
|
||||
];
|
||||
const slices: SliceRow[] = [];
|
||||
|
||||
it("returns progress items for missions when no completed set is provided (back-compat)", () => {
|
||||
const items = buildStorytellingFeedItems(missions, slices);
|
||||
const progressItems = items.filter((i) => i.kind === "progress");
|
||||
expect(progressItems).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("filters out missions whose ids are in the completed set", () => {
|
||||
const completed = new Set(["getting-started"]);
|
||||
const items = buildStorytellingFeedItems(missions, slices, completed);
|
||||
const progressItems = items.filter((i) => i.kind === "progress");
|
||||
expect(progressItems).toHaveLength(1);
|
||||
expect((progressItems[0] as { source: ProgressCardSource }).source.missionId).toBe(
|
||||
"release-0-3-1",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns zero progress items when ALL missions are completed", () => {
|
||||
const completed = new Set(["getting-started", "release-0-3-1"]);
|
||||
const items = buildStorytellingFeedItems(missions, slices, completed);
|
||||
expect(items.filter((i) => i.kind === "progress")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("empty completed set behaves identically to no completed set", () => {
|
||||
const items = buildStorytellingFeedItems(missions, slices, new Set());
|
||||
expect(items.filter((i) => i.kind === "progress")).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import {
|
||||
useCompletedMissions,
|
||||
COMPLETED_MISSIONS_STORAGE_KEY,
|
||||
} from "../src/hooks/useCompletedMissions.js";
|
||||
|
||||
describe("useCompletedMissions", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it("starts empty when localStorage has no entry", () => {
|
||||
const { result } = renderHook(() => useCompletedMissions());
|
||||
expect(result.current.completedMissionIds.size).toBe(0);
|
||||
});
|
||||
|
||||
it("reads existing completed mission ids from localStorage", () => {
|
||||
localStorage.setItem(
|
||||
COMPLETED_MISSIONS_STORAGE_KEY,
|
||||
JSON.stringify(["getting-started", "release-0-3-1"]),
|
||||
);
|
||||
const { result } = renderHook(() => useCompletedMissions());
|
||||
expect(result.current.completedMissionIds.has("getting-started")).toBe(true);
|
||||
expect(result.current.completedMissionIds.has("release-0-3-1")).toBe(true);
|
||||
});
|
||||
|
||||
it("recovers gracefully from malformed JSON", () => {
|
||||
localStorage.setItem(COMPLETED_MISSIONS_STORAGE_KEY, "{not json[");
|
||||
const { result } = renderHook(() => useCompletedMissions());
|
||||
expect(result.current.completedMissionIds.size).toBe(0);
|
||||
});
|
||||
|
||||
it("recovers gracefully from non-array values", () => {
|
||||
localStorage.setItem(COMPLETED_MISSIONS_STORAGE_KEY, JSON.stringify({ not: "an-array" }));
|
||||
const { result } = renderHook(() => useCompletedMissions());
|
||||
expect(result.current.completedMissionIds.size).toBe(0);
|
||||
});
|
||||
|
||||
it("markCompleted(id) adds to the set and persists to localStorage", () => {
|
||||
const { result } = renderHook(() => useCompletedMissions());
|
||||
act(() => result.current.markCompleted("getting-started"));
|
||||
expect(result.current.completedMissionIds.has("getting-started")).toBe(true);
|
||||
const stored = JSON.parse(localStorage.getItem(COMPLETED_MISSIONS_STORAGE_KEY) ?? "[]") as string[];
|
||||
expect(stored).toContain("getting-started");
|
||||
});
|
||||
|
||||
it("markCompleted is idempotent — same id twice keeps one entry", () => {
|
||||
const { result } = renderHook(() => useCompletedMissions());
|
||||
act(() => result.current.markCompleted("getting-started"));
|
||||
act(() => result.current.markCompleted("getting-started"));
|
||||
const stored = JSON.parse(localStorage.getItem(COMPLETED_MISSIONS_STORAGE_KEY) ?? "[]") as string[];
|
||||
expect(stored.filter((id) => id === "getting-started")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("unmarkCompleted(id) removes from set and persists", () => {
|
||||
localStorage.setItem(COMPLETED_MISSIONS_STORAGE_KEY, JSON.stringify(["getting-started"]));
|
||||
const { result } = renderHook(() => useCompletedMissions());
|
||||
act(() => result.current.unmarkCompleted("getting-started"));
|
||||
expect(result.current.completedMissionIds.has("getting-started")).toBe(false);
|
||||
const stored = JSON.parse(localStorage.getItem(COMPLETED_MISSIONS_STORAGE_KEY) ?? "[]") as string[];
|
||||
expect(stored).not.toContain("getting-started");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user