feat(mobile): page transitions, pull-to-refresh, share sheet and haptics

Page transitions: on the phone, pages move like a native navigation stack,
built on the View Transitions API (mobile/routeTransition.ts). A page opened
from another slides in from the trailing edge, back slides it away, and
switching between the dock's sections cross-fades while the dock stands
still. Lazy routes do not flash their skeleton: the swap waits, behind the
frozen snapshot, until the new page rendered. The edge-swipe back gesture
already moves the page and skips the transition. RTL mirrors the motion,
reduced motion and desktop get none.

Pull-to-refresh on the mobile dashboard: the page follows the finger with
resistance, ticks once past the threshold and re-reads trips and
notifications quietly, without a loading state.

Share: trip share and invite links and the journey link get a share button
next to copy, opening the system share sheet (Capacitor Share in the apps,
Web Share in browsers that have it).

Haptics: a selection click for switches and segmented controls, a tick when
a sheet is flung closed, and a firmer bump for the chat's long press.

Theme lint: this branch's new screens use the type and z-index tokens, the
Vacay company tint is a token, and the baseline is lowered to the new
counts.
This commit is contained in:
Maurice
2026-11-06 14:49:26 +01:00
parent 3c4c880c31
commit 6835fd7bf3
57 changed files with 699 additions and 43 deletions
+4 -6
View File
@@ -125,7 +125,6 @@
"components/shared/CustomSelect.tsx": 1,
"components/shared/CustomTimePicker.tsx": 1,
"components/shared/EmptyState.tsx": 1,
"components/shared/ErrorBoundary.tsx": 2,
"components/shared/Modal.tsx": 3,
"components/shared/NavigationMenu.tsx": 1,
"components/shared/NoteColorPicker.tsx": 2,
@@ -166,7 +165,7 @@
"mobile/screens/admin/MAdminUi.tsx": 10,
"mobile/screens/admin/MAdminUsersSection.tsx": 14,
"mobile/screens/admin/MApiProviderBlocks.tsx": 10,
"mobile/screens/atlas/MAtlas.tsx": 7,
"mobile/screens/atlas/MAtlas.tsx": 5,
"mobile/screens/atlas/MAtlasBucketSheet.tsx": 16,
"mobile/screens/atlas/MAtlasCountryPopup.tsx": 11,
"mobile/screens/atlas/MAtlasSearch.tsx": 5,
@@ -219,11 +218,10 @@
"mobile/screens/trip/places/MPlacesBrowser.tsx": 10,
"mobile/screens/trip/places/MPlacesBulkCategorySheet.tsx": 4,
"mobile/screens/trip/places/MPlacesFilterControls.tsx": 1,
"mobile/screens/trip/places/MPlacesFilterSheet.tsx": 3,
"mobile/screens/trip/places/MPlacesSaveToCollectionSheet.tsx": 7,
"mobile/screens/trip/places/MPlacesToursModeSwitch.tsx": 1,
"mobile/screens/trip/plan/MPlanTimeline.tsx": 14,
"mobile/screens/trip/plan/MPlanTimelineRows.tsx": 26,
"mobile/screens/trip/plan/MPlanTimeline.tsx": 12,
"mobile/screens/trip/plan/MPlanTimelineRows.tsx": 24,
"mobile/screens/trip/roadtrip/MRoadtripRows.tsx": 31,
"mobile/screens/trip/roadtrip/MRoadtripTab.tsx": 16,
"mobile/screens/trip/roadtrip/MRtAlternativesBar.tsx": 8,
@@ -281,7 +279,7 @@
"mobile/screens/trip/tabs/MTransportsTab.tsx": 2,
"mobile/screens/trip/tabs/filesModel.ts": 5,
"mobile/screens/trip/tabs/tabChrome.tsx": 12,
"mobile/screens/vacay/MVacay.tsx": 23,
"mobile/screens/vacay/MVacay.tsx": 17,
"mobile/screens/vacay/MVacayInviteSheet.tsx": 9,
"mobile/screens/vacay/MVacayMonth.tsx": 3,
"mobile/screens/vacay/MVacaySettingsSheet.tsx": 35,
+6 -2
View File
@@ -1,5 +1,5 @@
import React, { useEffect, useState, useRef, ReactNode, Suspense } from 'react'
import { Routes, Route, Navigate, useLocation } from 'react-router'
import { Routes, Route, Navigate, useLocation, useNavigationType } from 'react-router'
import { useAuthStore } from './store/authStore'
import { useSettingsStore } from './store/settingsStore'
import { applyAppearance } from './theme/applyAppearance'
@@ -21,6 +21,7 @@ import { lazyWithRetry } from './utils/lazyWithRetry'
import { reconcileAppVersion } from './utils/versionHandover'
import { useServerVersionStore } from './store/serverVersionStore'
import { useIsPhone } from './mobile/useIsPhone'
import { useRouteTransition } from './mobile/routeTransition'
import { TranslationProvider, useTranslation } from './i18n'
import { authApi, isAuthPublicPath } from './api/client'
import { tripRepo } from './repo/tripRepo'
@@ -411,6 +412,9 @@ export default function App() {
}, [settings.dark_mode, settings.appearance, isSharedPage])
const isPhone = useIsPhone()
// On the phone, pages slide like a native navigation stack (mobile/routeTransition.ts).
const navigationType = useNavigationType()
const routedLocation = useRouteTransition(location, navigationType, isPhone)
const keepingTour = useSaveToCollectionStore(s => s.tour !== null)
const isAuthPage = location.pathname.startsWith('/login')
|| location.pathname.startsWith('/register')
@@ -455,7 +459,7 @@ export default function App() {
so a mounted boundary keeps the current page on screen instead of flashing
a spinner on every jump — the spinner is for the first paint of a deep link. */}
<Suspense fallback={<RouteFallback />}>
<Routes>
<Routes location={routedLocation}>
<Route path="/" element={<RootRedirect />} />
<Route path="/login" element={<PublicRoute redirectAuthed><LoginPage /></PublicRoute>} />
<Route path="/shared/:token" element={<PublicRoute><SharedTripPage /></PublicRoute>} />
+1 -1
View File
@@ -62,7 +62,7 @@ export default function AppLockGate(): React.ReactElement | null {
role="dialog"
aria-modal="true"
aria-label={t('native.lock.title')}
className="fixed inset-0 z-[100001] flex flex-col items-center justify-center gap-5 bg-surface p-8 text-center backdrop-blur-2xl"
className="fixed inset-0 z-[calc(var(--z-toast)+1)] flex flex-col items-center justify-center gap-5 bg-surface p-8 text-center backdrop-blur-2xl"
>
<span className="flex h-16 w-16 items-center justify-center rounded-full bg-surface-card text-content shadow-lg">
<Lock size={26} strokeWidth={2} aria-hidden="true" />
@@ -53,9 +53,11 @@ export default function NativeImportSheet(): React.ReactElement | null {
return (
<MSheet open={open} onClose={() => setFile(null)} variant="bottom" ariaLabel={t('native.share.title')}>
<div className="px-4 pb-4 pt-6">
<div className="mb-1 px-1 text-[0.8125rem] font-bold text-m-ink">{t('native.share.title')}</div>
<div className="mb-1 px-1 text-[length:calc(13px*var(--fs-scale-body,1))] font-bold text-m-ink">
{t('native.share.title')}
</div>
{file && (
<div className="mb-3 flex items-center gap-2 px-1 font-geist text-[0.6875rem] text-m-muted">
<div className="mb-3 flex items-center gap-2 px-1 font-geist text-[length:calc(11px*var(--fs-scale-body,1))] text-m-muted">
<FileText size={13} aria-hidden="true" />
<span className="truncate">{file.name}</span>
</div>
@@ -68,11 +70,17 @@ export default function NativeImportSheet(): React.ReactElement | null {
onClick={() => pick(trip.id)}
className="flex items-center gap-3 rounded-[16px] border border-[color:var(--m-gbr)] bg-[color:var(--m-glass)] px-4 py-3 text-start transition-transform active:scale-[.98]"
>
<span className="min-w-0 flex-1 truncate text-[0.875rem] font-bold text-m-ink">{trip.title}</span>
<span className="min-w-0 flex-1 truncate text-[length:calc(14px*var(--fs-scale-body,1))] font-bold text-m-ink">
{trip.title}
</span>
<ChevronRight size={16} className="flex-none text-m-faint" aria-hidden="true" />
</button>
))}
{trips.length === 0 && <p className="px-1 text-[0.75rem] text-m-muted">{t('native.share.noTrips')}</p>}
{trips.length === 0 && (
<p className="px-1 text-[length:calc(12px*var(--fs-scale-body,1))] text-m-muted">
{t('native.share.noTrips')}
</p>
)}
</div>
</div>
</MSheet>
@@ -26,7 +26,7 @@ export default function NativeUpdateNotice(): React.ReactElement | null {
<div
role="status"
style={{ bottom: 'calc(var(--bottom-nav-h) + 56px)' }}
className="fixed inset-x-4 z-[9999] flex items-center gap-3 rounded-2xl border border-edge bg-surface-elevated px-4 py-3 text-content shadow-lg"
className="fixed inset-x-4 z-[var(--z-notice)] flex items-center gap-3 rounded-2xl border border-edge bg-surface-elevated px-4 py-3 text-content shadow-lg"
>
<Download size={18} className="shrink-0 text-content-muted" aria-hidden="true" />
<div className="min-w-0 flex-1">
@@ -1,4 +1,5 @@
import React from 'react'
import { selectionHaptic } from '../../native/haptics'
/** The track and the knob glide with the same easing the planner's panels use. */
const EASE = 'cubic-bezier(0.32, 0.72, 0, 1)'
@@ -10,7 +11,7 @@ const EASE = 'cubic-bezier(0.32, 0.72, 0, 1)'
*/
export default function ToggleSwitch({ on, onToggle, label, describedBy, disabled = false }: { on: boolean; onToggle: () => void; label?: string; describedBy?: string; disabled?: boolean }) {
return (
<button type="button" onClick={onToggle} aria-pressed={on} aria-label={label} aria-describedby={describedBy}
<button type="button" onClick={() => { selectionHaptic(); onToggle() }} aria-pressed={on} aria-label={label} aria-describedby={describedBy}
disabled={disabled} aria-disabled={disabled || undefined}
// A keyboard focus ring in the text colour, like the fields; the pointer gets none.
className="group outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--text-primary)] focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--bg-card)]"
+1 -1
View File
@@ -120,7 +120,7 @@ export default function MBottomNav() {
</div>
)}
<nav className="fixed inset-x-4 z-40 flex h-[62px] items-center rounded-[31px] border border-[color:var(--m-gbr)] bg-[color:var(--m-glass)] px-3 shadow-[0_16px_44px_-14px_rgba(0,0,0,.35)] backdrop-blur-[30px] backdrop-saturate-[1.8] bottom-[calc(env(safe-area-inset-bottom,0px)+12px)]">
<nav className="m-dock-vt fixed inset-x-4 z-40 flex h-[62px] items-center rounded-[31px] border border-[color:var(--m-gbr)] bg-[color:var(--m-glass)] px-3 shadow-[0_16px_44px_-14px_rgba(0,0,0,.35)] backdrop-blur-[30px] backdrop-saturate-[1.8] bottom-[calc(env(safe-area-inset-bottom,0px)+12px)]">
<div className="flex min-w-0 flex-1 items-center justify-around">{left.map(renderItem)}</div>
{logoSlot ? (
@@ -2,6 +2,7 @@ import type { RefObject } from 'react';
import { useEffect } from 'react';
import { tickHaptic } from '../../native/haptics';
import { closeTopOverlay, hasOpenOverlay } from '../overlayStack';
import { skipNextRouteTransition } from '../routeTransition';
/**
* The swipe from the left edge that goes back, the way every iOS app has it.
@@ -96,6 +97,7 @@ export default function MEdgeBack({ target, enabled }: { target: RefObject<HTMLE
}
move(width, 'transform 200ms cubic-bezier(0.2, 0.8, 0.2, 1)');
window.setTimeout(() => {
skipNextRouteTransition();
window.history.back();
// The page that comes back takes the place without a slide of its own.
requestAnimationFrame(() => move(0, 'none'));
@@ -0,0 +1,29 @@
import { RefreshCw } from 'lucide-react';
import { PULL_THRESHOLD, type PullState } from '../usePullToRefresh';
/**
* The round glass badge under the top bar that a pull-to-refresh reveals: it
* fades in and turns with the pull, and spins while the page reloads.
*/
export default function MPullIndicator({ state, label }: { state: PullState; label: string }) {
const progress = Math.min(1, state.distance / PULL_THRESHOLD);
const visible = state.refreshing || state.distance > 4;
return (
<div
aria-hidden={!state.refreshing}
role={state.refreshing ? 'status' : undefined}
aria-label={state.refreshing ? label : undefined}
className="pointer-events-none fixed inset-x-0 z-30 flex justify-center transition-opacity duration-200"
style={{ top: 'calc(var(--m-safe-top, 12px) + 70px)', opacity: visible ? Math.max(0.35, progress) : 0 }}
>
<span className="flex h-9 w-9 items-center justify-center rounded-full border border-[color:var(--m-gbr)] bg-[color:var(--m-glass)] text-m-ink shadow-sm backdrop-blur-xl">
<RefreshCw
size={16}
strokeWidth={2.2}
className={state.refreshing ? 'animate-spin' : undefined}
style={state.refreshing ? undefined : { transform: `rotate(${progress * 270}deg)` }}
/>
</span>
</div>
);
}
@@ -9,7 +9,7 @@
*/
export default function MRouteFallback() {
return (
<div className="m-root flex h-dvh flex-col gap-3 px-4 pt-14 bg-[color:var(--m-bg)]">
<div data-route-fallback className="m-root flex h-dvh flex-col gap-3 px-4 pt-14 bg-[color:var(--m-bg)]">
<div className="h-7 w-40 animate-pulse rounded-lg bg-m-card" />
<div className="h-28 animate-pulse rounded-xl bg-m-card" />
<div className="h-28 animate-pulse rounded-xl bg-m-card" />
+5 -1
View File
@@ -1,5 +1,6 @@
import { ReactNode, useRef } from 'react'
import { useActivePill } from './useActivePill'
import { selectionHaptic } from '../../native/haptics'
export interface MSegmentedOption<T extends string = string> {
value: T
@@ -45,7 +46,10 @@ export default function MSegmented<T extends string = string>({
role="tab"
aria-selected={active}
data-active={active}
onClick={() => onChange(opt.value)}
onClick={() => {
if (!active) selectionHaptic()
onChange(opt.value)
}}
className={`relative z-[1] rounded-full py-[7px] text-center ${
variant === 'intrinsic'
? 'whitespace-nowrap px-[11px] text-[0.75rem]'
+2
View File
@@ -6,6 +6,7 @@ import { useDiscardGuard } from '../../components/shared/useDiscardGuard'
import { keepTabInside, useModalFocus } from './modalFocus'
import { useSheetDrag } from './useSheetDrag'
import { useOverlay } from '../overlayStack'
import { tickHaptic } from '../../native/haptics'
export type MSheetVariant = 'card' | 'bottom' | 'drawer'
export type MSheetMaterial = 'glass' | 'bar-glass' | 'opaque'
@@ -106,6 +107,7 @@ export default function MSheet({
return false
}
setFlung(true)
tickHaptic()
onClose()
return true
},
+5 -1
View File
@@ -1,3 +1,4 @@
import { selectionHaptic } from '../../native/haptics'
interface MToggleProps {
checked: boolean
onChange: (checked: boolean) => void
@@ -15,7 +16,10 @@ export default function MToggle({ checked, onChange, ariaLabel, disabled = false
aria-checked={checked}
aria-label={ariaLabel}
disabled={disabled}
onClick={() => onChange(!checked)}
onClick={() => {
selectionHaptic()
onChange(!checked)
}}
className={`box-border h-[22px] w-10 flex-none rounded-full p-[2px] transition-colors duration-[180ms] disabled:opacity-50 ${
checked ? 'bg-m-act' : 'bg-[color:var(--m-trackoff)]'
} ${className}`}
+66
View File
@@ -412,3 +412,69 @@
.m-hscroll::-webkit-scrollbar {
display: none;
}
/* ── Page transitions (mobile/routeTransition.ts) ───────────────────────────
The way a native navigation stack moves: forward slides the new page in from
the trailing edge over the old one, which drifts a little the other way and
dims; back is the mirror; switching between the dock's sections only
cross-fades. --m-vt-dir flips the travel for right-to-left languages. The
dock keeps its own name, so it stands still while the pages move under it. */
:root {
--m-vt-dir: 1;
/* The company-holiday tint of the Vacay cells (vacayDayModel), for the legend chip. */
--m-vacay-company: #f5d9a6;
}
:root[dir='rtl'] {
--m-vt-dir: -1;
}
.m-dock-vt {
view-transition-name: m-dock;
}
@keyframes m-vt-in-trailing {
from {
transform: translateX(calc(100% * var(--m-vt-dir)));
}
}
@keyframes m-vt-out-leading {
to {
transform: translateX(calc(-30% * var(--m-vt-dir)));
filter: brightness(0.82);
}
}
@keyframes m-vt-in-leading {
from {
transform: translateX(calc(-30% * var(--m-vt-dir)));
filter: brightness(0.82);
}
}
@keyframes m-vt-out-trailing {
to {
transform: translateX(calc(100% * var(--m-vt-dir)));
}
}
:root[data-route-transition='forward']::view-transition-old(root) {
animation: m-vt-out-leading 0.4s cubic-bezier(0.32, 0.72, 0, 1) both;
}
:root[data-route-transition='forward']::view-transition-new(root) {
animation: m-vt-in-trailing 0.4s cubic-bezier(0.32, 0.72, 0, 1) both;
box-shadow: -16px 0 40px -12px rgb(0 0 0 / 0.35);
}
:root[data-route-transition='back']::view-transition-old(root) {
z-index: 1;
animation: m-vt-out-trailing 0.34s cubic-bezier(0.32, 0.72, 0, 1) both;
box-shadow: -16px 0 40px -12px rgb(0 0 0 / 0.35);
}
:root[data-route-transition='back']::view-transition-new(root) {
animation: m-vt-in-leading 0.34s cubic-bezier(0.32, 0.72, 0, 1) both;
}
:root[data-route-transition='tab']::view-transition-old(root),
:root[data-route-transition='tab']::view-transition-new(root) {
animation-duration: 0.18s;
}
@media (prefers-reduced-motion: reduce) {
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
animation: none !important;
}
}
@@ -0,0 +1,70 @@
import { act, renderHook } from '@testing-library/react';
import type { Location, NavigationType } from 'react-router';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { skipNextRouteTransition, transitionKind, useRouteTransition } from './routeTransition';
const loc = (pathname: string, key = pathname): Location => ({ pathname, key, search: '', hash: '', state: null });
describe('transitionKind', () => {
it('slides deeper pages forward and back, cross-fades between sections', () => {
expect(transitionKind(loc('/dashboard'), loc('/trips/4'), 'PUSH')).toBe('forward');
expect(transitionKind(loc('/trips/4'), loc('/dashboard'), 'POP')).toBe('back');
expect(transitionKind(loc('/trips/4'), loc('/trips/4/files'), 'PUSH')).toBe('forward');
expect(transitionKind(loc('/dashboard'), loc('/atlas'), 'PUSH')).toBe('tab');
expect(transitionKind(loc('/vacay'), loc('/dashboard'), 'POP')).toBe('tab');
});
});
describe('useRouteTransition', () => {
let start: ReturnType<typeof vi.fn>;
beforeEach(() => {
start = vi.fn((update: () => Promise<void>) => {
const done = update();
return { finished: done, ready: done, updateCallbackDone: done, skipTransition: vi.fn() };
});
Object.defineProperty(document, 'startViewTransition', { value: start, configurable: true, writable: true });
vi.stubGlobal(
'matchMedia',
vi.fn(() => ({ matches: false }))
);
});
afterEach(() => {
delete (document as { startViewTransition?: unknown }).startViewTransition;
vi.unstubAllGlobals();
});
function render(initial: Location, enabled = true) {
return renderHook(
({ location, type }: { location: Location; type: NavigationType }) => useRouteTransition(location, type, enabled),
{ initialProps: { location: initial, type: 'PUSH' as NavigationType } }
);
}
it('swaps the page inside a view transition and marks its direction', async () => {
const hook = render(loc('/dashboard'));
await act(async () => {
hook.rerender({ location: loc('/trips/1'), type: 'PUSH' });
});
expect(start).toHaveBeenCalledTimes(1);
expect(hook.result.current.pathname).toBe('/trips/1');
});
it('goes without a transition after the edge swipe, on the same page and when off', async () => {
const hook = render(loc('/trips/1'));
skipNextRouteTransition();
await act(async () => {
hook.rerender({ location: loc('/dashboard'), type: 'POP' });
});
await act(async () => {
hook.rerender({ location: { ...loc('/dashboard', 'k2'), search: '?f=1' }, type: 'PUSH' });
});
expect(start).not.toHaveBeenCalled();
expect(hook.result.current.key).toBe('k2');
const off = render(loc('/dashboard'), false);
off.rerender({ location: loc('/trips/2'), type: 'PUSH' });
expect(off.result.current.pathname).toBe('/trips/2');
expect(start).not.toHaveBeenCalled();
});
});
+93
View File
@@ -0,0 +1,93 @@
import { useLayoutEffect, useRef, useState } from 'react';
import { flushSync } from 'react-dom';
import type { Location, NavigationType } from 'react-router';
/**
* Page transitions on the phone, the way a native navigation stack moves: a
* page opened from another slides in from the trailing edge over the one it
* leaves, going back slides it away again, and switching between the main
* sections (the dock's destinations) only cross-fades. Built on the View
* Transitions API, so the browser animates snapshots and the pages themselves
* render as before; mobile.css holds the motion.
*
* The router is a declarative BrowserRouter, whose `viewTransition` option does
* nothing, so App renders <Routes> with the location this hook hands out and
* the hook swaps it inside the transition. Routes load lazily: the swap waits
* (at most SETTLE_MS) until the new page replaced the route fallback, while the
* browser keeps showing the old snapshot, so the skeleton never flashes.
*/
export type RouteTransitionKind = 'forward' | 'back' | 'tab';
const SETTLE_MS = 800;
let skipNext = false;
let current: ViewTransition | null = null;
/** The edge-swipe back gesture already slid the page away: the next change goes without a transition. */
export function skipNextRouteTransition(): void {
skipNext = true;
}
function depth(pathname: string): number {
return pathname.split('/').filter(Boolean).length;
}
export function transitionKind(from: Location, to: Location, navigationType: NavigationType): RouteTransitionKind {
if (depth(from.pathname) <= 1 && depth(to.pathname) <= 1) return 'tab';
return navigationType === 'POP' ? 'back' : 'forward';
}
function canAnimate(): boolean {
return (
typeof document !== 'undefined' &&
typeof document.startViewTransition === 'function' &&
!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
);
}
/** Resolves once no route fallback is on screen, or after SETTLE_MS. */
function settled(): Promise<void> {
const began = performance.now();
return new Promise((resolve) => {
const check = () => {
if (!document.querySelector('[data-route-fallback]') || performance.now() - began > SETTLE_MS) resolve();
else requestAnimationFrame(check);
};
check();
});
}
export function useRouteTransition(location: Location, navigationType: NavigationType, enabled: boolean): Location {
const [shown, setShown] = useState(location);
const shownRef = useRef(location);
useLayoutEffect(() => {
const from = shownRef.current;
if (from.key === location.key) return;
shownRef.current = location;
const skip = skipNext;
skipNext = false;
// A changed query or hash on the same page (a tab inside the trip, a filter) is not a navigation.
if (!enabled || skip || from.pathname === location.pathname || !canAnimate()) {
setShown(location);
return;
}
const root = document.documentElement;
root.dataset.routeTransition = transitionKind(from, location, navigationType);
const transition = document.startViewTransition(async () => {
flushSync(() => setShown(location));
await settled();
});
current = transition;
// A newer navigation skips this one: both promises then reject, which is no error.
transition.ready.catch(() => {});
void transition.finished
.catch(() => {})
.finally(() => {
// Only the newest transition clears the direction, or a skipped one would
// take it away from the one that replaced it.
if (current === transition) delete root.dataset.routeTransition;
});
}, [location, navigationType, enabled]);
return enabled ? shown : location;
}
@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react'
import React, { useEffect, useRef, useState } from 'react'
import {
Archive, ArchiveRestore, ArrowRight, Bell, CalendarDays, CalendarPlus, Copy,
LayoutGrid, List, MapPin, Pencil, Plus, RefreshCw, Sparkles, Trash2, Users,
@@ -26,6 +26,8 @@ import MUserMenu from './MUserMenu'
import { useMobileDashOrder, useMobileDashVisibility, MobileDashWidget } from './MDashWidgets'
import MNewTripSheet from './MNewTripSheet'
import type { MobileDashToken } from '@trek/shared'
import { usePullToRefresh } from '../../usePullToRefresh'
import MPullIndicator from '../../components/MPullIndicator'
type TripFilter = 'planned' | 'archive' | 'completed'
@@ -44,7 +46,7 @@ interface CardAction {
export default function MDashboard(): React.ReactElement {
const {
demoMode, locale, t, navigate,
spotlight, upcoming, gridTrips, isLoading, loadError, retryLoad,
spotlight, upcoming, gridTrips, isLoading, loadError, retryLoad, refreshQuietly,
tripFilter, setTripFilter, viewMode, toggleViewMode,
showForm, setShowForm, editingTrip, setEditingTrip,
deleteTrip, setDeleteTrip, copyTrip, setCopyTrip, applyCoverUpdate,
@@ -70,6 +72,10 @@ export default function MDashboard(): React.ReactElement {
const dashVisible = useMobileDashVisibility()
useEffect(() => { if (isAuthenticated) fetchUnreadCount() }, [isAuthenticated, fetchUnreadCount])
const contentRef = useRef<HTMLDivElement>(null)
const pull = usePullToRefresh(contentRef, async () => {
await Promise.all([refreshQuietly(), fetchUnreadCount()])
})
const openCreate = () => { setEditingTrip(null); setShowForm(true) }
const openEdit = (trip: DashboardTrip) => { setEditingTrip(trip); setShowForm(true) }
@@ -249,7 +255,8 @@ export default function MDashboard(): React.ReactElement {
{demoMode && <DemoWelcome />}
<div className="px-4 pb-[calc(var(--bottom-nav-h,84px)+32px)] pt-[calc(var(--m-safe-top,12px)+82px)]">
<MPullIndicator state={pull} label={t('common.loading')} />
<div ref={contentRef} className="px-4 pb-[calc(var(--bottom-nav-h,84px)+32px)] pt-[calc(var(--m-safe-top,12px)+82px)]">
{loadError && (
<div role="alert" className="mb-3 flex items-center gap-3 rounded-[20px] border border-[color:var(--m-gbr)] bg-[color:var(--m-glass)] p-[14px]">
<span className="min-w-0 flex-1 text-[0.8125rem] font-medium text-m-ink">{t('dashboard.loadErrorBanner')}</span>
@@ -1,5 +1,6 @@
import { useRef, useState } from 'react'
import { X, Image, Plus, Trash2, UserPlus, Link as LinkIcon, List, Grid3x3, MapPin, Route, Archive, ArchiveRestore, Undo2, Link2, ListChecks, RefreshCw } from 'lucide-react'
import { X, Image, Plus, Trash2, UserPlus, Link as LinkIcon, List, Grid3x3, MapPin, Route, Archive, ArchiveRestore, Undo2, Link2, ListChecks, RefreshCw, Share as ShareIcon } from 'lucide-react'
import { canShareLink, shareLink } from '../../../native/shareLink'
import MSheet from '../../components/MSheet'
import MIconBtn from '../../components/MIconBtn'
import MToggle from '../../components/MToggle'
@@ -266,6 +267,16 @@ export default function MJourneySettingsSheet({
>
{copied ? t('journey.share.copied') : t('journey.share.copy')}
</button>
{canShareLink() && shareUrl && (
<button
type="button"
aria-label={t('common.share')}
onClick={() => void shareLink(shareUrl)}
className="flex h-[26px] w-[26px] flex-none items-center justify-center rounded-full bg-m-act text-m-actfg"
>
<ShareIcon size={12} strokeWidth={2.4} />
</button>
)}
</div>
{([
{ key: 'share_timeline' as const, label: t('journey.share.timeline'), icon: List },
@@ -28,7 +28,7 @@ export default function MRouteAppsSheet({
return (
<MSheet open={open} onClose={onClose} variant="bottom" ariaLabel={t('mobileTrip.openRouteIn')}>
<div className="px-4 pb-4 pt-6">
<div className="mb-3 flex items-center gap-2 px-1 text-[0.8125rem] font-bold text-m-ink">
<div className="mb-3 flex items-center gap-2 px-1 text-[length:calc(13px*var(--fs-scale-body,1))] font-bold text-m-ink">
<MapPinned size={16} strokeWidth={2.2} className="text-m-muted" aria-hidden="true" />
{t('mobileTrip.openRouteIn')}
</div>
@@ -44,11 +44,11 @@ export default function MRouteAppsSheet({
className="flex items-center gap-3 rounded-[18px] border border-[color:var(--m-gbr)] bg-[color:var(--m-glass)] px-4 py-3 text-start transition-transform active:scale-[.98]"
>
<span className="min-w-0 flex-1">
<span className="block truncate text-[0.9375rem] font-bold text-m-ink">
<span className="block truncate text-[length:calc(15px*var(--fs-scale-body,1))] font-bold text-m-ink">
{target.labelKey ? t(target.labelKey) : target.label}
</span>
{target.firstStopOnly && (
<span className="mt-0.5 block font-geist text-[0.6875rem] text-m-muted">
<span className="mt-0.5 block font-geist text-[length:calc(11px*var(--fs-scale-body,1))] text-m-muted">
{t('mobileTrip.firstStopOnly')}
</span>
)}
@@ -8,6 +8,7 @@ import {
Plus,
RefreshCw,
Settings2,
Share as ShareIcon,
Trash2,
UserMinus,
UserPlus,
@@ -26,6 +27,7 @@ import {
type ListedMember,
} from '../../../../components/Trips/useTripShare';
import { useTranslation } from '../../../../i18n';
import { canShareLink, shareLink } from '../../../../native/shareLink';
import MChip from '../../../components/MChip';
import MSheet from '../../../components/MSheet';
import MConfirmSheet from '../../settings/MConfirmSheet';
@@ -122,6 +124,16 @@ function LinkField({
{copied ? <Check size={13} strokeWidth={2.4} /> : <Copy size={13} strokeWidth={2.2} />}
{copied ? t('common.copied') : t('common.copy')}
</button>
{canShareLink() && (
<button
type="button"
aria-label={t('common.share')}
onClick={() => void shareLink(url)}
className={`${PRIMARY} h-[32px] w-[32px] justify-center px-0`}
>
<ShareIcon size={13} strokeWidth={2.2} />
</button>
)}
</div>
);
}
@@ -7,6 +7,7 @@ import { useCollabChatData } from '../../../../components/Collab/useCollabChatDa
import { useAuthStore } from '../../../../store/authStore'
import { useTranslation } from '../../../../i18n'
import type { TripPlanner } from '../MTripShell'
import { pressHaptic } from '../../../../native/haptics'
import {
OTHER_BUBBLE_RADIUS,
OWN_BUBBLE_RADIUS,
@@ -252,6 +253,7 @@ function ChatBubbleRow({ msg, own, showHeader, isLastInGroup, marginTop, is12h,
startRef.current = { x: e.clientX, y: e.clientY }
pressTimer.current = setTimeout(() => {
firedRef.current = true
pressHaptic()
onOpenActions(e.clientX, e.clientY)
}, LONG_PRESS_MS)
}
+10 -10
View File
@@ -16,9 +16,9 @@ import { FALLBACK_PERSON_COLOR } from './vacayDayModel'
import { fmtDays } from '../../../components/Vacay/vacayFormat'
/** The company-holiday tint the calendar cells use (vacayDayModel). */
const COMPANY_COLOR = '#F5D9A6' // theme-lint-disable: the cells' fixed company-day tint
const GLASS_PILL_BUTTON = 'inline-flex h-8 min-w-0 flex-1 basis-0 items-center justify-center gap-2 rounded-full border border-[color:var(--m-gbr)] bg-[color:var(--m-glass)] px-3 text-[0.75rem] font-bold text-m-ink backdrop-blur-[24px] backdrop-saturate-[1.7] transition-transform active:scale-[.97]'
const SHEET_ROW = 'flex items-center gap-3 rounded-[16px] border border-[color:var(--m-gbr)] bg-[color:var(--m-glass)] px-4 py-3 text-start text-[0.875rem] font-bold transition-transform active:scale-[.98]'
const COMPANY_COLOR = 'var(--m-vacay-company)'
const GLASS_PILL_BUTTON = 'inline-flex h-8 min-w-0 flex-1 basis-0 items-center justify-center gap-2 rounded-full border border-[color:var(--m-gbr)] bg-[color:var(--m-glass)] px-3 text-[length:calc(12px*var(--fs-scale-body,1))] font-bold text-m-ink backdrop-blur-[24px] backdrop-saturate-[1.7] transition-transform active:scale-[.97]'
const SHEET_ROW = 'flex items-center gap-3 rounded-[16px] border border-[color:var(--m-gbr)] bg-[color:var(--m-glass)] px-4 py-3 text-start text-[length:calc(14px*var(--fs-scale-body,1))] font-bold transition-transform active:scale-[.98]'
const WEEKDAY_KEYS_MONDAY = ['vacay.mon', 'vacay.tue', 'vacay.wed', 'vacay.thu', 'vacay.fri', 'vacay.sat', 'vacay.sun'] as const
const WEEKDAY_KEYS_SUNDAY = ['vacay.sun', 'vacay.mon', 'vacay.tue', 'vacay.wed', 'vacay.thu', 'vacay.fri', 'vacay.sat'] as const
@@ -203,7 +203,7 @@ export default function MVacay() {
<button
type="button"
onClick={() => v.openMonthSlot(slot)}
className="mb-[6px] w-full rounded-full border border-[color:var(--m-gbr)] bg-[color:var(--m-glass)] py-[6px] text-center text-[0.75rem] font-extrabold capitalize shadow-[0_4px_12px_-8px_rgba(0,0,0,.25)] backdrop-blur-[24px] backdrop-saturate-[1.7] transition-transform active:scale-[.97]"
className="mb-[6px] w-full rounded-full border border-[color:var(--m-gbr)] bg-[color:var(--m-glass)] py-[6px] text-center text-[length:calc(12px*var(--fs-scale-body,1))] font-extrabold capitalize shadow-[0_4px_12px_-8px_rgba(0,0,0,.25)] backdrop-blur-[24px] backdrop-saturate-[1.7] transition-transform active:scale-[.97]"
>
{v.monthNamesShort[slot]}
</button>
@@ -246,7 +246,7 @@ export default function MVacay() {
<button type="button" onClick={v.prevMonth} aria-label={t('mobileVacay.prevMonth')} className="flex h-[30px] w-[30px] items-center justify-center rounded-full bg-[color:var(--m-ic)]">
<ChevronLeft size={16} strokeWidth={2.2} />
</button>
<span className="flex-1 text-center text-[1rem] font-extrabold capitalize">{v.monthNameLongFor(slot)}</span>
<span className="flex-1 text-center text-[length:calc(16px*var(--fs-scale-body,1))] font-extrabold capitalize">{v.monthNameLongFor(slot)}</span>
<button type="button" onClick={v.nextMonth} aria-label={t('mobileVacay.nextMonth')} className="flex h-[30px] w-[30px] items-center justify-center rounded-full bg-[color:var(--m-ic)]">
<ChevronRight size={16} strokeWidth={2.2} />
</button>
@@ -365,7 +365,7 @@ export default function MVacay() {
<MVacayShareSheet open={v.sheet === 'share'} onClose={() => v.setSheet(null)} />
<MSheet open={v.sheet === 'people'} onClose={() => v.setSheet(null)} variant="bottom" ariaLabel={t('mobileVacay.people')}>
<div className="px-4 pb-4 pt-6">
<div className="mb-3 px-1 text-[0.8125rem] font-bold">{t('mobileVacay.people')}</div>
<div className="mb-3 px-1 text-[length:calc(13px*var(--fs-scale-body,1))] font-bold">{t('mobileVacay.people')}</div>
<div className="flex flex-col gap-2">
{v.users.map(u => {
const color = u.color || FALLBACK_PERSON_COLOR
@@ -388,7 +388,7 @@ export default function MVacay() {
{/* Shared read-only calendars: a tap shows or hides that person's ring overlay. */}
{v.incomingShares.length > 0 && (
<>
<div className="mb-2 mt-4 px-1 font-geist text-[0.625rem] font-bold uppercase tracking-[.09em] text-m-faint">{t('vacay.sharedCalendars')}</div>
<div className="mb-2 mt-4 px-1 font-geist text-[length:calc(10px*var(--fs-scale-body,1))] font-bold uppercase tracking-[.09em] text-m-faint">{t('vacay.sharedCalendars')}</div>
<div className="flex flex-col gap-2">
{v.incomingShares.map(sh => (
<button
@@ -412,10 +412,10 @@ export default function MVacay() {
</MSheet>
<MSheet open={v.sheet === 'legend'} onClose={() => v.setSheet(null)} variant="bottom" ariaLabel={t('mobileVacay.legend')}>
<div className="px-4 pb-4 pt-6">
<div className="mb-3 px-1 text-[0.8125rem] font-bold">{t('mobileVacay.legend')}</div>
<div className="mb-3 px-1 text-[length:calc(13px*var(--fs-scale-body,1))] font-bold">{t('mobileVacay.legend')}</div>
<div className="flex flex-col gap-1">
{legendItems.map(item => (
<div key={item.key} className="flex items-center gap-3 px-1 py-2 text-[0.875rem] font-semibold">
<div key={item.key} className="flex items-center gap-3 px-1 py-2 text-[length:calc(14px*var(--fs-scale-body,1))] font-semibold">
<span className="h-[14px] w-[14px] flex-none rounded-[4px]" style={{ background: item.color }} />
<span className="min-w-0 flex-1 truncate">{item.label}</span>
</div>
@@ -506,7 +506,7 @@ function MonthQuickNav({ names, slot, onPick }: { names: string[]; slot: number;
data-active={i === slot}
aria-pressed={i === slot}
onClick={() => onPick(i)}
className={`relative z-[1] whitespace-nowrap rounded-full py-[7px] text-center text-[0.71875rem] font-bold capitalize transition-colors duration-200 ${
className={`relative z-[1] whitespace-nowrap rounded-full py-[7px] text-center text-[length:calc(11.5px*var(--fs-scale-body,1))] font-bold capitalize transition-colors duration-200 ${
i === slot ? `text-m-actfg ${pill ? '' : 'bg-m-act'}` : 'text-m-ink active:bg-[color:var(--m-ic)]'
}`}
>
@@ -0,0 +1,83 @@
import { act, renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../native/haptics', () => ({ tickHaptic: vi.fn() }));
vi.mock('./overlayStack', () => ({ hasOpenOverlay: vi.fn(() => false) }));
import { tickHaptic } from '../native/haptics';
import { hasOpenOverlay } from './overlayStack';
import { usePullToRefresh } from './usePullToRefresh';
function touch(type: string, x = 0, y = 0) {
const event = new Event(type, { cancelable: true });
Object.defineProperty(event, 'touches', { value: type === 'touchend' ? [] : [{ clientX: x, clientY: y }] });
act(() => {
window.dispatchEvent(event);
});
return event;
}
function setup() {
const el = document.createElement('div');
const ref = { current: el };
const onRefresh = vi.fn(async () => {});
const hook = renderHook(() => usePullToRefresh(ref, onRefresh));
return { el, onRefresh, hook };
}
beforeEach(() => {
vi.useFakeTimers();
Object.defineProperty(window, 'scrollY', { value: 0, configurable: true });
vi.mocked(tickHaptic).mockClear();
vi.mocked(hasOpenOverlay).mockReturnValue(false);
});
afterEach(() => vi.useRealTimers());
describe('usePullToRefresh', () => {
it('springs back without refreshing after a short pull', () => {
const { el, onRefresh } = setup();
touch('touchstart', 100, 100);
const move = touch('touchmove', 100, 160);
expect(move.defaultPrevented).toBe(true);
expect(el.style.transform).toContain('translate3d');
touch('touchend');
expect(onRefresh).not.toHaveBeenCalled();
expect(el.style.transform).toBe('');
});
it('arms with a tick past the threshold and refreshes on release', async () => {
const { onRefresh, hook } = setup();
touch('touchstart', 100, 100);
touch('touchmove', 100, 400);
expect(tickHaptic).toHaveBeenCalledTimes(1);
expect(hook.result.current.armed).toBe(true);
touch('touchend');
expect(hook.result.current.refreshing).toBe(true);
await act(async () => {
await vi.runAllTimersAsync();
});
expect(onRefresh).toHaveBeenCalledTimes(1);
expect(hook.result.current.refreshing).toBe(false);
});
it('leaves sideways drags, scrolled pages and open sheets alone', () => {
const { el, onRefresh } = setup();
touch('touchstart', 100, 100);
expect(touch('touchmove', 200, 120).defaultPrevented).toBe(false);
touch('touchend');
Object.defineProperty(window, 'scrollY', { value: 40, configurable: true });
touch('touchstart', 100, 100);
touch('touchmove', 100, 400);
touch('touchend');
Object.defineProperty(window, 'scrollY', { value: 0, configurable: true });
vi.mocked(hasOpenOverlay).mockReturnValue(true);
touch('touchstart', 100, 100);
touch('touchmove', 100, 400);
touch('touchend');
expect(onRefresh).not.toHaveBeenCalled();
expect(el.style.transform).toBe('');
});
});
+123
View File
@@ -0,0 +1,123 @@
import { useEffect, useRef, useState, type RefObject } from 'react';
import { tickHaptic } from '../native/haptics';
import { hasOpenOverlay } from './overlayStack';
/** How far the finger must pull (after resistance) before letting go refreshes. */
export const PULL_THRESHOLD = 64;
const MAX_PULL = 110;
/** The indicator stays at least this long, so a fast answer does not just blink. */
const MIN_SPIN_MS = 450;
export type PullState = { distance: number; refreshing: boolean; armed: boolean };
/**
* Pull-to-refresh on a page whose scroller is the window, as the mobile
* dashboard is. A downward drag that starts at the very top moves `contentRef`
* down with resistance; past the threshold a light tick tells the finger that
* letting go will refresh. Ignored while a sheet is open, for horizontal drags
* (chip rows, pagers) and when the page is not scrolled to the top.
*
* Writes the content's transform directly during the drag, so a pull costs no
* React render per frame; `state` only changes at the edges (armed, refreshing).
*/
export function usePullToRefresh(
contentRef: RefObject<HTMLElement | null>,
onRefresh: () => Promise<unknown>,
enabled = true
): PullState {
const [state, setState] = useState<PullState>({ distance: 0, refreshing: false, armed: false });
const refreshRef = useRef(onRefresh);
refreshRef.current = onRefresh;
useEffect(() => {
if (!enabled) return;
let start: { x: number; y: number } | null = null;
let pulling = false;
let armed = false;
let busy = false;
let distance = 0;
const place = (value: number, animate: boolean) => {
const el = contentRef.current;
if (!el) return;
el.style.transition = animate ? 'transform 0.32s cubic-bezier(0.32, 0.72, 0, 1)' : 'none';
el.style.transform = value > 0 ? `translate3d(0, ${value}px, 0)` : '';
};
const onStart = (e: TouchEvent) => {
if (busy || e.touches.length !== 1 || window.scrollY > 0 || hasOpenOverlay()) return;
start = { x: e.touches[0].clientX, y: e.touches[0].clientY };
pulling = false;
};
const onMove = (e: TouchEvent) => {
if (!start) return;
const dx = e.touches[0].clientX - start.x;
const dy = e.touches[0].clientY - start.y;
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) {
start = null;
return;
}
pulling = true;
}
if (e.cancelable) e.preventDefault();
// Resistance grows with the pull, like a rubber band.
distance = Math.min(MAX_PULL, dy * 0.5 * (1 - Math.min(dy, 400) / 1000));
place(distance, false);
const nowArmed = distance >= PULL_THRESHOLD;
if (nowArmed !== armed) {
armed = nowArmed;
if (armed) tickHaptic();
setState({ distance, refreshing: false, armed });
} else {
setState((s) => (Math.abs(s.distance - distance) > 4 ? { ...s, distance } : s));
}
};
const onEnd = () => {
if (!start) return;
start = null;
if (!pulling) return;
pulling = false;
if (!armed) {
distance = 0;
place(0, true);
setState({ distance: 0, refreshing: false, armed: false });
return;
}
busy = true;
armed = false;
place(PULL_THRESHOLD * 0.75, true);
setState({ distance: PULL_THRESHOLD, refreshing: true, armed: false });
const began = Date.now();
void Promise.resolve()
.then(() => refreshRef.current())
.catch(() => {})
.then(() => new Promise((resolve) => setTimeout(resolve, Math.max(0, MIN_SPIN_MS - (Date.now() - began)))))
.then(() => {
busy = false;
distance = 0;
place(0, true);
setState({ distance: 0, refreshing: false, armed: false });
});
};
window.addEventListener('touchstart', onStart, { passive: true });
// Not passive: once a pull has started, the page must not also scroll or bounce.
window.addEventListener('touchmove', onMove, { passive: false });
window.addEventListener('touchend', onEnd, { passive: true });
window.addEventListener('touchcancel', onEnd, { passive: true });
return () => {
window.removeEventListener('touchstart', onStart);
window.removeEventListener('touchmove', onMove);
window.removeEventListener('touchend', onEnd);
window.removeEventListener('touchcancel', onEnd);
place(0, false);
};
}, [contentRef, enabled]);
return state;
}
+18 -3
View File
@@ -1,11 +1,11 @@
import { Haptics, ImpactStyle, NotificationType } from '@capacitor/haptics';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { tickHaptic, toastHaptic } from './haptics';
import { pressHaptic, selectionHaptic, tickHaptic, toastHaptic } from './haptics';
import { isNativeApp } from './platform';
vi.mock('@capacitor/haptics', () => ({
Haptics: { notification: vi.fn(), impact: vi.fn() },
ImpactStyle: { Light: 'LIGHT' },
Haptics: { notification: vi.fn(), impact: vi.fn(), selectionChanged: vi.fn() },
ImpactStyle: { Light: 'LIGHT', Medium: 'MEDIUM' },
NotificationType: { Success: 'SUCCESS', Warning: 'WARNING', Error: 'ERROR' },
}));
vi.mock('./platform', () => ({ isNativeApp: vi.fn() }));
@@ -13,6 +13,7 @@ vi.mock('./platform', () => ({ isNativeApp: vi.fn() }));
beforeEach(() => {
vi.mocked(Haptics.notification).mockReset().mockResolvedValue();
vi.mocked(Haptics.impact).mockReset().mockResolvedValue();
vi.mocked(Haptics.selectionChanged).mockReset().mockResolvedValue();
vi.mocked(isNativeApp).mockReturnValue(true);
});
@@ -57,3 +58,17 @@ describe('tickHaptic', () => {
expect(Haptics.impact).not.toHaveBeenCalled();
});
});
describe('selectionHaptic and pressHaptic', () => {
it('click on a changed value and bump on a long press, in the app only', () => {
selectionHaptic();
pressHaptic();
expect(Haptics.selectionChanged).toHaveBeenCalledTimes(1);
expect(Haptics.impact).toHaveBeenCalledWith({ style: ImpactStyle.Medium });
vi.mocked(isNativeApp).mockReturnValue(false);
selectionHaptic();
pressHaptic();
expect(Haptics.selectionChanged).toHaveBeenCalledTimes(1);
expect(Haptics.impact).toHaveBeenCalledTimes(1);
});
});
+18
View File
@@ -27,3 +27,21 @@ export function tickHaptic(): void {
if (!isNativeApp()) return;
Haptics.impact({ style: ImpactStyle.Light }).catch(reportNativeFailure);
}
/**
* The selection click a native switch, segmented control or picker gives when
* its value changes. Does nothing in a browser.
*/
export function selectionHaptic(): void {
if (!isNativeApp()) return;
Haptics.selectionChanged().catch(reportNativeFailure);
}
/**
* The firmer bump of a long press that opens a context menu, or of an item
* picked up to drag. Does nothing in a browser.
*/
export function pressHaptic(): void {
if (!isNativeApp()) return;
Haptics.impact({ style: ImpactStyle.Medium }).catch(reportNativeFailure);
}
+40
View File
@@ -0,0 +1,40 @@
import { Share } from '@capacitor/share';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { isNativeApp } from './platform';
import { reportNativeFailure } from './reportFailure';
import { canShareLink, shareLink } from './shareLink';
vi.mock('@capacitor/share', () => ({ Share: { share: vi.fn() } }));
vi.mock('./platform', () => ({ isNativeApp: vi.fn() }));
vi.mock('./reportFailure', () => ({ reportNativeFailure: vi.fn() }));
vi.mock('../utils/fileShare', () => ({ canShareText: vi.fn(() => false) }));
beforeEach(() => {
vi.mocked(Share.share).mockReset().mockResolvedValue({ activityType: undefined });
vi.mocked(reportNativeFailure).mockClear();
vi.mocked(isNativeApp).mockReturnValue(true);
});
describe('shareLink', () => {
it('uses the native sheet inside the app', async () => {
expect(canShareLink()).toBe(true);
expect(await shareLink('https://trek.example/s/abc', 'Trip')).toBe(true);
expect(Share.share).toHaveBeenCalledWith({ url: 'https://trek.example/s/abc', title: 'Trip', dialogTitle: 'Trip' });
});
it('treats a closed sheet as no share, without a failure report', async () => {
vi.mocked(Share.share).mockRejectedValue(new Error('Share canceled'));
expect(await shareLink('https://trek.example/s/abc')).toBe(false);
expect(reportNativeFailure).not.toHaveBeenCalled();
});
it('falls back to the Web Share API in a browser, and offers nothing without one', async () => {
vi.mocked(isNativeApp).mockReturnValue(false);
expect(canShareLink()).toBe(false);
const share = vi.fn(async () => {});
vi.stubGlobal('navigator', { share });
expect(await shareLink('https://trek.example/s/abc')).toBe(true);
expect(share).toHaveBeenCalledWith({ url: 'https://trek.example/s/abc', title: undefined });
vi.unstubAllGlobals();
});
});
+33
View File
@@ -0,0 +1,33 @@
import { Share } from '@capacitor/share';
import { canShareText } from '../utils/fileShare';
import { isNativeApp } from './platform';
import { reportNativeFailure } from './reportFailure';
/**
* Handing a link to the system share sheet, the way a native app offers
* "Share…" next to "Copy": Messages, Mail, AirDrop, WhatsApp. Inside the apps
* through Capacitor's Share, in a browser through the Web Share API where it
* exists. Where neither does, `canShareLink` is false and the caller shows
* only the copy button.
*/
export function canShareLink(): boolean {
return isNativeApp() || canShareText();
}
/** True when a target took the link, false when the sheet was closed or failed. */
export async function shareLink(url: string, title?: string): Promise<boolean> {
try {
if (isNativeApp()) {
await Share.share({ url, title, dialogTitle: title });
return true;
}
await navigator.share({ url, title });
return true;
} catch (err) {
// Closing the sheet rejects too; only a real failure is worth a report.
const name = (err as { name?: string; message?: string } | null)?.name ?? '';
const message = (err as { message?: string } | null)?.message ?? '';
if (name !== 'AbortError' && !/cancel/i.test(message)) reportNativeFailure(err);
return false;
}
}
+5 -2
View File
@@ -97,7 +97,7 @@ export function useDashboard() {
// client) is read back quietly, one read per burst: no loading state and no
// toast, since nobody here asked for it. trips:changed goes to the acting user
// only; trip:deleted goes to the trip's room, which this page is never in.
useServerPing('trips:changed', true, async () => {
const refreshQuietly = async () => {
try {
const { trips, archivedTrips } = await tripRepo.list()
setTrips(sortTrips(trips))
@@ -106,7 +106,8 @@ export function useDashboard() {
} catch {
// The list on screen stays; the next visit reads it again.
}
})
}
useServerPing('trips:changed', true, refreshQuietly)
// Re-run both the trip fetch and the auth check so a recovered backend clears
// the error banner (loadUser resets authCheckFailed on success). #1283
@@ -240,6 +241,8 @@ export function useDashboard() {
// data + derived
spotlight, heroBundle, stats, upcoming, gridTrips, isLoading,
loadError: loadError || authCheckFailed, retryLoad,
// Pull-to-refresh on mobile: the same quiet read, no loading state.
refreshQuietly,
// ui state
tripFilter, setTripFilter, viewMode, toggleViewMode,
showForm, setShowForm, editingTrip, setEditingTrip,
+1
View File
@@ -89,5 +89,6 @@ const common: TranslationStrings = {
'common.errorPluginTitle': 'تعذّر عرض هذه الإضافة',
'common.needsConnection': 'يتطلب هذا اتصالاً. حاول مرة أخرى عندما يعود TREK للاتصال.',
'common.errorDetails': 'التفاصيل',
'common.share': 'مشاركة',
};
export default common;
+1
View File
@@ -81,5 +81,6 @@ const common: TranslationStrings = {
'common.errorPluginTitle': 'Bu plagini göstərmək mümkün olmadı',
'common.needsConnection': 'Bunun üçün bağlantı lazımdır. TREK yenidən onlayn olduqda bir daha cəhd edin.',
'common.errorDetails': 'Təfərrüatlar',
'common.share': 'Paylaş',
};
export default common;
+1
View File
@@ -82,5 +82,6 @@ const common: TranslationStrings = {
'common.errorPluginTitle': 'Não foi possível exibir este plugin',
'common.needsConnection': 'Isso precisa de conexão. Tente de novo quando o TREK voltar a ficar online.',
'common.errorDetails': 'Detalhes',
'common.share': 'Compartilhar',
};
export default common;
+1
View File
@@ -83,5 +83,6 @@ const common: TranslationStrings = {
'common.errorPluginTitle': "No s'ha pogut mostrar aquest connector",
'common.needsConnection': 'Això necessita connexió. Torna-ho a provar quan TREK torni a estar en línia.',
'common.errorDetails': 'Detalls',
'common.share': 'Comparteix',
};
export default common;
+1
View File
@@ -83,5 +83,6 @@ const common: TranslationStrings = {
'common.errorPluginTitle': 'Tento plugin se nepodařilo zobrazit',
'common.needsConnection': 'Na tohle je potřeba připojení. Zkus to znovu, až bude TREK zase online.',
'common.errorDetails': 'Podrobnosti',
'common.share': 'Sdílet',
};
export default common;
+2 -1
View File
@@ -81,6 +81,7 @@ const common: TranslationStrings = {
'TREK wurde aktualisiert, während dieser Tab offen war. Lade neu, um die neue Version zu erhalten.',
'common.errorPluginTitle': 'Dieses Plugin konnte nicht angezeigt werden',
'common.needsConnection': 'Dafür braucht es eine Verbindung. Versuch es noch einmal, sobald TREK wieder online ist.',
'common.errorDetails': 'Details', // same-as-en
'common.errorDetails': 'Details', // same-as-en,
'common.share': 'Teilen',
};
export default common;
+1
View File
@@ -81,5 +81,6 @@ const common: TranslationStrings = {
'common.errorPluginTitle': 'This plugin could not be shown',
'common.needsConnection': 'This needs a connection. Try again once TREK is back online.',
'common.errorDetails': 'Details',
'common.share': 'Share',
};
export default common;
+1
View File
@@ -82,5 +82,6 @@ const common: TranslationStrings = {
'common.errorPluginTitle': 'No se pudo mostrar este plugin',
'common.needsConnection': 'Esto necesita conexión. Vuelve a intentarlo cuando TREK esté de nuevo en línea.',
'common.errorDetails': 'Detalles',
'common.share': 'Compartir',
};
export default common;
+1
View File
@@ -82,5 +82,6 @@ const common: TranslationStrings = {
'common.errorPluginTitle': 'Seda pluginat ei saanud kuvada',
'common.needsConnection': 'Selleks on vaja ühendust. Proovi uuesti, kui TREK on taas võrgus.',
'common.errorDetails': 'Üksikasjad',
'common.share': 'Jaga',
};
export default common;
+1
View File
@@ -82,5 +82,6 @@ const common: TranslationStrings = {
'common.errorPluginTitle': "Ce plugin n'a pas pu être affiché",
'common.needsConnection': 'Cela nécessite une connexion. Réessaie dès que TREK sera de nouveau en ligne.',
'common.errorDetails': 'Détails',
'common.share': 'Partager',
};
export default common;
+1
View File
@@ -82,5 +82,6 @@ const common: TranslationStrings = {
'common.errorPluginTitle': 'Δεν ήταν δυνατή η εμφάνιση αυτού του πρόσθετου',
'common.needsConnection': 'Αυτό χρειάζεται σύνδεση. Δοκίμασε ξανά μόλις το TREK συνδεθεί ξανά.',
'common.errorDetails': 'Λεπτομέρειες',
'common.share': 'Κοινοποίηση',
};
export default common;
+1
View File
@@ -83,6 +83,7 @@ const common: TranslationStrings = {
'common.errorPluginTitle': 'לא ניתן להציג את התוסף הזה',
'common.needsConnection': 'הפעולה הזו דורשת חיבור. נסו שוב כש-TREK יחזור לרשת.',
'common.errorDetails': 'פרטים',
'common.share': 'שיתוף',
};
export default common;
+1
View File
@@ -81,5 +81,6 @@ const common: TranslationStrings = {
'common.errorPluginTitle': 'Ezt a bővítményt nem sikerült megjeleníteni',
'common.needsConnection': 'Ehhez kapcsolat kell. Próbáld újra, amint a TREK újra online lesz.',
'common.errorDetails': 'Részletek',
'common.share': 'Megosztás',
};
export default common;
+1
View File
@@ -79,5 +79,6 @@ const common: TranslationStrings = {
'common.errorPluginTitle': 'Plugin ini tidak dapat ditampilkan',
'common.needsConnection': 'Ini memerlukan koneksi. Coba lagi begitu TREK kembali online.',
'common.errorDetails': 'Detail',
'common.share': 'Bagikan',
};
export default common;
+1
View File
@@ -82,5 +82,6 @@ const common: TranslationStrings = {
'common.errorPluginTitle': 'Impossibile mostrare questo plugin',
'common.needsConnection': 'Serve una connessione. Riprova quando TREK tornerà online.',
'common.errorDetails': 'Dettagli',
'common.share': 'Condividi',
};
export default common;
+1
View File
@@ -80,5 +80,6 @@ const common: TranslationStrings = {
'common.errorPluginTitle': 'このプラグインを表示できませんでした',
'common.needsConnection': 'この操作には接続が必要です。TREK がオンラインに戻ってから、もう一度お試しください。',
'common.errorDetails': '詳細',
'common.share': '共有',
};
export default common;
+1
View File
@@ -79,5 +79,6 @@ const common: TranslationStrings = {
'common.errorPluginTitle': '이 플러그인을 표시할 수 없습니다',
'common.needsConnection': '연결이 필요한 작업입니다. TREK가 다시 온라인이 되면 다시 시도하세요.',
'common.errorDetails': '세부 정보',
'common.share': '공유',
};
export default common;
+2 -1
View File
@@ -81,6 +81,7 @@ const common: TranslationStrings = {
'TREK is bijgewerkt terwijl dit tabblad open stond. Herlaad om de nieuwe versie te krijgen.',
'common.errorPluginTitle': 'Deze plug-in kon niet worden getoond',
'common.needsConnection': 'Hiervoor is een verbinding nodig. Probeer het opnieuw zodra TREK weer online is.',
'common.errorDetails': 'Details', // same-as-en
'common.errorDetails': 'Details', // same-as-en,
'common.share': 'Delen',
};
export default common;
+1
View File
@@ -85,5 +85,6 @@ const common: TranslationStrings = {
'common.errorPluginTitle': 'Nie udało się wyświetlić tej wtyczki',
'common.needsConnection': 'To wymaga połączenia. Spróbuj ponownie, gdy TREK znów będzie online.',
'common.errorDetails': 'Szczegóły',
'common.share': 'Udostępnij',
};
export default common;
+1
View File
@@ -86,5 +86,6 @@ const common: TranslationStrings = {
'common.errorPluginTitle': 'Не удалось показать этот плагин',
'common.needsConnection': 'Для этого нужно подключение. Попробуйте ещё раз, когда TREK снова будет онлайн.',
'common.errorDetails': 'Подробности',
'common.share': 'Поделиться',
};
export default common;
+1
View File
@@ -84,5 +84,6 @@ const common: TranslationStrings = {
'common.errorPluginTitle': 'Tento plugin sa nepodarilo zobraziť',
'common.needsConnection': 'Na toto je potrebné pripojenie. Skús to znova, keď bude TREK opäť online.',
'common.errorDetails': 'Podrobnosti',
'common.share': 'Zdieľať',
};
export default common;
+1
View File
@@ -82,5 +82,6 @@ const common: TranslationStrings = {
'common.errorPluginTitle': 'Det här tillägget kunde inte visas',
'common.needsConnection': 'Det här kräver en anslutning. Försök igen när TREK är online igen.',
'common.errorDetails': 'Detaljer',
'common.share': 'Dela',
};
export default common;
+1
View File
@@ -79,5 +79,6 @@ const common: TranslationStrings = {
'common.errorPluginTitle': 'ไม่สามารถแสดงปลั๊กอินนี้ได้',
'common.needsConnection': 'การทำงานนี้ต้องใช้การเชื่อมต่อ ลองอีกครั้งเมื่อ TREK กลับมาออนไลน์',
'common.errorDetails': 'รายละเอียด',
'common.share': 'แชร์',
};
export default common;
+1
View File
@@ -81,5 +81,6 @@ const common: TranslationStrings = {
'common.errorPluginTitle': 'Bu eklenti gösterilemedi',
'common.needsConnection': 'Bunun için bağlantı gerekiyor. TREK yeniden çevrimiçi olduğunda tekrar dene.',
'common.errorDetails': 'Ayrıntılar',
'common.share': 'Paylaş',
};
export default common;
+1
View File
@@ -85,5 +85,6 @@ const common: TranslationStrings = {
'common.errorPluginTitle': 'Не вдалося показати цей плагін',
'common.needsConnection': "Для цього потрібне з'єднання. Спробуйте ще раз, коли TREK знову буде онлайн.",
'common.errorDetails': 'Подробиці',
'common.share': 'Поділитися',
};
export default common;
+1
View File
@@ -79,5 +79,6 @@ const common: TranslationStrings = {
'common.errorPluginTitle': 'Không thể hiển thị plugin này',
'common.needsConnection': 'Thao tác này cần kết nối. Hãy thử lại khi TREK trực tuyến trở lại.',
'common.errorDetails': 'Chi tiết',
'common.share': 'Chia sẻ',
};
export default common;
+1
View File
@@ -79,5 +79,6 @@ const common: TranslationStrings = {
'common.errorPluginTitle': '無法顯示此外掛程式',
'common.needsConnection': '此操作需要網路連線。請在 TREK 恢復連線後再試一次。',
'common.errorDetails': '詳細資訊',
'common.share': '分享',
};
export default common;
+1
View File
@@ -79,5 +79,6 @@ const common: TranslationStrings = {
'common.errorPluginTitle': '无法显示此插件',
'common.needsConnection': '此操作需要网络连接。请在 TREK 恢复联网后重试。',
'common.errorDetails': '详细信息',
'common.share': '分享',
};
export default common;