Compare commits
5 Commits
codex/canv
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| fa3210a367 | |||
| e335821ca8 | |||
| c2e8ff6eac | |||
| d26bec9864 | |||
| 24a3c930c2 |
@ -5,6 +5,7 @@ import { readCanvasDraft, writeCanvasDraft, clearCanvasDraft } from "@/lib/clien
|
||||
import { mergeCanvasSnapshots } from "@/lib/video-canvas/merge-snapshot";
|
||||
import { BEFORE_PAGE_REFRESH, type PageRefreshRequest } from "@/lib/client/page-refresh";
|
||||
import { CanvasRemoteOperationContext, type CanvasRemoteOperation, type CanvasRemoteOperationResult } from "@/components/canvas/CanvasRemoteOperationContext";
|
||||
import equal from "fast-deep-equal";
|
||||
import {
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
@ -171,6 +172,7 @@ import {
|
||||
type VideoCanvasSymbolMarkerId,
|
||||
} from "@/lib/canvas/video-canvas-markers";
|
||||
import {
|
||||
isCanvasImageUploadPending,
|
||||
LOCAL_UPLOAD_PENDING_METADATA_KEY,
|
||||
LOCAL_UPLOAD_PREVIEW_METADATA_KEY,
|
||||
} from "@/lib/canvas/local-upload-state";
|
||||
@ -5127,13 +5129,14 @@ export default function CanvasEditorLegacyPage({
|
||||
canvasPersistenceScope.current = id;
|
||||
const hasUnsavedCanvasChanges = useCallback(() => {
|
||||
if (!canvasReadyRef.current || useCanvasStore.getState().projectId !== id || savedCanvasSnapshotRef.current?.id !== id) return false;
|
||||
if (useCanvasStore.getState().nodes.some(isCanvasImageUploadPending)) return true;
|
||||
const saved = JSON.parse(savedCanvasSnapshotRef.current.snapshot).canvasData as CanvasState;
|
||||
const current = buildPersistedCanvasData();
|
||||
const current = JSON.parse(JSON.stringify(buildPersistedCanvasData())) as CanvasState;
|
||||
const content = (value: CanvasState) => [value.nodes.map(node => {
|
||||
const { updatedAt: _updatedAt, ...nodeData } = node;
|
||||
return { ...nodeData, connections: node.connections || [] };
|
||||
}), value.edges, value.suppressedMediaKeys || [], normalizeStyleProPersistedState(value.styleProState)];
|
||||
return JSON.stringify(content(saved)) !== JSON.stringify(content(current));
|
||||
}), value.edges || [], value.suppressedMediaKeys || [], normalizeStyleProPersistedState(value.styleProState)];
|
||||
return !equal(content(saved), content(current));
|
||||
}, [buildPersistedCanvasData, id]);
|
||||
const requestCanvasLeave = useCallback((url: string) => {
|
||||
if (hasUnsavedCanvasChanges()) {
|
||||
@ -5176,7 +5179,7 @@ export default function CanvasEditorLegacyPage({
|
||||
try {
|
||||
let canvasData = buildPersistedCanvasData();
|
||||
let snapshot = JSON.stringify({ canvasData });
|
||||
if (savedCanvasSnapshotRef.current?.id === id && savedCanvasSnapshotRef.current.snapshot === snapshot) {
|
||||
if (savedCanvasSnapshotRef.current?.id === id && equal(JSON.parse(savedCanvasSnapshotRef.current.snapshot), JSON.parse(snapshot))) {
|
||||
clearCanvasDraft(id);
|
||||
return true;
|
||||
}
|
||||
@ -5224,8 +5227,8 @@ export default function CanvasEditorLegacyPage({
|
||||
snapshot: JSON.stringify({ canvasData: buildPersistedCanvasData() }),
|
||||
});
|
||||
canvasSyncErrorRef.current = result.status === 409
|
||||
? "画布同步发生冲突,本次任务未提交,改动已保留。请在画布上方选择保留本页或云端的冲突内容,再继续操作。"
|
||||
: `画布自动同步失败${result.status ? `(HTTP ${result.status})` : "(网络连接失败)"},本次任务未提交。${retained ? "本地草稿已保留,请检查网络后再次发送。" : "请保留当前页面,检查网络后再次发送,暂勿刷新。"}`;
|
||||
? "画布同步发生冲突,改动已保留。请在画布上方选择保留本页或云端的冲突内容,再继续操作。"
|
||||
: `画布自动同步失败${result.status ? `(HTTP ${result.status})` : "(网络连接失败)"}。${retained ? "本地草稿已保留,系统会继续自动同步,也可稍后重试。" : "请保留当前页面,检查网络后重试同步,暂勿刷新。"}`;
|
||||
console.error(`[Canvas] ${reason} save failed:`, result.error);
|
||||
} else {
|
||||
canvasSyncErrorRef.current = "";
|
||||
@ -5258,6 +5261,10 @@ export default function CanvasEditorLegacyPage({
|
||||
const finishCanvasLeave = useCallback(async (save: boolean) => {
|
||||
if (!canvasLeaveUrl || isSavingBeforeLeave) return;
|
||||
setCanvasLeaveError("");
|
||||
if (save && useCanvasStore.getState().nodes.some(isCanvasImageUploadPending)) {
|
||||
setCanvasLeaveError("还有图片未上传成功。请等待上传完成;若上传失败,请重新上传或移除该图片后再保存并离开。");
|
||||
return;
|
||||
}
|
||||
if (canvasAutosaveTimer.current) {
|
||||
clearTimeout(canvasAutosaveTimer.current);
|
||||
canvasAutosaveTimer.current = null;
|
||||
@ -5321,7 +5328,7 @@ export default function CanvasEditorLegacyPage({
|
||||
return canvasSaveQueue.current!.enqueueOperation(
|
||||
async () => {
|
||||
const saved = await persistCanvasStateNow("autosave");
|
||||
if (!saved) throw new Error(canvasSyncErrorRef.current || "画布尚未完成加载,本次处理未提交,请等待加载完成后重试。");
|
||||
if (!saved) throw new Error(canvasSyncErrorRef.current ? `${canvasSyncErrorRef.current} 本次处理未提交。` : "画布尚未完成加载,本次处理未提交,请等待加载完成后重试。");
|
||||
return true;
|
||||
},
|
||||
async () => {
|
||||
@ -5343,20 +5350,18 @@ export default function CanvasEditorLegacyPage({
|
||||
|
||||
const persistCanvasBeforeAgent = useCallback(async () => {
|
||||
if (!canvasReadyRef.current || useCanvasStore.getState().projectId !== id || externalCanvasConflictRef.current)
|
||||
throw new Error(canvasSyncErrorRef.current || "画布尚未完成同步,本次任务未提交,请保留当前页面并重试同步。");
|
||||
throw new Error(canvasSyncErrorRef.current ? `${canvasSyncErrorRef.current} 本次任务未提交。` : "画布尚未完成同步,本次任务未提交,请保留当前页面并重试同步。");
|
||||
if (savedCanvasSnapshotRef.current?.id === id) {
|
||||
const saved = JSON.parse(savedCanvasSnapshotRef.current.snapshot).canvasData;
|
||||
const current = buildPersistedCanvasData();
|
||||
if (JSON.stringify(saved.nodes) === JSON.stringify(current.nodes) && JSON.stringify(saved.edges) === JSON.stringify(current.edges)) return true;
|
||||
if (!hasUnsavedCanvasChanges()) return true;
|
||||
}
|
||||
// Flush the pending local edit once instead of racing its scheduled autosave.
|
||||
if (canvasAutosaveTimer.current) {
|
||||
clearTimeout(canvasAutosaveTimer.current);
|
||||
canvasAutosaveTimer.current = null;
|
||||
}
|
||||
if (!(await persistCanvasState("autosave"))) throw new Error(canvasSyncErrorRef.current || "画布尚未完成加载,本次任务未提交;请等待加载完成后再次发送。");
|
||||
if (!(await persistCanvasState("autosave"))) throw new Error(canvasSyncErrorRef.current ? `${canvasSyncErrorRef.current} 本次任务未提交。` : "画布尚未完成加载,本次任务未提交;请等待加载完成后再次发送。");
|
||||
return true;
|
||||
}, [persistCanvasState, buildPersistedCanvasData, id]);
|
||||
}, [persistCanvasState, hasUnsavedCanvasChanges, id]);
|
||||
|
||||
const canvasRemoteOperationContext = useMemo(() => ({
|
||||
run: runCanvasRemoteOperation,
|
||||
@ -5367,6 +5372,10 @@ export default function CanvasEditorLegacyPage({
|
||||
if (isVideoMode) return;
|
||||
const beforeRefresh = (event: Event) => {
|
||||
const request = (event as CustomEvent<PageRefreshRequest>).detail;
|
||||
if (!isAgentMode && useCanvasStore.getState().nodes.some(isCanvasImageUploadPending)) {
|
||||
request.block("还有图片未上传成功。请等待上传完成;若上传失败,请重新上传或移除该图片后再刷新。");
|
||||
return;
|
||||
}
|
||||
if (useCanvasStore.getState().isGenerating) {
|
||||
request.block("当前图片任务仍在处理中,请等待结果返回后再刷新。");
|
||||
return;
|
||||
@ -5413,7 +5422,6 @@ export default function CanvasEditorLegacyPage({
|
||||
const onVisibilityChange = () => {
|
||||
if (!canvasLeaveApprovedRef.current && document.visibilityState === "hidden" && hasUnsavedCanvasChanges()) {
|
||||
writeCanvasDraft(id, { base: savedCanvasSnapshotRef.current!.snapshot, snapshot: JSON.stringify({ canvasData: buildPersistedCanvasData() }) });
|
||||
retrySync();
|
||||
}
|
||||
};
|
||||
const retryTimer = window.setInterval(() => {
|
||||
@ -5544,7 +5552,7 @@ export default function CanvasEditorLegacyPage({
|
||||
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
||||
if (!retainDraft()) return;
|
||||
event.preventDefault();
|
||||
event.returnValue = "";
|
||||
event.returnValue = "true";
|
||||
};
|
||||
const handlePageHide = () => {
|
||||
try {
|
||||
|
||||
@ -68,6 +68,7 @@ export function AppUpdateNotice() {
|
||||
</AlertDialogHeader>
|
||||
{error ? <p role="alert" className="text-sm text-destructive">{error}</p> : null}
|
||||
<AlertDialogFooter>
|
||||
{error ? <Button variant="outline" disabled={refreshing} onClick={() => { setAvailableVersion(""); setError(""); }}>返回处理</Button> : null}
|
||||
<Button disabled={refreshing} onClick={() => void refresh()}>{refreshing ? "正在同步内容…" : "同步并刷新"}</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
||||
@ -8972,6 +8972,10 @@ export function CanvasChatPanel({
|
||||
useEffect(() => {
|
||||
const beforeRefresh = (event: Event) => {
|
||||
const request = (event as CustomEvent<PageRefreshRequest>).detail;
|
||||
if (composerUploadProgress) {
|
||||
request.block("素材仍在上传,请等待上传完成后再刷新。当前页面和输入已保留。");
|
||||
return;
|
||||
}
|
||||
if (isCanvasAgentRunning || isClawdSubmitting || isDetailGenerating || status === "streaming" || status === "submitted" || Object.values(sendGuardRef.current).some(guard => guard.inFlight)) {
|
||||
request.block("当前对话任务仍在处理中,请等待结果返回后再刷新。原任务会继续执行,无需重新提交。");
|
||||
return;
|
||||
@ -8981,9 +8985,17 @@ export function CanvasChatPanel({
|
||||
throw new Error("浏览器无法保存当前输入,请保留此页并检查浏览器存储空间后重试。");
|
||||
});
|
||||
};
|
||||
const beforeUnload = (event: BeforeUnloadEvent) => {
|
||||
event.preventDefault();
|
||||
event.returnValue = "true";
|
||||
};
|
||||
window.addEventListener(BEFORE_PAGE_REFRESH, beforeRefresh);
|
||||
return () => window.removeEventListener(BEFORE_PAGE_REFRESH, beforeRefresh);
|
||||
}, [activeChatId, input, attachments, isCanvasAgentRunning, isClawdSubmitting, isDetailGenerating, status]);
|
||||
if (composerUploadProgress) window.addEventListener("beforeunload", beforeUnload);
|
||||
return () => {
|
||||
window.removeEventListener(BEFORE_PAGE_REFRESH, beforeRefresh);
|
||||
window.removeEventListener("beforeunload", beforeUnload);
|
||||
};
|
||||
}, [activeChatId, input, attachments, composerUploadProgress, isCanvasAgentRunning, isClawdSubmitting, isDetailGenerating, status]);
|
||||
|
||||
const restorePendingClawdRequestToComposer = useCallback(
|
||||
(snapshot: PendingClawdRequestRestoreSnapshot) => {
|
||||
|
||||
@ -13,9 +13,13 @@ async function setup(page: Page, failRead = false) {
|
||||
writes: [] as any[], metadataWrites: 0, submissions: [] as any[], capabilitySubmissions: [] as any[],
|
||||
appVersion: "development", holdRequest: false, finishRequest: null as (() => void) | null,
|
||||
nativeResultNext: false,
|
||||
holdUpload: false, uploadStarted: 0, finishUpload: null as (() => void) | null,
|
||||
};
|
||||
const revision = () => new Date(Date.UTC(2026, 10, 6, 0, 0, state.revision)).toISOString();
|
||||
const project = () => ({ id: projectId, title: "同步验证", canvasData: state.canvasData, updatedAt: revision() });
|
||||
const project = () => ({ id: projectId, title: "同步验证", canvasData: JSON.parse(JSON.stringify(state.canvasData, (_key, value) =>
|
||||
value && typeof value === "object" && !Array.isArray(value)
|
||||
? Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)))
|
||||
: value)), updatedAt: revision() });
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", error => errors.push(error.message));
|
||||
await page.context().addInitScript(() => localStorage.setItem("theme", "light"));
|
||||
@ -24,6 +28,10 @@ async function setup(page: Page, failRead = false) {
|
||||
const path = new URL(request.url()).pathname;
|
||||
let data: unknown = {};
|
||||
let status = 200;
|
||||
if (path.startsWith("/api/files/") && request.method() === "POST" && state.holdUpload) {
|
||||
state.uploadStarted++;
|
||||
await new Promise<void>(resolve => { state.finishUpload = resolve; });
|
||||
}
|
||||
if (path === `/api/canvas/${projectId}`) {
|
||||
if (request.method() === "GET") {
|
||||
status = state.failRead ? 500 : 200;
|
||||
@ -108,8 +116,9 @@ async function moveImage(page: Page) {
|
||||
|
||||
const unloadWouldBeBlocked = (page: Page) => page.evaluate(() => {
|
||||
const event = new Event("beforeunload", { cancelable: true });
|
||||
Object.defineProperty(event, "returnValue", { value: "", writable: true });
|
||||
window.dispatchEvent(event);
|
||||
return event.defaultPrevented;
|
||||
return event.defaultPrevented && Boolean(event.returnValue);
|
||||
});
|
||||
|
||||
test("background saving recovers when the network returns without a manual save", async ({ page }) => {
|
||||
@ -127,6 +136,18 @@ test("background saving recovers when the network returns without a manual save"
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("selecting a saved image does not require another save before an Agent message", async ({ page }) => {
|
||||
const { state, errors } = await setup(page);
|
||||
await page.locator('[data-canvas-node-id="source"]').click();
|
||||
state.failSave = true;
|
||||
const editor = page.getByLabel("描述你的设计需求", { exact: true });
|
||||
await editor.fill("分析这张已经保存的图片");
|
||||
await editor.press("Enter");
|
||||
await expect.poll(() => state.submissions.length).toBe(1);
|
||||
expect(state.canvasData.nodes.map(node => node.id)).toEqual(["source"]);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("only unsaved content blocks closing and beforeunload does not issue duplicate saves", async ({ page }) => {
|
||||
const { state, errors } = await setup(page);
|
||||
expect(await unloadWouldBeBlocked(page)).toBe(false);
|
||||
@ -143,6 +164,21 @@ test("only unsaved content blocks closing and beforeunload does not issue duplic
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("pagehide and visibilitychange issue only one closing save", async ({ page }) => {
|
||||
const { state, errors } = await setup(page);
|
||||
state.failSave = true;
|
||||
await moveImage(page);
|
||||
const writes = state.writes.length;
|
||||
await page.evaluate(() => {
|
||||
window.dispatchEvent(new Event("pagehide"));
|
||||
Object.defineProperty(document, "visibilityState", { value: "hidden", configurable: true });
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
});
|
||||
await expect.poll(() => state.writes.length).toBe(writes + 1);
|
||||
expect(state.submissions).toHaveLength(0);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("leaving with pending changes can continue editing or save before navigating", async ({ page }) => {
|
||||
const { state, errors } = await setup(page);
|
||||
await page.route(url => url.pathname === "/", route => route.fulfill({ contentType: "text/html; charset=utf-8", body: "<p>已返回首页</p>" }));
|
||||
@ -244,6 +280,7 @@ test("toolbar save failures show the cause and recover without a paid submission
|
||||
state.failSave = true;
|
||||
await page.locator('[data-tool-key="enhance"]').click();
|
||||
await expect(page.getByText(/画布自动同步失败(HTTP 503)/)).toBeVisible();
|
||||
await expect(page.getByText(/本次处理未提交/)).toBeVisible();
|
||||
expect(state.capabilitySubmissions).toHaveLength(0);
|
||||
state.failSave = false;
|
||||
await page.locator('[data-tool-key="enhance"]').click();
|
||||
@ -415,6 +452,70 @@ async function countRefreshes(page: Page, state: { appVersion: string }) {
|
||||
return () => navigations;
|
||||
}
|
||||
|
||||
test("unfinished image uploads remain on the page even after their canvas placeholder was saved", async ({ page }) => {
|
||||
const { state, errors } = await setup(page);
|
||||
const refreshes = await countRefreshes(page, state);
|
||||
state.holdUpload = true;
|
||||
await page.mouse.click(100, 90);
|
||||
await page.evaluate(() => {
|
||||
const bytes = Uint8Array.from(atob("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+j2d0AAAAASUVORK5CYII="), c => c.charCodeAt(0));
|
||||
const transfer = new DataTransfer();
|
||||
transfer.items.add(new File([bytes], "上传保护.png", { type: "image/png" }));
|
||||
window.dispatchEvent(new ClipboardEvent("paste", { clipboardData: transfer, bubbles: true, cancelable: true }));
|
||||
});
|
||||
await expect.poll(() => state.uploadStarted).toBeGreaterThan(0);
|
||||
await expect.poll(() => state.canvasData.nodes.filter(node => node.data.src === "").length).toBe(1);
|
||||
expect(await unloadWouldBeBlocked(page)).toBe(true);
|
||||
await page.getByRole("button", { name: "返回首页", exact: true }).click();
|
||||
const dialog = page.getByRole("dialog", { name: "离开前保存画布?" });
|
||||
await dialog.getByRole("button", { name: "保存并离开", exact: true }).click();
|
||||
await expect(dialog.getByRole("alert")).toContainText("还有图片未上传成功");
|
||||
await dialog.getByRole("button", { name: "继续编辑", exact: true }).click();
|
||||
await showUpdate(page, state);
|
||||
await page.getByRole("button", { name: "同步并刷新", exact: true }).click();
|
||||
await expect(page.getByRole("alertdialog")).toContainText("还有图片未上传成功");
|
||||
expect(refreshes()).toBe(0);
|
||||
state.holdUpload = false;
|
||||
state.finishUpload?.();
|
||||
await expect.poll(() => state.canvasData.nodes.some(node => node.data.src.includes("uploaded.png"))).toBe(true);
|
||||
await page.getByRole("button", { name: "同步并刷新", exact: true }).click();
|
||||
await expect.poll(refreshes).toBe(1);
|
||||
expect(state.canvasData.nodes).toHaveLength(2);
|
||||
expect(state.submissions).toHaveLength(0);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("a composer upload blocks release refresh and closing until the image and input are retained", async ({ page }) => {
|
||||
const { state, errors } = await setup(page);
|
||||
const refreshes = await countRefreshes(page, state);
|
||||
state.holdUpload = true;
|
||||
const editor = page.getByLabel("描述你的设计需求", { exact: true });
|
||||
await editor.fill("上传后保留这条输入");
|
||||
await editor.evaluate(element => {
|
||||
const transfer = new DataTransfer();
|
||||
transfer.items.add(new File([new Uint8Array([137, 80, 78, 71])], "聊天上传保护.png", { type: "image/png" }));
|
||||
element.dispatchEvent(new ClipboardEvent("paste", { clipboardData: transfer, bubbles: true, cancelable: true }));
|
||||
});
|
||||
await expect.poll(() => state.uploadStarted).toBeGreaterThan(0);
|
||||
await expect(page.getByText("聊天上传保护.png", { exact: true })).toBeVisible();
|
||||
expect(await unloadWouldBeBlocked(page)).toBe(true);
|
||||
await showUpdate(page, state);
|
||||
await page.getByRole("button", { name: "同步并刷新", exact: true }).click();
|
||||
await expect(page.getByRole("alertdialog")).toContainText("素材仍在上传");
|
||||
expect(refreshes()).toBe(0);
|
||||
state.holdUpload = false;
|
||||
state.finishUpload?.();
|
||||
await expect.poll(() => state.canvasData.nodes.some(node => node.data.src.includes("uploaded.png"))).toBe(true);
|
||||
await expect(page.getByText("聊天上传保护.png", { exact: true })).toHaveCount(1);
|
||||
await page.getByRole("button", { name: "同步并刷新", exact: true }).click();
|
||||
await expect.poll(refreshes).toBe(1);
|
||||
await expect(editor).toContainText("上传后保留这条输入");
|
||||
await expect(editor.getByText("聊天上传保护.png", { exact: true })).toBeVisible();
|
||||
expect(state.canvasData.nodes).toHaveLength(2);
|
||||
expect(state.submissions).toHaveLength(0);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("an older browser without AbortSignal static helpers receives the release notice", async ({ page }) => {
|
||||
await page.context().addInitScript(() => {
|
||||
Object.defineProperty(AbortSignal, "any", { value: undefined, configurable: true });
|
||||
@ -489,9 +590,19 @@ test("an update keeps the current page when automatic synchronization fails", as
|
||||
await showUpdate(page, state);
|
||||
await page.getByRole("button", { name: "同步并刷新", exact: true }).click();
|
||||
await expect(page.getByRole("alertdialog")).toContainText("HTTP 503");
|
||||
await expect(page.getByRole("alertdialog")).not.toContainText("本次任务未提交");
|
||||
await expect(page.getByRole("alertdialog")).not.toContainText("再次发送");
|
||||
expect(refreshes()).toBe(0);
|
||||
expect(state.submissions).toHaveLength(0);
|
||||
await page.screenshot({ path: test.info().outputPath("update-error-desktop.png"), animations: "disabled" });
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.screenshot({ path: test.info().outputPath("update-error-mobile.png"), animations: "disabled" });
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await page.getByRole("button", { name: "返回处理", exact: true }).click();
|
||||
await expect(page.getByRole("alertdialog")).toHaveCount(0);
|
||||
await expect(page.locator('[data-canvas-node-id="source"]')).toBeVisible();
|
||||
state.failSave = false;
|
||||
await showUpdate(page, state);
|
||||
await page.getByRole("button", { name: "同步并刷新", exact: true }).click();
|
||||
await expect.poll(refreshes).toBe(1);
|
||||
expect(state.canvasData.nodes[0].position.x).toBeGreaterThan(160);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user