mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
fix(mobile,map): long toasts wrap and http selfhosts get the right geolocation message (#2095)
The phone toast was a single truncated pill, which cut the denied message exactly before the settings hint it exists for; long messages wrap up to four lines now. An insecure context is reported as its own error instead of masquerading as a blocked device permission.
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<GeoWatchErrorCode, string> = {
|
||||
'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:
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(<MToastHost />)
|
||||
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(<MToastHost />)
|
||||
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(<MToastHost />)
|
||||
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(<MToastHost />)
|
||||
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(<MToastHost />)
|
||||
push(LONG, 'error', 0)
|
||||
|
||||
const pill = screen.getByRole('button')
|
||||
expect(pill.className).toContain('pointer-events-auto')
|
||||
expect(pill.className).toContain('rounded-2xl')
|
||||
})
|
||||
})
|
||||
@@ -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<Record<ToastType, MStatus>> = {
|
||||
success: 'confirmed',
|
||||
@@ -25,7 +30,8 @@ const TYPE_DOT: Partial<Record<ToastType, MStatus>> = {
|
||||
/**
|
||||
* 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() {
|
||||
<div className="pointer-events-none fixed inset-x-0 bottom-[calc(var(--bottom-nav-h,84px)+16px)] z-[90] flex flex-col-reverse items-center gap-2 px-6">
|
||||
{toasts.map((toast) => {
|
||||
const dot = TYPE_DOT[toast.type]
|
||||
const wraps = toast.message.length > PILL_CHARS
|
||||
return (
|
||||
<div
|
||||
key={toast.id}
|
||||
@@ -81,12 +88,13 @@ export default function MToastHost() {
|
||||
tabIndex={toast.sticky ? 0 : undefined}
|
||||
onClick={toast.sticky ? () => 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 && <MStatusDot status={dot} size={6} />}
|
||||
<span className="min-w-0 truncate">{toast.message}</span>
|
||||
{dot && <MStatusDot status={dot} size={6} className={wraps ? 'mt-[0.45rem]' : ''} />}
|
||||
{/* No display utility next to line-clamp: Tailwind emits it later and kills the clamp. */}
|
||||
<span className={`min-w-0 ${wraps ? 'line-clamp-4 leading-[1.45]' : 'truncate'}`}>{toast.message}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user