Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions desktop/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export default defineConfig({
"**/home-collapsed-top-chrome.spec.ts",
"**/thread-unread.spec.ts",
"**/thread-reply-anchor-roleplay.spec.ts",
"**/threadpane-ultrawide.spec.ts",
"**/animated-avatar.spec.ts",
"**/reminders.spec.ts",
"**/virtualization.spec.ts",
Expand Down
20 changes: 14 additions & 6 deletions desktop/src/shared/hooks/useThreadPanelWidth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,25 @@ import * as React from "react";

import {
AUXILIARY_PANEL_DEFAULT_WIDTH_PX,
AUXILIARY_PANEL_MAX_WIDTH_PX,
AUXILIARY_PANEL_MIN_WIDTH_PX,
clampAuxiliaryPanelWidth,
} from "@/shared/layout/AuxiliaryPanel";

const THREAD_PANEL_WIDTH_SESSION_KEY = "buzz.desktop.thread-panel-width";

function getViewportWidth(): number {
return typeof window === "undefined" ? 0 : window.innerWidth;
}

/**
* Clamp the stored panel width for the current viewport.
*
* The upper bound grows with the viewport (see {@link clampAuxiliaryPanelWidth}) so
* the pane can expand on ultrawide displays. `AuxiliaryPanelShell` additionally
* clamps the rendered width to `calc(100% - MIN)` at paint time, so a stored width
* larger than the current viewport never collapses the main pane.
*/
function clampThreadPanelWidth(width: number): number {
return Math.max(
AUXILIARY_PANEL_MIN_WIDTH_PX,
Math.min(AUXILIARY_PANEL_MAX_WIDTH_PX, width),
);
return clampAuxiliaryPanelWidth(width, getViewportWidth());
}

function getInitialThreadPanelWidth(): number {
Expand Down
2 changes: 2 additions & 0 deletions desktop/src/shared/layout/AuxiliaryPanel/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,6 @@ export {
AUXILIARY_PANEL_MAX_WIDTH_PX,
AUXILIARY_PANEL_MIN_WIDTH_PX,
AUXILIARY_PANEL_SINGLE_COLUMN_BREAKPOINT_PX,
clampAuxiliaryPanelWidth,
getAuxiliaryPanelMaxWidth,
} from "@/shared/layout/auxiliaryPanelLayout";
37 changes: 37 additions & 0 deletions desktop/src/shared/layout/auxiliaryPanelLayout.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import assert from "node:assert/strict";
import test from "node:test";

import {
AUXILIARY_PANEL_MAX_WIDTH_PX,
AUXILIARY_PANEL_MIN_WIDTH_PX,
clampAuxiliaryPanelWidth,
getAuxiliaryPanelMaxWidth,
} from "./auxiliaryPanelLayout.ts";

test("max width falls back to the static cap on narrow viewports", () => {
// On viewports where `viewportWidth - MIN` is below the static cap, the floor wins.
assert.equal(getAuxiliaryPanelMaxWidth(900), AUXILIARY_PANEL_MAX_WIDTH_PX);
assert.equal(getAuxiliaryPanelMaxWidth(0), AUXILIARY_PANEL_MAX_WIDTH_PX);
});

test("max width grows with the viewport, reserving the main pane", () => {
assert.equal(
getAuxiliaryPanelMaxWidth(3440),
3440 - AUXILIARY_PANEL_MIN_WIDTH_PX,
);
});

test("clamp keeps width within [min, viewport-aware max]", () => {
// Below the floor clamps up to the min.
assert.equal(
clampAuxiliaryPanelWidth(100, 3440),
AUXILIARY_PANEL_MIN_WIDTH_PX,
);
// A wide drag is allowed on an ultrawide viewport.
assert.equal(clampAuxiliaryPanelWidth(2000, 3440), 2000);
// The same wide value is clamped down on a small viewport.
assert.equal(
clampAuxiliaryPanelWidth(2000, 900),
AUXILIARY_PANEL_MAX_WIDTH_PX,
);
});
26 changes: 26 additions & 0 deletions desktop/src/shared/layout/auxiliaryPanelLayout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,29 @@ export const AUXILIARY_PANEL_MIN_WIDTH_PX = 300;
export const AUXILIARY_PANEL_SINGLE_COLUMN_BREAKPOINT_PX =
AUXILIARY_PANEL_MIN_WIDTH_PX * 2;
export const AUXILIARY_PANEL_MAX_WIDTH_PX = 720;

/**
* Upper bound for the auxiliary panel width clamp, given the current viewport width.
*
* On ultrawide displays the static {@link AUXILIARY_PANEL_MAX_WIDTH_PX} is too small,
* so the panel is allowed to grow with the viewport while always reserving at least
* {@link AUXILIARY_PANEL_MIN_WIDTH_PX} for the main pane. The static cap acts as a
* floor, so narrow viewports keep their existing behavior.
*/
export function getAuxiliaryPanelMaxWidth(viewportWidth: number): number {
return Math.max(
AUXILIARY_PANEL_MAX_WIDTH_PX,
viewportWidth - AUXILIARY_PANEL_MIN_WIDTH_PX,
);
}

/** Clamp a stored panel width into the allowed range for the current viewport. */
export function clampAuxiliaryPanelWidth(
width: number,
viewportWidth: number,
): number {
return Math.max(
AUXILIARY_PANEL_MIN_WIDTH_PX,
Math.min(getAuxiliaryPanelMaxWidth(viewportWidth), width),
);
}
121 changes: 121 additions & 0 deletions desktop/tests/e2e/threadpane-ultrawide.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { expect, test } from "@playwright/test";

import { TEST_IDENTITIES, installMockBridge } from "../helpers/bridge";

// Ultrawide viewport: 3440px is a common 21:9 monitor width.
const ULTRAWIDE = { width: 3440, height: 1440 };

async function waitForMockLiveSubscription(
page: import("@playwright/test").Page,
channelName: string,
) {
await expect
.poll(async () =>
page.evaluate(
({ ch }) =>
(
window as Window & {
__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?: (input: {
channelName: string;
}) => boolean;
}
).__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({ channelName: ch }) ??
false,
{ ch: channelName },
),
)
.toBe(true);
}

async function emitMockReply(
page: import("@playwright/test").Page,
channelName: string,
content: string,
parentEventId: string,
) {
await page.evaluate(
({ ch, msg, parent, pubkey }) =>
(
window as Window & {
__BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: {
channelName: string;
content: string;
parentEventId?: string | null;
pubkey?: string;
createdAt?: number;
}) => unknown;
}
).__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: ch,
content: msg,
parentEventId: parent,
pubkey,
createdAt: Math.floor(Date.now() / 1000) - 10,
}),
{
ch: channelName,
msg: content,
parent: parentEventId,
pubkey: TEST_IDENTITIES.alice.pubkey,
},
);
}

