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
10 changes: 10 additions & 0 deletions .github/workflows/react-native-cicd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,16 @@ jobs:
node-version: '24'
cache: 'yarn'

# Pin the JDK instead of taking the runner default: ubuntu-latest (26.04) defaults to JDK 25,
# where AGP's prefab step emits "A restricted method in java.lang.System has been called" and
# fails configureCMake (react-native-worklets).
- name: ☕ Setup Java
if: matrix.platform == 'android'
uses: actions/setup-java@v5
with:
distribution: 'temurin'
java-version: '17'

- name: 🔎 Verify Xcode toolchain
if: matrix.platform == 'ios'
run: |
Expand Down
33 changes: 31 additions & 2 deletions src/__tests__/app/map.web.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import React from 'react';

import { getMapDataAndMarkers } from '@/api/mapping/mapping';
import { LIVE_LOCATION_REFRESH_DELAY_MS } from '@/hooks/use-map-live-locations';
import { MAP_SIGNALR_REFETCH_DELAY_MS } from '@/hooks/use-map-signalr-updates';
import { clearMapboxToken, useMapboxTokenStore } from '@/lib/mapbox-token';
import { type GetConfigResultData } from '@/models/v4/configs/getConfigResultData';
import { type MapMakerInfoData } from '@/models/v4/mapping/getMapDataAndMarkersData';
Expand Down Expand Up @@ -94,14 +95,16 @@ jest.mock('mapbox-gl', () => {

jest.mock('@/stores/signalr/signalr-store', () => {
const { create } = jest.requireActual('zustand');
return { useSignalRStore: create(() => ({ liveLocations: {}, lastGeolocationJoinTimestamp: 0 })) };
return { useSignalRStore: create(() => ({ liveLocations: {}, lastGeolocationJoinTimestamp: 0, lastUpdateTimestamp: 0 })) };
});

jest.mock('@/api/mapping/mapping', () => ({ getMapDataAndMarkers: jest.fn() }));
jest.mock('@/lib/map-markers-web', () => ({
buildMapPinPopupHtml: jest.fn(() => '<div></div>'),
createMapMarkerElement: jest.fn((pin: { Id: string }) => ({ pinId: pin.Id })),
getMapMarkerPopupOffset: jest.fn(() => ({ bottom: [0, -36] })),
// The real signature, so the test sees exactly which pin changes rebuild a marker.
getMapMarkerSignature: jest.requireActual('@/lib/map-markers-web').getMapMarkerSignature,
MAP_MARKER_ANCHOR: 'bottom',
}));
jest.mock('@/lib/map-center', () => ({ getDepartmentMapCenter: () => ({ latitude: 39, longitude: -119, zoomLevel: 9 }) }));
Expand Down Expand Up @@ -150,7 +153,7 @@ describe('Map screen (web) realtime locations', () => {
mockMaps.length = 0;
mockGetMapDataAndMarkers.mockReset();
mockGetMapDataAndMarkers.mockResolvedValue(mapResponse(REST_PINS));
useSignalRStore.setState({ liveLocations: {}, lastGeolocationJoinTimestamp: 0 });
useSignalRStore.setState({ liveLocations: {}, lastGeolocationJoinTimestamp: 0, lastUpdateTimestamp: 0 });
// Only the stylesheet injection touches document; report it as already present.
(global as any).document = { getElementById: () => ({}) };
});
Expand Down Expand Up @@ -226,6 +229,32 @@ describe('Map screen (web) realtime locations', () => {
expect(mockMarkers).toHaveLength(REST_PINS.length + 1);
});

it('refetches after an update hub change and rebuilds only the marker whose status pin changed, without moving the camera', async () => {
const RESPONDING_PIN = 'https://api.resgrid.dev/api/v4/TypeIcons/Pins/person-responding?v=1';
const { createMapMarkerElement } = jest.requireMock('@/lib/map-markers-web') as { createMapMarkerElement: jest.Mock };
await renderLoadedMap();
const [personMarker] = liveMarker('pABC');
const [unitMarker] = liveMarker('u12');

// The person went responding: same pin, same place, only the status pin (and its style) changed.
mockGetMapDataAndMarkers.mockResolvedValue(mapResponse(REST_PINS.map((pin) => (pin.Id === 'pABC' ? ({ ...pin, PinIconUrl: RESPONDING_PIN, IconStyle: 'responding' } as MapMakerInfoData) : pin))));
act(() => {
useSignalRStore.setState({ lastUpdateTimestamp: 1000 });
});
expect(mockGetMapDataAndMarkers).toHaveBeenCalledTimes(1);

await act(async () => {
jest.advanceTimersByTime(MAP_SIGNALR_REFETCH_DELAY_MS);
});
await waitFor(() => expect(personMarker.remove).toHaveBeenCalled());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules high

Unhandled waitFor rejections can obscure timeout or assertion failures in src/tests/app/map.web.test.tsx:235, src/components/maps/tests/unified-map-view.web.test.tsx:243, src/components/maps/tests/unified-map-view.web.test.tsx:226, src/tests/app/map.web.test.tsx:246, src/components/maps/tests/unified-map-view.web.test.tsx:240-242, and src/components/maps/tests/unified-map-view.web.test.tsx:260-262. Wrap each awaited waitFor call in try/catch to handle or rethrow the failure explicitly.

Kody rule violation: Handle async operations with proper error handling

try {
  await waitFor(() => expect(personMarker.remove).toHaveBeenCalled());
} catch (error) {
  // Handle or assert the wait failure.
  throw error;
}
Prompt for LLM

File src/__tests__/app/map.web.test.tsx:

Line 249:

Unhandled waitFor rejections can obscure timeout or assertion failures in src/__tests__/app/map.web.test.tsx:235, src/components/maps/__tests__/unified-map-view.web.test.tsx:243, src/components/maps/__tests__/unified-map-view.web.test.tsx:226, src/__tests__/app/map.web.test.tsx:246, src/components/maps/__tests__/unified-map-view.web.test.tsx:240-242, and src/components/maps/__tests__/unified-map-view.web.test.tsx:260-262. Wrap each awaited waitFor call in try/catch to handle or rethrow the failure explicitly.

Suggested Code:

    try {
      await waitFor(() => expect(personMarker.remove).toHaveBeenCalled());
    } catch (error) {
      // Handle or assert the wait failure.
      throw error;
    }

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

​

​


expect(mockGetMapDataAndMarkers).toHaveBeenCalledTimes(2);
expect(createMapMarkerElement).toHaveBeenLastCalledWith(expect.objectContaining({ Id: 'pABC', PinIconUrl: RESPONDING_PIN, IconStyle: 'responding' }), 'light');
expect(liveMarker('pABC')).toHaveLength(1);
expect(liveMarker('u12')).toEqual([unitMarker]);
expect(mockMaps[0].flyTo).toHaveBeenCalledTimes(1);
});

it("keeps the user's POI layer toggles across a background refetch", async () => {
await renderLoadedMap();

Expand Down
16 changes: 11 additions & 5 deletions src/app/(app)/map.web.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,11 @@ import { useActiveMapLayers } from '@/hooks/use-active-map-layers';
import { useAnalytics } from '@/hooks/use-analytics';
import { MapLayerType, useMapLayers } from '@/hooks/use-map-layers';
import { useMapLiveLocations } from '@/hooks/use-map-live-locations';
import { useMapSignalRRefetch } from '@/hooks/use-map-signalr-updates';
import { logger } from '@/lib/logging';
import { getDepartmentMapCenter } from '@/lib/map-center';
import { hasValidMapCoordinates } from '@/lib/map-markers';
import { buildMapPinPopupHtml, createMapMarkerElement, getMapMarkerPopupOffset, MAP_MARKER_ANCHOR } from '@/lib/map-markers-web';
import { buildMapPinPopupHtml, createMapMarkerElement, getMapMarkerPopupOffset, getMapMarkerSignature, MAP_MARKER_ANCHOR } from '@/lib/map-markers-web';
import { useDepartmentMapStyle } from '@/lib/map-style';
import { applyMapboxGlAccessToken } from '@/lib/mapbox-gl-token-web';
import { createDefaultVisiblePoiLayerIds, filterMapPinsByPoiLayers, getPoiMapLayerId, mergeVisiblePoiLayerIds } from '@/lib/poi-map-layers';
Expand Down Expand Up @@ -217,7 +218,8 @@ export default function MapWeb() {
);

// Fetches map data and markers: once on mount, and again in the background when the realtime location feed
// asks for it. Only the first successful load may move the camera; a refresh never touches the user's view.
// asks for it or the update hub reports a change. Only the first successful load may move the camera; a refresh
// never touches the user's view.
const fetchAbortRef = useRef<AbortController | null>(null);
const hasCenteredOnDataRef = useRef(false);
const fetchMapData = useCallback(async () => {
Expand Down Expand Up @@ -282,6 +284,10 @@ export default function MapWeb() {
};
}, [fetchMapData]);

// Status and call changes from the update hub restyle pins (their pin images follow the status or priority), so they
// refetch the markers in the background as the native map does; fetchMapData only moves the camera on the first load.
useMapSignalRRefetch(requestPinsRefresh);

// Fetch map data and markers on mount
useEffect(() => {
void fetchMapData();
Expand Down Expand Up @@ -311,9 +317,9 @@ export default function MapWeb() {
// Pins that did not change keep their object identity, so most markers are skipped outright.
if (existing && meta && meta.pin === pin && meta.signature.startsWith(`${theme}:`)) return;

// Everything the marker element or its popup is built from, so a change to any of it (an uploaded icon included)
// rebuilds the marker instead of keeping the old element.
const signature = `${theme}:${pin.Title}:${pin.Type}:${pin.PoiTypeId}:${pin.LayerId}:${pin.ImagePath}:${pin.PoiImage}:${pin.Marker}:${pin.Color}:${pin.Address}:${pin.Note}:${pin.PoiTypeName}:${pin.InfoWindowContent}:${pin.IconUrl ?? ''}`;
// Everything the marker element or its popup is built from, so a change to any of it (an uploaded icon, or the
// status/priority pin after a status change, included) rebuilds the marker instead of keeping the old element.
const signature = getMapMarkerSignature(pin, theme);

if (existing && meta && meta.signature === signature) {
if (meta.pin.Latitude !== pin.Latitude || meta.pin.Longitude !== pin.Longitude) {
Expand Down
9 changes: 7 additions & 2 deletions src/components/common/type-icon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ export interface TypeIconProps {
style?: StyleProp<ImageStyle>;
/** Applied to the box around the rendered image (not the fallback), e.g. spacing that the fallback carries itself. */
containerStyle?: StyleProp<ViewStyle>;
/**
* How the image at `iconUrl` is fitted into the square: "contain" (the default) for an uploaded square icon, "cover" for
* a remote copy of a bundled pin that must sit exactly where the bundled image (drawn with resizeMode "cover") would.
*/
contentFit?: 'contain' | 'cover';
testID?: string;
}

Expand All @@ -26,7 +31,7 @@ export interface TypeIconProps {
* icon the type picked, then `fallback` (nothing by default). An image that fails to load falls through to the next
* option. Decorative: the name and type are always shown as text next to it, so it is hidden from screen readers.
*/
export const TypeIcon: React.FC<TypeIconProps> = React.memo(({ iconUrl, mapIcon, size = 24, fallback = null, style, containerStyle, testID }) => {
export const TypeIcon: React.FC<TypeIconProps> = React.memo(({ iconUrl, mapIcon, size = 24, fallback = null, style, containerStyle, contentFit = 'contain', testID }) => {
const [failedUrl, setFailedUrl] = useState<string | null>(null);
const [failedMapIcon, setFailedMapIcon] = useState<string | null>(null);

Expand All @@ -40,7 +45,7 @@ export const TypeIcon: React.FC<TypeIconProps> = React.memo(({ iconUrl, mapIcon,
<ExpoImage
source={{ uri: url }}
style={StyleSheet.flatten([box, style])}
contentFit="contain"
contentFit={contentFit}
cachePolicy="memory-disk"
recyclingKey={url}
accessible={false}
Expand Down
73 changes: 73 additions & 0 deletions src/components/maps/__tests__/pin-marker.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -112,3 +112,76 @@ describe('PinMarker type icons', () => {
expect(screen.queryByTestId('uploaded-icon', hidden)).toBeNull();
});
});

describe('PinMarker status / priority pins', () => {
const PIN_ICON_URL = 'https://api.resgrid.dev/api/v4/TypeIcons/Pins/engine-responding?v=1';
const UPLOADED_ICON_URL = 'https://api.resgrid.dev/api/v4/TypeIcons/u12.png?s=responding&v=1';
const unit = (patch: Partial<MapMakerInfoData> = {}) => pin({ Id: 'u12', Title: 'Engine 41', Type: 1, ImagePath: 'Engine_Responding', ...patch });
const remoteImages = () => screen.queryAllByTestId('uploaded-icon', hidden);
const bundledImages = () => screen.UNSAFE_queryAllByProps({ fadeDuration: 0 });

it("draws the status pin in the bundled pin's 32px box with the bundled pin's cover fit and no tint", () => {
render(<PinMarker pin={unit({ PinIconUrl: PIN_ICON_URL, IconStyle: 'responding' })} size={32} />);

const [image] = remoteImages();
expect(remoteImages()).toHaveLength(1);
expect(image.props.source).toEqual({ uri: PIN_ICON_URL });
expect(image.props.contentFit).toBe('cover');
expect(image.props.style).toEqual(expect.objectContaining({ width: 32, height: 32 }));
expect(image.props.style.tintColor).toBeUndefined();
expect(image.props.tintColor).toBeUndefined();
expect(bundledImages()).toHaveLength(0);
expect(screen.getByText('Engine 41')).toBeTruthy();
});

it('falls back to the bundled pin when the status pin fails to load', () => {
render(<PinMarker pin={unit({ PinIconUrl: PIN_ICON_URL })} />);

fireEvent(remoteImages()[0], 'error');

expect(remoteImages()).toHaveLength(0);
expect(bundledImages()[0].props.source).toBe(MAP_ICONS.engine_responding.uri);
});

it('prefers the uploaded icon, then the status pin, then the bundled pin as each fails to load', () => {
render(<PinMarker pin={unit({ IconUrl: UPLOADED_ICON_URL, PinIconUrl: PIN_ICON_URL })} />);

expect(remoteImages()).toHaveLength(1);
expect(remoteImages()[0].props.source).toEqual({ uri: UPLOADED_ICON_URL });
expect(remoteImages()[0].props.contentFit).toBe('contain');

fireEvent(remoteImages()[0], 'error');
expect(remoteImages()).toHaveLength(1);
expect(remoteImages()[0].props.source).toEqual({ uri: PIN_ICON_URL });
expect(remoteImages()[0].props.contentFit).toBe('cover');

fireEvent(remoteImages()[0], 'error');
expect(remoteImages()).toHaveLength(0);
expect(bundledImages()[0].props.source).toBe(MAP_ICONS.engine_responding.uri);
});

it('draws the new status pin when the status changes, even after the previous one failed to load', () => {
const view = render(<PinMarker pin={unit({ PinIconUrl: PIN_ICON_URL })} />);
fireEvent(remoteImages()[0], 'error');
expect(remoteImages()).toHaveLength(0);

const ON_SCENE_PIN = 'https://api.resgrid.dev/api/v4/TypeIcons/Pins/engine-onscene?v=1';
view.rerender(<PinMarker pin={unit({ PinIconUrl: ON_SCENE_PIN, IconStyle: 'onscene' })} />);

expect(remoteImages()[0].props.source).toEqual({ uri: ON_SCENE_PIN });
});

it('keeps the bundled pin without a usable status pin', () => {
render(<PinMarker pin={unit({ PinIconUrl: '/api/v4/TypeIcons/Pins/engine-responding' })} />);

expect(remoteImages()).toHaveLength(0);
expect(bundledImages()[0].props.source).toBe(MAP_ICONS.engine_responding.uri);
});

it('never draws a status pin on a POI marker', () => {
render(<PinMarker pin={pin({ Type: 4, PoiTypeId: 5, PoiImage: 'map-icon-hospital', Marker: 'MAP_PIN', PinIconUrl: PIN_ICON_URL })} />);

expect(screen.getByTestId('poi-shape')).toBeTruthy();
expect(remoteImages()).toHaveLength(0);
});
});
54 changes: 51 additions & 3 deletions src/components/maps/__tests__/unified-map-view.web.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import React from 'react';

import { getMapDataAndMarkers } from '@/api/mapping/mapping';
import { LIVE_LOCATION_REFRESH_DELAY_MS } from '@/hooks/use-map-live-locations';
import { MAP_SIGNALR_REFETCH_DELAY_MS } from '@/hooks/use-map-signalr-updates';
import { type MapMakerInfoData } from '@/models/v4/mapping/getMapDataAndMarkersData';
import { useSignalRStore } from '@/stores/signalr/signalr-store';

Expand Down Expand Up @@ -94,14 +95,16 @@ jest.mock('mapbox-gl', () => {

jest.mock('@/stores/signalr/signalr-store', () => {
const { create } = jest.requireActual('zustand');
return { useSignalRStore: create(() => ({ liveLocations: {}, lastGeolocationJoinTimestamp: 0 })) };
return { useSignalRStore: create(() => ({ liveLocations: {}, lastGeolocationJoinTimestamp: 0, lastUpdateTimestamp: 0 })) };
});

jest.mock('@/api/mapping/mapping', () => ({ getMapDataAndMarkers: jest.fn() }));
jest.mock('@/lib/map-markers-web', () => ({
buildMapPinPopupHtml: jest.fn(() => '<div></div>'),
createMapMarkerElement: jest.fn((pin: { Id: string }) => ({ pinId: pin.Id })),
getMapMarkerPopupOffset: jest.fn(() => ({ bottom: [0, -36] })),
// The real signature, so the test sees exactly which pin changes rebuild a marker.
getMapMarkerSignature: jest.requireActual('@/lib/map-markers-web').getMapMarkerSignature,
MAP_MARKER_ANCHOR: 'bottom',
}));
jest.mock('@/lib/map-center', () => ({ getDepartmentMapCenter: () => ({ latitude: 39, longitude: -119, zoomLevel: 9 }) }));
Expand Down Expand Up @@ -132,7 +135,7 @@ describe('UnifiedMapView (web) realtime locations', () => {
mockMaps.length = 0;
mockGetMapDataAndMarkers.mockReset();
mockGetMapDataAndMarkers.mockResolvedValue(mapResponse(REST_PINS));
useSignalRStore.setState({ liveLocations: {}, lastGeolocationJoinTimestamp: 0 });
useSignalRStore.setState({ liveLocations: {}, lastGeolocationJoinTimestamp: 0, lastUpdateTimestamp: 0 });
// Only the stylesheet injection touches document; report it as already present.
(global as any).document = { getElementById: () => ({}) };
});
Expand Down Expand Up @@ -215,6 +218,51 @@ describe('UnifiedMapView (web) realtime locations', () => {
expect(markerAt(38, -118)).toBeDefined();
expect(mockMaps[0].flyTo).toHaveBeenCalledTimes(1);
});

it('refetches after an update hub change and rebuilds only the marker whose status pin changed, without moving the camera', async () => {
const RESPONDING_PIN = 'https://api.resgrid.dev/api/v4/TypeIcons/Pins/engine-responding?v=1';
const { createMapMarkerElement } = jest.requireMock('@/lib/map-markers-web') as { createMapMarkerElement: jest.Mock };
const liveMarker = (pinId: string) => mockMarkers.filter((marker) => marker.pinId === pinId && marker.remove.mock.calls.length === 0);
await renderLoadedMap();
const [unitMarker] = liveMarker('u12');
const [personMarker] = liveMarker('pABC');

// The unit went responding: same pin, same place, only the status pin (and its style) changed.
mockGetMapDataAndMarkers.mockResolvedValue(mapResponse(REST_PINS.map((pin) => (pin.Id === 'u12' ? ({ ...pin, PinIconUrl: RESPONDING_PIN, IconStyle: 'responding' } as MapMakerInfoData) : pin))));
act(() => {
useSignalRStore.setState({ lastUpdateTimestamp: 1000 });
});
act(() => {
useSignalRStore.setState({ lastUpdateTimestamp: 1001 });
});
expect(mockGetMapDataAndMarkers).toHaveBeenCalledTimes(1);

await act(async () => {
jest.advanceTimersByTime(MAP_SIGNALR_REFETCH_DELAY_MS);
});
await waitFor(() => expect(unitMarker.remove).toHaveBeenCalled());

// One refetch for the burst of hub events.
expect(mockGetMapDataAndMarkers).toHaveBeenCalledTimes(2);
expect(createMapMarkerElement).toHaveBeenLastCalledWith(expect.objectContaining({ Id: 'u12', PinIconUrl: RESPONDING_PIN, IconStyle: 'responding' }), 'light', expect.any(Function));
expect(liveMarker('u12')).toHaveLength(1);
expect(liveMarker('pABC')).toEqual([personMarker]);
expect(mockMaps[0].flyTo).toHaveBeenCalledTimes(1);
});

it('leaves the refresh of pins passed in by a parent to the parent', async () => {
render(<UnifiedMapView pins={REST_PINS} />, { createNodeMock: () => ({}) });
act(() => mockMaps[0].handlers.load());

act(() => {
useSignalRStore.setState({ lastUpdateTimestamp: 2000 });
});
await act(async () => {
jest.advanceTimersByTime(MAP_SIGNALR_REFETCH_DELAY_MS * 2);
});

expect(mockGetMapDataAndMarkers).not.toHaveBeenCalled();
});
});

describe('UnifiedMapView (web) selected unit focus', () => {
Expand All @@ -230,7 +278,7 @@ describe('UnifiedMapView (web) selected unit focus', () => {
Object.assign(mockVisibleBox, { west: -180, south: -90, east: 180, north: 90 });
mockGetMapDataAndMarkers.mockReset();
mockGetMapDataAndMarkers.mockResolvedValue(mapResponse(FOCUS_PINS));
useSignalRStore.setState({ liveLocations: {}, lastGeolocationJoinTimestamp: 0 });
useSignalRStore.setState({ liveLocations: {}, lastGeolocationJoinTimestamp: 0, lastUpdateTimestamp: 0 });
(global as any).document = { getElementById: () => ({}) };
});

Expand Down
7 changes: 4 additions & 3 deletions src/components/maps/dispatch-map-legend.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@ import { StyleSheet, Text, View } from 'react-native';
import { BOUNDARY_COLOR, type DispatchMapOverlay, PERSON_MARKER_COLOR, UNIT_MARKER_COLOR } from '@/lib/dispatch-board';

/**
* Key for the dispatch overlay on the location picker: units are the larger circles, personnel the smaller ones (each
* filled with its status colour), boundaries are the groups' geofences. Plain RN styles so it renders the same over the
* native and web maps.
* Key for the dispatch overlay on the location picker: available units are the larger circles, available personnel the
* smaller ones (each filled with its status colour; the overlay leaves unavailable resources off, so the counts are of
* who can be sent), boundaries are the groups' geofences. Plain RN styles so it renders the same over the native and web
* maps.
*/
export const DispatchMapLegend: React.FC<{ overlay: DispatchMapOverlay }> = ({ overlay }) => {
const { t } = useTranslation();
Expand Down
1 change: 0 additions & 1 deletion src/components/maps/location-picker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,6 @@ const LocationPicker: React.FC<LocationPickerProps> = ({ initialLocation, onLoca
circleColor: ['get', 'color'],
circleStrokeColor: '#ffffff',
circleStrokeWidth: ['case', ['==', ['get', 'kind'], 'unit'], 2.5, 1.5],
circleOpacity: ['case', ['get', 'available'], 1, 0.5],
}}
/>
<Mapbox.SymbolLayer
Expand Down
Loading
Loading