From 431cc226e38b27beace22397c420e5659d220291 Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 6 Nov 2026 14:56:16 +0100 Subject: [PATCH] feat(mobile): pull-to-refresh on the journal and notification lists The journal list (its own scroll box, so the hook now takes an optional scroller) and the notifications screen reload by pulling, like the dashboard. The list stays on screen while it reloads. --- client/src/mobile/screens/journey/MJourney.tsx | 10 ++++++++-- .../mobile/screens/notifications/MNotifications.tsx | 10 ++++++++-- client/src/mobile/usePullToRefresh.test.tsx | 12 ++++++++++++ client/src/mobile/usePullToRefresh.ts | 11 +++++++---- .../inAppNotifications/useInAppNotifications.ts | 2 ++ client/src/pages/journey/useJourney.ts | 2 ++ 6 files changed, 39 insertions(+), 8 deletions(-) diff --git a/client/src/mobile/screens/journey/MJourney.tsx b/client/src/mobile/screens/journey/MJourney.tsx index 3bc2d5808..0fd0007f8 100644 --- a/client/src/mobile/screens/journey/MJourney.tsx +++ b/client/src/mobile/screens/journey/MJourney.tsx @@ -1,3 +1,4 @@ +import { useRef } from 'react' import { Plus, NotebookPen, Image, MapPin } from 'lucide-react' import { useTranslation } from '../../../i18n' import { useJourney } from '../../../pages/journey/useJourney' @@ -5,6 +6,8 @@ import type { Journey } from '../../../store/journeyStore' import MJourneyCreateSheet from './MJourneyCreateSheet' import MDancingTrek from '../../components/MDancingTrek' import { journeyCoverSrc, journeyCoverStyle } from './mobileJourneyMeta' +import { usePullToRefresh } from '../../usePullToRefresh' +import MPullIndicator from '../../components/MPullIndicator' type JourneyListItem = Journey & { entry_count?: number @@ -16,11 +19,13 @@ type JourneyListItem = Journey & { export default function MJourney() { const { t } = useTranslation() const { - navigate, journeys, loading, + navigate, journeys, loading, loadJourneys, showCreate, setShowCreate, newTitle, setNewTitle, availableTrips, selectedTripIds, setSelectedTripIds, openCreateModal, handleCreate, activeJourney, } = useJourney() + const scrollRef = useRef(null) + const pull = usePullToRefresh(scrollRef, loadJourneys, true, scrollRef) const list = journeys as JourneyListItem[] const hero = (activeJourney as JourneyListItem | null) ?? list[0] ?? null @@ -43,7 +48,8 @@ export default function MJourney() { -
+ +
{loading && list.length === 0 ? (
diff --git a/client/src/mobile/screens/notifications/MNotifications.tsx b/client/src/mobile/screens/notifications/MNotifications.tsx index 9d657827c..c462b37f3 100644 --- a/client/src/mobile/screens/notifications/MNotifications.tsx +++ b/client/src/mobile/screens/notifications/MNotifications.tsx @@ -1,3 +1,4 @@ +import { useRef } from 'react' import { useNavigate } from 'react-router' import { ArrowLeft, CheckCheck, Trash2 } from 'lucide-react' import MDancingTrek from '../../components/MDancingTrek' @@ -8,6 +9,8 @@ import MGlassBar from '../../components/MGlassBar' import MIconBtn from '../../components/MIconBtn' import MSegmented from '../../components/MSegmented' import MNotificationRow from './MNotificationRow' +import { usePullToRefresh } from '../../usePullToRefresh' +import MPullIndicator from '../../components/MPullIndicator' function Spinner({ size = 20 }: { size?: number }) { return ( @@ -29,9 +32,11 @@ export default function MNotifications() { const navigate = useNavigate() const { notifications, unreadCount, isLoading, hasMore, - unreadOnly, setUnreadOnly, loaderRef, displayed, + unreadOnly, setUnreadOnly, loaderRef, displayed, refresh, markAllRead, confirmingDeleteAll, requestDeleteAll, cancelDeleteAll, confirmDeleteAll, } = useInAppNotifications() + const contentRef = useRef(null) + const pull = usePullToRefresh(contentRef, refresh) return ( <> @@ -59,7 +64,8 @@ export default function MNotifications() { )} -
+ +
{ expect(onRefresh).not.toHaveBeenCalled(); expect(el.style.transform).toBe(''); }); + + it('reads the scroll position of its own scroll box when given one', () => { + const el = document.createElement('div'); + const scroller = document.createElement('div'); + Object.defineProperty(scroller, 'scrollTop', { value: 120, configurable: true }); + const onRefresh = vi.fn(async () => {}); + renderHook(() => usePullToRefresh({ current: el }, onRefresh, true, { current: scroller })); + touch('touchstart', 100, 100); + expect(touch('touchmove', 100, 400).defaultPrevented).toBe(false); + touch('touchend'); + expect(onRefresh).not.toHaveBeenCalled(); + }); }); diff --git a/client/src/mobile/usePullToRefresh.ts b/client/src/mobile/usePullToRefresh.ts index e58f94437..f07e9c4f4 100644 --- a/client/src/mobile/usePullToRefresh.ts +++ b/client/src/mobile/usePullToRefresh.ts @@ -23,7 +23,9 @@ export type PullState = { distance: number; refreshing: boolean; armed: boolean export function usePullToRefresh( contentRef: RefObject, onRefresh: () => Promise, - enabled = true + enabled = true, + /** The element that scrolls, where that is not the window (a screen with its own scroll box). */ + scrollerRef?: RefObject ): PullState { const [state, setState] = useState({ distance: 0, refreshing: false, armed: false }); const refreshRef = useRef(onRefresh); @@ -36,6 +38,7 @@ export function usePullToRefresh( let armed = false; let busy = false; let distance = 0; + const scrolled = () => (scrollerRef?.current ? scrollerRef.current.scrollTop : window.scrollY); const place = (value: number, animate: boolean) => { const el = contentRef.current; @@ -45,7 +48,7 @@ export function usePullToRefresh( }; const onStart = (e: TouchEvent) => { - if (busy || e.touches.length !== 1 || window.scrollY > 0 || hasOpenOverlay()) return; + if (busy || e.touches.length !== 1 || scrolled() > 0 || hasOpenOverlay()) return; start = { x: e.touches[0].clientX, y: e.touches[0].clientY }; pulling = false; }; @@ -57,7 +60,7 @@ export function usePullToRefresh( if (!pulling) { // Decide once: a sideways or upward start belongs to whatever is under the finger. if (Math.abs(dy) < 8 && Math.abs(dx) < 8) return; - if (dy <= 0 || Math.abs(dx) > Math.abs(dy) || window.scrollY > 0) { + if (dy <= 0 || Math.abs(dx) > Math.abs(dy) || scrolled() > 0) { start = null; return; } @@ -117,7 +120,7 @@ export function usePullToRefresh( window.removeEventListener('touchcancel', onEnd); place(0, false); }; - }, [contentRef, enabled]); + }, [contentRef, enabled, scrollerRef]); return state; } diff --git a/client/src/pages/inAppNotifications/useInAppNotifications.ts b/client/src/pages/inAppNotifications/useInAppNotifications.ts index 1be1f68ba..7ece58280 100644 --- a/client/src/pages/inAppNotifications/useInAppNotifications.ts +++ b/client/src/pages/inAppNotifications/useInAppNotifications.ts @@ -43,6 +43,8 @@ export function useInAppNotifications() { return { notifications, unreadCount, total: shownTotal, isLoading, hasMore, unreadOnly, setUnreadOnly, loaderRef, displayed, + // Pull-to-refresh on mobile: the first page again, with the current filter. + refresh: () => fetchNotifications(true, unreadOnly), markAllRead, confirmingDeleteAll, requestDeleteAll: () => setConfirmingDeleteAll(true), diff --git a/client/src/pages/journey/useJourney.ts b/client/src/pages/journey/useJourney.ts index 35b66ea1c..ae4f873ba 100644 --- a/client/src/pages/journey/useJourney.ts +++ b/client/src/pages/journey/useJourney.ts @@ -192,6 +192,8 @@ export function useJourney() { return { navigate, journeys, loading, + // Pull-to-refresh on mobile; the list stays on screen while it reloads. + loadJourneys, showCreate, setShowCreate, newTitle, setNewTitle, newSubtitle, setNewSubtitle, availableTrips, selectedTripIds, setSelectedTripIds, searchOpen, setSearchOpen, searchQuery, setSearchQuery, searchInputRef,