mirror of
https://github.com/block/buzz.git
synced 2026-11-05 00:41:28 +00:00
feat(desktop): add back history menu
Co-authored-by: Jitter <d14dfe033ef0f809866f9f984de04821b0d900d7652fd85a54776ee40ca3a68f@buzz.block.builderlab.xyz> Signed-off-by: Matt Toohey <contact@matttoohey.com>
This commit is contained in:
@@ -13,6 +13,7 @@ import {
|
||||
TerminalContextOverrideProvider,
|
||||
} from "@/app/TerminalContextOverrideContext";
|
||||
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
|
||||
import { describeHistoryLocation } from "@/app/navigation/navigationHistory";
|
||||
import { useBackForwardControls } from "@/app/navigation/useBackForwardControls";
|
||||
import { useCommunityNavigationTransitions } from "@/app/useCommunityNavigationTransitions";
|
||||
import { useLiveHomeFeedActions } from "@/app/useLiveHomeFeedActions";
|
||||
@@ -141,6 +142,8 @@ export function AppShell() {
|
||||
const mainInsetRef = React.useRef<HTMLElement>(null);
|
||||
const location = useLocation();
|
||||
const queryClient = useQueryClient();
|
||||
const channelsQuery = useChannelsQuery();
|
||||
const channels = channelsQuery.data ?? [];
|
||||
useManagedAgentRuntimeReconciliation(communitiesHook.communities); // sync storage snapshot
|
||||
const {
|
||||
goAgents,
|
||||
@@ -154,8 +157,8 @@ export function AppShell() {
|
||||
closeSettings,
|
||||
openSearchHit,
|
||||
} = useAppNavigation();
|
||||
const { canGoBack, canGoForward, goBack, goForward } =
|
||||
useBackForwardControls();
|
||||
const { backHistory, canGoBack, canGoForward, goBack, goBackTo, goForward } =
|
||||
useBackForwardControls(describeHistoryLocation(location, channels));
|
||||
const { selectedChannelId, selectedView } = React.useMemo(
|
||||
() => deriveShellRoute(location.pathname),
|
||||
[location.pathname],
|
||||
@@ -233,8 +236,6 @@ export function AppShell() {
|
||||
const { feedProfilesQuery, homeFeedQuery, notificationSettings } =
|
||||
useHomeFeedNotifications(identityQuery.data?.pubkey);
|
||||
const feedItemState = useFeedItemState(identityQuery.data?.pubkey);
|
||||
const channelsQuery = useChannelsQuery();
|
||||
const channels = channelsQuery.data ?? [];
|
||||
useReminderNotifications(
|
||||
identityQuery.data?.pubkey,
|
||||
notificationSettings.settings,
|
||||
@@ -774,10 +775,12 @@ export function AppShell() {
|
||||
<AppWorkflowEditorOverlayProvider>
|
||||
{!settingsOpen && !isHuddleRoom ? (
|
||||
<AppTopChrome
|
||||
backHistory={backHistory}
|
||||
canGoBack={canGoBack}
|
||||
canGoForward={canGoForward}
|
||||
hasCommunityRail={hasCommunityRail}
|
||||
onGoBack={goBack}
|
||||
onGoBackTo={goBackTo}
|
||||
onGoForward={goForward}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -1,18 +1,27 @@
|
||||
import * as React from "react";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
|
||||
import type { BackHistoryEntry } from "@/app/navigation/navigationHistory";
|
||||
import { isMacPlatform } from "@/shared/lib/platform";
|
||||
import { useIsFullscreen } from "@/shared/lib/useIsFullscreen";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuTrigger,
|
||||
} from "@/shared/ui/context-menu";
|
||||
import { DrawerPanelIcon } from "@/shared/ui/DrawerPanelIcon";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { topChromeBackdrop } from "@/shared/layout/chromeLayout";
|
||||
import { useOptionalSidebar } from "@/shared/ui/sidebar";
|
||||
|
||||
type AppTopChromeProps = {
|
||||
backHistory: BackHistoryEntry[];
|
||||
canGoBack: boolean;
|
||||
canGoForward: boolean;
|
||||
onGoBack: () => void;
|
||||
onGoBackTo: (index: number) => void;
|
||||
onGoForward: () => void;
|
||||
hasCommunityRail?: boolean;
|
||||
};
|
||||
@@ -25,6 +34,7 @@ const TOP_CHROME_ICON_BUTTON_CLASS =
|
||||
"h-[28px] w-[28px] rounded-[4px] text-sidebar-foreground/65 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground";
|
||||
const HISTORY_ICON_BUTTON_CLASS =
|
||||
"h-[28px] w-[24px] rounded-[4px] text-sidebar-foreground/65 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground [&_svg]:size-[16px]";
|
||||
const BACK_HISTORY_LONG_PRESS_MS = 500;
|
||||
|
||||
function preventTopChromeWheel(event: WheelEvent) {
|
||||
event.preventDefault();
|
||||
@@ -52,10 +62,117 @@ function TopChromeSidebarTrigger() {
|
||||
);
|
||||
}
|
||||
|
||||
function BackHistoryButton({
|
||||
backHistory,
|
||||
canGoBack,
|
||||
onGoBack,
|
||||
onGoBackTo,
|
||||
}: Pick<
|
||||
AppTopChromeProps,
|
||||
"backHistory" | "canGoBack" | "onGoBack" | "onGoBackTo"
|
||||
>) {
|
||||
const buttonRef = React.useRef<HTMLButtonElement>(null);
|
||||
const longPressTimerRef = React.useRef<number | null>(null);
|
||||
const longPressTriggeredRef = React.useRef(false);
|
||||
|
||||
const cancelLongPress = React.useCallback(() => {
|
||||
if (longPressTimerRef.current !== null) {
|
||||
window.clearTimeout(longPressTimerRef.current);
|
||||
longPressTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => cancelLongPress, [cancelLongPress]);
|
||||
|
||||
const handlePointerDown = React.useCallback(
|
||||
(event: React.PointerEvent<HTMLButtonElement>) => {
|
||||
if (event.button !== 0 || backHistory.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
cancelLongPress();
|
||||
longPressTriggeredRef.current = false;
|
||||
const { clientX, clientY } = event;
|
||||
longPressTimerRef.current = window.setTimeout(() => {
|
||||
longPressTimerRef.current = null;
|
||||
longPressTriggeredRef.current = true;
|
||||
buttonRef.current?.dispatchEvent(
|
||||
new MouseEvent("contextmenu", {
|
||||
bubbles: true,
|
||||
button: 2,
|
||||
cancelable: true,
|
||||
clientX,
|
||||
clientY,
|
||||
view: window,
|
||||
}),
|
||||
);
|
||||
}, BACK_HISTORY_LONG_PRESS_MS);
|
||||
},
|
||||
[backHistory.length, cancelLongPress],
|
||||
);
|
||||
|
||||
return (
|
||||
<ContextMenu
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
longPressTriggeredRef.current = false;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ContextMenuTrigger asChild>
|
||||
<Button
|
||||
ref={buttonRef}
|
||||
aria-label="Go back"
|
||||
className={HISTORY_ICON_BUTTON_CLASS}
|
||||
data-history-count={backHistory.length}
|
||||
data-testid="global-back"
|
||||
disabled={!canGoBack}
|
||||
onClick={(event) => {
|
||||
if (longPressTriggeredRef.current) {
|
||||
longPressTriggeredRef.current = false;
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
onGoBack();
|
||||
}}
|
||||
onPointerCancel={cancelLongPress}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerLeave={cancelLongPress}
|
||||
onPointerUp={cancelLongPress}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<ChevronLeft />
|
||||
</Button>
|
||||
</ContextMenuTrigger>
|
||||
{backHistory.length > 0 ? (
|
||||
<ContextMenuContent
|
||||
className="w-64"
|
||||
data-testid="global-back-history-menu"
|
||||
>
|
||||
{backHistory.map((entry) => (
|
||||
<ContextMenuItem
|
||||
aria-label={`Go back to ${entry.label}`}
|
||||
data-testid="global-back-history-item"
|
||||
key={entry.key}
|
||||
onSelect={() => onGoBackTo(entry.index)}
|
||||
>
|
||||
<span className="min-w-0 truncate">{entry.label}</span>
|
||||
</ContextMenuItem>
|
||||
))}
|
||||
</ContextMenuContent>
|
||||
) : null}
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
export function AppTopChrome({
|
||||
backHistory,
|
||||
canGoBack,
|
||||
canGoForward,
|
||||
onGoBack,
|
||||
onGoBackTo,
|
||||
onGoForward,
|
||||
hasCommunityRail = false,
|
||||
}: AppTopChromeProps) {
|
||||
@@ -132,17 +249,12 @@ export function AppTopChrome({
|
||||
>
|
||||
<div className={cn("flex items-center gap-0.5", navRowAlignmentClass)}>
|
||||
<TopChromeSidebarTrigger />
|
||||
<Button
|
||||
aria-label="Go back"
|
||||
className={HISTORY_ICON_BUTTON_CLASS}
|
||||
data-testid="global-back"
|
||||
disabled={!canGoBack}
|
||||
onClick={onGoBack}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<ChevronLeft />
|
||||
</Button>
|
||||
<BackHistoryButton
|
||||
backHistory={backHistory}
|
||||
canGoBack={canGoBack}
|
||||
onGoBack={onGoBack}
|
||||
onGoBackTo={onGoBackTo}
|
||||
/>
|
||||
<Button
|
||||
aria-label="Go forward"
|
||||
className={HISTORY_ICON_BUTTON_CLASS}
|
||||
|
||||
@@ -8,8 +8,12 @@ const target = {
|
||||
threadRootId: "thread-a",
|
||||
};
|
||||
|
||||
const { allowNavigation, registerNavigationGuard, traverseHistory } =
|
||||
await import("./navigationGuard.ts");
|
||||
const {
|
||||
allowNavigation,
|
||||
registerNavigationGuard,
|
||||
traverseHistory,
|
||||
traverseHistoryBy,
|
||||
} = await import("./navigationGuard.ts");
|
||||
|
||||
test("all navigation consults the registered boundary guard", () => {
|
||||
let received;
|
||||
@@ -67,6 +71,30 @@ test("guarded history traversal invokes the selected direction when allowed", ()
|
||||
assert.equal(forwardCalls, 1);
|
||||
});
|
||||
|
||||
test("guarded multi-entry traversal checks direction before jumping", () => {
|
||||
let received;
|
||||
let receivedDelta;
|
||||
const unregister = registerNavigationGuard((nextTarget) => {
|
||||
received = nextTarget;
|
||||
return true;
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
traverseHistoryBy(
|
||||
{
|
||||
go: (delta) => {
|
||||
receivedDelta = delta;
|
||||
},
|
||||
},
|
||||
-3,
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.deepEqual(received, { kind: "history", direction: "back" });
|
||||
assert.equal(receivedDelta, -3);
|
||||
unregister();
|
||||
});
|
||||
|
||||
test("unregistering the newer guard restores the prior live guard", () => {
|
||||
const unregisterFirst = registerNavigationGuard(() => false);
|
||||
const unregisterSecond = registerNavigationGuard(() => true);
|
||||
|
||||
@@ -44,6 +44,23 @@ export function traverseHistory(
|
||||
return true;
|
||||
}
|
||||
|
||||
export function traverseHistoryBy(
|
||||
history: Pick<History, "go">,
|
||||
delta: number,
|
||||
): boolean {
|
||||
if (delta === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const direction = delta < 0 ? "back" : "forward";
|
||||
if (!allowNavigation({ kind: "history", direction })) {
|
||||
return false;
|
||||
}
|
||||
|
||||
history.go(delta);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function registerNavigationGuard(guard: NavigationGuard): () => void {
|
||||
const registration = { guard };
|
||||
activeGuards.push(registration);
|
||||
|
||||
80
desktop/src/app/navigation/navigationHistory.test.mjs
Normal file
80
desktop/src/app/navigation/navigationHistory.test.mjs
Normal file
@@ -0,0 +1,80 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
describeHistoryLocation,
|
||||
getBackHistoryEntries,
|
||||
} from "./navigationHistory.ts";
|
||||
|
||||
function entry(index) {
|
||||
return { index, key: `key-${index}`, label: `Entry ${index}` };
|
||||
}
|
||||
|
||||
test("back history returns the nearest ten entries in reverse order", () => {
|
||||
const entriesByIndex = new Map(
|
||||
Array.from({ length: 13 }, (_, index) => [index, entry(index)]),
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
getBackHistoryEntries(entriesByIndex, 13).map(({ index }) => index),
|
||||
[12, 11, 10, 9, 8, 7, 6, 5, 4, 3],
|
||||
);
|
||||
});
|
||||
|
||||
test("history labels identify channel and thread destinations", () => {
|
||||
const channels = [
|
||||
{
|
||||
id: "channel-a",
|
||||
name: "general",
|
||||
channelType: "stream",
|
||||
},
|
||||
{
|
||||
id: "dm-a",
|
||||
name: "Ada Lovelace",
|
||||
channelType: "dm",
|
||||
},
|
||||
];
|
||||
|
||||
assert.equal(
|
||||
describeHistoryLocation(
|
||||
{ pathname: "/channels/channel-a", search: {} },
|
||||
channels,
|
||||
),
|
||||
"#general",
|
||||
);
|
||||
assert.equal(
|
||||
describeHistoryLocation(
|
||||
{
|
||||
pathname: "/channels/channel-a",
|
||||
search: { thread: "message-a" },
|
||||
},
|
||||
channels,
|
||||
),
|
||||
"#general thread",
|
||||
);
|
||||
assert.equal(
|
||||
describeHistoryLocation(
|
||||
{ pathname: "/channels/dm-a", search: {} },
|
||||
channels,
|
||||
),
|
||||
"Ada Lovelace",
|
||||
);
|
||||
});
|
||||
|
||||
test("history labels cover static and detail routes", () => {
|
||||
assert.equal(
|
||||
describeHistoryLocation({ pathname: "/", search: {} }, []),
|
||||
"Inbox",
|
||||
);
|
||||
assert.equal(
|
||||
describeHistoryLocation({ pathname: "/projects", search: {} }, []),
|
||||
"Projects",
|
||||
);
|
||||
assert.equal(
|
||||
describeHistoryLocation(
|
||||
{ pathname: "/projects/project-a", search: {} },
|
||||
[],
|
||||
),
|
||||
"Project details",
|
||||
);
|
||||
});
|
||||
85
desktop/src/app/navigation/navigationHistory.ts
Normal file
85
desktop/src/app/navigation/navigationHistory.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import type { Channel } from "@/shared/api/types";
|
||||
|
||||
export type BackHistoryEntry = {
|
||||
index: number;
|
||||
key: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
const MAX_BACK_HISTORY_ENTRIES = 10;
|
||||
|
||||
export function getBackHistoryEntries(
|
||||
entriesByIndex: ReadonlyMap<number, BackHistoryEntry>,
|
||||
currentIndex: number,
|
||||
): BackHistoryEntry[] {
|
||||
const entries: BackHistoryEntry[] = [];
|
||||
|
||||
for (
|
||||
let index = currentIndex - 1;
|
||||
index >= 0 && entries.length < MAX_BACK_HISTORY_ENTRIES;
|
||||
index -= 1
|
||||
) {
|
||||
const entry = entriesByIndex.get(index);
|
||||
if (entry) {
|
||||
entries.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
type HistoryLocation = {
|
||||
pathname: string;
|
||||
search: unknown;
|
||||
};
|
||||
|
||||
function searchHasValue(search: unknown, key: string): boolean {
|
||||
if (typeof search !== "object" || search === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const value = (search as Record<string, unknown>)[key];
|
||||
return typeof value === "string" && value.length > 0;
|
||||
}
|
||||
|
||||
export function describeHistoryLocation(
|
||||
location: HistoryLocation,
|
||||
channels: readonly Channel[],
|
||||
): string {
|
||||
const { pathname, search } = location;
|
||||
|
||||
if (pathname.startsWith("/channels/")) {
|
||||
const [, , encodedChannelId, childRoute] = pathname.split("/");
|
||||
const channelId = encodedChannelId
|
||||
? decodeURIComponent(encodedChannelId)
|
||||
: "";
|
||||
const channel = channels.find((candidate) => candidate.id === channelId);
|
||||
const channelLabel = channel
|
||||
? channel.channelType === "dm"
|
||||
? channel.name
|
||||
: `#${channel.name}`
|
||||
: "Channel";
|
||||
|
||||
if (
|
||||
childRoute === "posts" ||
|
||||
searchHasValue(search, "thread") ||
|
||||
searchHasValue(search, "messageId")
|
||||
) {
|
||||
return `${channelLabel} thread`;
|
||||
}
|
||||
|
||||
return channelLabel;
|
||||
}
|
||||
|
||||
if (pathname === "/messages/new") return "New message";
|
||||
if (pathname === "/agents") return "Agents";
|
||||
if (pathname === "/workflows") return "Workflows";
|
||||
if (pathname.startsWith("/workflows/")) return "Workflow details";
|
||||
if (pathname === "/projects") return "Projects";
|
||||
if (pathname.startsWith("/projects/")) return "Project details";
|
||||
if (pathname === "/pulse") return "Pulse";
|
||||
if (pathname === "/reminders") return "Reminders";
|
||||
if (pathname === "/settings") return "Settings";
|
||||
|
||||
return "Inbox";
|
||||
}
|
||||
@@ -8,7 +8,14 @@ import { isTauri } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
|
||||
import { matchBackForwardChord } from "@/app/navigation/backForwardChords";
|
||||
import { traverseHistory } from "@/app/navigation/navigationGuard";
|
||||
import {
|
||||
type BackHistoryEntry,
|
||||
getBackHistoryEntries,
|
||||
} from "@/app/navigation/navigationHistory";
|
||||
import {
|
||||
traverseHistory,
|
||||
traverseHistoryBy,
|
||||
} from "@/app/navigation/navigationGuard";
|
||||
import { isMacPlatform } from "@/shared/lib/platform";
|
||||
import { trimMapToSize } from "@/shared/lib/trimMapToSize";
|
||||
|
||||
@@ -18,7 +25,9 @@ type RouterHistoryState = {
|
||||
key?: string;
|
||||
};
|
||||
|
||||
export function useBackForwardControls() {
|
||||
const MAX_TRACKED_HISTORY_ENTRIES = 200;
|
||||
|
||||
export function useBackForwardControls(currentLabel: string) {
|
||||
const router = useRouter();
|
||||
const canGoBack = useCanGoBack();
|
||||
const locationState = useRouterState({
|
||||
@@ -27,33 +36,52 @@ export function useBackForwardControls() {
|
||||
const locationIndex = locationState.__TSR_index ?? 0;
|
||||
const locationKey =
|
||||
locationState.__TSR_key ?? locationState.key ?? String(locationIndex);
|
||||
const keysByIndexRef = React.useRef(new Map<number, string>());
|
||||
const [maxIndex, setMaxIndex] = React.useState(locationIndex);
|
||||
const [historyState, setHistoryState] = React.useState(() => ({
|
||||
entriesByIndex: new Map<number, BackHistoryEntry>([
|
||||
[
|
||||
locationIndex,
|
||||
{ index: locationIndex, key: locationKey, label: currentLabel },
|
||||
],
|
||||
]),
|
||||
maxIndex: locationIndex,
|
||||
}));
|
||||
|
||||
React.useEffect(() => {
|
||||
const keysByIndex = keysByIndexRef.current;
|
||||
const currentKey = keysByIndex.get(locationIndex);
|
||||
setHistoryState((current) => {
|
||||
const entriesByIndex = new Map(current.entriesByIndex);
|
||||
const currentEntry = entriesByIndex.get(locationIndex);
|
||||
const replacedForwardEntry =
|
||||
currentEntry !== undefined && currentEntry.key !== locationKey;
|
||||
|
||||
if (currentKey && currentKey !== locationKey) {
|
||||
for (const storedIndex of [...keysByIndex.keys()]) {
|
||||
if (storedIndex >= locationIndex) {
|
||||
keysByIndex.delete(storedIndex);
|
||||
if (replacedForwardEntry) {
|
||||
for (const storedIndex of entriesByIndex.keys()) {
|
||||
if (storedIndex >= locationIndex) {
|
||||
entriesByIndex.delete(storedIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
keysByIndex.set(locationIndex, locationKey);
|
||||
trimMapToSize(keysByIndex, 200);
|
||||
setMaxIndex((current: number) => {
|
||||
if (currentKey && currentKey !== locationKey) {
|
||||
return locationIndex;
|
||||
}
|
||||
entriesByIndex.set(locationIndex, {
|
||||
index: locationIndex,
|
||||
key: locationKey,
|
||||
label: currentLabel,
|
||||
});
|
||||
trimMapToSize(entriesByIndex, MAX_TRACKED_HISTORY_ENTRIES);
|
||||
|
||||
return Math.max(current, locationIndex);
|
||||
return {
|
||||
entriesByIndex,
|
||||
maxIndex: replacedForwardEntry
|
||||
? locationIndex
|
||||
: Math.max(current.maxIndex, locationIndex),
|
||||
};
|
||||
});
|
||||
}, [locationIndex, locationKey]);
|
||||
}, [currentLabel, locationIndex, locationKey]);
|
||||
|
||||
const canGoForward = locationIndex < maxIndex;
|
||||
const canGoForward = locationIndex < historyState.maxIndex;
|
||||
const backHistory = React.useMemo(
|
||||
() => getBackHistoryEntries(historyState.entriesByIndex, locationIndex),
|
||||
[historyState.entriesByIndex, locationIndex],
|
||||
);
|
||||
|
||||
const goBack = React.useCallback(() => {
|
||||
if (!canGoBack) {
|
||||
@@ -71,6 +99,18 @@ export function useBackForwardControls() {
|
||||
traverseHistory(router.history, "forward");
|
||||
}, [canGoForward, router.history]);
|
||||
|
||||
const goBackTo = React.useCallback(
|
||||
(index: number) => {
|
||||
const delta = index - locationIndex;
|
||||
if (delta >= 0 || !historyState.entriesByIndex.has(index)) {
|
||||
return;
|
||||
}
|
||||
|
||||
traverseHistoryBy(router.history, delta);
|
||||
},
|
||||
[historyState.entriesByIndex, locationIndex, router.history],
|
||||
);
|
||||
|
||||
const handleKeyDown = React.useEffectEvent((event: KeyboardEvent) => {
|
||||
// Note: the chords deliberately fire even when focus is inside an
|
||||
// editable element. The composer autofocuses on every channel switch
|
||||
@@ -129,9 +169,11 @@ export function useBackForwardControls() {
|
||||
}, []);
|
||||
|
||||
return {
|
||||
backHistory,
|
||||
canGoBack,
|
||||
canGoForward,
|
||||
goBack,
|
||||
goBackTo,
|
||||
goForward,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -93,6 +93,51 @@ test("global back and forward move across channel routes", async ({ page }) => {
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("random");
|
||||
});
|
||||
|
||||
test("back history menu opens on right click and long press", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
|
||||
await page.getByTestId("channel-general").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
||||
await page.getByTestId("channel-random").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("random");
|
||||
await page.getByTestId("channel-engineering").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("engineering");
|
||||
|
||||
const backButton = page.getByTestId("global-back");
|
||||
const historyMenu = page.getByTestId("global-back-history-menu");
|
||||
|
||||
await expect(backButton).toHaveAttribute("data-history-count", "3");
|
||||
await backButton.click({ button: "right" });
|
||||
await expect(historyMenu).toBeVisible();
|
||||
await expect(historyMenu.getByTestId("global-back-history-item")).toHaveText([
|
||||
"#random",
|
||||
"#general",
|
||||
"Inbox",
|
||||
]);
|
||||
await historyMenu
|
||||
.getByRole("menuitem", { name: "Go back to #general" })
|
||||
.click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
||||
|
||||
await page.getByTestId("channel-deep-history").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("deep-history");
|
||||
|
||||
const bounds = await backButton.boundingBox();
|
||||
expect(bounds).not.toBeNull();
|
||||
await page.mouse.move(
|
||||
(bounds?.x ?? 0) + (bounds?.width ?? 0) / 2,
|
||||
(bounds?.y ?? 0) + (bounds?.height ?? 0) / 2,
|
||||
);
|
||||
await page.mouse.down();
|
||||
await expect(historyMenu).toBeVisible();
|
||||
await page.mouse.up();
|
||||
await expect(historyMenu).toBeVisible();
|
||||
await historyMenu.getByRole("menuitem", { name: "Go back to Inbox" }).click();
|
||||
await expect(page.getByTestId("home-inbox")).toBeVisible();
|
||||
});
|
||||
|
||||
test("back/forward keyboard chords work while the composer has focus", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
Reference in New Issue
Block a user