Fix channels page shifting and dropped bottom-nav taps on mobile (#146)

This commit is contained in:
MrAlders0n authored and GitHub committed 2026-10-03 14:21:06 -04:00
1 parent 52d633d173
commit dc757cfb51
6 files changed
+95 -17

No files matched your search

+20 -12
View File
@@ -2,6 +2,7 @@ import { useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { BottomSheet } from "./BottomSheet";
import { ENABLED_TABS } from "../lib/constants";
import { touchClick } from "../lib/touch-click";
// Mobile-only tab bar (hidden at md+); overflow tabs live behind "More" in a bottom sheet.
const PRIMARY_TABS = ["Packets", "Channels", "Map", "Nodes"] as const;
@@ -62,6 +63,7 @@ function NavButton({ label, icon, active, onClick, role, ariaSelected, ariaHasPo
ariaHasPopup?: "menu";
ariaExpanded?: boolean;
}) {
const tap = touchClick(onClick);
return (
<button
type="button"
@@ -69,7 +71,7 @@ function NavButton({ label, icon, active, onClick, role, ariaSelected, ariaHasPo
aria-selected={ariaSelected}
aria-haspopup={ariaHasPopup}
aria-expanded={ariaExpanded}
onClick={onClick}
{...tap}
className={`flex-1 flex flex-col items-center justify-center gap-0.5 py-1.5 text-[10px] font-medium tracking-wide cursor-pointer transition-colors ${
active ? "text-primary" : "text-text-muted hover:text-text-normal"
}`}
@@ -80,23 +82,29 @@ function NavButton({ label, icon, active, onClick, role, ariaSelected, ariaHasPo
);
}
function SheetItem({ label, active, onPick }: { label: string; active: boolean; onPick: () => void }) {
const tap = touchClick(onPick);
return (
<button
type="button"
role="menuitem"
{...tap}
className={`w-full flex items-center gap-3 px-5 py-3 text-left text-sm font-medium cursor-pointer transition-colors ${
active ? "text-primary" : "text-text-normal hover:bg-text-normal/3"
}`}
>
{label}
</button>
);
}
// Bottom sheet listing the overflow tabs.
function MoreSheet({ tabs, activeTab, onPick, onClose }: { tabs: string[]; activeTab: string; onPick: (tab: string) => void; onClose: () => void }) {
const { t } = useTranslation();
return (
<BottomSheet onClose={onClose} role="menu" label={t("navigation.moreTabs")}>
{tabs.map((tab) => (
<button
key={tab}
type="button"
role="menuitem"
onClick={() => onPick(tab)}
className={`w-full flex items-center gap-3 px-5 py-3 text-left text-sm font-medium cursor-pointer transition-colors ${
activeTab === tab ? "text-primary" : "text-text-normal hover:bg-text-normal/3"
}`}
>
{t(`tabs.${tab}`, { defaultValue: tab })}
</button>
<SheetItem key={tab} label={t(`tabs.${tab}`, { defaultValue: tab })} active={activeTab === tab} onPick={() => onPick(tab)} />
))}
</BottomSheet>
);
+2 -3
View File
@@ -94,7 +94,6 @@ export function MessagePanel({ channel, heardCounts, iatas, regionKey, onAnalyze
[messages],
);
const bottomRef = useRef<HTMLDivElement>(null);
const [userScrolled, setUserScrolled] = useState(false);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const viewKey = `${channel?.id ?? ""}:${scope}`;
@@ -136,7 +135,8 @@ export function MessagePanel({ channel, heardCounts, iatas, regionKey, onAnalyze
}
if (sorted.length > anchor.count && !userScrolled) {
bottomRef.current?.scrollIntoView({ behavior: "smooth" }); // live message arrived; follow it down
// live message arrived; follow it down. Not scrollIntoView — that also scrolls the page shell
el?.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
}
anchor.count = sorted.length;
}, [sorted.length, viewKey, isLoading, userScrolled]);
@@ -221,7 +221,6 @@ export function MessagePanel({ channel, heardCounts, iatas, regionKey, onAnalyze
{sorted.map((msg) => (
<MessageRow key={msg.packetHash || msg.id} msg={msg} heardCount={heardCounts[msg.packetHash]} onAnalyze={onAnalyze} />
))}
<div ref={bottomRef} />
</div>
) : (
<div className="flex items-center justify-center h-32 text-text-muted text-xs font-mono">
+16
View File
@@ -0,0 +1,16 @@
import type { TouchEvent } from "react";
// iOS Safari drops a tap's click when new tappable content appears mid-tap (the live packet list
// does that constantly), so a touch acts on touchend and cancels the click iOS might still send.
export function touchClick(onActivate: () => void) {
return {
onClick: onActivate,
onTouchEnd: (e: TouchEvent<HTMLElement>) => {
const touch = e.changedTouches[0];
const r = e.currentTarget.getBoundingClientRect();
if (!touch || touch.clientX < r.left || touch.clientX > r.right || touch.clientY < r.top || touch.clientY > r.bottom) return;
e.preventDefault();
onActivate();
},
};
}
+28
View File
@@ -41,4 +41,32 @@ describe("BottomNav", () => {
const more = screen.getByText("More").closest("button")!;
expect(more.className).toContain("text-primary");
});
// iOS drops the click when live content lands mid-tap, so a touch acts on touchend alone
const tapEnd = (el: Element, x = 0, y = 0) => fireEvent.touchEnd(el, { changedTouches: [{ clientX: x, clientY: y }] });
it("switches tab on touchend and cancels the trailing click", () => {
const onTabChange = vi.fn();
render(<BottomNav activeTab="Packets" onTabChange={onTabChange} />);
const notPrevented = tapEnd(screen.getByRole("tab", { name: "Channels" }));
expect(onTabChange).toHaveBeenCalledTimes(1);
expect(onTabChange).toHaveBeenCalledWith("Channels");
expect(notPrevented).toBe(false);
});
it("ignores a touch that lifts off the button", () => {
const onTabChange = vi.fn();
render(<BottomNav activeTab="Packets" onTabChange={onTabChange} />);
tapEnd(screen.getByRole("tab", { name: "Channels" }), 50, 50);
expect(onTabChange).not.toHaveBeenCalled();
});
it("opens More and picks an overflow tab by touch", () => {
const onTabChange = vi.fn();
render(<BottomNav activeTab="Packets" onTabChange={onTabChange} />);
tapEnd(screen.getByText("More").closest("button")!);
tapEnd(screen.getByRole("menuitem", { name: "Routes" }));
expect(onTabChange).toHaveBeenCalledWith("Routes");
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
});
});
@@ -48,7 +48,7 @@ beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getChannelMessagesPage).mockResolvedValue(page(messages));
vi.mocked(getChannels).mockResolvedValue({ items: [channel], hasMore: false, nextCursor: null });
Element.prototype.scrollIntoView = vi.fn();
Element.prototype.scrollTo = vi.fn();
});
describe("channel scope evidence", () => {
+28 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { act, render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { MessagePanel } from "../../../src/features/channels/MessagePanel";
import type { ChannelMessage, ChannelSummary } from "../../../src/features/channels/types";
@@ -121,3 +121,30 @@ describe("MessagePanel heard badge", () => {
expect(screen.getByTitle("Entendu 1 fois")).toBeInTheDocument();
});
});
describe("MessagePanel live follow", () => {
it("scrolls only its own list when a live message arrives, not the page", async () => {
const scrollTo = vi.fn();
window.HTMLElement.prototype.scrollTo = scrollTo as unknown as typeof HTMLElement.prototype.scrollTo;
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={qc}>
<MessagePanel channel={channel} heardCounts={{}} regionKey="*" />
</QueryClientProvider>,
);
await screen.findByText("live two");
const live = { ...liveMsgB, packetHash: "ph-live-c", content: "live three", sentAt: 5000 } as ChannelMessage;
act(() => {
qc.setQueryData(["channel-messages", channel.id, "*", ""], (old: { pages: { items: ChannelMessage[] }[] }) => ({
...old,
pages: [{ ...old.pages[0], items: [live, ...old.pages[0].items] }, ...old.pages.slice(1)],
}));
});
await screen.findByText("live three");
// scrollIntoView also scrolls every ancestor up to the document, shoving the whole app shell up
expect(window.HTMLElement.prototype.scrollIntoView).not.toHaveBeenCalled();
expect(scrollTo).toHaveBeenCalled();
});
});