feat(webui): add temporary chat mode
This commit is contained in:
@@ -84,6 +84,7 @@ import { useMediaQuery } from "@/hooks/useMediaQuery";
|
||||
import type { SendAttachment, SendOptions } from "@/hooks/useNanobotStream";
|
||||
import { usePageVisibility } from "@/hooks/usePageVisibility";
|
||||
import { useVoiceRecorder, type VoiceRecorderErrorKey } from "@/hooks/useVoiceRecorder";
|
||||
import { isTemporaryChatId } from "@/lib/temporary-chat";
|
||||
import type {
|
||||
CliAppInfo,
|
||||
ChatSummary,
|
||||
@@ -205,6 +206,8 @@ interface ThreadComposerProps {
|
||||
/** Sustained objective for this chat (WebSocket ``goal_state``). */
|
||||
goalState?: GoalStateWsPayload;
|
||||
workspaceScope?: WorkspaceScopePayload | null;
|
||||
compactWorkspaceControls?: boolean;
|
||||
workspaceConnected?: boolean;
|
||||
workspaceDefaultScope?: WorkspaceScopePayload | null;
|
||||
workspaceControls?: WorkspacesPayload["controls"] | null;
|
||||
workspaceScopeDisabled?: boolean;
|
||||
@@ -440,7 +443,9 @@ function storeSlashRecents(commands: string[]): void {
|
||||
|
||||
function queuedPromptsStorageKey(key?: string | null): string | null {
|
||||
const clean = key?.trim();
|
||||
return clean ? `${QUEUED_PROMPTS_STORAGE_PREFIX}${clean}` : null;
|
||||
return clean && !isTemporaryChatId(clean)
|
||||
? `${QUEUED_PROMPTS_STORAGE_PREFIX}${clean}`
|
||||
: null;
|
||||
}
|
||||
|
||||
function normalizeQueuedSessionMentions(value: unknown): SessionMention[] {
|
||||
@@ -955,6 +960,8 @@ export function ThreadComposer({
|
||||
runStartedAt = null,
|
||||
goalState,
|
||||
workspaceScope = null,
|
||||
compactWorkspaceControls = false,
|
||||
workspaceConnected = false,
|
||||
workspaceDefaultScope = null,
|
||||
workspaceControls = null,
|
||||
workspaceScopeDisabled = false,
|
||||
@@ -1005,17 +1012,18 @@ export function ThreadComposer({
|
||||
() => queuedPromptsStorageKey(pendingQueueKey),
|
||||
[pendingQueueKey],
|
||||
);
|
||||
const showProjectPicker =
|
||||
const projectPickerAvailable =
|
||||
isHero
|
||||
&& !!workspaceDefaultScope
|
||||
&& !!onWorkspaceScopeChange
|
||||
&& workspaceControls?.can_change_project !== false;
|
||||
const showProjectPicker = projectPickerAvailable && !compactWorkspaceControls;
|
||||
|
||||
useEffect(() => {
|
||||
secondEnterPromptIdRef.current = null;
|
||||
skipQueuedPromptPersistRef.current = true;
|
||||
setQueuedPrompts(queuedPromptStorageKey ? readQueuedPrompts(queuedPromptStorageKey) : []);
|
||||
}, [queuedPromptStorageKey]);
|
||||
}, [pendingQueueKey, queuedPromptStorageKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!queuedPromptStorageKey) return;
|
||||
@@ -2425,6 +2433,19 @@ export function ThreadComposer({
|
||||
>
|
||||
<Plus className={cn(isHero ? "h-[18px] w-[18px]" : "h-4 w-4")} />
|
||||
</Button>
|
||||
{compactWorkspaceControls && projectPickerAvailable ? (
|
||||
<WorkspaceProjectPicker
|
||||
compact
|
||||
connected={workspaceConnected}
|
||||
isHero={isHero}
|
||||
disabled={disabled || workspaceScopeDisabled}
|
||||
scope={workspaceScope}
|
||||
defaultScope={workspaceDefaultScope}
|
||||
controls={workspaceControls}
|
||||
error={workspaceError}
|
||||
onChange={onWorkspaceScopeChange}
|
||||
/>
|
||||
) : null}
|
||||
{voiceRecorder.isRecording ? (
|
||||
<VoiceRecordingMeter
|
||||
ariaLabel={voiceRecordingStatusLabel}
|
||||
@@ -2433,7 +2454,7 @@ export function ThreadComposer({
|
||||
isHero={isHero}
|
||||
levels={voiceRecorder.levels}
|
||||
/>
|
||||
) : workspaceScope ? (
|
||||
) : workspaceScope && (!compactWorkspaceControls || workspaceConnected) ? (
|
||||
<WorkspaceAccessMenu
|
||||
scope={workspaceScope}
|
||||
disabled={disabled || workspaceScopeDisabled}
|
||||
@@ -2544,15 +2565,17 @@ export function ThreadComposer({
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<WorkspaceProjectPicker
|
||||
isHero={isHero}
|
||||
disabled={disabled || workspaceScopeDisabled}
|
||||
scope={workspaceScope}
|
||||
defaultScope={workspaceDefaultScope}
|
||||
controls={workspaceControls}
|
||||
error={workspaceError}
|
||||
onChange={onWorkspaceScopeChange}
|
||||
/>
|
||||
{showProjectPicker ? (
|
||||
<WorkspaceProjectPicker
|
||||
isHero={isHero}
|
||||
disabled={disabled || workspaceScopeDisabled}
|
||||
scope={workspaceScope}
|
||||
defaultScope={workspaceDefaultScope}
|
||||
controls={workspaceControls}
|
||||
error={workspaceError}
|
||||
onChange={onWorkspaceScopeChange}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import type { PointerEvent as ReactPointerEvent } from "react";
|
||||
import { RotateCcw } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { FilePreviewAvailabilityProvider } from "@/components/FilePreviewAvailabilityContext";
|
||||
import { FilePreviewPanel } from "@/components/FilePreviewPanel";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PromptNavigator } from "@/components/thread/PromptNavigator";
|
||||
import { SessionInfoPopover } from "@/components/thread/SessionInfoPopover";
|
||||
import { ThreadComposer } from "@/components/thread/ThreadComposer";
|
||||
@@ -295,6 +297,8 @@ interface ThreadShellProps {
|
||||
session: ChatSummary | null;
|
||||
sessions?: ChatSummary[];
|
||||
title: string;
|
||||
temporary?: boolean;
|
||||
onClearTemporaryChat?: () => void;
|
||||
onToggleSidebar: () => void;
|
||||
onGoHome?: () => void;
|
||||
onNewChat?: () => void;
|
||||
@@ -308,6 +312,7 @@ interface ThreadShellProps {
|
||||
hideThemeButton?: boolean;
|
||||
hideHeader?: boolean;
|
||||
workspaceScope?: WorkspaceScopePayload | null;
|
||||
workspaceConnected?: boolean;
|
||||
workspaceDefaultScope?: WorkspaceScopePayload | null;
|
||||
workspaceControls?: WorkspacesPayload["controls"] | null;
|
||||
workspaceScopeDisabled?: boolean;
|
||||
@@ -580,6 +585,8 @@ export function ThreadShell({
|
||||
session,
|
||||
sessions = [],
|
||||
title,
|
||||
temporary = false,
|
||||
onClearTemporaryChat,
|
||||
onToggleSidebar,
|
||||
onCreateChat,
|
||||
onForkChat,
|
||||
@@ -591,6 +598,7 @@ export function ThreadShell({
|
||||
hideThemeButton = false,
|
||||
hideHeader = false,
|
||||
workspaceScope = null,
|
||||
workspaceConnected = false,
|
||||
workspaceDefaultScope = null,
|
||||
workspaceControls = null,
|
||||
workspaceScopeDisabled = false,
|
||||
@@ -602,7 +610,7 @@ export function ThreadShell({
|
||||
}: ThreadShellProps) {
|
||||
const { t } = useTranslation();
|
||||
const chatId = session?.chatId ?? null;
|
||||
const historyKey = session?.key ?? null;
|
||||
const historyKey = temporary ? null : session?.key ?? null;
|
||||
const mentionSessions = useMemo(
|
||||
() => sessions.filter((candidate) => (
|
||||
candidate.key !== historyKey
|
||||
@@ -664,6 +672,7 @@ export function ThreadShell({
|
||||
const viewportRef = useRef<ThreadViewportHandle | null>(null);
|
||||
const activeViewportTurnByChatIdRef = useRef<Map<string, string>>(new Map());
|
||||
const messageCacheRef = useRef<Map<string, UIMessage[]>>(new Map());
|
||||
const temporaryChatIdRef = useRef<string | null>(null);
|
||||
/** Last chatId we associated with the in-memory thread (for cache-on-switch). */
|
||||
const prevChatIdForCacheRef = useRef<string | null>(null);
|
||||
/** Skip one message-cache write right after chatId changes (messages may not match yet). */
|
||||
@@ -736,6 +745,15 @@ export function ThreadShell({
|
||||
setSubmittedViewportTurnId(null);
|
||||
}, [historyKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!temporary || !chatId) return;
|
||||
const previous = temporaryChatIdRef.current;
|
||||
temporaryChatIdRef.current = chatId;
|
||||
if (!previous || previous === chatId) return;
|
||||
messageCacheRef.current.delete(previous);
|
||||
activeViewportTurnByChatIdRef.current.delete(previous);
|
||||
}, [chatId, temporary]);
|
||||
|
||||
const handleQuoteSelection = useCallback((text: string) => {
|
||||
setQuotedContext(text);
|
||||
setComposerFocusSignal((value) => value + 1);
|
||||
@@ -838,6 +856,12 @@ export function ThreadShell({
|
||||
() => modelPresetOptionsFromSettings(settings),
|
||||
[settings],
|
||||
);
|
||||
const availableSlashCommands = useMemo(
|
||||
() => temporary
|
||||
? slashCommands.filter(({ command }) => command === "/model" || command === "/stop")
|
||||
: slashCommands,
|
||||
[slashCommands, temporary],
|
||||
);
|
||||
const modelBadge = useMemo(
|
||||
() => toModelBadgeInfo(modelName, settings, activeModelPreset),
|
||||
[activeModelPreset, modelName, settings],
|
||||
@@ -898,7 +922,7 @@ export function ThreadShell({
|
||||
}, [chatId, client]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!chatId || loading) return;
|
||||
if (!historyKey || !chatId || loading) return;
|
||||
const cached = messageCacheRef.current.get(chatId);
|
||||
const pendingCanonicalHydrate = pendingCanonicalHydrateRef.current.get(chatId);
|
||||
const hasNewCanonicalHistory = (
|
||||
@@ -1028,10 +1052,11 @@ export function ThreadShell({
|
||||
historyLineage,
|
||||
historyActiveTurnId,
|
||||
hasPendingToolCalls,
|
||||
historyKey,
|
||||
]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!chatId) return;
|
||||
if (!historyKey || !chatId) return;
|
||||
const commit = pendingCanonicalCommitRef.current.get(chatId);
|
||||
if (!commit) return;
|
||||
if (
|
||||
@@ -1069,17 +1094,17 @@ export function ThreadShell({
|
||||
pendingCanonicalCommitRef.current.delete(chatId);
|
||||
committedHistoryLineageRef.current.set(chatId, historyLineage);
|
||||
completedCanonicalHydrateVersionRef.current.set(chatId, historyVersion);
|
||||
}, [chatId, client, historyLineage, historyVersion, messages, setMessages]);
|
||||
}, [chatId, client, historyKey, historyLineage, historyVersion, messages, setMessages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!chatId || hasPendingToolCalls) return;
|
||||
if (!historyKey || !chatId || hasPendingToolCalls) return;
|
||||
if (completedCanonicalHydrateVersionRef.current.get(chatId) !== historyVersion) return;
|
||||
completedCanonicalHydrateVersionRef.current.delete(chatId);
|
||||
reconcileTurnComplete();
|
||||
}, [chatId, hasPendingToolCalls, historyVersion, messages, reconcileTurnComplete]);
|
||||
}, [chatId, hasPendingToolCalls, historyKey, historyVersion, messages, reconcileTurnComplete]);
|
||||
|
||||
const refreshCanonicalHistory = useCallback(() => {
|
||||
if (!chatId) return;
|
||||
if (!historyKey || !chatId) return;
|
||||
pendingCanonicalHydrateRef.current.set(chatId, {
|
||||
historyLineage,
|
||||
historyVersion,
|
||||
@@ -1089,10 +1114,10 @@ export function ThreadShell({
|
||||
uiRevision: uiRevisionRef.current,
|
||||
});
|
||||
refreshHistory();
|
||||
}, [chatId, client, historyLineage, historyVersion, refreshHistory]);
|
||||
}, [chatId, client, historyKey, historyLineage, historyVersion, refreshHistory]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!chatId) return;
|
||||
if (!historyKey || !chatId) return;
|
||||
return client.onSessionUpdate((updatedChatId, scope) => {
|
||||
if (updatedChatId !== chatId) return;
|
||||
if (scope === "metadata") return;
|
||||
@@ -1101,7 +1126,7 @@ export function ThreadShell({
|
||||
// so keep an active programmatic follow alive across canonical hydration.
|
||||
refreshCanonicalHistory();
|
||||
});
|
||||
}, [chatId, client, refreshCanonicalHistory]);
|
||||
}, [chatId, client, historyKey, refreshCanonicalHistory]);
|
||||
|
||||
const wasPageHiddenRef = useRef(document.visibilityState === "hidden");
|
||||
useEffect(() => {
|
||||
@@ -1112,7 +1137,7 @@ export function ThreadShell({
|
||||
}
|
||||
if (!wasPageHiddenRef.current) return;
|
||||
wasPageHiddenRef.current = false;
|
||||
if (!chatId || client.status !== "open" || loading) return;
|
||||
if (!historyKey || !chatId || client.status !== "open" || loading) return;
|
||||
if (
|
||||
!turnActive
|
||||
&& !hasPendingToolCalls
|
||||
@@ -1129,6 +1154,7 @@ export function ThreadShell({
|
||||
chatId,
|
||||
client,
|
||||
hasPendingToolCalls,
|
||||
historyKey,
|
||||
historyError,
|
||||
loading,
|
||||
refreshCanonicalHistory,
|
||||
@@ -1386,7 +1412,7 @@ export function ThreadShell({
|
||||
fallbackModelName={fallbackModelName}
|
||||
onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined}
|
||||
variant={showHeroComposer ? "hero" : "thread"}
|
||||
slashCommands={slashCommands}
|
||||
slashCommands={availableSlashCommands}
|
||||
cliApps={cliApps}
|
||||
mcpPresets={mcpPresets}
|
||||
sessions={mentionSessions}
|
||||
@@ -1396,6 +1422,8 @@ export function ThreadShell({
|
||||
runStartedAt={currentRunStartedAt}
|
||||
goalState={currentGoalState}
|
||||
workspaceScope={workspaceScope}
|
||||
compactWorkspaceControls={temporary}
|
||||
workspaceConnected={workspaceConnected}
|
||||
workspaceDefaultScope={workspaceDefaultScope}
|
||||
workspaceControls={workspaceControls}
|
||||
workspaceScopeDisabled={workspaceScopeDisabled}
|
||||
@@ -1429,7 +1457,7 @@ export function ThreadShell({
|
||||
fallbackModelName={fallbackModelName}
|
||||
onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined}
|
||||
variant="hero"
|
||||
slashCommands={slashCommands}
|
||||
slashCommands={availableSlashCommands}
|
||||
cliApps={cliApps}
|
||||
mcpPresets={mcpPresets}
|
||||
sessions={mentionSessions}
|
||||
@@ -1438,6 +1466,8 @@ export function ThreadShell({
|
||||
onTranscribeAudio={transcribeAudio}
|
||||
goalState={currentGoalState}
|
||||
workspaceScope={workspaceScope}
|
||||
compactWorkspaceControls={temporary}
|
||||
workspaceConnected={workspaceConnected}
|
||||
workspaceDefaultScope={workspaceDefaultScope}
|
||||
workspaceControls={workspaceControls}
|
||||
workspaceScopeDisabled={workspaceScopeDisabled}
|
||||
@@ -1461,6 +1491,29 @@ export function ThreadShell({
|
||||
);
|
||||
const sessionInfoAction = historyKey ? (
|
||||
<SessionInfoPopover sessionKey={historyKey} token={token} title={title} />
|
||||
) : temporary ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<span
|
||||
className="rounded-full border border-border/70 bg-muted/35 px-2 py-1 text-[11px] text-muted-foreground"
|
||||
title={t("temporaryChat.description")}
|
||||
>
|
||||
{t("temporaryChat.notSaved")}
|
||||
</span>
|
||||
{onClearTemporaryChat ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={turnActive}
|
||||
aria-label={t("temporaryChat.clear")}
|
||||
title={t("temporaryChat.clear")}
|
||||
onClick={onClearTemporaryChat}
|
||||
className="h-8 w-8 rounded-full text-muted-foreground/85 hover:bg-accent/40 hover:text-foreground"
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : undefined;
|
||||
const promptNavigatorAction = historyKey ? (
|
||||
<PromptNavigator
|
||||
@@ -1502,7 +1555,7 @@ export function ThreadShell({
|
||||
showScrollToBottomButton={!!session}
|
||||
cliApps={cliApps}
|
||||
mcpPresets={mcpPresets}
|
||||
slashCommands={slashCommands}
|
||||
slashCommands={availableSlashCommands}
|
||||
forkBoundaryMessageCount={forkBoundaryMessageCount}
|
||||
hasMoreBefore={hasMoreBefore}
|
||||
loadingOlder={loadingOlder}
|
||||
|
||||
@@ -36,6 +36,8 @@ import {
|
||||
|
||||
export function WorkspaceProjectPicker({
|
||||
isHero,
|
||||
compact = false,
|
||||
connected = false,
|
||||
disabled,
|
||||
scope,
|
||||
defaultScope,
|
||||
@@ -44,6 +46,8 @@ export function WorkspaceProjectPicker({
|
||||
onChange,
|
||||
}: {
|
||||
isHero: boolean;
|
||||
compact?: boolean;
|
||||
connected?: boolean;
|
||||
disabled?: boolean;
|
||||
scope: WorkspaceScopePayload | null;
|
||||
defaultScope: WorkspaceScopePayload | null;
|
||||
@@ -115,7 +119,11 @@ export function WorkspaceProjectPicker({
|
||||
|
||||
if (nativeProjectPicker) {
|
||||
return (
|
||||
<div className="flex min-w-0 items-center rounded-b-[28px] bg-muted/45 px-3 py-1.5 dark:bg-white/[0.045] sm:px-4">
|
||||
<div className={cn(
|
||||
compact
|
||||
? "inline-flex"
|
||||
: "flex min-w-0 items-center rounded-b-[28px] bg-muted/45 px-3 py-1.5 dark:bg-white/[0.045] sm:px-4",
|
||||
)}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled || pickingFolder}
|
||||
@@ -123,16 +131,18 @@ export function WorkspaceProjectPicker({
|
||||
title={currentProjectScope?.project_path}
|
||||
onClick={() => void pickNativeFolder()}
|
||||
className={cn(
|
||||
"inline-flex h-7 max-w-full items-center gap-2 rounded-full px-2.5 sm:max-w-[18rem]",
|
||||
"text-[12px] font-medium text-muted-foreground/90 transition-colors",
|
||||
"hover:bg-background/70 hover:text-foreground disabled:pointer-events-none disabled:opacity-55",
|
||||
currentProjectScope && "text-foreground/82",
|
||||
compact
|
||||
? "thread-composer-action touch-target inline-flex h-8 w-8 items-center justify-center rounded-full border border-transparent"
|
||||
: "inline-flex h-7 max-w-full items-center gap-2 rounded-full px-2.5 sm:max-w-[18rem]",
|
||||
"text-[12px] font-medium text-muted-foreground/90 transition-colors hover:text-foreground disabled:pointer-events-none disabled:opacity-55",
|
||||
compact ? "hover:bg-muted/65" : "hover:bg-background/70",
|
||||
(connected || currentProjectScope) && "text-primary",
|
||||
)}
|
||||
>
|
||||
<Folder className={cn("h-3.5 w-3.5 shrink-0", currentProjectScope && "text-primary")} />
|
||||
<span className="truncate">{projectLabel}</span>
|
||||
<Folder className={cn("shrink-0", compact ? "h-4 w-4" : "h-3.5 w-3.5")} />
|
||||
<span className={compact ? "sr-only" : "truncate"}>{projectLabel}</span>
|
||||
</button>
|
||||
{pathError || error ? (
|
||||
{!compact && (pathError || error) ? (
|
||||
<span role="alert" className="ml-2 min-w-0 truncate text-[11.5px] font-medium text-destructive">
|
||||
{pathError ?? error}
|
||||
</span>
|
||||
@@ -142,7 +152,11 @@ export function WorkspaceProjectPicker({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 items-center rounded-b-[28px] bg-muted/45 px-3 py-1.5 dark:bg-white/[0.045] sm:px-4">
|
||||
<div className={cn(
|
||||
compact
|
||||
? "inline-flex"
|
||||
: "flex min-w-0 items-center rounded-b-[28px] bg-muted/45 px-3 py-1.5 dark:bg-white/[0.045] sm:px-4",
|
||||
)}>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
@@ -150,15 +164,19 @@ export function WorkspaceProjectPicker({
|
||||
disabled={disabled}
|
||||
aria-label={t("thread.composer.workspace.projectAria")}
|
||||
className={cn(
|
||||
"inline-flex h-7 max-w-full items-center gap-2 rounded-full px-2.5 sm:max-w-[18rem]",
|
||||
"text-[12px] font-medium text-muted-foreground/90 transition-colors",
|
||||
"hover:bg-background/70 hover:text-foreground disabled:pointer-events-none disabled:opacity-55",
|
||||
currentProjectScope && "text-foreground/82",
|
||||
compact
|
||||
? "thread-composer-action touch-target inline-flex h-8 w-8 items-center justify-center rounded-full border border-transparent"
|
||||
: "inline-flex h-7 max-w-full items-center gap-2 rounded-full px-2.5 sm:max-w-[18rem]",
|
||||
"text-[12px] font-medium text-muted-foreground/90 transition-colors hover:text-foreground disabled:pointer-events-none disabled:opacity-55",
|
||||
compact ? "hover:bg-muted/65" : "hover:bg-background/70",
|
||||
(connected || currentProjectScope) && "text-primary",
|
||||
)}
|
||||
>
|
||||
<Folder className={cn("h-3.5 w-3.5 shrink-0", currentProjectScope && "text-primary")} />
|
||||
<span className="truncate">{projectLabel}</span>
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<Folder className={cn("shrink-0", compact ? "h-4 w-4" : "h-3.5 w-3.5")} />
|
||||
<span className={compact ? "sr-only" : "truncate"}>{projectLabel}</span>
|
||||
{!compact ? (
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
) : null}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
|
||||
Reference in New Issue
Block a user