From f162f1e3d46193e1d411f2e9d55f17c4a7f67737 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 19 Apr 2026 13:43:44 +0200 Subject: [PATCH] feat: refresh JMAP on reload gestures, fix mobile bottom nav spacing --- app/[locale]/calendar/page.tsx | 15 +++++ app/[locale]/contacts/page.tsx | 11 +++ app/[locale]/files/page.tsx | 10 +++ app/[locale]/page.tsx | 15 +++++ components/layout/navigation-rail.tsx | 16 ++--- hooks/use-refresh-gesture.ts | 96 +++++++++++++++++++++++++++ 6 files changed, 155 insertions(+), 8 deletions(-) create mode 100644 hooks/use-refresh-gesture.ts diff --git a/app/[locale]/calendar/page.tsx b/app/[locale]/calendar/page.tsx index 00b26dbc..209dc37d 100644 --- a/app/[locale]/calendar/page.tsx +++ b/app/[locale]/calendar/page.tsx @@ -32,6 +32,7 @@ import { EventModal, type PendingEventPreview } from "@/components/calendar/even import { EventDetailPopover } from "@/components/calendar/event-detail-popover"; import { EventContextMenu } from "@/components/calendar/event-context-menu"; import { useContextMenu } from "@/hooks/use-context-menu"; +import { useRefreshGesture } from "@/hooks/use-refresh-gesture"; import { downloadEventICS } from "@/lib/calendar-ics-export"; import { ICalImportModal } from "@/components/calendar/ical-import-modal"; import { ICalSubscriptionModal } from "@/components/calendar/ical-subscription-modal"; @@ -432,6 +433,20 @@ export default function CalendarPage() { } }, [client, fetchEvents]); + // Intercept browser refresh gestures (F5, Ctrl/Cmd+R, pull-to-refresh) + // and refresh calendar data via JMAP instead of reloading the page. + useRefreshGesture({ + enabled: isAuthenticated && !!client, + onRefresh: async () => { + if (!client) return; + await Promise.all([ + fetchCalendars(client), + refetchCurrentRange(), + refreshAllSubscriptions(client), + ]); + }, + }); + const focusCalendarOnEvent = useCallback((event: Pick, "start" | "utcStart" | "showWithoutTime">) => { if (!event.start) { return; diff --git a/app/[locale]/contacts/page.tsx b/app/[locale]/contacts/page.tsx index 2b1b7758..bb078413 100644 --- a/app/[locale]/contacts/page.tsx +++ b/app/[locale]/contacts/page.tsx @@ -26,6 +26,7 @@ import { InlineAppView } from "@/components/layout/inline-app-view"; import { useSidebarApps } from "@/hooks/use-sidebar-apps"; import { ResizeHandle } from "@/components/layout/resize-handle"; import { useIsMobile } from "@/hooks/use-media-query"; +import { useRefreshGesture } from "@/hooks/use-refresh-gesture"; import type { ContactCard, AddressBook } from "@/lib/jmap/types"; type View = @@ -123,6 +124,16 @@ export default function ContactsPage() { } }, [client, supportsSync, fetchContacts]); + // Intercept browser refresh gestures (F5, Ctrl/Cmd+R, pull-to-refresh) + // and refresh contacts via JMAP instead of reloading the page. + useRefreshGesture({ + enabled: isAuthenticated && !!client && supportsSync, + onRefresh: async () => { + if (!client) return; + await fetchContacts(client); + }, + }); + const groups = useMemo(() => contacts.filter(c => c.kind === 'group'), [contacts]); const individuals = useMemo(() => contacts.filter(c => c.kind !== 'group'), [contacts]); const selectedContact = contacts.find((c) => c.id === selectedContactId) || null; diff --git a/app/[locale]/files/page.tsx b/app/[locale]/files/page.tsx index 5d89386a..0d4c80bc 100644 --- a/app/[locale]/files/page.tsx +++ b/app/[locale]/files/page.tsx @@ -17,6 +17,7 @@ import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal"; import { InlineAppView } from "@/components/layout/inline-app-view"; import { useSidebarApps } from "@/hooks/use-sidebar-apps"; import { useIsMobile } from "@/hooks/use-media-query"; +import { useRefreshGesture } from "@/hooks/use-refresh-gesture"; import { usePolicyStore } from "@/stores/policy-store"; import { FileBrowser } from "@/components/files/file-browser"; import { ImagePreviewModal } from "@/components/files/image-preview-modal"; @@ -127,6 +128,15 @@ export default function FilesPage() { } }, [isAuthenticated, client, initClient]); + // Intercept browser refresh gestures (F5, Ctrl/Cmd+R, pull-to-refresh) + // and refresh files via JMAP instead of reloading the page. + useRefreshGesture({ + enabled: isAuthenticated && !!client && supportsFiles === true, + onRefresh: async () => { + await refresh(); + }, + }); + // Check support and load root after client is initialized const storeClient = useFileStore(s => s.client); useEffect(() => { diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index 28b5ef11..190874a4 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -21,6 +21,7 @@ import { useIdentityStore } from "@/stores/identity-store"; import { useUIStore } from "@/stores/ui-store"; import { useDeviceDetection } from "@/hooks/use-media-query"; import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts"; +import { useRefreshGesture } from "@/hooks/use-refresh-gesture"; import { useConfirmDialog } from "@/hooks/use-confirm-dialog"; import { useBrowserNavigation, type NavSnapshot } from "@/hooks/use-browser-navigation"; import { debug } from "@/lib/debug"; @@ -408,6 +409,20 @@ export default function Home() { handlers: keyboardHandlers, }); + // Intercept browser refresh gestures (F5, Ctrl/Cmd+R, pull-to-refresh) + // and refresh mail data via JMAP instead of reloading the page. + useRefreshGesture({ + enabled: isAuthenticated && !!client, + onRefresh: async () => { + if (!client) return; + const state = useEmailStore.getState(); + await Promise.all([ + state.fetchMailboxes(client), + state.selectedMailbox ? state.fetchEmails(client, state.selectedMailbox) : state.fetchEmails(client), + ]); + }, + }); + // Update page title based on context useEffect(() => { let title = appName; diff --git a/components/layout/navigation-rail.tsx b/components/layout/navigation-rail.tsx index d413f57b..20062155 100644 --- a/components/layout/navigation-rail.tsx +++ b/components/layout/navigation-rail.tsx @@ -275,7 +275,7 @@ export function NavigationRail({ href={item.href} onClick={activeAppId ? () => onCloseInlineApp?.() : undefined} className={cn( - "flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0", + "flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px]", "transition-colors duration-150", isActive ? "text-primary" @@ -294,7 +294,7 @@ export function NavigationRail({ )} - {t(item.labelKey)} + {t(item.labelKey)} ); })} @@ -316,7 +316,7 @@ export function NavigationRail({ } }} className={cn( - "flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0", + "flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px]", "transition-colors duration-150", isActive ? "text-primary" @@ -329,7 +329,7 @@ export function NavigationRail({ )} - {app.name} + {app.name} ); })} @@ -339,13 +339,13 @@ export function NavigationRail({ - {t("admin") || "Admin"} + {t("admin") || "Admin"} )} @@ -354,7 +354,7 @@ export function NavigationRail({ href="/settings" onClick={activeAppId ? () => onCloseInlineApp?.() : undefined} className={cn( - "flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0", + "flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px]", "transition-colors duration-150", isSettingsActive ? "text-primary" @@ -368,7 +368,7 @@ export function NavigationRail({ )} - {t("settings")} + {t("settings")} ); diff --git a/hooks/use-refresh-gesture.ts b/hooks/use-refresh-gesture.ts new file mode 100644 index 00000000..692793cd --- /dev/null +++ b/hooks/use-refresh-gesture.ts @@ -0,0 +1,96 @@ +"use client"; + +import { useEffect, useRef } from "react"; + +export interface UseRefreshGestureOptions { + onRefresh: () => void | Promise; + enabled?: boolean; +} + +/** + * Capture browser refresh gestures (F5, Ctrl/Cmd+R, pull-to-refresh) and run + * a JMAP-level refresh instead of reloading the full page. + * + * Pull-to-refresh is only active when the document is already scrolled to the + * top, so normal touch scrolling is unaffected. + */ +export function useRefreshGesture({ onRefresh, enabled = true }: UseRefreshGestureOptions) { + const onRefreshRef = useRef(onRefresh); + const runningRef = useRef(false); + + useEffect(() => { + onRefreshRef.current = onRefresh; + }, [onRefresh]); + + useEffect(() => { + if (!enabled) return; + + const trigger = () => { + if (runningRef.current) return; + runningRef.current = true; + Promise.resolve(onRefreshRef.current()).finally(() => { + runningRef.current = false; + }); + }; + + const handleKeyDown = (event: KeyboardEvent) => { + const isReloadKey = + event.key === "F5" || + ((event.ctrlKey || event.metaKey) && !event.shiftKey && !event.altKey && event.key.toLowerCase() === "r"); + if (!isReloadKey) return; + + event.preventDefault(); + event.stopPropagation(); + trigger(); + }; + + let touchStartY = 0; + let tracking = false; + let triggered = false; + + const handleTouchStart = (event: TouchEvent) => { + if (event.touches.length !== 1) { + tracking = false; + return; + } + const atTop = window.scrollY <= 0 && document.documentElement.scrollTop <= 0; + if (!atTop) { + tracking = false; + return; + } + touchStartY = event.touches[0].clientY; + tracking = true; + triggered = false; + }; + + const handleTouchMove = (event: TouchEvent) => { + if (!tracking || triggered) return; + const dy = event.touches[0].clientY - touchStartY; + // Require a deliberate pull of ~80px from the very top of the page. + if (dy > 80) { + triggered = true; + tracking = false; + trigger(); + } + }; + + const handleTouchEnd = () => { + tracking = false; + triggered = false; + }; + + window.addEventListener("keydown", handleKeyDown, { capture: true }); + window.addEventListener("touchstart", handleTouchStart, { passive: true }); + window.addEventListener("touchmove", handleTouchMove, { passive: true }); + window.addEventListener("touchend", handleTouchEnd, { passive: true }); + window.addEventListener("touchcancel", handleTouchEnd, { passive: true }); + + return () => { + window.removeEventListener("keydown", handleKeyDown, { capture: true }); + window.removeEventListener("touchstart", handleTouchStart); + window.removeEventListener("touchmove", handleTouchMove); + window.removeEventListener("touchend", handleTouchEnd); + window.removeEventListener("touchcancel", handleTouchEnd); + }; + }, [enabled]); +}