diff --git a/client/src/components/Map/LocationButton.test.tsx b/client/src/components/Map/LocationButton.test.tsx index e62ee13af..6e5226811 100644 --- a/client/src/components/Map/LocationButton.test.tsx +++ b/client/src/components/Map/LocationButton.test.tsx @@ -1,4 +1,4 @@ -// FE-MAP-LOCBTN-001 to FE-MAP-LOCBTN-004 +// FE-MAP-LOCBTN-001 to FE-MAP-LOCBTN-005 import { render } from '@testing-library/react'; import LocationButton from './LocationButton'; import type { GeoWatchErrorCode } from '../../hooks/useGeolocation'; @@ -66,4 +66,11 @@ describe('LocationButton', () => { expect(addToast).toHaveBeenCalledTimes(2); expect(addToast).toHaveBeenLastCalledWith('map.location.timeout', 'error', 6000); }); + + it('FE-MAP-LOCBTN-005: an insecure origin gets the HTTPS hint, not the permission one', () => { + const { getByRole } = render(button('insecure-context', 'Only secure origins are allowed')); + + expect(addToast).toHaveBeenCalledWith('journey.editor.locationInsecureContext', 'error', 6000); + expect(getByRole('button').getAttribute('title')).toBe('journey.editor.locationInsecureContext'); + }); }); diff --git a/client/src/components/Map/LocationButton.tsx b/client/src/components/Map/LocationButton.tsx index 3e1abd83c..236a6e076 100644 --- a/client/src/components/Map/LocationButton.tsx +++ b/client/src/components/Map/LocationButton.tsx @@ -17,11 +17,14 @@ interface Props { // The raw error string from the browser is unlocalized WebKit English, so the // user-facing message is looked up from the typed code instead. 'unsupported' // shares the generic text: a browser without geolocation cannot locate you. +// 'insecure-context' borrows the journal editor's wording, which already says +// exactly this in all locales. const ERROR_KEYS: Record = { 'permission-denied': 'map.location.denied', 'unavailable': 'map.location.unavailable', 'timeout': 'map.location.timeout', 'unsupported': 'map.location.unavailable', + 'insecure-context': 'journey.editor.locationInsecureContext', } // Three-state FAB. Matches the Apple/Google Maps pattern: diff --git a/client/src/hooks/useGeolocation.test.ts b/client/src/hooks/useGeolocation.test.ts index 7a241130f..9ad8d2c76 100644 --- a/client/src/hooks/useGeolocation.test.ts +++ b/client/src/hooks/useGeolocation.test.ts @@ -1,4 +1,4 @@ -// FE-HOOK-GEO-001 to FE-HOOK-GEO-029 +// FE-HOOK-GEO-001 to FE-HOOK-GEO-031 import { StrictMode } from 'react'; import { act, renderHook } from '@testing-library/react'; import { GeoOnceError, getCurrentPositionOnce, useGeolocation } from './useGeolocation'; @@ -354,6 +354,30 @@ describe('useGeolocation', () => { expect(result.current.position).not.toBeNull(); }); + it('FE-HOOK-GEO-030: an insecure origin is named as such, not as a blocked permission', async () => { + // Plain-HTTP self-hosting: the browser would answer PERMISSION_DENIED and the + // user would be sent into device settings that are already correct. + vi.stubGlobal('isSecureContext', false); + const { result } = renderHook(() => useGeolocation()); + + await act(() => result.current.cycleMode()); + + expect(watchPosition).not.toHaveBeenCalled(); + expect(result.current.mode).toBe('off'); + expect(result.current.errorCode).toBe('insecure-context'); + }); + + it('FE-HOOK-GEO-031: a secure origin is not mistaken for an insecure one', async () => { + vi.stubGlobal('isSecureContext', true); + const { result } = renderHook(() => useGeolocation()); + + await act(() => result.current.cycleMode()); + + expect(result.current.mode).toBe('show'); + expect(result.current.errorCode).toBeNull(); + expect(watchPosition).toHaveBeenCalledTimes(1); + }); + it('FE-HOOK-GEO-019: stops the watch when the component unmounts', async () => { const { result, unmount } = renderHook(() => useGeolocation()); await act(() => result.current.cycleMode()); diff --git a/client/src/hooks/useGeolocation.ts b/client/src/hooks/useGeolocation.ts index ee4bb06e7..ce4bdd03b 100644 --- a/client/src/hooks/useGeolocation.ts +++ b/client/src/hooks/useGeolocation.ts @@ -20,7 +20,7 @@ export type TrackingMode = 'off' | 'show' | 'follow' // Typed counterpart to the raw error string. The string is whatever the // browser produced (raw WebKit English on iOS), so the UI keys off this // code to show a localized, actionable message instead. -export type GeoWatchErrorCode = 'permission-denied' | 'unavailable' | 'timeout' | 'unsupported' +export type GeoWatchErrorCode = 'permission-denied' | 'unavailable' | 'timeout' | 'unsupported' | 'insecure-context' export interface UseGeolocationReturn { position: GeoPosition | null @@ -139,6 +139,14 @@ export function useGeolocation(): UseGeolocationReturn { setErrorCode('unsupported') return false } + // Same block getCurrentPositionOnce guards against: on a plain-HTTP origin + // the browser answers PERMISSION_DENIED, which would otherwise read as a + // revoked device permission and send the user into their OS settings. + if (window.isSecureContext === false) { + setError('Location requires a secure (HTTPS) connection') + setErrorCode('insecure-context') + return false + } // Already watching, or still waiting on the iOS prompt below: a second // subscription would overwrite watchIdRef and leave the first one running // with nobody left holding its id. diff --git a/client/src/mobile/components/MToastHost.test.tsx b/client/src/mobile/components/MToastHost.test.tsx new file mode 100644 index 000000000..fd1a1fecd --- /dev/null +++ b/client/src/mobile/components/MToastHost.test.tsx @@ -0,0 +1,70 @@ +// FE-COMP-MTOASTHOST-001 to FE-COMP-MTOASTHOST-005 +import { act, render, screen } from '@testing-library/react' +import MToastHost from './MToastHost' + +// The geolocation hint from discussion #2095: the longest string the toast bus +// carries, and the one whose actionable half used to sit behind the ellipsis. +const LONG = 'Location access is blocked. Check your device settings; an installed app has its own location permission, separate from the browser.' + +// Tailwind emits the display plugin AFTER lineClamp, so any display utility on +// the same element overrides `line-clamp-*`'s own `-webkit-box`. +const DISPLAY_UTILITIES = ['block', 'inline-block', 'flex', 'inline-flex', 'grid', 'inline-grid', 'flow-root', 'contents', 'inline'] + +function push(message: string, type: 'success' | 'error' | 'warning' | 'info' = 'info', duration = 3000): void { + act(() => { window.__addToast?.(message, type, duration) }) +} + +afterEach(() => { + delete window.__addToast +}) + +describe('MToastHost', () => { + it('FE-COMP-MTOASTHOST-001: takes over the toast bus and hands it back on unmount', () => { + const previous = vi.fn(() => 1) + window.__addToast = previous + + const { unmount } = render() + expect(window.__addToast).not.toBe(previous) + + unmount() + expect(window.__addToast).toBe(previous) + }) + + it('FE-COMP-MTOASTHOST-002: keeps a short message on the single-line pill', () => { + render() + push('Trip saved') + + const text = screen.getByText('Trip saved') + expect(text.className.split(/\s+/)).toContain('truncate') + expect(text.parentElement!.className).toContain('rounded-full') + }) + + it('FE-COMP-MTOASTHOST-003: wraps a long message instead of cutting it off', () => { + render() + push(LONG, 'error', 6000) + + const classes = screen.getByText(LONG).className.split(/\s+/) + expect(classes).not.toContain('truncate') + expect(classes).toContain('line-clamp-4') + DISPLAY_UTILITIES.forEach(utility => expect(classes).not.toContain(utility)) + }) + + it('FE-COMP-MTOASTHOST-004: softens the pill into a card once the message wraps', () => { + render() + push(LONG, 'error', 6000) + + const pill = screen.getByText(LONG).parentElement! + expect(pill.className).toContain('rounded-2xl') + expect(pill.className).not.toContain('rounded-full') + expect(pill.className).toContain('items-start') + }) + + it('FE-COMP-MTOASTHOST-005: a wrapping sticky toast is still tappable', () => { + render() + push(LONG, 'error', 0) + + const pill = screen.getByRole('button') + expect(pill.className).toContain('pointer-events-auto') + expect(pill.className).toContain('rounded-2xl') + }) +}) diff --git a/client/src/mobile/components/MToastHost.tsx b/client/src/mobile/components/MToastHost.tsx index 4992f8ba3..316c99d20 100644 --- a/client/src/mobile/components/MToastHost.tsx +++ b/client/src/mobile/components/MToastHost.tsx @@ -15,6 +15,11 @@ interface MToast { let toastId = 0 const EXIT_MS = 220 +// Roughly what one line of the pill holds on a 375px phone. Longer messages +// (the geolocation permission hint runs ~150 characters) render as a wrapping +// card instead, or the actionable half disappears behind the ellipsis. +const PILL_CHARS = 42 + // info stays the plain act-pill; the other types get a status dot. const TYPE_DOT: Partial> = { success: 'confirmed', @@ -25,7 +30,8 @@ const TYPE_DOT: Partial> = { /** * Mobile toast presenter. Takes over the global `window.__addToast` bridge * (fed by useToast / store/notify) while the mobile shell is mounted, so every - * existing toast call renders as the design's act-pill above the bottom nav. + * existing toast call renders above the bottom nav, as the design's act-pill or, + * for a message too long for one line, as a wrapping card. * The desktop ToastContainer handler is restored on unmount. */ export default function MToastHost() { @@ -74,6 +80,7 @@ export default function MToastHost() {
{toasts.map((toast) => { const dot = TYPE_DOT[toast.type] + const wraps = toast.message.length > PILL_CHARS return (
dismissToast(toast.id) : undefined} onKeyDown={toast.sticky ? (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); dismissToast(toast.id) } } : undefined} - className={`flex max-w-full items-center gap-2 rounded-full bg-m-act px-4 py-2 text-[0.75rem] font-semibold text-m-actfg shadow-[0_10px_30px_-8px_rgba(0,0,0,.5)] ${ - toast.sticky ? 'pointer-events-auto cursor-pointer' : '' - } ${toast.removing ? 'm-toast-out' : 'm-toast-in'}`} + className={`flex max-w-full gap-2 bg-m-act px-4 py-2 text-[0.75rem] font-semibold text-m-actfg shadow-[0_10px_30px_-8px_rgba(0,0,0,.5)] ${ + wraps ? 'items-start rounded-2xl' : 'items-center rounded-full' + } ${toast.sticky ? 'pointer-events-auto cursor-pointer' : ''} ${toast.removing ? 'm-toast-out' : 'm-toast-in'}`} > - {dot && } - {toast.message} + {dot && } + {/* No display utility next to line-clamp: Tailwind emits it later and kills the clamp. */} + {toast.message}
) })}