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,