mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-14 00:01:03 +00:00
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.
This commit is contained in:
@@ -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<HTMLDivElement>(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() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="h-full overflow-y-auto px-4 pt-[calc(var(--m-safe-top,12px)+52px)] pb-[calc(var(--bottom-nav-h,84px)+16px)]">
|
||||
<MPullIndicator state={pull} label={t('common.loading')} />
|
||||
<div ref={scrollRef} className="h-full overflow-y-auto px-4 pt-[calc(var(--m-safe-top,12px)+52px)] pb-[calc(var(--bottom-nav-h,84px)+16px)]">
|
||||
{loading && list.length === 0 ? (
|
||||
<div className="flex justify-center py-16">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-[color:var(--m-rowbr)] border-t-m-ink" />
|
||||
|
||||
@@ -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<HTMLDivElement>(null)
|
||||
const pull = usePullToRefresh(contentRef, refresh)
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -59,7 +64,8 @@ export default function MNotifications() {
|
||||
)}
|
||||
</MGlassBar>
|
||||
|
||||
<div className="px-4 pb-[calc(var(--bottom-nav-h)+24px)] pt-[calc(var(--m-safe-top,12px)+66px)]">
|
||||
<MPullIndicator state={pull} label={t('common.loading')} />
|
||||
<div ref={contentRef} className="px-4 pb-[calc(var(--bottom-nav-h)+24px)] pt-[calc(var(--m-safe-top,12px)+66px)]">
|
||||
<MSegmented
|
||||
options={[
|
||||
{ value: 'all', label: t('notifications.all') },
|
||||
|
||||
@@ -80,4 +80,16 @@ describe('usePullToRefresh', () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,7 +23,9 @@ export type PullState = { distance: number; refreshing: boolean; armed: boolean
|
||||
export function usePullToRefresh(
|
||||
contentRef: RefObject<HTMLElement | null>,
|
||||
onRefresh: () => Promise<unknown>,
|
||||
enabled = true
|
||||
enabled = true,
|
||||
/** The element that scrolls, where that is not the window (a screen with its own scroll box). */
|
||||
scrollerRef?: RefObject<HTMLElement | null>
|
||||
): PullState {
|
||||
const [state, setState] = useState<PullState>({ 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;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user