FN-9195: Keep the chat composer visible above iOS keyboards
Keep Chat input reachable across software-keyboard viewport changes. - Track visual viewport changes across phones, tablets, compact docks, and narrow floating Chat hosts. - Clamp thread height using the measured host offset while preserving iOS focus behavior. - Add regression coverage, shared viewport test helpers, operator documentation, and a patch changeset. Files changed: .changeset/fn-9195-chat-ios-keyboard-composer.md | 7 + docs/dashboard-guide.md | 2 +- packages/dashboard/app/components/ChatView.css | 44 +-- packages/dashboard/app/components/ChatView.tsx | 43 ++- .../__tests__/ChatView.ios-keyboard.test.tsx | 335 +++++++++++++++++++++ .../components/__tests__/ChatView.test-harness.tsx | 103 ++++++- 6 files changed, 506 insertions(+), 28 deletions(-) Fusion-Task-Id: FN-9195 Fusion-Task-Lineage: cdada0cc-af6c-4cf8-8ffd-0940d280b07a Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-9195-chat-ios-keyboard-composer.md
Normal file
7
.changeset/fn-9195-chat-ios-keyboard-composer.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Keep the Chat message box visible above the software keyboard on tablets and landscape phones.
|
||||||
|
category: fix
|
||||||
|
dev: Aligns keyboard tracking hosts, promotes the thread clamp, and measures thread viewport offset.
|
||||||
@@ -754,7 +754,7 @@ The full **New Task** dialog includes a compact **GitHub issue or PR** picker ne
|
|||||||
|
|
||||||
## Chat View
|
## Chat View
|
||||||
|
|
||||||
Chat view provides project-scoped conversations with agents. Every host—embedded Chat, Quick Chat, and the dock—starts at the conversation list. Select a conversation to open its thread.
|
Chat view provides project-scoped conversations with agents. Every host—embedded Chat, Quick Chat, and the dock—starts at the conversation list. Select a conversation to open its thread. When the software keyboard is open, the composer remains visible on phones in portrait and landscape, tablets/iPad, compact right-dock Chat, and narrow floating Quick Chat; desktop Chat is unaffected.
|
||||||
|
|
||||||
### Docked conversation sidebar
|
### Docked conversation sidebar
|
||||||
|
|
||||||
|
|||||||
@@ -496,6 +496,31 @@ FN-8191 gives the conversation action menu a token-composed border-box width. Ch
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:ChatComposer 2026-08-23-16:07:
|
||||||
|
This clamp is class-gated rather than width-gated: iPhone landscape, iPad, compact dock, and
|
||||||
|
narrow floating Chat can all have a visible keyboard outside a phone-width media query. The
|
||||||
|
measured thread top accounts for host chrome above Chat instead of assuming only the app header.
|
||||||
|
*/
|
||||||
|
.chat-thread--keyboard-active {
|
||||||
|
height: calc(var(--vv-height, calc(100dvh - var(--keyboard-overlap, 0px))) - var(--chat-thread-viewport-top, var(--header-height)));
|
||||||
|
max-height: calc(var(--vv-height, calc(100dvh - var(--keyboard-overlap, 0px))) - var(--chat-thread-viewport-top, var(--header-height)));
|
||||||
|
/* NOTE: the translateY drift compensation is applied imperatively in
|
||||||
|
JS (see ChatView's vv `apply()`), NOT here. Declaring
|
||||||
|
`transform`/`will-change: transform` in CSS keeps a non-`none`
|
||||||
|
transform on .chat-thread for the entire keyboard-active window —
|
||||||
|
and since .chat-thread is an ancestor of the focused composer
|
||||||
|
textarea, iOS Safari treats establishing that containing block as a
|
||||||
|
reason to blur the input and collapse the keyboard the instant it
|
||||||
|
opens. JS only sets a transform when there is real viewport drift
|
||||||
|
(offsetTop > 0); at the focus moment offsetTop is 0, so the
|
||||||
|
ancestor stays `transform: none` and the keyboard stays up. */
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-thread--keyboard-active .chat-input-area {
|
||||||
|
padding-bottom: calc(var(--space-md) + env(safe-area-inset-bottom, 0px) + var(--chat-keyboard-accessory-clearance, 0px));
|
||||||
|
}
|
||||||
|
|
||||||
.chat-thread-header {
|
.chat-thread-header {
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
@@ -2224,25 +2249,6 @@ Thinking-section text uses the defined muted text token across all themes. The m
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-thread--keyboard-active {
|
|
||||||
height: calc(var(--vv-height, calc(100dvh - var(--keyboard-overlap, 0px))) - var(--header-height));
|
|
||||||
max-height: calc(var(--vv-height, calc(100dvh - var(--keyboard-overlap, 0px))) - var(--header-height));
|
|
||||||
/* NOTE: the translateY drift compensation is applied imperatively in
|
|
||||||
JS (see ChatView's vv `apply()`), NOT here. Declaring
|
|
||||||
`transform`/`will-change: transform` in CSS keeps a non-`none`
|
|
||||||
transform on .chat-thread for the entire keyboard-active window —
|
|
||||||
and since .chat-thread is an ancestor of the focused composer
|
|
||||||
textarea, iOS Safari treats establishing that containing block as a
|
|
||||||
reason to blur the input and collapse the keyboard the instant it
|
|
||||||
opens. JS only sets a transform when there is real viewport drift
|
|
||||||
(offsetTop > 0); at the focus moment offsetTop is 0, so the
|
|
||||||
ancestor stays `transform: none` and the keyboard stays up. */
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-thread--keyboard-active .chat-input-area {
|
|
||||||
padding-bottom: calc(var(--space-md) + env(safe-area-inset-bottom, 0px) + var(--chat-keyboard-accessory-clearance, 0px));
|
|
||||||
}
|
|
||||||
|
|
||||||
/* On mobile, the active scope affordance uses a full-width pinned footer. */
|
/* On mobile, the active scope affordance uses a full-width pinned footer. */
|
||||||
.chat-sidebar-footer {
|
.chat-sidebar-footer {
|
||||||
display: block;
|
display: block;
|
||||||
|
|||||||
@@ -912,6 +912,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
|
|||||||
const isMobile = mode === "mobile";
|
const isMobile = mode === "mobile";
|
||||||
const isTablet = mode === "tablet";
|
const isTablet = mode === "tablet";
|
||||||
const chatViewRef = useRef<HTMLDivElement>(null);
|
const chatViewRef = useRef<HTMLDivElement>(null);
|
||||||
|
const appliedThreadTranslateYRef = useRef(0);
|
||||||
const [floatingNarrow, setFloatingNarrow] = useState(false);
|
const [floatingNarrow, setFloatingNarrow] = useState(false);
|
||||||
/*
|
/*
|
||||||
FNXC:ChatModal 2026-06-22-14:38:
|
FNXC:ChatModal 2026-06-22-14:38:
|
||||||
@@ -938,6 +939,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
|
|||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, [floating]);
|
}, [floating]);
|
||||||
const isChatMobile = isMobile || floatingNarrow || compactLayout;
|
const isChatMobile = isMobile || floatingNarrow || compactLayout;
|
||||||
|
const keyboardTrackedHost = isChatMobile || isTablet;
|
||||||
/*
|
/*
|
||||||
FNXC:ChatNavigation 2026-08-23-03:40:
|
FNXC:ChatNavigation 2026-08-23-03:40:
|
||||||
FN-9193 restores an optional conversation list only for non-floating tablet-or-wider hosts.
|
FN-9193 restores an optional conversation list only for non-floating tablet-or-wider hosts.
|
||||||
@@ -1053,9 +1055,15 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
|
|||||||
}, [activeDraftKey, messageInput]);
|
}, [activeDraftKey, messageInput]);
|
||||||
|
|
||||||
const roomThreadActive = chatRoomsEnabled && chatScope === "rooms" && !!rooms.activeRoom;
|
const roomThreadActive = chatRoomsEnabled && chatScope === "rooms" && !!rooms.activeRoom;
|
||||||
|
/*
|
||||||
|
FNXC:ChatComposer 2026-08-23-16:07:
|
||||||
|
The composer must track the soft keyboard on every Fusion-classified Chat host, not only a
|
||||||
|
phone-width viewport. Keep enabled and allowNonMobileViewport on keyboardTrackedHost so the
|
||||||
|
hook's internal width heuristic cannot disagree with Chat's tablet, dock, or floating host.
|
||||||
|
*/
|
||||||
const { keyboardOverlap, keyboardOpen } = useMobileKeyboard({
|
const { keyboardOverlap, keyboardOpen } = useMobileKeyboard({
|
||||||
enabled: (isChatMobile || isTablet) && (!!activeSession || roomThreadActive),
|
enabled: keyboardTrackedHost && (!!activeSession || roomThreadActive),
|
||||||
allowNonMobileViewport: isTablet,
|
allowNonMobileViewport: keyboardTrackedHost,
|
||||||
});
|
});
|
||||||
|
|
||||||
const filteredSkills = useMemo(() => {
|
const filteredSkills = useMemo(() => {
|
||||||
@@ -1496,10 +1504,18 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
|
|||||||
// window.scrollTo(0, 0) on cleanup to recover from any iOS drift.
|
// window.scrollTo(0, 0) on cleanup to recover from any iOS drift.
|
||||||
useMobileKeyboardViewportLock(isMobile && keyboardOpen);
|
useMobileKeyboardViewportLock(isMobile && keyboardOpen);
|
||||||
|
|
||||||
// FN-5365: mirror QuickChatFAB keyboard handling by writing visualViewport
|
/*
|
||||||
// metrics directly to .chat-thread, avoiding React commit lag/jitter.
|
FNXC:ChatComposer 2026-08-23-16:07:
|
||||||
|
The composer must remain inside the visual viewport whenever Fusion knows a soft keyboard is
|
||||||
|
up on phone portrait/landscape, tablet, compact dock, or narrow floating Chat. The writer,
|
||||||
|
hook enabled state, and allowNonMobileViewport deliberately share keyboardTrackedHost so their
|
||||||
|
host gates cannot drift. Detection remains a layout-height-minus-visual-height gap; the measured
|
||||||
|
thread top lets CSS account for dock/floating chrome instead of assuming only the app header.
|
||||||
|
Landscape-phone keyboard state newly reaches the existing touch guard while its body lock keeps
|
||||||
|
its own phone-width iOS gate, so this does not add body pinning on wide hosts.
|
||||||
|
*/
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!isMobile || (!activeSession && !roomThreadActive)) return;
|
if (!keyboardTrackedHost || (!activeSession && !roomThreadActive)) return;
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
|
|
||||||
const thread = chatThreadRef.current;
|
const thread = chatThreadRef.current;
|
||||||
@@ -1518,8 +1534,10 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
|
|||||||
if (suppressVvShrinkRef.current) {
|
if (suppressVvShrinkRef.current) {
|
||||||
thread.classList.remove("chat-thread--keyboard-active");
|
thread.classList.remove("chat-thread--keyboard-active");
|
||||||
thread.style.setProperty("--chat-keyboard-accessory-clearance", "0px");
|
thread.style.setProperty("--chat-keyboard-accessory-clearance", "0px");
|
||||||
|
thread.style.removeProperty("--chat-thread-viewport-top");
|
||||||
thread.style.transform = "";
|
thread.style.transform = "";
|
||||||
thread.style.willChange = "";
|
thread.style.willChange = "";
|
||||||
|
appliedThreadTranslateYRef.current = 0;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const overlap = Math.max(0, window.innerHeight - vv.offsetTop - vv.height);
|
const overlap = Math.max(0, window.innerHeight - vv.offsetTop - vv.height);
|
||||||
@@ -1528,6 +1546,15 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
|
|||||||
thread.style.setProperty("--vv-offset-top", `${offsetTop}px`);
|
thread.style.setProperty("--vv-offset-top", `${offsetTop}px`);
|
||||||
thread.style.setProperty("--keyboard-overlap", `${overlap}px`);
|
thread.style.setProperty("--keyboard-overlap", `${overlap}px`);
|
||||||
|
|
||||||
|
const threadRect = thread.getBoundingClientRect();
|
||||||
|
if (threadRect.height > 0) {
|
||||||
|
const untransformedTop = Math.max(0, threadRect.top - appliedThreadTranslateYRef.current - offsetTop);
|
||||||
|
const viewportTop = Math.min(vv.height, untransformedTop);
|
||||||
|
thread.style.setProperty("--chat-thread-viewport-top", `${viewportTop}px`);
|
||||||
|
} else {
|
||||||
|
thread.style.removeProperty("--chat-thread-viewport-top");
|
||||||
|
}
|
||||||
|
|
||||||
const keyboardActive = (overlap > 0 || offsetTop > 0) && isKeyboardTrackingFocusable(document.activeElement);
|
const keyboardActive = (overlap > 0 || offsetTop > 0) && isKeyboardTrackingFocusable(document.activeElement);
|
||||||
thread.classList.toggle("chat-thread--keyboard-active", keyboardActive);
|
thread.classList.toggle("chat-thread--keyboard-active", keyboardActive);
|
||||||
/*
|
/*
|
||||||
@@ -1550,9 +1577,11 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
|
|||||||
if (keyboardActive && offsetTop > 0) {
|
if (keyboardActive && offsetTop > 0) {
|
||||||
thread.style.transform = `translateY(${offsetTop}px)`;
|
thread.style.transform = `translateY(${offsetTop}px)`;
|
||||||
thread.style.willChange = "transform";
|
thread.style.willChange = "transform";
|
||||||
|
appliedThreadTranslateYRef.current = offsetTop;
|
||||||
} else {
|
} else {
|
||||||
thread.style.transform = "";
|
thread.style.transform = "";
|
||||||
thread.style.willChange = "";
|
thread.style.willChange = "";
|
||||||
|
appliedThreadTranslateYRef.current = 0;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1572,10 +1601,12 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
|
|||||||
document.removeEventListener("visibilitychange", apply);
|
document.removeEventListener("visibilitychange", apply);
|
||||||
thread.classList.remove("chat-thread--keyboard-active");
|
thread.classList.remove("chat-thread--keyboard-active");
|
||||||
thread.style.setProperty("--chat-keyboard-accessory-clearance", "0px");
|
thread.style.setProperty("--chat-keyboard-accessory-clearance", "0px");
|
||||||
|
thread.style.removeProperty("--chat-thread-viewport-top");
|
||||||
thread.style.transform = "";
|
thread.style.transform = "";
|
||||||
thread.style.willChange = "";
|
thread.style.willChange = "";
|
||||||
|
appliedThreadTranslateYRef.current = 0;
|
||||||
};
|
};
|
||||||
}, [activeSession, isMobile, roomThreadActive]);
|
}, [activeSession, keyboardTrackedHost, roomThreadActive]);
|
||||||
|
|
||||||
// Close context menu on outside click
|
// Close context menu on outside click
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -0,0 +1,335 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { act, cleanup, render, screen } from "@testing-library/react";
|
||||||
|
import { ChatView } from "../ChatView";
|
||||||
|
import { loadAllAppCss } from "../../test/cssFixture";
|
||||||
|
import * as useChatModule from "../../hooks/useChat";
|
||||||
|
import * as useChatRoomsModule from "../../hooks/useChatRooms";
|
||||||
|
import {
|
||||||
|
activeSessionFixture,
|
||||||
|
createRoomFixture,
|
||||||
|
defaultChatState,
|
||||||
|
defaultRoomsState,
|
||||||
|
installChatViewEnv,
|
||||||
|
mockDesktopNonTouchViewport,
|
||||||
|
mockPhoneLandscapeViewport,
|
||||||
|
mockTabletClassTouchViewport,
|
||||||
|
mockViewportMode,
|
||||||
|
mockVisualViewport,
|
||||||
|
setLayoutViewportHeight,
|
||||||
|
setVisualViewportHeight,
|
||||||
|
setVisualViewportOffsetTop,
|
||||||
|
setupMockChat,
|
||||||
|
setupMockRooms,
|
||||||
|
simulateKeyboardOpen,
|
||||||
|
} from "./ChatView.test-harness";
|
||||||
|
|
||||||
|
vi.mock("../../hooks/useChat");
|
||||||
|
vi.mock("../../hooks/useChatRooms");
|
||||||
|
vi.mock("../../hooks/useNavigationHistory", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import("../../hooks/useNavigationHistory")>()),
|
||||||
|
useNavigationHistoryContext: () => ({ pushNav: vi.fn(), replaceCurrent: vi.fn() }),
|
||||||
|
}));
|
||||||
|
vi.mock("../../api", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import("../../api")>()),
|
||||||
|
fetchAgents: vi.fn().mockResolvedValue([]),
|
||||||
|
fetchModels: vi.fn().mockResolvedValue({ models: [] }),
|
||||||
|
fetchDiscoveredSkills: vi.fn().mockResolvedValue([]),
|
||||||
|
fetchTasks: vi.fn().mockResolvedValue([]),
|
||||||
|
searchFiles: vi.fn().mockResolvedValue({ files: [] }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const css = loadAllAppCss();
|
||||||
|
const mockUseChat = vi.mocked(useChatModule.useChat);
|
||||||
|
const mockUseChatRooms = vi.mocked(useChatRoomsModule.useChatRooms);
|
||||||
|
installChatViewEnv();
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
document.head.innerHTML = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
async function renderChat(props: Partial<React.ComponentProps<typeof ChatView>> = {}) {
|
||||||
|
const style = document.createElement("style");
|
||||||
|
style.textContent = css;
|
||||||
|
document.head.append(style);
|
||||||
|
// Start without a selected session so selecting this row changes activeSession, matching the
|
||||||
|
// production transition that re-runs the viewport writer after the thread ref has mounted.
|
||||||
|
setupMockChat({ ...defaultChatState, sessions: [activeSessionFixture], filteredSessions: [activeSessionFixture], activeSession: undefined });
|
||||||
|
setupMockRooms(defaultRoomsState);
|
||||||
|
const view = render(<ChatView projectId="proj-123" addToast={vi.fn()} {...props} />);
|
||||||
|
await act(async () => { screen.getByTestId(`chat-session-${activeSessionFixture.id}`).click(); });
|
||||||
|
setupMockChat({ ...defaultChatState, sessions: [activeSessionFixture], filteredSessions: [activeSessionFixture], activeSession: activeSessionFixture });
|
||||||
|
view.rerender(<ChatView projectId="proj-123" addToast={vi.fn()} {...props} />);
|
||||||
|
return screen.getByTestId("chat-input") as HTMLTextAreaElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getThread() {
|
||||||
|
return screen.getByTestId("chat-input").closest(".chat-thread") as HTMLElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openKeyboard(input: HTMLTextAreaElement, vv: VisualViewport, height: number) {
|
||||||
|
await act(async () => simulateKeyboardOpen({ input, vv, visualHeight: height }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function observeKeyboardScroll() {
|
||||||
|
const messages = document.querySelector(".chat-messages") as HTMLElement;
|
||||||
|
let scrollTop = 0;
|
||||||
|
Object.defineProperties(messages, {
|
||||||
|
scrollHeight: { value: 1000, configurable: true },
|
||||||
|
clientHeight: { value: 300, configurable: true },
|
||||||
|
scrollTop: {
|
||||||
|
get: () => scrollTop,
|
||||||
|
set: (value: number) => { scrollTop = value; },
|
||||||
|
configurable: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return () => scrollTop;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInsideNarrowMediaRule(source: string, ruleIndex: number) {
|
||||||
|
const mediaIndex = source.lastIndexOf("@media (max-width: 768px)", ruleIndex);
|
||||||
|
if (mediaIndex < 0) return false;
|
||||||
|
const openIndex = source.indexOf("{", mediaIndex);
|
||||||
|
let depth = 0;
|
||||||
|
for (let index = openIndex; index < ruleIndex; index += 1) {
|
||||||
|
if (source[index] === "{") depth += 1;
|
||||||
|
if (source[index] === "}") depth -= 1;
|
||||||
|
}
|
||||||
|
return depth > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("FN-9195 Chat composer visual viewport", () => {
|
||||||
|
it("preserves the layout-versus-visual viewport helper contract", () => {
|
||||||
|
const { vv, restore } = mockVisualViewport({ width: 375, height: 812 });
|
||||||
|
try {
|
||||||
|
setVisualViewportHeight(vv, 400);
|
||||||
|
expect(window.innerHeight).toBe(812);
|
||||||
|
expect(document.documentElement.clientHeight).toBe(812);
|
||||||
|
expect(vv.height).toBe(400);
|
||||||
|
expect(window.innerHeight - vv.offsetTop - vv.height).toBe(412);
|
||||||
|
setLayoutViewportHeight(vv, 600);
|
||||||
|
expect(window.innerHeight).toBe(600);
|
||||||
|
expect(document.documentElement.clientHeight).toBe(600);
|
||||||
|
expect(vv.height).toBe(600);
|
||||||
|
expect(window.innerHeight - vv.offsetTop - vv.height).toBe(0);
|
||||||
|
} finally { restore(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the portrait phone composer in a keyboard-active thread", async () => {
|
||||||
|
const viewport = mockVisualViewport({ width: 375, height: 812 });
|
||||||
|
const mode = mockViewportMode("mobile");
|
||||||
|
try {
|
||||||
|
const input = await renderChat();
|
||||||
|
expect(window.visualViewport).toBe(viewport.vv);
|
||||||
|
await openKeyboard(input, viewport.vv, 400);
|
||||||
|
expect(getThread()).toHaveClass("chat-thread--keyboard-active");
|
||||||
|
expect(getThread().style.getPropertyValue("--vv-height")).toBe("400px");
|
||||||
|
expect(getThread().style.getPropertyValue("--keyboard-overlap")).toBe("412px");
|
||||||
|
expect(getThread().contains(input)).toBe(true);
|
||||||
|
} finally { mode.mockRestore(); viewport.restore(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies the keyboard clamp to a landscape phone outside the narrow media query", async () => {
|
||||||
|
const restoreHost = mockPhoneLandscapeViewport();
|
||||||
|
const viewport = mockVisualViewport({ width: 932, height: 430 });
|
||||||
|
try {
|
||||||
|
const input = await renderChat();
|
||||||
|
await openKeyboard(input, viewport.vv, 200);
|
||||||
|
expect(getThread()).toHaveClass("chat-thread--keyboard-active");
|
||||||
|
expect(getThread().style.getPropertyValue("--vv-height")).toBe("200px");
|
||||||
|
expect(getThread().style.getPropertyValue("--keyboard-overlap")).toBe("230px");
|
||||||
|
const ruleIndex = css.indexOf(".chat-thread--keyboard-active {");
|
||||||
|
expect(ruleIndex).toBeGreaterThan(-1);
|
||||||
|
expect(isInsideNarrowMediaRule(css, ruleIndex)).toBe(false);
|
||||||
|
} finally { viewport.restore(); restoreHost(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["tablet viewport mode", () => mockViewportMode("tablet"), 900, 1180, 700],
|
||||||
|
["tablet-class touch viewport", mockTabletClassTouchViewport, 768, 1024, 600],
|
||||||
|
])("tracks the keyboard on %s", async (_name, setup, width, layoutHeight, visualHeight) => {
|
||||||
|
const restoreHost = setup();
|
||||||
|
const viewport = mockVisualViewport({ width, height: layoutHeight });
|
||||||
|
try {
|
||||||
|
const input = await renderChat();
|
||||||
|
await openKeyboard(input, viewport.vv, visualHeight);
|
||||||
|
expect(getThread()).toHaveClass("chat-thread--keyboard-active");
|
||||||
|
expect(getThread().style.getPropertyValue("--vv-height")).toBe(`${visualHeight}px`);
|
||||||
|
} finally { viewport.restore(); restoreHost(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["compact dock", { compactLayout: true }],
|
||||||
|
["narrow floating Chat", { floating: true }],
|
||||||
|
])("tracks the keyboard in %s", async (_name, props) => {
|
||||||
|
const restoreHost = mockDesktopNonTouchViewport();
|
||||||
|
const viewport = mockVisualViewport({ width: 1280, height: 900 });
|
||||||
|
try {
|
||||||
|
const input = await renderChat(props);
|
||||||
|
await openKeyboard(input, viewport.vv, 500);
|
||||||
|
expect(getThread()).toHaveClass("chat-thread--keyboard-active");
|
||||||
|
if (props.floating) expect(document.querySelector(".chat-view")).toHaveClass("chat-view--narrow");
|
||||||
|
} finally { viewport.restore(); restoreHost(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not fabricate keyboard layout state on desktop", async () => {
|
||||||
|
const restoreHost = mockDesktopNonTouchViewport();
|
||||||
|
const viewport = mockVisualViewport({ width: 1280, height: 900 });
|
||||||
|
try {
|
||||||
|
const input = await renderChat();
|
||||||
|
await openKeyboard(input, viewport.vv, 500);
|
||||||
|
expect(getThread()).not.toHaveClass("chat-thread--keyboard-active");
|
||||||
|
expect(getThread().style.getPropertyValue("--vv-height")).toBe("");
|
||||||
|
expect(getThread().style.transform).toBe("");
|
||||||
|
} finally { viewport.restore(); restoreHost(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
it("makes the landscape keyboard state reachable without pinning the body", async () => {
|
||||||
|
const restoreHost = mockPhoneLandscapeViewport();
|
||||||
|
const viewport = mockVisualViewport({ width: 932, height: 430 });
|
||||||
|
try {
|
||||||
|
const input = await renderChat();
|
||||||
|
await openKeyboard(input, viewport.vv, 200);
|
||||||
|
await act(async () => undefined);
|
||||||
|
const outsideMove = new Event("touchmove", { bubbles: true, cancelable: true });
|
||||||
|
document.body.dispatchEvent(outsideMove);
|
||||||
|
expect(outsideMove.defaultPrevented).toBe(true);
|
||||||
|
const messages = document.querySelector(".chat-messages") as HTMLElement;
|
||||||
|
const messagesMove = new Event("touchmove", { bubbles: true, cancelable: true });
|
||||||
|
messages.dispatchEvent(messagesMove);
|
||||||
|
expect(messagesMove.defaultPrevented).toBe(false);
|
||||||
|
expect(document.body.style.position).not.toBe("fixed");
|
||||||
|
} finally { viewport.restore(); restoreHost(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
it("anchors messages after the keyboard opens on landscape, compact dock, and floating hosts", async () => {
|
||||||
|
for (const [name, setupHost, props, width, layoutHeight, visualHeight] of [
|
||||||
|
["landscape", mockPhoneLandscapeViewport, {}, 932, 430, 200],
|
||||||
|
["compact dock", mockDesktopNonTouchViewport, { compactLayout: true }, 1280, 900, 500],
|
||||||
|
["narrow floating", mockDesktopNonTouchViewport, { floating: true }, 1280, 900, 500],
|
||||||
|
] as const) {
|
||||||
|
const restoreHost = setupHost();
|
||||||
|
const viewport = mockVisualViewport({ width, height: layoutHeight });
|
||||||
|
try {
|
||||||
|
const input = await renderChat(props);
|
||||||
|
const readScrollTop = observeKeyboardScroll();
|
||||||
|
await openKeyboard(input, viewport.vv, visualHeight);
|
||||||
|
expect(readScrollTop(), name).toBe(1000);
|
||||||
|
} finally { cleanup(); viewport.restore(); restoreHost(); }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the active room-thread render path keyboard-active", async () => {
|
||||||
|
const restoreHost = mockDesktopNonTouchViewport();
|
||||||
|
const viewport = mockVisualViewport({ width: 1280, height: 900 });
|
||||||
|
const room = createRoomFixture("keyboard-room");
|
||||||
|
try {
|
||||||
|
localStorage.setItem("fusion:chat-scope", "rooms");
|
||||||
|
setupMockChat({ sessions: [], filteredSessions: [] });
|
||||||
|
setupMockRooms({ rooms: [room], activeRoom: null });
|
||||||
|
const style = document.createElement("style");
|
||||||
|
style.textContent = css;
|
||||||
|
document.head.append(style);
|
||||||
|
const view = render(<ChatView projectId="proj-123" addToast={vi.fn()} compactLayout />);
|
||||||
|
await act(async () => { screen.getByTestId("chat-room-item-keyboard-room").click(); });
|
||||||
|
setupMockRooms({ rooms: [room], activeRoom: room });
|
||||||
|
view.rerender(<ChatView projectId="proj-123" addToast={vi.fn()} compactLayout />);
|
||||||
|
const input = screen.getByTestId("chat-input") as HTMLTextAreaElement;
|
||||||
|
expect(getThread().querySelector(".chat-room-thread-header")).toBeTruthy();
|
||||||
|
await openKeyboard(input, viewport.vv, 500);
|
||||||
|
expect(getThread()).toHaveClass("chat-thread--keyboard-active");
|
||||||
|
expect(getThread().querySelector(".chat-room-thread-header")).toBeTruthy();
|
||||||
|
} finally { viewport.restore(); restoreHost(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not fabricate keyboard state for an unshrunk compact host", async () => {
|
||||||
|
const restoreHost = mockDesktopNonTouchViewport();
|
||||||
|
const viewport = mockVisualViewport({ width: 1280, height: 900 });
|
||||||
|
try {
|
||||||
|
const input = await renderChat({ compactLayout: true });
|
||||||
|
await act(async () => input.focus());
|
||||||
|
const move = new Event("touchmove", { bubbles: true, cancelable: true });
|
||||||
|
document.body.dispatchEvent(move);
|
||||||
|
expect(getThread()).not.toHaveClass("chat-thread--keyboard-active");
|
||||||
|
expect(move.defaultPrevented).toBe(false);
|
||||||
|
} finally { viewport.restore(); restoreHost(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
it("only applies drift compensation when visual viewport offset is nonzero", async () => {
|
||||||
|
const viewport = mockVisualViewport({ width: 375, height: 812 });
|
||||||
|
const mode = mockViewportMode("mobile");
|
||||||
|
try {
|
||||||
|
const input = await renderChat();
|
||||||
|
await openKeyboard(input, viewport.vv, 400);
|
||||||
|
expect(getThread().style.transform).toBe("");
|
||||||
|
await act(async () => setVisualViewportOffsetTop(viewport.vv, 40));
|
||||||
|
expect(getThread().style.transform).toBe("translateY(40px)");
|
||||||
|
expect(getThread().style.willChange).toBe("transform");
|
||||||
|
expect(css.slice(css.indexOf(".chat-thread--keyboard-active {"), css.indexOf(".chat-thread-header"))).not.toMatch(/\n\s*(transform|will-change)\s*:/);
|
||||||
|
} finally { mode.mockRestore(); viewport.restore(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not activate keyboard consumers for pinch zoom", async () => {
|
||||||
|
const restoreHost = mockPhoneLandscapeViewport();
|
||||||
|
const viewport = mockVisualViewport({ width: 932, height: 430 });
|
||||||
|
try {
|
||||||
|
const input = await renderChat();
|
||||||
|
observeKeyboardScroll();
|
||||||
|
Object.defineProperty(viewport.vv, "scale", { value: 1.5, writable: true, configurable: true });
|
||||||
|
await openKeyboard(input, viewport.vv, 200);
|
||||||
|
const move = new Event("touchmove", { bubbles: true, cancelable: true });
|
||||||
|
document.body.dispatchEvent(move);
|
||||||
|
// The visual-viewport writer deliberately remains scale-agnostic; this verifies the shared
|
||||||
|
// keyboard hook does not turn a pinch sample into touch keyboard state.
|
||||||
|
expect(move.defaultPrevented).toBe(false);
|
||||||
|
} finally { viewport.restore(); restoreHost(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
it("no-ops cleanly when visualViewport is unavailable", async () => {
|
||||||
|
const descriptor = Object.getOwnPropertyDescriptor(window, "visualViewport");
|
||||||
|
const mode = mockViewportMode("mobile");
|
||||||
|
try {
|
||||||
|
delete (window as { visualViewport?: VisualViewport }).visualViewport;
|
||||||
|
const input = await renderChat();
|
||||||
|
await act(async () => input.focus());
|
||||||
|
expect(getThread()).not.toHaveClass("chat-thread--keyboard-active");
|
||||||
|
} finally {
|
||||||
|
mode.mockRestore();
|
||||||
|
if (descriptor) Object.defineProperty(window, "visualViewport", descriptor);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears keyboard layout state during blur suppression and unmount", async () => {
|
||||||
|
const viewport = mockVisualViewport({ width: 375, height: 812 });
|
||||||
|
const mode = mockViewportMode("mobile");
|
||||||
|
try {
|
||||||
|
const input = await renderChat();
|
||||||
|
await openKeyboard(input, viewport.vv, 400);
|
||||||
|
const thread = getThread();
|
||||||
|
expect(thread).toHaveClass("chat-thread--keyboard-active");
|
||||||
|
await act(async () => input.blur());
|
||||||
|
await act(async () => setVisualViewportHeight(viewport.vv, 400));
|
||||||
|
expect(thread).not.toHaveClass("chat-thread--keyboard-active");
|
||||||
|
expect(thread.style.getPropertyValue("--chat-keyboard-accessory-clearance")).toBe("0px");
|
||||||
|
expect(thread.style.transform).toBe("");
|
||||||
|
cleanup();
|
||||||
|
expect(thread).not.toHaveClass("chat-thread--keyboard-active");
|
||||||
|
expect(thread.style.getPropertyValue("--chat-keyboard-accessory-clearance")).toBe("0px");
|
||||||
|
expect(thread.style.willChange).toBe("");
|
||||||
|
} finally { viewport.restore(); mode.mockRestore(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the header fallback when a test layout has no measurable thread rectangle", async () => {
|
||||||
|
const viewport = mockVisualViewport({ width: 375, height: 812 });
|
||||||
|
const mode = mockViewportMode("mobile");
|
||||||
|
try {
|
||||||
|
const input = await renderChat();
|
||||||
|
await openKeyboard(input, viewport.vv, 400);
|
||||||
|
expect(getThread().style.getPropertyValue("--chat-thread-viewport-top")).toBe("");
|
||||||
|
expect(css).toContain("var(--chat-thread-viewport-top, var(--header-height))");
|
||||||
|
} finally { mode.mockRestore(); viewport.restore(); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
void mockUseChat;
|
||||||
|
void mockUseChatRooms;
|
||||||
@@ -224,7 +224,7 @@ export function mockViewportMode(mode: "mobile" | "tablet" | "desktop") {
|
|||||||
ensureMatchMedia();
|
ensureMatchMedia();
|
||||||
const width = { mobile: 375, tablet: 900, desktop: 1280 }[mode];
|
const width = { mobile: 375, tablet: 900, desktop: 1280 }[mode];
|
||||||
Object.defineProperty(window, "innerWidth", { value: width, configurable: true });
|
Object.defineProperty(window, "innerWidth", { value: width, configurable: true });
|
||||||
return vi.spyOn(window, "matchMedia").mockImplementation((query: string) => ({
|
return vi.spyOn(window, "matchMedia").mockImplementation((query: string = "") => ({
|
||||||
matches:
|
matches:
|
||||||
(mode === "mobile" && (query === "(max-width: 768px)" || query === "(max-width: 768px), (max-height: 480px)")) ||
|
(mode === "mobile" && (query === "(max-width: 768px)" || query === "(max-width: 768px), (max-height: 480px)")) ||
|
||||||
(mode === "tablet" && query.includes("min-width: 769px") && query.includes("max-width: 1024px")),
|
(mode === "tablet" && query.includes("min-width: 769px") && query.includes("max-width: 1024px")),
|
||||||
@@ -243,6 +243,105 @@ FNXC:DashboardTests 2026-08-23-03:40:
|
|||||||
FN-9193 also covers tablet-class touch hardware at 768 CSS pixels. Touch and physical screen
|
FN-9193 also covers tablet-class touch hardware at 768 CSS pixels. Touch and physical screen
|
||||||
signals are required by useViewportMode; without them this setup silently becomes mobile.
|
signals are required by useViewportMode; without them this setup silently becomes mobile.
|
||||||
*/
|
*/
|
||||||
|
/*
|
||||||
|
FNXC:DashboardTests 2026-08-23-16:07:
|
||||||
|
Keyboard tests need a visual viewport whose height can shrink independently from layout height. iOS
|
||||||
|
reports that gap while its software keyboard is open; syncing both values would make the keyboard
|
||||||
|
formula zero and silently make tests vacuous.
|
||||||
|
*/
|
||||||
|
export function mockVisualViewport({ width, height }: { width: number; height: number }) {
|
||||||
|
const visualViewportDescriptor = Object.getOwnPropertyDescriptor(window, "visualViewport");
|
||||||
|
const innerHeightDescriptor = Object.getOwnPropertyDescriptor(window, "innerHeight");
|
||||||
|
const clientHeightDescriptor = Object.getOwnPropertyDescriptor(document.documentElement, "clientHeight");
|
||||||
|
const vv = new EventTarget() as unknown as VisualViewport;
|
||||||
|
for (const [key, value] of Object.entries({ width, height, offsetTop: 0, offsetLeft: 0, pageTop: 0, pageLeft: 0, scale: 1 })) {
|
||||||
|
Object.defineProperty(vv, key, { value, writable: true, configurable: true });
|
||||||
|
}
|
||||||
|
Object.defineProperty(window, "visualViewport", { value: vv, configurable: true });
|
||||||
|
Object.defineProperty(window, "innerHeight", { value: height, configurable: true });
|
||||||
|
Object.defineProperty(document.documentElement, "clientHeight", { value: height, configurable: true });
|
||||||
|
return {
|
||||||
|
vv,
|
||||||
|
restore: () => {
|
||||||
|
if (visualViewportDescriptor) Object.defineProperty(window, "visualViewport", visualViewportDescriptor);
|
||||||
|
else delete (window as { visualViewport?: VisualViewport }).visualViewport;
|
||||||
|
if (innerHeightDescriptor) Object.defineProperty(window, "innerHeight", innerHeightDescriptor);
|
||||||
|
if (clientHeightDescriptor) Object.defineProperty(document.documentElement, "clientHeight", clientHeightDescriptor);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* FNXC:DashboardTests 2026-08-23-16:07: A software keyboard changes only visualViewport height; retain layout height so overlap remains observable. */
|
||||||
|
export function setVisualViewportHeight(vv: VisualViewport, height: number) {
|
||||||
|
Object.defineProperty(vv, "height", { value: height, writable: true, configurable: true });
|
||||||
|
vv.dispatchEvent(new Event("resize"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* FNXC:DashboardTests 2026-08-23-16:07: A genuine layout resize changes layout and visual heights together, unlike keyboard opening. */
|
||||||
|
export function setLayoutViewportHeight(vv: VisualViewport, height: number) {
|
||||||
|
Object.defineProperty(window, "innerHeight", { value: height, configurable: true });
|
||||||
|
Object.defineProperty(document.documentElement, "clientHeight", { value: height, configurable: true });
|
||||||
|
setVisualViewportHeight(vv, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setVisualViewportOffsetTop(vv: VisualViewport, offsetTop: number) {
|
||||||
|
Object.defineProperty(vv, "offsetTop", { value: offsetTop, writable: true, configurable: true });
|
||||||
|
vv.dispatchEvent(new Event("scroll"));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function simulateKeyboardOpen({ vv, input, visualHeight }: { vv: VisualViewport; input: HTMLElement; visualHeight: number }) {
|
||||||
|
input.focus();
|
||||||
|
input.dispatchEvent(new FocusEvent("focusin", { bubbles: true }));
|
||||||
|
setVisualViewportHeight(vv, visualHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:DashboardTests 2026-08-23-16:07:
|
||||||
|
This reproduces a phone-class landscape screen: Chat resolves mobile from short height while the
|
||||||
|
shared hook's width heuristic sees 932px, covering the previously mismatched gate.
|
||||||
|
*/
|
||||||
|
export function mockPhoneLandscapeViewport() {
|
||||||
|
ensureMatchMedia();
|
||||||
|
const widthDescriptor = Object.getOwnPropertyDescriptor(window, "innerWidth");
|
||||||
|
const heightDescriptor = Object.getOwnPropertyDescriptor(window, "innerHeight");
|
||||||
|
const screenDescriptor = Object.getOwnPropertyDescriptor(window, "screen");
|
||||||
|
const touchDescriptor = Object.getOwnPropertyDescriptor(navigator, "maxTouchPoints");
|
||||||
|
Object.defineProperty(window, "innerWidth", { value: 932, configurable: true });
|
||||||
|
Object.defineProperty(window, "innerHeight", { value: 430, configurable: true });
|
||||||
|
Object.defineProperty(window, "screen", { value: { width: 430, height: 932 }, configurable: true });
|
||||||
|
Object.defineProperty(navigator, "maxTouchPoints", { value: 1, configurable: true });
|
||||||
|
const spy = vi.spyOn(window, "matchMedia").mockImplementation((query: string = "") => ({
|
||||||
|
matches: query === "(max-height: 480px)" || query === "(max-width: 768px), (max-height: 480px)",
|
||||||
|
media: query, onchange: null, addListener: vi.fn(), removeListener: vi.fn(), addEventListener: vi.fn(), removeEventListener: vi.fn(), dispatchEvent: vi.fn(),
|
||||||
|
}));
|
||||||
|
return () => {
|
||||||
|
spy.mockRestore();
|
||||||
|
if (widthDescriptor) Object.defineProperty(window, "innerWidth", widthDescriptor);
|
||||||
|
if (heightDescriptor) Object.defineProperty(window, "innerHeight", heightDescriptor);
|
||||||
|
if (screenDescriptor) Object.defineProperty(window, "screen", screenDescriptor);
|
||||||
|
if (touchDescriptor) Object.defineProperty(navigator, "maxTouchPoints", touchDescriptor);
|
||||||
|
else delete (navigator as { maxTouchPoints?: number }).maxTouchPoints;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* FNXC:DashboardTests 2026-08-23-16:07: Explicit desktop non-touch setup keeps desktop-negative and compact-host tests independent of harness defaults. */
|
||||||
|
export function mockDesktopNonTouchViewport() {
|
||||||
|
ensureMatchMedia();
|
||||||
|
const widthDescriptor = Object.getOwnPropertyDescriptor(window, "innerWidth");
|
||||||
|
const touchDescriptor = Object.getOwnPropertyDescriptor(navigator, "maxTouchPoints");
|
||||||
|
Object.defineProperty(window, "innerWidth", { value: 1280, configurable: true });
|
||||||
|
Object.defineProperty(navigator, "maxTouchPoints", { value: 0, configurable: true });
|
||||||
|
const spy = vi.spyOn(window, "matchMedia").mockImplementation((query: string = "") => ({
|
||||||
|
matches: false, media: query, onchange: null, addListener: vi.fn(), removeListener: vi.fn(), addEventListener: vi.fn(), removeEventListener: vi.fn(), dispatchEvent: vi.fn(),
|
||||||
|
}));
|
||||||
|
return () => {
|
||||||
|
spy.mockRestore();
|
||||||
|
if (widthDescriptor) Object.defineProperty(window, "innerWidth", widthDescriptor);
|
||||||
|
if (touchDescriptor) Object.defineProperty(navigator, "maxTouchPoints", touchDescriptor);
|
||||||
|
else delete (navigator as { maxTouchPoints?: number }).maxTouchPoints;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function mockTabletClassTouchViewport() {
|
export function mockTabletClassTouchViewport() {
|
||||||
ensureMatchMedia();
|
ensureMatchMedia();
|
||||||
const previousWidth = window.innerWidth;
|
const previousWidth = window.innerWidth;
|
||||||
@@ -251,7 +350,7 @@ export function mockTabletClassTouchViewport() {
|
|||||||
Object.defineProperty(window, "innerWidth", { value: 768, configurable: true });
|
Object.defineProperty(window, "innerWidth", { value: 768, configurable: true });
|
||||||
Object.defineProperty(navigator, "maxTouchPoints", { value: 1, configurable: true });
|
Object.defineProperty(navigator, "maxTouchPoints", { value: 1, configurable: true });
|
||||||
Object.defineProperty(window, "screen", { value: { width: 768, height: 1024 }, configurable: true });
|
Object.defineProperty(window, "screen", { value: { width: 768, height: 1024 }, configurable: true });
|
||||||
const spy = vi.spyOn(window, "matchMedia").mockImplementation((query: string) => ({
|
const spy = vi.spyOn(window, "matchMedia").mockImplementation((query: string = "") => ({
|
||||||
matches: query.includes("max-width: 768px") && !query.includes("max-width: 600px") && !query.includes("max-height: 480px"),
|
matches: query.includes("max-width: 768px") && !query.includes("max-width: 600px") && !query.includes("max-height: 480px"),
|
||||||
media: query, onchange: null, addListener: vi.fn(), removeListener: vi.fn(), addEventListener: vi.fn(), removeEventListener: vi.fn(), dispatchEvent: vi.fn(),
|
media: query, onchange: null, addListener: vi.fn(), removeListener: vi.fn(), addEventListener: vi.fn(), removeEventListener: vi.fn(), dispatchEvent: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user