mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Fix duplicate workspace shell navigation
This commit is contained in:
@@ -36,6 +36,7 @@ import {
|
||||
workspaceDeckEntryLocator,
|
||||
expectWorkspaceDeckEntryCount,
|
||||
} from "./helpers/workspace-ui";
|
||||
import { clickSettingsBackToWorkspace } from "./helpers/settings";
|
||||
|
||||
const LOADING_WORKSPACE_TEXT_PATTERN = /Loading workspace/i;
|
||||
|
||||
@@ -75,6 +76,18 @@ async function expectNoLoadingPane(page: Page): Promise<void> {
|
||||
await expect(page.getByText(LOADING_WORKSPACE_TEXT_PATTERN)).toHaveCount(0);
|
||||
}
|
||||
|
||||
async function getVisibleDraftTabCount(page: Page): Promise<number> {
|
||||
return page.locator('[data-testid^="workspace-tab-draft"]').filter({ visible: true }).count();
|
||||
}
|
||||
|
||||
async function closeFirstVisibleDraftTab(page: Page): Promise<void> {
|
||||
const closeButton = page.locator('[data-testid^="workspace-draft-close-"]').filter({
|
||||
visible: true,
|
||||
});
|
||||
await expect(closeButton.first()).toBeVisible({ timeout: 30_000 });
|
||||
await closeButton.first().click();
|
||||
}
|
||||
|
||||
async function installDaemonWebSocketGate(page: Page, daemonPort: string) {
|
||||
let acceptingConnections = true;
|
||||
const activeSockets = new Set<WebSocketRoute>();
|
||||
@@ -133,6 +146,25 @@ async function installDaemonWebSocketGate(page: Page, daemonPort: string) {
|
||||
test.describe("Workspace navigation regression", () => {
|
||||
test.describe.configure({ timeout: 240_000 });
|
||||
|
||||
test("keeps one replacement draft after returning from settings and closing the last tab", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
const workspace = await withWorkspace({ prefix: "workspace-settings-back-tab-" });
|
||||
|
||||
await workspace.navigateTo();
|
||||
await expect.poll(() => getVisibleDraftTabCount(page), { timeout: 30_000 }).toBe(1);
|
||||
|
||||
await openSettings(page);
|
||||
await clickSettingsBackToWorkspace(page);
|
||||
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
|
||||
await expect.poll(() => getVisibleDraftTabCount(page), { timeout: 30_000 }).toBe(1);
|
||||
|
||||
await closeFirstVisibleDraftTab(page);
|
||||
|
||||
await expect.poll(() => getVisibleDraftTabCount(page), { timeout: 30_000 }).toBe(1);
|
||||
});
|
||||
|
||||
test("keeps the workspace rendered while reconnecting to the host", async ({ page }) => {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useLocalSearchParams, useRouter, type Href } from "expo-router";
|
||||
import { useLocalSearchParams, usePathname, useRouter, type Href } from "expo-router";
|
||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useResolveWorkspaceIdByCwd } from "@/stores/session-store-hooks";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { buildHostRootRoute } from "@/utils/host-routes";
|
||||
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
|
||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
|
||||
export default function HostAgentReadyRoute() {
|
||||
return (
|
||||
@@ -18,6 +18,7 @@ export default function HostAgentReadyRoute() {
|
||||
|
||||
function HostAgentReadyRouteContent() {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const params = useLocalSearchParams<{
|
||||
serverId?: string;
|
||||
agentId?: string;
|
||||
@@ -50,15 +51,15 @@ function HostAgentReadyRouteContent() {
|
||||
|
||||
if (resolvedWorkspaceId) {
|
||||
redirectedRef.current = true;
|
||||
router.replace(
|
||||
prepareWorkspaceTab({
|
||||
serverId,
|
||||
workspaceId: resolvedWorkspaceId,
|
||||
target: { kind: "agent", agentId },
|
||||
}) as Href,
|
||||
);
|
||||
navigateToPreparedWorkspaceTab({
|
||||
serverId,
|
||||
workspaceId: resolvedWorkspaceId,
|
||||
target: { kind: "agent", agentId },
|
||||
navigationMethod: "replace",
|
||||
currentPathname: pathname,
|
||||
});
|
||||
}
|
||||
}, [agentId, resolvedWorkspaceId, router, serverId]);
|
||||
}, [agentId, pathname, resolvedWorkspaceId, router, serverId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (redirectedRef.current) {
|
||||
@@ -102,13 +103,13 @@ function HostAgentReadyRouteContent() {
|
||||
}
|
||||
redirectedRef.current = true;
|
||||
if (workspaceId) {
|
||||
router.replace(
|
||||
prepareWorkspaceTab({
|
||||
serverId,
|
||||
workspaceId,
|
||||
target: { kind: "agent", agentId },
|
||||
}) as Href,
|
||||
);
|
||||
navigateToPreparedWorkspaceTab({
|
||||
serverId,
|
||||
workspaceId,
|
||||
target: { kind: "agent", agentId },
|
||||
navigationMethod: "replace",
|
||||
currentPathname: pathname,
|
||||
});
|
||||
return;
|
||||
}
|
||||
router.replace(buildHostRootRoute(serverId));
|
||||
@@ -125,7 +126,7 @@ function HostAgentReadyRouteContent() {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [agentId, client, hasHydratedWorkspaces, isConnected, router, serverId]);
|
||||
}, [agentId, client, hasHydratedWorkspaces, isConnected, pathname, router, serverId]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ import { Archive } from "lucide-react-native";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
|
||||
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
|
||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
import type { Agent } from "@/stores/session-store";
|
||||
import { useArchiveAgent } from "@/hooks/use-archive-agent";
|
||||
|
||||
@@ -324,13 +324,12 @@ export function AgentList({
|
||||
}
|
||||
|
||||
rememberArchivedAgentDetail(agent);
|
||||
const route = prepareWorkspaceTab({
|
||||
navigateToPreparedWorkspaceTab({
|
||||
serverId,
|
||||
workspaceId,
|
||||
target: { kind: "agent", agentId },
|
||||
pin: Boolean(agent.archivedAt),
|
||||
});
|
||||
router.navigate(route);
|
||||
},
|
||||
[isActionSheetVisible, onAgentSelect],
|
||||
);
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useRouter } from "expo-router";
|
||||
import Animated, {
|
||||
FadeIn,
|
||||
FadeOut,
|
||||
@@ -83,7 +82,7 @@ import {
|
||||
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
|
||||
import { normalizeInlinePathTarget } from "@/utils/inline-path";
|
||||
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
|
||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import {
|
||||
getWorkingIndicatorDotStrength,
|
||||
@@ -135,7 +134,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
ref,
|
||||
) {
|
||||
const viewportRef = useRef<StreamViewportHandle | null>(null);
|
||||
const router = useRouter();
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const streamRenderStrategy = useMemo(
|
||||
() =>
|
||||
@@ -213,12 +211,11 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
}
|
||||
|
||||
if (workspaceId) {
|
||||
const route = prepareWorkspaceTab({
|
||||
navigateToPreparedWorkspaceTab({
|
||||
serverId: resolvedServerId,
|
||||
workspaceId,
|
||||
target: { kind: "file", path: normalized.file },
|
||||
});
|
||||
router.navigate(route);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -247,7 +244,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
onOpenWorkspaceFile,
|
||||
requestDirectoryListing,
|
||||
resolvedServerId,
|
||||
router,
|
||||
setExplorerTabForCheckout,
|
||||
openWorkspaceFile,
|
||||
workspaceId,
|
||||
|
||||
@@ -19,7 +19,7 @@ import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-over
|
||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||
import { getIsElectronRuntime } from "@/constants/layout";
|
||||
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
|
||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
import { focusWithRetries } from "@/utils/web-focus";
|
||||
import { useActiveServerId } from "@/hooks/use-active-server-id";
|
||||
|
||||
@@ -123,7 +123,7 @@ function resolveActionShortcutKeys(
|
||||
}
|
||||
|
||||
export function useCommandCenter() {
|
||||
const _pathname = usePathname();
|
||||
const pathname = usePathname();
|
||||
const routeActiveServerId = useActiveServerId();
|
||||
const { overrides } = useKeyboardShortcutOverrides();
|
||||
const open = useKeyboardShortcutsStore((s) => s.commandCenterOpen);
|
||||
@@ -212,14 +212,14 @@ export function useCommandCenter() {
|
||||
router.navigate(buildHostAgentDetailRoute(agent.serverId, agent.id) as Href);
|
||||
return;
|
||||
}
|
||||
const route = prepareWorkspaceTab({
|
||||
navigateToPreparedWorkspaceTab({
|
||||
serverId: agent.serverId,
|
||||
workspaceId,
|
||||
target: { kind: "agent", agentId: agent.id },
|
||||
currentPathname: pathname,
|
||||
});
|
||||
router.navigate(route);
|
||||
},
|
||||
[setOpen],
|
||||
[pathname, setOpen],
|
||||
);
|
||||
|
||||
const openProjectPicker = useOpenProjectPicker(activeServerId);
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.hoisted(() => {
|
||||
(globalThis as typeof globalThis & { __DEV__?: boolean }).__DEV__ = false;
|
||||
});
|
||||
|
||||
vi.mock("@react-native-async-storage/async-storage", () => {
|
||||
const storage = new Map<string, string>();
|
||||
return {
|
||||
@@ -19,10 +23,8 @@ const { replaceRoute } = vi.hoisted(() => ({
|
||||
replaceRoute: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("expo-router", () => ({
|
||||
router: {
|
||||
replace: replaceRoute,
|
||||
},
|
||||
vi.mock("@/hooks/use-workspace-navigation", () => ({
|
||||
navigateToWorkspace: replaceRoute,
|
||||
}));
|
||||
|
||||
import { openProjectDirectly } from "@/hooks/use-open-project";
|
||||
@@ -89,7 +91,7 @@ describe("openProjectDirectly", () => {
|
||||
mergeWorkspaces: useSessionStore.getState().mergeWorkspaces,
|
||||
setHasHydratedWorkspaces: useSessionStore.getState().setHasHydratedWorkspaces,
|
||||
openDraftTab: createOpenDraftTab(),
|
||||
replaceRoute,
|
||||
navigateToWorkspace: replaceRoute,
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
@@ -115,7 +117,7 @@ describe("openProjectDirectly", () => {
|
||||
const tabs = collectAllTabs(layout.root);
|
||||
expect(tabs).toHaveLength(1);
|
||||
expect(tabs[0]?.target.kind).toBe("draft");
|
||||
expect(replaceRoute).toHaveBeenCalledWith("/h/server-1/workspace/1");
|
||||
expect(replaceRoute).toHaveBeenCalledWith("server-1", "1");
|
||||
});
|
||||
|
||||
it("does not navigate or seed tabs when openProject fails", async () => {
|
||||
@@ -133,7 +135,7 @@ describe("openProjectDirectly", () => {
|
||||
mergeWorkspaces: useSessionStore.getState().mergeWorkspaces,
|
||||
setHasHydratedWorkspaces: useSessionStore.getState().setHasHydratedWorkspaces,
|
||||
openDraftTab: createOpenDraftTab(),
|
||||
replaceRoute,
|
||||
navigateToWorkspace: replaceRoute,
|
||||
});
|
||||
|
||||
expect(result).toBe(false);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useCallback } from "react";
|
||||
import { router, type Href } from "expo-router";
|
||||
import type { DaemonClient } from "@server/client/daemon-client";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import {
|
||||
@@ -12,7 +11,7 @@ import {
|
||||
useWorkspaceLayoutStore,
|
||||
} from "@/stores/workspace-layout-store";
|
||||
import { generateDraftId } from "@/stores/draft-keys";
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { navigateToWorkspace } from "@/hooks/use-workspace-navigation";
|
||||
|
||||
interface OpenProjectDirectlyInput {
|
||||
serverId: string;
|
||||
@@ -22,7 +21,7 @@ interface OpenProjectDirectlyInput {
|
||||
mergeWorkspaces: (serverId: string, workspaces: Iterable<WorkspaceDescriptor>) => void;
|
||||
setHasHydratedWorkspaces: (serverId: string, hydrated: boolean) => void;
|
||||
openDraftTab: (workspaceKey: string) => string | null;
|
||||
replaceRoute: (route: string) => void;
|
||||
navigateToWorkspace: (serverId: string, workspaceId: string) => void;
|
||||
}
|
||||
|
||||
export async function openProjectDirectly(input: OpenProjectDirectlyInput): Promise<boolean> {
|
||||
@@ -50,7 +49,7 @@ export async function openProjectDirectly(input: OpenProjectDirectlyInput): Prom
|
||||
}
|
||||
|
||||
input.openDraftTab(workspaceKey);
|
||||
input.replaceRoute(buildHostWorkspaceRoute(normalizedServerId, workspace.id));
|
||||
input.navigateToWorkspace(normalizedServerId, workspace.id);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -75,9 +74,7 @@ export function useOpenProject(serverId: string | null): (path: string) => Promi
|
||||
kind: "draft",
|
||||
draftId: generateDraftId(),
|
||||
}),
|
||||
replaceRoute: (route) => {
|
||||
router.replace(route as Href);
|
||||
},
|
||||
navigateToWorkspace,
|
||||
});
|
||||
},
|
||||
[client, isConnected, mergeWorkspaces, normalizedServerId, setHasHydratedWorkspaces],
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { navigateMock } = vi.hoisted(() => ({
|
||||
navigateMock: vi.fn(),
|
||||
const { dismissToMock } = vi.hoisted(() => ({
|
||||
dismissToMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("expo-router", () => ({
|
||||
router: {
|
||||
navigate: navigateMock,
|
||||
dismissTo: dismissToMock,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -19,11 +19,11 @@ import {
|
||||
|
||||
describe("navigateToWorkspace", () => {
|
||||
beforeEach(() => {
|
||||
navigateMock.mockReset();
|
||||
dismissToMock.mockReset();
|
||||
syncNavigationActiveWorkspace({ current: null });
|
||||
});
|
||||
|
||||
it("uses router navigation from a non-workspace route even when active selection is stale", () => {
|
||||
it("dismisses to the workspace route from a non-workspace route even when active selection is stale", () => {
|
||||
activateNavigationWorkspaceSelection({
|
||||
serverId: "server-1",
|
||||
workspaceId: "workspace-a",
|
||||
@@ -33,7 +33,7 @@ describe("navigateToWorkspace", () => {
|
||||
currentPathname: "/h/server-1/sessions",
|
||||
});
|
||||
|
||||
expect(navigateMock).toHaveBeenCalledWith("/h/server-1/workspace/workspace-b");
|
||||
expect(dismissToMock).toHaveBeenCalledWith("/h/server-1/workspace/workspace-b");
|
||||
expect(getNavigationActiveWorkspaceSelection()).toEqual({
|
||||
serverId: "server-1",
|
||||
workspaceId: "workspace-a",
|
||||
@@ -50,7 +50,7 @@ describe("navigateToWorkspace", () => {
|
||||
currentPathname: "/h/server-1/workspace/workspace-a",
|
||||
});
|
||||
|
||||
expect(navigateMock).not.toHaveBeenCalled();
|
||||
expect(dismissToMock).not.toHaveBeenCalled();
|
||||
expect(getNavigationActiveWorkspaceSelection()).toEqual({
|
||||
serverId: "server-1",
|
||||
workspaceId: "workspace-b",
|
||||
|
||||
@@ -19,7 +19,7 @@ function shouldUseRetainedWorkspaceSwitch(input: {
|
||||
}
|
||||
|
||||
if (input.currentPathname == null) {
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
return parseHostWorkspaceRouteFromPathname(input.currentPathname) !== null;
|
||||
@@ -49,7 +49,7 @@ export function navigateToWorkspace(
|
||||
}
|
||||
|
||||
const href = buildHostWorkspaceRoute(serverId, workspaceId);
|
||||
router.navigate(href);
|
||||
router.dismissTo(href);
|
||||
}
|
||||
|
||||
export function useWorkspaceNavigation() {
|
||||
|
||||
@@ -78,13 +78,13 @@ import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon";
|
||||
import {
|
||||
buildHostOpenProjectRoute,
|
||||
buildHostWorkspaceRoute,
|
||||
buildProjectsSettingsRoute,
|
||||
buildSettingsHostRoute,
|
||||
buildSettingsSectionRoute,
|
||||
type SettingsSectionSlug,
|
||||
} from "@/utils/host-routes";
|
||||
import { getLastNavigationWorkspaceRouteSelection } from "@/stores/navigation-active-workspace-store";
|
||||
import { navigateToWorkspace } from "@/hooks/use-workspace-navigation";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// View model
|
||||
@@ -963,9 +963,7 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
|
||||
const handleBackToWorkspace = useCallback(() => {
|
||||
const lastWorkspaceRoute = getLastNavigationWorkspaceRouteSelection();
|
||||
if (lastWorkspaceRoute) {
|
||||
router.replace(
|
||||
buildHostWorkspaceRoute(lastWorkspaceRoute.serverId, lastWorkspaceRoute.workspaceId),
|
||||
);
|
||||
navigateToWorkspace(lastWorkspaceRoute.serverId, lastWorkspaceRoute.workspaceId);
|
||||
return;
|
||||
}
|
||||
if (anyOnlineServerId) {
|
||||
|
||||
@@ -8,6 +8,7 @@ const { platformState, routerMock } = vi.hoisted(() => ({
|
||||
routerMock: {
|
||||
back: vi.fn(),
|
||||
canGoBack: vi.fn(() => false),
|
||||
dismissTo: vi.fn(),
|
||||
navigate: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
},
|
||||
@@ -60,6 +61,7 @@ describe("prepareWorkspaceTab", () => {
|
||||
routerMock.back.mockReset();
|
||||
routerMock.canGoBack.mockReset();
|
||||
routerMock.canGoBack.mockReturnValue(false);
|
||||
routerMock.dismissTo.mockReset();
|
||||
routerMock.navigate.mockReset();
|
||||
routerMock.replace.mockReset();
|
||||
syncNavigationActiveWorkspace({ current: null });
|
||||
@@ -106,6 +108,7 @@ describe("prepareWorkspaceTab", () => {
|
||||
|
||||
expect(route).toBe("/h/server-1/workspace/b64_L3JlcG8vd29ya3RyZWU");
|
||||
expect(routerMock.back).toHaveBeenCalledOnce();
|
||||
expect(routerMock.dismissTo).not.toHaveBeenCalled();
|
||||
expect(routerMock.replace).not.toHaveBeenCalled();
|
||||
|
||||
vi.runAllTimers();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { router, type Href } from "expo-router";
|
||||
import { router } from "expo-router";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import { navigateToWorkspace } from "@/hooks/use-workspace-navigation";
|
||||
import {
|
||||
activateNavigationWorkspaceSelection,
|
||||
getLastNavigationWorkspaceRouteSelection,
|
||||
@@ -22,6 +23,7 @@ interface PrepareWorkspaceTabInput {
|
||||
|
||||
interface NavigateToPreparedWorkspaceTabInput extends PrepareWorkspaceTabInput {
|
||||
navigationMethod?: "navigate" | "replace";
|
||||
currentPathname?: string | null;
|
||||
}
|
||||
|
||||
function getPreparedTarget(target: WorkspaceTabTarget): WorkspaceTabTarget {
|
||||
@@ -65,9 +67,13 @@ export function navigateToPreparedWorkspaceTab(input: NavigateToPreparedWorkspac
|
||||
}, 0);
|
||||
return route;
|
||||
}
|
||||
router.replace(route as Href);
|
||||
navigateToWorkspace(input.serverId, input.workspaceId, {
|
||||
currentPathname: input.currentPathname,
|
||||
});
|
||||
} else {
|
||||
router.navigate(route as Href);
|
||||
navigateToWorkspace(input.serverId, input.workspaceId, {
|
||||
currentPathname: input.currentPathname,
|
||||
});
|
||||
}
|
||||
return route;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user