refactor(web): extract project drag finalize
This commit is contained in:
@@ -0,0 +1,120 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { finalizeProjectDrag } from "./timelineProjectDragFinalize.js";
|
||||||
|
|
||||||
|
const BASE_DRAG = {
|
||||||
|
isDragging: true,
|
||||||
|
projectId: "project-1",
|
||||||
|
currentStartDate: new Date("2025-01-12T00:00:00.000Z"),
|
||||||
|
currentEndDate: new Date("2025-01-22T00:00:00.000Z"),
|
||||||
|
daysDelta: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("timelineProjectDragFinalize", () => {
|
||||||
|
it("returns null without clearing when the drag is already inactive", () => {
|
||||||
|
const updatePosition = vi.fn();
|
||||||
|
const clearSession = vi.fn();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
finalizeProjectDrag({
|
||||||
|
clientX: 20,
|
||||||
|
dragRef: { current: { ...BASE_DRAG, isDragging: false } },
|
||||||
|
previewRef: { current: null },
|
||||||
|
updatePosition,
|
||||||
|
clearSession,
|
||||||
|
mutate: vi.fn(),
|
||||||
|
mutateAsync: vi.fn(),
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
|
||||||
|
expect(updatePosition).toHaveBeenCalledWith(20);
|
||||||
|
expect(clearSession).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves preview, clears the session, and fires mutate for real drags", () => {
|
||||||
|
const clearSession = vi.fn();
|
||||||
|
const mutate = vi.fn();
|
||||||
|
const cancelAnimationFrameSpy = vi.fn();
|
||||||
|
vi.stubGlobal("cancelAnimationFrame", cancelAnimationFrameSpy);
|
||||||
|
const previewRef = {
|
||||||
|
current: {
|
||||||
|
mode: "move" as const,
|
||||||
|
cellWidth: 48,
|
||||||
|
targets: [],
|
||||||
|
pointerDeltaX: 0,
|
||||||
|
daysDelta: 0,
|
||||||
|
frame: 7,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
expect(
|
||||||
|
finalizeProjectDrag({
|
||||||
|
clientX: 24,
|
||||||
|
dragRef: { current: BASE_DRAG },
|
||||||
|
previewRef,
|
||||||
|
updatePosition: vi.fn(),
|
||||||
|
clearSession,
|
||||||
|
mutate,
|
||||||
|
mutateAsync: vi.fn(),
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
|
||||||
|
expect(cancelAnimationFrameSpy).toHaveBeenCalledWith(7);
|
||||||
|
expect(previewRef.current.frame).toBeNull();
|
||||||
|
expect(clearSession).toHaveBeenCalledOnce();
|
||||||
|
expect(mutate).toHaveBeenCalledWith({
|
||||||
|
projectId: "project-1",
|
||||||
|
newStartDate: new Date("2025-01-12T00:00:00.000Z"),
|
||||||
|
newEndDate: new Date("2025-01-22T00:00:00.000Z"),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears but suppresses mutation for no-op drags", () => {
|
||||||
|
const clearSession = vi.fn();
|
||||||
|
const mutate = vi.fn();
|
||||||
|
const mutateAsync = vi.fn();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
finalizeProjectDrag({
|
||||||
|
clientX: 30,
|
||||||
|
dragRef: { current: { ...BASE_DRAG, daysDelta: 0 } },
|
||||||
|
previewRef: { current: null },
|
||||||
|
updatePosition: vi.fn(),
|
||||||
|
clearSession,
|
||||||
|
mutate,
|
||||||
|
mutateAsync,
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
|
||||||
|
expect(clearSession).toHaveBeenCalledOnce();
|
||||||
|
expect(mutate).not.toHaveBeenCalled();
|
||||||
|
expect(mutateAsync).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the async mutation promise when requested", async () => {
|
||||||
|
const result = { ok: true };
|
||||||
|
const mutateAsync = vi.fn().mockResolvedValue(result);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
finalizeProjectDrag({
|
||||||
|
clientX: 36,
|
||||||
|
mode: "mutateAsync",
|
||||||
|
dragRef: { current: BASE_DRAG },
|
||||||
|
previewRef: { current: null },
|
||||||
|
updatePosition: vi.fn(),
|
||||||
|
clearSession: vi.fn(),
|
||||||
|
mutate: vi.fn(),
|
||||||
|
mutateAsync,
|
||||||
|
}),
|
||||||
|
).resolves.toEqual(result);
|
||||||
|
|
||||||
|
expect(mutateAsync).toHaveBeenCalledWith({
|
||||||
|
projectId: "project-1",
|
||||||
|
newStartDate: new Date("2025-01-12T00:00:00.000Z"),
|
||||||
|
newEndDate: new Date("2025-01-22T00:00:00.000Z"),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { buildProjectShiftMutationInput } from "./timelineProjectDrag.js";
|
||||||
|
import { preserveLivePreview, type LivePreviewSession } from "./timelineLivePreview.js";
|
||||||
|
|
||||||
|
type MutableCurrent<T> = { current: T };
|
||||||
|
type ProjectDragFinalizeLike = Parameters<typeof buildProjectShiftMutationInput>[0] & { isDragging: boolean };
|
||||||
|
|
||||||
|
export function finalizeProjectDrag({
|
||||||
|
clientX,
|
||||||
|
mode = "mutate",
|
||||||
|
dragRef,
|
||||||
|
previewRef,
|
||||||
|
updatePosition,
|
||||||
|
clearSession,
|
||||||
|
mutate,
|
||||||
|
mutateAsync,
|
||||||
|
}: {
|
||||||
|
clientX: number;
|
||||||
|
mode?: "mutate" | "mutateAsync";
|
||||||
|
dragRef: MutableCurrent<ProjectDragFinalizeLike>;
|
||||||
|
previewRef: MutableCurrent<LivePreviewSession | null>;
|
||||||
|
updatePosition: (clientX: number) => void;
|
||||||
|
clearSession: () => void;
|
||||||
|
mutate: (input: { projectId: string; newStartDate: Date; newEndDate: Date }) => void;
|
||||||
|
mutateAsync: (input: { projectId: string; newStartDate: Date; newEndDate: Date }) => Promise<unknown>;
|
||||||
|
}) {
|
||||||
|
updatePosition(clientX);
|
||||||
|
const finalDrag = dragRef.current;
|
||||||
|
if (!finalDrag.isDragging) return null;
|
||||||
|
|
||||||
|
const mutationInput = buildProjectShiftMutationInput(finalDrag);
|
||||||
|
if (finalDrag.daysDelta !== 0) preserveLivePreview(previewRef.current);
|
||||||
|
clearSession();
|
||||||
|
|
||||||
|
if (!mutationInput) return null;
|
||||||
|
if (mode === "mutateAsync") return mutateAsync(mutationInput);
|
||||||
|
mutate(mutationInput);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -24,7 +24,8 @@ import { finalizeAllocationReleaseEffects } from "./timelineAllocationReleaseEff
|
|||||||
import { cleanupTimelineDragState } from "./timelineDragCleanup.js";
|
import { cleanupTimelineDragState } from "./timelineDragCleanup.js";
|
||||||
import { resolveAllocationDragPosition, resolveProjectDragPosition } from "./timelineDragPosition.js";
|
import { resolveAllocationDragPosition, resolveProjectDragPosition } from "./timelineDragPosition.js";
|
||||||
import { attachDocumentMouseDrag } from "./timelineDocumentDrag.js";
|
import { attachDocumentMouseDrag } from "./timelineDocumentDrag.js";
|
||||||
import { buildProjectShiftMutationInput, createProjectDragState } from "./timelineProjectDrag.js";
|
import { finalizeProjectDrag } from "./timelineProjectDragFinalize.js";
|
||||||
|
import { createProjectDragState } from "./timelineProjectDrag.js";
|
||||||
import { beginProjectDragSession } from "./timelineProjectDragSession.js";
|
import { beginProjectDragSession } from "./timelineProjectDragSession.js";
|
||||||
import {
|
import {
|
||||||
forwardCanvasTouchEnd,
|
forwardCanvasTouchEnd,
|
||||||
@@ -408,28 +409,18 @@ export function useTimelineDrag({
|
|||||||
mutateAsync: (...args: unknown[]) => Promise<unknown>;
|
mutateAsync: (...args: unknown[]) => Promise<unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const finalizeProjectDrag = useCallback(
|
const finalizeActiveProjectDrag = useCallback(
|
||||||
(clientX: number, mode: "mutate" | "mutateAsync" = "mutate") => {
|
(clientX: number, mode: "mutate" | "mutateAsync" = "mutate") =>
|
||||||
updateProjectDragPosition(clientX);
|
finalizeProjectDrag({
|
||||||
const finalDrag = dragStateRef.current;
|
clientX,
|
||||||
if (!finalDrag.isDragging) return null;
|
mode,
|
||||||
|
dragRef: dragStateRef,
|
||||||
const mutationInput = buildProjectShiftMutationInput(finalDrag);
|
previewRef: projectPreviewRef,
|
||||||
|
updatePosition: updateProjectDragPosition,
|
||||||
if (finalDrag.daysDelta !== 0) {
|
clearSession: clearProjectDragSession,
|
||||||
preserveLivePreview(projectPreviewRef.current);
|
mutate: applyShiftMutation.mutate,
|
||||||
}
|
mutateAsync: applyShiftMutation.mutateAsync,
|
||||||
|
}),
|
||||||
clearProjectDragSession();
|
|
||||||
|
|
||||||
if (!mutationInput) return null;
|
|
||||||
if (mode === "mutateAsync") {
|
|
||||||
return applyShiftMutation.mutateAsync(mutationInput);
|
|
||||||
}
|
|
||||||
|
|
||||||
applyShiftMutation.mutate(mutationInput);
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
[applyShiftMutation, clearProjectDragSession, updateProjectDragPosition],
|
[applyShiftMutation, clearProjectDragSession, updateProjectDragPosition],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -516,11 +507,11 @@ export function useTimelineDrag({
|
|||||||
attachDrag: attachDocumentMouseDrag,
|
attachDrag: attachDocumentMouseDrag,
|
||||||
updatePosition: updateProjectDragPosition,
|
updatePosition: updateProjectDragPosition,
|
||||||
finalize: (clientX) => {
|
finalize: (clientX) => {
|
||||||
void finalizeProjectDrag(clientX);
|
void finalizeActiveProjectDrag(clientX);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[finalizeProjectDrag, setProjectPreviewTargets, updateProjectDragPosition],
|
[finalizeActiveProjectDrag, setProjectPreviewTargets, updateProjectDragPosition],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Legacy — kept for backward compat (triggers project shift from allocation block)
|
// Legacy — kept for backward compat (triggers project shift from allocation block)
|
||||||
@@ -717,7 +708,7 @@ export function useTimelineDrag({
|
|||||||
const drag = dragStateRef.current;
|
const drag = dragStateRef.current;
|
||||||
if (drag.isDragging) {
|
if (drag.isDragging) {
|
||||||
try {
|
try {
|
||||||
await finalizeProjectDrag(e.clientX, "mutateAsync");
|
await finalizeActiveProjectDrag(e.clientX, "mutateAsync");
|
||||||
} catch {
|
} catch {
|
||||||
// Validation error — revert visually
|
// Validation error — revert visually
|
||||||
}
|
}
|
||||||
@@ -734,7 +725,7 @@ export function useTimelineDrag({
|
|||||||
setRangeState(INITIAL_RANGE_STATE);
|
setRangeState(INITIAL_RANGE_STATE);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[finalizeProjectDrag, onRangeSelected],
|
[finalizeActiveProjectDrag, onRangeSelected],
|
||||||
);
|
);
|
||||||
|
|
||||||
const onCanvasMouseLeave = useCallback(() => {
|
const onCanvasMouseLeave = useCallback(() => {
|
||||||
|
|||||||
@@ -412,6 +412,25 @@ export const rules = [
|
|||||||
],
|
],
|
||||||
forbidden: [],
|
forbidden: [],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
file: "apps/web/src/hooks/timelineProjectDragFinalize.ts",
|
||||||
|
maxLines: 60,
|
||||||
|
required: [
|
||||||
|
{
|
||||||
|
pattern: /\bexport function finalizeProjectDrag\b/,
|
||||||
|
message: "timeline project drag finalize helpers must keep completion flow centralized",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pattern: /from "\.\/timelineProjectDrag\.js"/,
|
||||||
|
message: "timeline project drag finalize helpers must keep mutation gating delegated to the project drag helper module",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pattern: /from "\.\/timelineLivePreview\.js"/,
|
||||||
|
message: "timeline project drag finalize helpers must keep preview preservation delegated to the live preview helper module",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
forbidden: [],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
file: "apps/web/src/hooks/timelineProjectDragSession.ts",
|
file: "apps/web/src/hooks/timelineProjectDragSession.ts",
|
||||||
maxLines: 70,
|
maxLines: 70,
|
||||||
@@ -484,7 +503,11 @@ export const rules = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
pattern: /from "\.\/timelineProjectDrag\.js"/,
|
pattern: /from "\.\/timelineProjectDrag\.js"/,
|
||||||
message: "timeline drag must keep project drag bootstrap and mutation gating delegated to the extracted helper module",
|
message: "timeline drag must keep project drag bootstrap delegated to the extracted helper module",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pattern: /from "\.\/timelineProjectDragFinalize\.js"/,
|
||||||
|
message: "timeline drag must keep project drag completion delegated to the extracted helper module",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
pattern: /from "\.\/timelineProjectDragSession\.js"/,
|
pattern: /from "\.\/timelineProjectDragSession\.js"/,
|
||||||
@@ -544,6 +567,10 @@ export const rules = [
|
|||||||
pattern: /\bfunction (?:createProjectDragState|buildProjectShiftMutationInput)\b/,
|
pattern: /\bfunction (?:createProjectDragState|buildProjectShiftMutationInput)\b/,
|
||||||
message: "timeline drag must not re-inline extracted project drag helper implementations",
|
message: "timeline drag must not re-inline extracted project drag helper implementations",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
pattern: /\bconst mutationInput = buildProjectShiftMutationInput\(finalDrag\)\b[\s\S]*applyShiftMutation\.(?:mutate|mutateAsync)\(/,
|
||||||
|
message: "timeline drag must not re-inline extracted project drag finalize flow",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -94,6 +94,9 @@ describe("architecture guardrails", () => {
|
|||||||
(rule) => rule.file === "apps/web/src/hooks/timelineAllocationReleaseEffects.ts",
|
(rule) => rule.file === "apps/web/src/hooks/timelineAllocationReleaseEffects.ts",
|
||||||
);
|
);
|
||||||
const projectDragRule = rules.find((rule) => rule.file === "apps/web/src/hooks/timelineProjectDrag.ts");
|
const projectDragRule = rules.find((rule) => rule.file === "apps/web/src/hooks/timelineProjectDrag.ts");
|
||||||
|
const projectDragFinalizeRule = rules.find(
|
||||||
|
(rule) => rule.file === "apps/web/src/hooks/timelineProjectDragFinalize.ts",
|
||||||
|
);
|
||||||
const projectDragSessionRule = rules.find((rule) => rule.file === "apps/web/src/hooks/timelineProjectDragSession.ts");
|
const projectDragSessionRule = rules.find((rule) => rule.file === "apps/web/src/hooks/timelineProjectDragSession.ts");
|
||||||
|
|
||||||
assert.ok(dragRule);
|
assert.ok(dragRule);
|
||||||
@@ -117,6 +120,7 @@ describe("architecture guardrails", () => {
|
|||||||
assert.ok(allocationDragSessionRule);
|
assert.ok(allocationDragSessionRule);
|
||||||
assert.ok(allocationReleaseEffectsRule);
|
assert.ok(allocationReleaseEffectsRule);
|
||||||
assert.ok(projectDragRule);
|
assert.ok(projectDragRule);
|
||||||
|
assert.ok(projectDragFinalizeRule);
|
||||||
assert.ok(projectDragSessionRule);
|
assert.ok(projectDragSessionRule);
|
||||||
|
|
||||||
assert.deepEqual(evaluateRule(dragRule, "function clearLivePreview() {}\n"), [
|
assert.deepEqual(evaluateRule(dragRule, "function clearLivePreview() {}\n"), [
|
||||||
@@ -134,7 +138,8 @@ describe("architecture guardrails", () => {
|
|||||||
"apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep allocation drag bootstrap delegated to the extracted helper module",
|
"apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep allocation drag bootstrap delegated to the extracted helper module",
|
||||||
"apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep allocation drag document session wiring delegated to the extracted helper module",
|
"apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep allocation drag document session wiring delegated to the extracted helper module",
|
||||||
"apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep allocation release side effects delegated to the extracted helper module",
|
"apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep allocation release side effects delegated to the extracted helper module",
|
||||||
"apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep project drag bootstrap and mutation gating delegated to the extracted helper module",
|
"apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep project drag bootstrap delegated to the extracted helper module",
|
||||||
|
"apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep project drag completion delegated to the extracted helper module",
|
||||||
"apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep project drag document session wiring delegated to the extracted helper module",
|
"apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep project drag document session wiring delegated to the extracted helper module",
|
||||||
"apps/web/src/hooks/useTimelineDrag.ts: forbidden pattern matched: timeline drag must not re-inline live preview helper implementations",
|
"apps/web/src/hooks/useTimelineDrag.ts: forbidden pattern matched: timeline drag must not re-inline live preview helper implementations",
|
||||||
]);
|
]);
|
||||||
@@ -233,6 +238,12 @@ describe("architecture guardrails", () => {
|
|||||||
"apps/web/src/hooks/timelineProjectDrag.ts: missing guardrail anchor: timeline project drag helpers must keep no-op project-shift mutation gating centralized",
|
"apps/web/src/hooks/timelineProjectDrag.ts: missing guardrail anchor: timeline project drag helpers must keep no-op project-shift mutation gating centralized",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
assert.deepEqual(evaluateRule(projectDragFinalizeRule, ""), [
|
||||||
|
"apps/web/src/hooks/timelineProjectDragFinalize.ts: missing guardrail anchor: timeline project drag finalize helpers must keep completion flow centralized",
|
||||||
|
"apps/web/src/hooks/timelineProjectDragFinalize.ts: missing guardrail anchor: timeline project drag finalize helpers must keep mutation gating delegated to the project drag helper module",
|
||||||
|
"apps/web/src/hooks/timelineProjectDragFinalize.ts: missing guardrail anchor: timeline project drag finalize helpers must keep preview preservation delegated to the live preview helper module",
|
||||||
|
]);
|
||||||
|
|
||||||
assert.deepEqual(evaluateRule(projectDragSessionRule, ""), [
|
assert.deepEqual(evaluateRule(projectDragSessionRule, ""), [
|
||||||
"apps/web/src/hooks/timelineProjectDragSession.ts: missing guardrail anchor: timeline project drag session helpers must keep document drag lifecycle centralized",
|
"apps/web/src/hooks/timelineProjectDragSession.ts: missing guardrail anchor: timeline project drag session helpers must keep document drag lifecycle centralized",
|
||||||
]);
|
]);
|
||||||
@@ -257,7 +268,8 @@ describe("architecture guardrails", () => {
|
|||||||
"apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep allocation drag bootstrap delegated to the extracted helper module",
|
"apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep allocation drag bootstrap delegated to the extracted helper module",
|
||||||
"apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep allocation drag document session wiring delegated to the extracted helper module",
|
"apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep allocation drag document session wiring delegated to the extracted helper module",
|
||||||
"apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep allocation release side effects delegated to the extracted helper module",
|
"apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep allocation release side effects delegated to the extracted helper module",
|
||||||
"apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep project drag bootstrap and mutation gating delegated to the extracted helper module",
|
"apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep project drag bootstrap delegated to the extracted helper module",
|
||||||
|
"apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep project drag completion delegated to the extracted helper module",
|
||||||
"apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep project drag document session wiring delegated to the extracted helper module",
|
"apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep project drag document session wiring delegated to the extracted helper module",
|
||||||
"apps/web/src/hooks/useTimelineDrag.ts: forbidden pattern matched: timeline drag must not re-inline extracted touch event forwarding dependencies",
|
"apps/web/src/hooks/useTimelineDrag.ts: forbidden pattern matched: timeline drag must not re-inline extracted touch event forwarding dependencies",
|
||||||
],
|
],
|
||||||
|
|||||||
Reference in New Issue
Block a user