refactor(app): extract workspace archive transaction (#848)
This commit is contained in:
@@ -101,16 +101,15 @@ import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspac
|
|||||||
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
|
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
|
||||||
import { useWorkspaceFields } from "@/stores/session-store-hooks";
|
import { useWorkspaceFields } from "@/stores/session-store-hooks";
|
||||||
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
|
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
|
||||||
import {
|
|
||||||
clearWorkspaceArchivePending,
|
|
||||||
markWorkspaceArchivePending,
|
|
||||||
} from "@/contexts/session-workspace-upserts";
|
|
||||||
import { openExternalUrl } from "@/utils/open-external-url";
|
import { openExternalUrl } from "@/utils/open-external-url";
|
||||||
import {
|
import {
|
||||||
requireWorkspaceExecutionDirectory,
|
requireWorkspaceExecutionDirectory,
|
||||||
resolveWorkspaceMapKeyByIdentity,
|
|
||||||
resolveWorkspaceExecutionDirectory,
|
resolveWorkspaceExecutionDirectory,
|
||||||
} from "@/utils/workspace-execution";
|
} from "@/utils/workspace-execution";
|
||||||
|
import {
|
||||||
|
archiveWorkspaceOptimistically,
|
||||||
|
archiveWorkspacesOptimistically,
|
||||||
|
} from "@/workspace/workspace-archive";
|
||||||
import { WorkspaceHoverCard } from "@/components/workspace-hover-card";
|
import { WorkspaceHoverCard } from "@/components/workspace-hover-card";
|
||||||
import { GitHubIcon } from "@/components/icons/github-icon";
|
import { GitHubIcon } from "@/components/icons/github-icon";
|
||||||
import { isWeb as platformIsWeb, isNative as platformIsNative } from "@/constants/platform";
|
import { isWeb as platformIsWeb, isNative as platformIsNative } from "@/constants/platform";
|
||||||
@@ -126,35 +125,6 @@ const workspaceKeyExtractor = (workspace: SidebarWorkspaceEntry) => workspace.wo
|
|||||||
|
|
||||||
const projectKeyExtractor = (project: SidebarProjectEntry) => project.projectKey;
|
const projectKeyExtractor = (project: SidebarProjectEntry) => project.projectKey;
|
||||||
|
|
||||||
function hideWorkspaceOptimistically(workspace: SidebarWorkspaceEntry): WorkspaceDescriptor | null {
|
|
||||||
const workspaces = useSessionStore.getState().sessions[workspace.serverId]?.workspaces;
|
|
||||||
const workspaceKey = resolveWorkspaceMapKeyByIdentity({
|
|
||||||
workspaces,
|
|
||||||
workspaceId: workspace.workspaceId,
|
|
||||||
});
|
|
||||||
const snapshot = workspaceKey ? (workspaces?.get(workspaceKey) ?? null) : null;
|
|
||||||
markWorkspaceArchivePending({
|
|
||||||
serverId: workspace.serverId,
|
|
||||||
workspaceId: workspace.workspaceId,
|
|
||||||
workspaceDirectory: workspace.workspaceDirectory,
|
|
||||||
});
|
|
||||||
useSessionStore.getState().removeWorkspace(workspace.serverId, workspace.workspaceId);
|
|
||||||
return snapshot;
|
|
||||||
}
|
|
||||||
|
|
||||||
function restoreOptimisticallyHiddenWorkspace(input: {
|
|
||||||
serverId: string;
|
|
||||||
workspaceId: string;
|
|
||||||
snapshot: WorkspaceDescriptor | null;
|
|
||||||
}): void {
|
|
||||||
clearWorkspaceArchivePending({
|
|
||||||
serverId: input.serverId,
|
|
||||||
workspaceId: input.workspaceId,
|
|
||||||
});
|
|
||||||
if (input.snapshot) {
|
|
||||||
useSessionStore.getState().mergeWorkspaces(input.serverId, [input.snapshot]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const WORKSPACE_STATUS_DOT_WIDTH = 14;
|
const WORKSPACE_STATUS_DOT_WIDTH = 14;
|
||||||
const DEFAULT_STATUS_DOT_SIZE = 7;
|
const DEFAULT_STATUS_DOT_SIZE = 7;
|
||||||
const EMPHASIZED_STATUS_DOT_SIZE = 9;
|
const EMPHASIZED_STATUS_DOT_SIZE = 9;
|
||||||
@@ -1574,21 +1544,14 @@ function WorkspaceRowWithMenu({
|
|||||||
}
|
}
|
||||||
|
|
||||||
setIsArchivingWorkspace(true);
|
setIsArchivingWorkspace(true);
|
||||||
const snapshot = hideWorkspaceOptimistically(workspace);
|
|
||||||
redirectAfterArchive();
|
|
||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
const payload = await client.archiveWorkspace(workspace.workspaceId);
|
await archiveWorkspaceOptimistically({
|
||||||
if (payload.error) {
|
client,
|
||||||
throw new Error(payload.error);
|
workspace,
|
||||||
}
|
afterHide: redirectAfterArchive,
|
||||||
} catch (error) {
|
|
||||||
restoreOptimisticallyHiddenWorkspace({
|
|
||||||
serverId: workspace.serverId,
|
|
||||||
workspaceId: workspace.workspaceId,
|
|
||||||
snapshot,
|
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
toast.error(error instanceof Error ? error.message : "Failed to hide workspace");
|
toast.error(error instanceof Error ? error.message : "Failed to hide workspace");
|
||||||
} finally {
|
} finally {
|
||||||
setIsArchivingWorkspace(false);
|
setIsArchivingWorkspace(false);
|
||||||
@@ -1719,21 +1682,14 @@ function NonGitProjectRowWithMenuContent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
setIsArchivingWorkspace(true);
|
setIsArchivingWorkspace(true);
|
||||||
const snapshot = hideWorkspaceOptimistically(workspace);
|
|
||||||
redirectAfterArchive();
|
|
||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
const payload = await client.archiveWorkspace(workspace.workspaceId);
|
await archiveWorkspaceOptimistically({
|
||||||
if (payload.error) {
|
client,
|
||||||
throw new Error(payload.error);
|
workspace,
|
||||||
}
|
afterHide: redirectAfterArchive,
|
||||||
} catch (error) {
|
|
||||||
restoreOptimisticallyHiddenWorkspace({
|
|
||||||
serverId: workspace.serverId,
|
|
||||||
workspaceId: workspace.workspaceId,
|
|
||||||
snapshot,
|
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
toast.error(error instanceof Error ? error.message : "Failed to hide workspace");
|
toast.error(error instanceof Error ? error.message : "Failed to hide workspace");
|
||||||
} finally {
|
} finally {
|
||||||
setIsArchivingWorkspace(false);
|
setIsArchivingWorkspace(false);
|
||||||
@@ -2145,33 +2101,11 @@ function ProjectBlock({
|
|||||||
}
|
}
|
||||||
|
|
||||||
setIsRemovingProject(true);
|
setIsRemovingProject(true);
|
||||||
const snapshots = new Map(
|
void archiveWorkspacesOptimistically({
|
||||||
project.workspaces.map((workspace) => [
|
client,
|
||||||
workspace.workspaceId,
|
workspaces: project.workspaces,
|
||||||
hideWorkspaceOptimistically(workspace),
|
}).then((failures) => {
|
||||||
]),
|
if (failures.length > 0) {
|
||||||
);
|
|
||||||
|
|
||||||
const isRejected = (r: PromiseSettledResult<unknown>) => r.status === "rejected";
|
|
||||||
void Promise.allSettled(
|
|
||||||
project.workspaces.map(async (ws) => {
|
|
||||||
try {
|
|
||||||
const payload = await client.archiveWorkspace(ws.workspaceId);
|
|
||||||
if (payload.error) {
|
|
||||||
throw new Error(payload.error);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
restoreOptimisticallyHiddenWorkspace({
|
|
||||||
serverId,
|
|
||||||
workspaceId: ws.workspaceId,
|
|
||||||
snapshot: snapshots.get(ws.workspaceId) ?? null,
|
|
||||||
});
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
).then((results) => {
|
|
||||||
const failed = results.filter(isRejected);
|
|
||||||
if (failed.length > 0) {
|
|
||||||
toast.error("Failed to remove some workspaces");
|
toast.error("Failed to remove some workspaces");
|
||||||
}
|
}
|
||||||
setIsRemovingProject(false);
|
setIsRemovingProject(false);
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import type { DaemonClient } from "@server/client/daemon-client";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
clearWorkspaceArchivePending,
|
||||||
|
isWorkspaceArchivePending,
|
||||||
|
} from "@/contexts/session-workspace-upserts";
|
||||||
|
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
|
||||||
|
import {
|
||||||
|
archiveWorkspaceOptimistically,
|
||||||
|
archiveWorkspacesOptimistically,
|
||||||
|
type WorkspaceArchiveTarget,
|
||||||
|
} from "@/workspace/workspace-archive";
|
||||||
|
|
||||||
|
const SERVER_ID = "workspace-archive-test";
|
||||||
|
|
||||||
|
type ArchiveWorkspacePayload = Awaited<ReturnType<DaemonClient["archiveWorkspace"]>>;
|
||||||
|
|
||||||
|
function archivePayload(input: {
|
||||||
|
workspaceId: string;
|
||||||
|
error?: string | null;
|
||||||
|
}): ArchiveWorkspacePayload {
|
||||||
|
return {
|
||||||
|
requestId: "request",
|
||||||
|
workspaceId: input.workspaceId,
|
||||||
|
archivedAt: null,
|
||||||
|
error: input.error ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function workspace(input?: Partial<WorkspaceDescriptor>): WorkspaceDescriptor {
|
||||||
|
return {
|
||||||
|
id: "workspace-1",
|
||||||
|
projectId: "project-1",
|
||||||
|
projectDisplayName: "Project",
|
||||||
|
projectRootPath: "/repo/project",
|
||||||
|
workspaceDirectory: "/repo/project/workspace-1",
|
||||||
|
projectKind: "git",
|
||||||
|
workspaceKind: "worktree",
|
||||||
|
name: "workspace-1",
|
||||||
|
status: "done",
|
||||||
|
archivingAt: null,
|
||||||
|
diffStat: null,
|
||||||
|
scripts: [],
|
||||||
|
...input,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function target(input?: Partial<WorkspaceArchiveTarget>): WorkspaceArchiveTarget {
|
||||||
|
const base = workspace();
|
||||||
|
return {
|
||||||
|
serverId: SERVER_ID,
|
||||||
|
workspaceId: base.id,
|
||||||
|
workspaceDirectory: base.workspaceDirectory,
|
||||||
|
...input,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createClient(
|
||||||
|
archiveWorkspace: DaemonClient["archiveWorkspace"],
|
||||||
|
): Pick<DaemonClient, "archiveWorkspace"> {
|
||||||
|
return { archiveWorkspace };
|
||||||
|
}
|
||||||
|
|
||||||
|
function deferred<T>(): {
|
||||||
|
promise: Promise<T>;
|
||||||
|
resolve: (value: T) => void;
|
||||||
|
reject: (error: unknown) => void;
|
||||||
|
} {
|
||||||
|
let resolve: (value: T) => void = () => {};
|
||||||
|
let reject: (error: unknown) => void = () => {};
|
||||||
|
const promise = new Promise<T>((promiseResolve, promiseReject) => {
|
||||||
|
resolve = promiseResolve;
|
||||||
|
reject = promiseReject;
|
||||||
|
});
|
||||||
|
return { promise, resolve, reject };
|
||||||
|
}
|
||||||
|
|
||||||
|
function storedWorkspace(id: string): WorkspaceDescriptor | undefined {
|
||||||
|
return useSessionStore.getState().sessions[SERVER_ID]?.workspaces.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
useSessionStore.getState().initializeSession(SERVER_ID, {} as DaemonClient);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
clearWorkspaceArchivePending({ serverId: SERVER_ID, workspaceId: "workspace-1" });
|
||||||
|
clearWorkspaceArchivePending({ serverId: SERVER_ID, workspaceId: "workspace-2" });
|
||||||
|
useSessionStore.setState((state) => ({ ...state, sessions: {} }));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("archiveWorkspaceOptimistically", () => {
|
||||||
|
it("hides the workspace and marks the archive pending while the daemon call runs", async () => {
|
||||||
|
const archived = workspace();
|
||||||
|
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [archived]);
|
||||||
|
const releaseArchive = deferred<ArchiveWorkspacePayload>();
|
||||||
|
const client = createClient(vi.fn(async () => releaseArchive.promise));
|
||||||
|
|
||||||
|
const archive = archiveWorkspaceOptimistically({
|
||||||
|
client,
|
||||||
|
workspace: target(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(storedWorkspace(archived.id)).toBeUndefined();
|
||||||
|
expect(
|
||||||
|
isWorkspaceArchivePending({
|
||||||
|
serverId: SERVER_ID,
|
||||||
|
workspaceId: archived.id,
|
||||||
|
workspaceDirectory: archived.workspaceDirectory,
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
releaseArchive.resolve(archivePayload({ workspaceId: archived.id }));
|
||||||
|
await archive;
|
||||||
|
|
||||||
|
expect(storedWorkspace(archived.id)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restores the workspace and clears pending state when the daemon rejects the archive", async () => {
|
||||||
|
const archived = workspace();
|
||||||
|
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [archived]);
|
||||||
|
const client = createClient(
|
||||||
|
vi.fn(async () => archivePayload({ workspaceId: archived.id, error: "nope" })),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
archiveWorkspaceOptimistically({
|
||||||
|
client,
|
||||||
|
workspace: target(),
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("nope");
|
||||||
|
|
||||||
|
expect(storedWorkspace(archived.id)).toEqual(archived);
|
||||||
|
expect(
|
||||||
|
isWorkspaceArchivePending({
|
||||||
|
serverId: SERVER_ID,
|
||||||
|
workspaceId: archived.id,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runs the after-hide hook after local state is hidden", async () => {
|
||||||
|
const archived = workspace();
|
||||||
|
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [archived]);
|
||||||
|
const client = createClient(vi.fn(async () => archivePayload({ workspaceId: archived.id })));
|
||||||
|
const afterHide = vi.fn(() => {
|
||||||
|
expect(storedWorkspace(archived.id)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
await archiveWorkspaceOptimistically({
|
||||||
|
client,
|
||||||
|
workspace: target(),
|
||||||
|
afterHide,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(afterHide).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("archiveWorkspacesOptimistically", () => {
|
||||||
|
it("returns failures and restores only the workspaces whose archive failed", async () => {
|
||||||
|
const first = workspace({ id: "workspace-1" });
|
||||||
|
const second = workspace({
|
||||||
|
id: "workspace-2",
|
||||||
|
workspaceDirectory: "/repo/project/workspace-2",
|
||||||
|
name: "workspace-2",
|
||||||
|
});
|
||||||
|
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [first, second]);
|
||||||
|
const client = createClient(
|
||||||
|
vi.fn(async (workspaceId) =>
|
||||||
|
archivePayload({
|
||||||
|
workspaceId,
|
||||||
|
error: workspaceId === second.id ? "failed" : null,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const failures = await archiveWorkspacesOptimistically({
|
||||||
|
client,
|
||||||
|
workspaces: [
|
||||||
|
target({ workspaceId: first.id, workspaceDirectory: first.workspaceDirectory }),
|
||||||
|
target({ workspaceId: second.id, workspaceDirectory: second.workspaceDirectory }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(failures).toHaveLength(1);
|
||||||
|
expect(failures[0]?.workspaceId).toBe(second.id);
|
||||||
|
expect(storedWorkspace(first.id)).toBeUndefined();
|
||||||
|
expect(storedWorkspace(second.id)).toEqual(second);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import {
|
||||||
|
clearWorkspaceArchivePending,
|
||||||
|
markWorkspaceArchivePending,
|
||||||
|
} from "@/contexts/session-workspace-upserts";
|
||||||
|
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
|
||||||
|
import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-execution";
|
||||||
|
|
||||||
|
export interface WorkspaceArchiveTarget {
|
||||||
|
serverId: string;
|
||||||
|
workspaceId: string;
|
||||||
|
workspaceDirectory?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WorkspaceArchiveClient {
|
||||||
|
archiveWorkspace: (workspaceId: string) => Promise<{ error: string | null }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OptimisticWorkspaceArchiveSnapshot {
|
||||||
|
workspace: WorkspaceDescriptor | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkspaceArchiveFailure {
|
||||||
|
workspaceId: string;
|
||||||
|
error: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWorkspaceArchiveFailure(error: unknown): error is WorkspaceArchiveFailure {
|
||||||
|
return (
|
||||||
|
typeof error === "object" &&
|
||||||
|
error !== null &&
|
||||||
|
"workspaceId" in error &&
|
||||||
|
typeof error.workspaceId === "string" &&
|
||||||
|
"error" in error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideWorkspaceOptimistically(
|
||||||
|
workspace: WorkspaceArchiveTarget,
|
||||||
|
): OptimisticWorkspaceArchiveSnapshot {
|
||||||
|
const workspaces = useSessionStore.getState().sessions[workspace.serverId]?.workspaces;
|
||||||
|
const workspaceKey = resolveWorkspaceMapKeyByIdentity({
|
||||||
|
workspaces,
|
||||||
|
workspaceId: workspace.workspaceId,
|
||||||
|
});
|
||||||
|
const snapshot = workspaceKey ? (workspaces?.get(workspaceKey) ?? null) : null;
|
||||||
|
markWorkspaceArchivePending({
|
||||||
|
serverId: workspace.serverId,
|
||||||
|
workspaceId: workspace.workspaceId,
|
||||||
|
workspaceDirectory: workspace.workspaceDirectory,
|
||||||
|
});
|
||||||
|
useSessionStore.getState().removeWorkspace(workspace.serverId, workspace.workspaceId);
|
||||||
|
return { workspace: snapshot };
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreOptimisticallyHiddenWorkspace(input: {
|
||||||
|
serverId: string;
|
||||||
|
workspaceId: string;
|
||||||
|
snapshot: OptimisticWorkspaceArchiveSnapshot;
|
||||||
|
}): void {
|
||||||
|
clearWorkspaceArchivePending({
|
||||||
|
serverId: input.serverId,
|
||||||
|
workspaceId: input.workspaceId,
|
||||||
|
});
|
||||||
|
if (input.snapshot.workspace) {
|
||||||
|
useSessionStore.getState().mergeWorkspaces(input.serverId, [input.snapshot.workspace]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function archiveWorkspaceOrThrow(input: {
|
||||||
|
client: WorkspaceArchiveClient;
|
||||||
|
workspaceId: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
const payload = await input.client.archiveWorkspace(input.workspaceId);
|
||||||
|
if (payload.error) {
|
||||||
|
throw new Error(payload.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function archiveWorkspaceOptimistically(input: {
|
||||||
|
client: WorkspaceArchiveClient;
|
||||||
|
workspace: WorkspaceArchiveTarget;
|
||||||
|
afterHide?: () => void;
|
||||||
|
}): Promise<void> {
|
||||||
|
const snapshot = hideWorkspaceOptimistically(input.workspace);
|
||||||
|
input.afterHide?.();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await archiveWorkspaceOrThrow({
|
||||||
|
client: input.client,
|
||||||
|
workspaceId: input.workspace.workspaceId,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
restoreOptimisticallyHiddenWorkspace({
|
||||||
|
serverId: input.workspace.serverId,
|
||||||
|
workspaceId: input.workspace.workspaceId,
|
||||||
|
snapshot,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function archiveWorkspacesOptimistically(input: {
|
||||||
|
client: WorkspaceArchiveClient;
|
||||||
|
workspaces: WorkspaceArchiveTarget[];
|
||||||
|
}): Promise<WorkspaceArchiveFailure[]> {
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
input.workspaces.map(async (workspace) => {
|
||||||
|
try {
|
||||||
|
await archiveWorkspaceOptimistically({
|
||||||
|
client: input.client,
|
||||||
|
workspace,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
throw { workspaceId: workspace.workspaceId, error } satisfies WorkspaceArchiveFailure;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return results.flatMap((result) =>
|
||||||
|
result.status === "rejected" && isWorkspaceArchiveFailure(result.reason) ? [result.reason] : [],
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user