From 9a569beb566fa83bd118e5887fa6ad9daa58d698 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sat, 3 Oct 2026 14:20:59 -0400 Subject: [PATCH] Fix channels page shifting and dropped bottom-nav taps on mobile --- src/components/BottomNav.tsx | 32 ++++++++++++------- src/features/channels/MessagePanel.tsx | 5 ++- src/lib/touch-click.ts | 16 ++++++++++ tests/components/BottomNav.test.tsx | 28 ++++++++++++++++ .../channels/MessagePanel.scope.test.tsx | 2 +- tests/features/channels/MessagePanel.test.tsx | 29 ++++++++++++++++- 6 files changed, 95 insertions(+), 17 deletions(-) create mode 100644 src/lib/touch-click.ts diff --git a/src/components/BottomNav.tsx b/src/components/BottomNav.tsx index ab2896a4..26bed734 100644 --- a/src/components/BottomNav.tsx +++ b/src/components/BottomNav.tsx @@ -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 ( + ); +} + // 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 ( {tabs.map((tab) => ( - + onPick(tab)} /> ))} ); diff --git a/src/features/channels/MessagePanel.tsx b/src/features/channels/MessagePanel.tsx index c3cd34c5..1aad0510 100644 --- a/src/features/channels/MessagePanel.tsx +++ b/src/features/channels/MessagePanel.tsx @@ -94,7 +94,6 @@ export function MessagePanel({ channel, heardCounts, iatas, regionKey, onAnalyze [messages], ); - const bottomRef = useRef(null); const [userScrolled, setUserScrolled] = useState(false); const scrollContainerRef = useRef(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) => ( ))} -
) : (
diff --git a/src/lib/touch-click.ts b/src/lib/touch-click.ts new file mode 100644 index 00000000..9daeec1b --- /dev/null +++ b/src/lib/touch-click.ts @@ -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) => { + 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(); + }, + }; +} diff --git a/tests/components/BottomNav.test.tsx b/tests/components/BottomNav.test.tsx index be2c0398..0c78aa5c 100644 --- a/tests/components/BottomNav.test.tsx +++ b/tests/components/BottomNav.test.tsx @@ -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(); + 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(); + 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(); + tapEnd(screen.getByText("More").closest("button")!); + tapEnd(screen.getByRole("menuitem", { name: "Routes" })); + expect(onTabChange).toHaveBeenCalledWith("Routes"); + expect(screen.queryByRole("menu")).not.toBeInTheDocument(); + }); }); diff --git a/tests/features/channels/MessagePanel.scope.test.tsx b/tests/features/channels/MessagePanel.scope.test.tsx index d95bf885..17624be0 100644 --- a/tests/features/channels/MessagePanel.scope.test.tsx +++ b/tests/features/channels/MessagePanel.scope.test.tsx @@ -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", () => { diff --git a/tests/features/channels/MessagePanel.test.tsx b/tests/features/channels/MessagePanel.test.tsx index 65e34819..e2e7e136 100644 --- a/tests/features/channels/MessagePanel.test.tsx +++ b/tests/features/channels/MessagePanel.test.tsx @@ -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( + + + , + ); + 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(); + }); +});