async function openThread(page: import("@playwright/test").Page) {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await waitForMockLiveSubscription(page, "general");

// Seed a reply so a thread summary row appears, then open it.
await emitMockReply(
page,
"general",
"Reply to welcome",
"mock-general-welcome",
);
const threadSummary = page.getByTestId("message-thread-summary").first();
await expect(threadSummary).toBeVisible();
await threadSummary.click();
await expect(page.getByTestId("message-thread-panel")).toBeVisible();
}

test.describe("thread pane on ultrawide monitors", () => {
test("expands well past the legacy 720px cap", async ({ page }) => {
await page.setViewportSize(ULTRAWIDE);
await installMockBridge(page);
await openThread(page);

const pane = page.getByTestId("message-thread-panel");
const handle = page.getByTestId("right-auxiliary-pane-resize-handle");

const beforeBox = await pane.boundingBox();
if (!beforeBox) throw new Error("thread panel not laid out");
// The panel opens at its default narrow width, far from the viewport edge.
expect(beforeBox.width).toBeLessThan(720);
await page.screenshot({
path: "test-results/threadpane-ultrawide-before.png",
});

// Drag the resize handle far to the left to widen the right-hand pane.
const handleBox = await handle.boundingBox();
if (!handleBox) throw new Error("resize handle not laid out");
const startX = handleBox.x + handleBox.width / 2;
const startY = handleBox.y + handleBox.height / 2;
await page.mouse.move(startX, startY);
await page.mouse.down();
// Move left in steps so pointermove fires repeatedly.
for (let x = startX; x >= 600; x -= 120) {
await page.mouse.move(x, startY);
}
await page.mouse.up();

const afterBox = await pane.boundingBox();
if (!afterBox) throw new Error("thread panel not laid out after resize");
// The pane is now far wider than the old 720px hard cap.
expect(afterBox.width).toBeGreaterThan(1200);
await page.screenshot({
path: "test-results/threadpane-ultrawide-after.png",
});
});
});
Loading