chore: sync local workspace changes
This commit is contained in:
@@ -24,6 +24,8 @@ const CHAT_NOTIFY_OFFLINE_STORAGE_PREFIX = 'main-chat-panel:notify-offline:';
|
||||
const CHAT_SESSION_LAST_TYPE_STORAGE_PREFIX = 'main-chat-panel:last-chat-type:';
|
||||
const CHAT_INTRO_MESSAGE = '요청은 기본적으로 순차 처리됩니다. 급한 요청만 즉시 실행을 사용하세요.';
|
||||
const CHAT_ACTIVITY_MESSAGE_PREFIX = '[[activity-log]]';
|
||||
const CHAT_MISSING_REQUEST_MESSAGE_PREFIX = '[[missing-request]]';
|
||||
const CHAT_EXECUTION_FAILURE_MESSAGE_PREFIX = '[[execution-failure]]';
|
||||
const CHAT_COMPOSER_UPLOAD_FILE_SIZE_LIMIT = 10 * 1024 * 1024;
|
||||
const KST_TIME_ZONE = 'Asia/Seoul';
|
||||
const chatSessionLastTypeMemory = new Map<string, string>();
|
||||
@@ -46,18 +48,23 @@ function toConversationSortTime(value: string | null | undefined) {
|
||||
return Number.isNaN(parsed) ? 0 : parsed;
|
||||
}
|
||||
|
||||
function getConversationLastMessageSortTime(item: ChatConversationSummary) {
|
||||
const lastMessageTime = toConversationSortTime(item.lastMessageAt);
|
||||
|
||||
if (lastMessageTime > 0) {
|
||||
return lastMessageTime;
|
||||
}
|
||||
|
||||
return Math.max(
|
||||
toConversationSortTime(item.createdAt),
|
||||
toConversationSortTime(item.updatedAt),
|
||||
);
|
||||
}
|
||||
|
||||
export function sortChatConversationSummaries(items: ChatConversationSummary[]) {
|
||||
return [...items].sort((left, right) => {
|
||||
const leftTime = Math.max(
|
||||
toConversationSortTime(left.lastMessageAt),
|
||||
toConversationSortTime(left.updatedAt),
|
||||
toConversationSortTime(left.createdAt),
|
||||
);
|
||||
const rightTime = Math.max(
|
||||
toConversationSortTime(right.lastMessageAt),
|
||||
toConversationSortTime(right.updatedAt),
|
||||
toConversationSortTime(right.createdAt),
|
||||
);
|
||||
const leftTime = getConversationLastMessageSortTime(left);
|
||||
const rightTime = getConversationLastMessageSortTime(right);
|
||||
|
||||
if (rightTime !== leftTime) {
|
||||
return rightTime - leftTime;
|
||||
@@ -289,18 +296,29 @@ function createLocalMessageId() {
|
||||
return Date.now() * 1_000 + localMessageSequence;
|
||||
}
|
||||
|
||||
function createRecoveredMessageId(requestId: string, variant: 'user' | 'codex' | 'activity') {
|
||||
function createRecoveredMessageId(
|
||||
requestId: string,
|
||||
variant: 'user' | 'codex' | 'activity' | 'missing-request' | 'execution-failure',
|
||||
) {
|
||||
const baseId = hashRequestId(requestId) * 10;
|
||||
|
||||
if (variant === 'user') {
|
||||
return -(baseId + 3);
|
||||
}
|
||||
|
||||
if (variant === 'activity') {
|
||||
if (variant === 'missing-request') {
|
||||
return -(baseId + 2);
|
||||
}
|
||||
|
||||
return -(baseId + 1);
|
||||
if (variant === 'activity') {
|
||||
return -(baseId + 1);
|
||||
}
|
||||
|
||||
if (variant === 'execution-failure') {
|
||||
return -(baseId + 4);
|
||||
}
|
||||
|
||||
return -(baseId + 5);
|
||||
}
|
||||
|
||||
function hashRequestId(value: string) {
|
||||
@@ -355,6 +373,170 @@ function isActivityLogMessage(message: ChatMessage) {
|
||||
return message.author === 'system' && message.text.startsWith(`${CHAT_ACTIVITY_MESSAGE_PREFIX}\n`);
|
||||
}
|
||||
|
||||
export function isMissingRequestMessage(message: ChatMessage) {
|
||||
return message.author === 'system' && message.text.startsWith(`${CHAT_MISSING_REQUEST_MESSAGE_PREFIX}\n`);
|
||||
}
|
||||
|
||||
export function isExecutionFailureMessage(message: ChatMessage) {
|
||||
return message.author === 'system' && message.text.startsWith(`${CHAT_EXECUTION_FAILURE_MESSAGE_PREFIX}\n`);
|
||||
}
|
||||
|
||||
function isEmptyCodexExecutionResponse(text: string) {
|
||||
const normalized = text.replace(/\s+/g, ' ').trim();
|
||||
return normalized === 'Codex 실행 결과가 비어 있습니다.';
|
||||
}
|
||||
|
||||
function extractActivityLogFailureReason(lines?: string[] | null) {
|
||||
const normalizedLines = (lines ?? []).map((line) => line.trim()).filter(Boolean);
|
||||
|
||||
for (let index = normalizedLines.length - 1; index >= 0; index -= 1) {
|
||||
const line = normalizedLines[index];
|
||||
|
||||
if (!line.startsWith('# 오류:')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const raw = line.slice('# 오류:'.length).trim();
|
||||
|
||||
if (!raw) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { message?: unknown };
|
||||
const message = typeof parsed.message === 'string' ? parsed.message.trim() : '';
|
||||
|
||||
if (message) {
|
||||
return message;
|
||||
}
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function buildExecutionFailureMessage(reason: string) {
|
||||
const normalizedReason = reason.trim();
|
||||
|
||||
if (!normalizedReason) {
|
||||
return `${CHAT_EXECUTION_FAILURE_MESSAGE_PREFIX}\n실행 중 오류가 발생했습니다.`;
|
||||
}
|
||||
|
||||
const simplifiedReason = normalizedReason.includes("mkdir '") && normalizedReason.includes('/resource/uploads')
|
||||
? `세션 리소스 업로드 폴더를 만들 권한이 없어 응답 생성이 중단되었습니다.\n\n원인: ${normalizedReason}`
|
||||
: `실행 중 오류가 발생했습니다.\n\n원인: ${normalizedReason}`;
|
||||
|
||||
return `${CHAT_EXECUTION_FAILURE_MESSAGE_PREFIX}\n${simplifiedReason}`;
|
||||
}
|
||||
|
||||
function buildFailurePreviewText(reason: string) {
|
||||
const normalizedReason = reason.trim();
|
||||
|
||||
if (!normalizedReason) {
|
||||
return '실행 실패';
|
||||
}
|
||||
|
||||
if (normalizedReason.includes("mkdir '") && normalizedReason.includes('/resource/uploads')) {
|
||||
return '실행 실패: 세션 리소스 업로드 폴더 권한 오류';
|
||||
}
|
||||
|
||||
return `실행 실패: ${normalizedReason}`;
|
||||
}
|
||||
|
||||
function enrichFailedRequestsWithActivityLogs(
|
||||
requests: ChatConversationRequest[],
|
||||
activityLogs: ChatConversationActivityLog[],
|
||||
) {
|
||||
const activityLogMap = new Map(activityLogs.map((item) => [item.requestId.trim(), item]));
|
||||
|
||||
return requests.map((request) => {
|
||||
if (request.status !== 'failed') {
|
||||
return request;
|
||||
}
|
||||
|
||||
const activityLog = activityLogMap.get(request.requestId.trim());
|
||||
const failureReason = extractActivityLogFailureReason(activityLog?.lines);
|
||||
const normalizedStatusMessage = String(request.statusMessage ?? '').trim();
|
||||
|
||||
if (!failureReason) {
|
||||
return request;
|
||||
}
|
||||
|
||||
if (normalizedStatusMessage && normalizedStatusMessage !== '요청 처리 실패') {
|
||||
return request;
|
||||
}
|
||||
|
||||
return {
|
||||
...request,
|
||||
statusMessage: failureReason,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function replaceGenericFailureMessages(
|
||||
messages: ChatMessage[],
|
||||
requests: ChatConversationRequest[],
|
||||
activityLogs: ChatConversationActivityLog[],
|
||||
): ChatMessage[] {
|
||||
const requestMap = new Map(requests.map((item) => [item.requestId.trim(), item]));
|
||||
const activityLogMap = new Map(activityLogs.map((item) => [item.requestId.trim(), item]));
|
||||
|
||||
return messages.map((message) => {
|
||||
const requestId = message.clientRequestId?.trim() ?? '';
|
||||
|
||||
if (!requestId || message.author !== 'codex' || !isEmptyCodexExecutionResponse(message.text)) {
|
||||
return message;
|
||||
}
|
||||
|
||||
const request = requestMap.get(requestId);
|
||||
|
||||
if (request?.status !== 'failed') {
|
||||
return message;
|
||||
}
|
||||
|
||||
const failureReason = extractActivityLogFailureReason(activityLogMap.get(requestId)?.lines);
|
||||
|
||||
if (!failureReason) {
|
||||
return message;
|
||||
}
|
||||
|
||||
return {
|
||||
...message,
|
||||
author: 'system' as const,
|
||||
text: buildExecutionFailureMessage(failureReason),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function resolveConversationFailurePreview(
|
||||
currentPreview: string,
|
||||
requests: ChatConversationRequest[],
|
||||
activityLogs: ChatConversationActivityLog[],
|
||||
) {
|
||||
if (!isEmptyCodexExecutionResponse(currentPreview)) {
|
||||
return currentPreview;
|
||||
}
|
||||
|
||||
const latestFailedRequest = [...requests]
|
||||
.reverse()
|
||||
.find((request) => request.status === 'failed' && isEmptyCodexExecutionResponse(String(request.responseText ?? '').trim()));
|
||||
|
||||
if (!latestFailedRequest) {
|
||||
return currentPreview;
|
||||
}
|
||||
|
||||
const activityLog = activityLogs.find((item) => item.requestId.trim() === latestFailedRequest.requestId.trim());
|
||||
const failureReason = extractActivityLogFailureReason(activityLog?.lines);
|
||||
|
||||
if (!failureReason) {
|
||||
return currentPreview;
|
||||
}
|
||||
|
||||
return buildFailurePreviewText(failureReason);
|
||||
}
|
||||
|
||||
function extractActivityLogLines(text: string) {
|
||||
return text
|
||||
.slice(`${CHAT_ACTIVITY_MESSAGE_PREFIX}\n`.length)
|
||||
@@ -934,7 +1116,9 @@ export async function fetchChatConversations() {
|
||||
}
|
||||
|
||||
const clientId = getOrCreateClientId();
|
||||
chatConversationListRequestPromise = requestChatApi<{ ok: boolean; items: ChatConversationSummary[] }>('/conversations')
|
||||
chatConversationListRequestPromise = requestChatApi<{ ok: boolean; items: ChatConversationSummary[] }>(
|
||||
'/conversations?limit=200',
|
||||
)
|
||||
.then((response) => {
|
||||
return sortChatConversationSummaries(
|
||||
response.items.map((item) => ({
|
||||
@@ -971,17 +1155,24 @@ export async function fetchChatConversationDetail(
|
||||
const response = await requestChatApi<ChatConversationDetailResponse>(
|
||||
`/conversations/${encodeURIComponent(sessionId)}${query.toString() ? `?${query.toString()}` : ''}`,
|
||||
);
|
||||
const normalizedRequests = response.requests.map((item) => normalizeChatConversationRequest(item));
|
||||
const normalizedRequests = enrichFailedRequestsWithActivityLogs(
|
||||
response.requests.map((item) => normalizeChatConversationRequest(item)),
|
||||
response.activityLogs,
|
||||
);
|
||||
const visibleRequestIds = new Set(
|
||||
response.messages
|
||||
.map((message) => message.clientRequestId?.trim() ?? '')
|
||||
.filter(Boolean),
|
||||
);
|
||||
const hydratedMessages = hydrateActivityLogMessages(
|
||||
response.messages,
|
||||
replaceGenericFailureMessages(response.messages, normalizedRequests, response.activityLogs),
|
||||
response.activityLogs.filter((item) => visibleRequestIds.has(item.requestId?.trim() ?? '')),
|
||||
).filter(
|
||||
(message) => message.author !== 'system' || isActivityLogMessage(message),
|
||||
(message) =>
|
||||
message.author !== 'system' ||
|
||||
isActivityLogMessage(message) ||
|
||||
isMissingRequestMessage(message) ||
|
||||
isExecutionFailureMessage(message),
|
||||
);
|
||||
const recoveredMessages = buildRecoveredMessagesFromConversationDetail(normalizedRequests, response.activityLogs);
|
||||
|
||||
@@ -990,6 +1181,11 @@ export async function fetchChatConversationDetail(
|
||||
messages: mergeRecoveredChatMessages(recoveredMessages, hydratedMessages),
|
||||
item: {
|
||||
...response.item,
|
||||
lastMessagePreview: resolveConversationFailurePreview(
|
||||
response.item.lastMessagePreview,
|
||||
normalizedRequests,
|
||||
response.activityLogs,
|
||||
),
|
||||
notifyOffline: resolveSyncedChatOfflineNotificationSetting(
|
||||
response.item.sessionId,
|
||||
response.item.notifyOffline,
|
||||
@@ -1123,6 +1319,7 @@ export async function createChatConversationRoom(args: {
|
||||
title?: string;
|
||||
chatTypeId?: string | null;
|
||||
lastChatTypeId?: string | null;
|
||||
generalSectionName?: string | null;
|
||||
contextLabel?: string;
|
||||
contextDescription?: string;
|
||||
notifyOffline?: boolean;
|
||||
@@ -1136,6 +1333,7 @@ export async function createChatConversationRoom(args: {
|
||||
title: args.title ?? '새 대화',
|
||||
chatTypeId: args.chatTypeId ?? null,
|
||||
lastChatTypeId: args.lastChatTypeId ?? args.chatTypeId ?? null,
|
||||
generalSectionName: args.generalSectionName ?? null,
|
||||
contextLabel: args.contextLabel ?? null,
|
||||
contextDescription: args.contextDescription ?? null,
|
||||
notifyOffline,
|
||||
@@ -1173,6 +1371,7 @@ export async function updateChatConversationRoom(
|
||||
title?: string;
|
||||
chatTypeId?: string | null;
|
||||
lastChatTypeId?: string | null;
|
||||
generalSectionName?: string | null;
|
||||
contextLabel?: string | null;
|
||||
contextDescription?: string | null;
|
||||
notifyOffline?: boolean;
|
||||
@@ -1307,6 +1506,14 @@ function isSameChatMessage(left: ChatMessage, right: ChatMessage) {
|
||||
return Boolean(left.clientRequestId && right.clientRequestId && left.clientRequestId === right.clientRequestId);
|
||||
}
|
||||
|
||||
if (isMissingRequestMessage(left) && isMissingRequestMessage(right)) {
|
||||
return Boolean(left.clientRequestId && right.clientRequestId && left.clientRequestId === right.clientRequestId);
|
||||
}
|
||||
|
||||
if (isExecutionFailureMessage(left) && isExecutionFailureMessage(right)) {
|
||||
return Boolean(left.clientRequestId && right.clientRequestId && left.clientRequestId === right.clientRequestId);
|
||||
}
|
||||
|
||||
return Boolean(
|
||||
(left.author === 'user' || left.author === 'codex') &&
|
||||
left.author === right.author &&
|
||||
@@ -1321,6 +1528,14 @@ function buildComparableChatMessageKey(message: ChatMessage) {
|
||||
return `activity:${message.clientRequestId}`;
|
||||
}
|
||||
|
||||
if (isMissingRequestMessage(message) && message.clientRequestId) {
|
||||
return `missing-request:${message.clientRequestId}`;
|
||||
}
|
||||
|
||||
if (isExecutionFailureMessage(message) && message.clientRequestId) {
|
||||
return `execution-failure:${message.clientRequestId}`;
|
||||
}
|
||||
|
||||
if (message.author === 'user' && message.clientRequestId) {
|
||||
return `user-request:${message.clientRequestId}`;
|
||||
}
|
||||
@@ -1337,6 +1552,123 @@ function getComparableChatMessageTime(message: ChatMessage) {
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
function getChatMessageRequestId(message: ChatMessage) {
|
||||
return message.clientRequestId?.trim() || '';
|
||||
}
|
||||
|
||||
function getChatMessageOrderRank(message: ChatMessage) {
|
||||
if (message.author === 'user') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (isMissingRequestMessage(message)) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (isExecutionFailureMessage(message)) {
|
||||
return 2;
|
||||
}
|
||||
|
||||
if (isActivityLogMessage(message)) {
|
||||
return 3;
|
||||
}
|
||||
|
||||
if (message.author === 'codex') {
|
||||
return 3;
|
||||
}
|
||||
|
||||
return 4;
|
||||
}
|
||||
|
||||
function sortConversationMessages(messages: ChatMessage[]) {
|
||||
if (messages.length <= 1) {
|
||||
return messages;
|
||||
}
|
||||
|
||||
const messageIndexMap = new Map(messages.map((message, index) => [message, index]));
|
||||
const requestOrder = new Map<
|
||||
string,
|
||||
{
|
||||
time: number;
|
||||
firstIndex: number;
|
||||
}
|
||||
>();
|
||||
|
||||
messages.forEach((message, index) => {
|
||||
const requestId = getChatMessageRequestId(message);
|
||||
|
||||
if (!requestId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const time = getComparableChatMessageTime(message);
|
||||
const existing = requestOrder.get(requestId);
|
||||
|
||||
if (!existing) {
|
||||
requestOrder.set(requestId, {
|
||||
time,
|
||||
firstIndex: index,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
requestOrder.set(requestId, {
|
||||
time:
|
||||
existing.time > 0 && time > 0
|
||||
? Math.min(existing.time, time)
|
||||
: existing.time > 0
|
||||
? existing.time
|
||||
: time,
|
||||
firstIndex: Math.min(existing.firstIndex, index),
|
||||
});
|
||||
});
|
||||
|
||||
return [...messages].sort((left, right) => {
|
||||
const leftRequestId = getChatMessageRequestId(left);
|
||||
const rightRequestId = getChatMessageRequestId(right);
|
||||
|
||||
if (leftRequestId && rightRequestId && leftRequestId === rightRequestId) {
|
||||
const rankDiff = getChatMessageOrderRank(left) - getChatMessageOrderRank(right);
|
||||
|
||||
if (rankDiff !== 0) {
|
||||
return rankDiff;
|
||||
}
|
||||
}
|
||||
|
||||
const leftOrder = leftRequestId ? requestOrder.get(leftRequestId) : null;
|
||||
const rightOrder = rightRequestId ? requestOrder.get(rightRequestId) : null;
|
||||
const leftTime = leftOrder?.time ?? getComparableChatMessageTime(left);
|
||||
const rightTime = rightOrder?.time ?? getComparableChatMessageTime(right);
|
||||
|
||||
if (leftTime !== rightTime) {
|
||||
return leftTime - rightTime;
|
||||
}
|
||||
|
||||
const leftIndex = leftOrder?.firstIndex ?? messageIndexMap.get(left) ?? 0;
|
||||
const rightIndex = rightOrder?.firstIndex ?? messageIndexMap.get(right) ?? 0;
|
||||
|
||||
if (leftIndex !== rightIndex) {
|
||||
return leftIndex - rightIndex;
|
||||
}
|
||||
|
||||
if (leftRequestId && rightRequestId && leftRequestId !== rightRequestId) {
|
||||
const requestDiff = leftRequestId.localeCompare(rightRequestId, 'ko-KR');
|
||||
|
||||
if (requestDiff !== 0) {
|
||||
return requestDiff;
|
||||
}
|
||||
}
|
||||
|
||||
const messageTimeDiff = getComparableChatMessageTime(left) - getComparableChatMessageTime(right);
|
||||
|
||||
if (messageTimeDiff !== 0) {
|
||||
return messageTimeDiff;
|
||||
}
|
||||
|
||||
return left.id - right.id;
|
||||
});
|
||||
}
|
||||
|
||||
function buildRecoveredMessagesFromConversationDetail(
|
||||
requests: ChatConversationRequest[],
|
||||
activityLogs: ChatConversationActivityLog[],
|
||||
@@ -1354,6 +1686,9 @@ function buildRecoveredMessagesFromConversationDetail(
|
||||
const userText = String(request.userText ?? '').trim();
|
||||
const responseText = String(request.responseText ?? '').trim();
|
||||
const activityLog = activityLogMap.get(requestId);
|
||||
const failureReason = extractActivityLogFailureReason(activityLog?.lines);
|
||||
const shouldReplaceEmptyFailureResponse =
|
||||
request.status === 'failed' && isEmptyCodexExecutionResponse(responseText) && Boolean(failureReason);
|
||||
|
||||
if (userText) {
|
||||
nextMessages.push({
|
||||
@@ -1363,9 +1698,17 @@ function buildRecoveredMessagesFromConversationDetail(
|
||||
timestamp: request.createdAt || request.updatedAt || '',
|
||||
clientRequestId: requestId,
|
||||
});
|
||||
} else if (responseText || activityLog?.lines.length) {
|
||||
nextMessages.push({
|
||||
id: createRecoveredMessageId(requestId, 'missing-request'),
|
||||
author: 'system',
|
||||
text: `${CHAT_MISSING_REQUEST_MESSAGE_PREFIX}\n이 요청은 저장된 원문이 없어 실제 요청 문장을 표시할 수 없습니다.`,
|
||||
timestamp: request.createdAt || request.updatedAt || '',
|
||||
clientRequestId: requestId,
|
||||
});
|
||||
}
|
||||
|
||||
if (responseText) {
|
||||
if (responseText && !shouldReplaceEmptyFailureResponse) {
|
||||
nextMessages.push({
|
||||
id: request.responseMessageId ?? createRecoveredMessageId(requestId, 'codex'),
|
||||
author: 'codex',
|
||||
@@ -1375,6 +1718,16 @@ function buildRecoveredMessagesFromConversationDetail(
|
||||
});
|
||||
}
|
||||
|
||||
if (shouldReplaceEmptyFailureResponse) {
|
||||
nextMessages.push({
|
||||
id: request.responseMessageId ?? createRecoveredMessageId(requestId, 'execution-failure'),
|
||||
author: 'system',
|
||||
text: buildExecutionFailureMessage(failureReason),
|
||||
timestamp: request.answeredAt || request.updatedAt || request.createdAt || '',
|
||||
clientRequestId: requestId,
|
||||
});
|
||||
}
|
||||
|
||||
if (activityLog && activityLog.lines.length > 0) {
|
||||
nextMessages.push({
|
||||
id: createRecoveredMessageId(requestId, 'activity'),
|
||||
@@ -1386,15 +1739,7 @@ function buildRecoveredMessagesFromConversationDetail(
|
||||
}
|
||||
});
|
||||
|
||||
return nextMessages.sort((left, right) => {
|
||||
const timeDiff = getComparableChatMessageTime(left) - getComparableChatMessageTime(right);
|
||||
|
||||
if (timeDiff !== 0) {
|
||||
return timeDiff;
|
||||
}
|
||||
|
||||
return left.id - right.id;
|
||||
});
|
||||
return sortConversationMessages(nextMessages);
|
||||
}
|
||||
|
||||
export function mergeRecoveredChatMessages(previous: ChatMessage[], incoming: ChatMessage[]) {
|
||||
@@ -1459,7 +1804,7 @@ export function mergeRecoveredChatMessages(previous: ChatMessage[], incoming: Ch
|
||||
});
|
||||
|
||||
const unmatchedLocalMessages = Array.from(previousBuckets.values()).flat();
|
||||
const nextMessages = [...mergedServerMessages, ...unmatchedLocalMessages];
|
||||
const nextMessages = sortConversationMessages([...mergedServerMessages, ...unmatchedLocalMessages]);
|
||||
|
||||
return areChatMessagesEquivalent(previous, nextMessages) ? previous : nextMessages;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user