Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added assets/fonts/map-icons.ttf
Binary file not shown.
2 changes: 1 addition & 1 deletion src/app/(app)/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ export default function TabLayout() {
const parentRef = useRef(null);

// Initialize push notifications
//usePushNotifications();
usePushNotifications();

// Initialize Mapbox - only on native platforms
// On web, Mapbox GL JS is loaded separately and doesn't use this initialization
Expand Down
7 changes: 7 additions & 0 deletions src/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { FloatingDevTools } from '@react-buoy/core';
import { createNavigationContainerRef, DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native';
import * as Sentry from '@sentry/react-native';
import { isRunningInExpoGo } from 'expo';
import { useFonts } from 'expo-font';
import * as Notifications from 'expo-notifications';
import { Stack, useNavigationContainerRef } from 'expo-router';
import * as SplashScreen from 'expo-splash-screen';
Expand Down Expand Up @@ -139,6 +140,12 @@ function RootLayout() {
// Capture the NavigationContainer ref and register it with the integration.
const ref = useNavigationContainerRef();

// Register the map-icons glyph font (POI map markers) on native and web.
// Non-blocking: map screens mount well after this resolves.
useFonts({
'map-icons': require('@assets/fonts/map-icons.ttf'),
});

useEffect(() => {
if (ref?.current) {
navigationIntegration.registerNavigationContainer(ref);
Expand Down
10 changes: 4 additions & 6 deletions src/components/maps/pin-marker.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type Mapbox from '@rnmapbox/maps';
import { useColorScheme } from 'nativewind';
import React, { useCallback } from 'react';
import { Image, Platform, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { Image, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import Svg, { Path } from 'react-native-svg';

import { MAP_ICONS } from '@/constants/map-icons';
Expand Down Expand Up @@ -70,10 +70,8 @@ const PinMarker = React.memo<PinMarkerProps>(({ pin, size = 32, onPress }) => {
elevation: 3,
};

// On native, the map-icons font Unicode characters (PUA range) won't render
// without the font bundled. We show the character anyway — if the font is
// bundled with the app under the "map-icons" family name, it will render.
// Otherwise the system will show a fallback glyph or nothing.
// The map-icons glyph font is registered at app startup via expo-font
// (see src/app/_layout.tsx) from the bundled assets/fonts/map-icons.ttf.
const poiIconTextStyle = {
position: 'absolute' as const,
top: POI_ICON_TOP_OFFSET,
Expand All @@ -82,7 +80,7 @@ const PinMarker = React.memo<PinMarkerProps>(({ pin, size = 32, onPress }) => {
fontSize: POI_ICON_FONT_SIZE,
lineHeight: POI_ICON_FONT_SIZE,
color: '#ffffff',
fontFamily: Platform.OS === 'web' ? 'map-icons' : 'map-icons',
fontFamily: 'map-icons',
includeFontPadding: false,
textAlignVertical: 'center' as const,
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ jest.mock('@/hooks/use-analytics', () => ({
}));

jest.mock('@/stores/push-notification/store', () => ({
// Keep the real isSafeRouteId so the tests exercise the same call id validation the
// deep-link handler uses, rather than a stub that always says yes.
...jest.requireActual('@/stores/push-notification/store'),
usePushNotificationModalStore: jest.fn(),
}));

Expand Down Expand Up @@ -283,6 +286,32 @@ describe('PushNotificationModal', () => {
});
});

// The call id comes from the push payload and is interpolated into the route path, so the same
// separator/query/fragment rejection the deep-link handler applies has to hold here too.
it.each(['../chat/9999', 'a\\b', 'a?x=1', 'a#fragment', ''])('should not offer the call button for unsafe call id %s', (id) => {
const hideNotificationModalMock = jest.fn();

(usePushNotificationModalStore as unknown as jest.Mock).mockReturnValue({
...mockStore,
isOpen: true,
notification: {
type: 'call' as const,
id,
eventCode: `C:${id}`,
title: 'Emergency Call',
body: 'Structure fire',
},
hideNotificationModal: hideNotificationModalMock,
});

render(<PushNotificationModal />);

expect(screen.queryByText('View call')).toBeNull();
expect(router.push).not.toHaveBeenCalled();
expect(hideNotificationModalMock).not.toHaveBeenCalled();
expect(mockAnalytics.trackEvent).not.toHaveBeenCalledWith('push_notification_view_call_pressed', expect.any(Object));
});

it('should handle view call button press', async () => {
const hideNotificationModalMock = jest.fn();

Expand Down
6 changes: 3 additions & 3 deletions src/components/push-notification/push-notification-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { Modal, ModalBackdrop, ModalBody, ModalContent, ModalFooter, ModalHeader
import { Text } from '@/components/ui/text';
import { VStack } from '@/components/ui/vstack';
import { useAnalytics } from '@/hooks/use-analytics';
import { type NotificationType, usePushNotificationModalStore } from '@/stores/push-notification/store';
import { isSafeRouteId, type NotificationType, usePushNotificationModalStore } from '@/stores/push-notification/store';

interface NotificationIconProps {
type: NotificationType;
Expand Down Expand Up @@ -55,7 +55,7 @@ export const PushNotificationModal: React.FC = () => {
};

const handleViewCall = () => {
if (notification?.type === 'call' && notification.id) {
if (notification?.type === 'call' && isSafeRouteId(notification.id)) {
trackEvent('push_notification_view_call_pressed', {
id: notification.id,
eventCode: notification.eventCode,
Expand Down Expand Up @@ -159,7 +159,7 @@ export const PushNotificationModal: React.FC = () => {

<ModalFooter className="pt-4">
<HStack className="w-full space-x-3">
{notification.type === 'call' && notification.id ? (
{notification.type === 'call' && isSafeRouteId(notification.id) ? (
<>
<Button variant="outline" className="flex-1" onPress={handleClose}>
<ButtonText>{t('common.dismiss')}</ButtonText>
Expand Down
39 changes: 39 additions & 0 deletions src/lib/__tests__/get-time-ago-utc.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { getTimeAgoUtc } from '../utils';

describe('getTimeAgoUtc', () => {
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2026-08-19T20:00:00Z'));
});

afterEach(() => {
jest.useRealTimers();
});

it('reads a Z-stamped UTC string without applying the legacy offset shift', () => {
expect(getTimeAgoUtc('2026-08-19T19:55:00.000Z')).toBe('5 minutes ago');
});

it('reads an offset-stamped string without applying the legacy offset shift', () => {
expect(getTimeAgoUtc('2026-08-19T12:55:00-07:00')).toBe('5 minutes ago');
});

it('still reads legacy zone-less UTC strings via the offset-shifted comparison', () => {
// Zone-less strings parse as device-local; the function compensates by shifting "now"
// by the same offset, so the result is correct in any device timezone.
const zoneless = new Date(Date.now() - 5 * 60 * 1000).toISOString().replace(/\.\d{3}Z$/, '');
expect(getTimeAgoUtc(zoneless)).toBe('5 minutes ago');
});

it('treats Date objects as exact instants', () => {
expect(getTimeAgoUtc(new Date(Date.now() - 5 * 60 * 1000))).toBe('5 minutes ago');
});

it('treats epoch milliseconds as exact instants', () => {
expect(getTimeAgoUtc(Date.now() - 5 * 60 * 1000)).toBe('5 minutes ago');
});

it('returns Unknown for empty input', () => {
expect(getTimeAgoUtc('')).toBe('Unknown');
});
});
18 changes: 4 additions & 14 deletions src/lib/map-markers-web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,13 @@ import { type MapMakerInfoData } from '@/models/v4/mapping/getMapDataAndMarkersD
type MapIconKey = keyof typeof MAP_ICONS;

/**
* Injects the map-icons @font-face CSS into the document head if not already present.
* The map-icons font files are expected at /clib/mapMarkers/ on the server.
* Injects the shared .map-icon CSS class into the document head if not already present.
* The 'map-icons' font-face itself is registered by expo-font at app startup
* (see src/app/_layout.tsx) from the bundled assets/fonts/map-icons.ttf.
*/
let mapIconsFontInjected = false;

export const injectMapIconsFont = (baseUrl = '/clib/mapMarkers/'): void => {
export const injectMapIconsFont = (): void => {
if (mapIconsFontInjected || typeof document === 'undefined') return;

const styleId = 'map-icons-font-face';
Expand All @@ -23,17 +24,6 @@ export const injectMapIconsFont = (baseUrl = '/clib/mapMarkers/'): void => {
const style = document.createElement('style');
style.id = styleId;
style.textContent = `
@font-face {
font-family: 'map-icons';
src: url('${baseUrl}map-icons.eot');
src: url('${baseUrl}map-icons.eot#iefix') format('embedded-opentype'),
url('${baseUrl}map-icons.ttf') format('truetype'),
url('${baseUrl}map-icons.woff') format('woff'),
url('${baseUrl}map-icons.svg#map-icons') format('svg');
font-weight: normal;
font-style: normal;
}

.map-icon {
font-family: 'map-icons';
speak: none;
Expand Down
Loading
Loading