diff --git a/apps/meteor/app/api/server/index.ts b/apps/meteor/app/api/server/index.ts index 971c0476ad5b0..c73e8d412a20a 100644 --- a/apps/meteor/app/api/server/index.ts +++ b/apps/meteor/app/api/server/index.ts @@ -21,6 +21,7 @@ import './v1/integrations'; import './v1/invites'; import './v1/import'; import './v1/ldap'; +import './v1/liveLocation'; import './v1/misc'; import './v1/permissions'; import './v1/presence'; diff --git a/apps/meteor/app/api/server/v1/liveLocation.ts b/apps/meteor/app/api/server/v1/liveLocation.ts new file mode 100644 index 0000000000000..2ae54c7c81ce9 --- /dev/null +++ b/apps/meteor/app/api/server/v1/liveLocation.ts @@ -0,0 +1,424 @@ +import type { IRoom, IUser, MessageAttachment } from '@rocket.chat/core-typings'; +import { Rooms, Subscriptions, Messages } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; + +import { canAccessRoomIdAsync } from '../../../authorization/server/functions/canAccessRoom'; +import { notifyOnMessageChange } from '../../../lib/server/lib/notifyListener'; +import { Notifications } from '../../../notifications/server'; +import { API } from '../api'; + +const MIN_INTERVAL_MS = 3000; +const MAX_DURATION_SEC = 3600; +const DEFAULT_DURATION_SEC = 3600; + +const isValidCoords = (c?: { lat: number; lon: number }) => + c && + typeof c.lat === 'number' && + Number.isFinite(c.lat) && + c.lat >= -90 && + c.lat <= 90 && + typeof c.lon === 'number' && + Number.isFinite(c.lon) && + c.lon >= -180 && + c.lon <= 180; + +interface IAPIRouteContext { + bodyParams: { + rid?: string; + msgId?: string; + durationSec?: number; + initial?: { lat: number; lon: number }; + coords?: { lat: number; lon: number }; + finalCoords?: { lat: number; lon: number }; + }; + queryParams: { + rid?: string; + msgId?: string; + }; + userId: string; +} + +interface IUserWithName extends IUser { + name?: string; +} + +interface ILiveLocationAttachment { + type: 'live-location'; + live: { + isActive: boolean; + ownerId: string; + startedAt: Date; + lastUpdateAt: Date; + expiresAt?: Date; + stoppedAt?: Date; + coords?: { lat: number; lon: number }; + version: number; + }; +} + +API.v1.addRoute( + 'liveLocation.start', + { authRequired: true }, + { + async post(this: IAPIRouteContext) { + const { rid, durationSec, initial } = this.bodyParams; + + if (!rid || typeof rid !== 'string') { + return API.v1.failure('The required "rid" param is missing or invalid.'); + } + + if (durationSec !== undefined) { + if (typeof durationSec !== 'number' || !Number.isFinite(durationSec) || durationSec <= 0 || durationSec > MAX_DURATION_SEC) { + return API.v1.failure(`"durationSec" must be a positive number ≤ ${MAX_DURATION_SEC}.`); + } + } + + if (initial !== undefined && !isValidCoords(initial)) { + return API.v1.failure('Invalid "initial" coordinates.'); + } + + const uid = this.userId; + if (!uid) { + return API.v1.failure('User not authenticated'); + } + + const room = await Rooms.findOneById(rid); + if (!room) { + return API.v1.failure('Room not found'); + } + + const sub = await Subscriptions.findOneByRoomIdAndUserId(rid, uid); + if (!sub) { + return API.v1.failure('User is not in the room'); + } + + if (!(await canAccessRoomIdAsync(rid, uid))) { + return API.v1.failure('Not allowed'); + } + + const existing = await Messages.findOne({ + rid, + 'u._id': uid, + 'attachments': { + $elemMatch: { + 'type': 'live-location', + 'live.isActive': true, + }, + }, + }); + + if (existing) { + return API.v1.success({ msgId: existing._id }); + } + + const now = new Date(); + const effectiveDuration = + typeof durationSec === 'number' && Number.isFinite(durationSec) + ? Math.min(Math.max(1, Math.floor(durationSec)), MAX_DURATION_SEC) + : DEFAULT_DURATION_SEC; + const expiresAt = new Date(now.getTime() + effectiveDuration * 1000); + const user = await Meteor.users.findOneAsync( + { _id: uid }, + { + projection: { username: 1, name: 1 }, + }, + ); + + if (!user) { + return API.v1.failure('User not found'); + } + + const msg = { + rid, + ts: now, + msg: '', + u: { + _id: uid, + username: user.username || '', + name: (user as IUserWithName).name || user.username || '', + }, + attachments: [ + { + type: 'live-location', + live: { + isActive: true, + ownerId: uid, + startedAt: now, + lastUpdateAt: now, + expiresAt, + coords: initial || undefined, + version: 1, + }, + } as MessageAttachment, + ], + }; + + try { + const result = await Messages.insertOne(msg); + + const createdMsg = await Messages.findOneById(result.insertedId); + if (createdMsg) { + void notifyOnMessageChange({ + id: createdMsg._id, + data: createdMsg, + }); + } + + return API.v1.success({ msgId: result.insertedId }); + } catch (e: any) { + if (e?.code === 11000) { + const alreadyActive = await Messages.findOne({ + rid, + 'u._id': uid, + 'attachments': { + $elemMatch: { + 'type': 'live-location', + 'live.isActive': true, + }, + }, + }); + if (alreadyActive) { + return API.v1.success({ msgId: alreadyActive._id }); + } + } + return API.v1.failure('Failed to create live location message'); + } + }, + }, +); + +API.v1.addRoute( + 'liveLocation.update', + { authRequired: true }, + { + async post(this: IAPIRouteContext) { + const { rid, msgId, coords } = this.bodyParams; + + if (!rid || typeof rid !== 'string') { + return API.v1.failure('The required "rid" param is missing or invalid.'); + } + + if (!msgId || typeof msgId !== 'string') { + return API.v1.failure('The required "msgId" param is missing or invalid.'); + } + + if (!coords || !isValidCoords(coords)) { + return API.v1.failure('The required "coords" param is missing or invalid.'); + } + + const uid = this.userId; + if (!uid) { + return API.v1.failure('User not authenticated'); + } + + if (!(await canAccessRoomIdAsync(rid, uid))) { + return API.v1.failure('Not allowed'); + } + + const sub = await Subscriptions.findOneByRoomIdAndUserId(rid, uid); + if (!sub) { + return API.v1.failure('User is not in the room'); + } + + const msg = await Messages.findOne({ + '_id': msgId, + rid, + 'u._id': uid, + 'attachments': { + $elemMatch: { + 'type': 'live-location', + 'live.isActive': true, + }, + }, + }); + + if (!msg) { + return API.v1.failure('Active live location not found'); + } + + const last: Date | undefined = (msg.attachments?.[0] as ILiveLocationAttachment)?.live?.lastUpdateAt; + const now = new Date(); + if (last && now.getTime() - new Date(last).getTime() < MIN_INTERVAL_MS) { + return API.v1.success({ ignored: true, reason: 'too-soon' }); + } + + const updateTime = new Date(); + const res = await Messages.updateOne( + { '_id': msgId, rid, 'u._id': uid }, + { + $set: { + 'attachments.$[liveAtt].live.coords': coords, + 'attachments.$[liveAtt].live.lastUpdateAt': updateTime, + }, + }, + { + arrayFilters: [ + { + 'liveAtt.type': 'live-location', + 'liveAtt.live.isActive': true, + }, + ], + } as any, + ); + + if (res.modifiedCount > 0) { + const updatedMsg = await Messages.findOneById(msgId); + if (updatedMsg) { + void notifyOnMessageChange({ + id: updatedMsg._id, + data: updatedMsg, + }); + } + } + + return API.v1.success({ updated: Boolean(res.modifiedCount) }); + }, + }, +); + +API.v1.addRoute( + 'liveLocation.stop', + { authRequired: true }, + { + async post(this: any) { + const { rid, msgId, finalCoords } = this.bodyParams; + + if (!rid || typeof rid !== 'string') { + return API.v1.failure('The required "rid" param is missing or invalid.'); + } + + if (!msgId || typeof msgId !== 'string') { + return API.v1.failure('The required "msgId" param is missing or invalid.'); + } + + const uid = this.userId; + if (!uid) { + return API.v1.failure('User not authenticated'); + } + + if (finalCoords !== undefined && !isValidCoords(finalCoords)) { + return API.v1.failure('Invalid "finalCoords" coordinates.'); + } + + if (!(await canAccessRoomIdAsync(rid, uid))) { + return API.v1.failure('Not allowed'); + } + + const sub = await Subscriptions.findOneByRoomIdAndUserId(rid, uid); + if (!sub) { + return API.v1.failure('User is not in the room'); + } + + const selector = { + '_id': msgId, + rid, + 'u._id': uid, + }; + + const $set: Record = { + 'attachments.$[liveAtt].live.isActive': false, + 'attachments.$[liveAtt].live.stoppedAt': new Date(), + }; + + if (finalCoords !== undefined) { + $set['attachments.$[liveAtt].live.coords'] = finalCoords; + } + + const res = await Messages.updateOne(selector, { $set }, { + arrayFilters: [ + { + 'liveAtt.type': 'live-location', + 'liveAtt.live.isActive': true, + }, + ], + } as any); + + const success = Boolean(res.modifiedCount); + + if (success) { + const updatedMsg = await Messages.findOneById(msgId); + if (updatedMsg) { + void notifyOnMessageChange({ + id: updatedMsg._id, + data: updatedMsg, + }); + + Notifications.streamRoom.emit(`${rid}/live-location-ended`, { + msgId, + ownerId: uid, + stoppedAt: new Date(), + }); + } + } + + return API.v1.success({ stopped: success }); + }, + }, +); + +API.v1.addRoute( + 'liveLocation.get', + { authRequired: true }, + { + async get(this: IAPIRouteContext) { + const { rid, msgId } = this.queryParams; + + if (!rid || typeof rid !== 'string') { + return API.v1.failure('The required "rid" param is missing or invalid.'); + } + + if (!msgId || typeof msgId !== 'string') { + return API.v1.failure('The required "msgId" param is missing or invalid.'); + } + + const uid = this.userId; + if (!uid) { + return API.v1.failure('User not authenticated'); + } + + if (!(await canAccessRoomIdAsync(rid, uid))) { + return API.v1.failure('Not allowed'); + } + + const sub = await Subscriptions.findOneByRoomIdAndUserId(rid, uid); + if (!sub) { + return API.v1.failure('User is not in the room'); + } + + const msg = await Messages.findOne({ + _id: msgId, + rid, + attachments: { + $elemMatch: { + type: 'live-location', + }, + }, + }); + + if (!msg) { + return API.v1.failure('Live location not found'); + } + + const attachment = msg.attachments?.find( + (att: unknown): att is ILiveLocationAttachment => (att as ILiveLocationAttachment)?.type === 'live-location', + ) as ILiveLocationAttachment; + if (!attachment) { + return API.v1.failure('Live location attachment not found'); + } + + return API.v1.success({ + messageId: msg._id, + ownerId: attachment.live?.ownerId, + ownerUsername: msg.u?.username, + ownerName: (msg.u as { name?: string; username?: string })?.name || msg.u?.username, + isActive: attachment.live?.isActive || false, + startedAt: attachment.live?.startedAt ? new Date(attachment.live.startedAt) : undefined, + lastUpdateAt: attachment.live?.lastUpdateAt ? new Date(attachment.live.lastUpdateAt) : undefined, + stoppedAt: attachment.live?.stoppedAt ? new Date(attachment.live.stoppedAt) : undefined, + coords: attachment.live?.coords, + expiresAt: attachment.live?.expiresAt ? new Date(attachment.live.expiresAt) : undefined, + version: attachment.live?.version || 1, + }); + }, + }, +); diff --git a/apps/meteor/app/live-location/server/index.ts b/apps/meteor/app/live-location/server/index.ts new file mode 100644 index 0000000000000..8e0459e438d5a --- /dev/null +++ b/apps/meteor/app/live-location/server/index.ts @@ -0,0 +1 @@ +import './startup/live-location'; diff --git a/apps/meteor/app/live-location/server/startup/live-location.ts b/apps/meteor/app/live-location/server/startup/live-location.ts new file mode 100644 index 0000000000000..a3cb74fec6f99 --- /dev/null +++ b/apps/meteor/app/live-location/server/startup/live-location.ts @@ -0,0 +1,113 @@ +// liveLocationCleanup.ts +import type { IMessage } from '@rocket.chat/core-typings'; +import { Messages } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; +import type { Filter, UpdateFilter, UpdateOptions } from 'mongodb'; + +import { notifyOnMessageChange } from '../../../lib/server/lib/notifyListener'; + +const CLEANUP_INTERVAL_MS = 60_000; +const INACTIVE_GRACE_MS = 30_000; + +async function broadcastMessageUpdate(messageId: string) { + const msg = await Messages.findOneById(messageId, { + projection: { rid: 1, attachments: 1, msg: 1, u: 1, _id: 1, ts: 1, editedAt: 1 }, + }); + if (!msg) return; + + // Broadcast message changes via centralized notifier + try { + await notifyOnMessageChange({ id: msg._id, data: msg }); + } catch (e) { + console.error('[LiveLocationCleanup] notifyOnMessageChange failed:', e); + } +} + +Meteor.startup(async () => { + let isCleanupRunning = false; + + Meteor.setInterval(async () => { + if (isCleanupRunning) return; + isCleanupRunning = true; + + const startedAt = Date.now(); + try { + const now = new Date(); + const staleBefore = new Date(now.getTime() - INACTIVE_GRACE_MS); + + // Find messages that have ANY live-location attachment that is active & stale/expired + const findFilter: Filter = { + attachments: { + $elemMatch: { + 'type': 'live-location', + 'live.isActive': true, + '$or': [{ 'live.lastUpdateAt': { $lt: staleBefore } }, { 'live.expiresAt': { $lte: now } }], + }, + }, + }; + + // Capture IDs for broadcasting + let ids: string[] = []; + try { + const docs = await Messages.find(findFilter, { projection: { _id: 1 } }).toArray(); + ids = docs.map((d) => d._id); + } catch (e) { + console.error('[LiveLocationCleanup] find() failed:', e); + return; + } + + if (!ids.length) { + return; + } + + const update: UpdateFilter = { + $set: { + 'attachments.$[liveAtt].live.isActive': false, + 'attachments.$[liveAtt].live.stoppedAt': now, + }, + }; + + const options: UpdateOptions = { + arrayFilters: [ + { + 'liveAtt.type': 'live-location', + 'liveAtt.live.isActive': true, + }, + ], + }; + + let matched = 'unknown'; + let modified = 'unknown'; + try { + const res = await Messages.updateMany({ _id: { $in: ids } }, update, options as any); + matched = (res as any)?.matchedCount ?? (res as any)?.nMatched ?? 'unknown'; + modified = (res as any)?.modifiedCount ?? (res as any)?.nModified ?? 'unknown'; + console.log(`[LiveLocationCleanup] cleaned ${modified}/${matched} (ids=${ids.length})`); + } catch (e) { + console.error('[LiveLocationCleanup] updateMany() failed:', e, 'ids:', ids); + return; + } + + try { + const results = await Promise.allSettled(ids.map((id) => broadcastMessageUpdate(id))); + const rejected = results.filter((r) => r.status === 'rejected').length; + if (rejected) { + console.error(`[LiveLocationCleanup] broadcastMessageUpdate(): ${rejected} failures of ${results.length}`); + } + } catch (e) { + console.error('[LiveLocationCleanup] broadcast phase threw unexpectedly:', e); + } + } catch (e) { + console.error('[LiveLocationCleanup] Uncaught error in interval:', e); + } finally { + const elapsed = Date.now() - startedAt; + console.log(`[LiveLocationCleanup] cycle finished in ${elapsed} ms`); + isCleanupRunning = false; + } + }, CLEANUP_INTERVAL_MS); +}); + +export const LiveLocationStartup = { + CLEANUP_INTERVAL_MS, + INACTIVE_GRACE_MS, +}; diff --git a/apps/meteor/client/startup/startup.ts b/apps/meteor/client/startup/startup.ts index 652b85045f19a..b432c12ab24c5 100644 --- a/apps/meteor/client/startup/startup.ts +++ b/apps/meteor/client/startup/startup.ts @@ -4,6 +4,7 @@ import { Tracker } from 'meteor/tracker'; import moment from 'moment'; import 'highlight.js/styles/github.css'; +import 'maplibre-gl/dist/maplibre-gl.css'; import { sdk } from '../../app/utils/client/lib/SDKClient'; import { synchronizeUserData, removeLocalUserData } from '../lib/userData'; import { fireGlobalEvent } from '../lib/utils/fireGlobalEvent'; diff --git a/apps/meteor/client/views/room/ShareLocation/MapLibreMap.tsx b/apps/meteor/client/views/room/ShareLocation/MapLibreMap.tsx new file mode 100644 index 0000000000000..2a4e9e9dad26f --- /dev/null +++ b/apps/meteor/client/views/room/ShareLocation/MapLibreMap.tsx @@ -0,0 +1,168 @@ +// MapLibreMap.tsx +import type { FeatureCollection, LineString } from 'geojson'; +import type { Map as MLMap } from 'maplibre-gl'; +import maplibregl from 'maplibre-gl'; +import { useEffect, useRef } from 'react'; + +type Props = { + lat: number; + lon: number; + zoom?: number; + height?: number | string; + liveCoords?: { lon: number; lat: number } | null; + visible?: boolean; +}; + +export default function MapLibreMap({ lat, lon, zoom = 15, height = 360, liveCoords, visible = true }: Props) { + const containerRef = useRef(null); + const mapRef = useRef(null); + const markerRef = useRef(null); + const trailRef = useRef(null); + const trailCoordsRef = useRef<[number, number][]>([]); + const resizeObsRef = useRef(null); + + // init map + useEffect(() => { + if (!containerRef.current || mapRef.current) return; + + const style: maplibregl.StyleSpecification = { + version: 8, + sources: { + osm: { + type: 'raster', + tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'], + tileSize: 256, + attribution: '© OpenStreetMap contributors', + }, + }, + layers: [{ id: 'osm', type: 'raster', source: 'osm' }], + }; + + const map = new maplibregl.Map({ + container: containerRef.current, + style, + center: [lon, lat], + zoom, + }); + map.addControl(new maplibregl.NavigationControl({ showCompass: false }), 'top-right'); + mapRef.current = map; + + markerRef.current = new maplibregl.Marker({ color: '#1976d2' }).setLngLat([lon, lat]).addTo(map); + + map.on('error', (e) => { + console.error('MapLibre error:', e); + }); + + map.on('load', () => { + map.addSource('trail', { + type: 'geojson', + data: { type: 'FeatureCollection', features: [] }, + }); + map.addLayer({ + id: 'trail-line', + type: 'line', + source: 'trail', + paint: { 'line-width': 4, 'line-color': '#1976d2' }, + }); + trailRef.current = map.getSource('trail') as maplibregl.GeoJSONSource; + }); + + requestAnimationFrame(() => map.resize()); + + const onWinResize = () => map.resize(); + window.addEventListener('resize', onWinResize); + + if ('ResizeObserver' in window) { + resizeObsRef.current = new ResizeObserver(() => map.resize()); + resizeObsRef.current.observe(containerRef.current); + } + + return () => { + resizeObsRef.current?.disconnect(); + window.removeEventListener('resize', onWinResize); + map.remove(); + mapRef.current = null; + markerRef.current = null; + trailRef.current = null; + trailCoordsRef.current = []; + }; + }, []); + useEffect(() => { + const map = mapRef.current; + if (!map) return; + map.setCenter([lon, lat]); + markerRef.current?.setLngLat([lon, lat]); + }, [lat, lon]); + + useEffect(() => { + const map = mapRef.current; + if (!map || !visible) return; + + let r1 = 0; + let r2 = 0; + + // wait for visibility/layout to settle, then resize + r1 = requestAnimationFrame(() => { + r2 = requestAnimationFrame(() => { + map.resize(); + }); + }); + + return () => { + if (r1) cancelAnimationFrame(r1); + if (r2) cancelAnimationFrame(r2); + }; + }, [visible]); + + useEffect(() => { + if (!liveCoords || !mapRef.current || !markerRef.current) return; + const { lon: LON, lat: LAT } = liveCoords; + + markerRef.current.setLngLat([LON, LAT]); + const maxTrailLength = 1000; + trailCoordsRef.current.push([LON, LAT]); + if (trailCoordsRef.current.length > maxTrailLength) { + trailCoordsRef.current.shift(); + } + + if (trailRef.current) { + const data: FeatureCollection = { + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + properties: {}, + geometry: { type: 'LineString', coordinates: trailCoordsRef.current }, + }, + ], + }; + trailRef.current.setData(data); + } + }, [liveCoords]); + + return ( +
+
+
+ © OpenStreetMap contributors +
+
+ ); +} diff --git a/apps/meteor/client/views/room/ShareLocation/MapView.spec.tsx b/apps/meteor/client/views/room/ShareLocation/MapView.spec.tsx new file mode 100644 index 0000000000000..a4ed9fb2a4074 --- /dev/null +++ b/apps/meteor/client/views/room/ShareLocation/MapView.spec.tsx @@ -0,0 +1,63 @@ +import { render, screen, fireEvent } from '@testing-library/react'; + +import MapView from './MapView'; + +jest.mock('@rocket.chat/ui-contexts', () => ({ + useTranslation: () => (key: string) => key, +})); + +describe('MapView', () => { + const lat = 1.234567; + const lon = 2.345678; + + test('renders fallback placeholder when no mapInstance', () => { + render(); + + // Uses literal aria-label/title strings in component + const fallback = screen.getByLabelText('Map preview unavailable'); + expect(fallback).toBeInTheDocument(); + // Coordinates shown with 5 decimals + expect(screen.getByText(/1\.23457, 2\.34568/)).toBeInTheDocument(); + // No image should be rendered + expect(screen.queryByRole('img')).not.toBeInTheDocument(); + }); + + test('renders static map image and attribution when mapInstance provided', () => { + const mockSrc = 'https://static.example/map.png'; + const mapInstance = { + getStaticMapUrl: jest.fn(() => mockSrc), + } as any; + + render(); + + const img = screen.getByRole('img'); + expect(img).toHaveAttribute('src', mockSrc); + expect(img).toHaveAttribute('alt', 'Map_Preview_Alt'); + // Attribution string is translated key in tests + expect(screen.getByText('OSM_Attribution')).toBeInTheDocument(); + }); + + test('hides attribution when showAttribution is false', () => { + const mapInstance = { + getStaticMapUrl: jest.fn(() => 'https://static.example/map.png'), + } as any; + + render(); + + expect(screen.queryByText('OSM_Attribution')).not.toBeInTheDocument(); + }); + + test('falls back to placeholder when image fails to load', () => { + const mapInstance = { + getStaticMapUrl: jest.fn(() => 'https://static.example/map.png'), + } as any; + + render(); + + const img = screen.getByRole('img'); + fireEvent.error(img); + + // After error, placeholder should appear + expect(screen.getByLabelText('Map preview unavailable')).toBeInTheDocument(); + }); +}); diff --git a/apps/meteor/client/views/room/ShareLocation/MapView.tsx b/apps/meteor/client/views/room/ShareLocation/MapView.tsx new file mode 100644 index 0000000000000..297530f2e20af --- /dev/null +++ b/apps/meteor/client/views/room/ShareLocation/MapView.tsx @@ -0,0 +1,92 @@ +// MapView.tsx +import { useTranslation } from '@rocket.chat/ui-contexts'; +import React, { useMemo, useState } from 'react'; + +import type { IMapProvider, MapProviderName } from './mapProvider'; + +export type MapViewProps = { + latitude: number; + longitude: number; + zoom?: number; + width?: number; + height?: number; + provider?: MapProviderName; + mapInstance?: IMapProvider; + showAttribution?: boolean; +}; + +const MapView: React.FC = ({ + latitude, + longitude, + zoom = 15, + width = 512, + height = 320, + mapInstance, + showAttribution = true, +}) => { + const [errored, setErrored] = useState(false); + const t = useTranslation(); + + const src = useMemo(() => { + if (!mapInstance) return ''; + return mapInstance.getStaticMapUrl(latitude, longitude, { zoom, width, height }); + }, [mapInstance, latitude, longitude, zoom, width, height]); + + if (!mapInstance || !src || errored) { + // Fallback placeholder + return ( +
+ {t('Map_Preview_Unavailable')} +
+ {latitude.toFixed(5)}, {longitude.toFixed(5)} +
+
+ ); + } + + return ( +
+ {t('Map_Preview_Alt')} setErrored(true)} + loading='lazy' + referrerPolicy='no-referrer' + /> + {showAttribution && ( +
+ {t('OSM_Attribution')} +
+ )} +
+ ); +}; + +export default MapView; diff --git a/apps/meteor/client/views/room/ShareLocation/ShareLocationModal.spec.tsx b/apps/meteor/client/views/room/ShareLocation/ShareLocationModal.spec.tsx new file mode 100644 index 0000000000000..f14483c579fb6 --- /dev/null +++ b/apps/meteor/client/views/room/ShareLocation/ShareLocationModal.spec.tsx @@ -0,0 +1,139 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import React from 'react'; + +import ShareLocationModal from './ShareLocationModal'; +import { getGeolocationPermission } from './getGeolocationPermission'; +import { getGeolocationPosition } from './getGeolocationPosition'; + +// Spy to capture messages sent via endpoint +const sendMessageSpy = jest.fn().mockResolvedValue({}); + +jest.mock('@rocket.chat/ui-contexts', () => ({ + useTranslation: () => (key: string) => key, + useEndpoint: () => sendMessageSpy, + useToastMessageDispatch: () => jest.fn(), +})); + +// Simplify modal rendering for tests +jest.mock('@rocket.chat/ui-client', () => ({ + GenericModal: ({ title, confirmText, cancelText, onConfirm, onCancel, onClose, children }: any) => ( +
+
{title}
+
{children}
+ {cancelText ? ( + + ) : null} + {confirmText ? ( + + ) : null} + {onClose ? ( + + ) : null} +
+ ), +})); + +// Map preview component is irrelevant to logic; mock it out +jest.mock('./MapLibreMap', () => () =>
); + +// Mock map provider so link generation is deterministic +jest.mock('./mapProvider', () => ({ + createMapProvider: jest.fn(() => ({ + getMapsLink: (lat: number, lon: number) => `https://maps.example/?lat=${lat}&lon=${lon}`, + })), +})); + +jest.mock('./getGeolocationPermission', () => ({ + getGeolocationPermission: jest.fn(), +})); + +jest.mock('./getGeolocationPosition', () => ({ + getGeolocationPosition: jest.fn(), +})); + +const renderWithClient = (ui: React.ReactElement) => { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return render({ui}); +}; + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('ShareLocationModal', () => { + test('initial choose stage and live-location disabled flow', () => { + renderWithClient(); + + // Choose modal shows options + expect(screen.getByText('Current_Location')).toBeInTheDocument(); + expect(screen.getByText('Live_Location')).toBeInTheDocument(); + + // Click Live_Location (cancel) -> shows disabled placeholder modal + fireEvent.click(screen.getByText('Live_Location')); + expect(screen.getByText('Live_Location_Disabled_Body')).toBeInTheDocument(); + + // Close returns to choose stage + fireEvent.click(screen.getByText('Ok')); + expect(screen.getByText('Current_Location')).toBeInTheDocument(); + }); + + test('static flow: requests permission/position, shows preview, and sends message on Share', async () => { + (getGeolocationPermission as jest.Mock).mockResolvedValue('prompt'); + (getGeolocationPosition as jest.Mock).mockResolvedValue({ + coords: { latitude: 10.1234, longitude: 20.5678 }, + }); + + const onClose = jest.fn(); + renderWithClient(); + + // Go to static flow + fireEvent.click(screen.getByText('Current_Location')); + + // Permission gating modal + expect(screen.getByText('You_will_be_asked_for_permissions')).toBeInTheDocument(); + + // Continue triggers position fetch and sets permission to granted + fireEvent.click(screen.getByText('Continue')); + + // After data arrives, preview modal with map and Share button should appear + await screen.findByTestId('maplibre'); + expect(screen.getByText('Share')).toBeInTheDocument(); + + // Share -> sends message and closes + fireEvent.click(screen.getByText('Share')); + + await waitFor(() => expect(sendMessageSpy).toHaveBeenCalled()); + const payload = sendMessageSpy.mock.calls[0][0]; + expect(payload).toMatchObject({ message: expect.objectContaining({ rid: 'RID999', tmid: 'TID42' }) }); + // Contains link and formatted coordinates + expect(payload.message.msg).toContain('View on OpenStreetMap'); + expect(payload.message.msg).toContain('10.1234°'); + expect(payload.message.msg).toContain('20.5678°'); + + await waitFor(() => expect(onClose).toHaveBeenCalled()); + }); + + test('static flow: permission denied shows error modal and closes on Ok', async () => { + (getGeolocationPermission as jest.Mock).mockResolvedValue('denied'); + const onClose = jest.fn(); + renderWithClient(); + + // Move to static stage + fireEvent.click(screen.getByText('Current_Location')); + + // Denied modal + await screen.findByText('Cannot_share_your_location'); + fireEvent.click(screen.getByText('Ok')); + + expect(onClose).toHaveBeenCalled(); + }); +}); diff --git a/apps/meteor/client/views/room/ShareLocation/ShareLocationModal.tsx b/apps/meteor/client/views/room/ShareLocation/ShareLocationModal.tsx index 38ee2aab125c7..5dbf1cb6b3c55 100644 --- a/apps/meteor/client/views/room/ShareLocation/ShareLocationModal.tsx +++ b/apps/meteor/client/views/room/ShareLocation/ShareLocationModal.tsx @@ -1,98 +1,223 @@ +// ShareLocationModal.tsx — Static only; Live option kept (no-op) import type { IMessage, IRoom } from '@rocket.chat/core-typings'; import { GenericModal } from '@rocket.chat/ui-client'; import { useEndpoint, useTranslation, useToastMessageDispatch } from '@rocket.chat/ui-contexts'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import type { ReactElement } from 'react'; +import { useMemo, useState } from 'react'; +import MapLibreMap from './MapLibreMap'; import { getGeolocationPermission } from './getGeolocationPermission'; import { getGeolocationPosition } from './getGeolocationPosition'; -import MapView from '../../../components/message/content/location/MapView'; +import { createMapProvider, type IMapProvider } from './mapProvider'; + +interface IGeolocationError { + code?: number; + message?: string; +} type ShareLocationModalProps = { rid: IRoom['_id']; - tmid: IMessage['tmid']; + tmid?: IMessage['tmid']; onClose: () => void; }; +type Stage = 'choose' | 'static'; + const ShareLocationModal = ({ rid, tmid, onClose }: ShareLocationModalProps): ReactElement => { const t = useTranslation(); const dispatchToast = useToastMessageDispatch(); + const queryClient = useQueryClient(); + const sendMessage = useEndpoint('POST', '/v1/chat.sendMessage'); + + const [stage, setStage] = useState('choose'); + const [choice, setChoice] = useState<'current' | 'live' | null>(null); + + const map: IMapProvider = useMemo(() => createMapProvider('openstreetmap', {}), []); + const { data: permissionState, isLoading: permissionLoading } = useQuery({ queryKey: ['geolocationPermission'], queryFn: getGeolocationPermission, + refetchOnWindowFocus: false, }); - const { data: positionData } = useQuery({ - queryKey: ['geolocationPosition', permissionState], - queryFn: async () => { - if (permissionLoading || permissionState === 'prompt' || permissionState === 'denied') { - return; - } - return getGeolocationPosition(); + const { + data: positionData, + isLoading: positionLoading, + isFetching: positionFetching, + isError: positionError, + error: positionErr, + } = useQuery({ + queryKey: ['geolocationPosition'], + queryFn: () => getGeolocationPosition(), + enabled: stage === 'static' && permissionState === 'granted', + refetchOnWindowFocus: false, + retry: (failureCount, error) => { + const e = error as IGeolocationError; + const code = e?.code; + const msg = String(e?.message || '').toLowerCase(); + const transient = code !== 1 && (code === 2 || msg.includes('kclerrorlocationunknown') || msg.includes('location unknown')); + return transient && failureCount < 1; }, + retryDelay: 1500, }); - const queryClient = useQueryClient(); - - const sendMessage = useEndpoint('POST', '/v1/chat.sendMessage'); - - const onConfirm = (): void => { - if (!positionData) { - throw new Error('Failed to load position'); - } - try { - sendMessage({ - message: { - rid, - tmid, - location: { - type: 'Point', - coordinates: [positionData.coords.longitude, positionData.coords.latitude], - }, - }, - }); - } catch (error) { - dispatchToast({ type: 'error', message: error }); - } finally { - onClose(); - } - }; - const onConfirmRequestLocation = async (): Promise => { try { - const position = await getGeolocationPosition(); - queryClient.setQueryData(['geolocationPosition', 'granted'], position); + const pos = await getGeolocationPosition(); queryClient.setQueryData(['geolocationPermission'], 'granted'); - } catch (e) { - queryClient.setQueryData(['geolocationPermission'], () => getGeolocationPermission); + queryClient.setQueryData(['geolocationPosition'], pos); + } catch { + const state = await getGeolocationPermission(); + queryClient.setQueryData(['geolocationPermission'], state); } }; - if (permissionLoading || permissionState === 'prompt') { + // --- Stage 2: Choose static vs live --- + if (stage === 'choose' && !choice) { return ( + cancelText={t('Live_Location')} + onCancel={() => { + setChoice('live'); + }} + confirmText={t('Current_Location')} + onConfirm={() => { + setChoice('current'); + setStage('static'); + }} + > + {t('Share_Location_Choice_Description')} + ); } - if (permissionState === 'denied' || !positionData) { + // Live path (disabled placeholder) + if (choice === 'live') { return ( - - {t('The_necessary_browser_permissions_for_location_sharing_are_not_granted')} + { + setChoice(null); + setStage('choose'); + }} + confirmText={t('Ok')} + onConfirm={() => { + setChoice(null); + setStage('choose'); + }} + > + {t('Live_Location_Disabled_Body')} + + ); + } + + // --- Stage 3: Static flow (with permission gating) --- + if (stage === 'static') { + if (permissionLoading || permissionState === 'prompt' || permissionState === undefined) { + return ( + + ); + } + + // Explicitly denied + if (permissionState === 'denied') { + return ( + + {t('The_necessary_browser_permissions_for_location_sharing_are_not_granted')} + + ); + } + + // Granted, still fetching coordinates → loader + if (permissionState === 'granted' && (positionLoading || positionFetching)) { + return ( + + {t('Getting_Your_Location')} + + ); + } + + // Granted but failed + if (permissionState === 'granted' && positionError) { + return ( + { + queryClient.resetQueries({ queryKey: ['geolocationPosition'] }); + }} + onClose={onClose} + cancelText={t('Cancel')} + onCancel={onClose} + > +
{(positionErr as Error | undefined)?.message || t('Unable_To_Fetch_Current_Location')}
+ +
+ {t('Tips_Improve_Location_Accuracy_Title')} +
• {t('Tips_Improve_Location_Accuracy_Bullet_1')} +
• {t('Tips_Improve_Location_Accuracy_Bullet_2')} +
• {t('Tips_Improve_Location_Accuracy_Bullet_3')} +
• {t('Tips_Improve_Location_Accuracy_Bullet_4')} +
+
+ ); + } + + const onConfirmStatic = async (): Promise => { + if (!positionData) return; + const { latitude, longitude } = positionData.coords; + + try { + const mapsLink = map.getMapsLink(latitude, longitude); + + const locationMessage = `📍 **${t('Shared_Location')}** +🔗 **[${t('View_on_OpenStreetMap')}](${mapsLink})** +📌 \`${latitude.toFixed(4)}°, ${longitude.toFixed(4)}°\``; + + await sendMessage({ + message: { + rid, + tmid, + msg: locationMessage, + }, + }); + } catch (error) { + dispatchToast({ type: 'error', message: error instanceof Error ? error.message : String(error) }); + } finally { + onClose(); + } + }; + + // Static share preview + confirm + return ( + +
{t('Using_OpenStreetMap_Label')}
+ + {positionData && ( + <> + + + )}
); } - return ( - - - - ); + return <>; }; export default ShareLocationModal; diff --git a/apps/meteor/client/views/room/ShareLocation/getGeolocationPosition.spec.ts b/apps/meteor/client/views/room/ShareLocation/getGeolocationPosition.spec.ts new file mode 100644 index 0000000000000..754a10a2de88b --- /dev/null +++ b/apps/meteor/client/views/room/ShareLocation/getGeolocationPosition.spec.ts @@ -0,0 +1,118 @@ +import { jest } from '@jest/globals'; + +// Helpers to craft GeolocationPosition +const makePos = (lat: number, lon: number): GeolocationPosition => + ({ + coords: { + latitude: lat, + longitude: lon, + accuracy: 5, + altitude: null, + altitudeAccuracy: null, + heading: null, + speed: null, + } as GeolocationCoordinates, + timestamp: Date.now(), + toJSON() { + return { coords: this.coords, timestamp: this.timestamp }; + }, + }) as any; + +// Build a mockable geolocation API +const buildGeo = () => { + let currentSuccess: PositionCallback | null = null; + let currentError: PositionErrorCallback | null = null; + let watchSuccess: PositionCallback | null = null; + let watchError: PositionErrorCallback | null = null; + let nextWatchId = 1; + + return { + getCurrentPosition: jest.fn((success: PositionCallback, error?: PositionErrorCallback) => { + currentSuccess = success; + currentError = error || null; + }), + watchPosition: jest.fn((success: PositionCallback, error?: PositionErrorCallback) => { + watchSuccess = success; + watchError = error || null; + return nextWatchId++; + }), + clearWatch: jest.fn(), + // helpers + succeedGetOnce(lat: number, lon: number) { + currentSuccess && currentSuccess(makePos(lat, lon)); + }, + errorGetOnce(err: any) { + currentError && currentError(err as GeolocationPositionError); + }, + succeedWatch(lat: number, lon: number) { + watchSuccess && watchSuccess(makePos(lat, lon)); + }, + errorWatch(err: any) { + watchError && watchError(err as GeolocationPositionError); + }, + }; +}; + +describe('getGeolocationPosition', () => { + const originalGeo = global.navigator.geolocation; + + beforeEach(() => { + jest.resetModules(); + }); + + afterAll(() => { + Object.defineProperty(global.navigator, 'geolocation', { value: originalGeo, configurable: true }); + }); + + test('throws when Geolocation API is not available', async () => { + // Remove the property entirely so `('geolocation' in navigator)` becomes false + delete (global.navigator as any).geolocation; + const { getGeolocationPosition } = await import('./getGeolocationPosition'); + await expect(getGeolocationPosition()).rejects.toThrow('Geolocation API not available'); + }); + + test('resolves via single getOnce and quantizes to 3 dp', async () => { + const geo = buildGeo(); + Object.defineProperty(global.navigator, 'geolocation', { value: geo, configurable: true }); + const { getGeolocationPosition } = await import('./getGeolocationPosition'); + + const prom = getGeolocationPosition(); + geo.succeedGetOnce(10.12356, 20.98765); + const pos = await prom; + expect(pos.coords.latitude).toBeCloseTo(10.124, 3); + expect(pos.coords.longitude).toBeCloseTo(20.988, 3); + // cache should make a second call return immediately without new getCurrentPosition + const p2 = getGeolocationPosition(); + const pos2 = await p2; + expect(pos2.coords.latitude).toBe(pos.coords.latitude); + expect(geo.getCurrentPosition).toHaveBeenCalledTimes(1); + }); + + test('permission denied bubbles error immediately', async () => { + const geo = buildGeo(); + Object.defineProperty(global.navigator, 'geolocation', { value: geo, configurable: true }); + const { getGeolocationPosition } = await import('./getGeolocationPosition'); + + const prom = getGeolocationPosition(); + geo.errorGetOnce({ code: 1, PERMISSION_DENIED: 1, message: 'denied' }); + await expect(prom).rejects.toMatchObject({ code: 1 }); + }); + + test('falls back to watchPosition when transient error, then succeeds', async () => { + const geo = buildGeo(); + Object.defineProperty(global.navigator, 'geolocation', { value: geo, configurable: true }); + const { getGeolocationPosition } = await import('./getGeolocationPosition'); + + const prom = getGeolocationPosition(); + // initial getOnce transient failure (POSITION_UNAVAILABLE = 2) + geo.errorGetOnce({ code: 2, POSITION_UNAVAILABLE: 2, message: 'location unknown' }); + // allow event loop to progress until watchPosition is registered, then emit a fix + await new Promise((r) => setTimeout(r, 0)); + geo.succeedWatch(11.1111, 22.2222); + const pos = await prom; + expect(pos.coords.latitude).toBeCloseTo(11.111, 3); + expect(pos.coords.longitude).toBeCloseTo(22.222, 3); + // clearWatch must be called for the watch id 1 + expect(geo.clearWatch).toHaveBeenCalledWith(1); + }); +}); diff --git a/apps/meteor/client/views/room/ShareLocation/getGeolocationPosition.ts b/apps/meteor/client/views/room/ShareLocation/getGeolocationPosition.ts index ac9859e9f2d0e..6741a9114a157 100644 --- a/apps/meteor/client/views/room/ShareLocation/getGeolocationPosition.ts +++ b/apps/meteor/client/views/room/ShareLocation/getGeolocationPosition.ts @@ -1,8 +1,161 @@ -export const getGeolocationPosition = (): Promise => - new Promise((resolvePos, rejectPos) => { - navigator.geolocation.getCurrentPosition(resolvePos, rejectPos, { - enableHighAccuracy: true, +// getGeolocationPosition.ts + +type AnyErr = GeolocationPositionError & { message?: string }; + +const MAX_CACHE_AGE_MS = 30_000; +const COARSE_DP = 3; + +type Cached = { timestamp: number; position: GeolocationPosition }; +let memCache: Cached | null = null; + +try { + sessionStorage.removeItem('lastGeoPosition'); +} catch { + // ignore sessionStorage unavailability (SSR, privacy mode, etc.) + void 0; +} +const quantize = (v: number, dp: number) => { + const f = 10 ** dp; + return Math.round(v * f) / f; +}; + +function toCoarsePosition(src: GeolocationPosition, dp = COARSE_DP): GeolocationPosition { + const c = src.coords; + const coarse: GeolocationPosition = { + coords: { + latitude: quantize(c.latitude, dp), + longitude: quantize(c.longitude, dp), + accuracy: c.accuracy, + altitude: c.altitude ?? null, + altitudeAccuracy: c.altitudeAccuracy ?? null, + heading: c.heading ?? null, + speed: c.speed ?? null, + } as GeolocationCoordinates, + timestamp: src.timestamp, + toJSON() { + return { + coords: this.coords, + timestamp: this.timestamp, + }; + }, + }; + return coarse; +} + +export async function getGeolocationPosition(opts?: PositionOptions): Promise { + if (typeof window === 'undefined' || !('geolocation' in navigator)) { + throw new Error('Geolocation API not available'); + } + + // 0) Serve a fresh cached fix immediately if available (in-memory only) + const cached = readCached(); + if (cached && Date.now() - cached.timestamp < MAX_CACHE_AGE_MS) { + return cached.position; + } + + // 1) Quick relaxed single read (lets Apple CoreLocation settle) + try { + const pos = await getOnce({ + enableHighAccuracy: false, + timeout: 8000, + maximumAge: 60_000, + ...opts, + }); + const coarse = toCoarsePosition(pos); + cache(coarse); + return coarse; + } catch (e) { + const err = e as AnyErr; + // If the user denied, bail immediately + if (err.code === err.PERMISSION_DENIED) throw err; + } + + // 2) Transient fallback: wait for the first fix via watchPosition (up to 20s) + try { + const pos = await watchOnce({ + enableHighAccuracy: false, + timeout: 20_000, maximumAge: 0, - timeout: 10000, + ...opts, }); + const coarse = toCoarsePosition(pos); + cache(coarse); + return coarse; + } catch (e) { + const err = e as AnyErr; + if (isTransient(err)) { + try { + const pos = await getOnce({ + enableHighAccuracy: false, + timeout: 12_000, + maximumAge: 2 * 60_000, + ...opts, + }); + const coarse = toCoarsePosition(pos); + cache(coarse); + return coarse; + } catch (e) { + // ignore transient failure; a final high-accuracy attempt will follow + void e; + } + } + } + + // 3) Final attempt: high-accuracy single read (may take longer indoors) + const pos = await getOnce({ + enableHighAccuracy: true, + timeout: 15_000, + maximumAge: 0, + ...opts, + }); + const coarse = toCoarsePosition(pos); + cache(coarse); + return coarse; +} + +function getOnce(options: PositionOptions): Promise { + return new Promise((resolve, reject) => { + navigator.geolocation.getCurrentPosition(resolve, reject, options); + }); +} + +function watchOnce(options: PositionOptions): Promise { + return new Promise((resolve, reject) => { + let watchId: number | null = null; + const timer = window.setTimeout(() => { + if (watchId !== null) navigator.geolocation.clearWatch(watchId); + reject(new Error('Timed out waiting for position')); + }, options.timeout ?? 20000); + + watchId = navigator.geolocation.watchPosition( + (pos) => { + if (watchId !== null) navigator.geolocation.clearWatch(watchId); + window.clearTimeout(timer); + resolve(pos); + }, + (err) => { + if (watchId !== null) navigator.geolocation.clearWatch(watchId); + window.clearTimeout(timer); + reject(err); + }, + options, + ); }); +} + +function isTransient(err: AnyErr): boolean { + if (!err) return false; + if (err.code === err.POSITION_UNAVAILABLE) return true; + const m = String(err.message || '').toLowerCase(); + if (m.includes('kclerrorlocationunknown')) return true; + if (m.includes('location unknown')) return true; + return false; +} + +function cache(position: GeolocationPosition) { + memCache = { timestamp: Date.now(), position }; +} + +function readCached(): Cached | null { + return memCache; +} diff --git a/apps/meteor/client/views/room/ShareLocation/mapProvider.spec.ts b/apps/meteor/client/views/room/ShareLocation/mapProvider.spec.ts new file mode 100644 index 0000000000000..6b7a34476c129 --- /dev/null +++ b/apps/meteor/client/views/room/ShareLocation/mapProvider.spec.ts @@ -0,0 +1,28 @@ +import { createMapProvider, OSMProvider } from './mapProvider'; + +describe('mapProvider (OSM)', () => { + it('creates an OSMProvider by default', () => { + const provider = createMapProvider(); + expect(provider).toBeInstanceOf(OSMProvider); + expect(provider.name).toBe('openstreetmap'); + }); + + it('getStaticMapUrl builds expected URL', () => { + const provider = createMapProvider(); + const url = provider.getStaticMapUrl(10.5, 20.75, { zoom: 12, width: 512, height: 256 }); + expect(url).toBe( + 'https://staticmap.openstreetmap.fr/staticmap.php?center=10.5,20.75&zoom=12&size=512x256&markers=10.5,20.75,red-pushpin', + ); + }); + + it('getMapsLink builds expected OSM link with default zoom 16', () => { + const provider = createMapProvider(); + const link = provider.getMapsLink(1.2345, -2.3456); + expect(link).toBe('https://www.openstreetmap.org/?mlat=1.2345&mlon=-2.3456#map=16/1.2345/-2.3456'); + }); + + it('getAttribution returns OSM attribution', () => { + const provider = createMapProvider(); + expect(provider.getAttribution()).toContain('OpenStreetMap'); + }); +}); diff --git a/apps/meteor/client/views/room/ShareLocation/mapProvider.ts b/apps/meteor/client/views/room/ShareLocation/mapProvider.ts new file mode 100644 index 0000000000000..a6a388549779f --- /dev/null +++ b/apps/meteor/client/views/room/ShareLocation/mapProvider.ts @@ -0,0 +1,52 @@ +// mapProvider.ts + +export type MapProviderName = 'openstreetmap'; + +export type ProviderOpts = { + apiKey?: string; +}; + +export interface IMapProvider { + name: MapProviderName; + getStaticMapUrl(lat: number, lng: number, opts?: { zoom?: number; width?: number; height?: number }): string; + getMapsLink(lat: number, lng: number): string; + getAttribution(): string; +} + +export class OSMProvider implements IMapProvider { + name: MapProviderName = 'openstreetmap'; + + private readonly _opts: ProviderOpts; + + constructor(opts: ProviderOpts = {}) { + // store options for future extensibility (e.g., API keys, style params) + this._opts = opts; + // read to avoid TS "is declared but its value is never read" when not yet used + void this._opts; + } + + getStaticMapUrl(lat: number, lng: number, opts?: { zoom?: number; width?: number; height?: number }): string { + const zoom = opts?.zoom ?? 15; + const width = opts?.width ?? 600; + const height = opts?.height ?? 320; + + return `https://staticmap.openstreetmap.fr/staticmap.php?center=${lat},${lng}&zoom=${zoom}&size=${width}x${height}&markers=${lat},${lng},red-pushpin`; + } + + getMapsLink(lat: number, lng: number): string { + const defaultZoom = 16; + return `https://www.openstreetmap.org/?mlat=${lat}&mlon=${lng}#map=${defaultZoom}/${lat}/${lng}`; + } + + getAttribution(): string { + return '© OpenStreetMap contributors'; + } +} + +export function createMapProvider(name: MapProviderName = 'openstreetmap', opts: ProviderOpts = {}): IMapProvider { + switch (name) { + case 'openstreetmap': + default: + return new OSMProvider(opts); + } +} diff --git a/apps/meteor/client/views/room/ShareLocation/useBrowserLiveLocation.spec.tsx b/apps/meteor/client/views/room/ShareLocation/useBrowserLiveLocation.spec.tsx new file mode 100644 index 0000000000000..2fad93e5a638d --- /dev/null +++ b/apps/meteor/client/views/room/ShareLocation/useBrowserLiveLocation.spec.tsx @@ -0,0 +1,87 @@ +import { render, screen, act, fireEvent } from '@testing-library/react'; +import { useState } from 'react'; + +import { useBrowserLiveLocation } from './useBrowserLiveLocation'; + +// Build a component to exercise the hook +function Harness({ throttleMs = 10000 }: { throttleMs?: number }) { + const { coord, shouldPublish } = useBrowserLiveLocation(throttleMs); + const [flag, setFlag] = useState(null); + return ( +
+
{coord ? `${coord.lat},${coord.lon}` : 'none'}
+ +
{flag === null ? 'unset' : String(flag)}
+
+ ); +} + +describe('useBrowserLiveLocation', () => { + const originalGeo = global.navigator.geolocation; + afterEach(() => { + jest.useRealTimers(); + }); + + let watchSuccess: PositionCallback | null; + let _watchError: PositionErrorCallback | null; + let clearWatch: jest.Mock; + + beforeEach(() => { + watchSuccess = null; + _watchError = null; + clearWatch = jest.fn(); + Object.defineProperty(global.navigator, 'geolocation', { + configurable: true, + value: { + watchPosition: (s: PositionCallback, e?: PositionErrorCallback) => { + watchSuccess = s; + _watchError = e || null; + // mark as read to satisfy TS + void _watchError; + return 99; + }, + clearWatch, + }, + }); + }); + + afterAll(() => { + Object.defineProperty(global.navigator, 'geolocation', { + configurable: true, + value: originalGeo, + }); + }); + + function emit(lat: number, lon: number) { + const pos = { + coords: { latitude: lat, longitude: lon } as GeolocationCoordinates, + timestamp: Date.now(), + } as GeolocationPosition; + watchSuccess && watchSuccess(pos); + } + + it('updates coord when geolocation emits positions and cleans up on unmount', () => { + const { unmount } = render(); + expect(screen.getByTestId('coord').textContent).toBe('none'); + act(() => emit(12.34, 56.78)); + expect(screen.getByTestId('coord').textContent).toBe('12.34,56.78'); + unmount(); + expect(clearWatch).toHaveBeenCalledWith(99); + }); + + it('shouldPublish throttles subsequent publishes', () => { + render(); + // First call -> true + fireEvent.click(screen.getByText('publish?')); + expect(screen.getByTestId('flag').textContent).toBe('true'); + // Immediate second call -> false + fireEvent.click(screen.getByText('publish?')); + expect(screen.getByTestId('flag').textContent).toBe('false'); + // After throttle window -> true again + act(() => { + jest.advanceTimersByTime(5001); + }); + fireEvent.click(screen.getByText('publish?')); + expect(screen.getByTestId('flag').textContent).toBe('true'); + }); +}); diff --git a/apps/meteor/client/views/room/ShareLocation/useBrowserLiveLocation.ts b/apps/meteor/client/views/room/ShareLocation/useBrowserLiveLocation.ts new file mode 100644 index 0000000000000..ca571b4fd61d1 --- /dev/null +++ b/apps/meteor/client/views/room/ShareLocation/useBrowserLiveLocation.ts @@ -0,0 +1,32 @@ +import { useEffect, useRef, useState } from 'react'; + +export function useBrowserLiveLocation(throttleMs = 10000) { + const [coord, setCoord] = useState<{ lon: number; lat: number } | null>(null); + const lastSentRef = useRef(0); + useEffect(() => { + if (!navigator.geolocation) return; + + const watchId = navigator.geolocation.watchPosition( + (p) => { + const { latitude, longitude } = p.coords; + setCoord({ lon: longitude, lat: latitude }); + }, + () => { + // Error handler - no action needed for this implementation + }, + { enableHighAccuracy: true, maximumAge: 5000 }, + ); + return () => navigator.geolocation.clearWatch(watchId); + }, []); + + function shouldPublish() { + const now = Date.now(); + if (now - lastSentRef.current >= throttleMs) { + lastSentRef.current = now; + return true; + } + return false; + } + + return { coord, shouldPublish }; +} diff --git a/apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/hooks/useShareLocationAction.tsx b/apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/hooks/useShareLocationAction.tsx index 71b0c35ce516c..0db4b6332eb0a 100644 --- a/apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/hooks/useShareLocationAction.tsx +++ b/apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/hooks/useShareLocationAction.tsx @@ -16,11 +16,10 @@ export const useShareLocationAction = (room?: IRoom, tmid?: IMessage['tmid']): G const isMapViewEnabled = useSetting('MapView_Enabled') === true; const isGeolocationCurrentPositionSupported = Boolean(navigator.geolocation?.getCurrentPosition); - const googleMapsApiKey = useSetting('MapView_GMapsAPIKey', ''); - const canGetGeolocation = isMapViewEnabled && isGeolocationCurrentPositionSupported && googleMapsApiKey && googleMapsApiKey.length; + // OSM-based map preview does not require a Google Maps API key + const canGetGeolocation = isMapViewEnabled && isGeolocationCurrentPositionSupported; const handleShareLocation = () => setModal( setModal(null)} />); - const allowGeolocation = room && canGetGeolocation && !isRoomFederated(room); return { diff --git a/apps/meteor/package.json b/apps/meteor/package.json index 5f19685a74194..869efe6d1c565 100644 --- a/apps/meteor/package.json +++ b/apps/meteor/package.json @@ -392,6 +392,7 @@ "lodash.escape": "^4.0.1", "lodash.get": "^4.4.2", "mailparser": "~3.7.4", + "maplibre-gl": "^4.7.1", "marked": "^4.3.0", "mem": "^8.1.1", "meteor-node-stubs": "~1.2.24", diff --git a/apps/meteor/server/main.ts b/apps/meteor/server/main.ts index 10f724c745e79..e13ced6c92546 100644 --- a/apps/meteor/server/main.ts +++ b/apps/meteor/server/main.ts @@ -17,6 +17,7 @@ import { startRocketChat } from '../startRocketChat'; import './routes'; import '../app/lib/server/startup'; +import '../app/live-location/server'; import './importPackages'; import './methods'; import './publications'; diff --git a/packages/apps-engine/deno-runtime/deno.lock b/packages/apps-engine/deno-runtime/deno.lock index 61763f056cce3..92349e3131039 100644 --- a/packages/apps-engine/deno-runtime/deno.lock +++ b/packages/apps-engine/deno-runtime/deno.lock @@ -112,4 +112,4 @@ "npm:uuid@8.3.2" ] } -} +} \ No newline at end of file diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index dc60f672c4edc..26bc572c11d24 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -1502,6 +1502,7 @@ "Current_Chats": "Current Chats", "Current_File": "Current File", "Current_Import_Operation": "Current Import Operation", + "Current_Location": "Current Location", "Current_Status": "Current Status", "Currently_we_dont_support_joining_servers_with_this_many_people": "Currently we don't support joining servers with this many people", "Custom": "Custom", @@ -2372,6 +2373,7 @@ "Generate_new_key": "Generate a new key", "Generating_key": "Generating key", "Get_all_apps": "Get all the apps your team needs", + "Getting_Your_Location": "Getting your location", "Give_the_application_a_name_This_will_be_seen_by_your_users": "Give the application a name. This will be seen by your users.", "Global": "Global", "Global Policy": "Global Policy", @@ -3153,6 +3155,8 @@ "Livestream_unavailable_for_federation": "Livestram is unavailable for Federated rooms", "Livestream_url": "Livestream source url", "Livestream_url_incorrect": "Livestream url is incorrect", + "Live_Location": "Live Location", + "Live_Location_Disabled_Body": "Live location sharing is not available in this build", "Load_Balancing": "Load Balancing", "Load_Rotation": "Load Rotation", "Load_more": "Load more", @@ -3247,6 +3251,8 @@ "MapView_Enabled_Description": "Enabling mapview will display a location share button on the right of the chat input field.", "MapView_GMapsAPIKey": "Google Static Maps API Key", "MapView_GMapsAPIKey_Description": "This can be obtained from the Google Developers Console for free.", + "Map_Preview_Alt": "Map preview", + "Map_Preview_Unavailable": "Map preview unavailable", "Mark_all_as_read": "`%s` - Mark all messages (in all channels) as read", "Mark_as_read": "Mark as read", "Mark_as_unread": "Mark as unread", @@ -3947,6 +3953,7 @@ "Origin": "Origin", "Origin_When_Cross_Origin": "Origin when cross origin", "Original": "Original", + "OSM_Attribution": "© OpenStreetMap contributors", "Other": "Other", "Others": "Others", "Outbound_message": "Outbound message", @@ -4807,6 +4814,7 @@ "Setup_Wizard_Description": "Basic info about your workspace such as organization name and country.", "Setup_Wizard_Info": "We'll guide you through setting up your first admin user, configuring your organisation and registering your server to receive free push notifications and more.", "Share": "Share", + "Share_Location_Choice_Description": "Choose to share your current location once or start live location sharing", "Share_Location_Title": "Share Location?", "Share_screen": "Share screen", "Shared_Location": "Shared Location", @@ -5239,6 +5247,11 @@ "Timeouts": "Timeouts", "Timestamp": "Timestamp", "Timezone": "Timezone", + "Tips_Improve_Location_Accuracy_Title": "Tips to improve location accuracy:", + "Tips_Improve_Location_Accuracy_Bullet_1": "Move closer to a window or go outside", + "Tips_Improve_Location_Accuracy_Bullet_2": "Make sure location services are enabled on your device", + "Tips_Improve_Location_Accuracy_Bullet_3": "Check that your browser has location permissions", + "Tips_Improve_Location_Accuracy_Bullet_4": "Try refreshing the page and allowing location access again", "Title": "Title", "Title_bar_color": "Title bar color", "Title_bar_color_offline": "Title bar color offline", @@ -5317,6 +5330,7 @@ "Troubleshoot_Force_Caching_Version": "Force browsers to clear networking cache based on version change", "Troubleshoot_Force_Caching_Version_Alert": "If the value provided is not empty and different from previous one the browsers will try to clear the cache. This setting should not be set for a long period since it affects the browser performance, please clear it as soon as possible.", "True": "True", + "Try_again": "Try again", "Try_different_filters": "Try different filters", "Try_entering_a_different_search_term": "Try entering a different search term.", "Try_now": "Try now", @@ -5368,6 +5382,7 @@ "UTF8_User_Names_Validation_Description": "RegExp that will be used to validate usernames", "Unable_to_complete_call": "Unable to complete call", "Unable_to_complete_call__code": "Unable to complete call. Error code [{{statusCode}}]", + "Unable_To_Fetch_Current_Location": "Unable to fetch your current location", "Unable_to_load_active_connections": "Unable to load active connections", "Unable_to_make_calls_while_another_is_ongoing": "Unable to make calls while another call is ongoing", "Unable_to_negotiate_call_params": "Unable to negotiate call params.", @@ -5622,6 +5637,7 @@ "Users_reacted_with": "{{users}} reacted with {{emoji}}", "Uses": "Uses", "Uses_left": "Uses left", + "Using_OpenStreetMap_Label": "Using OpenStreetMap", "Utilities": "Utilities", "Validate_email_address": "Validate Email Address", "Validation": "Validation", @@ -5674,6 +5690,7 @@ "View_channels": "View channels", "View_full_conversation": "View full conversation", "View_mode": "View Mode", + "View_on_OpenStreetMap": "View on OpenStreetMap", "View_original": "View Original", "View_the_Logs_for": "View the logs for: \"{{name}}\"", "View_thread": "View thread", diff --git a/packages/models/src/models/Messages.ts b/packages/models/src/models/Messages.ts index 452c3fbb05d4d..2f1cba7e60838 100644 --- a/packages/models/src/models/Messages.ts +++ b/packages/models/src/models/Messages.ts @@ -63,7 +63,36 @@ export class MessagesRaw extends BaseRaw implements IMessagesModel { { key: { 'pinnedBy._id': 1 }, sparse: true }, { key: { 'starred._id': 1 }, sparse: true }, - // discussions + // live-location attachments + { key: { 'attachments.0.type': 1, 'attachments.0.live.isActive': 1 } }, + { key: { 'rid': 1, 'u._id': 1, 'attachments.0.type': 1, 'attachments.0.live.isActive': 1 } }, + { + key: { rid: 1, 'u._id': 1, 'attachments.type': 1, 'attachments.live.isActive': 1 }, + unique: true, + partialFilterExpression: { + 'attachments': { + $elemMatch: { + 'type': 'live-location', + 'live.isActive': true, + }, + }, + }, + name: 'uniq_active_live_location_per_user_room', + }, + { + key: { 'attachments.0.live.expiresAt': 1 }, + expireAfterSeconds: 0, + partialFilterExpression: { + 'attachments.0.type': 'live-location', + 'attachments.0.live.expiresAt': { $type: 'date' }, + }, + name: 'liveLocation_expiresAt_TTL', + }, + { + key: { 'attachments.0.live.lastUpdateAt': 1 }, + partialFilterExpression: { 'attachments.0.type': 'live-location' }, + name: 'liveLocation_lastUpdateAt_idx', + }, // discussions { key: { drid: 1 }, sparse: true }, // threads diff --git a/yarn.lock b/yarn.lock index ad901035c76fd..448a2a773ca10 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4792,6 +4792,25 @@ __metadata: languageName: node linkType: hard +"@mapbox/geojson-rewind@npm:^0.5.2": + version: 0.5.2 + resolution: "@mapbox/geojson-rewind@npm:0.5.2" + dependencies: + get-stream: "npm:^6.0.1" + minimist: "npm:^1.2.6" + bin: + geojson-rewind: geojson-rewind + checksum: 10/721470ab5e8912d69aef06fa4db891bade8b028d6708a35a982b1dfec0f40eb4ba05a749258867f5844cf4e776e53866813bf9c97e3289054b21cbf7840d3608 + languageName: node + linkType: hard + +"@mapbox/jsonlint-lines-primitives@npm:^2.0.2, @mapbox/jsonlint-lines-primitives@npm:~2.0.2": + version: 2.0.2 + resolution: "@mapbox/jsonlint-lines-primitives@npm:2.0.2" + checksum: 10/6d8e64d34d912ebf29fead0d1917c8d8ad86e96f69b6100a9764af8cba391609474cdce7f7e4a2d579ccea58a142d1454257b795403179e9133a09af13101068 + languageName: node + linkType: hard + "@mapbox/node-pre-gyp@npm:^1.0.10, @mapbox/node-pre-gyp@npm:^1.0.11": version: 1.0.11 resolution: "@mapbox/node-pre-gyp@npm:1.0.11" @@ -4811,6 +4830,62 @@ __metadata: languageName: node linkType: hard +"@mapbox/point-geometry@npm:0.1.0, @mapbox/point-geometry@npm:^0.1.0, @mapbox/point-geometry@npm:~0.1.0": + version: 0.1.0 + resolution: "@mapbox/point-geometry@npm:0.1.0" + checksum: 10/f6f78ac8a7f798efb19db6eb1a9e05da7ba942102f5347c1a673d94202d0c606ec3f522efa3e76d583cdca46fb96dde52c3d37234f162d21df42f9e8c4f182bd + languageName: node + linkType: hard + +"@mapbox/tiny-sdf@npm:^2.0.6": + version: 2.0.7 + resolution: "@mapbox/tiny-sdf@npm:2.0.7" + checksum: 10/ca3423092deb53f747ee02d0761c4ef8231f1077cb320a3c964348055cbf752ae8135e98b564379d015b016de145330ab48365c5752b13fe92bf3bc58ccb6335 + languageName: node + linkType: hard + +"@mapbox/unitbezier@npm:^0.0.1": + version: 0.0.1 + resolution: "@mapbox/unitbezier@npm:0.0.1" + checksum: 10/bf104c85dbff37bf47d3217d9457a3abbf23714f78fefadea64e56bdc7c538491b626166809ef28db134f09baccd6ca3df6988a6422df90d8d0c9a23b0686043 + languageName: node + linkType: hard + +"@mapbox/vector-tile@npm:^1.3.1": + version: 1.3.1 + resolution: "@mapbox/vector-tile@npm:1.3.1" + dependencies: + "@mapbox/point-geometry": "npm:~0.1.0" + checksum: 10/ed31eeef0d593befde76b5b4edf0472709a2ba66dd6b32fad5671caa245fdac976e23ff385facf36e297f14a53c905bfde8911599e8aa690354d52b22bc4cfc5 + languageName: node + linkType: hard + +"@mapbox/whoots-js@npm:^3.1.0": + version: 3.1.0 + resolution: "@mapbox/whoots-js@npm:3.1.0" + checksum: 10/c1837c04effd205b207f441356d952eae7e8aad6c58f7c4900de50318c2147cf175936fc9434f20dfa409f9e6a78ec604d61e70c1c20572db0cc7655fbb65f50 + languageName: node + linkType: hard + +"@maplibre/maplibre-gl-style-spec@npm:^20.3.1": + version: 20.4.0 + resolution: "@maplibre/maplibre-gl-style-spec@npm:20.4.0" + dependencies: + "@mapbox/jsonlint-lines-primitives": "npm:~2.0.2" + "@mapbox/unitbezier": "npm:^0.0.1" + json-stringify-pretty-compact: "npm:^4.0.0" + minimist: "npm:^1.2.8" + quickselect: "npm:^2.0.0" + rw: "npm:^1.3.3" + tinyqueue: "npm:^3.0.0" + bin: + gl-style-format: dist/gl-style-format.mjs + gl-style-migrate: dist/gl-style-migrate.mjs + gl-style-validate: dist/gl-style-validate.mjs + checksum: 10/499d5af31433d9a92dc3dd69269b55c05bdff069355e41617bb7f1cc0f1f3133b3178eb8c29046a032fddd5e9107a322d2eaa0e393ba9711a12491c595fb769b + languageName: node + linkType: hard + "@mdx-js/react@npm:^3.0.0": version: 3.0.1 resolution: "@mdx-js/react@npm:3.0.1" @@ -9375,6 +9450,7 @@ __metadata: lodash.escape: "npm:^4.0.1" lodash.get: "npm:^4.4.2" mailparser: "npm:~3.7.4" + maplibre-gl: "npm:^4.7.1" marked: "npm:^4.3.0" mem: "npm:^8.1.1" meteor-node-stubs: "npm:~1.2.24" @@ -13349,6 +13425,15 @@ __metadata: languageName: node linkType: hard +"@types/geojson-vt@npm:3.2.5": + version: 3.2.5 + resolution: "@types/geojson-vt@npm:3.2.5" + dependencies: + "@types/geojson": "npm:*" + checksum: 10/3c77f52c4a82b8087d3e04b86a62027ad1dccf4d339df7c7c191cfcf288564e050b241664e072fc9fd3bb5b71e217dc0dcfb7c467bded4be303ab2b283612b72 + languageName: node + linkType: hard + "@types/geojson@npm:*": version: 7946.0.10 resolution: "@types/geojson@npm:7946.0.10" @@ -13356,6 +13441,13 @@ __metadata: languageName: node linkType: hard +"@types/geojson@npm:^7946.0.14": + version: 7946.0.16 + resolution: "@types/geojson@npm:7946.0.16" + checksum: 10/34d07421bdd60e7b99fa265441d17ac6e9aef48e3ce22d04324127d0de1daf7fbaa0bd3be1cece2092eb6995f21da84afa5231e24621a2910ff7340bc98f496f + languageName: node + linkType: hard + "@types/glob@npm:^7.1.1": version: 7.2.0 resolution: "@types/glob@npm:7.2.0" @@ -13699,6 +13791,24 @@ __metadata: languageName: node linkType: hard +"@types/mapbox__point-geometry@npm:*, @types/mapbox__point-geometry@npm:^0.1.4": + version: 0.1.4 + resolution: "@types/mapbox__point-geometry@npm:0.1.4" + checksum: 10/d315f3e396bebd40f1cab682595f3d1c5ac46c5ddb080cf65dfcd0401dc6a3f235a7ac9ada2d28e6c49485fa5f231458f29fee87069e42a137e20e5865801dd1 + languageName: node + linkType: hard + +"@types/mapbox__vector-tile@npm:^1.3.4": + version: 1.3.4 + resolution: "@types/mapbox__vector-tile@npm:1.3.4" + dependencies: + "@types/geojson": "npm:*" + "@types/mapbox__point-geometry": "npm:*" + "@types/pbf": "npm:*" + checksum: 10/5715d9da88a5ecadb63e3ca4d52272ead2c1d63fcf616841932719788e458fc10dd9919ad01aa9c95b15c83e9074dae9ffc7193a7ae4ae7b8436d26630f0e269 + languageName: node + linkType: hard + "@types/marked@npm:^4.3.2": version: 4.3.2 resolution: "@types/marked@npm:4.3.2" @@ -13924,6 +14034,13 @@ __metadata: languageName: node linkType: hard +"@types/pbf@npm:*, @types/pbf@npm:^3.0.5": + version: 3.0.5 + resolution: "@types/pbf@npm:3.0.5" + checksum: 10/9115eb3cc61e535748dd6de98c7a8bd64e02a4052646796013b075fed66fd52a3a2aaae6b75648e9c0361e8ed462a50549ca0af1015e2e48296cd8c31bb54577 + languageName: node + linkType: hard + "@types/polka@npm:^0.5.7": version: 0.5.7 resolution: "@types/polka@npm:0.5.7" @@ -14247,6 +14364,15 @@ __metadata: languageName: node linkType: hard +"@types/supercluster@npm:^7.1.3": + version: 7.1.3 + resolution: "@types/supercluster@npm:7.1.3" + dependencies: + "@types/geojson": "npm:*" + checksum: 10/e4c4e6174780ea68f4182b6d17f99d2651f9fb23f254c9ee6cfbb74025f75550057027f2c292662775377a53dd12af4b1908d0cecbe5b0f8490d1ce9fc6e726a + languageName: node + linkType: hard + "@types/supertest@npm:~6.0.3": version: 6.0.3 resolution: "@types/supertest@npm:6.0.3" @@ -20432,6 +20558,13 @@ __metadata: languageName: node linkType: hard +"earcut@npm:^3.0.0": + version: 3.0.2 + resolution: "earcut@npm:3.0.2" + checksum: 10/08374619e9510f3d669438c292153ec5c31a0ebac058bd03dab04c5b341e0fd2d91794681a1128321a49651ee55822ebb7bda5f8a35de636c4e3b0b27fa39a84 + languageName: node + linkType: hard + "eastasianwidth@npm:^0.2.0": version: 0.2.0 resolution: "eastasianwidth@npm:0.2.0" @@ -23062,6 +23195,13 @@ __metadata: languageName: node linkType: hard +"geojson-vt@npm:^4.0.2": + version: 4.0.2 + resolution: "geojson-vt@npm:4.0.2" + checksum: 10/2a9e1894321184c48612221b4649d8cc5afa972962ee21b91c09b9d85d9076777164cce6f1d438d0bfb0c7d6b4713f518cf0501de210127f9a601e316197eb91 + languageName: node + linkType: hard + "get-assigned-identifiers@npm:^1.2.0": version: 1.2.0 resolution: "get-assigned-identifiers@npm:1.2.0" @@ -23169,7 +23309,7 @@ __metadata: languageName: node linkType: hard -"get-stream@npm:^6.0.0": +"get-stream@npm:^6.0.0, get-stream@npm:^6.0.1": version: 6.0.1 resolution: "get-stream@npm:6.0.1" checksum: 10/781266d29725f35c59f1d214aedc92b0ae855800a980800e2923b3fbc4e56b3cb6e462c42e09a1cf1a00c64e056a78fa407cbe06c7c92b7e5cd49b4b85c2a497 @@ -23225,6 +23365,13 @@ __metadata: languageName: node linkType: hard +"gl-matrix@npm:^3.4.3": + version: 3.4.4 + resolution: "gl-matrix@npm:3.4.4" + checksum: 10/0a19a881fbfa2cdcff2b5ece0f62041d17e55665393349653e8742a20e43d4516239c68ac6798baa7c35b0c7bd6c9226e70e7824af162228d471eba358a03090 + languageName: node + linkType: hard + "glob-parent@npm:^5.1.2, glob-parent@npm:~5.1.2": version: 5.1.2 resolution: "glob-parent@npm:5.1.2" @@ -24416,7 +24563,7 @@ __metadata: languageName: node linkType: hard -"ieee754@npm:^1.1.13, ieee754@npm:^1.1.4, ieee754@npm:^1.2.1": +"ieee754@npm:^1.1.12, ieee754@npm:^1.1.13, ieee754@npm:^1.1.4, ieee754@npm:^1.2.1": version: 1.2.1 resolution: "ieee754@npm:1.2.1" checksum: 10/d9f2557a59036f16c282aaeb107832dc957a93d73397d89bbad4eb1130560560eb695060145e8e6b3b498b15ab95510226649a0b8f52ae06583575419fe10fc4 @@ -27048,6 +27195,13 @@ __metadata: languageName: node linkType: hard +"json-stringify-pretty-compact@npm:^4.0.0": + version: 4.0.0 + resolution: "json-stringify-pretty-compact@npm:4.0.0" + checksum: 10/a10d5c423e467872994a49c5c1b56b073f277ce02d899cf567fc625f3783b89406bee6408bfb3b4bdeeff509b6a562f5259227e26754a6186f721809ca895f0c + languageName: node + linkType: hard + "json-stringify-safe@npm:~5.0.1": version: 5.0.1 resolution: "json-stringify-safe@npm:5.0.1" @@ -27259,6 +27413,13 @@ __metadata: languageName: node linkType: hard +"kdbush@npm:^4.0.2": + version: 4.0.2 + resolution: "kdbush@npm:4.0.2" + checksum: 10/ca1f7a106c129056044ab19851909efcc33680d568066872de996d3bc4d41f81d2a6423e577f20436d1a8b96a6f3c514af8cb94cc54a4d784d9df976b43066f8 + languageName: node + linkType: hard + "kebab-case@npm:^1.0.0": version: 1.0.2 resolution: "kebab-case@npm:1.0.2" @@ -28079,6 +28240,40 @@ __metadata: languageName: node linkType: hard +"maplibre-gl@npm:^4.7.1": + version: 4.7.1 + resolution: "maplibre-gl@npm:4.7.1" + dependencies: + "@mapbox/geojson-rewind": "npm:^0.5.2" + "@mapbox/jsonlint-lines-primitives": "npm:^2.0.2" + "@mapbox/point-geometry": "npm:^0.1.0" + "@mapbox/tiny-sdf": "npm:^2.0.6" + "@mapbox/unitbezier": "npm:^0.0.1" + "@mapbox/vector-tile": "npm:^1.3.1" + "@mapbox/whoots-js": "npm:^3.1.0" + "@maplibre/maplibre-gl-style-spec": "npm:^20.3.1" + "@types/geojson": "npm:^7946.0.14" + "@types/geojson-vt": "npm:3.2.5" + "@types/mapbox__point-geometry": "npm:^0.1.4" + "@types/mapbox__vector-tile": "npm:^1.3.4" + "@types/pbf": "npm:^3.0.5" + "@types/supercluster": "npm:^7.1.3" + earcut: "npm:^3.0.0" + geojson-vt: "npm:^4.0.2" + gl-matrix: "npm:^3.4.3" + global-prefix: "npm:^4.0.0" + kdbush: "npm:^4.0.2" + murmurhash-js: "npm:^1.0.0" + pbf: "npm:^3.3.0" + potpack: "npm:^2.0.0" + quickselect: "npm:^3.0.0" + supercluster: "npm:^8.0.1" + tinyqueue: "npm:^3.0.0" + vt-pbf: "npm:^3.1.3" + checksum: 10/5dae400a26ac8d978a2bc73ab81025b72fdc1972d387f0ce2a68142d88d4c48b7abb743adf649d36d2d31ec976bca036b33786664f0ba19301213fd274c4b15c + languageName: node + linkType: hard + "markdown-it@npm:^14.1.0": version: 14.1.0 resolution: "markdown-it@npm:14.1.0" @@ -29149,6 +29344,13 @@ __metadata: languageName: node linkType: hard +"murmurhash-js@npm:^1.0.0": + version: 1.0.0 + resolution: "murmurhash-js@npm:1.0.0" + checksum: 10/875a24e0dd7870e51a7f73906e158fb06de50478669629746a35955cb0a00b6bb797f6b5a2884ee4ec4feefb9c5c27b74190f561eb72530ffc1c5d7c5429f49a + languageName: node + linkType: hard + "mute-stream@npm:0.0.8": version: 0.0.8 resolution: "mute-stream@npm:0.0.8" @@ -30756,6 +30958,18 @@ __metadata: languageName: node linkType: hard +"pbf@npm:^3.2.1, pbf@npm:^3.3.0": + version: 3.3.0 + resolution: "pbf@npm:3.3.0" + dependencies: + ieee754: "npm:^1.1.12" + resolve-protobuf-schema: "npm:^2.1.0" + bin: + pbf: bin/pbf + checksum: 10/46488694528740097c33443efa240ca7f99538a2b96e9fbd2284d9be45ec91dab6954b9c03df237656ac2757d7f046153a031ad24519b4cfcaa2e7b18ddeb5dd + languageName: node + linkType: hard + "pbkdf2@npm:^3.0.3, pbkdf2@npm:^3.1.2": version: 3.1.2 resolution: "pbkdf2@npm:3.1.2" @@ -31833,6 +32047,13 @@ __metadata: languageName: node linkType: hard +"potpack@npm:^2.0.0": + version: 2.1.0 + resolution: "potpack@npm:2.1.0" + checksum: 10/8b5c07c8569f06cb14a8034de3057129f8c4909837cb892eb6c1cbe79f06ee68f2e9fbc0610599e2e38a29a489e1ed72fbb4059e7ff6f648826fc961d4c0f607 + languageName: node + linkType: hard + "preact-router@npm:^4.1.2": version: 4.1.2 resolution: "preact-router@npm:4.1.2" @@ -32164,6 +32385,13 @@ __metadata: languageName: node linkType: hard +"protocol-buffers-schema@npm:^3.3.1": + version: 3.6.0 + resolution: "protocol-buffers-schema@npm:3.6.0" + checksum: 10/55a1caed123fb2385eae5ea4770dc36b3017d1fe2005ffb1ef20c97dadf43a91876238ebc23bc240ef1f8501d054bdd9d12992796e9abed18ddf958e4f942eea + languageName: node + linkType: hard + "proxy-addr@npm:~2.0.7": version: 2.0.7 resolution: "proxy-addr@npm:2.0.7" @@ -32441,6 +32669,20 @@ __metadata: languageName: node linkType: hard +"quickselect@npm:^2.0.0": + version: 2.0.0 + resolution: "quickselect@npm:2.0.0" + checksum: 10/ed2e78431050d223fb75da20ee98011aef1a03f7cb04e1a32ee893402e640be3cfb76d72e9dbe01edf3bb457ff6a62e5c2d85748424d1aa531f6ba50daef098c + languageName: node + linkType: hard + +"quickselect@npm:^3.0.0": + version: 3.0.0 + resolution: "quickselect@npm:3.0.0" + checksum: 10/8f72bedb8bb14bce5c3767c55f567bc296fa3ca9d98ba385e3867e434463bc633feee1eddf3dfec17914b7e88feeb08c7b313cf47114a8ff11bf964f77f51cfc + languageName: node + linkType: hard + "raf-schd@npm:^4.0.2, raf-schd@npm:^4.0.3": version: 4.0.3 resolution: "raf-schd@npm:4.0.3" @@ -33579,6 +33821,15 @@ __metadata: languageName: node linkType: hard +"resolve-protobuf-schema@npm:^2.1.0": + version: 2.1.0 + resolution: "resolve-protobuf-schema@npm:2.1.0" + dependencies: + protocol-buffers-schema: "npm:^3.3.1" + checksum: 10/88fffab2a3757888884a36f9aa4e24be5186b01820a8c26297dc1ce406b9daf776594926bdf524c2c8e8e5b0aba8ac48362b6584cdecc9a7083215ebca01c599 + languageName: node + linkType: hard + "resolve-url-loader@npm:~5.0.0": version: 5.0.0 resolution: "resolve-url-loader@npm:5.0.0" @@ -33978,6 +34229,13 @@ __metadata: languageName: node linkType: hard +"rw@npm:^1.3.3": + version: 1.3.3 + resolution: "rw@npm:1.3.3" + checksum: 10/e90985d64777a00f4ab5f8c0bfea2fb5645c6bda5238840afa339c8a4f86f776e8ce83731155643a7425a0b27ce89077dab27b2f57519996ba4d2fe54cac1941 + languageName: node + linkType: hard + "rxjs@npm:^7.5.5, rxjs@npm:^7.8.1": version: 7.8.2 resolution: "rxjs@npm:7.8.2" @@ -35930,6 +36188,15 @@ __metadata: languageName: node linkType: hard +"supercluster@npm:^8.0.1": + version: 8.0.1 + resolution: "supercluster@npm:8.0.1" + dependencies: + kdbush: "npm:^4.0.2" + checksum: 10/3e517e54087fe24efa274013a5c116b0083d140d3cd855c5004fef61b64dde83510d2a65cd6565349b693751936aede2305f76d84778e6107ae1b3ffa18a5bae + languageName: node + linkType: hard + "supertest@npm:~7.1.4": version: 7.1.4 resolution: "supertest@npm:7.1.4" @@ -36521,6 +36788,13 @@ __metadata: languageName: node linkType: hard +"tinyqueue@npm:^3.0.0": + version: 3.0.0 + resolution: "tinyqueue@npm:3.0.0" + checksum: 10/44195ae628e98f4de49acefac1fafa63a7f2b5d8a5c23ace6f49917109db3435db8ec9854f87c0d50f8a8c6a73f1526f3941921618a071e4ee1d246afacf69bb + languageName: node + linkType: hard + "tinyrainbow@npm:^1.2.0": version: 1.2.0 resolution: "tinyrainbow@npm:1.2.0" @@ -38157,6 +38431,17 @@ __metadata: languageName: node linkType: hard +"vt-pbf@npm:^3.1.3": + version: 3.1.3 + resolution: "vt-pbf@npm:3.1.3" + dependencies: + "@mapbox/point-geometry": "npm:0.1.0" + "@mapbox/vector-tile": "npm:^1.3.1" + pbf: "npm:^3.2.1" + checksum: 10/e1b6c5611440f3c5aa6eee59d50d755c8225836c21ed491f880f1de304052967e9534626969119f79a1a1b276c3b9fe01200c2c213e8c7c668297d24ebe515e7 + languageName: node + linkType: hard + "w3c-keyname@npm:^2.2.4": version: 2.2.7 resolution: "w3c-keyname@npm:2.2.7"