Refactor inline comment architecture across plan/files/diff and fix diff overlay rendering (#461)
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
import React from 'react';
|
||||
import { InlineCommentCard } from './InlineCommentCard';
|
||||
import { InlineCommentInput } from './InlineCommentInput';
|
||||
import type { InlineCommentDraft } from '@/stores/useInlineCommentDraftStore';
|
||||
import type { BlockWidgetDef } from '@/components/ui/CodeMirrorEditor';
|
||||
|
||||
type LineRange = {
|
||||
start: number;
|
||||
end: number;
|
||||
side?: 'additions' | 'deletions';
|
||||
};
|
||||
|
||||
interface CodeMirrorCommentWidgetsOptions {
|
||||
drafts: InlineCommentDraft[];
|
||||
editingDraftId: string | null;
|
||||
commentText: string;
|
||||
selection: LineRange | null;
|
||||
isDragging: boolean;
|
||||
fileLabel: string;
|
||||
newWidgetId: string;
|
||||
mapDraftToRange: (draft: InlineCommentDraft) => LineRange;
|
||||
onSave: (text: string, range?: LineRange) => void;
|
||||
onCancel: () => void;
|
||||
onEdit: (draft: InlineCommentDraft) => void;
|
||||
onDelete: (draft: InlineCommentDraft) => void;
|
||||
}
|
||||
|
||||
export function buildCodeMirrorCommentWidgets(options: CodeMirrorCommentWidgetsOptions): BlockWidgetDef[] {
|
||||
const {
|
||||
drafts,
|
||||
editingDraftId,
|
||||
commentText,
|
||||
selection,
|
||||
isDragging,
|
||||
fileLabel,
|
||||
newWidgetId,
|
||||
mapDraftToRange,
|
||||
onSave,
|
||||
onCancel,
|
||||
onEdit,
|
||||
onDelete,
|
||||
} = options;
|
||||
|
||||
const widgets: BlockWidgetDef[] = [];
|
||||
|
||||
for (const draft of drafts) {
|
||||
const draftRange = mapDraftToRange(draft);
|
||||
if (draft.id === editingDraftId) {
|
||||
widgets.push({
|
||||
afterLine: draftRange.end,
|
||||
id: `edit-${draft.id}`,
|
||||
content: (
|
||||
<InlineCommentInput
|
||||
key={`edit-${draft.id}`}
|
||||
initialText={commentText}
|
||||
fileLabel={fileLabel}
|
||||
lineRange={draftRange}
|
||||
isEditing={true}
|
||||
onSave={onSave}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
widgets.push({
|
||||
afterLine: draftRange.end,
|
||||
id: `card-${draft.id}`,
|
||||
content: (
|
||||
<InlineCommentCard
|
||||
key={`card-${draft.id}`}
|
||||
draft={draft}
|
||||
onEdit={() => onEdit(draft)}
|
||||
onDelete={() => onDelete(draft)}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (selection && !editingDraftId && !isDragging) {
|
||||
const normalizedSelection = {
|
||||
...selection,
|
||||
start: Math.min(selection.start, selection.end),
|
||||
end: Math.max(selection.start, selection.end),
|
||||
};
|
||||
|
||||
widgets.push({
|
||||
afterLine: normalizedSelection.end,
|
||||
id: newWidgetId,
|
||||
content: (
|
||||
<InlineCommentInput
|
||||
key={newWidgetId}
|
||||
initialText={commentText}
|
||||
fileLabel={fileLabel}
|
||||
lineRange={normalizedSelection}
|
||||
isEditing={false}
|
||||
onSave={onSave}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return widgets;
|
||||
}
|
||||
@@ -17,6 +17,7 @@ interface InlineCommentCardProps {
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
className?: string;
|
||||
maxWidth?: number;
|
||||
}
|
||||
|
||||
export function InlineCommentCard({
|
||||
@@ -24,6 +25,7 @@ export function InlineCommentCard({
|
||||
onEdit,
|
||||
onDelete,
|
||||
className,
|
||||
maxWidth,
|
||||
}: InlineCommentCardProps) {
|
||||
const themeContext = useOptionalThemeSystem();
|
||||
const currentTheme = themeContext?.currentTheme;
|
||||
@@ -42,6 +44,7 @@ export function InlineCommentCard({
|
||||
style={{
|
||||
backgroundColor: currentTheme?.colors?.surface?.elevated,
|
||||
borderColor: currentTheme?.colors?.interactive?.border,
|
||||
maxWidth: maxWidth ? `${Math.max(200, Math.floor(maxWidth))}px` : undefined,
|
||||
}}
|
||||
data-comment-card="true"
|
||||
>
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface InlineCommentInputProps {
|
||||
lineRange?: { start: number; end: number; side?: 'additions' | 'deletions' };
|
||||
isEditing?: boolean;
|
||||
className?: string;
|
||||
maxWidth?: number;
|
||||
}
|
||||
|
||||
export function InlineCommentInput({
|
||||
@@ -23,6 +24,7 @@ export function InlineCommentInput({
|
||||
lineRange,
|
||||
isEditing = false,
|
||||
className,
|
||||
maxWidth,
|
||||
}: InlineCommentInputProps) {
|
||||
const themeContext = useOptionalThemeSystem();
|
||||
const currentTheme = themeContext?.currentTheme;
|
||||
@@ -118,6 +120,7 @@ export function InlineCommentInput({
|
||||
style={{
|
||||
backgroundColor: currentTheme?.colors?.surface?.elevated,
|
||||
borderColor: currentTheme?.colors?.interactive?.border,
|
||||
maxWidth: maxWidth ? `${Math.max(200, Math.floor(maxWidth))}px` : undefined,
|
||||
}}
|
||||
data-comment-input="true"
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
import React from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import type { SelectedLineRange } from '@pierre/diffs';
|
||||
import type { InlineCommentDraft } from '@/stores/useInlineCommentDraftStore';
|
||||
import { InlineCommentCard } from './InlineCommentCard';
|
||||
import { InlineCommentInput } from './InlineCommentInput';
|
||||
import { toPierreAnnotationId } from './PierreDiffCommentUtils';
|
||||
|
||||
interface PierreDiffCommentOverlaysProps {
|
||||
diffRootRef: React.RefObject<HTMLDivElement | null>;
|
||||
drafts: InlineCommentDraft[];
|
||||
selection: SelectedLineRange | null;
|
||||
editingDraftId: string | null;
|
||||
commentText: string;
|
||||
fileLabel: string;
|
||||
onSave: (text: string, range?: SelectedLineRange) => void;
|
||||
onCancel: () => void;
|
||||
onEdit: (draft: InlineCommentDraft) => void;
|
||||
onDelete: (draft: InlineCommentDraft) => void;
|
||||
}
|
||||
|
||||
function parseCssWidth(value: string): number | null {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
if (/^-?\d+(\.\d+)?$/.test(trimmed)) {
|
||||
const parsed = Number.parseFloat(trimmed);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
if (trimmed.endsWith('px')) {
|
||||
const parsed = Number.parseFloat(trimmed.slice(0, -2));
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function clampMaxWidth(value: number | null | undefined): number | undefined {
|
||||
if (!value || value <= 0) return undefined;
|
||||
return Math.max(200, Math.floor(value));
|
||||
}
|
||||
|
||||
export function PierreDiffCommentOverlays(props: PierreDiffCommentOverlaysProps) {
|
||||
const {
|
||||
diffRootRef,
|
||||
drafts,
|
||||
selection,
|
||||
editingDraftId,
|
||||
commentText,
|
||||
fileLabel,
|
||||
onSave,
|
||||
onCancel,
|
||||
onEdit,
|
||||
onDelete,
|
||||
} = props;
|
||||
|
||||
const [retryTick, setRetryTick] = React.useState(0);
|
||||
const [fallbackMaxWidth, setFallbackMaxWidth] = React.useState<number | null>(null);
|
||||
|
||||
const selectionAnnotationId = React.useMemo(() => {
|
||||
if (!selection || editingDraftId) return null;
|
||||
return toPierreAnnotationId({ type: 'new', selection });
|
||||
}, [editingDraftId, selection]);
|
||||
|
||||
const expectedTargetIds = React.useMemo(() => {
|
||||
const ids = drafts.map((draft) => toPierreAnnotationId({ type: draft.id === editingDraftId ? 'edit' : 'saved', draft }));
|
||||
if (selectionAnnotationId) {
|
||||
ids.push(selectionAnnotationId);
|
||||
}
|
||||
return ids;
|
||||
}, [drafts, editingDraftId, selectionAnnotationId]);
|
||||
|
||||
const resolveTarget = React.useCallback((annotationId: string): HTMLElement | null => {
|
||||
const wrapper = diffRootRef.current;
|
||||
if (!wrapper) return null;
|
||||
|
||||
const host = wrapper.querySelector('diffs-container');
|
||||
if (!(host instanceof HTMLElement)) return null;
|
||||
|
||||
const lightDomTarget = host.querySelector(`[data-annotation-id="${annotationId}"]`);
|
||||
if (lightDomTarget instanceof HTMLElement) {
|
||||
return lightDomTarget;
|
||||
}
|
||||
|
||||
const shadowRoot = host.shadowRoot;
|
||||
if (!shadowRoot) return null;
|
||||
|
||||
return shadowRoot.querySelector(`[data-annotation-id="${annotationId}"]`) as HTMLElement | null;
|
||||
}, [diffRootRef]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (expectedTargetIds.length === 0) return;
|
||||
|
||||
let cancelled = false;
|
||||
let attempts = 0;
|
||||
const maxAttempts = 12;
|
||||
|
||||
const checkTargets = () => {
|
||||
if (cancelled) return;
|
||||
const allResolved = expectedTargetIds.every((id) => Boolean(resolveTarget(id)));
|
||||
if (allResolved || attempts >= maxAttempts) {
|
||||
return;
|
||||
}
|
||||
attempts += 1;
|
||||
requestAnimationFrame(() => {
|
||||
if (cancelled) return;
|
||||
setRetryTick((tick) => tick + 1);
|
||||
checkTargets();
|
||||
});
|
||||
};
|
||||
|
||||
checkTargets();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [expectedTargetIds, resolveTarget]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const root = diffRootRef.current;
|
||||
if (!root) return;
|
||||
|
||||
const computeMaxWidth = () => {
|
||||
const styles = getComputedStyle(root);
|
||||
const cssWidth = parseCssWidth(styles.getPropertyValue('--oc-context-panel-width'));
|
||||
const rootRect = root.getBoundingClientRect();
|
||||
const measured = cssWidth ?? rootRect.width;
|
||||
setFallbackMaxWidth(measured > 0 ? measured : null);
|
||||
};
|
||||
|
||||
computeMaxWidth();
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
computeMaxWidth();
|
||||
});
|
||||
observer.observe(root);
|
||||
|
||||
window.addEventListener('resize', computeMaxWidth);
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
window.removeEventListener('resize', computeMaxWidth);
|
||||
};
|
||||
}, [diffRootRef]);
|
||||
|
||||
const resolveTargetMaxWidth = React.useCallback((target: HTMLElement): number | undefined => {
|
||||
const root = diffRootRef.current;
|
||||
const rootRect = root?.getBoundingClientRect();
|
||||
|
||||
const annotationContent = target.closest('[data-annotation-content]');
|
||||
const contentRect = annotationContent instanceof HTMLElement
|
||||
? annotationContent.getBoundingClientRect()
|
||||
: target.getBoundingClientRect();
|
||||
|
||||
const candidates = [contentRect.width];
|
||||
if (rootRect) {
|
||||
candidates.push(rootRect.right - contentRect.left);
|
||||
}
|
||||
|
||||
const positiveCandidates = candidates.filter((value) => Number.isFinite(value) && value > 0);
|
||||
if (positiveCandidates.length > 0) {
|
||||
return clampMaxWidth(Math.min(...positiveCandidates));
|
||||
}
|
||||
|
||||
return clampMaxWidth(fallbackMaxWidth);
|
||||
}, [diffRootRef, fallbackMaxWidth]);
|
||||
|
||||
void retryTick;
|
||||
|
||||
return (
|
||||
<>
|
||||
{drafts.map((draft) => {
|
||||
const id = toPierreAnnotationId({ type: draft.id === editingDraftId ? 'edit' : 'saved', draft });
|
||||
const target = resolveTarget(id);
|
||||
if (!target) return null;
|
||||
const targetMaxWidth = resolveTargetMaxWidth(target);
|
||||
|
||||
if (draft.id === editingDraftId) {
|
||||
return createPortal(
|
||||
<InlineCommentInput
|
||||
initialText={commentText}
|
||||
fileLabel={fileLabel}
|
||||
lineRange={{
|
||||
start: draft.startLine,
|
||||
end: draft.endLine,
|
||||
side: draft.side === 'original' ? 'deletions' : 'additions',
|
||||
}}
|
||||
isEditing={true}
|
||||
onSave={onSave}
|
||||
onCancel={onCancel}
|
||||
maxWidth={targetMaxWidth}
|
||||
/>,
|
||||
target,
|
||||
`draft-edit-${draft.id}`
|
||||
);
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<InlineCommentCard
|
||||
draft={draft}
|
||||
onEdit={() => onEdit(draft)}
|
||||
onDelete={() => onDelete(draft)}
|
||||
maxWidth={targetMaxWidth}
|
||||
/>,
|
||||
target,
|
||||
`draft-card-${draft.id}`
|
||||
);
|
||||
})}
|
||||
|
||||
{selection && !editingDraftId && selectionAnnotationId && (() => {
|
||||
const target = resolveTarget(selectionAnnotationId);
|
||||
if (!target) return null;
|
||||
const targetMaxWidth = resolveTargetMaxWidth(target);
|
||||
|
||||
return createPortal(
|
||||
<InlineCommentInput
|
||||
initialText={commentText}
|
||||
fileLabel={fileLabel}
|
||||
lineRange={selection}
|
||||
isEditing={false}
|
||||
onSave={onSave}
|
||||
onCancel={onCancel}
|
||||
maxWidth={targetMaxWidth}
|
||||
/>,
|
||||
target,
|
||||
selectionAnnotationId
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { AnnotationSide, DiffLineAnnotation, SelectedLineRange } from '@pierre/diffs';
|
||||
import type { InlineCommentDraft } from '@/stores/useInlineCommentDraftStore';
|
||||
|
||||
export type PierreAnnotationData =
|
||||
| { type: 'saved' | 'edit'; draft: InlineCommentDraft }
|
||||
| { type: 'new'; selection: SelectedLineRange };
|
||||
|
||||
export const toPierreAnnotationId = (meta: PierreAnnotationData): string => {
|
||||
if (meta.type === 'new') {
|
||||
const start = Math.min(meta.selection.start, meta.selection.end);
|
||||
const end = Math.max(meta.selection.start, meta.selection.end);
|
||||
const side = meta.selection.side ?? 'additions';
|
||||
return `new-comment-${side}-${start}-${end}`;
|
||||
}
|
||||
|
||||
return `draft-${meta.draft.id}`;
|
||||
};
|
||||
|
||||
interface BuildPierreLineAnnotationsOptions {
|
||||
drafts: InlineCommentDraft[];
|
||||
editingDraftId: string | null;
|
||||
selection: SelectedLineRange | null;
|
||||
}
|
||||
|
||||
export const buildPierreLineAnnotations = (
|
||||
options: BuildPierreLineAnnotationsOptions
|
||||
): DiffLineAnnotation<PierreAnnotationData>[] => {
|
||||
const { drafts, editingDraftId, selection } = options;
|
||||
const annotations: DiffLineAnnotation<PierreAnnotationData>[] = [];
|
||||
|
||||
for (const draft of drafts) {
|
||||
const side: AnnotationSide = draft.side === 'original' ? 'deletions' : 'additions';
|
||||
annotations.push({
|
||||
lineNumber: draft.endLine,
|
||||
side,
|
||||
metadata: {
|
||||
type: draft.id === editingDraftId ? 'edit' : 'saved',
|
||||
draft,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (selection && !editingDraftId) {
|
||||
annotations.push({
|
||||
lineNumber: Math.max(selection.start, selection.end),
|
||||
side: selection.side ?? 'additions',
|
||||
metadata: { type: 'new', selection },
|
||||
});
|
||||
}
|
||||
|
||||
return annotations;
|
||||
};
|
||||
@@ -1,2 +1,6 @@
|
||||
export * from './InlineCommentCard';
|
||||
export * from './InlineCommentInput';
|
||||
export * from './useInlineCommentController';
|
||||
export * from './CodeMirrorCommentWidgets';
|
||||
export * from './PierreDiffCommentUtils';
|
||||
export * from './PierreDiffCommentOverlays';
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import React from 'react';
|
||||
import { toast } from '@/components/ui';
|
||||
import { useInlineCommentDraftStore, type InlineCommentDraft, type InlineCommentSource } from '@/stores/useInlineCommentDraftStore';
|
||||
import { useSessionStore } from '@/stores/useSessionStore';
|
||||
|
||||
type LineRangeBase = {
|
||||
start: number;
|
||||
end: number;
|
||||
};
|
||||
|
||||
type StoreRange = {
|
||||
startLine: number;
|
||||
endLine: number;
|
||||
side?: 'original' | 'modified';
|
||||
};
|
||||
|
||||
interface UseInlineCommentControllerOptions<TRange extends LineRangeBase> {
|
||||
source: InlineCommentSource;
|
||||
fileLabel: string | null;
|
||||
language: string;
|
||||
getCodeForRange: (range: TRange) => string;
|
||||
toStoreRange: (range: TRange) => StoreRange;
|
||||
fromDraftRange: (draft: InlineCommentDraft) => TRange;
|
||||
}
|
||||
|
||||
const normalizeStoreRange = (range: StoreRange): StoreRange => {
|
||||
const startLine = Math.min(range.startLine, range.endLine);
|
||||
const endLine = Math.max(range.startLine, range.endLine);
|
||||
return {
|
||||
...range,
|
||||
startLine,
|
||||
endLine,
|
||||
};
|
||||
};
|
||||
|
||||
export const normalizeLineRange = <TRange extends LineRangeBase>(range: TRange): TRange => {
|
||||
const start = Math.min(range.start, range.end);
|
||||
const end = Math.max(range.start, range.end);
|
||||
return {
|
||||
...range,
|
||||
start,
|
||||
end,
|
||||
};
|
||||
};
|
||||
|
||||
export function useInlineCommentController<TRange extends LineRangeBase>(
|
||||
options: UseInlineCommentControllerOptions<TRange>
|
||||
) {
|
||||
const { source, fileLabel, language, getCodeForRange, toStoreRange, fromDraftRange } = options;
|
||||
|
||||
const currentSessionId = useSessionStore((state) => state.currentSessionId);
|
||||
const newSessionDraftOpen = useSessionStore((state) => state.newSessionDraft?.open);
|
||||
|
||||
const addDraft = useInlineCommentDraftStore((state) => state.addDraft);
|
||||
const updateDraft = useInlineCommentDraftStore((state) => state.updateDraft);
|
||||
const removeDraft = useInlineCommentDraftStore((state) => state.removeDraft);
|
||||
const allDrafts = useInlineCommentDraftStore((state) => state.drafts);
|
||||
|
||||
const [selection, setSelection] = React.useState<TRange | null>(null);
|
||||
const [commentText, setCommentText] = React.useState('');
|
||||
const [editingDraftId, setEditingDraftId] = React.useState<string | null>(null);
|
||||
|
||||
const sessionKey = React.useMemo(() => {
|
||||
return currentSessionId ?? (newSessionDraftOpen ? 'draft' : null);
|
||||
}, [currentSessionId, newSessionDraftOpen]);
|
||||
|
||||
const drafts = React.useMemo(() => {
|
||||
if (!sessionKey || !fileLabel) return [];
|
||||
const sessionDrafts = allDrafts[sessionKey] ?? [];
|
||||
return sessionDrafts.filter((draft) => draft.source === source && draft.fileLabel === fileLabel);
|
||||
}, [allDrafts, fileLabel, sessionKey, source]);
|
||||
|
||||
const reset = React.useCallback(() => {
|
||||
setSelection(null);
|
||||
setCommentText('');
|
||||
setEditingDraftId(null);
|
||||
}, []);
|
||||
|
||||
const cancel = React.useCallback(() => {
|
||||
reset();
|
||||
}, [reset]);
|
||||
|
||||
const startEdit = React.useCallback((draft: InlineCommentDraft) => {
|
||||
const draftRange = normalizeLineRange(fromDraftRange(draft));
|
||||
setSelection(draftRange);
|
||||
setCommentText(draft.text);
|
||||
setEditingDraftId(draft.id);
|
||||
}, [fromDraftRange]);
|
||||
|
||||
const deleteDraft = React.useCallback((draft: InlineCommentDraft) => {
|
||||
removeDraft(draft.sessionKey, draft.id);
|
||||
if (editingDraftId === draft.id) {
|
||||
reset();
|
||||
}
|
||||
}, [editingDraftId, removeDraft, reset]);
|
||||
|
||||
const saveComment = React.useCallback((textToSave: string, rangeOverride?: TRange) => {
|
||||
const targetRange = rangeOverride ?? selection;
|
||||
const trimmedText = textToSave.trim();
|
||||
if (!targetRange || !trimmedText || !fileLabel) return;
|
||||
|
||||
if (!sessionKey) {
|
||||
toast.error('Select a session to save comment');
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedRange = normalizeLineRange(targetRange);
|
||||
const normalizedStoreRange = normalizeStoreRange(toStoreRange(normalizedRange));
|
||||
const code = getCodeForRange(normalizedRange);
|
||||
|
||||
if (editingDraftId) {
|
||||
updateDraft(sessionKey, editingDraftId, {
|
||||
fileLabel,
|
||||
startLine: normalizedStoreRange.startLine,
|
||||
endLine: normalizedStoreRange.endLine,
|
||||
side: normalizedStoreRange.side,
|
||||
code,
|
||||
language,
|
||||
text: trimmedText,
|
||||
});
|
||||
} else {
|
||||
addDraft({
|
||||
sessionKey,
|
||||
source,
|
||||
fileLabel,
|
||||
startLine: normalizedStoreRange.startLine,
|
||||
endLine: normalizedStoreRange.endLine,
|
||||
side: normalizedStoreRange.side,
|
||||
code,
|
||||
language,
|
||||
text: trimmedText,
|
||||
});
|
||||
}
|
||||
|
||||
reset();
|
||||
}, [addDraft, editingDraftId, fileLabel, getCodeForRange, language, reset, selection, sessionKey, source, toStoreRange, updateDraft]);
|
||||
|
||||
return {
|
||||
sessionKey,
|
||||
drafts,
|
||||
selection,
|
||||
setSelection,
|
||||
commentText,
|
||||
setCommentText,
|
||||
editingDraftId,
|
||||
setEditingDraftId,
|
||||
reset,
|
||||
cancel,
|
||||
startEdit,
|
||||
deleteDraft,
|
||||
saveComment,
|
||||
fromDraftRange,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user