From 7cc8d09b5b8c78645e2437bc56a7a010c360c5de Mon Sep 17 00:00:00 2001 From: Eva Gao Date: Mon, 11 Aug 2025 04:00:18 +0000 Subject: [PATCH 01/14] current and live locatiton implement --- .gitpod.yml | 11 + .../message/content/location/MapView.tsx | 8 +- .../room/ShareLocation/LiveLocationModal.tsx | 604 ++++++++++++++++++ .../room/ShareLocation/ShareLocationModal.tsx | 105 ++- .../room/ShareLocation/liveLocationService.ts | 250 ++++++++ .../useLiveLocationStopListener.ts | 119 ++++ .../hooks/useShareLocationAction.tsx | 3 +- 7 files changed, 1076 insertions(+), 24 deletions(-) create mode 100644 .gitpod.yml create mode 100644 apps/meteor/client/views/room/ShareLocation/LiveLocationModal.tsx create mode 100644 apps/meteor/client/views/room/ShareLocation/liveLocationService.ts create mode 100644 apps/meteor/client/views/room/ShareLocation/useLiveLocationStopListener.ts diff --git a/.gitpod.yml b/.gitpod.yml new file mode 100644 index 0000000000000..8238b63060d60 --- /dev/null +++ b/.gitpod.yml @@ -0,0 +1,11 @@ +# This configuration file was automatically generated by Gitpod. +# Please adjust to your needs (see https://www.gitpod.io/docs/introduction/learn-gitpod/gitpod-yaml) +# and commit this file to your remote git repository to share the goodness with others. + +# Learn more from ready-to-use templates: https://www.gitpod.io/docs/introduction/getting-started/quickstart + +tasks: + - init: yarn install && yarn run build + command: yarn run dev + + diff --git a/apps/meteor/client/components/message/content/location/MapView.tsx b/apps/meteor/client/components/message/content/location/MapView.tsx index 0337b36195f28..6dae22ecca57a 100644 --- a/apps/meteor/client/components/message/content/location/MapView.tsx +++ b/apps/meteor/client/components/message/content/location/MapView.tsx @@ -11,14 +11,12 @@ type MapViewProps = { }; const MapView = ({ latitude, longitude }: MapViewProps) => { - const googleMapsApiKey = useSetting('MapView_GMapsAPIKey', ''); + const locationIQKey = 'pk.898e468814facdcffda869b42260a2f0'; - const linkUrl = `https://maps.google.com/maps?daddr=${latitude},${longitude}`; + const linkUrl = `https://www.openstreetmap.org/?mlat=${latitude}&mlon=${longitude}#map=16/${latitude}/${longitude}`; const imageUrl = useAsyncImage( - googleMapsApiKey - ? `https://maps.googleapis.com/maps/api/staticmap?zoom=14&size=250x250&markers=color:gray%7Clabel:%7C${latitude},${longitude}&key=${googleMapsApiKey}` - : undefined, + `https://maps.locationiq.com/v2/staticmap?key=${locationIQKey}¢er=${latitude},${longitude}&zoom=16&size=250x250&markers=icon:small-red-cutout|${latitude},${longitude}`, ); if (!linkUrl) { diff --git a/apps/meteor/client/views/room/ShareLocation/LiveLocationModal.tsx b/apps/meteor/client/views/room/ShareLocation/LiveLocationModal.tsx new file mode 100644 index 0000000000000..edbe2703ce89e --- /dev/null +++ b/apps/meteor/client/views/room/ShareLocation/LiveLocationModal.tsx @@ -0,0 +1,604 @@ +// LiveLocationModal.tsx - Enhanced version with persistent modal +import { useEffect, useRef, useState, useCallback } from 'react'; +import type { ReactElement } from 'react'; +import { useEndpoint } from '@rocket.chat/ui-contexts'; +import { LiveLocationService, type LocationState } from './liveLocationService'; +import { useLiveLocationStopListener } from './useLiveLocationStopListener'; + +type Props = { + rid: string; + tmid?: string; + onClose: () => void; +}; + +const LiveLocationChatWidget = ({ rid, tmid, onClose }: Props): ReactElement => { + const [position, setPosition] = useState(null); + const [locationState, setLocationState] = useState('waiting'); + const [error, setError] = useState(null); + const [gpsUpdateCount, setGpsUpdateCount] = useState(0); + const [lastMessageUpdate, setLastMessageUpdate] = useState(null); + + const [isMinimized, setIsMinimized] = useState(false); + const [isDragging, setIsDragging] = useState(false); + const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 }); + const [position2D, setPosition2D] = useState({ x: 0, y: 0 }); + + const messageIdRef = useRef(null); + const sendingRef = useRef(false); + const isClosingRef = useRef(false); + const isSharingRef = useRef(false); + const serviceRef = useRef(null); + const modalRef = useRef(null); + + const sendMessage = useEndpoint('POST', '/v1/chat.sendMessage'); + const updateMessage = useEndpoint('POST', '/v1/chat.update'); + const { stopLiveLocationSharing } = useLiveLocationStopListener(); + + // Prevent modal from being closed when clicking outside while sharing + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (isSharingRef.current && modalRef.current && !modalRef.current.contains(event.target as Node)) { + // Prevent the modal from closing when sharing is active + event.stopPropagation(); + event.preventDefault(); + } + }; + + const handleEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape' && isSharingRef.current) { + // Prevent ESC key from closing when sharing + event.stopPropagation(); + event.preventDefault(); + } + }; + + document.addEventListener('mousedown', handleClickOutside, true); + document.addEventListener('keydown', handleEscape, true); + + return () => { + document.removeEventListener('mousedown', handleClickOutside, true); + document.removeEventListener('keydown', handleEscape, true); + }; + }, []); + + // Dragging functionality + const handleMouseDown = useCallback((e: React.MouseEvent) => { + if (e.target === e.currentTarget || (e.target as HTMLElement).classList.contains('drag-handle')) { + setIsDragging(true); + const rect = modalRef.current?.getBoundingClientRect(); + if (rect) { + setDragOffset({ + x: e.clientX - rect.left, + y: e.clientY - rect.top + }); + } + } + }, []); + + useEffect(() => { + const handleMouseMove = (e: MouseEvent) => { + if (isDragging && modalRef.current) { + const newX = e.clientX - dragOffset.x; + const newY = e.clientY - dragOffset.y; + + // Keep within viewport bounds + const maxX = window.innerWidth - modalRef.current.offsetWidth; + const maxY = window.innerHeight - modalRef.current.offsetHeight; + + setPosition2D({ + x: Math.max(0, Math.min(newX, maxX)), + y: Math.max(0, Math.min(newY, maxY)) + }); + } + }; + + const handleMouseUp = () => { + setIsDragging(false); + }; + + if (isDragging) { + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + } + + return () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + }, [isDragging, dragOffset]); + + // Initialize service + useEffect(() => { + serviceRef.current = new LiveLocationService({ + locationIQKey: 'pk.898e468814facdcffda869b42260a2f0', + updateInterval: 10000, + minMoveMeters: 5 + }); + + if (typeof window !== 'undefined') { + (window as any).liveLocationService = serviceRef.current; + } + + return () => { + if (serviceRef.current && !isSharingRef.current) { + serviceRef.current.cleanup(); + } + if (typeof window !== 'undefined') { + delete (window as any).liveLocationService; + } + }; + }, []); + + const sendInitialMessage = useCallback( + async (pos: GeolocationPosition) => { + if (!serviceRef.current || sendingRef.current || isClosingRef.current) return; + sendingRef.current = true; + + try { + const attachment = serviceRef.current.createLocationAttachment(pos, true); + const response = await sendMessage({ + message: { + rid, + tmid, + attachments: [attachment], + }, + }); + + if (!isClosingRef.current) { + messageIdRef.current = response.message._id; + serviceRef.current.updateLastUpdateTime(); + setLastMessageUpdate(new Date()); + LiveLocationService.storeLiveLocationData(response.message._id, rid); + } + } catch (err) { + console.error('[Send Initial Error]', err); + setError('Failed to send initial location'); + setLocationState('error'); + } finally { + sendingRef.current = false; + } + }, + [sendMessage, rid, tmid], + ); + + const updateLiveLocationMessage = useCallback( + async (pos: GeolocationPosition) => { + if (!serviceRef.current || !messageIdRef.current || sendingRef.current || isClosingRef.current) return; + + sendingRef.current = true; + try { + const attachment = serviceRef.current.createLocationAttachment(pos, true); + + const updatePayload = { + roomId: rid, + msgId: messageIdRef.current, + text: '', + attachments: [attachment], + customFields: {}, + }; + + await updateMessage(updatePayload); + serviceRef.current.updateLastUpdateTime(); + setLastMessageUpdate(new Date()); + } catch (err) { + console.error('[Update Location Error]', err); + } finally { + sendingRef.current = false; + } + }, + [updateMessage, rid], + ); + + const handlePositionSuccess = useCallback( + (pos: GeolocationPosition) => { + if (isClosingRef.current) return; + + const prev = position; + setPosition(pos); + setError(null); + setGpsUpdateCount((c) => c + 1); + + if (!isSharingRef.current) { + return; + } + + if (locationState !== 'sharing') setLocationState('sharing'); + + if (!messageIdRef.current) { + void sendInitialMessage(pos); + } else if (serviceRef.current?.shouldPushUpdate(prev, pos)) { + void updateLiveLocationMessage(pos); + } + }, + [position, locationState, sendInitialMessage, updateLiveLocationMessage], + ); + + const handlePositionError = useCallback( + (err: GeolocationPositionError) => { + if (isClosingRef.current) return; + + if (err.code === err.PERMISSION_DENIED) { + setError('Location permission is denied'); + setLocationState('error'); + return; + } + + console.warn('[Geolocation transient error]', err); + + if (isSharingRef.current && serviceRef.current) { + setTimeout(() => { + if (!isClosingRef.current && isSharingRef.current && serviceRef.current) { + serviceRef.current.startWatching(handlePositionSuccess, handlePositionError); + } + }, 1500); + } + }, + [handlePositionSuccess], + ); + + const startSharing = useCallback(() => { + if (isClosingRef.current || !serviceRef.current) return; + + isSharingRef.current = true; + setLocationState('sharing'); + serviceRef.current.startSharing(); + + if (position) { + handlePositionSuccess(position); + } + }, [position, handlePositionSuccess]); + + const stopSharing = useCallback(async () => { + if (!serviceRef.current) return; + + isSharingRef.current = false; + serviceRef.current.stopSharing(); + + await stopLiveLocationSharing(rid, messageIdRef.current || undefined, position || undefined); + + messageIdRef.current = null; + setLocationState('waiting'); + }, [stopLiveLocationSharing, rid, position]); + + const handleClose = useCallback(() => { + // Only allow closing if not currently sharing + if (isSharingRef.current) { + // Show confirmation dialog + if (window.confirm('Live location sharing is active. Stop sharing and close?')) { + stopSharing().then(() => { + isClosingRef.current = true; + if (serviceRef.current) { + serviceRef.current.cleanup(); + } + onClose(); + }); + } + return; + } + + isClosingRef.current = true; + if (serviceRef.current) { + serviceRef.current.cleanup(); + } + onClose(); + }, [onClose, stopSharing]); + + // Start watching on mount + useEffect(() => { + if (!serviceRef.current) return; + + serviceRef.current.startWatching(handlePositionSuccess, handlePositionError); + + return () => { + if (!isSharingRef.current && serviceRef.current) { + serviceRef.current.cleanup(); + } + }; + }, [handlePositionSuccess, handlePositionError]); + + const getStatusIcon = () => { + switch (locationState) { + case 'waiting': return 'πŸ“‘'; + case 'sharing': return 'πŸ“'; + case 'error': return '❌'; + default: return 'πŸ“‘'; + } + }; + + const getStatusText = () => { + switch (locationState) { + case 'waiting': return 'Getting location...'; + case 'sharing': return 'Live sharing active'; + case 'error': return 'Location error'; + default: return 'Preparing...'; + } + }; + + // Minimized view + if (isMinimized) { + return ( +
setIsMinimized(false)} + onMouseDown={handleMouseDown} + title={getStatusText()} + > + {getStatusIcon()} + {locationState === 'sharing' && ( +
+ )} +
+ ); + } + + return ( + <> + + +
+ {/* Header - draggable */} +
+
+ {getStatusIcon()} + + {getStatusText()} + + {isSharingRef.current && ( +
+ )} +
+
+ + + +
+
+ + {/* Content */} +
+ {locationState === 'error' && ( +
+

{error}

+

+ Please enable location access in your browser settings. +

+
+ )} + + {locationState === 'waiting' && ( + <> + {position ? ( +
+
+ Map preview +
+ +
+
Lat: {position.coords.latitude.toFixed(6)}
+
Lng: {position.coords.longitude.toFixed(6)}
+ {position.coords.accuracy && ( +
Accuracy: Β±{Math.round(position.coords.accuracy)}m
+ )} +
+ + +
+ ) : ( +
+
πŸ“‘
+
Getting your location...
+
+ )} + + )} + + {locationState === 'sharing' && ( +
+ {position && ( + <> +
+ Live location +
+ +
+
Lat: {position.coords.latitude.toFixed(6)}
+
Lng: {position.coords.longitude.toFixed(6)}
+ {position.coords.accuracy && ( +
Accuracy: Β±{Math.round(position.coords.accuracy)}m
+ )} +
+ {lastMessageUpdate && ( +
Last update: {lastMessageUpdate.toLocaleTimeString()}
+ )} +
+
+ +
+ βœ… Sharing live location every 10 seconds +
+ + )} + + +
+ )} +
+
+ + ); +}; + +export default LiveLocationChatWidget; \ No newline at end of file diff --git a/apps/meteor/client/views/room/ShareLocation/ShareLocationModal.tsx b/apps/meteor/client/views/room/ShareLocation/ShareLocationModal.tsx index 38ee2aab125c7..4e05132cc05b0 100644 --- a/apps/meteor/client/views/room/ShareLocation/ShareLocationModal.tsx +++ b/apps/meteor/client/views/room/ShareLocation/ShareLocationModal.tsx @@ -1,58 +1,85 @@ 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 { + useEndpoint, + useTranslation, + useToastMessageDispatch, + useSetting, +} from '@rocket.chat/ui-contexts'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import type { ReactElement } from 'react'; import { getGeolocationPermission } from './getGeolocationPermission'; import { getGeolocationPosition } from './getGeolocationPosition'; import MapView from '../../../components/message/content/location/MapView'; +import { useState } from 'react'; +import LiveLocationModal from './LiveLocationModal'; + type ShareLocationModalProps = { rid: IRoom['_id']; - tmid: IMessage['tmid']; + tmid?: IMessage['tmid']; onClose: () => void; }; const ShareLocationModal = ({ rid, tmid, onClose }: ShareLocationModalProps): ReactElement => { const t = useTranslation(); const dispatchToast = useToastMessageDispatch(); + const queryClient = useQueryClient(); + const sendMessage = useEndpoint('POST', '/v1/chat.sendMessage'); + //const googleMapsApiKey = 'AIzaSyBeNJSMCi8kD4c6SOvZ4vxHnWYp2yzDbmg'; + const [choice, setChoice] = useState<'current' | 'live' | null>(null); + // const googleMapsApiKey = useSetting('MapView_GMapsAPIKey') as string; + const { data: permissionState, isLoading: permissionLoading } = useQuery({ queryKey: ['geolocationPermission'], queryFn: getGeolocationPermission, }); + const { data: positionData } = useQuery({ queryKey: ['geolocationPosition', permissionState], - queryFn: async () => { - if (permissionLoading || permissionState === 'prompt' || permissionState === 'denied') { + if (permissionLoading || permissionState !== 'granted') { return; } return getGeolocationPosition(); }, + enabled: permissionState === 'granted', }); - const queryClient = useQueryClient(); - - const sendMessage = useEndpoint('POST', '/v1/chat.sendMessage'); - const onConfirm = (): void => { if (!positionData) { - throw new Error('Failed to load position'); + dispatchToast({ type: 'error', message: 'Location not available.' }); + return; } + + const { latitude, longitude } = positionData.coords; + + const mapsLink = `https://www.openstreetmap.org/?mlat=${latitude}&mlon=${longitude}#map=16/${latitude}/${longitude}`; + + const locationIQKey = 'pk.898e468814facdcffda869b42260a2f0'; + const staticMapUrl = `https://maps.locationiq.com/v2/staticmap?key=${locationIQKey}¢er=${latitude},${longitude}&zoom=17&size=512x512&markers=icon:small-red-cutout|${latitude},${longitude}`; + try { sendMessage({ message: { rid, tmid, - location: { - type: 'Point', - coordinates: [positionData.coords.longitude, positionData.coords.latitude], - }, + attachments: [ + { + ts: new Date(), + title: 'πŸ“ Shared Location', // Add this + title_link: mapsLink, // link to OSM + title_link_download: false, + image_url: staticMapUrl, // Map image + // REMOVE thumb_url to avoid the broken "Retry" + description: `Latitude: ${latitude.toFixed(5)}, Longitude: ${longitude.toFixed(5)}`, + }, + ], }, }); } catch (error) { - dispatchToast({ type: 'error', message: error }); + dispatchToast({ type: 'error', message: error instanceof Error ? error.message : String(error) }); } finally { onClose(); } @@ -64,10 +91,38 @@ const ShareLocationModal = ({ rid, tmid, onClose }: ShareLocationModalProps): Re queryClient.setQueryData(['geolocationPosition', 'granted'], position); queryClient.setQueryData(['geolocationPermission'], 'granted'); } catch (e) { - queryClient.setQueryData(['geolocationPermission'], () => getGeolocationPermission); + queryClient.setQueryData(['geolocationPermission'], () => getGeolocationPermission()); } }; + if (!choice) { + return ( + { + setChoice('live'); + }} + cancelText="Live Location" + confirmText="Current Location" + onConfirm={() => setChoice('current')} + > + Would you like to share your current location or start live location sharing? + + ); + } + + if (choice === 'live') { + return ( + + ); + } + + if (permissionLoading || permissionState === 'prompt') { return ( + {t('The_necessary_browser_permissions_for_location_sharing_are_not_granted')} ); } return ( - - + + ); }; diff --git a/apps/meteor/client/views/room/ShareLocation/liveLocationService.ts b/apps/meteor/client/views/room/ShareLocation/liveLocationService.ts new file mode 100644 index 0000000000000..0ff37ef1b03f8 --- /dev/null +++ b/apps/meteor/client/views/room/ShareLocation/liveLocationService.ts @@ -0,0 +1,250 @@ +// liveLocationService.ts +export type LocationState = 'waiting' | 'sharing' | 'error'; + +export interface LocationServiceConfig { + locationIQKey: string; + updateInterval: number; + minMoveMeters: number; +} + +export class LiveLocationService { + private config: LocationServiceConfig; + private watchId: number | null = null; + private pollTimerRef: number | null = null; + private sharingIntervalRef: number | null = null; + private isSharing = false; + private lastUpdateTime = 0; + private onPositionSuccess?: (position: GeolocationPosition) => void; + private onPositionError?: (error: GeolocationPositionError) => void; + + constructor(config: LocationServiceConfig) { + this.config = config; + this.setupDevelopmentMock(); + } + + private setupDevelopmentMock() { + if (process.env.NODE_ENV === 'development') { + let lat = 40.7128; // starting latitude (NYC) + let lon = -74.0060; // starting longitude (NYC) + + navigator.geolocation.getCurrentPosition = (success, error) => { + success({ + coords: { + latitude: lat, + longitude: lon, + accuracy: 10, + altitude: null, + altitudeAccuracy: null, + heading: null, + speed: null + }, + timestamp: Date.now() + } as GeolocationPosition); + }; + + navigator.geolocation.watchPosition = (success, error) => { + const watchId = setInterval(() => { + // Simulate movement + lat += (Math.random() - 0.5) * 0.001; + lon += (Math.random() - 0.5) * 0.001; + + success({ + coords: { + latitude: lat, + longitude: lon, + accuracy: 10, + altitude: null, + altitudeAccuracy: null, + heading: null, + speed: null + }, + timestamp: Date.now() + } as GeolocationPosition); + }, 10000); // every 5 seconds for testing + + return watchId as unknown as number; + }; + + navigator.geolocation.clearWatch = (id) => clearInterval(id as unknown as number); + } + } + + generateMapUrls(latitude: number, longitude: number) { + const mapsLink = `https://www.openstreetmap.org/?mlat=${latitude}&mlon=${longitude}#map=16/${latitude}/${longitude}`; + const staticMapUrl = `https://maps.locationiq.com/v2/staticmap?key=${this.config.locationIQKey}¢er=${latitude},${longitude}&zoom=17&size=512x512&markers=icon:small-red-cutout|${latitude},${longitude}`; + return { mapsLink, staticMapUrl }; + } + + createLocationAttachment(pos: GeolocationPosition, isLive: boolean = false) { + const { latitude, longitude, accuracy, heading, speed } = pos.coords; + const { mapsLink, staticMapUrl } = this.generateMapUrls(latitude, longitude); + + const baseAttachment = { + ts: new Date(), + title: isLive ? 'πŸ“ Live Location (Sharing)' : 'πŸ“ Location', + title_link: mapsLink, + image_url: staticMapUrl, + description: [ + `Lat: ${latitude.toFixed(5)}, Lng: ${longitude.toFixed(5)}`, + accuracy ? `Accuracy: Β±${Math.round(accuracy)}m` : null, + heading != null ? `Heading: ${heading.toFixed(1)}Β°` : null, + speed != null ? `Speed: ${speed.toFixed(1)} m/s` : null, + isLive ? `Updated: ${new Date().toLocaleTimeString()}` : null, + ] + .filter(Boolean) + .join(' β€’ '), + }; + + // Add action buttons for live location + if (isLive) { + return { + ...baseAttachment, + actions: [ + { + type: 'button', + text: 'πŸ›‘ Stop Sharing', + msg: '/stop-live-location', + msg_in_chat_window: false, + msg_processing_type: 'sendMessage' + } + ] + }; + } + + return baseAttachment; + } + + private haversineMeters(a: GeolocationCoordinates, b: GeolocationCoordinates): number { + const toRad = (x: number) => (x * Math.PI) / 180; + const R = 6371000; + const dLat = toRad(b.latitude - a.latitude); + const dLon = toRad(b.longitude - a.longitude); + const lat1 = toRad(a.latitude); + const lat2 = toRad(b.latitude); + const h = + Math.sin(dLat / 2) ** 2 + + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) ** 2; + return 2 * R * Math.asin(Math.sqrt(h)); + } + + shouldPushUpdate(prev: GeolocationPosition | null, curr: GeolocationPosition): boolean { + const now = Date.now(); + if (now - this.lastUpdateTime >= this.config.updateInterval) return true; + if (!prev) return true; + return this.haversineMeters(prev.coords, curr.coords) >= this.config.minMoveMeters; + } + + startWatching( + onSuccess: (position: GeolocationPosition) => void, + onError: (error: GeolocationPositionError) => void + ) { + if (!navigator.geolocation) { + onError({ + code: 2, + message: 'Geolocation is not supported by this browser', + PERMISSION_DENIED: 1, + POSITION_UNAVAILABLE: 2, + TIMEOUT: 3 + } as GeolocationPositionError); + return; + } + + this.onPositionSuccess = onSuccess; + this.onPositionError = onError; + + this.watchId = navigator.geolocation.watchPosition( + onSuccess, + onError, + { enableHighAccuracy: true, timeout: 20000, maximumAge: 0 } + ); + + // Optional heartbeat to smooth out quiet watches + this.pollTimerRef = window.setInterval(() => { + navigator.geolocation.getCurrentPosition( + onSuccess, + onError, + { enableHighAccuracy: true, timeout: 20000, maximumAge: 0 } + ); + }, this.config.updateInterval); + } + + startSharing() { + this.isSharing = true; + + // Set up continuous sharing interval + this.sharingIntervalRef = window.setInterval(() => { + if (this.isSharing && this.onPositionSuccess) { + navigator.geolocation.getCurrentPosition( + (pos) => { + if (this.isSharing && this.onPositionSuccess) { + this.onPositionSuccess(pos); + } + }, + (error) => { + if (this.onPositionError) { + this.onPositionError(error); + } + }, + { enableHighAccuracy: true, timeout: 20000, maximumAge: 0 } + ); + } + }, this.config.updateInterval); + } + + stopSharing() { + this.isSharing = false; + + if (this.sharingIntervalRef !== null) { + clearInterval(this.sharingIntervalRef); + this.sharingIntervalRef = null; + } + } + + cleanup() { + this.stopSharing(); + + if (this.watchId !== null) { + navigator.geolocation.clearWatch(this.watchId); + this.watchId = null; + } + + if (this.pollTimerRef !== null) { + clearInterval(this.pollTimerRef); + this.pollTimerRef = null; + } + } + + updateLastUpdateTime() { + this.lastUpdateTime = Date.now(); + } + + get isSharingActive(): boolean { + return this.isSharing; + } + + // Storage helpers + static storeLiveLocationData(messageId: string, roomId: string) { + if (typeof window !== 'undefined') { + window.localStorage.setItem('liveLocationMessageId', messageId); + window.localStorage.setItem('liveLocationRoomId', roomId); + } + } + + static getLiveLocationData(): { messageId: string | null; roomId: string | null } { + if (typeof window === 'undefined') { + return { messageId: null, roomId: null }; + } + + return { + messageId: window.localStorage.getItem('liveLocationMessageId'), + roomId: window.localStorage.getItem('liveLocationRoomId') + }; + } + + static clearLiveLocationData() { + if (typeof window !== 'undefined') { + window.localStorage.removeItem('liveLocationMessageId'); + window.localStorage.removeItem('liveLocationRoomId'); + } + } +} \ No newline at end of file diff --git a/apps/meteor/client/views/room/ShareLocation/useLiveLocationStopListener.ts b/apps/meteor/client/views/room/ShareLocation/useLiveLocationStopListener.ts new file mode 100644 index 0000000000000..3fa3baa1b72cd --- /dev/null +++ b/apps/meteor/client/views/room/ShareLocation/useLiveLocationStopListener.ts @@ -0,0 +1,119 @@ +// useLiveLocationStopListener.ts +import { useCallback, useEffect } from 'react'; +import { useEndpoint } from '@rocket.chat/ui-contexts'; +import { LiveLocationService } from './liveLocationService'; + +export const useLiveLocationStopListener = () => { + const updateMessage = useEndpoint('POST', '/v1/chat.update'); + + const stopLiveLocationSharing = useCallback(async ( + rid?: string, + messageId?: string, + currentPosition?: GeolocationPosition + ) => { + // Get stored data if not provided + const { messageId: storedMessageId, roomId: storedRoomId } = LiveLocationService.getLiveLocationData(); + + const finalMessageId = messageId || storedMessageId; + const finalRoomId = rid || storedRoomId; + + if (!finalMessageId || !finalRoomId) { + console.warn('No active live location sharing found'); + return false; + } + + try { + // Stop the sharing service if available globally + if ((window as any).liveLocationService) { + (window as any).liveLocationService.stopSharing(); + } + + // Update the message to remove live status if we have current position + if (currentPosition) { + const service = new LiveLocationService({ + locationIQKey: 'pk.898e468814facdcffda869b42260a2f0', // TODO: move to config + updateInterval: 10000, + minMoveMeters: 5 + }); + + const finalAttachment = service.createLocationAttachment(currentPosition, false); + finalAttachment.title = 'πŸ“ Location (Sharing Stopped)'; + + const updatePayload = { + roomId: finalRoomId, + msgId: finalMessageId, + text: '', + attachments: [finalAttachment], + customFields: {}, + }; + + await updateMessage(updatePayload); + } + + // Clean up stored data + LiveLocationService.clearLiveLocationData(); + + return true; + } catch (error) { + console.error('Error stopping live location:', error); + return false; + } + }, [updateMessage]); + + const handleStopCommand = useCallback(async (rid: string) => { + const { messageId, roomId } = LiveLocationService.getLiveLocationData(); + + if (!messageId || roomId !== rid) { + console.warn('No active live location sharing found for this room'); + return false; + } + + return await stopLiveLocationSharing(rid, messageId); + }, [stopLiveLocationSharing]); + + // Set up global stop function + useEffect(() => { + if (typeof window !== 'undefined') { + (window as any).stopLiveLocationSharing = stopLiveLocationSharing; + } + + return () => { + if (typeof window !== 'undefined') { + delete (window as any).stopLiveLocationSharing; + } + }; + }, [stopLiveLocationSharing]); + + return { + stopLiveLocationSharing, + handleStopCommand + }; +}; + +// Slash command processor +export const processLiveLocationSlashCommand = ( + message: string, + rid: string, + handleStopCommand: (rid: string) => Promise +): boolean => { + if (message === '/stop-live-location') { + handleStopCommand(rid); + return true; // Prevent the message from being sent to chat + } + + return false; // Let other processors handle it +}; + +// Button action handler +export const handleLiveLocationAction = ( + action: any, + message: any, + handleStopCommand: (rid: string) => Promise +): boolean => { + if (action.msg === '/stop-live-location') { + handleStopCommand(message.rid); + return true; + } + + return false; // Let other handlers process it +}; \ No newline at end of file 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..650d9d2b647ae 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 @@ -21,7 +21,8 @@ export const useShareLocationAction = (room?: IRoom, tmid?: IMessage['tmid']): G const handleShareLocation = () => setModal( setModal(null)} />); - const allowGeolocation = room && canGetGeolocation && !isRoomFederated(room); + const allowGeolocation = true; + // const allowGeolocation = room && canGetGeolocation && !isRoomFederated(room); return { id: 'share-location', From c98e05c0b69db419ec854c4b912e9fc7a823efa6 Mon Sep 17 00:00:00 2001 From: Eva Gao Date: Tue, 19 Aug 2025 03:26:05 +0000 Subject: [PATCH 02/14] feat: static & live location sharing (draft) --- .../room/ShareLocation/LiveLocationModal.tsx | 629 ++++++++++-------- .../views/room/ShareLocation/MapView.tsx | 89 +++ .../room/ShareLocation/ShareLocationModal.tsx | 435 +++++++----- .../ShareLocation/getGeolocationPosition.ts | 145 +++- .../views/room/ShareLocation/mapProvider.ts | 66 ++ 5 files changed, 916 insertions(+), 448 deletions(-) create mode 100644 apps/meteor/client/views/room/ShareLocation/MapView.tsx create mode 100644 apps/meteor/client/views/room/ShareLocation/mapProvider.ts diff --git a/apps/meteor/client/views/room/ShareLocation/LiveLocationModal.tsx b/apps/meteor/client/views/room/ShareLocation/LiveLocationModal.tsx index edbe2703ce89e..73a1089ab2b50 100644 --- a/apps/meteor/client/views/room/ShareLocation/LiveLocationModal.tsx +++ b/apps/meteor/client/views/room/ShareLocation/LiveLocationModal.tsx @@ -1,9 +1,10 @@ -// LiveLocationModal.tsx - Enhanced version with persistent modal -import { useEffect, useRef, useState, useCallback } from 'react'; +// LiveLocationModal.tsx β€” provider-aware live location (clean Google / OSM) +import { useEffect, useRef, useState, useCallback, useMemo } from 'react'; import type { ReactElement } from 'react'; import { useEndpoint } from '@rocket.chat/ui-contexts'; import { LiveLocationService, type LocationState } from './liveLocationService'; import { useLiveLocationStopListener } from './useLiveLocationStopListener'; +import { createMapProvider, type MapProviderName, type MapProvider } from './mapProvider'; type Props = { rid: string; @@ -34,43 +35,123 @@ const LiveLocationChatWidget = ({ rid, tmid, onClose }: Props): ReactElement => const updateMessage = useEndpoint('POST', '/v1/chat.update'); const { stopLiveLocationSharing } = useLiveLocationStopListener(); - // Prevent modal from being closed when clicking outside while sharing + // --- Provider (shared via localStorage with the static modal) ------------- + const [provider, setProvider] = useState(() => { + const saved = localStorage.getItem('mapProvider') as MapProviderName | null; + return saved ?? 'openstreetmap'; + }); + useEffect(() => { + const onStorage = (e: StorageEvent) => { + if (e.key === 'mapProvider' && e.newValue) setProvider(e.newValue as MapProviderName); + }; + window.addEventListener('storage', onStorage); + return () => window.removeEventListener('storage', onStorage); + }, []); + + // Keys (swap to app settings if you have them) + const googleMapsApiKey = 'AIzaSyBeNJSMCi8kD4c6SOvZ4vxHnWYp2yzDbmg'; + const locationIQKey = 'pk.898e468814facdcffda869b42260a2f0'; + + const map: MapProvider = useMemo( + () => + createMapProvider(provider, { + googleApiKey: googleMapsApiKey, + locationIqKey: locationIQKey, + }), + [provider, googleMapsApiKey, locationIQKey], + ); + + const cacheBust = (url: string) => url + (url.includes('?') ? '&' : '?') + 'ts=' + Date.now(); + + // ---------- Provider-aware attachment (clean theme + retina) -------------- + const createLiveLocationAttachment = useCallback( + (pos: GeolocationPosition, isLive: boolean = true) => { + const { latitude, longitude, accuracy } = pos.coords; + const mapsLink = map.getMapsLink(latitude, longitude); + const staticMapUrl = map.getStaticMapUrl(latitude, longitude, { + zoom: 16, + width: 640, + height: 360, + // theme + scale are used by the Google provider; ignored by OSM provider + // @ts-expect-error allow extra options if your interface is stricter + theme: 'clean', + scale: 2, + }); + + return { + ts: new Date(), + title: isLive ? 'πŸ“ Live Location (Active)' : 'πŸ“ Location Shared', + title_link: mapsLink, + image_url: staticMapUrl, + image_type: 'image/png', + text: isLive ? 'Click to view live location updates' : 'Static location', + // Keep fields present (empty) to satisfy renderers that assume the key exists + fields: [] as any[], + actions: [ + { + type: 'button', + text: isLive ? 'πŸ‘οΈ View Live Location' : 'πŸ—ΊοΈ View Location', + msg: `/viewlocation ${messageIdRef.current || 'temp'}`, + msg_in_chat_window: false, + msg_processing_type: 'sendMessage', + }, + ], + // Optional: include coords + // description: `Lat ${latitude.toFixed(6)}, Lng ${longitude.toFixed(6)}${ + // accuracy ? ` (Β±${Math.round(accuracy)}m)` : '' + // }`, + customFields: { + isLiveLocation: isLive, + locationId: messageIdRef.current, + lastUpdate: new Date().toISOString(), + coordinates: { lat: latitude, lng: longitude, accuracy }, + }, + }; + }, + [map], + ); + + // ---------- Prevent closing while sharing --------------------------------- useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if (isSharingRef.current && modalRef.current && !modalRef.current.contains(event.target as Node)) { - // Prevent the modal from closing when sharing is active event.stopPropagation(); event.preventDefault(); } }; - const handleEscape = (event: KeyboardEvent) => { if (event.key === 'Escape' && isSharingRef.current) { - // Prevent ESC key from closing when sharing event.stopPropagation(); event.preventDefault(); } }; - document.addEventListener('mousedown', handleClickOutside, true); document.addEventListener('keydown', handleEscape, true); - return () => { document.removeEventListener('mousedown', handleClickOutside, true); document.removeEventListener('keydown', handleEscape, true); }; }, []); - // Dragging functionality + // ---------- Initial placement --------------------------------------------- + useEffect(() => { + const w = typeof window !== 'undefined' ? window.innerWidth : 1200; + const h = typeof window !== 'undefined' ? window.innerHeight : 800; + const defaultWidth = 360; + const defaultHeight = 240; + setPosition2D({ + x: Math.max(12, w - defaultWidth - 12), + y: Math.max(60, h - defaultHeight - 12), + }); + }, []); + + // ---------- Dragging ------------------------------------------------------ const handleMouseDown = useCallback((e: React.MouseEvent) => { if (e.target === e.currentTarget || (e.target as HTMLElement).classList.contains('drag-handle')) { setIsDragging(true); const rect = modalRef.current?.getBoundingClientRect(); if (rect) { - setDragOffset({ - x: e.clientX - rect.left, - y: e.clientY - rect.top - }); + setDragOffset({ x: e.clientX - rect.left, y: e.clientY - rect.top }); } } }, []); @@ -80,66 +161,56 @@ const LiveLocationChatWidget = ({ rid, tmid, onClose }: Props): ReactElement => if (isDragging && modalRef.current) { const newX = e.clientX - dragOffset.x; const newY = e.clientY - dragOffset.y; - - // Keep within viewport bounds const maxX = window.innerWidth - modalRef.current.offsetWidth; const maxY = window.innerHeight - modalRef.current.offsetHeight; - setPosition2D({ x: Math.max(0, Math.min(newX, maxX)), - y: Math.max(0, Math.min(newY, maxY)) + y: Math.max(0, Math.min(newY, maxY)), }); } }; - - const handleMouseUp = () => { - setIsDragging(false); - }; + const handleMouseUp = () => setIsDragging(false); if (isDragging) { document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); } - return () => { document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); }; }, [isDragging, dragOffset]); - // Initialize service + // ---------- Initialize live service (watchers/timers only) ---------------- useEffect(() => { serviceRef.current = new LiveLocationService({ - locationIQKey: 'pk.898e468814facdcffda869b42260a2f0', + locationIQKey: locationIQKey, updateInterval: 10000, - minMoveMeters: 5 + minMoveMeters: 5, }); - - if (typeof window !== 'undefined') { - (window as any).liveLocationService = serviceRef.current; - } - + if (typeof window !== 'undefined') (window as any).liveLocationService = serviceRef.current; return () => { - if (serviceRef.current && !isSharingRef.current) { - serviceRef.current.cleanup(); - } - if (typeof window !== 'undefined') { - delete (window as any).liveLocationService; - } + if (serviceRef.current && !isSharingRef.current) serviceRef.current.cleanup(); + if (typeof window !== 'undefined') delete (window as any).liveLocationService; }; - }, []); + }, [locationIQKey]); + // ---------- Messaging (provider-aware attachments) ------------------------ const sendInitialMessage = useCallback( async (pos: GeolocationPosition) => { if (!serviceRef.current || sendingRef.current || isClosingRef.current) return; sendingRef.current = true; - try { - const attachment = serviceRef.current.createLocationAttachment(pos, true); + const tempMessageId = `live-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`; + messageIdRef.current = tempMessageId; + + const attachment = createLiveLocationAttachment(pos, true); + const response = await sendMessage({ message: { rid, tmid, + msg: 'πŸ“ Started sharing live location', attachments: [attachment], }, }); @@ -149,6 +220,15 @@ const LiveLocationChatWidget = ({ rid, tmid, onClose }: Props): ReactElement => serviceRef.current.updateLastUpdateTime(); setLastMessageUpdate(new Date()); LiveLocationService.storeLiveLocationData(response.message._id, rid); + + const updatedAttachment = createLiveLocationAttachment(pos, true); + await updateMessage({ + roomId: rid, + msgId: response.message._id, + text: 'πŸ“ Started sharing live location', + attachments: [updatedAttachment], + customFields: {}, + } as any); } } catch (err) { console.error('[Send Initial Error]', err); @@ -158,26 +238,22 @@ const LiveLocationChatWidget = ({ rid, tmid, onClose }: Props): ReactElement => sendingRef.current = false; } }, - [sendMessage, rid, tmid], + [sendMessage, updateMessage, rid, tmid, createLiveLocationAttachment], ); const updateLiveLocationMessage = useCallback( async (pos: GeolocationPosition) => { if (!serviceRef.current || !messageIdRef.current || sendingRef.current || isClosingRef.current) return; - sendingRef.current = true; try { - const attachment = serviceRef.current.createLocationAttachment(pos, true); - - const updatePayload = { + const attachment = createLiveLocationAttachment(pos, true); + await updateMessage({ roomId: rid, msgId: messageIdRef.current, - text: '', + text: 'πŸ“ Live location (Active)', attachments: [attachment], customFields: {}, - }; - - await updateMessage(updatePayload); + } as any); serviceRef.current.updateLastUpdateTime(); setLastMessageUpdate(new Date()); } catch (err) { @@ -186,24 +262,18 @@ const LiveLocationChatWidget = ({ rid, tmid, onClose }: Props): ReactElement => sendingRef.current = false; } }, - [updateMessage, rid], + [updateMessage, rid, createLiveLocationAttachment], ); const handlePositionSuccess = useCallback( (pos: GeolocationPosition) => { if (isClosingRef.current) return; - const prev = position; setPosition(pos); setError(null); setGpsUpdateCount((c) => c + 1); - - if (!isSharingRef.current) { - return; - } - + if (!isSharingRef.current) return; if (locationState !== 'sharing') setLocationState('sharing'); - if (!messageIdRef.current) { void sendInitialMessage(pos); } else if (serviceRef.current?.shouldPushUpdate(prev, pos)) { @@ -216,15 +286,12 @@ const LiveLocationChatWidget = ({ rid, tmid, onClose }: Props): ReactElement => const handlePositionError = useCallback( (err: GeolocationPositionError) => { if (isClosingRef.current) return; - if (err.code === err.PERMISSION_DENIED) { setError('Location permission is denied'); setLocationState('error'); return; } - console.warn('[Geolocation transient error]', err); - if (isSharingRef.current && serviceRef.current) { setTimeout(() => { if (!isClosingRef.current && isSharingRef.current && serviceRef.current) { @@ -238,96 +305,94 @@ const LiveLocationChatWidget = ({ rid, tmid, onClose }: Props): ReactElement => const startSharing = useCallback(() => { if (isClosingRef.current || !serviceRef.current) return; - isSharingRef.current = true; setLocationState('sharing'); serviceRef.current.startSharing(); - - if (position) { - handlePositionSuccess(position); - } + if (position) handlePositionSuccess(position); }, [position, handlePositionSuccess]); const stopSharing = useCallback(async () => { if (!serviceRef.current) return; - isSharingRef.current = false; serviceRef.current.stopSharing(); + if (messageIdRef.current && position) { + try { + const finalAttachment = createLiveLocationAttachment(position, false); + await updateMessage({ + roomId: rid, + msgId: messageIdRef.current, + text: 'πŸ“ Live location sharing stopped', + attachments: [finalAttachment], + customFields: {}, + } as any); + } catch (err) { + console.error('[Stop sharing update error]', err); + } + } + await stopLiveLocationSharing(rid, messageIdRef.current || undefined, position || undefined); - messageIdRef.current = null; setLocationState('waiting'); - }, [stopLiveLocationSharing, rid, position]); + }, [stopLiveLocationSharing, rid, position, createLiveLocationAttachment, updateMessage]); const handleClose = useCallback(() => { - // Only allow closing if not currently sharing if (isSharingRef.current) { - // Show confirmation dialog if (window.confirm('Live location sharing is active. Stop sharing and close?')) { stopSharing().then(() => { isClosingRef.current = true; - if (serviceRef.current) { - serviceRef.current.cleanup(); - } + serviceRef.current?.cleanup(); onClose(); }); } return; } - isClosingRef.current = true; - if (serviceRef.current) { - serviceRef.current.cleanup(); - } + serviceRef.current?.cleanup(); onClose(); }, [onClose, stopSharing]); // Start watching on mount useEffect(() => { if (!serviceRef.current) return; - serviceRef.current.startWatching(handlePositionSuccess, handlePositionError); - return () => { - if (!isSharingRef.current && serviceRef.current) { - serviceRef.current.cleanup(); - } + if (!isSharingRef.current && serviceRef.current) serviceRef.current.cleanup(); }; }, [handlePositionSuccess, handlePositionError]); - const getStatusIcon = () => { - switch (locationState) { - case 'waiting': return 'πŸ“‘'; - case 'sharing': return 'πŸ“'; - case 'error': return '❌'; - default: return 'πŸ“‘'; - } - }; - - const getStatusText = () => { - switch (locationState) { - case 'waiting': return 'Getting location...'; - case 'sharing': return 'Live sharing active'; - case 'error': return 'Location error'; - default: return 'Preparing...'; - } - }; - - // Minimized view + const getStatusIcon = () => (locationState === 'waiting' ? 'πŸ“‘' : locationState === 'sharing' ? 'πŸ“' : '❌'); + const getStatusText = () => (locationState === 'waiting' ? 'Getting location...' : locationState === 'sharing' ? 'Live sharing active' : 'Location error'); + + // Provider-aware preview (clean theme + retina) + const previewUrl = + position + ? cacheBust( + map.getStaticMapUrl(position.coords.latitude, position.coords.longitude, { + zoom: 15, + width: 320, + height: 180, + // @ts-expect-error allow extra options if interface is strict + theme: 'clean', + scale: 2, + }), + ) + : ''; + + // -------------------------- UI ------------------------------------------- if (isMinimized) { return (
onMouseDown={handleMouseDown} title={getStatusText()} > - {getStatusIcon()} + {getStatusIcon()} {locationState === 'sharing' && ( -
+
)}
); @@ -359,45 +426,39 @@ const LiveLocationChatWidget = ({ rid, tmid, onClose }: Props): ReactElement => return ( <> - - + +
- {/* Header - draggable */} + {/* Header */}
cursor: 'move', }} > -
- {getStatusIcon()} - - {getStatusText()} - +
+ {getStatusIcon()} + {getStatusText()} {isSharingRef.current && ( -
+
)}
-
+ +
- @@ -465,140 +520,136 @@ const LiveLocationChatWidget = ({ rid, tmid, onClose }: Props): ReactElement =>
{/* Content */} -
- {locationState === 'error' && ( -
-

{error}

-

- Please enable location access in your browser settings. -

-
- )} - - {locationState === 'waiting' && ( - <> - {position ? ( -
-
- Map preview -
- -
-
Lat: {position.coords.latitude.toFixed(6)}
-
Lng: {position.coords.longitude.toFixed(6)}
- {position.coords.accuracy && ( -
Accuracy: Β±{Math.round(position.coords.accuracy)}m
- )} -
- - + />
- ) : ( -
-
πŸ“‘
-
Getting your location...
+ +
+
Lat: {position.coords.latitude.toFixed(6)}
+
Lng: {position.coords.longitude.toFixed(6)}
+ {position.coords.accuracy && ( +
Accuracy: Β±{Math.round(position.coords.accuracy)}m
+ )}
- )} - - )} - {locationState === 'sharing' && ( -
- {position && ( - <> -
- Live location -
+ +
+ ) : ( +
+
πŸ“‘
+
Getting your location...
+
+ )} + + )} + + {locationState === 'sharing' && ( +
+ {position && ( + <> +
+ Live location +
-
-
Lat: {position.coords.latitude.toFixed(6)}
-
Lng: {position.coords.longitude.toFixed(6)}
- {position.coords.accuracy && ( -
Accuracy: Β±{Math.round(position.coords.accuracy)}m
- )} -
- {lastMessageUpdate && ( -
Last update: {lastMessageUpdate.toLocaleTimeString()}
- )} -
+
+
Lat: {position.coords.latitude.toFixed(6)}
+
Lng: {position.coords.longitude.toFixed(6)}
+ {position.coords.accuracy && ( +
Accuracy: Β±{Math.round(position.coords.accuracy)}m
+ )} +
+ {lastMessageUpdate &&
Last update: {lastMessageUpdate.toLocaleTimeString()}
}
+
-
- βœ… Sharing live location every 10 seconds -
- - )} - - -
- )} -
+
+ βœ… Sharing live location every 10 seconds +
+ Others can click "View Live Location" to see updates +
+ + )} + + +
+ )}
+
); }; -export default LiveLocationChatWidget; \ No newline at end of file +export default LiveLocationChatWidget; 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..93a7295a74158 --- /dev/null +++ b/apps/meteor/client/views/room/ShareLocation/MapView.tsx @@ -0,0 +1,89 @@ +// MapView.tsx +import React from 'react'; +import type { MapProviderName, MapProvider } from '../ShareLocation/mapProvider'; + +export type MapViewProps = { + latitude: number; + longitude: number; + zoom?: number; + width?: number; + height?: number; + + /** + * Optional: make MapView provider-aware. + * If present, we'll use mapInstance to render a provider-specific static map. + * If omitted, we fall back to a neutral placeholder so legacy callers still work. + */ + provider?: MapProviderName; + mapInstance?: MapProvider; + + // Optional: show a tiny attribution line beneath the map (defaults true) + showAttribution?: boolean; +}; + +const MapView: React.FC = ({ + latitude, + longitude, + zoom = 17, + width = 512, + height = 512, + provider, + mapInstance, + showAttribution = true, +}) => { + // If a provider instance is provided, use it to compute a static map URL + const staticUrl = + mapInstance?.getStaticMapUrl(latitude, longitude, { zoom, width, height }) ?? null; + const attribution = mapInstance?.getAttribution?.(); + + if (staticUrl) { + return ( +
+
+ Map preview +
+ {showAttribution && attribution && provider === 'openstreetmap' && ( +
{attribution}
+ )} +
+ ); + } + + // Fallback: neutral placeholder (legacy behavior safety net) + return ( +
+ Map preview unavailable +
+ {latitude.toFixed(5)}, {longitude.toFixed(5)} +
+
+ ); +}; + +export default MapView; diff --git a/apps/meteor/client/views/room/ShareLocation/ShareLocationModal.tsx b/apps/meteor/client/views/room/ShareLocation/ShareLocationModal.tsx index 4e05132cc05b0..37ae234320a11 100644 --- a/apps/meteor/client/views/room/ShareLocation/ShareLocationModal.tsx +++ b/apps/meteor/client/views/room/ShareLocation/ShareLocationModal.tsx @@ -1,167 +1,300 @@ -import type { IMessage, IRoom } from '@rocket.chat/core-typings'; +// ShareLocationModal.tsx β€” Provider first, then Current vs Live +import type { IMessage, IRoom, MessageAttachment } from '@rocket.chat/core-typings'; import { GenericModal } from '@rocket.chat/ui-client'; -import { - useEndpoint, - useTranslation, - useToastMessageDispatch, - useSetting, -} from '@rocket.chat/ui-contexts'; +import { useEndpoint, useTranslation, useToastMessageDispatch } from '@rocket.chat/ui-contexts'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import type { ReactElement } from 'react'; +import { useMemo, useState, useEffect } from 'react'; import { getGeolocationPermission } from './getGeolocationPermission'; import { getGeolocationPosition } from './getGeolocationPosition'; -import MapView from '../../../components/message/content/location/MapView'; -import { useState } from 'react'; +import MapView from './MapView'; import LiveLocationModal from './LiveLocationModal'; +import { createMapProvider, type MapProviderName, type MapProvider } from './mapProvider'; type ShareLocationModalProps = { - rid: IRoom['_id']; - tmid?: IMessage['tmid']; - onClose: () => void; + rid: IRoom['_id']; + tmid?: IMessage['tmid']; + onClose: () => void; }; +type Stage = 'provider' | '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 googleMapsApiKey = 'AIzaSyBeNJSMCi8kD4c6SOvZ4vxHnWYp2yzDbmg'; - const [choice, setChoice] = useState<'current' | 'live' | null>(null); - // const googleMapsApiKey = useSetting('MapView_GMapsAPIKey') as string; - - const { data: permissionState, isLoading: permissionLoading } = useQuery({ - queryKey: ['geolocationPermission'], - queryFn: getGeolocationPermission, - }); - - const { data: positionData } = useQuery({ - queryKey: ['geolocationPosition', permissionState], - queryFn: async () => { - if (permissionLoading || permissionState !== 'granted') { - return; - } - return getGeolocationPosition(); - }, - enabled: permissionState === 'granted', - }); - - const onConfirm = (): void => { - if (!positionData) { - dispatchToast({ type: 'error', message: 'Location not available.' }); - return; - } - - const { latitude, longitude } = positionData.coords; - - const mapsLink = `https://www.openstreetmap.org/?mlat=${latitude}&mlon=${longitude}#map=16/${latitude}/${longitude}`; - - const locationIQKey = 'pk.898e468814facdcffda869b42260a2f0'; - const staticMapUrl = `https://maps.locationiq.com/v2/staticmap?key=${locationIQKey}¢er=${latitude},${longitude}&zoom=17&size=512x512&markers=icon:small-red-cutout|${latitude},${longitude}`; - - try { - sendMessage({ - message: { - rid, - tmid, - attachments: [ - { - ts: new Date(), - title: 'πŸ“ Shared Location', // Add this - title_link: mapsLink, // link to OSM - title_link_download: false, - image_url: staticMapUrl, // Map image - // REMOVE thumb_url to avoid the broken "Retry" - description: `Latitude: ${latitude.toFixed(5)}, Longitude: ${longitude.toFixed(5)}`, - }, - ], - }, - }); - } catch (error) { - dispatchToast({ type: 'error', message: error instanceof Error ? error.message : String(error) }); - } finally { - onClose(); - } - }; - - const onConfirmRequestLocation = async (): Promise => { - try { - const position = await getGeolocationPosition(); - queryClient.setQueryData(['geolocationPosition', 'granted'], position); - queryClient.setQueryData(['geolocationPermission'], 'granted'); - } catch (e) { - queryClient.setQueryData(['geolocationPermission'], () => getGeolocationPermission()); - } - }; - - if (!choice) { - return ( - { - setChoice('live'); - }} - cancelText="Live Location" - confirmText="Current Location" - onConfirm={() => setChoice('current')} - > - Would you like to share your current location or start live location sharing? - - ); - } - - if (choice === 'live') { - return ( - - ); - } - - - if (permissionLoading || permissionState === 'prompt') { - return ( - - ); - } - - if (permissionState === 'denied' || !positionData) { - return ( - - {t('The_necessary_browser_permissions_for_location_sharing_are_not_granted')} - - ); - } - - return ( - - - - ); + const t = useTranslation(); + const dispatchToast = useToastMessageDispatch(); + const queryClient = useQueryClient(); + const sendMessage = useEndpoint('POST', '/v1/chat.sendMessage'); + + // --- New: stages (provider -> choose -> static/live) --- + const [stage, setStage] = useState('provider'); + const [choice, setChoice] = useState<'current' | 'live' | null>(null); + + // Provider picker (persisted) + const [provider, setProvider] = useState(() => { + const saved = localStorage.getItem('mapProvider') as MapProviderName | null; + return saved ?? 'openstreetmap'; + }); + useEffect(() => { + localStorage.setItem('mapProvider', provider); + }, [provider]); + + // Keys (swap to settings if you have them) + const googleMapsApiKey = 'AIzaSyBeNJSMCi8kD4c6SOvZ4vxHnWYp2yzDbmg'; + const locationIQKey = 'pk.898e468814facdcffda869b42260a2f0'; + + // Provider instance + const map: MapProvider = useMemo( + () => + createMapProvider(provider, { + googleApiKey: googleMapsApiKey, + locationIqKey: locationIQKey, + }), + [provider, googleMapsApiKey, locationIQKey], + ); + + // Permission & position queries (only used in static flow) + const { data: permissionState, isLoading: permissionLoading } = useQuery({ + queryKey: ['geolocationPermission'], + queryFn: getGeolocationPermission, + refetchOnWindowFocus: false, + }); + + 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) => { + // retry ONCE for transient errors; never retry on permission denied + const e = error as any; + const code = e?.code; + const msg = String(e?.message || '').toLowerCase(); + const transient = + code !== 1 && // not PERMISSION_DENIED + (code === 2 || msg.includes('kclerrorlocationunknown') || msg.includes('location unknown')); + return transient && failureCount < 1; + }, + retryDelay: 1500, + }); + + const onConfirmRequestLocation = async (): Promise => { + try { + const pos = await getGeolocationPosition(); // triggers browser prompt + queryClient.setQueryData(['geolocationPermission'], 'granted'); + queryClient.setQueryData(['geolocationPosition'], pos); + } catch { + const state = await getGeolocationPermission(); + queryClient.setQueryData(['geolocationPermission'], state); + } + }; + + // --- Stage 1: Provider selection (FIRST) --- + if (stage === 'provider') { + return ( + setStage('choose')} + onCancel={onClose} + > +
+ + +
+ You can change this later. Your choice is saved for live sharing too. +
+
+
+ ); + } + + // --- Stage 2: Choose static vs live --- + if (stage === 'choose' && !choice) { + return ( + { + setChoice('live'); + }} + confirmText="Current Location" + onConfirm={() => { + setChoice('current'); + setStage('static'); + }} + > + Choose to share your current location once or start live location sharing. + + ); + } + + // Live path + if (choice === 'live') { + // Your LiveLocation modal/widget reads provider from localStorage, + // which we already saved above. + return ; + } + + // --- Stage 3: Static flow (with permission gating) --- + if (stage === 'static') { + // Ask for permission + 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 ( + + Getting your location… + + ); + } + + // Granted but failed + if (permissionState === 'granted' && positionError) { + return ( + { + // Clear the error and try again + queryClient.resetQueries({ queryKey: ['geolocationPosition'] }); + }} + onClose={onClose} + cancelText={t('Cancel')} + onCancel={onClose} + > +
+ {(positionErr as Error | undefined)?.message || 'Unable to fetch your current location.'} +
+ +
+ Tips to improve location accuracy: +
β€’ Move closer to a window or go outside +
β€’ Make sure location services are enabled on your device +
β€’ Check that your browser has location permissions +
β€’ Try refreshing the page and allowing location access again +
+
+ ); + } + + const onConfirmStatic = (): void => { + if (!positionData) return; + const { latitude, longitude, accuracy } = positionData.coords; + + if (provider === 'google' && !googleMapsApiKey) { + dispatchToast({ + type: 'warning', + message: 'Google Maps API key is missing; consider using OpenStreetMap.', + }); + } + + try { + const mapsLink = map.getMapsLink(latitude, longitude); + const staticMapUrl = map.getStaticMapUrl(latitude, longitude, { zoom: 17, width: 512, height: 512 }); + + const attachment: MessageAttachment = { + ts: new Date(), + title: 'πŸ“ Shared Location', + title_link: mapsLink, + image_url: staticMapUrl, + image_type: 'image/png', + // keep fields as empty array to avoid any renderer crashes + fields: [], + }; + + void sendMessage({ + message: { + rid, + tmid, + attachments: [attachment], + }, + }); + } catch (error) { + dispatchToast({ type: 'error', message: error instanceof Error ? error.message : String(error) }); + } finally { + onClose(); + } + }; + + // Static share preview + confirm + return ( + +
+ Provider: {provider === 'google' ? 'Google Maps' : 'OpenStreetMap'} +
+ + {positionData && ( + + )} +
+ ); + } + + // Should never hit here + return <>; }; export default ShareLocationModal; diff --git a/apps/meteor/client/views/room/ShareLocation/getGeolocationPosition.ts b/apps/meteor/client/views/room/ShareLocation/getGeolocationPosition.ts index ac9859e9f2d0e..1240e03b91a24 100644 --- a/apps/meteor/client/views/room/ShareLocation/getGeolocationPosition.ts +++ b/apps/meteor/client/views/room/ShareLocation/getGeolocationPosition.ts @@ -1,8 +1,137 @@ -export const getGeolocationPosition = (): Promise => - new Promise((resolvePos, rejectPos) => { - navigator.geolocation.getCurrentPosition(resolvePos, rejectPos, { - enableHighAccuracy: true, - maximumAge: 0, - timeout: 10000, - }); - }); +// getGeolocationPosition.ts +type AnyErr = GeolocationPositionError & { message?: string }; + +const CACHE_KEY = 'lastGeoPosition'; +const MAX_CACHE_AGE_MS = 5 * 60 * 1000; // 5 minutes + +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 + 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, + }); + cache(pos); + return pos; + } catch (e) { + const err = e as AnyErr; + // If the user denied, bail immediately + if (err.code === err.PERMISSION_DENIED) throw err; + // Otherwise continue to fallback + } + + // 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, + ...opts, + }); + cache(pos); + return pos; + } catch (e) { + const err = e as AnyErr; + // If it's the well-known transient Apple error, try one more relaxed single read + if (isTransient(err)) { + try { + const pos = await getOnce({ + enableHighAccuracy: false, + timeout: 12_000, + maximumAge: 2 * 60_000, + ...opts, + }); + cache(pos); + return pos; + } catch { + // fall through to final attempt + } + } + } + + // 3) Final attempt: high-accuracy single read (may take longer indoors) + const pos = await getOnce({ + enableHighAccuracy: true, + timeout: 15_000, + maximumAge: 0, + ...opts, + }); + cache(pos); + return pos; +} + +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; + // POSITION_UNAVAILABLE (2) is commonly transient + if (err.code === err.POSITION_UNAVAILABLE) return true; + const m = String(err.message || '').toLowerCase(); + // Apple/macOS transient message + if (m.includes('kclerrorlocationunknown')) return true; + if (m.includes('location unknown')) return true; + return false; +} + +function cache(position: GeolocationPosition) { + try { + sessionStorage.setItem( + CACHE_KEY, + JSON.stringify({ timestamp: Date.now(), position }), + ); + } catch { + // ignore + } +} + +function readCached(): + | { timestamp: number; position: GeolocationPosition } + | null { + try { + const raw = sessionStorage.getItem(CACHE_KEY); + if (!raw) return null; + return JSON.parse(raw); + } catch { + return null; + } +} 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..570f58a085000 --- /dev/null +++ b/apps/meteor/client/views/room/ShareLocation/mapProvider.ts @@ -0,0 +1,66 @@ +// Unified map provider interface + two implementations (Google, OpenStreetMap via LocationIQ) + +export type MapProviderName = 'google' | 'openstreetmap'; + +export interface MapProvider { + name: MapProviderName; + // Static preview image for messages / modal + getStaticMapUrl(lat: number, lng: number, opts?: { zoom?: number; width?: number; height?: number }): string; + // Deep link that opens the native app or web directions + getMapsLink(lat: number, lng: number): string; + // Human-readable attribution (OSM requires) + getAttribution?: () => string | undefined; +} + +type ProviderOpts = { + googleApiKey?: string; // required for Google Static Maps + locationIqKey?: string; // required for LocationIQ static (OSM-backed) +}; + +// ------------ Google ------------ +export class GoogleProvider implements MapProvider { + name: MapProviderName = 'google'; + constructor(private opts: ProviderOpts) {} + getStaticMapUrl(lat: number, lng: number, opts?: { zoom?: number; width?: number; height?: number }): string { + const key = this.opts.googleApiKey; + const zoom = opts?.zoom ?? 15; + const width = opts?.width ?? 600; + const height = opts?.height ?? 320; + // NOTE: consider server-side signing if you need URL signing for premium usage. + return `https://maps.googleapis.com/maps/api/staticmap?center=${lat},${lng}&zoom=${zoom}&size=${width}x${height}&markers=${lat},${lng}&key=${key}`; + } + getMapsLink(lat: number, lng: number): string { + // works on web + mobile + return `https://maps.google.com/?q=${lat},${lng}`; + } +} + +// ------------ OpenStreetMap via LocationIQ ------------ +export class OSMProvider implements MapProvider { + name: MapProviderName = 'openstreetmap'; + constructor(private opts: ProviderOpts) {} + getStaticMapUrl(lat: number, lng: number, opts?: { zoom?: number; width?: number; height?: number }): string { + const key = this.opts.locationIqKey; + const zoom = opts?.zoom ?? 15; + const width = opts?.width ?? 600; + const height = opts?.height ?? 320; + // LocationIQ static map API (OSM-backed). See their docs for style params. + return `https://maps.locationiq.com/v2/staticmap?key=${key}¢er=${lat},${lng}&zoom=${zoom}&size=${width}x${height}&markers=icon:large-red-cutout|${lat},${lng}`; + } + getMapsLink(lat: number, lng: number): string { + // Deep link to openstreetmap + return `https://www.openstreetmap.org/?mlat=${lat}&mlon=${lng}#map=16/${lat}/${lng}`; + } + getAttribution() { + return 'Β© OpenStreetMap contributors'; + } +} + +// ------------ Factory ------------ +export function createMapProvider( + name: MapProviderName, + keys: ProviderOpts +): MapProvider { + if (name === 'google') return new GoogleProvider(keys); + return new OSMProvider(keys); +} From 4684422e4f826b05c537c030b8bcf452c0325999 Mon Sep 17 00:00:00 2001 From: yiwei gao Date: Mon, 13 Oct 2025 01:50:20 -0400 Subject: [PATCH 03/14] feat: static & live location sharing (draft) --- apps/meteor/app/live-location/server/index.ts | 5 + .../server/methods/getLiveLocation.ts | 76 +++++++++++ .../server/methods/startLiveLocation.ts | 122 ++++++++++++++++++ .../server/methods/stopLiveLocation.ts | 82 ++++++++++++ .../server/methods/updateLiveLocation.ts | 94 ++++++++++++++ .../server/startup/live-location.ts | 61 +++++++++ apps/meteor/server/main.ts | 1 + packages/apps-engine/deno-runtime/deno.lock | 118 +++++++++-------- .../src/models/ILiveLocationSessionModel.ts | 0 9 files changed, 502 insertions(+), 57 deletions(-) create mode 100644 apps/meteor/app/live-location/server/index.ts create mode 100644 apps/meteor/app/live-location/server/methods/getLiveLocation.ts create mode 100644 apps/meteor/app/live-location/server/methods/startLiveLocation.ts create mode 100644 apps/meteor/app/live-location/server/methods/stopLiveLocation.ts create mode 100644 apps/meteor/app/live-location/server/methods/updateLiveLocation.ts create mode 100644 apps/meteor/app/live-location/server/startup/live-location.ts create mode 100644 packages/model-typings/src/models/ILiveLocationSessionModel.ts 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..8e8de549ff6cd --- /dev/null +++ b/apps/meteor/app/live-location/server/index.ts @@ -0,0 +1,5 @@ +import './methods/startLiveLocation'; +import './methods/updateLiveLocation'; +import './methods/stopLiveLocation'; +import './methods/getLiveLocation'; +import './startup/live-location'; \ No newline at end of file diff --git a/apps/meteor/app/live-location/server/methods/getLiveLocation.ts b/apps/meteor/app/live-location/server/methods/getLiveLocation.ts new file mode 100644 index 0000000000000..d658c1c8cfd83 --- /dev/null +++ b/apps/meteor/app/live-location/server/methods/getLiveLocation.ts @@ -0,0 +1,76 @@ +import { Meteor } from 'meteor/meteor'; +import { check } from 'meteor/check'; +import { Messages, Subscriptions } from '@rocket.chat/models'; +import { canAccessRoomIdAsync } from '../../../authorization/server/functions/canAccessRoom'; +import { DDPRateLimiter } from 'meteor/ddp-rate-limiter'; + +Meteor.methods({ + /** + * Get live location data for a message + */ + async 'liveLocation.get'(rid: string, msgId: string) { + check(rid, String); + check(msgId, String); + + const uid = Meteor.userId(); + if (!uid) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'liveLocation.get' }); + } + + if (!(await canAccessRoomIdAsync(rid, uid))) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'liveLocation.get' }); + } + + const sub = await Subscriptions.findOneByRoomIdAndUserId(rid, uid); + if (!sub) { + throw new Meteor.Error('error-not-in-room', 'User is not in the room', { method: 'liveLocation.get' }); + } + const msg = await Messages.findOne({ + _id: msgId, + rid, + attachments: { + $elemMatch: { + type: 'live-location', + }, + }, + }); + + if (!msg) { + throw new Meteor.Error('error-live-location-not-found', 'Live location not found', { + method: 'liveLocation.get', + }); + } + + const attachment = msg.attachments?.find((att: any) => att.type === 'live-location') as any; + if (!attachment) { + throw new Meteor.Error('error-live-location-not-found', 'Live location attachment not found', { + method: 'liveLocation.get', + }); + } + return { + messageId: msg._id, + ownerId: attachment.live?.ownerId, + ownerUsername: msg.u?.username, + ownerName: (msg.u as any)?.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, + }; + }, +}); + +DDPRateLimiter.addRule( + { + userId(userId: string) { + return !!userId; + }, + type: 'method', + name: 'liveLocation.get', + }, + 10, + 60000 +); \ No newline at end of file diff --git a/apps/meteor/app/live-location/server/methods/startLiveLocation.ts b/apps/meteor/app/live-location/server/methods/startLiveLocation.ts new file mode 100644 index 0000000000000..7bbb3feefaf38 --- /dev/null +++ b/apps/meteor/app/live-location/server/methods/startLiveLocation.ts @@ -0,0 +1,122 @@ +import type { IRoom, IUser } from '@rocket.chat/core-typings'; +import { Meteor } from 'meteor/meteor'; +import { check, Match } from 'meteor/check'; + +import { Rooms, Subscriptions, Messages } from '@rocket.chat/models'; +import { canAccessRoomIdAsync } from '../../../authorization/server/functions/canAccessRoom'; +import { notifyOnMessageChange } from '../../../lib/server/lib/notifyListener'; +import { DDPRateLimiter } from 'meteor/ddp-rate-limiter'; + +type Coords = { lat: number; lng: number; acc?: number }; + +declare module 'meteor/meteor' { + namespace Meteor { + function user(): IUser | null; + } +} + +Meteor.methods({ + /** + * Start live location sharing in a room + */ + async 'liveLocation.start'(rid: string, opts: { durationSec?: number; initial?: Coords } = {}) { + check(rid, String); + check(opts, Match.ObjectIncluding({ durationSec: Match.Optional(Number), initial: Match.Optional(Object) })); + + const uid = Meteor.userId(); + if (!uid) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'liveLocation.start' }); + } + + const room = await Rooms.findOneById(rid); + if (!room) { + throw new Meteor.Error('error-room-not-found', 'Room not found', { method: 'liveLocation.start' }); + } + + const sub = await Subscriptions.findOneByRoomIdAndUserId(rid, uid); + if (!sub) { + throw new Meteor.Error('error-not-in-room', 'User is not in the room', { method: 'liveLocation.start' }); + } + if (!(await canAccessRoomIdAsync(rid, uid))) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'liveLocation.start' }); + } + + // Reuse existing active live location if found + const existing = await Messages.findOne({ + rid, + 'u._id': uid, + attachments: { + $elemMatch: { + type: 'live-location', + 'live.isActive': true, + }, + }, + }); + + if (existing) { + return { msgId: existing._id }; + } + + const now = new Date(); + const expiresAt = opts.durationSec ? new Date(now.getTime() + opts.durationSec * 1000) : undefined; + const user = await Meteor.users.findOneAsync({ _id: uid }, { + projection: { username: 1, name: 1 } + }); + + if (!user) { + throw new Meteor.Error('error-invalid-user', 'User not found', { method: 'liveLocation.start' }); + } + + const msg = { + rid, + ts: now, + u: { + _id: uid, + username: user.username, + name: (user as any).name || user.username + }, + attachments: [ + { + type: 'live-location', + live: { + isActive: true, + ownerId: uid, + startedAt: now, + lastUpdateAt: now, + expiresAt, + coords: opts.initial || null, + version: 1, + }, + }, + ], + } as any; + + try { + const result = await Messages.insertOne(msg); + + const createdMsg = await Messages.findOneById(result.insertedId); + if (createdMsg) { + void notifyOnMessageChange({ + id: createdMsg._id, + data: createdMsg, + }); + } + + return { msgId: result.insertedId }; + } catch (insertError) { + throw new Meteor.Error('error-message-creation-failed', 'Failed to create live location message', { method: 'liveLocation.start' }); + } + }, +}); + +DDPRateLimiter.addRule( + { + userId(userId: string) { + return !!userId; + }, + type: 'method', + name: 'liveLocation.start', + }, + 5, + 60000 +); \ No newline at end of file diff --git a/apps/meteor/app/live-location/server/methods/stopLiveLocation.ts b/apps/meteor/app/live-location/server/methods/stopLiveLocation.ts new file mode 100644 index 0000000000000..1f3d8e0821a9e --- /dev/null +++ b/apps/meteor/app/live-location/server/methods/stopLiveLocation.ts @@ -0,0 +1,82 @@ +import { Meteor } from 'meteor/meteor'; +import { check } from 'meteor/check'; +import { Messages, Subscriptions } from '@rocket.chat/models'; +import { canAccessRoomIdAsync } from '../../../authorization/server/functions/canAccessRoom'; +import { DDPRateLimiter } from 'meteor/ddp-rate-limiter'; +import { notifyOnMessageChange } from '../../../lib/server/lib/notifyListener'; + +type Coords = { lat: number; lng: number; acc?: number }; + +Meteor.methods({ + /** + * Stop live location sharing + */ + async 'liveLocation.stop'(rid: string, msgId: string, finalCoords?: Coords) { + check(rid, String); + check(msgId, String); + + const uid = Meteor.userId(); + if (!uid) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'liveLocation.stop' }); + } + + if (!(await canAccessRoomIdAsync(rid, uid))) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'liveLocation.stop' }); + } + + const sub = await Subscriptions.findOneByRoomIdAndUserId(rid, uid); + if (!sub) { + throw new Meteor.Error('error-not-in-room', 'User is not in the room', { method: 'liveLocation.stop' }); + } + + const selector = { + _id: msgId, + rid, + 'u._id': uid, + attachments: { + $elemMatch: { + type: 'live-location', + 'live.isActive': true, + }, + }, + }; + + const modifier: any = { + $set: { + 'attachments.0.live.isActive': false, + 'attachments.0.live.stoppedAt': new Date(), + }, + }; + + if (finalCoords) { + modifier.$set['attachments.0.live.coords'] = finalCoords; + } + + const res = await Messages.updateOne(selector, modifier); + const success = Boolean(res.modifiedCount); + + if (success) { + const updatedMsg = await Messages.findOneById(msgId); + if (updatedMsg) { + void notifyOnMessageChange({ + id: updatedMsg._id, + data: updatedMsg, + }); + } + } + + return { stopped: success }; + }, +}); + +DDPRateLimiter.addRule( + { + userId(userId: string) { + return !!userId; + }, + type: 'method', + name: 'liveLocation.stop', + }, + 10, + 60000, +); diff --git a/apps/meteor/app/live-location/server/methods/updateLiveLocation.ts b/apps/meteor/app/live-location/server/methods/updateLiveLocation.ts new file mode 100644 index 0000000000000..ea6abf3358ccf --- /dev/null +++ b/apps/meteor/app/live-location/server/methods/updateLiveLocation.ts @@ -0,0 +1,94 @@ +import { Meteor } from 'meteor/meteor'; +import { check } from 'meteor/check'; +import { Messages, Subscriptions } from '@rocket.chat/models'; +import { canAccessRoomIdAsync } from '../../../authorization/server/functions/canAccessRoom'; +import { DDPRateLimiter } from 'meteor/ddp-rate-limiter'; +import { notifyOnMessageChange } from '../../../lib/server/lib/notifyListener'; + +type Coords = { lat: number; lng: number; acc?: number }; + +const MIN_INTERVAL_MS = 3000; + +Meteor.methods({ + /** + * Update live location coordinates + */ + async 'liveLocation.update'(rid: string, msgId: string, coords: Coords) { + check(rid, String); + check(msgId, String); + check(coords, Object); + + const uid = Meteor.userId(); + if (!uid) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'liveLocation.update' }); + } + + if (!(await canAccessRoomIdAsync(rid, uid))) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'liveLocation.update' }); + } + const sub = await Subscriptions.findOneByRoomIdAndUserId(rid, uid); + if (!sub) { + throw new Meteor.Error('error-not-in-room', 'User is not in the room', { method: 'liveLocation.update' }); + } + + const msg = await Messages.findOne({ + _id: msgId, + rid, + 'u._id': uid, + attachments: { + $elemMatch: { + type: 'live-location', + 'live.isActive': true, + }, + }, + }); + + if (!msg) { + throw new Meteor.Error('error-live-location-not-found', 'Active live location not found', { + method: 'liveLocation.update', + }); + } + + const last: Date | undefined = (msg.attachments?.[0] as any)?.live?.lastUpdateAt; + const now = new Date(); + if (last && now.getTime() - new Date(last).getTime() < MIN_INTERVAL_MS) { + return { ignored: true, reason: 'too-soon' }; + } + + const updateTime = new Date(); + const res = await Messages.updateOne( + { _id: msgId }, + { + $set: { + 'attachments.0.live.coords': coords, + 'attachments.0.live.lastUpdateAt': updateTime, + }, + }, + ); + + // Notify clients of message update for real-time UI refresh + if (res.modifiedCount > 0) { + const updatedMsg = await Messages.findOneById(msgId); + if (updatedMsg) { + void notifyOnMessageChange({ + id: updatedMsg._id, + data: updatedMsg, + }); + } + } + + return { updated: Boolean(res.modifiedCount) }; + }, +}); + +DDPRateLimiter.addRule( + { + userId(userId: string) { + return !!userId; + }, + type: 'method', + name: 'liveLocation.update', + }, + 12, + 60000 +); 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..2559e3ba50de1 --- /dev/null +++ b/apps/meteor/app/live-location/server/startup/live-location.ts @@ -0,0 +1,61 @@ +import { Meteor } from 'meteor/meteor'; + +import { Messages } from '@rocket.chat/models'; + +const CLEANUP_INTERVAL_MS = 60_000; +const INACTIVE_GRACE_MS = 30_000; +async function ensureIndex(collection: any, keys: any, options: any = {}) { + try { + await collection.col.createIndex(keys, options); + } catch (e) { + // ignore errors + } +} + +Meteor.startup(async () => { + await ensureIndex(Messages, { 'attachments.0.type': 1, 'attachments.0.live.isActive': 1 }); + await ensureIndex(Messages, { rid: 1, 'u._id': 1, 'attachments.0.type': 1, 'attachments.0.live.isActive': 1 }); + await ensureIndex( + Messages, + { 'attachments.0.live.expiresAt': 1 }, + { + expireAfterSeconds: 0, + partialFilterExpression: { 'attachments.0.type': 'live-location', 'attachments.0.live.expiresAt': { $type: 'date' } }, + name: 'liveLocation_expiresAt_TTL', + }, + ); + await ensureIndex( + Messages, + { 'attachments.0.live.lastUpdateAt': 1 }, + { + partialFilterExpression: { 'attachments.0.type': 'live-location' }, + name: 'liveLocation_lastUpdateAt_idx', + }, + ); + + Meteor.setInterval(async () => { + const now = new Date(); + const staleBefore = new Date(now.getTime() - INACTIVE_GRACE_MS); + await Messages.updateMany( + { + 'attachments.0.type': 'live-location', + 'attachments.0.live.isActive': true, + $or: [ + { 'attachments.0.live.lastUpdateAt': { $lt: staleBefore } }, + { 'attachments.0.live.expiresAt': { $lte: now } }, + ], + }, + { + $set: { + 'attachments.0.live.isActive': false, + 'attachments.0.live.stoppedAt': now, + }, + }, + ); + }, CLEANUP_INTERVAL_MS); +}); + +export const LiveLocationStartup = { + CLEANUP_INTERVAL_MS, + INACTIVE_GRACE_MS, +}; 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..e2a816f68a4a9 100644 --- a/packages/apps-engine/deno-runtime/deno.lock +++ b/packages/apps-engine/deno-runtime/deno.lock @@ -1,62 +1,66 @@ { - "version": "3", - "packages": { - "specifiers": { - "jsr:@std/cli@^1.0.9": "jsr:@std/cli@1.0.13", - "npm:@msgpack/msgpack@3.0.0-beta2": "npm:@msgpack/msgpack@3.0.0-beta2", - "npm:@rocket.chat/ui-kit@^0.31.22": "npm:@rocket.chat/ui-kit@0.31.25_@rocket.chat+icons@0.32.0", - "npm:acorn-walk@8.2.0": "npm:acorn-walk@8.2.0", - "npm:acorn@8.10.0": "npm:acorn@8.10.0", - "npm:astring@1.8.6": "npm:astring@1.8.6", - "npm:jsonrpc-lite@2.2.0": "npm:jsonrpc-lite@2.2.0", - "npm:stack-trace": "npm:stack-trace@0.0.10", - "npm:stack-trace@0.0.10": "npm:stack-trace@0.0.10", - "npm:uuid@8.3.2": "npm:uuid@8.3.2" + "version": "5", + "specifiers": { + "jsr:@std/cli@^1.0.9": "1.0.13", + "npm:@msgpack/msgpack@3.0.0-beta2": "3.0.0-beta2", + "npm:@rocket.chat/ui-kit@~0.31.22": "0.31.25_@rocket.chat+icons@0.32.0", + "npm:@types/node@*": "22.15.15", + "npm:acorn-walk@8.2.0": "8.2.0", + "npm:acorn@8.10.0": "8.10.0", + "npm:astring@1.8.6": "1.8.6", + "npm:jsonrpc-lite@2.2.0": "2.2.0", + "npm:stack-trace@*": "0.0.10", + "npm:stack-trace@0.0.10": "0.0.10", + "npm:uuid@8.3.2": "8.3.2" + }, + "jsr": { + "@std/cli@1.0.13": { + "integrity": "5db2d95ab2dca3bca9fb6ad3c19908c314e93d6391c8b026725e4892d4615a69" + } + }, + "npm": { + "@msgpack/msgpack@3.0.0-beta2": { + "integrity": "sha512-y+l1PNV0XDyY8sM3YtuMLK5vE3/hkfId+Do8pLo/OPxfxuFAUwcGz3oiiUuV46/aBpwTzZ+mRWVMtlSKbradhw==", + "deprecated": true + }, + "@rocket.chat/icons@0.32.0": { + "integrity": "sha512-7yhhELKNLb9kUtXCvau0V+iMXraV2bOsxcPjc/ZtLR5VeeIDTeaflqRWGtLroX6f3bE+J1n5qB5zi8A4YXuH2g==" + }, + "@rocket.chat/ui-kit@0.31.25_@rocket.chat+icons@0.32.0": { + "integrity": "sha512-yTgTKDw9SMlJ6p8n0PDO6zSvox/nHYUrwCIvILQeAK6PvTrgSe/u9CvU7ATTYjnQiQ603yEGR6dxjF4euCGdNA==", + "dependencies": [ + "@rocket.chat/icons" + ] + }, + "@types/node@22.15.15": { + "integrity": "sha512-R5muMcZob3/Jjchn5LcO8jdKwSCbzqmPB6ruBxMcf9kbxtniZHP327s6C37iOfuw8mbKK3cAQa7sEl7afLrQ8A==", + "dependencies": [ + "undici-types" + ] + }, + "acorn-walk@8.2.0": { + "integrity": "sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==" + }, + "acorn@8.10.0": { + "integrity": "sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw==", + "bin": true + }, + "astring@1.8.6": { + "integrity": "sha512-ISvCdHdlTDlH5IpxQJIex7BWBywFWgjJSVdwst+/iQCoEYnyOaQ95+X1JGshuBjGp6nxKUy1jMgE3zPqN7fQdg==", + "bin": true + }, + "jsonrpc-lite@2.2.0": { + "integrity": "sha512-/cbbSxtZWs1O7R4tWqabrCM/t3N8qKUZMAg9IUqpPvUs6UyRvm6pCNYkskyKN/XU0UgffW+NY2ZRr8t0AknX7g==" + }, + "stack-trace@0.0.10": { + "integrity": "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==" }, - "jsr": { - "@std/cli@1.0.13": { - "integrity": "5db2d95ab2dca3bca9fb6ad3c19908c314e93d6391c8b026725e4892d4615a69" - } + "undici-types@6.21.0": { + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==" }, - "npm": { - "@msgpack/msgpack@3.0.0-beta2": { - "integrity": "sha512-y+l1PNV0XDyY8sM3YtuMLK5vE3/hkfId+Do8pLo/OPxfxuFAUwcGz3oiiUuV46/aBpwTzZ+mRWVMtlSKbradhw==", - "dependencies": {} - }, - "@rocket.chat/icons@0.32.0": { - "integrity": "sha512-7yhhELKNLb9kUtXCvau0V+iMXraV2bOsxcPjc/ZtLR5VeeIDTeaflqRWGtLroX6f3bE+J1n5qB5zi8A4YXuH2g==", - "dependencies": {} - }, - "@rocket.chat/ui-kit@0.31.25_@rocket.chat+icons@0.32.0": { - "integrity": "sha512-yTgTKDw9SMlJ6p8n0PDO6zSvox/nHYUrwCIvILQeAK6PvTrgSe/u9CvU7ATTYjnQiQ603yEGR6dxjF4euCGdNA==", - "dependencies": { - "@rocket.chat/icons": "@rocket.chat/icons@0.32.0" - } - }, - "acorn-walk@8.2.0": { - "integrity": "sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==", - "dependencies": {} - }, - "acorn@8.10.0": { - "integrity": "sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw==", - "dependencies": {} - }, - "astring@1.8.6": { - "integrity": "sha512-ISvCdHdlTDlH5IpxQJIex7BWBywFWgjJSVdwst+/iQCoEYnyOaQ95+X1JGshuBjGp6nxKUy1jMgE3zPqN7fQdg==", - "dependencies": {} - }, - "jsonrpc-lite@2.2.0": { - "integrity": "sha512-/cbbSxtZWs1O7R4tWqabrCM/t3N8qKUZMAg9IUqpPvUs6UyRvm6pCNYkskyKN/XU0UgffW+NY2ZRr8t0AknX7g==", - "dependencies": {} - }, - "stack-trace@0.0.10": { - "integrity": "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==", - "dependencies": {} - }, - "uuid@8.3.2": { - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "dependencies": {} - } + "uuid@8.3.2": { + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "bin": true } }, "remote": { @@ -103,7 +107,7 @@ "dependencies": [ "jsr:@std/cli@^1.0.9", "npm:@msgpack/msgpack@3.0.0-beta2", - "npm:@rocket.chat/ui-kit@^0.31.22", + "npm:@rocket.chat/ui-kit@~0.31.22", "npm:acorn-walk@8.2.0", "npm:acorn@8.10.0", "npm:astring@1.8.6", diff --git a/packages/model-typings/src/models/ILiveLocationSessionModel.ts b/packages/model-typings/src/models/ILiveLocationSessionModel.ts new file mode 100644 index 0000000000000..e69de29bb2d1d From 411b5b5443d2e6203af5f89c7f4ffb23a6e1e5eb Mon Sep 17 00:00:00 2001 From: yiwei gao Date: Tue, 14 Oct 2025 00:03:36 -0400 Subject: [PATCH 04/14] feat: static & live location sharing (draft) --- apps/meteor/app/live-location/server/index.ts | 2 +- .../server/methods/getLiveLocation.ts | 129 +- .../server/methods/startLiveLocation.ts | 218 +-- .../server/methods/stopLiveLocation.ts | 129 +- .../server/methods/updateLiveLocation.ts | 147 +- .../server/startup/live-location.ts | 94 +- .../message/content/location/MapView.tsx | 1 - .../room/ShareLocation/LiveLocationModal.tsx | 1292 +++++++++-------- .../views/room/ShareLocation/MapView.tsx | 144 +- .../room/ShareLocation/ShareLocationModal.tsx | 505 ++++--- .../ShareLocation/getGeolocationPosition.ts | 215 ++- .../room/ShareLocation/liveLocationService.ts | 482 +++--- .../views/room/ShareLocation/mapProvider.ts | 106 +- .../useLiveLocationStopListener.ts | 211 ++- .../hooks/useShareLocationAction.tsx | 11 +- .../.npm/package/.gitignore | 1 + .../meteor-user-presence/.npm/package/README | 7 + .../.npm/package/npm-shrinkwrap.json | 10 + 18 files changed, 1869 insertions(+), 1835 deletions(-) create mode 100644 apps/meteor/packages/meteor-user-presence/.npm/package/.gitignore create mode 100644 apps/meteor/packages/meteor-user-presence/.npm/package/README create mode 100644 apps/meteor/packages/meteor-user-presence/.npm/package/npm-shrinkwrap.json diff --git a/apps/meteor/app/live-location/server/index.ts b/apps/meteor/app/live-location/server/index.ts index 8e8de549ff6cd..852c61eb7afd2 100644 --- a/apps/meteor/app/live-location/server/index.ts +++ b/apps/meteor/app/live-location/server/index.ts @@ -2,4 +2,4 @@ import './methods/startLiveLocation'; import './methods/updateLiveLocation'; import './methods/stopLiveLocation'; import './methods/getLiveLocation'; -import './startup/live-location'; \ No newline at end of file +import './startup/live-location'; diff --git a/apps/meteor/app/live-location/server/methods/getLiveLocation.ts b/apps/meteor/app/live-location/server/methods/getLiveLocation.ts index d658c1c8cfd83..f58468e84af89 100644 --- a/apps/meteor/app/live-location/server/methods/getLiveLocation.ts +++ b/apps/meteor/app/live-location/server/methods/getLiveLocation.ts @@ -1,76 +1,77 @@ -import { Meteor } from 'meteor/meteor'; -import { check } from 'meteor/check'; import { Messages, Subscriptions } from '@rocket.chat/models'; -import { canAccessRoomIdAsync } from '../../../authorization/server/functions/canAccessRoom'; +import { check } from 'meteor/check'; import { DDPRateLimiter } from 'meteor/ddp-rate-limiter'; +import { Meteor } from 'meteor/meteor'; + +import { canAccessRoomIdAsync } from '../../../authorization/server/functions/canAccessRoom'; Meteor.methods({ - /** - * Get live location data for a message - */ - async 'liveLocation.get'(rid: string, msgId: string) { - check(rid, String); - check(msgId, String); + /** + * Get live location data for a message + */ + async 'liveLocation.get'(rid: string, msgId: string) { + check(rid, String); + check(msgId, String); - const uid = Meteor.userId(); - if (!uid) { - throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'liveLocation.get' }); - } + const uid = Meteor.userId(); + if (!uid) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'liveLocation.get' }); + } - if (!(await canAccessRoomIdAsync(rid, uid))) { - throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'liveLocation.get' }); - } + if (!(await canAccessRoomIdAsync(rid, uid))) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'liveLocation.get' }); + } - const sub = await Subscriptions.findOneByRoomIdAndUserId(rid, uid); - if (!sub) { - throw new Meteor.Error('error-not-in-room', 'User is not in the room', { method: 'liveLocation.get' }); - } - const msg = await Messages.findOne({ - _id: msgId, - rid, - attachments: { - $elemMatch: { - type: 'live-location', - }, - }, - }); + const sub = await Subscriptions.findOneByRoomIdAndUserId(rid, uid); + if (!sub) { + throw new Meteor.Error('error-not-in-room', 'User is not in the room', { method: 'liveLocation.get' }); + } + const msg = await Messages.findOne({ + _id: msgId, + rid, + attachments: { + $elemMatch: { + type: 'live-location', + }, + }, + }); - if (!msg) { - throw new Meteor.Error('error-live-location-not-found', 'Live location not found', { - method: 'liveLocation.get', - }); - } + if (!msg) { + throw new Meteor.Error('error-live-location-not-found', 'Live location not found', { + method: 'liveLocation.get', + }); + } - const attachment = msg.attachments?.find((att: any) => att.type === 'live-location') as any; - if (!attachment) { - throw new Meteor.Error('error-live-location-not-found', 'Live location attachment not found', { - method: 'liveLocation.get', - }); - } - return { - messageId: msg._id, - ownerId: attachment.live?.ownerId, - ownerUsername: msg.u?.username, - ownerName: (msg.u as any)?.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, - }; - }, + const attachment = msg.attachments?.find((att: any) => att.type === 'live-location') as any; + if (!attachment) { + throw new Meteor.Error('error-live-location-not-found', 'Live location attachment not found', { + method: 'liveLocation.get', + }); + } + return { + messageId: msg._id, + ownerId: attachment.live?.ownerId, + ownerUsername: msg.u?.username, + ownerName: (msg.u as any)?.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, + }; + }, }); DDPRateLimiter.addRule( - { - userId(userId: string) { - return !!userId; - }, - type: 'method', - name: 'liveLocation.get', - }, - 10, - 60000 -); \ No newline at end of file + { + userId(userId: string) { + return !!userId; + }, + type: 'method', + name: 'liveLocation.get', + }, + 10, + 60000, +); diff --git a/apps/meteor/app/live-location/server/methods/startLiveLocation.ts b/apps/meteor/app/live-location/server/methods/startLiveLocation.ts index 7bbb3feefaf38..3764bee5a5786 100644 --- a/apps/meteor/app/live-location/server/methods/startLiveLocation.ts +++ b/apps/meteor/app/live-location/server/methods/startLiveLocation.ts @@ -1,122 +1,126 @@ import type { IRoom, IUser } from '@rocket.chat/core-typings'; -import { Meteor } from 'meteor/meteor'; +import { Rooms, Subscriptions, Messages } from '@rocket.chat/models'; import { check, Match } from 'meteor/check'; +import { DDPRateLimiter } from 'meteor/ddp-rate-limiter'; +import { Meteor } from 'meteor/meteor'; -import { Rooms, Subscriptions, Messages } from '@rocket.chat/models'; import { canAccessRoomIdAsync } from '../../../authorization/server/functions/canAccessRoom'; import { notifyOnMessageChange } from '../../../lib/server/lib/notifyListener'; -import { DDPRateLimiter } from 'meteor/ddp-rate-limiter'; type Coords = { lat: number; lng: number; acc?: number }; declare module 'meteor/meteor' { - namespace Meteor { - function user(): IUser | null; - } + // eslint-disable-next-line @typescript-eslint/naming-convention + interface Meteor { + user(): IUser | null; + } } Meteor.methods({ - /** - * Start live location sharing in a room - */ - async 'liveLocation.start'(rid: string, opts: { durationSec?: number; initial?: Coords } = {}) { - check(rid, String); - check(opts, Match.ObjectIncluding({ durationSec: Match.Optional(Number), initial: Match.Optional(Object) })); - - const uid = Meteor.userId(); - if (!uid) { - throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'liveLocation.start' }); - } - - const room = await Rooms.findOneById(rid); - if (!room) { - throw new Meteor.Error('error-room-not-found', 'Room not found', { method: 'liveLocation.start' }); - } - - const sub = await Subscriptions.findOneByRoomIdAndUserId(rid, uid); - if (!sub) { - throw new Meteor.Error('error-not-in-room', 'User is not in the room', { method: 'liveLocation.start' }); - } - if (!(await canAccessRoomIdAsync(rid, uid))) { - throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'liveLocation.start' }); - } - - // Reuse existing active live location if found - const existing = await Messages.findOne({ - rid, - 'u._id': uid, - attachments: { - $elemMatch: { - type: 'live-location', - 'live.isActive': true, - }, - }, - }); - - if (existing) { - return { msgId: existing._id }; - } - - const now = new Date(); - const expiresAt = opts.durationSec ? new Date(now.getTime() + opts.durationSec * 1000) : undefined; - const user = await Meteor.users.findOneAsync({ _id: uid }, { - projection: { username: 1, name: 1 } - }); - - if (!user) { - throw new Meteor.Error('error-invalid-user', 'User not found', { method: 'liveLocation.start' }); - } - - const msg = { - rid, - ts: now, - u: { - _id: uid, - username: user.username, - name: (user as any).name || user.username - }, - attachments: [ - { - type: 'live-location', - live: { - isActive: true, - ownerId: uid, - startedAt: now, - lastUpdateAt: now, - expiresAt, - coords: opts.initial || null, - version: 1, - }, - }, - ], - } as any; - - try { - const result = await Messages.insertOne(msg); - - const createdMsg = await Messages.findOneById(result.insertedId); - if (createdMsg) { - void notifyOnMessageChange({ - id: createdMsg._id, - data: createdMsg, - }); - } - - return { msgId: result.insertedId }; - } catch (insertError) { - throw new Meteor.Error('error-message-creation-failed', 'Failed to create live location message', { method: 'liveLocation.start' }); - } - }, + /** + * Start live location sharing in a room + */ + async 'liveLocation.start'(rid: string, opts: { durationSec?: number; initial?: Coords } = {}) { + check(rid, String); + check(opts, Match.ObjectIncluding({ durationSec: Match.Optional(Number), initial: Match.Optional(Object) })); + + const uid = Meteor.userId(); + if (!uid) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'liveLocation.start' }); + } + + const room = await Rooms.findOneById(rid); + if (!room) { + throw new Meteor.Error('error-room-not-found', 'Room not found', { method: 'liveLocation.start' }); + } + + const sub = await Subscriptions.findOneByRoomIdAndUserId(rid, uid); + if (!sub) { + throw new Meteor.Error('error-not-in-room', 'User is not in the room', { method: 'liveLocation.start' }); + } + if (!(await canAccessRoomIdAsync(rid, uid))) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'liveLocation.start' }); + } + + // Reuse existing active live location if found + const existing = await Messages.findOne({ + rid, + 'u._id': uid, + 'attachments': { + $elemMatch: { + 'type': 'live-location', + 'live.isActive': true, + }, + }, + }); + + if (existing) { + return { msgId: existing._id }; + } + + const now = new Date(); + const expiresAt = opts.durationSec ? new Date(now.getTime() + opts.durationSec * 1000) : undefined; + const user = await Meteor.users.findOneAsync( + { _id: uid }, + { + projection: { username: 1, name: 1 }, + }, + ); + + if (!user) { + throw new Meteor.Error('error-invalid-user', 'User not found', { method: 'liveLocation.start' }); + } + + const msg = { + rid, + ts: now, + u: { + _id: uid, + username: user.username, + name: (user as any).name || user.username, + }, + attachments: [ + { + type: 'live-location', + live: { + isActive: true, + ownerId: uid, + startedAt: now, + lastUpdateAt: now, + expiresAt, + coords: opts.initial || null, + version: 1, + }, + }, + ], + } as any; + + try { + const result = await Messages.insertOne(msg); + + const createdMsg = await Messages.findOneById(result.insertedId); + if (createdMsg) { + void notifyOnMessageChange({ + id: createdMsg._id, + data: createdMsg, + }); + } + + return { msgId: result.insertedId }; + } catch (insertError) { + throw new Meteor.Error('error-message-creation-failed', 'Failed to create live location message', { method: 'liveLocation.start' }); + } + }, }); DDPRateLimiter.addRule( - { - userId(userId: string) { - return !!userId; - }, - type: 'method', - name: 'liveLocation.start', - }, - 5, - 60000 -); \ No newline at end of file + { + userId(userId: string) { + return !!userId; + }, + type: 'method', + name: 'liveLocation.start', + }, + 5, + 60000, +); diff --git a/apps/meteor/app/live-location/server/methods/stopLiveLocation.ts b/apps/meteor/app/live-location/server/methods/stopLiveLocation.ts index 1f3d8e0821a9e..53cc64fbacae4 100644 --- a/apps/meteor/app/live-location/server/methods/stopLiveLocation.ts +++ b/apps/meteor/app/live-location/server/methods/stopLiveLocation.ts @@ -1,82 +1,83 @@ -import { Meteor } from 'meteor/meteor'; -import { check } from 'meteor/check'; import { Messages, Subscriptions } from '@rocket.chat/models'; -import { canAccessRoomIdAsync } from '../../../authorization/server/functions/canAccessRoom'; +import { check } from 'meteor/check'; import { DDPRateLimiter } from 'meteor/ddp-rate-limiter'; +import { Meteor } from 'meteor/meteor'; + +import { canAccessRoomIdAsync } from '../../../authorization/server/functions/canAccessRoom'; import { notifyOnMessageChange } from '../../../lib/server/lib/notifyListener'; type Coords = { lat: number; lng: number; acc?: number }; Meteor.methods({ - /** - * Stop live location sharing - */ - async 'liveLocation.stop'(rid: string, msgId: string, finalCoords?: Coords) { - check(rid, String); - check(msgId, String); + /** + * Stop live location sharing + */ + async 'liveLocation.stop'(rid: string, msgId: string, finalCoords?: Coords) { + check(rid, String); + check(msgId, String); + + const uid = Meteor.userId(); + if (!uid) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'liveLocation.stop' }); + } + + if (!(await canAccessRoomIdAsync(rid, uid))) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'liveLocation.stop' }); + } - const uid = Meteor.userId(); - if (!uid) { - throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'liveLocation.stop' }); - } + const sub = await Subscriptions.findOneByRoomIdAndUserId(rid, uid); + if (!sub) { + throw new Meteor.Error('error-not-in-room', 'User is not in the room', { method: 'liveLocation.stop' }); + } - if (!(await canAccessRoomIdAsync(rid, uid))) { - throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'liveLocation.stop' }); - } + const selector = { + '_id': msgId, + rid, + 'u._id': uid, + 'attachments': { + $elemMatch: { + 'type': 'live-location', + 'live.isActive': true, + }, + }, + }; - const sub = await Subscriptions.findOneByRoomIdAndUserId(rid, uid); - if (!sub) { - throw new Meteor.Error('error-not-in-room', 'User is not in the room', { method: 'liveLocation.stop' }); - } + const modifier: any = { + $set: { + 'attachments.0.live.isActive': false, + 'attachments.0.live.stoppedAt': new Date(), + }, + }; - const selector = { - _id: msgId, - rid, - 'u._id': uid, - attachments: { - $elemMatch: { - type: 'live-location', - 'live.isActive': true, - }, - }, - }; + if (finalCoords) { + modifier.$set['attachments.0.live.coords'] = finalCoords; + } - const modifier: any = { - $set: { - 'attachments.0.live.isActive': false, - 'attachments.0.live.stoppedAt': new Date(), - }, - }; + const res = await Messages.updateOne(selector, modifier); + const success = Boolean(res.modifiedCount); - if (finalCoords) { - modifier.$set['attachments.0.live.coords'] = finalCoords; - } + if (success) { + const updatedMsg = await Messages.findOneById(msgId); + if (updatedMsg) { + void notifyOnMessageChange({ + id: updatedMsg._id, + data: updatedMsg, + }); + } + } - const res = await Messages.updateOne(selector, modifier); - const success = Boolean(res.modifiedCount); - - if (success) { - const updatedMsg = await Messages.findOneById(msgId); - if (updatedMsg) { - void notifyOnMessageChange({ - id: updatedMsg._id, - data: updatedMsg, - }); - } - } - - return { stopped: success }; - }, + return { stopped: success }; + }, }); DDPRateLimiter.addRule( - { - userId(userId: string) { - return !!userId; - }, - type: 'method', - name: 'liveLocation.stop', - }, - 10, - 60000, + { + userId(userId: string) { + return !!userId; + }, + type: 'method', + name: 'liveLocation.stop', + }, + 10, + 60000, ); diff --git a/apps/meteor/app/live-location/server/methods/updateLiveLocation.ts b/apps/meteor/app/live-location/server/methods/updateLiveLocation.ts index ea6abf3358ccf..606167a30cf67 100644 --- a/apps/meteor/app/live-location/server/methods/updateLiveLocation.ts +++ b/apps/meteor/app/live-location/server/methods/updateLiveLocation.ts @@ -1,8 +1,9 @@ -import { Meteor } from 'meteor/meteor'; -import { check } from 'meteor/check'; import { Messages, Subscriptions } from '@rocket.chat/models'; -import { canAccessRoomIdAsync } from '../../../authorization/server/functions/canAccessRoom'; +import { check } from 'meteor/check'; import { DDPRateLimiter } from 'meteor/ddp-rate-limiter'; +import { Meteor } from 'meteor/meteor'; + +import { canAccessRoomIdAsync } from '../../../authorization/server/functions/canAccessRoom'; import { notifyOnMessageChange } from '../../../lib/server/lib/notifyListener'; type Coords = { lat: number; lng: number; acc?: number }; @@ -10,85 +11,85 @@ type Coords = { lat: number; lng: number; acc?: number }; const MIN_INTERVAL_MS = 3000; Meteor.methods({ - /** - * Update live location coordinates - */ - async 'liveLocation.update'(rid: string, msgId: string, coords: Coords) { - check(rid, String); - check(msgId, String); - check(coords, Object); + /** + * Update live location coordinates + */ + async 'liveLocation.update'(rid: string, msgId: string, coords: Coords) { + check(rid, String); + check(msgId, String); + check(coords, Object); - const uid = Meteor.userId(); - if (!uid) { - throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'liveLocation.update' }); - } + const uid = Meteor.userId(); + if (!uid) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'liveLocation.update' }); + } - if (!(await canAccessRoomIdAsync(rid, uid))) { - throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'liveLocation.update' }); - } - const sub = await Subscriptions.findOneByRoomIdAndUserId(rid, uid); - if (!sub) { - throw new Meteor.Error('error-not-in-room', 'User is not in the room', { method: 'liveLocation.update' }); - } + if (!(await canAccessRoomIdAsync(rid, uid))) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'liveLocation.update' }); + } + const sub = await Subscriptions.findOneByRoomIdAndUserId(rid, uid); + if (!sub) { + throw new Meteor.Error('error-not-in-room', 'User is not in the room', { method: 'liveLocation.update' }); + } - const msg = await Messages.findOne({ - _id: msgId, - rid, - 'u._id': uid, - attachments: { - $elemMatch: { - type: 'live-location', - 'live.isActive': true, - }, - }, - }); + const msg = await Messages.findOne({ + '_id': msgId, + rid, + 'u._id': uid, + 'attachments': { + $elemMatch: { + 'type': 'live-location', + 'live.isActive': true, + }, + }, + }); - if (!msg) { - throw new Meteor.Error('error-live-location-not-found', 'Active live location not found', { - method: 'liveLocation.update', - }); - } + if (!msg) { + throw new Meteor.Error('error-live-location-not-found', 'Active live location not found', { + method: 'liveLocation.update', + }); + } - const last: Date | undefined = (msg.attachments?.[0] as any)?.live?.lastUpdateAt; - const now = new Date(); - if (last && now.getTime() - new Date(last).getTime() < MIN_INTERVAL_MS) { - return { ignored: true, reason: 'too-soon' }; - } + const last: Date | undefined = (msg.attachments?.[0] as any)?.live?.lastUpdateAt; + const now = new Date(); + if (last && now.getTime() - new Date(last).getTime() < MIN_INTERVAL_MS) { + return { ignored: true, reason: 'too-soon' }; + } - const updateTime = new Date(); - const res = await Messages.updateOne( - { _id: msgId }, - { - $set: { - 'attachments.0.live.coords': coords, - 'attachments.0.live.lastUpdateAt': updateTime, - }, - }, - ); + const updateTime = new Date(); + const res = await Messages.updateOne( + { _id: msgId }, + { + $set: { + 'attachments.0.live.coords': coords, + 'attachments.0.live.lastUpdateAt': updateTime, + }, + }, + ); - // Notify clients of message update for real-time UI refresh - if (res.modifiedCount > 0) { - const updatedMsg = await Messages.findOneById(msgId); - if (updatedMsg) { - void notifyOnMessageChange({ - id: updatedMsg._id, - data: updatedMsg, - }); - } - } + // Notify clients of message update for real-time UI refresh + if (res.modifiedCount > 0) { + const updatedMsg = await Messages.findOneById(msgId); + if (updatedMsg) { + void notifyOnMessageChange({ + id: updatedMsg._id, + data: updatedMsg, + }); + } + } - return { updated: Boolean(res.modifiedCount) }; - }, + return { updated: Boolean(res.modifiedCount) }; + }, }); DDPRateLimiter.addRule( - { - userId(userId: string) { - return !!userId; - }, - type: 'method', - name: 'liveLocation.update', - }, - 12, - 60000 + { + userId(userId: string) { + return !!userId; + }, + type: 'method', + name: 'liveLocation.update', + }, + 12, + 60000, ); diff --git a/apps/meteor/app/live-location/server/startup/live-location.ts b/apps/meteor/app/live-location/server/startup/live-location.ts index 2559e3ba50de1..e5bb2272ea2a6 100644 --- a/apps/meteor/app/live-location/server/startup/live-location.ts +++ b/apps/meteor/app/live-location/server/startup/live-location.ts @@ -1,61 +1,57 @@ -import { Meteor } from 'meteor/meteor'; - import { Messages } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; const CLEANUP_INTERVAL_MS = 60_000; -const INACTIVE_GRACE_MS = 30_000; +const INACTIVE_GRACE_MS = 30_000; async function ensureIndex(collection: any, keys: any, options: any = {}) { - try { - await collection.col.createIndex(keys, options); - } catch (e) { - // ignore errors - } + try { + await collection.col.createIndex(keys, options); + } catch (e) { + // ignore errors + } } Meteor.startup(async () => { - await ensureIndex(Messages, { 'attachments.0.type': 1, 'attachments.0.live.isActive': 1 }); - await ensureIndex(Messages, { rid: 1, 'u._id': 1, 'attachments.0.type': 1, 'attachments.0.live.isActive': 1 }); - await ensureIndex( - Messages, - { 'attachments.0.live.expiresAt': 1 }, - { - expireAfterSeconds: 0, - partialFilterExpression: { 'attachments.0.type': 'live-location', 'attachments.0.live.expiresAt': { $type: 'date' } }, - name: 'liveLocation_expiresAt_TTL', - }, - ); - await ensureIndex( - Messages, - { 'attachments.0.live.lastUpdateAt': 1 }, - { - partialFilterExpression: { 'attachments.0.type': 'live-location' }, - name: 'liveLocation_lastUpdateAt_idx', - }, - ); + await ensureIndex(Messages, { 'attachments.0.type': 1, 'attachments.0.live.isActive': 1 }); + await ensureIndex(Messages, { 'rid': 1, 'u._id': 1, 'attachments.0.type': 1, 'attachments.0.live.isActive': 1 }); + await ensureIndex( + Messages, + { 'attachments.0.live.expiresAt': 1 }, + { + expireAfterSeconds: 0, + partialFilterExpression: { 'attachments.0.type': 'live-location', 'attachments.0.live.expiresAt': { $type: 'date' } }, + name: 'liveLocation_expiresAt_TTL', + }, + ); + await ensureIndex( + Messages, + { 'attachments.0.live.lastUpdateAt': 1 }, + { + partialFilterExpression: { 'attachments.0.type': 'live-location' }, + name: 'liveLocation_lastUpdateAt_idx', + }, + ); - Meteor.setInterval(async () => { - const now = new Date(); - const staleBefore = new Date(now.getTime() - INACTIVE_GRACE_MS); - await Messages.updateMany( - { - 'attachments.0.type': 'live-location', - 'attachments.0.live.isActive': true, - $or: [ - { 'attachments.0.live.lastUpdateAt': { $lt: staleBefore } }, - { 'attachments.0.live.expiresAt': { $lte: now } }, - ], - }, - { - $set: { - 'attachments.0.live.isActive': false, - 'attachments.0.live.stoppedAt': now, - }, - }, - ); - }, CLEANUP_INTERVAL_MS); + Meteor.setInterval(async () => { + const now = new Date(); + const staleBefore = new Date(now.getTime() - INACTIVE_GRACE_MS); + await Messages.updateMany( + { + 'attachments.0.type': 'live-location', + 'attachments.0.live.isActive': true, + '$or': [{ 'attachments.0.live.lastUpdateAt': { $lt: staleBefore } }, { 'attachments.0.live.expiresAt': { $lte: now } }], + }, + { + $set: { + 'attachments.0.live.isActive': false, + 'attachments.0.live.stoppedAt': now, + }, + }, + ); + }, CLEANUP_INTERVAL_MS); }); export const LiveLocationStartup = { - CLEANUP_INTERVAL_MS, - INACTIVE_GRACE_MS, + CLEANUP_INTERVAL_MS, + INACTIVE_GRACE_MS, }; diff --git a/apps/meteor/client/components/message/content/location/MapView.tsx b/apps/meteor/client/components/message/content/location/MapView.tsx index 6dae22ecca57a..b9d73523c1ca0 100644 --- a/apps/meteor/client/components/message/content/location/MapView.tsx +++ b/apps/meteor/client/components/message/content/location/MapView.tsx @@ -1,4 +1,3 @@ -import { useSetting } from '@rocket.chat/ui-contexts'; import { memo } from 'react'; import MapViewFallback from './MapViewFallback'; diff --git a/apps/meteor/client/views/room/ShareLocation/LiveLocationModal.tsx b/apps/meteor/client/views/room/ShareLocation/LiveLocationModal.tsx index 73a1089ab2b50..a80b32fc4cdfe 100644 --- a/apps/meteor/client/views/room/ShareLocation/LiveLocationModal.tsx +++ b/apps/meteor/client/views/room/ShareLocation/LiveLocationModal.tsx @@ -1,432 +1,446 @@ // LiveLocationModal.tsx β€” provider-aware live location (clean Google / OSM) +import { useEndpoint } from '@rocket.chat/ui-contexts'; import { useEffect, useRef, useState, useCallback, useMemo } from 'react'; import type { ReactElement } from 'react'; -import { useEndpoint } from '@rocket.chat/ui-contexts'; + import { LiveLocationService, type LocationState } from './liveLocationService'; +import { createMapProvider, type MapProviderName, type IMapProvider } from './mapProvider'; import { useLiveLocationStopListener } from './useLiveLocationStopListener'; -import { createMapProvider, type MapProviderName, type MapProvider } from './mapProvider'; type Props = { - rid: string; - tmid?: string; - onClose: () => void; + rid: string; + tmid?: string; + onClose: () => void; }; const LiveLocationChatWidget = ({ rid, tmid, onClose }: Props): ReactElement => { - const [position, setPosition] = useState(null); - const [locationState, setLocationState] = useState('waiting'); - const [error, setError] = useState(null); - const [gpsUpdateCount, setGpsUpdateCount] = useState(0); - const [lastMessageUpdate, setLastMessageUpdate] = useState(null); - - const [isMinimized, setIsMinimized] = useState(false); - const [isDragging, setIsDragging] = useState(false); - const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 }); - const [position2D, setPosition2D] = useState({ x: 0, y: 0 }); - - const messageIdRef = useRef(null); - const sendingRef = useRef(false); - const isClosingRef = useRef(false); - const isSharingRef = useRef(false); - const serviceRef = useRef(null); - const modalRef = useRef(null); - - const sendMessage = useEndpoint('POST', '/v1/chat.sendMessage'); - const updateMessage = useEndpoint('POST', '/v1/chat.update'); - const { stopLiveLocationSharing } = useLiveLocationStopListener(); - - // --- Provider (shared via localStorage with the static modal) ------------- - const [provider, setProvider] = useState(() => { - const saved = localStorage.getItem('mapProvider') as MapProviderName | null; - return saved ?? 'openstreetmap'; - }); - useEffect(() => { - const onStorage = (e: StorageEvent) => { - if (e.key === 'mapProvider' && e.newValue) setProvider(e.newValue as MapProviderName); - }; - window.addEventListener('storage', onStorage); - return () => window.removeEventListener('storage', onStorage); - }, []); - - // Keys (swap to app settings if you have them) - const googleMapsApiKey = 'AIzaSyBeNJSMCi8kD4c6SOvZ4vxHnWYp2yzDbmg'; - const locationIQKey = 'pk.898e468814facdcffda869b42260a2f0'; - - const map: MapProvider = useMemo( - () => - createMapProvider(provider, { - googleApiKey: googleMapsApiKey, - locationIqKey: locationIQKey, - }), - [provider, googleMapsApiKey, locationIQKey], - ); - - const cacheBust = (url: string) => url + (url.includes('?') ? '&' : '?') + 'ts=' + Date.now(); - - // ---------- Provider-aware attachment (clean theme + retina) -------------- - const createLiveLocationAttachment = useCallback( - (pos: GeolocationPosition, isLive: boolean = true) => { - const { latitude, longitude, accuracy } = pos.coords; - const mapsLink = map.getMapsLink(latitude, longitude); - const staticMapUrl = map.getStaticMapUrl(latitude, longitude, { - zoom: 16, - width: 640, - height: 360, - // theme + scale are used by the Google provider; ignored by OSM provider - // @ts-expect-error allow extra options if your interface is stricter - theme: 'clean', - scale: 2, - }); - - return { - ts: new Date(), - title: isLive ? 'πŸ“ Live Location (Active)' : 'πŸ“ Location Shared', - title_link: mapsLink, - image_url: staticMapUrl, - image_type: 'image/png', - text: isLive ? 'Click to view live location updates' : 'Static location', - // Keep fields present (empty) to satisfy renderers that assume the key exists - fields: [] as any[], - actions: [ - { - type: 'button', - text: isLive ? 'πŸ‘οΈ View Live Location' : 'πŸ—ΊοΈ View Location', - msg: `/viewlocation ${messageIdRef.current || 'temp'}`, - msg_in_chat_window: false, - msg_processing_type: 'sendMessage', - }, - ], - // Optional: include coords - // description: `Lat ${latitude.toFixed(6)}, Lng ${longitude.toFixed(6)}${ - // accuracy ? ` (Β±${Math.round(accuracy)}m)` : '' - // }`, - customFields: { - isLiveLocation: isLive, - locationId: messageIdRef.current, - lastUpdate: new Date().toISOString(), - coordinates: { lat: latitude, lng: longitude, accuracy }, - }, - }; - }, - [map], - ); - - // ---------- Prevent closing while sharing --------------------------------- - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if (isSharingRef.current && modalRef.current && !modalRef.current.contains(event.target as Node)) { - event.stopPropagation(); - event.preventDefault(); - } - }; - const handleEscape = (event: KeyboardEvent) => { - if (event.key === 'Escape' && isSharingRef.current) { - event.stopPropagation(); - event.preventDefault(); - } - }; - document.addEventListener('mousedown', handleClickOutside, true); - document.addEventListener('keydown', handleEscape, true); - return () => { - document.removeEventListener('mousedown', handleClickOutside, true); - document.removeEventListener('keydown', handleEscape, true); - }; - }, []); - - // ---------- Initial placement --------------------------------------------- - useEffect(() => { - const w = typeof window !== 'undefined' ? window.innerWidth : 1200; - const h = typeof window !== 'undefined' ? window.innerHeight : 800; - const defaultWidth = 360; - const defaultHeight = 240; - setPosition2D({ - x: Math.max(12, w - defaultWidth - 12), - y: Math.max(60, h - defaultHeight - 12), - }); - }, []); - - // ---------- Dragging ------------------------------------------------------ - const handleMouseDown = useCallback((e: React.MouseEvent) => { - if (e.target === e.currentTarget || (e.target as HTMLElement).classList.contains('drag-handle')) { - setIsDragging(true); - const rect = modalRef.current?.getBoundingClientRect(); - if (rect) { - setDragOffset({ x: e.clientX - rect.left, y: e.clientY - rect.top }); - } - } - }, []); - - useEffect(() => { - const handleMouseMove = (e: MouseEvent) => { - if (isDragging && modalRef.current) { - const newX = e.clientX - dragOffset.x; - const newY = e.clientY - dragOffset.y; - const maxX = window.innerWidth - modalRef.current.offsetWidth; - const maxY = window.innerHeight - modalRef.current.offsetHeight; - setPosition2D({ - x: Math.max(0, Math.min(newX, maxX)), - y: Math.max(0, Math.min(newY, maxY)), - }); - } - }; - const handleMouseUp = () => setIsDragging(false); - - if (isDragging) { - document.addEventListener('mousemove', handleMouseMove); - document.addEventListener('mouseup', handleMouseUp); - } - return () => { - document.removeEventListener('mousemove', handleMouseMove); - document.removeEventListener('mouseup', handleMouseUp); - }; - }, [isDragging, dragOffset]); - - // ---------- Initialize live service (watchers/timers only) ---------------- - useEffect(() => { - serviceRef.current = new LiveLocationService({ - locationIQKey: locationIQKey, - updateInterval: 10000, - minMoveMeters: 5, - }); - if (typeof window !== 'undefined') (window as any).liveLocationService = serviceRef.current; - return () => { - if (serviceRef.current && !isSharingRef.current) serviceRef.current.cleanup(); - if (typeof window !== 'undefined') delete (window as any).liveLocationService; - }; - }, [locationIQKey]); - - // ---------- Messaging (provider-aware attachments) ------------------------ - const sendInitialMessage = useCallback( - async (pos: GeolocationPosition) => { - if (!serviceRef.current || sendingRef.current || isClosingRef.current) return; - sendingRef.current = true; - try { - const tempMessageId = `live-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`; - messageIdRef.current = tempMessageId; - - const attachment = createLiveLocationAttachment(pos, true); - - const response = await sendMessage({ - message: { - rid, - tmid, - msg: 'πŸ“ Started sharing live location', - attachments: [attachment], - }, - }); - - if (!isClosingRef.current) { - messageIdRef.current = response.message._id; - serviceRef.current.updateLastUpdateTime(); - setLastMessageUpdate(new Date()); - LiveLocationService.storeLiveLocationData(response.message._id, rid); - - const updatedAttachment = createLiveLocationAttachment(pos, true); - await updateMessage({ - roomId: rid, - msgId: response.message._id, - text: 'πŸ“ Started sharing live location', - attachments: [updatedAttachment], - customFields: {}, - } as any); - } - } catch (err) { - console.error('[Send Initial Error]', err); - setError('Failed to send initial location'); - setLocationState('error'); - } finally { - sendingRef.current = false; - } - }, - [sendMessage, updateMessage, rid, tmid, createLiveLocationAttachment], - ); - - const updateLiveLocationMessage = useCallback( - async (pos: GeolocationPosition) => { - if (!serviceRef.current || !messageIdRef.current || sendingRef.current || isClosingRef.current) return; - sendingRef.current = true; - try { - const attachment = createLiveLocationAttachment(pos, true); - await updateMessage({ - roomId: rid, - msgId: messageIdRef.current, - text: 'πŸ“ Live location (Active)', - attachments: [attachment], - customFields: {}, - } as any); - serviceRef.current.updateLastUpdateTime(); - setLastMessageUpdate(new Date()); - } catch (err) { - console.error('[Update Location Error]', err); - } finally { - sendingRef.current = false; - } - }, - [updateMessage, rid, createLiveLocationAttachment], - ); - - const handlePositionSuccess = useCallback( - (pos: GeolocationPosition) => { - if (isClosingRef.current) return; - const prev = position; - setPosition(pos); - setError(null); - setGpsUpdateCount((c) => c + 1); - if (!isSharingRef.current) return; - if (locationState !== 'sharing') setLocationState('sharing'); - if (!messageIdRef.current) { - void sendInitialMessage(pos); - } else if (serviceRef.current?.shouldPushUpdate(prev, pos)) { - void updateLiveLocationMessage(pos); - } - }, - [position, locationState, sendInitialMessage, updateLiveLocationMessage], - ); - - const handlePositionError = useCallback( - (err: GeolocationPositionError) => { - if (isClosingRef.current) return; - if (err.code === err.PERMISSION_DENIED) { - setError('Location permission is denied'); - setLocationState('error'); - return; - } - console.warn('[Geolocation transient error]', err); - if (isSharingRef.current && serviceRef.current) { - setTimeout(() => { - if (!isClosingRef.current && isSharingRef.current && serviceRef.current) { - serviceRef.current.startWatching(handlePositionSuccess, handlePositionError); - } - }, 1500); - } - }, - [handlePositionSuccess], - ); - - const startSharing = useCallback(() => { - if (isClosingRef.current || !serviceRef.current) return; - isSharingRef.current = true; - setLocationState('sharing'); - serviceRef.current.startSharing(); - if (position) handlePositionSuccess(position); - }, [position, handlePositionSuccess]); - - const stopSharing = useCallback(async () => { - if (!serviceRef.current) return; - isSharingRef.current = false; - serviceRef.current.stopSharing(); - - if (messageIdRef.current && position) { - try { - const finalAttachment = createLiveLocationAttachment(position, false); - await updateMessage({ - roomId: rid, - msgId: messageIdRef.current, - text: 'πŸ“ Live location sharing stopped', - attachments: [finalAttachment], - customFields: {}, - } as any); - } catch (err) { - console.error('[Stop sharing update error]', err); - } - } - - await stopLiveLocationSharing(rid, messageIdRef.current || undefined, position || undefined); - messageIdRef.current = null; - setLocationState('waiting'); - }, [stopLiveLocationSharing, rid, position, createLiveLocationAttachment, updateMessage]); - - const handleClose = useCallback(() => { - if (isSharingRef.current) { - if (window.confirm('Live location sharing is active. Stop sharing and close?')) { - stopSharing().then(() => { - isClosingRef.current = true; - serviceRef.current?.cleanup(); - onClose(); - }); - } - return; - } - isClosingRef.current = true; - serviceRef.current?.cleanup(); - onClose(); - }, [onClose, stopSharing]); - - // Start watching on mount - useEffect(() => { - if (!serviceRef.current) return; - serviceRef.current.startWatching(handlePositionSuccess, handlePositionError); - return () => { - if (!isSharingRef.current && serviceRef.current) serviceRef.current.cleanup(); - }; - }, [handlePositionSuccess, handlePositionError]); - - const getStatusIcon = () => (locationState === 'waiting' ? 'πŸ“‘' : locationState === 'sharing' ? 'πŸ“' : '❌'); - const getStatusText = () => (locationState === 'waiting' ? 'Getting location...' : locationState === 'sharing' ? 'Live sharing active' : 'Location error'); - - // Provider-aware preview (clean theme + retina) - const previewUrl = - position - ? cacheBust( - map.getStaticMapUrl(position.coords.latitude, position.coords.longitude, { - zoom: 15, - width: 320, - height: 180, - // @ts-expect-error allow extra options if interface is strict - theme: 'clean', - scale: 2, - }), - ) - : ''; - - // -------------------------- UI ------------------------------------------- - if (isMinimized) { - return ( -
setIsMinimized(false)} - onMouseDown={handleMouseDown} - title={getStatusText()} - > - {getStatusIcon()} - {locationState === 'sharing' && ( -
- )} -
- ); - } - - return ( - <> - -
- {/* Header */} -
-
- {getStatusIcon()} - {getStatusText()} - {isSharingRef.current && ( -
- )} -
- -
- - -
-
- - {/* Content */} -
- {locationState === 'error' && ( -
-

{error}

-

- Please enable location access in your browser settings. -

-
- )} - - {locationState === 'waiting' && ( - <> - {position ? ( -
-
- Map preview -
- -
-
Lat: {position.coords.latitude.toFixed(6)}
-
Lng: {position.coords.longitude.toFixed(6)}
- {position.coords.accuracy && ( -
Accuracy: Β±{Math.round(position.coords.accuracy)}m
- )} -
- - -
- ) : ( -
-
πŸ“‘
-
Getting your location...
-
- )} - - )} - - {locationState === 'sharing' && ( -
- {position && ( - <> -
- Live location -
- -
-
Lat: {position.coords.latitude.toFixed(6)}
-
Lng: {position.coords.longitude.toFixed(6)}
- {position.coords.accuracy && ( -
Accuracy: Β±{Math.round(position.coords.accuracy)}m
- )} -
- {lastMessageUpdate &&
Last update: {lastMessageUpdate.toLocaleTimeString()}
} -
-
- -
- βœ… Sharing live location every 10 seconds -
- Others can click "View Live Location" to see updates -
- - )} - - -
- )} -
-
- - ); + {/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */} +
{ + if (e.key === 'Enter' || e.key === ' ') { + handleMouseDown(e as any); + } + }} + > + {/* Header */} +
+
+ {getStatusIcon()} + {getStatusText()} + {isSharingRef.current && ( +
+ )} +
+ +
+ + +
+
+ + {/* Content */} +
+ {locationState === 'error' && ( +
+

{error}

+

Please enable location access in your browser settings.

+
+ )} + + {locationState === 'waiting' && ( + <> + {position ? ( +
+
+ Map preview +
+ +
+
+ Lat: {position.coords.latitude.toFixed(6)} +
+
+ Lng: {position.coords.longitude.toFixed(6)} +
+ {position.coords.accuracy && ( +
+ Accuracy: Β±{Math.round(position.coords.accuracy)}m +
+ )} +
+ + +
+ ) : ( +
+
πŸ“‘
+
Getting your location...
+
+ )} + + )} + + {locationState === 'sharing' && ( +
+ {position && ( + <> +
+ Live location +
+ +
+
+ Lat: {position.coords.latitude.toFixed(6)} +
+
+ Lng: {position.coords.longitude.toFixed(6)} +
+ {position.coords.accuracy && ( +
+ Accuracy: Β±{Math.round(position.coords.accuracy)}m +
+ )} +
+ {lastMessageUpdate &&
Last update: {lastMessageUpdate.toLocaleTimeString()}
} +
+
+ +
+ βœ… Sharing live location every 10 seconds +
+ Others can click "View Live Location" to see updates +
+ + )} + + +
+ )} +
+
+ + ); }; export default LiveLocationChatWidget; diff --git a/apps/meteor/client/views/room/ShareLocation/MapView.tsx b/apps/meteor/client/views/room/ShareLocation/MapView.tsx index 93a7295a74158..a5390bc0d26a3 100644 --- a/apps/meteor/client/views/room/ShareLocation/MapView.tsx +++ b/apps/meteor/client/views/room/ShareLocation/MapView.tsx @@ -1,89 +1,85 @@ // MapView.tsx import React from 'react'; -import type { MapProviderName, MapProvider } from '../ShareLocation/mapProvider'; + +import type { MapProviderName, IMapProvider } from './mapProvider'; export type MapViewProps = { - latitude: number; - longitude: number; - zoom?: number; - width?: number; - height?: number; + latitude: number; + longitude: number; + zoom?: number; + width?: number; + height?: number; - /** - * Optional: make MapView provider-aware. - * If present, we'll use mapInstance to render a provider-specific static map. - * If omitted, we fall back to a neutral placeholder so legacy callers still work. - */ - provider?: MapProviderName; - mapInstance?: MapProvider; + /** + * Optional: make MapView provider-aware. + * If present, we'll use mapInstance to render a provider-specific static map. + * If omitted, we fall back to a neutral placeholder so legacy callers still work. + */ + provider?: MapProviderName; + mapInstance?: IMapProvider; - // Optional: show a tiny attribution line beneath the map (defaults true) - showAttribution?: boolean; + // Optional: show a tiny attribution line beneath the map (defaults true) + showAttribution?: boolean; }; const MapView: React.FC = ({ - latitude, - longitude, - zoom = 17, - width = 512, - height = 512, - provider, - mapInstance, - showAttribution = true, + latitude, + longitude, + zoom = 17, + width = 512, + height = 512, + provider, + mapInstance, + showAttribution = true, }) => { - // If a provider instance is provided, use it to compute a static map URL - const staticUrl = - mapInstance?.getStaticMapUrl(latitude, longitude, { zoom, width, height }) ?? null; - const attribution = mapInstance?.getAttribution?.(); + // If a provider instance is provided, use it to compute a static map URL + const staticUrl = mapInstance?.getStaticMapUrl(latitude, longitude, { zoom, width, height }) ?? null; + const attribution = mapInstance?.getAttribution?.(); - if (staticUrl) { - return ( -
-
- Map preview -
- {showAttribution && attribution && provider === 'openstreetmap' && ( -
{attribution}
- )} -
- ); - } + if (staticUrl) { + return ( +
+
+ Map preview +
+ {showAttribution && attribution && provider === 'openstreetmap' && ( +
{attribution}
+ )} +
+ ); + } - // Fallback: neutral placeholder (legacy behavior safety net) - return ( -
- Map preview unavailable -
- {latitude.toFixed(5)}, {longitude.toFixed(5)} -
-
- ); + // Fallback: neutral placeholder (legacy behavior safety net) + return ( +
+ Map preview unavailable +
+ {latitude.toFixed(5)}, {longitude.toFixed(5)} +
+
+ ); }; export default MapView; diff --git a/apps/meteor/client/views/room/ShareLocation/ShareLocationModal.tsx b/apps/meteor/client/views/room/ShareLocation/ShareLocationModal.tsx index 37ae234320a11..f252105538b81 100644 --- a/apps/meteor/client/views/room/ShareLocation/ShareLocationModal.tsx +++ b/apps/meteor/client/views/room/ShareLocation/ShareLocationModal.tsx @@ -6,295 +6,284 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'; import type { ReactElement } from 'react'; import { useMemo, useState, useEffect } from 'react'; +import LiveLocationModal from './LiveLocationModal'; +import MapView from './MapView'; import { getGeolocationPermission } from './getGeolocationPermission'; import { getGeolocationPosition } from './getGeolocationPosition'; -import MapView from './MapView'; -import LiveLocationModal from './LiveLocationModal'; - -import { createMapProvider, type MapProviderName, type MapProvider } from './mapProvider'; +import { createMapProvider, type MapProviderName, type IMapProvider } from './mapProvider'; type ShareLocationModalProps = { - rid: IRoom['_id']; - tmid?: IMessage['tmid']; - onClose: () => void; + rid: IRoom['_id']; + tmid?: IMessage['tmid']; + onClose: () => void; }; type Stage = 'provider' | '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 t = useTranslation(); + const dispatchToast = useToastMessageDispatch(); + const queryClient = useQueryClient(); + const sendMessage = useEndpoint('POST', '/v1/chat.sendMessage'); + + // --- New: stages (provider -> choose -> static/live) --- + const [stage, setStage] = useState('provider'); + const [choice, setChoice] = useState<'current' | 'live' | null>(null); - // --- New: stages (provider -> choose -> static/live) --- - const [stage, setStage] = useState('provider'); - const [choice, setChoice] = useState<'current' | 'live' | null>(null); + // Provider picker (persisted) + const [provider, setProvider] = useState(() => { + const saved = localStorage.getItem('mapProvider') as MapProviderName | null; + return saved ?? 'openstreetmap'; + }); + useEffect(() => { + localStorage.setItem('mapProvider', provider); + }, [provider]); - // Provider picker (persisted) - const [provider, setProvider] = useState(() => { - const saved = localStorage.getItem('mapProvider') as MapProviderName | null; - return saved ?? 'openstreetmap'; - }); - useEffect(() => { - localStorage.setItem('mapProvider', provider); - }, [provider]); + // Keys (swap to settings if you have them) + const googleMapsApiKey = 'AIzaSyBeNJSMCi8kD4c6SOvZ4vxHnWYp2yzDbmg'; + const locationIQKey = 'pk.898e468814facdcffda869b42260a2f0'; - // Keys (swap to settings if you have them) - const googleMapsApiKey = 'AIzaSyBeNJSMCi8kD4c6SOvZ4vxHnWYp2yzDbmg'; - const locationIQKey = 'pk.898e468814facdcffda869b42260a2f0'; + // Provider instance + const map: IMapProvider = useMemo( + () => + createMapProvider(provider, { + googleApiKey: googleMapsApiKey, + locationIqKey: locationIQKey, + }), + [provider, googleMapsApiKey, locationIQKey], + ); - // Provider instance - const map: MapProvider = useMemo( - () => - createMapProvider(provider, { - googleApiKey: googleMapsApiKey, - locationIqKey: locationIQKey, - }), - [provider, googleMapsApiKey, locationIQKey], - ); + // Permission & position queries (only used in static flow) + const { data: permissionState, isLoading: permissionLoading } = useQuery({ + queryKey: ['geolocationPermission'], + queryFn: getGeolocationPermission, + refetchOnWindowFocus: false, + }); - // Permission & position queries (only used in static flow) - const { data: permissionState, isLoading: permissionLoading } = useQuery({ - queryKey: ['geolocationPermission'], - queryFn: getGeolocationPermission, - refetchOnWindowFocus: false, - }); + 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) => { + // retry ONCE for transient errors; never retry on permission denied + const e = error as any; + const code = e?.code; + const msg = String(e?.message || '').toLowerCase(); + const transient = + code !== 1 && // not PERMISSION_DENIED + (code === 2 || msg.includes('kclerrorlocationunknown') || msg.includes('location unknown')); + return transient && failureCount < 1; + }, + retryDelay: 1500, + }); - 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) => { - // retry ONCE for transient errors; never retry on permission denied - const e = error as any; - const code = e?.code; - const msg = String(e?.message || '').toLowerCase(); - const transient = - code !== 1 && // not PERMISSION_DENIED - (code === 2 || msg.includes('kclerrorlocationunknown') || msg.includes('location unknown')); - return transient && failureCount < 1; - }, - retryDelay: 1500, - }); + const onConfirmRequestLocation = async (): Promise => { + try { + const pos = await getGeolocationPosition(); // triggers browser prompt + queryClient.setQueryData(['geolocationPermission'], 'granted'); + queryClient.setQueryData(['geolocationPosition'], pos); + } catch { + const state = await getGeolocationPermission(); + queryClient.setQueryData(['geolocationPermission'], state); + } + }; - const onConfirmRequestLocation = async (): Promise => { - try { - const pos = await getGeolocationPosition(); // triggers browser prompt - queryClient.setQueryData(['geolocationPermission'], 'granted'); - queryClient.setQueryData(['geolocationPosition'], pos); - } catch { - const state = await getGeolocationPermission(); - queryClient.setQueryData(['geolocationPermission'], state); - } - }; + // --- Stage 1: Provider selection (FIRST) --- + if (stage === 'provider') { + return ( + setStage('choose')} + onCancel={onClose} + > +
+ + +
You can change this later. Your choice is saved for live sharing too.
+
+
+ ); + } - // --- Stage 1: Provider selection (FIRST) --- - if (stage === 'provider') { - return ( - setStage('choose')} - onCancel={onClose} - > -
- - -
- You can change this later. Your choice is saved for live sharing too. -
-
-
- ); - } + // --- Stage 2: Choose static vs live --- + if (stage === 'choose' && !choice) { + return ( + { + setChoice('live'); + }} + confirmText='Current Location' + onConfirm={() => { + setChoice('current'); + setStage('static'); + }} + > + Choose to share your current location once or start live location sharing. + + ); + } - // --- Stage 2: Choose static vs live --- - if (stage === 'choose' && !choice) { - return ( - { - setChoice('live'); - }} - confirmText="Current Location" - onConfirm={() => { - setChoice('current'); - setStage('static'); - }} - > - Choose to share your current location once or start live location sharing. - - ); - } + // Live path + if (choice === 'live') { + // Your LiveLocation modal/widget reads provider from localStorage, + // which we already saved above. + return ; + } - // Live path - if (choice === 'live') { - // Your LiveLocation modal/widget reads provider from localStorage, - // which we already saved above. - return ; - } + // --- Stage 3: Static flow (with permission gating) --- + if (stage === 'static') { + // Ask for permission + if (permissionLoading || permissionState === 'prompt' || permissionState === undefined) { + return ( + + ); + } - // --- Stage 3: Static flow (with permission gating) --- - if (stage === 'static') { - // Ask for permission - if (permissionLoading || permissionState === 'prompt' || permissionState === undefined) { - return ( - - ); - } + // Explicitly denied + if (permissionState === 'denied') { + return ( + + {t('The_necessary_browser_permissions_for_location_sharing_are_not_granted')} + + ); + } - // 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 ( + + Getting your location… + + ); + } - // Granted, still fetching coordinates β†’ loader - if (permissionState === 'granted' && (positionLoading || positionFetching)) { - return ( - - Getting your location… - - ); - } + // Granted but failed + if (permissionState === 'granted' && positionError) { + return ( + { + // Clear the error and try again + queryClient.resetQueries({ queryKey: ['geolocationPosition'] }); + }} + onClose={onClose} + cancelText={t('Cancel')} + onCancel={onClose} + > +
{(positionErr as Error | undefined)?.message || 'Unable to fetch your current location.'}
- // Granted but failed - if (permissionState === 'granted' && positionError) { - return ( - { - // Clear the error and try again - queryClient.resetQueries({ queryKey: ['geolocationPosition'] }); - }} - onClose={onClose} - cancelText={t('Cancel')} - onCancel={onClose} - > -
- {(positionErr as Error | undefined)?.message || 'Unable to fetch your current location.'} -
- -
- Tips to improve location accuracy: -
β€’ Move closer to a window or go outside -
β€’ Make sure location services are enabled on your device -
β€’ Check that your browser has location permissions -
β€’ Try refreshing the page and allowing location access again -
-
- ); - } +
+ Tips to improve location accuracy: +
β€’ Move closer to a window or go outside +
β€’ Make sure location services are enabled on your device +
β€’ Check that your browser has location permissions +
β€’ Try refreshing the page and allowing location access again +
+
+ ); + } - const onConfirmStatic = (): void => { - if (!positionData) return; - const { latitude, longitude, accuracy } = positionData.coords; + const onConfirmStatic = (): void => { + if (!positionData) return; + const { latitude, longitude } = positionData.coords; - if (provider === 'google' && !googleMapsApiKey) { - dispatchToast({ - type: 'warning', - message: 'Google Maps API key is missing; consider using OpenStreetMap.', - }); - } + if (provider === 'google' && !googleMapsApiKey) { + dispatchToast({ + type: 'warning', + message: 'Google Maps API key is missing; consider using OpenStreetMap.', + }); + } - try { - const mapsLink = map.getMapsLink(latitude, longitude); - const staticMapUrl = map.getStaticMapUrl(latitude, longitude, { zoom: 17, width: 512, height: 512 }); + try { + const mapsLink = map.getMapsLink(latitude, longitude); + const staticMapUrl = map.getStaticMapUrl(latitude, longitude, { zoom: 17, width: 512, height: 512 }); - const attachment: MessageAttachment = { - ts: new Date(), - title: 'πŸ“ Shared Location', - title_link: mapsLink, - image_url: staticMapUrl, - image_type: 'image/png', - // keep fields as empty array to avoid any renderer crashes - fields: [], - }; + const attachment: MessageAttachment = { + ts: new Date(), + title: 'πŸ“ Shared Location', + title_link: mapsLink, + image_url: staticMapUrl, + image_type: 'image/png', + // keep fields as empty array to avoid any renderer crashes + fields: [], + }; - void sendMessage({ - message: { - rid, - tmid, - attachments: [attachment], - }, - }); - } catch (error) { - dispatchToast({ type: 'error', message: error instanceof Error ? error.message : String(error) }); - } finally { - onClose(); - } - }; + void sendMessage({ + message: { + rid, + tmid, + attachments: [attachment], + }, + }); + } catch (error) { + dispatchToast({ type: 'error', message: error instanceof Error ? error.message : String(error) }); + } finally { + onClose(); + } + }; - // Static share preview + confirm - return ( - -
- Provider: {provider === 'google' ? 'Google Maps' : 'OpenStreetMap'} -
+ // Static share preview + confirm + return ( + +
+ Provider: {provider === 'google' ? 'Google Maps' : 'OpenStreetMap'} +
- {positionData && ( - - )} -
- ); - } + {positionData && ( + + )} +
+ ); + } - // Should never hit here - return <>; + // Should never hit here + return <>; }; export default ShareLocationModal; diff --git a/apps/meteor/client/views/room/ShareLocation/getGeolocationPosition.ts b/apps/meteor/client/views/room/ShareLocation/getGeolocationPosition.ts index 1240e03b91a24..16642dc9c7462 100644 --- a/apps/meteor/client/views/room/ShareLocation/getGeolocationPosition.ts +++ b/apps/meteor/client/views/room/ShareLocation/getGeolocationPosition.ts @@ -5,133 +5,128 @@ const CACHE_KEY = 'lastGeoPosition'; const MAX_CACHE_AGE_MS = 5 * 60 * 1000; // 5 minutes export async function getGeolocationPosition(opts?: PositionOptions): Promise { - if (typeof window === 'undefined' || !('geolocation' in navigator)) { - throw new Error('Geolocation API not available'); - } + if (typeof window === 'undefined' || !('geolocation' in navigator)) { + throw new Error('Geolocation API not available'); + } - // 0) Serve a fresh cached fix immediately if available - const cached = readCached(); - if (cached && Date.now() - cached.timestamp < MAX_CACHE_AGE_MS) { - return cached.position; - } + // 0) Serve a fresh cached fix immediately if available + 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, - }); - cache(pos); - return pos; - } catch (e) { - const err = e as AnyErr; - // If the user denied, bail immediately - if (err.code === err.PERMISSION_DENIED) throw err; - // Otherwise continue to fallback - } + // 1) Quick relaxed single read (lets Apple CoreLocation settle) + try { + const pos = await getOnce({ + enableHighAccuracy: false, + timeout: 8000, + maximumAge: 60_000, + ...opts, + }); + cache(pos); + return pos; + } catch (e) { + const err = e as AnyErr; + // If the user denied, bail immediately + if (err.code === err.PERMISSION_DENIED) throw err; + // Otherwise continue to fallback + } - // 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, - ...opts, - }); - cache(pos); - return pos; - } catch (e) { - const err = e as AnyErr; - // If it's the well-known transient Apple error, try one more relaxed single read - if (isTransient(err)) { - try { - const pos = await getOnce({ - enableHighAccuracy: false, - timeout: 12_000, - maximumAge: 2 * 60_000, - ...opts, - }); - cache(pos); - return pos; - } catch { - // fall through to final attempt - } - } - } + // 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, + ...opts, + }); + cache(pos); + return pos; + } catch (e) { + const err = e as AnyErr; + // If it's the well-known transient Apple error, try one more relaxed single read + if (isTransient(err)) { + try { + const pos = await getOnce({ + enableHighAccuracy: false, + timeout: 12_000, + maximumAge: 2 * 60_000, + ...opts, + }); + cache(pos); + return pos; + } catch { + // fall through to final attempt + } + } + } - // 3) Final attempt: high-accuracy single read (may take longer indoors) - const pos = await getOnce({ - enableHighAccuracy: true, - timeout: 15_000, - maximumAge: 0, - ...opts, - }); - cache(pos); - return pos; + // 3) Final attempt: high-accuracy single read (may take longer indoors) + const pos = await getOnce({ + enableHighAccuracy: true, + timeout: 15_000, + maximumAge: 0, + ...opts, + }); + cache(pos); + return pos; } function getOnce(options: PositionOptions): Promise { - return new Promise((resolve, reject) => { - navigator.geolocation.getCurrentPosition(resolve, reject, options); - }); + 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); + 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, - ); - }); + 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; - // POSITION_UNAVAILABLE (2) is commonly transient - if (err.code === err.POSITION_UNAVAILABLE) return true; - const m = String(err.message || '').toLowerCase(); - // Apple/macOS transient message - if (m.includes('kclerrorlocationunknown')) return true; - if (m.includes('location unknown')) return true; - return false; + if (!err) return false; + // POSITION_UNAVAILABLE (2) is commonly transient + if (err.code === err.POSITION_UNAVAILABLE) return true; + const m = String(err.message || '').toLowerCase(); + // Apple/macOS transient message + if (m.includes('kclerrorlocationunknown')) return true; + if (m.includes('location unknown')) return true; + return false; } function cache(position: GeolocationPosition) { - try { - sessionStorage.setItem( - CACHE_KEY, - JSON.stringify({ timestamp: Date.now(), position }), - ); - } catch { - // ignore - } + try { + sessionStorage.setItem(CACHE_KEY, JSON.stringify({ timestamp: Date.now(), position })); + } catch { + // ignore + } } -function readCached(): - | { timestamp: number; position: GeolocationPosition } - | null { - try { - const raw = sessionStorage.getItem(CACHE_KEY); - if (!raw) return null; - return JSON.parse(raw); - } catch { - return null; - } +function readCached(): { timestamp: number; position: GeolocationPosition } | null { + try { + const raw = sessionStorage.getItem(CACHE_KEY); + if (!raw) return null; + return JSON.parse(raw); + } catch { + return null; + } } diff --git a/apps/meteor/client/views/room/ShareLocation/liveLocationService.ts b/apps/meteor/client/views/room/ShareLocation/liveLocationService.ts index 0ff37ef1b03f8..809d06033d5da 100644 --- a/apps/meteor/client/views/room/ShareLocation/liveLocationService.ts +++ b/apps/meteor/client/views/room/ShareLocation/liveLocationService.ts @@ -1,250 +1,244 @@ // liveLocationService.ts export type LocationState = 'waiting' | 'sharing' | 'error'; -export interface LocationServiceConfig { - locationIQKey: string; - updateInterval: number; - minMoveMeters: number; +export interface ILocationServiceConfig { + locationIQKey: string; + updateInterval: number; + minMoveMeters: number; } export class LiveLocationService { - private config: LocationServiceConfig; - private watchId: number | null = null; - private pollTimerRef: number | null = null; - private sharingIntervalRef: number | null = null; - private isSharing = false; - private lastUpdateTime = 0; - private onPositionSuccess?: (position: GeolocationPosition) => void; - private onPositionError?: (error: GeolocationPositionError) => void; - - constructor(config: LocationServiceConfig) { - this.config = config; - this.setupDevelopmentMock(); - } - - private setupDevelopmentMock() { - if (process.env.NODE_ENV === 'development') { - let lat = 40.7128; // starting latitude (NYC) - let lon = -74.0060; // starting longitude (NYC) - - navigator.geolocation.getCurrentPosition = (success, error) => { - success({ - coords: { - latitude: lat, - longitude: lon, - accuracy: 10, - altitude: null, - altitudeAccuracy: null, - heading: null, - speed: null - }, - timestamp: Date.now() - } as GeolocationPosition); - }; - - navigator.geolocation.watchPosition = (success, error) => { - const watchId = setInterval(() => { - // Simulate movement - lat += (Math.random() - 0.5) * 0.001; - lon += (Math.random() - 0.5) * 0.001; - - success({ - coords: { - latitude: lat, - longitude: lon, - accuracy: 10, - altitude: null, - altitudeAccuracy: null, - heading: null, - speed: null - }, - timestamp: Date.now() - } as GeolocationPosition); - }, 10000); // every 5 seconds for testing - - return watchId as unknown as number; - }; - - navigator.geolocation.clearWatch = (id) => clearInterval(id as unknown as number); - } - } - - generateMapUrls(latitude: number, longitude: number) { - const mapsLink = `https://www.openstreetmap.org/?mlat=${latitude}&mlon=${longitude}#map=16/${latitude}/${longitude}`; - const staticMapUrl = `https://maps.locationiq.com/v2/staticmap?key=${this.config.locationIQKey}¢er=${latitude},${longitude}&zoom=17&size=512x512&markers=icon:small-red-cutout|${latitude},${longitude}`; - return { mapsLink, staticMapUrl }; - } - - createLocationAttachment(pos: GeolocationPosition, isLive: boolean = false) { - const { latitude, longitude, accuracy, heading, speed } = pos.coords; - const { mapsLink, staticMapUrl } = this.generateMapUrls(latitude, longitude); - - const baseAttachment = { - ts: new Date(), - title: isLive ? 'πŸ“ Live Location (Sharing)' : 'πŸ“ Location', - title_link: mapsLink, - image_url: staticMapUrl, - description: [ - `Lat: ${latitude.toFixed(5)}, Lng: ${longitude.toFixed(5)}`, - accuracy ? `Accuracy: Β±${Math.round(accuracy)}m` : null, - heading != null ? `Heading: ${heading.toFixed(1)}Β°` : null, - speed != null ? `Speed: ${speed.toFixed(1)} m/s` : null, - isLive ? `Updated: ${new Date().toLocaleTimeString()}` : null, - ] - .filter(Boolean) - .join(' β€’ '), - }; - - // Add action buttons for live location - if (isLive) { - return { - ...baseAttachment, - actions: [ - { - type: 'button', - text: 'πŸ›‘ Stop Sharing', - msg: '/stop-live-location', - msg_in_chat_window: false, - msg_processing_type: 'sendMessage' - } - ] - }; - } - - return baseAttachment; - } - - private haversineMeters(a: GeolocationCoordinates, b: GeolocationCoordinates): number { - const toRad = (x: number) => (x * Math.PI) / 180; - const R = 6371000; - const dLat = toRad(b.latitude - a.latitude); - const dLon = toRad(b.longitude - a.longitude); - const lat1 = toRad(a.latitude); - const lat2 = toRad(b.latitude); - const h = - Math.sin(dLat / 2) ** 2 + - Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) ** 2; - return 2 * R * Math.asin(Math.sqrt(h)); - } - - shouldPushUpdate(prev: GeolocationPosition | null, curr: GeolocationPosition): boolean { - const now = Date.now(); - if (now - this.lastUpdateTime >= this.config.updateInterval) return true; - if (!prev) return true; - return this.haversineMeters(prev.coords, curr.coords) >= this.config.minMoveMeters; - } - - startWatching( - onSuccess: (position: GeolocationPosition) => void, - onError: (error: GeolocationPositionError) => void - ) { - if (!navigator.geolocation) { - onError({ - code: 2, - message: 'Geolocation is not supported by this browser', - PERMISSION_DENIED: 1, - POSITION_UNAVAILABLE: 2, - TIMEOUT: 3 - } as GeolocationPositionError); - return; - } - - this.onPositionSuccess = onSuccess; - this.onPositionError = onError; - - this.watchId = navigator.geolocation.watchPosition( - onSuccess, - onError, - { enableHighAccuracy: true, timeout: 20000, maximumAge: 0 } - ); - - // Optional heartbeat to smooth out quiet watches - this.pollTimerRef = window.setInterval(() => { - navigator.geolocation.getCurrentPosition( - onSuccess, - onError, - { enableHighAccuracy: true, timeout: 20000, maximumAge: 0 } - ); - }, this.config.updateInterval); - } - - startSharing() { - this.isSharing = true; - - // Set up continuous sharing interval - this.sharingIntervalRef = window.setInterval(() => { - if (this.isSharing && this.onPositionSuccess) { - navigator.geolocation.getCurrentPosition( - (pos) => { - if (this.isSharing && this.onPositionSuccess) { - this.onPositionSuccess(pos); - } - }, - (error) => { - if (this.onPositionError) { - this.onPositionError(error); - } - }, - { enableHighAccuracy: true, timeout: 20000, maximumAge: 0 } - ); - } - }, this.config.updateInterval); - } - - stopSharing() { - this.isSharing = false; - - if (this.sharingIntervalRef !== null) { - clearInterval(this.sharingIntervalRef); - this.sharingIntervalRef = null; - } - } - - cleanup() { - this.stopSharing(); - - if (this.watchId !== null) { - navigator.geolocation.clearWatch(this.watchId); - this.watchId = null; - } - - if (this.pollTimerRef !== null) { - clearInterval(this.pollTimerRef); - this.pollTimerRef = null; - } - } - - updateLastUpdateTime() { - this.lastUpdateTime = Date.now(); - } - - get isSharingActive(): boolean { - return this.isSharing; - } - - // Storage helpers - static storeLiveLocationData(messageId: string, roomId: string) { - if (typeof window !== 'undefined') { - window.localStorage.setItem('liveLocationMessageId', messageId); - window.localStorage.setItem('liveLocationRoomId', roomId); - } - } - - static getLiveLocationData(): { messageId: string | null; roomId: string | null } { - if (typeof window === 'undefined') { - return { messageId: null, roomId: null }; - } - - return { - messageId: window.localStorage.getItem('liveLocationMessageId'), - roomId: window.localStorage.getItem('liveLocationRoomId') - }; - } - - static clearLiveLocationData() { - if (typeof window !== 'undefined') { - window.localStorage.removeItem('liveLocationMessageId'); - window.localStorage.removeItem('liveLocationRoomId'); - } - } -} \ No newline at end of file + private config: ILocationServiceConfig; + + private watchId: number | null = null; + + private pollTimerRef: number | null = null; + + private sharingIntervalRef: number | null = null; + + private isSharing = false; + + private lastUpdateTime = 0; + + private onPositionSuccess?: (position: GeolocationPosition) => void; + + private onPositionError?: (error: GeolocationPositionError) => void; + + constructor(config: ILocationServiceConfig) { + this.config = config; + this.setupDevelopmentMock(); + } + + private setupDevelopmentMock() { + if (process.env.NODE_ENV === 'development') { + let lat = 40.7128; // starting latitude (NYC) + let lon = -74.006; // starting longitude (NYC) + + navigator.geolocation.getCurrentPosition = (success, _error) => { + success({ + coords: { + latitude: lat, + longitude: lon, + accuracy: 10, + altitude: null, + altitudeAccuracy: null, + heading: null, + speed: null, + }, + timestamp: Date.now(), + } as GeolocationPosition); + }; + + navigator.geolocation.watchPosition = (success, _error) => { + const watchId = setInterval(() => { + // Simulate movement + lat += (Math.random() - 0.5) * 0.001; + lon += (Math.random() - 0.5) * 0.001; + + success({ + coords: { + latitude: lat, + longitude: lon, + accuracy: 10, + altitude: null, + altitudeAccuracy: null, + heading: null, + speed: null, + }, + timestamp: Date.now(), + } as GeolocationPosition); + }, 10000); // every 5 seconds for testing + + return watchId as unknown as number; + }; + + navigator.geolocation.clearWatch = (id) => clearInterval(id as unknown as number); + } + } + + generateMapUrls(latitude: number, longitude: number) { + const mapsLink = `https://www.openstreetmap.org/?mlat=${latitude}&mlon=${longitude}#map=16/${latitude}/${longitude}`; + const staticMapUrl = `https://maps.locationiq.com/v2/staticmap?key=${this.config.locationIQKey}¢er=${latitude},${longitude}&zoom=17&size=512x512&markers=icon:small-red-cutout|${latitude},${longitude}`; + return { mapsLink, staticMapUrl }; + } + + createLocationAttachment(pos: GeolocationPosition, isLive = false) { + const { latitude, longitude, accuracy, heading, speed } = pos.coords; + const { mapsLink, staticMapUrl } = this.generateMapUrls(latitude, longitude); + + const baseAttachment = { + ts: new Date(), + title: isLive ? 'πŸ“ Live Location (Sharing)' : 'πŸ“ Location', + title_link: mapsLink, + image_url: staticMapUrl, + description: [ + `Lat: ${latitude.toFixed(5)}, Lng: ${longitude.toFixed(5)}`, + accuracy ? `Accuracy: Β±${Math.round(accuracy)}m` : null, + heading != null ? `Heading: ${heading.toFixed(1)}Β°` : null, + speed != null ? `Speed: ${speed.toFixed(1)} m/s` : null, + isLive ? `Updated: ${new Date().toLocaleTimeString()}` : null, + ] + .filter(Boolean) + .join(' β€’ '), + }; + + // Add action buttons for live location + if (isLive) { + return { + ...baseAttachment, + actions: [ + { + type: 'button', + text: 'πŸ›‘ Stop Sharing', + msg: '/stop-live-location', + msg_in_chat_window: false, + msg_processing_type: 'sendMessage', + }, + ], + }; + } + + return baseAttachment; + } + + private haversineMeters(a: GeolocationCoordinates, b: GeolocationCoordinates): number { + const toRad = (x: number) => (x * Math.PI) / 180; + const R = 6371000; + const dLat = toRad(b.latitude - a.latitude); + const dLon = toRad(b.longitude - a.longitude); + const lat1 = toRad(a.latitude); + const lat2 = toRad(b.latitude); + const h = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) ** 2; + return 2 * R * Math.asin(Math.sqrt(h)); + } + + shouldPushUpdate(prev: GeolocationPosition | null, curr: GeolocationPosition): boolean { + const now = Date.now(); + if (now - this.lastUpdateTime >= this.config.updateInterval) return true; + if (!prev) return true; + return this.haversineMeters(prev.coords, curr.coords) >= this.config.minMoveMeters; + } + + startWatching(onSuccess: (position: GeolocationPosition) => void, onError: (error: GeolocationPositionError) => void) { + if (!navigator.geolocation) { + onError({ + code: 2, + message: 'Geolocation is not supported by this browser', + PERMISSION_DENIED: 1, + POSITION_UNAVAILABLE: 2, + TIMEOUT: 3, + } as GeolocationPositionError); + return; + } + + this.onPositionSuccess = onSuccess; + this.onPositionError = onError; + + this.watchId = navigator.geolocation.watchPosition(onSuccess, onError, { enableHighAccuracy: true, timeout: 20000, maximumAge: 0 }); + + // Optional heartbeat to smooth out quiet watches + this.pollTimerRef = window.setInterval(() => { + navigator.geolocation.getCurrentPosition(onSuccess, onError, { enableHighAccuracy: true, timeout: 20000, maximumAge: 0 }); + }, this.config.updateInterval); + } + + startSharing() { + this.isSharing = true; + + // Set up continuous sharing interval + this.sharingIntervalRef = window.setInterval(() => { + if (this.isSharing && this.onPositionSuccess) { + navigator.geolocation.getCurrentPosition( + (pos) => { + if (this.isSharing && this.onPositionSuccess) { + this.onPositionSuccess(pos); + } + }, + (error) => { + if (this.onPositionError) { + this.onPositionError(error); + } + }, + { enableHighAccuracy: true, timeout: 20000, maximumAge: 0 }, + ); + } + }, this.config.updateInterval); + } + + stopSharing() { + this.isSharing = false; + + if (this.sharingIntervalRef !== null) { + clearInterval(this.sharingIntervalRef); + this.sharingIntervalRef = null; + } + } + + cleanup() { + this.stopSharing(); + + if (this.watchId !== null) { + navigator.geolocation.clearWatch(this.watchId); + this.watchId = null; + } + + if (this.pollTimerRef !== null) { + clearInterval(this.pollTimerRef); + this.pollTimerRef = null; + } + } + + updateLastUpdateTime() { + this.lastUpdateTime = Date.now(); + } + + get isSharingActive(): boolean { + return this.isSharing; + } + + // Storage helpers + static storeLiveLocationData(messageId: string, roomId: string) { + if (typeof window !== 'undefined') { + window.localStorage.setItem('liveLocationMessageId', messageId); + window.localStorage.setItem('liveLocationRoomId', roomId); + } + } + + static getLiveLocationData(): { messageId: string | null; roomId: string | null } { + if (typeof window === 'undefined') { + return { messageId: null, roomId: null }; + } + + return { + messageId: window.localStorage.getItem('liveLocationMessageId'), + roomId: window.localStorage.getItem('liveLocationRoomId'), + }; + } + + static clearLiveLocationData() { + if (typeof window !== 'undefined') { + window.localStorage.removeItem('liveLocationMessageId'); + window.localStorage.removeItem('liveLocationRoomId'); + } + } +} diff --git a/apps/meteor/client/views/room/ShareLocation/mapProvider.ts b/apps/meteor/client/views/room/ShareLocation/mapProvider.ts index 570f58a085000..77301e2a0d18c 100644 --- a/apps/meteor/client/views/room/ShareLocation/mapProvider.ts +++ b/apps/meteor/client/views/room/ShareLocation/mapProvider.ts @@ -2,65 +2,73 @@ export type MapProviderName = 'google' | 'openstreetmap'; -export interface MapProvider { - name: MapProviderName; - // Static preview image for messages / modal - getStaticMapUrl(lat: number, lng: number, opts?: { zoom?: number; width?: number; height?: number }): string; - // Deep link that opens the native app or web directions - getMapsLink(lat: number, lng: number): string; - // Human-readable attribution (OSM requires) - getAttribution?: () => string | undefined; +export interface IMapProvider { + name: MapProviderName; + // Static preview image for messages / modal + getStaticMapUrl( + lat: number, + lng: number, + opts?: { zoom?: number; width?: number; height?: number; theme?: string; scale?: number }, + ): string; + // Deep link that opens the native app or web directions + getMapsLink(lat: number, lng: number): string; + // Human-readable attribution (OSM requires) + getAttribution?: () => string | undefined; } type ProviderOpts = { - googleApiKey?: string; // required for Google Static Maps - locationIqKey?: string; // required for LocationIQ static (OSM-backed) + googleApiKey?: string; // required for Google Static Maps + locationIqKey?: string; // required for LocationIQ static (OSM-backed) }; // ------------ Google ------------ -export class GoogleProvider implements MapProvider { - name: MapProviderName = 'google'; - constructor(private opts: ProviderOpts) {} - getStaticMapUrl(lat: number, lng: number, opts?: { zoom?: number; width?: number; height?: number }): string { - const key = this.opts.googleApiKey; - const zoom = opts?.zoom ?? 15; - const width = opts?.width ?? 600; - const height = opts?.height ?? 320; - // NOTE: consider server-side signing if you need URL signing for premium usage. - return `https://maps.googleapis.com/maps/api/staticmap?center=${lat},${lng}&zoom=${zoom}&size=${width}x${height}&markers=${lat},${lng}&key=${key}`; - } - getMapsLink(lat: number, lng: number): string { - // works on web + mobile - return `https://maps.google.com/?q=${lat},${lng}`; - } +export class GoogleProvider implements IMapProvider { + name: MapProviderName = 'google'; + + constructor(private opts: ProviderOpts) {} + + getStaticMapUrl(lat: number, lng: number, opts?: { zoom?: number; width?: number; height?: number }): string { + const key = this.opts.googleApiKey; + const zoom = opts?.zoom ?? 15; + const width = opts?.width ?? 600; + const height = opts?.height ?? 320; + // NOTE: consider server-side signing if you need URL signing for premium usage. + return `https://maps.googleapis.com/maps/api/staticmap?center=${lat},${lng}&zoom=${zoom}&size=${width}x${height}&markers=${lat},${lng}&key=${key}`; + } + + getMapsLink(lat: number, lng: number): string { + // works on web + mobile + return `https://maps.google.com/?q=${lat},${lng}`; + } } // ------------ OpenStreetMap via LocationIQ ------------ -export class OSMProvider implements MapProvider { - name: MapProviderName = 'openstreetmap'; - constructor(private opts: ProviderOpts) {} - getStaticMapUrl(lat: number, lng: number, opts?: { zoom?: number; width?: number; height?: number }): string { - const key = this.opts.locationIqKey; - const zoom = opts?.zoom ?? 15; - const width = opts?.width ?? 600; - const height = opts?.height ?? 320; - // LocationIQ static map API (OSM-backed). See their docs for style params. - return `https://maps.locationiq.com/v2/staticmap?key=${key}¢er=${lat},${lng}&zoom=${zoom}&size=${width}x${height}&markers=icon:large-red-cutout|${lat},${lng}`; - } - getMapsLink(lat: number, lng: number): string { - // Deep link to openstreetmap - return `https://www.openstreetmap.org/?mlat=${lat}&mlon=${lng}#map=16/${lat}/${lng}`; - } - getAttribution() { - return 'Β© OpenStreetMap contributors'; - } +export class OSMProvider implements IMapProvider { + name: MapProviderName = 'openstreetmap'; + + constructor(private opts: ProviderOpts) {} + + getStaticMapUrl(lat: number, lng: number, opts?: { zoom?: number; width?: number; height?: number }): string { + const key = this.opts.locationIqKey; + const zoom = opts?.zoom ?? 15; + const width = opts?.width ?? 600; + const height = opts?.height ?? 320; + // LocationIQ static map API (OSM-backed). See their docs for style params. + return `https://maps.locationiq.com/v2/staticmap?key=${key}¢er=${lat},${lng}&zoom=${zoom}&size=${width}x${height}&markers=icon:large-red-cutout|${lat},${lng}`; + } + + getMapsLink(lat: number, lng: number): string { + // Deep link to openstreetmap + return `https://www.openstreetmap.org/?mlat=${lat}&mlon=${lng}#map=16/${lat}/${lng}`; + } + + getAttribution() { + return 'Β© OpenStreetMap contributors'; + } } // ------------ Factory ------------ -export function createMapProvider( - name: MapProviderName, - keys: ProviderOpts -): MapProvider { - if (name === 'google') return new GoogleProvider(keys); - return new OSMProvider(keys); +export function createMapProvider(name: MapProviderName, keys: ProviderOpts): IMapProvider { + if (name === 'google') return new GoogleProvider(keys); + return new OSMProvider(keys); } diff --git a/apps/meteor/client/views/room/ShareLocation/useLiveLocationStopListener.ts b/apps/meteor/client/views/room/ShareLocation/useLiveLocationStopListener.ts index 3fa3baa1b72cd..ba93d8507f411 100644 --- a/apps/meteor/client/views/room/ShareLocation/useLiveLocationStopListener.ts +++ b/apps/meteor/client/views/room/ShareLocation/useLiveLocationStopListener.ts @@ -1,119 +1,118 @@ // useLiveLocationStopListener.ts -import { useCallback, useEffect } from 'react'; import { useEndpoint } from '@rocket.chat/ui-contexts'; +import { useCallback, useEffect } from 'react'; + import { LiveLocationService } from './liveLocationService'; export const useLiveLocationStopListener = () => { - const updateMessage = useEndpoint('POST', '/v1/chat.update'); - - const stopLiveLocationSharing = useCallback(async ( - rid?: string, - messageId?: string, - currentPosition?: GeolocationPosition - ) => { - // Get stored data if not provided - const { messageId: storedMessageId, roomId: storedRoomId } = LiveLocationService.getLiveLocationData(); - - const finalMessageId = messageId || storedMessageId; - const finalRoomId = rid || storedRoomId; - - if (!finalMessageId || !finalRoomId) { - console.warn('No active live location sharing found'); - return false; - } - - try { - // Stop the sharing service if available globally - if ((window as any).liveLocationService) { - (window as any).liveLocationService.stopSharing(); - } - - // Update the message to remove live status if we have current position - if (currentPosition) { - const service = new LiveLocationService({ - locationIQKey: 'pk.898e468814facdcffda869b42260a2f0', // TODO: move to config - updateInterval: 10000, - minMoveMeters: 5 - }); - - const finalAttachment = service.createLocationAttachment(currentPosition, false); - finalAttachment.title = 'πŸ“ Location (Sharing Stopped)'; - - const updatePayload = { - roomId: finalRoomId, - msgId: finalMessageId, - text: '', - attachments: [finalAttachment], - customFields: {}, - }; - - await updateMessage(updatePayload); - } - - // Clean up stored data - LiveLocationService.clearLiveLocationData(); - - return true; - } catch (error) { - console.error('Error stopping live location:', error); - return false; - } - }, [updateMessage]); - - const handleStopCommand = useCallback(async (rid: string) => { - const { messageId, roomId } = LiveLocationService.getLiveLocationData(); - - if (!messageId || roomId !== rid) { - console.warn('No active live location sharing found for this room'); - return false; - } - - return await stopLiveLocationSharing(rid, messageId); - }, [stopLiveLocationSharing]); - - // Set up global stop function - useEffect(() => { - if (typeof window !== 'undefined') { - (window as any).stopLiveLocationSharing = stopLiveLocationSharing; - } - - return () => { - if (typeof window !== 'undefined') { - delete (window as any).stopLiveLocationSharing; - } - }; - }, [stopLiveLocationSharing]); - - return { - stopLiveLocationSharing, - handleStopCommand - }; + const updateMessage = useEndpoint('POST', '/v1/chat.update'); + + const stopLiveLocationSharing = useCallback( + async (rid?: string, messageId?: string, currentPosition?: GeolocationPosition) => { + // Get stored data if not provided + const { messageId: storedMessageId, roomId: storedRoomId } = LiveLocationService.getLiveLocationData(); + + const finalMessageId = messageId || storedMessageId; + const finalRoomId = rid || storedRoomId; + + if (!finalMessageId || !finalRoomId) { + console.warn('No active live location sharing found'); + return false; + } + + try { + // Stop the sharing service if available globally + if ((window as any).liveLocationService) { + (window as any).liveLocationService.stopSharing(); + } + + // Update the message to remove live status if we have current position + if (currentPosition) { + const service = new LiveLocationService({ + locationIQKey: 'pk.898e468814facdcffda869b42260a2f0', // TODO: move to config + updateInterval: 10000, + minMoveMeters: 5, + }); + + const finalAttachment = service.createLocationAttachment(currentPosition, false); + finalAttachment.title = 'πŸ“ Location (Sharing Stopped)'; + + const updatePayload = { + roomId: finalRoomId, + msgId: finalMessageId, + text: '', + attachments: [finalAttachment], + customFields: {}, + }; + + await updateMessage(updatePayload); + } + + // Clean up stored data + LiveLocationService.clearLiveLocationData(); + + return true; + } catch (error) { + console.error('Error stopping live location:', error); + return false; + } + }, + [updateMessage], + ); + + const handleStopCommand = useCallback( + async (rid: string) => { + const { messageId, roomId } = LiveLocationService.getLiveLocationData(); + + if (!messageId || roomId !== rid) { + console.warn('No active live location sharing found for this room'); + return false; + } + + return stopLiveLocationSharing(rid, messageId); + }, + [stopLiveLocationSharing], + ); + + // Set up global stop function + useEffect(() => { + if (typeof window !== 'undefined') { + (window as any).stopLiveLocationSharing = stopLiveLocationSharing; + } + + return () => { + if (typeof window !== 'undefined') { + delete (window as any).stopLiveLocationSharing; + } + }; + }, [stopLiveLocationSharing]); + + return { + stopLiveLocationSharing, + handleStopCommand, + }; }; // Slash command processor export const processLiveLocationSlashCommand = ( - message: string, - rid: string, - handleStopCommand: (rid: string) => Promise + message: string, + rid: string, + handleStopCommand: (rid: string) => Promise, ): boolean => { - if (message === '/stop-live-location') { - handleStopCommand(rid); - return true; // Prevent the message from being sent to chat - } - - return false; // Let other processors handle it + if (message === '/stop-live-location') { + handleStopCommand(rid); + return true; // Prevent the message from being sent to chat + } + + return false; // Let other processors handle it }; // Button action handler -export const handleLiveLocationAction = ( - action: any, - message: any, - handleStopCommand: (rid: string) => Promise -): boolean => { - if (action.msg === '/stop-live-location') { - handleStopCommand(message.rid); - return true; - } - - return false; // Let other handlers process it -}; \ No newline at end of file +export const handleLiveLocationAction = (action: any, message: any, handleStopCommand: (rid: string) => Promise): boolean => { + if (action.msg === '/stop-live-location') { + handleStopCommand(message.rid); + return true; + } + + return false; // Let other handlers process it +}; 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 650d9d2b647ae..8c06eb461efb4 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 @@ -1,7 +1,6 @@ import type { IMessage, IRoom } from '@rocket.chat/core-typings'; -import { isRoomFederated } from '@rocket.chat/core-typings'; import type { GenericMenuItemProps } from '@rocket.chat/ui-client'; -import { useSetting, useSetModal } from '@rocket.chat/ui-contexts'; +import { useSetModal } from '@rocket.chat/ui-contexts'; import { useTranslation } from 'react-i18next'; import ShareLocationModal from '../../../../ShareLocation/ShareLocationModal'; @@ -14,10 +13,10 @@ export const useShareLocationAction = (room?: IRoom, tmid?: IMessage['tmid']): G const { t } = useTranslation(); const setModal = useSetModal(); - const isMapViewEnabled = useSetting('MapView_Enabled') === true; - const isGeolocationCurrentPositionSupported = Boolean(navigator.geolocation?.getCurrentPosition); - const googleMapsApiKey = useSetting('MapView_GMapsAPIKey', ''); - const canGetGeolocation = isMapViewEnabled && isGeolocationCurrentPositionSupported && googleMapsApiKey && googleMapsApiKey.length; + // const isMapViewEnabled = useSetting('MapView_Enabled') === true; + // const isGeolocationCurrentPositionSupported = Boolean(navigator.geolocation?.getCurrentPosition); + // const googleMapsApiKey = useSetting('MapView_GMapsAPIKey', ''); + // const canGetGeolocation = isMapViewEnabled && isGeolocationCurrentPositionSupported && googleMapsApiKey && googleMapsApiKey.length; const handleShareLocation = () => setModal( setModal(null)} />); diff --git a/apps/meteor/packages/meteor-user-presence/.npm/package/.gitignore b/apps/meteor/packages/meteor-user-presence/.npm/package/.gitignore new file mode 100644 index 0000000000000..3c3629e647f5d --- /dev/null +++ b/apps/meteor/packages/meteor-user-presence/.npm/package/.gitignore @@ -0,0 +1 @@ +node_modules diff --git a/apps/meteor/packages/meteor-user-presence/.npm/package/README b/apps/meteor/packages/meteor-user-presence/.npm/package/README new file mode 100644 index 0000000000000..3d492553a438e --- /dev/null +++ b/apps/meteor/packages/meteor-user-presence/.npm/package/README @@ -0,0 +1,7 @@ +This directory and the files immediately inside it are automatically generated +when you change this package's NPM dependencies. Commit the files in this +directory (npm-shrinkwrap.json, .gitignore, and this README) to source control +so that others run the same versions of sub-dependencies. + +You should NOT check in the node_modules directory that Meteor automatically +creates; if you are using git, the .gitignore file tells git to ignore it. diff --git a/apps/meteor/packages/meteor-user-presence/.npm/package/npm-shrinkwrap.json b/apps/meteor/packages/meteor-user-presence/.npm/package/npm-shrinkwrap.json new file mode 100644 index 0000000000000..2e6e97813bd99 --- /dev/null +++ b/apps/meteor/packages/meteor-user-presence/.npm/package/npm-shrinkwrap.json @@ -0,0 +1,10 @@ +{ + "lockfileVersion": 4, + "dependencies": { + "colors": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/colors/-/colors-1.3.2.tgz", + "integrity": "sha512-rhP0JSBGYvpcNQj4s5AdShMeE5ahMop96cTeDl/v9qQQm2fYClE2QXZRi8wLzc+GmXSxdIqqbOIAhyObEXDbfQ==" + } + } +} From 1b969b0442be5181ca582de1689399a26a12af1c Mon Sep 17 00:00:00 2001 From: yiwei gao Date: Sat, 25 Oct 2025 01:07:21 -0400 Subject: [PATCH 05/14] feat: static & live location sharing (draft) --- apps/meteor/app/api/server/index.ts | 1 + apps/meteor/app/api/server/v1/liveLocation.ts | 381 ++++++++++ apps/meteor/app/live-location/server/index.ts | 4 - .../server/methods/getLiveLocation.ts | 77 -- .../server/methods/startLiveLocation.ts | 126 ---- .../server/methods/stopLiveLocation.ts | 83 --- .../server/methods/updateLiveLocation.ts | 95 --- .../server/startup/live-location.ts | 126 +++- apps/meteor/client/startup/startup.ts | 1 + .../room/ShareLocation/LiveLocationModal.tsx | 689 ------------------ .../views/room/ShareLocation/MapLibreMap.tsx | 148 ++++ .../views/room/ShareLocation/MapView.spec.tsx | 63 ++ .../views/room/ShareLocation/MapView.tsx | 109 +-- .../ShareLocation/ShareLocationModal.spec.tsx | 139 ++++ .../room/ShareLocation/ShareLocationModal.tsx | 172 ++--- .../getGeolocationPosition.spec.ts | 118 +++ .../ShareLocation/getGeolocationPosition.ts | 89 ++- .../room/ShareLocation/liveLocationService.ts | 244 ------- .../room/ShareLocation/mapProvider.spec.ts | 28 + .../views/room/ShareLocation/mapProvider.ts | 76 +- .../useBrowserLiveLocation.spec.tsx | 85 +++ .../ShareLocation/useBrowserLiveLocation.ts | 32 + .../useLiveLocationStopListener.ts | 118 --- .../hooks/useShareLocationAction.tsx | 15 +- apps/meteor/package.json | 1 + .../.npm/package/.gitignore | 1 - .../meteor-user-presence/.npm/package/README | 7 - .../.npm/package/npm-shrinkwrap.json | 10 - packages/i18n/src/locales/en.i18n.json | 16 + .../src/models/ILiveLocationSessionModel.ts | 0 packages/models/src/models/Messages.ts | 18 + yarn.lock | 289 +++++++- 32 files changed, 1614 insertions(+), 1747 deletions(-) create mode 100644 apps/meteor/app/api/server/v1/liveLocation.ts delete mode 100644 apps/meteor/app/live-location/server/methods/getLiveLocation.ts delete mode 100644 apps/meteor/app/live-location/server/methods/startLiveLocation.ts delete mode 100644 apps/meteor/app/live-location/server/methods/stopLiveLocation.ts delete mode 100644 apps/meteor/app/live-location/server/methods/updateLiveLocation.ts delete mode 100644 apps/meteor/client/views/room/ShareLocation/LiveLocationModal.tsx create mode 100644 apps/meteor/client/views/room/ShareLocation/MapLibreMap.tsx create mode 100644 apps/meteor/client/views/room/ShareLocation/MapView.spec.tsx create mode 100644 apps/meteor/client/views/room/ShareLocation/ShareLocationModal.spec.tsx create mode 100644 apps/meteor/client/views/room/ShareLocation/getGeolocationPosition.spec.ts delete mode 100644 apps/meteor/client/views/room/ShareLocation/liveLocationService.ts create mode 100644 apps/meteor/client/views/room/ShareLocation/mapProvider.spec.ts create mode 100644 apps/meteor/client/views/room/ShareLocation/useBrowserLiveLocation.spec.tsx create mode 100644 apps/meteor/client/views/room/ShareLocation/useBrowserLiveLocation.ts delete mode 100644 apps/meteor/client/views/room/ShareLocation/useLiveLocationStopListener.ts delete mode 100644 apps/meteor/packages/meteor-user-presence/.npm/package/.gitignore delete mode 100644 apps/meteor/packages/meteor-user-presence/.npm/package/README delete mode 100644 apps/meteor/packages/meteor-user-presence/.npm/package/npm-shrinkwrap.json delete mode 100644 packages/model-typings/src/models/ILiveLocationSessionModel.ts 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..eb397c278cda6 --- /dev/null +++ b/apps/meteor/app/api/server/v1/liveLocation.ts @@ -0,0 +1,381 @@ +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 { API } from '../api'; + +const MIN_INTERVAL_MS = 3000; + +// Type definitions for API route contexts +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; + }; +} + +declare module 'meteor/meteor' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface Meteor { + user(): IUser | null; + } +} + +// Start live location sharing +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.'); + } + + 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 expiresAt = durationSec ? new Date(now.getTime() + durationSec * 1000) : undefined; + 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 (insertError) { + return API.v1.failure('Failed to create live location message'); + } + }, + }, +); + +// Update live location coordinates +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 || typeof coords !== 'object') { + 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 }, + { + $set: { + 'attachments.0.live.coords': coords, + 'attachments.0.live.lastUpdateAt': updateTime, + }, + }, + ); + + 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) }); + }, + }, +); + +// Stop live location sharing +API.v1.addRoute( + 'liveLocation.stop', + { authRequired: true }, + { + async post(this: IAPIRouteContext) { + 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 (!(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, + 'attachments': { + $elemMatch: { + 'type': 'live-location', + 'live.isActive': true, + }, + }, + }; + + const modifier: { + $set: { + 'attachments.0.live.isActive': boolean; + 'attachments.0.live.stoppedAt': Date; + 'attachments.0.live.coords'?: { lat: number; lon: number }; + }; + } = { + $set: { + 'attachments.0.live.isActive': false, + 'attachments.0.live.stoppedAt': new Date(), + }, + }; + + if (finalCoords) { + modifier.$set['attachments.0.live.coords'] = finalCoords; + } + + const res = await Messages.updateOne(selector, modifier); + const success = Boolean(res.modifiedCount); + + if (success) { + const updatedMsg = await Messages.findOneById(msgId); + if (updatedMsg) { + void notifyOnMessageChange({ + id: updatedMsg._id, + data: updatedMsg, + }); + } + } + + return API.v1.success({ stopped: success }); + }, + }, +); + +// Get live location data +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 index 852c61eb7afd2..8e0459e438d5a 100644 --- a/apps/meteor/app/live-location/server/index.ts +++ b/apps/meteor/app/live-location/server/index.ts @@ -1,5 +1 @@ -import './methods/startLiveLocation'; -import './methods/updateLiveLocation'; -import './methods/stopLiveLocation'; -import './methods/getLiveLocation'; import './startup/live-location'; diff --git a/apps/meteor/app/live-location/server/methods/getLiveLocation.ts b/apps/meteor/app/live-location/server/methods/getLiveLocation.ts deleted file mode 100644 index f58468e84af89..0000000000000 --- a/apps/meteor/app/live-location/server/methods/getLiveLocation.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { Messages, Subscriptions } from '@rocket.chat/models'; -import { check } from 'meteor/check'; -import { DDPRateLimiter } from 'meteor/ddp-rate-limiter'; -import { Meteor } from 'meteor/meteor'; - -import { canAccessRoomIdAsync } from '../../../authorization/server/functions/canAccessRoom'; - -Meteor.methods({ - /** - * Get live location data for a message - */ - async 'liveLocation.get'(rid: string, msgId: string) { - check(rid, String); - check(msgId, String); - - const uid = Meteor.userId(); - if (!uid) { - throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'liveLocation.get' }); - } - - if (!(await canAccessRoomIdAsync(rid, uid))) { - throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'liveLocation.get' }); - } - - const sub = await Subscriptions.findOneByRoomIdAndUserId(rid, uid); - if (!sub) { - throw new Meteor.Error('error-not-in-room', 'User is not in the room', { method: 'liveLocation.get' }); - } - const msg = await Messages.findOne({ - _id: msgId, - rid, - attachments: { - $elemMatch: { - type: 'live-location', - }, - }, - }); - - if (!msg) { - throw new Meteor.Error('error-live-location-not-found', 'Live location not found', { - method: 'liveLocation.get', - }); - } - - const attachment = msg.attachments?.find((att: any) => att.type === 'live-location') as any; - if (!attachment) { - throw new Meteor.Error('error-live-location-not-found', 'Live location attachment not found', { - method: 'liveLocation.get', - }); - } - return { - messageId: msg._id, - ownerId: attachment.live?.ownerId, - ownerUsername: msg.u?.username, - ownerName: (msg.u as any)?.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, - }; - }, -}); - -DDPRateLimiter.addRule( - { - userId(userId: string) { - return !!userId; - }, - type: 'method', - name: 'liveLocation.get', - }, - 10, - 60000, -); diff --git a/apps/meteor/app/live-location/server/methods/startLiveLocation.ts b/apps/meteor/app/live-location/server/methods/startLiveLocation.ts deleted file mode 100644 index 3764bee5a5786..0000000000000 --- a/apps/meteor/app/live-location/server/methods/startLiveLocation.ts +++ /dev/null @@ -1,126 +0,0 @@ -import type { IRoom, IUser } from '@rocket.chat/core-typings'; -import { Rooms, Subscriptions, Messages } from '@rocket.chat/models'; -import { check, Match } from 'meteor/check'; -import { DDPRateLimiter } from 'meteor/ddp-rate-limiter'; -import { Meteor } from 'meteor/meteor'; - -import { canAccessRoomIdAsync } from '../../../authorization/server/functions/canAccessRoom'; -import { notifyOnMessageChange } from '../../../lib/server/lib/notifyListener'; - -type Coords = { lat: number; lng: number; acc?: number }; - -declare module 'meteor/meteor' { - // eslint-disable-next-line @typescript-eslint/naming-convention - interface Meteor { - user(): IUser | null; - } -} - -Meteor.methods({ - /** - * Start live location sharing in a room - */ - async 'liveLocation.start'(rid: string, opts: { durationSec?: number; initial?: Coords } = {}) { - check(rid, String); - check(opts, Match.ObjectIncluding({ durationSec: Match.Optional(Number), initial: Match.Optional(Object) })); - - const uid = Meteor.userId(); - if (!uid) { - throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'liveLocation.start' }); - } - - const room = await Rooms.findOneById(rid); - if (!room) { - throw new Meteor.Error('error-room-not-found', 'Room not found', { method: 'liveLocation.start' }); - } - - const sub = await Subscriptions.findOneByRoomIdAndUserId(rid, uid); - if (!sub) { - throw new Meteor.Error('error-not-in-room', 'User is not in the room', { method: 'liveLocation.start' }); - } - if (!(await canAccessRoomIdAsync(rid, uid))) { - throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'liveLocation.start' }); - } - - // Reuse existing active live location if found - const existing = await Messages.findOne({ - rid, - 'u._id': uid, - 'attachments': { - $elemMatch: { - 'type': 'live-location', - 'live.isActive': true, - }, - }, - }); - - if (existing) { - return { msgId: existing._id }; - } - - const now = new Date(); - const expiresAt = opts.durationSec ? new Date(now.getTime() + opts.durationSec * 1000) : undefined; - const user = await Meteor.users.findOneAsync( - { _id: uid }, - { - projection: { username: 1, name: 1 }, - }, - ); - - if (!user) { - throw new Meteor.Error('error-invalid-user', 'User not found', { method: 'liveLocation.start' }); - } - - const msg = { - rid, - ts: now, - u: { - _id: uid, - username: user.username, - name: (user as any).name || user.username, - }, - attachments: [ - { - type: 'live-location', - live: { - isActive: true, - ownerId: uid, - startedAt: now, - lastUpdateAt: now, - expiresAt, - coords: opts.initial || null, - version: 1, - }, - }, - ], - } as any; - - try { - const result = await Messages.insertOne(msg); - - const createdMsg = await Messages.findOneById(result.insertedId); - if (createdMsg) { - void notifyOnMessageChange({ - id: createdMsg._id, - data: createdMsg, - }); - } - - return { msgId: result.insertedId }; - } catch (insertError) { - throw new Meteor.Error('error-message-creation-failed', 'Failed to create live location message', { method: 'liveLocation.start' }); - } - }, -}); - -DDPRateLimiter.addRule( - { - userId(userId: string) { - return !!userId; - }, - type: 'method', - name: 'liveLocation.start', - }, - 5, - 60000, -); diff --git a/apps/meteor/app/live-location/server/methods/stopLiveLocation.ts b/apps/meteor/app/live-location/server/methods/stopLiveLocation.ts deleted file mode 100644 index 53cc64fbacae4..0000000000000 --- a/apps/meteor/app/live-location/server/methods/stopLiveLocation.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { Messages, Subscriptions } from '@rocket.chat/models'; -import { check } from 'meteor/check'; -import { DDPRateLimiter } from 'meteor/ddp-rate-limiter'; -import { Meteor } from 'meteor/meteor'; - -import { canAccessRoomIdAsync } from '../../../authorization/server/functions/canAccessRoom'; -import { notifyOnMessageChange } from '../../../lib/server/lib/notifyListener'; - -type Coords = { lat: number; lng: number; acc?: number }; - -Meteor.methods({ - /** - * Stop live location sharing - */ - async 'liveLocation.stop'(rid: string, msgId: string, finalCoords?: Coords) { - check(rid, String); - check(msgId, String); - - const uid = Meteor.userId(); - if (!uid) { - throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'liveLocation.stop' }); - } - - if (!(await canAccessRoomIdAsync(rid, uid))) { - throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'liveLocation.stop' }); - } - - const sub = await Subscriptions.findOneByRoomIdAndUserId(rid, uid); - if (!sub) { - throw new Meteor.Error('error-not-in-room', 'User is not in the room', { method: 'liveLocation.stop' }); - } - - const selector = { - '_id': msgId, - rid, - 'u._id': uid, - 'attachments': { - $elemMatch: { - 'type': 'live-location', - 'live.isActive': true, - }, - }, - }; - - const modifier: any = { - $set: { - 'attachments.0.live.isActive': false, - 'attachments.0.live.stoppedAt': new Date(), - }, - }; - - if (finalCoords) { - modifier.$set['attachments.0.live.coords'] = finalCoords; - } - - const res = await Messages.updateOne(selector, modifier); - const success = Boolean(res.modifiedCount); - - if (success) { - const updatedMsg = await Messages.findOneById(msgId); - if (updatedMsg) { - void notifyOnMessageChange({ - id: updatedMsg._id, - data: updatedMsg, - }); - } - } - - return { stopped: success }; - }, -}); - -DDPRateLimiter.addRule( - { - userId(userId: string) { - return !!userId; - }, - type: 'method', - name: 'liveLocation.stop', - }, - 10, - 60000, -); diff --git a/apps/meteor/app/live-location/server/methods/updateLiveLocation.ts b/apps/meteor/app/live-location/server/methods/updateLiveLocation.ts deleted file mode 100644 index 606167a30cf67..0000000000000 --- a/apps/meteor/app/live-location/server/methods/updateLiveLocation.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { Messages, Subscriptions } from '@rocket.chat/models'; -import { check } from 'meteor/check'; -import { DDPRateLimiter } from 'meteor/ddp-rate-limiter'; -import { Meteor } from 'meteor/meteor'; - -import { canAccessRoomIdAsync } from '../../../authorization/server/functions/canAccessRoom'; -import { notifyOnMessageChange } from '../../../lib/server/lib/notifyListener'; - -type Coords = { lat: number; lng: number; acc?: number }; - -const MIN_INTERVAL_MS = 3000; - -Meteor.methods({ - /** - * Update live location coordinates - */ - async 'liveLocation.update'(rid: string, msgId: string, coords: Coords) { - check(rid, String); - check(msgId, String); - check(coords, Object); - - const uid = Meteor.userId(); - if (!uid) { - throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'liveLocation.update' }); - } - - if (!(await canAccessRoomIdAsync(rid, uid))) { - throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'liveLocation.update' }); - } - const sub = await Subscriptions.findOneByRoomIdAndUserId(rid, uid); - if (!sub) { - throw new Meteor.Error('error-not-in-room', 'User is not in the room', { method: 'liveLocation.update' }); - } - - const msg = await Messages.findOne({ - '_id': msgId, - rid, - 'u._id': uid, - 'attachments': { - $elemMatch: { - 'type': 'live-location', - 'live.isActive': true, - }, - }, - }); - - if (!msg) { - throw new Meteor.Error('error-live-location-not-found', 'Active live location not found', { - method: 'liveLocation.update', - }); - } - - const last: Date | undefined = (msg.attachments?.[0] as any)?.live?.lastUpdateAt; - const now = new Date(); - if (last && now.getTime() - new Date(last).getTime() < MIN_INTERVAL_MS) { - return { ignored: true, reason: 'too-soon' }; - } - - const updateTime = new Date(); - const res = await Messages.updateOne( - { _id: msgId }, - { - $set: { - 'attachments.0.live.coords': coords, - 'attachments.0.live.lastUpdateAt': updateTime, - }, - }, - ); - - // Notify clients of message update for real-time UI refresh - if (res.modifiedCount > 0) { - const updatedMsg = await Messages.findOneById(msgId); - if (updatedMsg) { - void notifyOnMessageChange({ - id: updatedMsg._id, - data: updatedMsg, - }); - } - } - - return { updated: Boolean(res.modifiedCount) }; - }, -}); - -DDPRateLimiter.addRule( - { - userId(userId: string) { - return !!userId; - }, - type: 'method', - name: 'liveLocation.update', - }, - 12, - 60000, -); diff --git a/apps/meteor/app/live-location/server/startup/live-location.ts b/apps/meteor/app/live-location/server/startup/live-location.ts index e5bb2272ea2a6..a3cb74fec6f99 100644 --- a/apps/meteor/app/live-location/server/startup/live-location.ts +++ b/apps/meteor/app/live-location/server/startup/live-location.ts @@ -1,53 +1,109 @@ +// 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 ensureIndex(collection: any, keys: any, options: any = {}) { + +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 collection.col.createIndex(keys, options); + await notifyOnMessageChange({ id: msg._id, data: msg }); } catch (e) { - // ignore errors + console.error('[LiveLocationCleanup] notifyOnMessageChange failed:', e); } } Meteor.startup(async () => { - await ensureIndex(Messages, { 'attachments.0.type': 1, 'attachments.0.live.isActive': 1 }); - await ensureIndex(Messages, { 'rid': 1, 'u._id': 1, 'attachments.0.type': 1, 'attachments.0.live.isActive': 1 }); - await ensureIndex( - Messages, - { 'attachments.0.live.expiresAt': 1 }, - { - expireAfterSeconds: 0, - partialFilterExpression: { 'attachments.0.type': 'live-location', 'attachments.0.live.expiresAt': { $type: 'date' } }, - name: 'liveLocation_expiresAt_TTL', - }, - ); - await ensureIndex( - Messages, - { 'attachments.0.live.lastUpdateAt': 1 }, - { - partialFilterExpression: { 'attachments.0.type': 'live-location' }, - name: 'liveLocation_lastUpdateAt_idx', - }, - ); + let isCleanupRunning = false; Meteor.setInterval(async () => { - const now = new Date(); - const staleBefore = new Date(now.getTime() - INACTIVE_GRACE_MS); - await Messages.updateMany( - { - 'attachments.0.type': 'live-location', - 'attachments.0.live.isActive': true, - '$or': [{ 'attachments.0.live.lastUpdateAt': { $lt: staleBefore } }, { 'attachments.0.live.expiresAt': { $lte: now } }], - }, - { + 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.0.live.isActive': false, - 'attachments.0.live.stoppedAt': now, + '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); }); 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/LiveLocationModal.tsx b/apps/meteor/client/views/room/ShareLocation/LiveLocationModal.tsx deleted file mode 100644 index a80b32fc4cdfe..0000000000000 --- a/apps/meteor/client/views/room/ShareLocation/LiveLocationModal.tsx +++ /dev/null @@ -1,689 +0,0 @@ -// LiveLocationModal.tsx β€” provider-aware live location (clean Google / OSM) -import { useEndpoint } from '@rocket.chat/ui-contexts'; -import { useEffect, useRef, useState, useCallback, useMemo } from 'react'; -import type { ReactElement } from 'react'; - -import { LiveLocationService, type LocationState } from './liveLocationService'; -import { createMapProvider, type MapProviderName, type IMapProvider } from './mapProvider'; -import { useLiveLocationStopListener } from './useLiveLocationStopListener'; - -type Props = { - rid: string; - tmid?: string; - onClose: () => void; -}; - -const LiveLocationChatWidget = ({ rid, tmid, onClose }: Props): ReactElement => { - const [position, setPosition] = useState(null); - const [locationState, setLocationState] = useState('waiting'); - const [error, setError] = useState(null); - const [, setGpsUpdateCount] = useState(0); - const [lastMessageUpdate, setLastMessageUpdate] = useState(null); - - const [isMinimized, setIsMinimized] = useState(false); - const [isDragging, setIsDragging] = useState(false); - const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 }); - const [position2D, setPosition2D] = useState({ x: 0, y: 0 }); - - const messageIdRef = useRef(null); - const sendingRef = useRef(false); - const isClosingRef = useRef(false); - const isSharingRef = useRef(false); - const serviceRef = useRef(null); - const modalRef = useRef(null); - - const sendMessage = useEndpoint('POST', '/v1/chat.sendMessage'); - const updateMessage = useEndpoint('POST', '/v1/chat.update'); - const { stopLiveLocationSharing } = useLiveLocationStopListener(); - - // --- Provider (shared via localStorage with the static modal) ------------- - const [provider, setProvider] = useState(() => { - const saved = localStorage.getItem('mapProvider') as MapProviderName | null; - return saved ?? 'openstreetmap'; - }); - useEffect(() => { - const onStorage = (e: StorageEvent) => { - if (e.key === 'mapProvider' && e.newValue) setProvider(e.newValue as MapProviderName); - }; - window.addEventListener('storage', onStorage); - return () => window.removeEventListener('storage', onStorage); - }, []); - - // Keys (swap to app settings if you have them) - const googleMapsApiKey = 'AIzaSyBeNJSMCi8kD4c6SOvZ4vxHnWYp2yzDbmg'; - const locationIQKey = 'pk.898e468814facdcffda869b42260a2f0'; - - const map: IMapProvider = useMemo( - () => - createMapProvider(provider, { - googleApiKey: googleMapsApiKey, - locationIqKey: locationIQKey, - }), - [provider, googleMapsApiKey, locationIQKey], - ); - - const cacheBust = (url: string) => `${url + (url.includes('?') ? '&' : '?')}ts=${Date.now()}`; - - // ---------- Provider-aware attachment (clean theme + retina) -------------- - const createLiveLocationAttachment = useCallback( - (pos: GeolocationPosition, isLive = true) => { - const { latitude, longitude, accuracy } = pos.coords; - const mapsLink = map.getMapsLink(latitude, longitude); - const staticMapUrl = map.getStaticMapUrl(latitude, longitude, { - zoom: 16, - width: 640, - height: 360, - // theme + scale are used by the Google provider; ignored by OSM provider - theme: 'clean', - scale: 2, - }); - - return { - ts: new Date(), - title: isLive ? 'πŸ“ Live Location (Active)' : 'πŸ“ Location Shared', - title_link: mapsLink, - image_url: staticMapUrl, - image_type: 'image/png', - text: isLive ? 'Click to view live location updates' : 'Static location', - // Keep fields present (empty) to satisfy renderers that assume the key exists - fields: [] as any[], - actions: [ - { - type: 'button', - text: isLive ? 'πŸ‘οΈ View Live Location' : 'πŸ—ΊοΈ View Location', - msg: `/viewlocation ${messageIdRef.current || 'temp'}`, - msg_in_chat_window: false, - msg_processing_type: 'sendMessage', - }, - ], - // Optional: include coords - // description: `Lat ${latitude.toFixed(6)}, Lng ${longitude.toFixed(6)}${ - // accuracy ? ` (Β±${Math.round(accuracy)}m)` : '' - // }`, - customFields: { - isLiveLocation: isLive, - locationId: messageIdRef.current, - lastUpdate: new Date().toISOString(), - coordinates: { lat: latitude, lng: longitude, accuracy }, - }, - }; - }, - [map], - ); - - // ---------- Prevent closing while sharing --------------------------------- - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if (isSharingRef.current && modalRef.current && !modalRef.current.contains(event.target as Node)) { - event.stopPropagation(); - event.preventDefault(); - } - }; - const handleEscape = (event: KeyboardEvent) => { - if (event.key === 'Escape' && isSharingRef.current) { - event.stopPropagation(); - event.preventDefault(); - } - }; - document.addEventListener('mousedown', handleClickOutside, true); - document.addEventListener('keydown', handleEscape, true); - return () => { - document.removeEventListener('mousedown', handleClickOutside, true); - document.removeEventListener('keydown', handleEscape, true); - }; - }, []); - - // ---------- Initial placement --------------------------------------------- - useEffect(() => { - const w = typeof window !== 'undefined' ? window.innerWidth : 1200; - const h = typeof window !== 'undefined' ? window.innerHeight : 800; - const defaultWidth = 360; - const defaultHeight = 240; - setPosition2D({ - x: Math.max(12, w - defaultWidth - 12), - y: Math.max(60, h - defaultHeight - 12), - }); - }, []); - - // ---------- Dragging ------------------------------------------------------ - const handleMouseDown = useCallback((e: React.MouseEvent) => { - if (e.target === e.currentTarget || (e.target as HTMLElement).classList.contains('drag-handle')) { - setIsDragging(true); - const rect = modalRef.current?.getBoundingClientRect(); - if (rect) { - setDragOffset({ x: e.clientX - rect.left, y: e.clientY - rect.top }); - } - } - }, []); - - useEffect(() => { - const handleMouseMove = (e: MouseEvent) => { - if (isDragging && modalRef.current) { - const newX = e.clientX - dragOffset.x; - const newY = e.clientY - dragOffset.y; - const maxX = window.innerWidth - modalRef.current.offsetWidth; - const maxY = window.innerHeight - modalRef.current.offsetHeight; - setPosition2D({ - x: Math.max(0, Math.min(newX, maxX)), - y: Math.max(0, Math.min(newY, maxY)), - }); - } - }; - const handleMouseUp = () => setIsDragging(false); - - if (isDragging) { - document.addEventListener('mousemove', handleMouseMove); - document.addEventListener('mouseup', handleMouseUp); - } - return () => { - document.removeEventListener('mousemove', handleMouseMove); - document.removeEventListener('mouseup', handleMouseUp); - }; - }, [isDragging, dragOffset]); - - // ---------- Initialize live service (watchers/timers only) ---------------- - useEffect(() => { - serviceRef.current = new LiveLocationService({ - locationIQKey, - updateInterval: 10000, - minMoveMeters: 5, - }); - if (typeof window !== 'undefined') (window as any).liveLocationService = serviceRef.current; - return () => { - if (serviceRef.current && !isSharingRef.current) serviceRef.current.cleanup(); - if (typeof window !== 'undefined') delete (window as any).liveLocationService; - }; - }, [locationIQKey]); - - // ---------- Messaging (provider-aware attachments) ------------------------ - const sendInitialMessage = useCallback( - async (pos: GeolocationPosition) => { - if (!serviceRef.current || sendingRef.current || isClosingRef.current) return; - sendingRef.current = true; - try { - const tempMessageId = `live-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`; - messageIdRef.current = tempMessageId; - - const attachment = createLiveLocationAttachment(pos, true); - - const response = await sendMessage({ - message: { - rid, - tmid, - msg: 'πŸ“ Started sharing live location', - attachments: [attachment], - }, - }); - - if (!isClosingRef.current) { - messageIdRef.current = response.message._id; - serviceRef.current.updateLastUpdateTime(); - setLastMessageUpdate(new Date()); - LiveLocationService.storeLiveLocationData(response.message._id, rid); - - const updatedAttachment = createLiveLocationAttachment(pos, true); - await updateMessage({ - roomId: rid, - msgId: response.message._id, - text: 'πŸ“ Started sharing live location', - attachments: [updatedAttachment], - customFields: {}, - } as any); - } - } catch (err) { - console.error('[Send Initial Error]', err); - setError('Failed to send initial location'); - setLocationState('error'); - } finally { - sendingRef.current = false; - } - }, - [sendMessage, updateMessage, rid, tmid, createLiveLocationAttachment], - ); - - const updateLiveLocationMessage = useCallback( - async (pos: GeolocationPosition) => { - if (!serviceRef.current || !messageIdRef.current || sendingRef.current || isClosingRef.current) return; - sendingRef.current = true; - try { - const attachment = createLiveLocationAttachment(pos, true); - await updateMessage({ - roomId: rid, - msgId: messageIdRef.current, - text: 'πŸ“ Live location (Active)', - attachments: [attachment], - customFields: {}, - } as any); - serviceRef.current.updateLastUpdateTime(); - setLastMessageUpdate(new Date()); - } catch (err) { - console.error('[Update Location Error]', err); - } finally { - sendingRef.current = false; - } - }, - [updateMessage, rid, createLiveLocationAttachment], - ); - - const handlePositionSuccess = useCallback( - (pos: GeolocationPosition) => { - if (isClosingRef.current) return; - const prev = position; - setPosition(pos); - setError(null); - setGpsUpdateCount((c) => c + 1); - if (!isSharingRef.current) return; - if (locationState !== 'sharing') setLocationState('sharing'); - if (!messageIdRef.current) { - void sendInitialMessage(pos); - } else if (serviceRef.current?.shouldPushUpdate(prev, pos)) { - void updateLiveLocationMessage(pos); - } - }, - [position, locationState, sendInitialMessage, updateLiveLocationMessage], - ); - - const handlePositionError = useCallback( - (err: GeolocationPositionError) => { - if (isClosingRef.current) return; - if (err.code === err.PERMISSION_DENIED) { - setError('Location permission is denied'); - setLocationState('error'); - return; - } - console.warn('[Geolocation transient error]', err); - if (isSharingRef.current && serviceRef.current) { - setTimeout(() => { - if (!isClosingRef.current && isSharingRef.current && serviceRef.current) { - serviceRef.current.startWatching(handlePositionSuccess, handlePositionError); - } - }, 1500); - } - }, - [handlePositionSuccess], - ); - - const startSharing = useCallback(() => { - if (isClosingRef.current || !serviceRef.current) return; - isSharingRef.current = true; - setLocationState('sharing'); - serviceRef.current.startSharing(); - if (position) handlePositionSuccess(position); - }, [position, handlePositionSuccess]); - - const stopSharing = useCallback(async () => { - if (!serviceRef.current) return; - isSharingRef.current = false; - serviceRef.current.stopSharing(); - - if (messageIdRef.current && position) { - try { - const finalAttachment = createLiveLocationAttachment(position, false); - await updateMessage({ - roomId: rid, - msgId: messageIdRef.current, - text: 'πŸ“ Live location sharing stopped', - attachments: [finalAttachment], - customFields: {}, - } as any); - } catch (err) { - console.error('[Stop sharing update error]', err); - } - } - - await stopLiveLocationSharing(rid, messageIdRef.current || undefined, position || undefined); - messageIdRef.current = null; - setLocationState('waiting'); - }, [stopLiveLocationSharing, rid, position, createLiveLocationAttachment, updateMessage]); - - const handleClose = useCallback(() => { - if (isSharingRef.current) { - if (window.confirm('Live location sharing is active. Stop sharing and close?')) { - stopSharing().then(() => { - isClosingRef.current = true; - serviceRef.current?.cleanup(); - onClose(); - }); - } - return; - } - isClosingRef.current = true; - serviceRef.current?.cleanup(); - onClose(); - }, [onClose, stopSharing]); - - // Start watching on mount - useEffect(() => { - if (!serviceRef.current) return; - serviceRef.current.startWatching(handlePositionSuccess, handlePositionError); - return () => { - if (!isSharingRef.current && serviceRef.current) serviceRef.current.cleanup(); - }; - }, [handlePositionSuccess, handlePositionError]); - - const getStatusIcon = () => { - if (locationState === 'waiting') return 'πŸ“‘'; - if (locationState === 'sharing') return 'πŸ“'; - return '❌'; - }; - const getStatusText = () => { - if (locationState === 'waiting') return 'Getting location...'; - if (locationState === 'sharing') return 'Live sharing active'; - return 'Location error'; - }; - - // Provider-aware preview (clean theme + retina) - const previewUrl = position - ? cacheBust( - map.getStaticMapUrl(position.coords.latitude, position.coords.longitude, { - zoom: 15, - width: 320, - height: 180, - theme: 'clean', - scale: 2, - }), - ) - : ''; - - // -------------------------- UI ------------------------------------------- - if (isMinimized) { - return ( -
setIsMinimized(false)} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - setIsMinimized(false); - } - }} - onMouseDown={handleMouseDown} - title={getStatusText()} - > - {getStatusIcon()} - {locationState === 'sharing' && ( -
- )} -
- ); - } - - return ( - <> - - - {/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */} -
{ - if (e.key === 'Enter' || e.key === ' ') { - handleMouseDown(e as any); - } - }} - > - {/* Header */} -
-
- {getStatusIcon()} - {getStatusText()} - {isSharingRef.current && ( -
- )} -
- -
- - -
-
- - {/* Content */} -
- {locationState === 'error' && ( -
-

{error}

-

Please enable location access in your browser settings.

-
- )} - - {locationState === 'waiting' && ( - <> - {position ? ( -
-
- Map preview -
- -
-
- Lat: {position.coords.latitude.toFixed(6)} -
-
- Lng: {position.coords.longitude.toFixed(6)} -
- {position.coords.accuracy && ( -
- Accuracy: Β±{Math.round(position.coords.accuracy)}m -
- )} -
- - -
- ) : ( -
-
πŸ“‘
-
Getting your location...
-
- )} - - )} - - {locationState === 'sharing' && ( -
- {position && ( - <> -
- Live location -
- -
-
- Lat: {position.coords.latitude.toFixed(6)} -
-
- Lng: {position.coords.longitude.toFixed(6)} -
- {position.coords.accuracy && ( -
- Accuracy: Β±{Math.round(position.coords.accuracy)}m -
- )} -
- {lastMessageUpdate &&
Last update: {lastMessageUpdate.toLocaleTimeString()}
} -
-
- -
- βœ… Sharing live location every 10 seconds -
- Others can click "View Live Location" to see updates -
- - )} - - -
- )} -
-
- - ); -}; - -export default LiveLocationChatWidget; 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..88c338626dd7e --- /dev/null +++ b/apps/meteor/client/views/room/ShareLocation/MapLibreMap.tsx @@ -0,0 +1,148 @@ +// MapLibreMap.tsx +import type { FeatureCollection, LineString } from 'geojson'; +import type { Map } 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('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 = []; + }; + }, [lat, lon, zoom]); + + 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; + const t = setTimeout(() => map.resize(), 50); + return () => clearTimeout(t); + }, [visible]); + + useEffect(() => { + if (!liveCoords || !mapRef.current || !markerRef.current) return; + const { lon: LON, lat: LAT } = liveCoords; + + markerRef.current.setLngLat([LON, LAT]); + trailCoordsRef.current.push([LON, LAT]); + + 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 index a5390bc0d26a3..297530f2e20af 100644 --- a/apps/meteor/client/views/room/ShareLocation/MapView.tsx +++ b/apps/meteor/client/views/room/ShareLocation/MapView.tsx @@ -1,7 +1,8 @@ // MapView.tsx -import React from 'react'; +import { useTranslation } from '@rocket.chat/ui-contexts'; +import React, { useMemo, useState } from 'react'; -import type { MapProviderName, IMapProvider } from './mapProvider'; +import type { IMapProvider, MapProviderName } from './mapProvider'; export type MapViewProps = { latitude: number; @@ -9,75 +10,81 @@ export type MapViewProps = { zoom?: number; width?: number; height?: number; - - /** - * Optional: make MapView provider-aware. - * If present, we'll use mapInstance to render a provider-specific static map. - * If omitted, we fall back to a neutral placeholder so legacy callers still work. - */ provider?: MapProviderName; mapInstance?: IMapProvider; - - // Optional: show a tiny attribution line beneath the map (defaults true) showAttribution?: boolean; }; const MapView: React.FC = ({ latitude, longitude, - zoom = 17, + zoom = 15, width = 512, - height = 512, - provider, + height = 320, mapInstance, showAttribution = true, }) => { - // If a provider instance is provided, use it to compute a static map URL - const staticUrl = mapInstance?.getStaticMapUrl(latitude, longitude, { zoom, width, height }) ?? null; - const attribution = mapInstance?.getAttribution?.(); + const [errored, setErrored] = useState(false); + const t = useTranslation(); - if (staticUrl) { + 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 ( -
-
- Map preview +
+ {t('Map_Preview_Unavailable')} +
+ {latitude.toFixed(5)}, {longitude.toFixed(5)}
- {showAttribution && attribution && provider === 'openstreetmap' && ( -
{attribution}
- )}
); } - // Fallback: neutral placeholder (legacy behavior safety net) return ( -
- Map preview unavailable -
- {latitude.toFixed(5)}, {longitude.toFixed(5)} -
+
+ {t('Map_Preview_Alt')} setErrored(true)} + loading='lazy' + referrerPolicy='no-referrer' + /> + {showAttribution && ( +
+ {t('OSM_Attribution')} +
+ )}
); }; 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 f252105538b81..e1a437332c243 100644 --- a/apps/meteor/client/views/room/ShareLocation/ShareLocationModal.tsx +++ b/apps/meteor/client/views/room/ShareLocation/ShareLocationModal.tsx @@ -1,16 +1,20 @@ -// ShareLocationModal.tsx β€” Provider first, then Current vs Live -import type { IMessage, IRoom, MessageAttachment } from '@rocket.chat/core-typings'; +// 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, useEffect } from 'react'; +import { useMemo, useState } from 'react'; -import LiveLocationModal from './LiveLocationModal'; -import MapView from './MapView'; +import MapLibreMap from './MapLibreMap'; import { getGeolocationPermission } from './getGeolocationPermission'; import { getGeolocationPosition } from './getGeolocationPosition'; -import { createMapProvider, type MapProviderName, type IMapProvider } from './mapProvider'; +import { createMapProvider, type IMapProvider } from './mapProvider'; + +interface IGeolocationError { + code?: number; + message?: string; +} type ShareLocationModalProps = { rid: IRoom['_id']; @@ -18,7 +22,7 @@ type ShareLocationModalProps = { onClose: () => void; }; -type Stage = 'provider' | 'choose' | 'static'; +type Stage = 'choose' | 'static'; const ShareLocationModal = ({ rid, tmid, onClose }: ShareLocationModalProps): ReactElement => { const t = useTranslation(); @@ -26,34 +30,11 @@ const ShareLocationModal = ({ rid, tmid, onClose }: ShareLocationModalProps): Re const queryClient = useQueryClient(); const sendMessage = useEndpoint('POST', '/v1/chat.sendMessage'); - // --- New: stages (provider -> choose -> static/live) --- - const [stage, setStage] = useState('provider'); + const [stage, setStage] = useState('choose'); const [choice, setChoice] = useState<'current' | 'live' | null>(null); - // Provider picker (persisted) - const [provider, setProvider] = useState(() => { - const saved = localStorage.getItem('mapProvider') as MapProviderName | null; - return saved ?? 'openstreetmap'; - }); - useEffect(() => { - localStorage.setItem('mapProvider', provider); - }, [provider]); - - // Keys (swap to settings if you have them) - const googleMapsApiKey = 'AIzaSyBeNJSMCi8kD4c6SOvZ4vxHnWYp2yzDbmg'; - const locationIQKey = 'pk.898e468814facdcffda869b42260a2f0'; - - // Provider instance - const map: IMapProvider = useMemo( - () => - createMapProvider(provider, { - googleApiKey: googleMapsApiKey, - locationIqKey: locationIQKey, - }), - [provider, googleMapsApiKey, locationIQKey], - ); + const map: IMapProvider = useMemo(() => createMapProvider('openstreetmap', {}), []); - // Permission & position queries (only used in static flow) const { data: permissionState, isLoading: permissionLoading } = useQuery({ queryKey: ['geolocationPermission'], queryFn: getGeolocationPermission, @@ -72,13 +53,10 @@ const ShareLocationModal = ({ rid, tmid, onClose }: ShareLocationModalProps): Re enabled: stage === 'static' && permissionState === 'granted', refetchOnWindowFocus: false, retry: (failureCount, error) => { - // retry ONCE for transient errors; never retry on permission denied - const e = error as any; + const e = error as IGeolocationError; const code = e?.code; const msg = String(e?.message || '').toLowerCase(); - const transient = - code !== 1 && // not PERMISSION_DENIED - (code === 2 || msg.includes('kclerrorlocationunknown') || msg.includes('location unknown')); + const transient = code !== 1 && (code === 2 || msg.includes('kclerrorlocationunknown') || msg.includes('location unknown')); return transient && failureCount < 1; }, retryDelay: 1500, @@ -86,7 +64,7 @@ const ShareLocationModal = ({ rid, tmid, onClose }: ShareLocationModalProps): Re const onConfirmRequestLocation = async (): Promise => { try { - const pos = await getGeolocationPosition(); // triggers browser prompt + const pos = await getGeolocationPosition(); queryClient.setQueryData(['geolocationPermission'], 'granted'); queryClient.setQueryData(['geolocationPosition'], pos); } catch { @@ -95,69 +73,49 @@ const ShareLocationModal = ({ rid, tmid, onClose }: ShareLocationModalProps): Re } }; - // --- Stage 1: Provider selection (FIRST) --- - if (stage === 'provider') { - return ( - setStage('choose')} - onCancel={onClose} - > -
- - -
You can change this later. Your choice is saved for live sharing too.
-
-
- ); - } - // --- Stage 2: Choose static vs live --- if (stage === 'choose' && !choice) { return ( { setChoice('live'); }} - confirmText='Current Location' + confirmText={t('Current_Location')} onConfirm={() => { setChoice('current'); setStage('static'); }} > - Choose to share your current location once or start live location sharing. + {t('Share_Location_Choice_Description')} ); } - // Live path + // Live path (disabled placeholder) if (choice === 'live') { - // Your LiveLocation modal/widget reads provider from localStorage, - // which we already saved above. - return ; + return ( + { + 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') { - // Ask for permission if (permissionLoading || permissionState === 'prompt' || permissionState === undefined) { return ( - Getting your location… + {t('Getting_Your_Location')} ); } @@ -193,23 +151,22 @@ const ShareLocationModal = ({ rid, tmid, onClose }: ShareLocationModalProps): Re return ( { - // Clear the error and try again queryClient.resetQueries({ queryKey: ['geolocationPosition'] }); }} onClose={onClose} cancelText={t('Cancel')} onCancel={onClose} > -
{(positionErr as Error | undefined)?.message || 'Unable to fetch your current location.'}
+
{(positionErr as Error | undefined)?.message || t('Unable_To_Fetch_Current_Location')}
- Tips to improve location accuracy: -
β€’ Move closer to a window or go outside -
β€’ Make sure location services are enabled on your device -
β€’ Check that your browser has location permissions -
β€’ Try refreshing the page and allowing location access again + {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')}
); @@ -219,32 +176,18 @@ const ShareLocationModal = ({ rid, tmid, onClose }: ShareLocationModalProps): Re if (!positionData) return; const { latitude, longitude } = positionData.coords; - if (provider === 'google' && !googleMapsApiKey) { - dispatchToast({ - type: 'warning', - message: 'Google Maps API key is missing; consider using OpenStreetMap.', - }); - } - try { const mapsLink = map.getMapsLink(latitude, longitude); - const staticMapUrl = map.getStaticMapUrl(latitude, longitude, { zoom: 17, width: 512, height: 512 }); - const attachment: MessageAttachment = { - ts: new Date(), - title: 'πŸ“ Shared Location', - title_link: mapsLink, - image_url: staticMapUrl, - image_type: 'image/png', - // keep fields as empty array to avoid any renderer crashes - fields: [], - }; + const locationMessage = `πŸ“ **Location Shared** +πŸ”— **[View on OpenStreetMap](${mapsLink})** +πŸ“Œ \`${latitude.toFixed(4)}Β°, ${longitude.toFixed(4)}Β°\``; void sendMessage({ message: { rid, tmid, - attachments: [attachment], + msg: locationMessage, }, }); } catch (error) { @@ -263,26 +206,19 @@ const ShareLocationModal = ({ rid, tmid, onClose }: ShareLocationModalProps): Re onClose={onClose} onCancel={onClose} > -
- Provider: {provider === 'google' ? 'Google Maps' : 'OpenStreetMap'} -
+
{t('Using_OpenStreetMap_Label')}
{positionData && ( - + <> + + + {} + )} ); } - // Should never hit here return <>; }; 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 16642dc9c7462..d68b73a15b1d5 100644 --- a/apps/meteor/client/views/room/ShareLocation/getGeolocationPosition.ts +++ b/apps/meteor/client/views/room/ShareLocation/getGeolocationPosition.ts @@ -1,15 +1,53 @@ // getGeolocationPosition.ts + type AnyErr = GeolocationPositionError & { message?: string }; -const CACHE_KEY = 'lastGeoPosition'; -const MAX_CACHE_AGE_MS = 5 * 60 * 1000; // 5 minutes +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: Date.now(), + 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 + // 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; @@ -23,13 +61,13 @@ export async function getGeolocationPosition(opts?: PositionOptions): Promise { @@ -104,29 +145,17 @@ function watchOnce(options: PositionOptions): Promise { function isTransient(err: AnyErr): boolean { if (!err) return false; - // POSITION_UNAVAILABLE (2) is commonly transient if (err.code === err.POSITION_UNAVAILABLE) return true; const m = String(err.message || '').toLowerCase(); - // Apple/macOS transient message if (m.includes('kclerrorlocationunknown')) return true; if (m.includes('location unknown')) return true; return false; } function cache(position: GeolocationPosition) { - try { - sessionStorage.setItem(CACHE_KEY, JSON.stringify({ timestamp: Date.now(), position })); - } catch { - // ignore - } + memCache = { timestamp: Date.now(), position }; } -function readCached(): { timestamp: number; position: GeolocationPosition } | null { - try { - const raw = sessionStorage.getItem(CACHE_KEY); - if (!raw) return null; - return JSON.parse(raw); - } catch { - return null; - } +function readCached(): Cached | null { + return memCache; } diff --git a/apps/meteor/client/views/room/ShareLocation/liveLocationService.ts b/apps/meteor/client/views/room/ShareLocation/liveLocationService.ts deleted file mode 100644 index 809d06033d5da..0000000000000 --- a/apps/meteor/client/views/room/ShareLocation/liveLocationService.ts +++ /dev/null @@ -1,244 +0,0 @@ -// liveLocationService.ts -export type LocationState = 'waiting' | 'sharing' | 'error'; - -export interface ILocationServiceConfig { - locationIQKey: string; - updateInterval: number; - minMoveMeters: number; -} - -export class LiveLocationService { - private config: ILocationServiceConfig; - - private watchId: number | null = null; - - private pollTimerRef: number | null = null; - - private sharingIntervalRef: number | null = null; - - private isSharing = false; - - private lastUpdateTime = 0; - - private onPositionSuccess?: (position: GeolocationPosition) => void; - - private onPositionError?: (error: GeolocationPositionError) => void; - - constructor(config: ILocationServiceConfig) { - this.config = config; - this.setupDevelopmentMock(); - } - - private setupDevelopmentMock() { - if (process.env.NODE_ENV === 'development') { - let lat = 40.7128; // starting latitude (NYC) - let lon = -74.006; // starting longitude (NYC) - - navigator.geolocation.getCurrentPosition = (success, _error) => { - success({ - coords: { - latitude: lat, - longitude: lon, - accuracy: 10, - altitude: null, - altitudeAccuracy: null, - heading: null, - speed: null, - }, - timestamp: Date.now(), - } as GeolocationPosition); - }; - - navigator.geolocation.watchPosition = (success, _error) => { - const watchId = setInterval(() => { - // Simulate movement - lat += (Math.random() - 0.5) * 0.001; - lon += (Math.random() - 0.5) * 0.001; - - success({ - coords: { - latitude: lat, - longitude: lon, - accuracy: 10, - altitude: null, - altitudeAccuracy: null, - heading: null, - speed: null, - }, - timestamp: Date.now(), - } as GeolocationPosition); - }, 10000); // every 5 seconds for testing - - return watchId as unknown as number; - }; - - navigator.geolocation.clearWatch = (id) => clearInterval(id as unknown as number); - } - } - - generateMapUrls(latitude: number, longitude: number) { - const mapsLink = `https://www.openstreetmap.org/?mlat=${latitude}&mlon=${longitude}#map=16/${latitude}/${longitude}`; - const staticMapUrl = `https://maps.locationiq.com/v2/staticmap?key=${this.config.locationIQKey}¢er=${latitude},${longitude}&zoom=17&size=512x512&markers=icon:small-red-cutout|${latitude},${longitude}`; - return { mapsLink, staticMapUrl }; - } - - createLocationAttachment(pos: GeolocationPosition, isLive = false) { - const { latitude, longitude, accuracy, heading, speed } = pos.coords; - const { mapsLink, staticMapUrl } = this.generateMapUrls(latitude, longitude); - - const baseAttachment = { - ts: new Date(), - title: isLive ? 'πŸ“ Live Location (Sharing)' : 'πŸ“ Location', - title_link: mapsLink, - image_url: staticMapUrl, - description: [ - `Lat: ${latitude.toFixed(5)}, Lng: ${longitude.toFixed(5)}`, - accuracy ? `Accuracy: Β±${Math.round(accuracy)}m` : null, - heading != null ? `Heading: ${heading.toFixed(1)}Β°` : null, - speed != null ? `Speed: ${speed.toFixed(1)} m/s` : null, - isLive ? `Updated: ${new Date().toLocaleTimeString()}` : null, - ] - .filter(Boolean) - .join(' β€’ '), - }; - - // Add action buttons for live location - if (isLive) { - return { - ...baseAttachment, - actions: [ - { - type: 'button', - text: 'πŸ›‘ Stop Sharing', - msg: '/stop-live-location', - msg_in_chat_window: false, - msg_processing_type: 'sendMessage', - }, - ], - }; - } - - return baseAttachment; - } - - private haversineMeters(a: GeolocationCoordinates, b: GeolocationCoordinates): number { - const toRad = (x: number) => (x * Math.PI) / 180; - const R = 6371000; - const dLat = toRad(b.latitude - a.latitude); - const dLon = toRad(b.longitude - a.longitude); - const lat1 = toRad(a.latitude); - const lat2 = toRad(b.latitude); - const h = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) ** 2; - return 2 * R * Math.asin(Math.sqrt(h)); - } - - shouldPushUpdate(prev: GeolocationPosition | null, curr: GeolocationPosition): boolean { - const now = Date.now(); - if (now - this.lastUpdateTime >= this.config.updateInterval) return true; - if (!prev) return true; - return this.haversineMeters(prev.coords, curr.coords) >= this.config.minMoveMeters; - } - - startWatching(onSuccess: (position: GeolocationPosition) => void, onError: (error: GeolocationPositionError) => void) { - if (!navigator.geolocation) { - onError({ - code: 2, - message: 'Geolocation is not supported by this browser', - PERMISSION_DENIED: 1, - POSITION_UNAVAILABLE: 2, - TIMEOUT: 3, - } as GeolocationPositionError); - return; - } - - this.onPositionSuccess = onSuccess; - this.onPositionError = onError; - - this.watchId = navigator.geolocation.watchPosition(onSuccess, onError, { enableHighAccuracy: true, timeout: 20000, maximumAge: 0 }); - - // Optional heartbeat to smooth out quiet watches - this.pollTimerRef = window.setInterval(() => { - navigator.geolocation.getCurrentPosition(onSuccess, onError, { enableHighAccuracy: true, timeout: 20000, maximumAge: 0 }); - }, this.config.updateInterval); - } - - startSharing() { - this.isSharing = true; - - // Set up continuous sharing interval - this.sharingIntervalRef = window.setInterval(() => { - if (this.isSharing && this.onPositionSuccess) { - navigator.geolocation.getCurrentPosition( - (pos) => { - if (this.isSharing && this.onPositionSuccess) { - this.onPositionSuccess(pos); - } - }, - (error) => { - if (this.onPositionError) { - this.onPositionError(error); - } - }, - { enableHighAccuracy: true, timeout: 20000, maximumAge: 0 }, - ); - } - }, this.config.updateInterval); - } - - stopSharing() { - this.isSharing = false; - - if (this.sharingIntervalRef !== null) { - clearInterval(this.sharingIntervalRef); - this.sharingIntervalRef = null; - } - } - - cleanup() { - this.stopSharing(); - - if (this.watchId !== null) { - navigator.geolocation.clearWatch(this.watchId); - this.watchId = null; - } - - if (this.pollTimerRef !== null) { - clearInterval(this.pollTimerRef); - this.pollTimerRef = null; - } - } - - updateLastUpdateTime() { - this.lastUpdateTime = Date.now(); - } - - get isSharingActive(): boolean { - return this.isSharing; - } - - // Storage helpers - static storeLiveLocationData(messageId: string, roomId: string) { - if (typeof window !== 'undefined') { - window.localStorage.setItem('liveLocationMessageId', messageId); - window.localStorage.setItem('liveLocationRoomId', roomId); - } - } - - static getLiveLocationData(): { messageId: string | null; roomId: string | null } { - if (typeof window === 'undefined') { - return { messageId: null, roomId: null }; - } - - return { - messageId: window.localStorage.getItem('liveLocationMessageId'), - roomId: window.localStorage.getItem('liveLocationRoomId'), - }; - } - - static clearLiveLocationData() { - if (typeof window !== 'undefined') { - window.localStorage.removeItem('liveLocationMessageId'); - window.localStorage.removeItem('liveLocationRoomId'); - } - } -} 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 index 77301e2a0d18c..a6a388549779f 100644 --- a/apps/meteor/client/views/room/ShareLocation/mapProvider.ts +++ b/apps/meteor/client/views/room/ShareLocation/mapProvider.ts @@ -1,74 +1,52 @@ -// Unified map provider interface + two implementations (Google, OpenStreetMap via LocationIQ) +// mapProvider.ts -export type MapProviderName = 'google' | 'openstreetmap'; +export type MapProviderName = 'openstreetmap'; + +export type ProviderOpts = { + apiKey?: string; +}; export interface IMapProvider { name: MapProviderName; - // Static preview image for messages / modal - getStaticMapUrl( - lat: number, - lng: number, - opts?: { zoom?: number; width?: number; height?: number; theme?: string; scale?: number }, - ): string; - // Deep link that opens the native app or web directions + getStaticMapUrl(lat: number, lng: number, opts?: { zoom?: number; width?: number; height?: number }): string; getMapsLink(lat: number, lng: number): string; - // Human-readable attribution (OSM requires) - getAttribution?: () => string | undefined; -} - -type ProviderOpts = { - googleApiKey?: string; // required for Google Static Maps - locationIqKey?: string; // required for LocationIQ static (OSM-backed) -}; - -// ------------ Google ------------ -export class GoogleProvider implements IMapProvider { - name: MapProviderName = 'google'; - - constructor(private opts: ProviderOpts) {} - - getStaticMapUrl(lat: number, lng: number, opts?: { zoom?: number; width?: number; height?: number }): string { - const key = this.opts.googleApiKey; - const zoom = opts?.zoom ?? 15; - const width = opts?.width ?? 600; - const height = opts?.height ?? 320; - // NOTE: consider server-side signing if you need URL signing for premium usage. - return `https://maps.googleapis.com/maps/api/staticmap?center=${lat},${lng}&zoom=${zoom}&size=${width}x${height}&markers=${lat},${lng}&key=${key}`; - } - - getMapsLink(lat: number, lng: number): string { - // works on web + mobile - return `https://maps.google.com/?q=${lat},${lng}`; - } + getAttribution(): string; } -// ------------ OpenStreetMap via LocationIQ ------------ export class OSMProvider implements IMapProvider { name: MapProviderName = 'openstreetmap'; - constructor(private opts: ProviderOpts) {} + 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 key = this.opts.locationIqKey; const zoom = opts?.zoom ?? 15; const width = opts?.width ?? 600; const height = opts?.height ?? 320; - // LocationIQ static map API (OSM-backed). See their docs for style params. - return `https://maps.locationiq.com/v2/staticmap?key=${key}¢er=${lat},${lng}&zoom=${zoom}&size=${width}x${height}&markers=icon:large-red-cutout|${lat},${lng}`; + + 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 { - // Deep link to openstreetmap - return `https://www.openstreetmap.org/?mlat=${lat}&mlon=${lng}#map=16/${lat}/${lng}`; + const defaultZoom = 16; + return `https://www.openstreetmap.org/?mlat=${lat}&mlon=${lng}#map=${defaultZoom}/${lat}/${lng}`; } - getAttribution() { + getAttribution(): string { return 'Β© OpenStreetMap contributors'; } } -// ------------ Factory ------------ -export function createMapProvider(name: MapProviderName, keys: ProviderOpts): IMapProvider { - if (name === 'google') return new GoogleProvider(keys); - return new OSMProvider(keys); +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..0d515c073d928 --- /dev/null +++ b/apps/meteor/client/views/room/ShareLocation/useBrowserLiveLocation.spec.tsx @@ -0,0 +1,85 @@ +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; + jest.useFakeTimers(); + + 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/ShareLocation/useLiveLocationStopListener.ts b/apps/meteor/client/views/room/ShareLocation/useLiveLocationStopListener.ts deleted file mode 100644 index ba93d8507f411..0000000000000 --- a/apps/meteor/client/views/room/ShareLocation/useLiveLocationStopListener.ts +++ /dev/null @@ -1,118 +0,0 @@ -// useLiveLocationStopListener.ts -import { useEndpoint } from '@rocket.chat/ui-contexts'; -import { useCallback, useEffect } from 'react'; - -import { LiveLocationService } from './liveLocationService'; - -export const useLiveLocationStopListener = () => { - const updateMessage = useEndpoint('POST', '/v1/chat.update'); - - const stopLiveLocationSharing = useCallback( - async (rid?: string, messageId?: string, currentPosition?: GeolocationPosition) => { - // Get stored data if not provided - const { messageId: storedMessageId, roomId: storedRoomId } = LiveLocationService.getLiveLocationData(); - - const finalMessageId = messageId || storedMessageId; - const finalRoomId = rid || storedRoomId; - - if (!finalMessageId || !finalRoomId) { - console.warn('No active live location sharing found'); - return false; - } - - try { - // Stop the sharing service if available globally - if ((window as any).liveLocationService) { - (window as any).liveLocationService.stopSharing(); - } - - // Update the message to remove live status if we have current position - if (currentPosition) { - const service = new LiveLocationService({ - locationIQKey: 'pk.898e468814facdcffda869b42260a2f0', // TODO: move to config - updateInterval: 10000, - minMoveMeters: 5, - }); - - const finalAttachment = service.createLocationAttachment(currentPosition, false); - finalAttachment.title = 'πŸ“ Location (Sharing Stopped)'; - - const updatePayload = { - roomId: finalRoomId, - msgId: finalMessageId, - text: '', - attachments: [finalAttachment], - customFields: {}, - }; - - await updateMessage(updatePayload); - } - - // Clean up stored data - LiveLocationService.clearLiveLocationData(); - - return true; - } catch (error) { - console.error('Error stopping live location:', error); - return false; - } - }, - [updateMessage], - ); - - const handleStopCommand = useCallback( - async (rid: string) => { - const { messageId, roomId } = LiveLocationService.getLiveLocationData(); - - if (!messageId || roomId !== rid) { - console.warn('No active live location sharing found for this room'); - return false; - } - - return stopLiveLocationSharing(rid, messageId); - }, - [stopLiveLocationSharing], - ); - - // Set up global stop function - useEffect(() => { - if (typeof window !== 'undefined') { - (window as any).stopLiveLocationSharing = stopLiveLocationSharing; - } - - return () => { - if (typeof window !== 'undefined') { - delete (window as any).stopLiveLocationSharing; - } - }; - }, [stopLiveLocationSharing]); - - return { - stopLiveLocationSharing, - handleStopCommand, - }; -}; - -// Slash command processor -export const processLiveLocationSlashCommand = ( - message: string, - rid: string, - handleStopCommand: (rid: string) => Promise, -): boolean => { - if (message === '/stop-live-location') { - handleStopCommand(rid); - return true; // Prevent the message from being sent to chat - } - - return false; // Let other processors handle it -}; - -// Button action handler -export const handleLiveLocationAction = (action: any, message: any, handleStopCommand: (rid: string) => Promise): boolean => { - if (action.msg === '/stop-live-location') { - handleStopCommand(message.rid); - return true; - } - - return false; // Let other handlers process it -}; 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 8c06eb461efb4..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 @@ -1,6 +1,7 @@ import type { IMessage, IRoom } from '@rocket.chat/core-typings'; +import { isRoomFederated } from '@rocket.chat/core-typings'; import type { GenericMenuItemProps } from '@rocket.chat/ui-client'; -import { useSetModal } from '@rocket.chat/ui-contexts'; +import { useSetting, useSetModal } from '@rocket.chat/ui-contexts'; import { useTranslation } from 'react-i18next'; import ShareLocationModal from '../../../../ShareLocation/ShareLocationModal'; @@ -13,15 +14,13 @@ export const useShareLocationAction = (room?: IRoom, tmid?: IMessage['tmid']): G const { t } = useTranslation(); const setModal = useSetModal(); - // const isMapViewEnabled = useSetting('MapView_Enabled') === true; - // const isGeolocationCurrentPositionSupported = Boolean(navigator.geolocation?.getCurrentPosition); - // const googleMapsApiKey = useSetting('MapView_GMapsAPIKey', ''); - // const canGetGeolocation = isMapViewEnabled && isGeolocationCurrentPositionSupported && googleMapsApiKey && googleMapsApiKey.length; + const isMapViewEnabled = useSetting('MapView_Enabled') === true; + const isGeolocationCurrentPositionSupported = Boolean(navigator.geolocation?.getCurrentPosition); + // OSM-based map preview does not require a Google Maps API key + const canGetGeolocation = isMapViewEnabled && isGeolocationCurrentPositionSupported; const handleShareLocation = () => setModal( setModal(null)} />); - - const allowGeolocation = true; - // const allowGeolocation = room && canGetGeolocation && !isRoomFederated(room); + const allowGeolocation = room && canGetGeolocation && !isRoomFederated(room); return { id: 'share-location', diff --git a/apps/meteor/package.json b/apps/meteor/package.json index 926fa5487530f..b0fa974cf01a6 100644 --- a/apps/meteor/package.json +++ b/apps/meteor/package.json @@ -394,6 +394,7 @@ "lodash.escape": "^4.0.1", "lodash.get": "^4.4.2", "mailparser": "~3.7.4", + "maplibre-gl": "^4.7.1", "marked": "^4.3.0", "matrix-appservice": "^2.0.0", "matrix-appservice-bridge": "^10.3.3", diff --git a/apps/meteor/packages/meteor-user-presence/.npm/package/.gitignore b/apps/meteor/packages/meteor-user-presence/.npm/package/.gitignore deleted file mode 100644 index 3c3629e647f5d..0000000000000 --- a/apps/meteor/packages/meteor-user-presence/.npm/package/.gitignore +++ /dev/null @@ -1 +0,0 @@ -node_modules diff --git a/apps/meteor/packages/meteor-user-presence/.npm/package/README b/apps/meteor/packages/meteor-user-presence/.npm/package/README deleted file mode 100644 index 3d492553a438e..0000000000000 --- a/apps/meteor/packages/meteor-user-presence/.npm/package/README +++ /dev/null @@ -1,7 +0,0 @@ -This directory and the files immediately inside it are automatically generated -when you change this package's NPM dependencies. Commit the files in this -directory (npm-shrinkwrap.json, .gitignore, and this README) to source control -so that others run the same versions of sub-dependencies. - -You should NOT check in the node_modules directory that Meteor automatically -creates; if you are using git, the .gitignore file tells git to ignore it. diff --git a/apps/meteor/packages/meteor-user-presence/.npm/package/npm-shrinkwrap.json b/apps/meteor/packages/meteor-user-presence/.npm/package/npm-shrinkwrap.json deleted file mode 100644 index 2e6e97813bd99..0000000000000 --- a/apps/meteor/packages/meteor-user-presence/.npm/package/npm-shrinkwrap.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "lockfileVersion": 4, - "dependencies": { - "colors": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/colors/-/colors-1.3.2.tgz", - "integrity": "sha512-rhP0JSBGYvpcNQj4s5AdShMeE5ahMop96cTeDl/v9qQQm2fYClE2QXZRi8wLzc+GmXSxdIqqbOIAhyObEXDbfQ==" - } - } -} diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index dcdbe01b31e34..699aff8eebfeb 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -1500,6 +1500,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", @@ -2366,6 +2367,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", @@ -3147,6 +3149,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", @@ -3241,6 +3245,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", @@ -3941,6 +3947,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", @@ -4801,6 +4808,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", @@ -5233,6 +5241,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", @@ -5307,6 +5320,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", @@ -5358,6 +5372,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.", @@ -5612,6 +5627,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", diff --git a/packages/model-typings/src/models/ILiveLocationSessionModel.ts b/packages/model-typings/src/models/ILiveLocationSessionModel.ts deleted file mode 100644 index e69de29bb2d1d..0000000000000 diff --git a/packages/models/src/models/Messages.ts b/packages/models/src/models/Messages.ts index 452c3fbb05d4d..f11aeee1bdd2f 100644 --- a/packages/models/src/models/Messages.ts +++ b/packages/models/src/models/Messages.ts @@ -63,6 +63,24 @@ export class MessagesRaw extends BaseRaw implements IMessagesModel { { key: { 'pinnedBy._id': 1 }, sparse: true }, { key: { 'starred._id': 1 }, sparse: true }, + // 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: { '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 }, diff --git a/yarn.lock b/yarn.lock index 532227e7b3a05..aaa3472c460a6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4826,6 +4826,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" @@ -4845,6 +4864,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 + "@matrix-org/matrix-sdk-crypto-nodejs@npm:0.3.0-beta.1": version: 0.3.0-beta.1 resolution: "@matrix-org/matrix-sdk-crypto-nodejs@npm:0.3.0-beta.1" @@ -9461,6 +9536,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" matrix-appservice: "npm:^2.0.0" matrix-appservice-bridge: "npm:^10.3.3" @@ -13446,6 +13522,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" @@ -13453,6 +13538,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" @@ -13796,6 +13888,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" @@ -14030,6 +14140,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" @@ -14353,6 +14470,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" @@ -20640,6 +20766,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" @@ -23327,6 +23460,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" @@ -23434,7 +23574,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 @@ -23490,6 +23630,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" @@ -24688,7 +24835,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 @@ -27374,6 +27521,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" @@ -27592,6 +27746,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" @@ -28446,6 +28607,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" @@ -29569,6 +29764,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" @@ -31201,6 +31403,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" @@ -32292,6 +32506,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" @@ -32633,6 +32854,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" @@ -32910,6 +33138,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" @@ -34073,6 +34315,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" @@ -34472,6 +34723,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" @@ -36461,6 +36719,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" @@ -37059,6 +37326,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" @@ -38702,6 +38976,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" From 88a56e0e0268f62a86affd76bd372a93478351c0 Mon Sep 17 00:00:00 2001 From: yiwei gao Date: Sat, 25 Oct 2025 01:31:42 -0400 Subject: [PATCH 06/14] feat: static & live location sharing (draft) --- packages/apps-engine/deno-runtime/deno.lock | 120 ++++++++++---------- 1 file changed, 58 insertions(+), 62 deletions(-) diff --git a/packages/apps-engine/deno-runtime/deno.lock b/packages/apps-engine/deno-runtime/deno.lock index e2a816f68a4a9..92349e3131039 100644 --- a/packages/apps-engine/deno-runtime/deno.lock +++ b/packages/apps-engine/deno-runtime/deno.lock @@ -1,66 +1,62 @@ { - "version": "5", - "specifiers": { - "jsr:@std/cli@^1.0.9": "1.0.13", - "npm:@msgpack/msgpack@3.0.0-beta2": "3.0.0-beta2", - "npm:@rocket.chat/ui-kit@~0.31.22": "0.31.25_@rocket.chat+icons@0.32.0", - "npm:@types/node@*": "22.15.15", - "npm:acorn-walk@8.2.0": "8.2.0", - "npm:acorn@8.10.0": "8.10.0", - "npm:astring@1.8.6": "1.8.6", - "npm:jsonrpc-lite@2.2.0": "2.2.0", - "npm:stack-trace@*": "0.0.10", - "npm:stack-trace@0.0.10": "0.0.10", - "npm:uuid@8.3.2": "8.3.2" - }, - "jsr": { - "@std/cli@1.0.13": { - "integrity": "5db2d95ab2dca3bca9fb6ad3c19908c314e93d6391c8b026725e4892d4615a69" - } - }, - "npm": { - "@msgpack/msgpack@3.0.0-beta2": { - "integrity": "sha512-y+l1PNV0XDyY8sM3YtuMLK5vE3/hkfId+Do8pLo/OPxfxuFAUwcGz3oiiUuV46/aBpwTzZ+mRWVMtlSKbradhw==", - "deprecated": true - }, - "@rocket.chat/icons@0.32.0": { - "integrity": "sha512-7yhhELKNLb9kUtXCvau0V+iMXraV2bOsxcPjc/ZtLR5VeeIDTeaflqRWGtLroX6f3bE+J1n5qB5zi8A4YXuH2g==" - }, - "@rocket.chat/ui-kit@0.31.25_@rocket.chat+icons@0.32.0": { - "integrity": "sha512-yTgTKDw9SMlJ6p8n0PDO6zSvox/nHYUrwCIvILQeAK6PvTrgSe/u9CvU7ATTYjnQiQ603yEGR6dxjF4euCGdNA==", - "dependencies": [ - "@rocket.chat/icons" - ] - }, - "@types/node@22.15.15": { - "integrity": "sha512-R5muMcZob3/Jjchn5LcO8jdKwSCbzqmPB6ruBxMcf9kbxtniZHP327s6C37iOfuw8mbKK3cAQa7sEl7afLrQ8A==", - "dependencies": [ - "undici-types" - ] - }, - "acorn-walk@8.2.0": { - "integrity": "sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==" - }, - "acorn@8.10.0": { - "integrity": "sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw==", - "bin": true - }, - "astring@1.8.6": { - "integrity": "sha512-ISvCdHdlTDlH5IpxQJIex7BWBywFWgjJSVdwst+/iQCoEYnyOaQ95+X1JGshuBjGp6nxKUy1jMgE3zPqN7fQdg==", - "bin": true - }, - "jsonrpc-lite@2.2.0": { - "integrity": "sha512-/cbbSxtZWs1O7R4tWqabrCM/t3N8qKUZMAg9IUqpPvUs6UyRvm6pCNYkskyKN/XU0UgffW+NY2ZRr8t0AknX7g==" - }, - "stack-trace@0.0.10": { - "integrity": "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==" + "version": "3", + "packages": { + "specifiers": { + "jsr:@std/cli@^1.0.9": "jsr:@std/cli@1.0.13", + "npm:@msgpack/msgpack@3.0.0-beta2": "npm:@msgpack/msgpack@3.0.0-beta2", + "npm:@rocket.chat/ui-kit@^0.31.22": "npm:@rocket.chat/ui-kit@0.31.25_@rocket.chat+icons@0.32.0", + "npm:acorn-walk@8.2.0": "npm:acorn-walk@8.2.0", + "npm:acorn@8.10.0": "npm:acorn@8.10.0", + "npm:astring@1.8.6": "npm:astring@1.8.6", + "npm:jsonrpc-lite@2.2.0": "npm:jsonrpc-lite@2.2.0", + "npm:stack-trace": "npm:stack-trace@0.0.10", + "npm:stack-trace@0.0.10": "npm:stack-trace@0.0.10", + "npm:uuid@8.3.2": "npm:uuid@8.3.2" }, - "undici-types@6.21.0": { - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==" + "jsr": { + "@std/cli@1.0.13": { + "integrity": "5db2d95ab2dca3bca9fb6ad3c19908c314e93d6391c8b026725e4892d4615a69" + } }, - "uuid@8.3.2": { - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "bin": true + "npm": { + "@msgpack/msgpack@3.0.0-beta2": { + "integrity": "sha512-y+l1PNV0XDyY8sM3YtuMLK5vE3/hkfId+Do8pLo/OPxfxuFAUwcGz3oiiUuV46/aBpwTzZ+mRWVMtlSKbradhw==", + "dependencies": {} + }, + "@rocket.chat/icons@0.32.0": { + "integrity": "sha512-7yhhELKNLb9kUtXCvau0V+iMXraV2bOsxcPjc/ZtLR5VeeIDTeaflqRWGtLroX6f3bE+J1n5qB5zi8A4YXuH2g==", + "dependencies": {} + }, + "@rocket.chat/ui-kit@0.31.25_@rocket.chat+icons@0.32.0": { + "integrity": "sha512-yTgTKDw9SMlJ6p8n0PDO6zSvox/nHYUrwCIvILQeAK6PvTrgSe/u9CvU7ATTYjnQiQ603yEGR6dxjF4euCGdNA==", + "dependencies": { + "@rocket.chat/icons": "@rocket.chat/icons@0.32.0" + } + }, + "acorn-walk@8.2.0": { + "integrity": "sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==", + "dependencies": {} + }, + "acorn@8.10.0": { + "integrity": "sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw==", + "dependencies": {} + }, + "astring@1.8.6": { + "integrity": "sha512-ISvCdHdlTDlH5IpxQJIex7BWBywFWgjJSVdwst+/iQCoEYnyOaQ95+X1JGshuBjGp6nxKUy1jMgE3zPqN7fQdg==", + "dependencies": {} + }, + "jsonrpc-lite@2.2.0": { + "integrity": "sha512-/cbbSxtZWs1O7R4tWqabrCM/t3N8qKUZMAg9IUqpPvUs6UyRvm6pCNYkskyKN/XU0UgffW+NY2ZRr8t0AknX7g==", + "dependencies": {} + }, + "stack-trace@0.0.10": { + "integrity": "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==", + "dependencies": {} + }, + "uuid@8.3.2": { + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "dependencies": {} + } } }, "remote": { @@ -107,7 +103,7 @@ "dependencies": [ "jsr:@std/cli@^1.0.9", "npm:@msgpack/msgpack@3.0.0-beta2", - "npm:@rocket.chat/ui-kit@~0.31.22", + "npm:@rocket.chat/ui-kit@^0.31.22", "npm:acorn-walk@8.2.0", "npm:acorn@8.10.0", "npm:astring@1.8.6", @@ -116,4 +112,4 @@ "npm:uuid@8.3.2" ] } -} +} \ No newline at end of file From dee5d7900e3ff407dd9b625d0aeeb41c7027914f Mon Sep 17 00:00:00 2001 From: yiwei gao Date: Sat, 25 Oct 2025 01:50:42 -0400 Subject: [PATCH 07/14] feat: static & live location sharing (draft) --- .../message/content/location/MapView.tsx | 11 ++++++---- yarn.lock | 21 +------------------ 2 files changed, 8 insertions(+), 24 deletions(-) diff --git a/apps/meteor/client/components/message/content/location/MapView.tsx b/apps/meteor/client/components/message/content/location/MapView.tsx index b9d73523c1ca0..ad9cf65a8419d 100644 --- a/apps/meteor/client/components/message/content/location/MapView.tsx +++ b/apps/meteor/client/components/message/content/location/MapView.tsx @@ -1,3 +1,4 @@ +import { useSetting } from '@rocket.chat/ui-contexts'; import { memo } from 'react'; import MapViewFallback from './MapViewFallback'; @@ -10,12 +11,14 @@ type MapViewProps = { }; const MapView = ({ latitude, longitude }: MapViewProps) => { - const locationIQKey = 'pk.898e468814facdcffda869b42260a2f0'; + const googleMapsApiKey = useSetting('MapView_GMapsAPIKey', ''); - const linkUrl = `https://www.openstreetmap.org/?mlat=${latitude}&mlon=${longitude}#map=16/${latitude}/${longitude}`; + const linkUrl = `https://maps.google.com/maps?daddr=${latitude},${longitude}`; const imageUrl = useAsyncImage( - `https://maps.locationiq.com/v2/staticmap?key=${locationIQKey}¢er=${latitude},${longitude}&zoom=16&size=250x250&markers=icon:small-red-cutout|${latitude},${longitude}`, + googleMapsApiKey + ? `https://maps.googleapis.com/maps/api/staticmap?zoom=14&size=250x250&markers=color:gray%7Clabel:%7C${latitude},${longitude}&key=${googleMapsApiKey}` + : undefined, ); if (!linkUrl) { @@ -29,4 +32,4 @@ const MapView = ({ latitude, longitude }: MapViewProps) => { return ; }; -export default memo(MapView); +export default memo(MapView); \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 44cb49fea37ca..efc150548331a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4886,26 +4886,6 @@ __metadata: languageName: node linkType: hard -"@matrix-org/matrix-sdk-crypto-nodejs@npm:0.3.0-beta.1": - version: 0.3.0-beta.1 - resolution: "@matrix-org/matrix-sdk-crypto-nodejs@npm:0.3.0-beta.1" - dependencies: - https-proxy-agent: "npm:^7.0.5" - node-downloader-helper: "npm:^2.1.9" - checksum: 10/0d82b7a009e6c2a8254e21d9587a4d181bd36a75f5baaa0ef9c30814223701eb60d3ea66c7a53f4bc5ea35653278760c5e822b821afed0d8cd6cd0c310ef3e40 - languageName: node - linkType: hard - -"@matrix-org/matrix-sdk-crypto-nodejs@npm:0.4.0-beta.1": - version: 0.4.0-beta.1 - resolution: "@matrix-org/matrix-sdk-crypto-nodejs@npm:0.4.0-beta.1" - dependencies: - https-proxy-agent: "npm:^7.0.5" - node-downloader-helper: "npm:^2.1.9" - checksum: 10/a1402d18b166cd9fc8122ae40c40f179f1df225dd7c98b8c89ef7a00f94a08256e988ab923d79c2aa44c6dd050792ee4f787ecdbde3c88b276fba96558ae0f50 - languageName: node - linkType: hard - "@mdx-js/react@npm:^3.0.0": version: 3.0.1 resolution: "@mdx-js/react@npm:3.0.1" @@ -9216,6 +9196,7 @@ __metadata: "@rocket.chat/eslint-config": "workspace:^" "@rocket.chat/favicon": "workspace:^" "@rocket.chat/federation-matrix": "workspace:^" + "@rocket.chat/federation-sdk": "npm:0.2.0" "@rocket.chat/freeswitch": "workspace:^" "@rocket.chat/fuselage": "npm:~0.66.4" "@rocket.chat/fuselage-forms": "npm:~0.1.0" From 2ea17fdd083437680f279eccc2376a339da57a83 Mon Sep 17 00:00:00 2001 From: yiwei gao Date: Sat, 25 Oct 2025 01:54:17 -0400 Subject: [PATCH 08/14] feat: static & live location sharing (draft) --- .gitpod.yml | 11 ----------- 1 file changed, 11 deletions(-) delete mode 100644 .gitpod.yml diff --git a/.gitpod.yml b/.gitpod.yml deleted file mode 100644 index 8238b63060d60..0000000000000 --- a/.gitpod.yml +++ /dev/null @@ -1,11 +0,0 @@ -# This configuration file was automatically generated by Gitpod. -# Please adjust to your needs (see https://www.gitpod.io/docs/introduction/learn-gitpod/gitpod-yaml) -# and commit this file to your remote git repository to share the goodness with others. - -# Learn more from ready-to-use templates: https://www.gitpod.io/docs/introduction/getting-started/quickstart - -tasks: - - init: yarn install && yarn run build - command: yarn run dev - - From a994c387022426aae8ae51108a97b6b400dceccd Mon Sep 17 00:00:00 2001 From: yiwei gao Date: Sat, 25 Oct 2025 02:07:28 -0400 Subject: [PATCH 09/14] feat: static & live location sharing (draft) --- apps/meteor/app/api/server/v1/liveLocation.ts | 84 ++++++++++++------- .../views/room/ShareLocation/MapLibreMap.tsx | 4 +- 2 files changed, 58 insertions(+), 30 deletions(-) diff --git a/apps/meteor/app/api/server/v1/liveLocation.ts b/apps/meteor/app/api/server/v1/liveLocation.ts index eb397c278cda6..99480ca19ba99 100644 --- a/apps/meteor/app/api/server/v1/liveLocation.ts +++ b/apps/meteor/app/api/server/v1/liveLocation.ts @@ -7,6 +7,18 @@ import { notifyOnMessageChange } from '../../../lib/server/lib/notifyListener'; import { API } from '../api'; const MIN_INTERVAL_MS = 3000; +const MAX_DURATION_SEC = 3600; // 1 hour, adjust as needed + +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; // Type definitions for API route contexts interface IAPIRouteContext { @@ -62,6 +74,16 @@ API.v1.addRoute( 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'); @@ -169,7 +191,7 @@ API.v1.addRoute( return API.v1.failure('The required "msgId" param is missing or invalid.'); } - if (!coords || typeof coords !== 'object') { + if (!coords || !isValidCoords(coords)) { return API.v1.failure('The required "coords" param is missing or invalid.'); } @@ -211,13 +233,21 @@ API.v1.addRoute( const updateTime = new Date(); const res = await Messages.updateOne( - { _id: msgId }, + { _id: msgId, rid, 'u._id': uid }, { $set: { - 'attachments.0.live.coords': coords, - 'attachments.0.live.lastUpdateAt': updateTime, + '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) { @@ -256,6 +286,10 @@ API.v1.addRoute( 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'); } @@ -266,35 +300,29 @@ API.v1.addRoute( } const selector = { - '_id': msgId, + _id: msgId, rid, 'u._id': uid, - 'attachments': { - $elemMatch: { - 'type': 'live-location', - 'live.isActive': true, - }, - }, }; - - const modifier: { - $set: { - 'attachments.0.live.isActive': boolean; - 'attachments.0.live.stoppedAt': Date; - 'attachments.0.live.coords'?: { lat: number; lon: number }; - }; - } = { - $set: { - 'attachments.0.live.isActive': false, - 'attachments.0.live.stoppedAt': new Date(), - }, + const $set: Record = { + 'attachments.$[liveAtt].live.isActive': false, + 'attachments.$[liveAtt].live.stoppedAt': new Date(), }; - - if (finalCoords) { - modifier.$set['attachments.0.live.coords'] = finalCoords; + if (finalCoords !== undefined) { + $set['attachments.$[liveAtt].live.coords'] = finalCoords; } - - const res = await Messages.updateOne(selector, modifier); + 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) { diff --git a/apps/meteor/client/views/room/ShareLocation/MapLibreMap.tsx b/apps/meteor/client/views/room/ShareLocation/MapLibreMap.tsx index 88c338626dd7e..57a7930fc4b8b 100644 --- a/apps/meteor/client/views/room/ShareLocation/MapLibreMap.tsx +++ b/apps/meteor/client/views/room/ShareLocation/MapLibreMap.tsx @@ -1,6 +1,6 @@ // MapLibreMap.tsx import type { FeatureCollection, LineString } from 'geojson'; -import type { Map } from 'maplibre-gl'; +import type { Map as MLMap } from 'maplibre-gl'; import maplibregl from 'maplibre-gl'; import { useEffect, useRef } from 'react'; @@ -15,7 +15,7 @@ type Props = { export default function MapLibreMap({ lat, lon, zoom = 15, height = 360, liveCoords, visible = true }: Props) { const containerRef = useRef(null); - const mapRef = useRef(null); + const mapRef = useRef(null); const markerRef = useRef(null); const trailRef = useRef(null); const trailCoordsRef = useRef<[number, number][]>([]); From f2168a69a485e0dcf1eacdadce14fe9f94486bc8 Mon Sep 17 00:00:00 2001 From: yiwei gao Date: Sat, 25 Oct 2025 02:21:24 -0400 Subject: [PATCH 10/14] feat: static & live location sharing (draft) --- .../views/room/ShareLocation/ShareLocationModal.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/meteor/client/views/room/ShareLocation/ShareLocationModal.tsx b/apps/meteor/client/views/room/ShareLocation/ShareLocationModal.tsx index e1a437332c243..14b81839a89f2 100644 --- a/apps/meteor/client/views/room/ShareLocation/ShareLocationModal.tsx +++ b/apps/meteor/client/views/room/ShareLocation/ShareLocationModal.tsx @@ -172,18 +172,18 @@ const ShareLocationModal = ({ rid, tmid, onClose }: ShareLocationModalProps): Re ); } - const onConfirmStatic = (): void => { + const onConfirmStatic = async (): Promise => { if (!positionData) return; const { latitude, longitude } = positionData.coords; try { const mapsLink = map.getMapsLink(latitude, longitude); - const locationMessage = `πŸ“ **Location Shared** -πŸ”— **[View on OpenStreetMap](${mapsLink})** + const locationMessage = `πŸ“ **{t('Shared_Location')}** +πŸ”— **[${t('View_on_OpenStreetMap')}](${mapsLink})** πŸ“Œ \`${latitude.toFixed(4)}Β°, ${longitude.toFixed(4)}Β°\``; - void sendMessage({ + await sendMessage({ message: { rid, tmid, From 753cde2bcd3c7ce31220760778135a7dab0a4071 Mon Sep 17 00:00:00 2001 From: yiwei gao Date: Wed, 29 Oct 2025 22:40:58 -0400 Subject: [PATCH 11/14] feat: static & live location sharing (draft) --- apps/meteor/app/api/server/v1/liveLocation.ts | 63 ++++++--- .../views/room/ShareLocation/MapLibreMap.tsx | 32 ++++- .../room/ShareLocation/ShareLocationModal.tsx | 4 +- .../ShareLocation/getGeolocationPosition.ts | 2 +- .../useBrowserLiveLocation.spec.tsx | 4 +- packages/apps-engine/deno-runtime/deno.lock | 120 +++++++++--------- packages/i18n/src/locales/en.i18n.json | 1 + 7 files changed, 138 insertions(+), 88 deletions(-) diff --git a/apps/meteor/app/api/server/v1/liveLocation.ts b/apps/meteor/app/api/server/v1/liveLocation.ts index 99480ca19ba99..ee3ff181724f9 100644 --- a/apps/meteor/app/api/server/v1/liveLocation.ts +++ b/apps/meteor/app/api/server/v1/liveLocation.ts @@ -4,10 +4,12 @@ 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; // 1 hour, adjust as needed +const MAX_DURATION_SEC = 3600; +const DEFAULT_DURATION_SEC = 3600; const isValidCoords = (c?: { lat: number; lon: number }) => c && @@ -20,7 +22,6 @@ const isValidCoords = (c?: { lat: number; lon: number }) => c.lon >= -180 && c.lon <= 180; -// Type definitions for API route contexts interface IAPIRouteContext { bodyParams: { rid?: string; @@ -56,13 +57,11 @@ interface ILiveLocationAttachment { } declare module 'meteor/meteor' { - // eslint-disable-next-line @typescript-eslint/naming-convention interface Meteor { user(): IUser | null; } } -// Start live location sharing API.v1.addRoute( 'liveLocation.start', { authRequired: true }, @@ -106,9 +105,9 @@ API.v1.addRoute( const existing = await Messages.findOne({ rid, 'u._id': uid, - 'attachments': { + attachments: { $elemMatch: { - 'type': 'live-location', + type: 'live-location', 'live.isActive': true, }, }, @@ -119,7 +118,11 @@ API.v1.addRoute( } const now = new Date(); - const expiresAt = durationSec ? new Date(now.getTime() + durationSec * 1000) : undefined; + 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 }, { @@ -168,14 +171,28 @@ API.v1.addRoute( } return API.v1.success({ msgId: result.insertedId }); - } catch (insertError) { + } 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'); } }, }, ); -// Update live location coordinates API.v1.addRoute( 'liveLocation.update', { authRequired: true }, @@ -210,12 +227,12 @@ API.v1.addRoute( } const msg = await Messages.findOne({ - '_id': msgId, + _id: msgId, rid, 'u._id': uid, - 'attachments': { + attachments: { $elemMatch: { - 'type': 'live-location', + type: 'live-location', 'live.isActive': true, }, }, @@ -265,12 +282,11 @@ API.v1.addRoute( }, ); -// Stop live location sharing API.v1.addRoute( 'liveLocation.stop', { authRequired: true }, { - async post(this: IAPIRouteContext) { + async post(this: any) { const { rid, msgId, finalCoords } = this.bodyParams; if (!rid || typeof rid !== 'string') { @@ -304,25 +320,29 @@ API.v1.addRoute( 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, - }, + { + 'liveAtt.type': 'live-location', + 'liveAtt.live.isActive': true, + }, ], } as any, ); + const success = Boolean(res.modifiedCount); if (success) { @@ -332,6 +352,12 @@ API.v1.addRoute( id: updatedMsg._id, data: updatedMsg, }); + + Notifications.streamRoom.emit(`${rid}/live-location-ended`, { + msgId, + ownerId: uid, + stoppedAt: new Date(), + }); } } @@ -340,7 +366,6 @@ API.v1.addRoute( }, ); -// Get live location data API.v1.addRoute( 'liveLocation.get', { authRequired: true }, diff --git a/apps/meteor/client/views/room/ShareLocation/MapLibreMap.tsx b/apps/meteor/client/views/room/ShareLocation/MapLibreMap.tsx index 57a7930fc4b8b..8b8e10fdc1ad5 100644 --- a/apps/meteor/client/views/room/ShareLocation/MapLibreMap.tsx +++ b/apps/meteor/client/views/room/ShareLocation/MapLibreMap.tsx @@ -82,7 +82,10 @@ export default function MapLibreMap({ lat, lon, zoom = 15, height = 360, liveCoo trailRef.current = null; trailCoordsRef.current = []; }; - }, [lat, lon, zoom]); + map.on('error', (e) => { + console.error('MapLibre error:', e); + }); + }, []); useEffect(() => { const map = mapRef.current; @@ -92,18 +95,35 @@ export default function MapLibreMap({ lat, lon, zoom = 15, height = 360, liveCoo }, [lat, lon]); useEffect(() => { - const map = mapRef.current; - if (!map || !visible) return; - const t = setTimeout(() => map.resize(), 50); - return () => clearTimeout(t); - }, [visible]); + 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 = { diff --git a/apps/meteor/client/views/room/ShareLocation/ShareLocationModal.tsx b/apps/meteor/client/views/room/ShareLocation/ShareLocationModal.tsx index 14b81839a89f2..5dbf1cb6b3c55 100644 --- a/apps/meteor/client/views/room/ShareLocation/ShareLocationModal.tsx +++ b/apps/meteor/client/views/room/ShareLocation/ShareLocationModal.tsx @@ -179,7 +179,7 @@ const ShareLocationModal = ({ rid, tmid, onClose }: ShareLocationModalProps): Re try { const mapsLink = map.getMapsLink(latitude, longitude); - const locationMessage = `πŸ“ **{t('Shared_Location')}** + const locationMessage = `πŸ“ **${t('Shared_Location')}** πŸ”— **[${t('View_on_OpenStreetMap')}](${mapsLink})** πŸ“Œ \`${latitude.toFixed(4)}Β°, ${longitude.toFixed(4)}Β°\``; @@ -211,8 +211,6 @@ const ShareLocationModal = ({ rid, tmid, onClose }: ShareLocationModalProps): Re {positionData && ( <> - - {} )} diff --git a/apps/meteor/client/views/room/ShareLocation/getGeolocationPosition.ts b/apps/meteor/client/views/room/ShareLocation/getGeolocationPosition.ts index d68b73a15b1d5..6741a9114a157 100644 --- a/apps/meteor/client/views/room/ShareLocation/getGeolocationPosition.ts +++ b/apps/meteor/client/views/room/ShareLocation/getGeolocationPosition.ts @@ -31,7 +31,7 @@ function toCoarsePosition(src: GeolocationPosition, dp = COARSE_DP): Geolocation heading: c.heading ?? null, speed: c.speed ?? null, } as GeolocationCoordinates, - timestamp: Date.now(), + timestamp: src.timestamp, toJSON() { return { coords: this.coords, diff --git a/apps/meteor/client/views/room/ShareLocation/useBrowserLiveLocation.spec.tsx b/apps/meteor/client/views/room/ShareLocation/useBrowserLiveLocation.spec.tsx index 0d515c073d928..bb87ac75e0a44 100644 --- a/apps/meteor/client/views/room/ShareLocation/useBrowserLiveLocation.spec.tsx +++ b/apps/meteor/client/views/room/ShareLocation/useBrowserLiveLocation.spec.tsx @@ -18,7 +18,9 @@ function Harness({ throttleMs = 10000 }: { throttleMs?: number }) { describe('useBrowserLiveLocation', () => { const originalGeo = global.navigator.geolocation; - jest.useFakeTimers(); + afterEach(() => { + jest.useRealTimers(); + }); let watchSuccess: PositionCallback | null; let _watchError: PositionErrorCallback | null; diff --git a/packages/apps-engine/deno-runtime/deno.lock b/packages/apps-engine/deno-runtime/deno.lock index 92349e3131039..e2a816f68a4a9 100644 --- a/packages/apps-engine/deno-runtime/deno.lock +++ b/packages/apps-engine/deno-runtime/deno.lock @@ -1,62 +1,66 @@ { - "version": "3", - "packages": { - "specifiers": { - "jsr:@std/cli@^1.0.9": "jsr:@std/cli@1.0.13", - "npm:@msgpack/msgpack@3.0.0-beta2": "npm:@msgpack/msgpack@3.0.0-beta2", - "npm:@rocket.chat/ui-kit@^0.31.22": "npm:@rocket.chat/ui-kit@0.31.25_@rocket.chat+icons@0.32.0", - "npm:acorn-walk@8.2.0": "npm:acorn-walk@8.2.0", - "npm:acorn@8.10.0": "npm:acorn@8.10.0", - "npm:astring@1.8.6": "npm:astring@1.8.6", - "npm:jsonrpc-lite@2.2.0": "npm:jsonrpc-lite@2.2.0", - "npm:stack-trace": "npm:stack-trace@0.0.10", - "npm:stack-trace@0.0.10": "npm:stack-trace@0.0.10", - "npm:uuid@8.3.2": "npm:uuid@8.3.2" + "version": "5", + "specifiers": { + "jsr:@std/cli@^1.0.9": "1.0.13", + "npm:@msgpack/msgpack@3.0.0-beta2": "3.0.0-beta2", + "npm:@rocket.chat/ui-kit@~0.31.22": "0.31.25_@rocket.chat+icons@0.32.0", + "npm:@types/node@*": "22.15.15", + "npm:acorn-walk@8.2.0": "8.2.0", + "npm:acorn@8.10.0": "8.10.0", + "npm:astring@1.8.6": "1.8.6", + "npm:jsonrpc-lite@2.2.0": "2.2.0", + "npm:stack-trace@*": "0.0.10", + "npm:stack-trace@0.0.10": "0.0.10", + "npm:uuid@8.3.2": "8.3.2" + }, + "jsr": { + "@std/cli@1.0.13": { + "integrity": "5db2d95ab2dca3bca9fb6ad3c19908c314e93d6391c8b026725e4892d4615a69" + } + }, + "npm": { + "@msgpack/msgpack@3.0.0-beta2": { + "integrity": "sha512-y+l1PNV0XDyY8sM3YtuMLK5vE3/hkfId+Do8pLo/OPxfxuFAUwcGz3oiiUuV46/aBpwTzZ+mRWVMtlSKbradhw==", + "deprecated": true + }, + "@rocket.chat/icons@0.32.0": { + "integrity": "sha512-7yhhELKNLb9kUtXCvau0V+iMXraV2bOsxcPjc/ZtLR5VeeIDTeaflqRWGtLroX6f3bE+J1n5qB5zi8A4YXuH2g==" + }, + "@rocket.chat/ui-kit@0.31.25_@rocket.chat+icons@0.32.0": { + "integrity": "sha512-yTgTKDw9SMlJ6p8n0PDO6zSvox/nHYUrwCIvILQeAK6PvTrgSe/u9CvU7ATTYjnQiQ603yEGR6dxjF4euCGdNA==", + "dependencies": [ + "@rocket.chat/icons" + ] + }, + "@types/node@22.15.15": { + "integrity": "sha512-R5muMcZob3/Jjchn5LcO8jdKwSCbzqmPB6ruBxMcf9kbxtniZHP327s6C37iOfuw8mbKK3cAQa7sEl7afLrQ8A==", + "dependencies": [ + "undici-types" + ] + }, + "acorn-walk@8.2.0": { + "integrity": "sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==" + }, + "acorn@8.10.0": { + "integrity": "sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw==", + "bin": true + }, + "astring@1.8.6": { + "integrity": "sha512-ISvCdHdlTDlH5IpxQJIex7BWBywFWgjJSVdwst+/iQCoEYnyOaQ95+X1JGshuBjGp6nxKUy1jMgE3zPqN7fQdg==", + "bin": true + }, + "jsonrpc-lite@2.2.0": { + "integrity": "sha512-/cbbSxtZWs1O7R4tWqabrCM/t3N8qKUZMAg9IUqpPvUs6UyRvm6pCNYkskyKN/XU0UgffW+NY2ZRr8t0AknX7g==" + }, + "stack-trace@0.0.10": { + "integrity": "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==" }, - "jsr": { - "@std/cli@1.0.13": { - "integrity": "5db2d95ab2dca3bca9fb6ad3c19908c314e93d6391c8b026725e4892d4615a69" - } + "undici-types@6.21.0": { + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==" }, - "npm": { - "@msgpack/msgpack@3.0.0-beta2": { - "integrity": "sha512-y+l1PNV0XDyY8sM3YtuMLK5vE3/hkfId+Do8pLo/OPxfxuFAUwcGz3oiiUuV46/aBpwTzZ+mRWVMtlSKbradhw==", - "dependencies": {} - }, - "@rocket.chat/icons@0.32.0": { - "integrity": "sha512-7yhhELKNLb9kUtXCvau0V+iMXraV2bOsxcPjc/ZtLR5VeeIDTeaflqRWGtLroX6f3bE+J1n5qB5zi8A4YXuH2g==", - "dependencies": {} - }, - "@rocket.chat/ui-kit@0.31.25_@rocket.chat+icons@0.32.0": { - "integrity": "sha512-yTgTKDw9SMlJ6p8n0PDO6zSvox/nHYUrwCIvILQeAK6PvTrgSe/u9CvU7ATTYjnQiQ603yEGR6dxjF4euCGdNA==", - "dependencies": { - "@rocket.chat/icons": "@rocket.chat/icons@0.32.0" - } - }, - "acorn-walk@8.2.0": { - "integrity": "sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==", - "dependencies": {} - }, - "acorn@8.10.0": { - "integrity": "sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw==", - "dependencies": {} - }, - "astring@1.8.6": { - "integrity": "sha512-ISvCdHdlTDlH5IpxQJIex7BWBywFWgjJSVdwst+/iQCoEYnyOaQ95+X1JGshuBjGp6nxKUy1jMgE3zPqN7fQdg==", - "dependencies": {} - }, - "jsonrpc-lite@2.2.0": { - "integrity": "sha512-/cbbSxtZWs1O7R4tWqabrCM/t3N8qKUZMAg9IUqpPvUs6UyRvm6pCNYkskyKN/XU0UgffW+NY2ZRr8t0AknX7g==", - "dependencies": {} - }, - "stack-trace@0.0.10": { - "integrity": "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==", - "dependencies": {} - }, - "uuid@8.3.2": { - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "dependencies": {} - } + "uuid@8.3.2": { + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "bin": true } }, "remote": { @@ -103,7 +107,7 @@ "dependencies": [ "jsr:@std/cli@^1.0.9", "npm:@msgpack/msgpack@3.0.0-beta2", - "npm:@rocket.chat/ui-kit@^0.31.22", + "npm:@rocket.chat/ui-kit@~0.31.22", "npm:acorn-walk@8.2.0", "npm:acorn@8.10.0", "npm:astring@1.8.6", @@ -112,4 +116,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 b193d85a3fc70..26bc572c11d24 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -5690,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", From 350114f3e12c1728c44cd504a42aaabe27426098 Mon Sep 17 00:00:00 2001 From: yiwei gao Date: Wed, 29 Oct 2025 22:44:48 -0400 Subject: [PATCH 12/14] feat: static & live location sharing (draft) --- packages/apps-engine/deno-runtime/deno.lock | 120 ++++++++++---------- 1 file changed, 58 insertions(+), 62 deletions(-) diff --git a/packages/apps-engine/deno-runtime/deno.lock b/packages/apps-engine/deno-runtime/deno.lock index e2a816f68a4a9..92349e3131039 100644 --- a/packages/apps-engine/deno-runtime/deno.lock +++ b/packages/apps-engine/deno-runtime/deno.lock @@ -1,66 +1,62 @@ { - "version": "5", - "specifiers": { - "jsr:@std/cli@^1.0.9": "1.0.13", - "npm:@msgpack/msgpack@3.0.0-beta2": "3.0.0-beta2", - "npm:@rocket.chat/ui-kit@~0.31.22": "0.31.25_@rocket.chat+icons@0.32.0", - "npm:@types/node@*": "22.15.15", - "npm:acorn-walk@8.2.0": "8.2.0", - "npm:acorn@8.10.0": "8.10.0", - "npm:astring@1.8.6": "1.8.6", - "npm:jsonrpc-lite@2.2.0": "2.2.0", - "npm:stack-trace@*": "0.0.10", - "npm:stack-trace@0.0.10": "0.0.10", - "npm:uuid@8.3.2": "8.3.2" - }, - "jsr": { - "@std/cli@1.0.13": { - "integrity": "5db2d95ab2dca3bca9fb6ad3c19908c314e93d6391c8b026725e4892d4615a69" - } - }, - "npm": { - "@msgpack/msgpack@3.0.0-beta2": { - "integrity": "sha512-y+l1PNV0XDyY8sM3YtuMLK5vE3/hkfId+Do8pLo/OPxfxuFAUwcGz3oiiUuV46/aBpwTzZ+mRWVMtlSKbradhw==", - "deprecated": true - }, - "@rocket.chat/icons@0.32.0": { - "integrity": "sha512-7yhhELKNLb9kUtXCvau0V+iMXraV2bOsxcPjc/ZtLR5VeeIDTeaflqRWGtLroX6f3bE+J1n5qB5zi8A4YXuH2g==" - }, - "@rocket.chat/ui-kit@0.31.25_@rocket.chat+icons@0.32.0": { - "integrity": "sha512-yTgTKDw9SMlJ6p8n0PDO6zSvox/nHYUrwCIvILQeAK6PvTrgSe/u9CvU7ATTYjnQiQ603yEGR6dxjF4euCGdNA==", - "dependencies": [ - "@rocket.chat/icons" - ] - }, - "@types/node@22.15.15": { - "integrity": "sha512-R5muMcZob3/Jjchn5LcO8jdKwSCbzqmPB6ruBxMcf9kbxtniZHP327s6C37iOfuw8mbKK3cAQa7sEl7afLrQ8A==", - "dependencies": [ - "undici-types" - ] - }, - "acorn-walk@8.2.0": { - "integrity": "sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==" - }, - "acorn@8.10.0": { - "integrity": "sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw==", - "bin": true - }, - "astring@1.8.6": { - "integrity": "sha512-ISvCdHdlTDlH5IpxQJIex7BWBywFWgjJSVdwst+/iQCoEYnyOaQ95+X1JGshuBjGp6nxKUy1jMgE3zPqN7fQdg==", - "bin": true - }, - "jsonrpc-lite@2.2.0": { - "integrity": "sha512-/cbbSxtZWs1O7R4tWqabrCM/t3N8qKUZMAg9IUqpPvUs6UyRvm6pCNYkskyKN/XU0UgffW+NY2ZRr8t0AknX7g==" - }, - "stack-trace@0.0.10": { - "integrity": "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==" + "version": "3", + "packages": { + "specifiers": { + "jsr:@std/cli@^1.0.9": "jsr:@std/cli@1.0.13", + "npm:@msgpack/msgpack@3.0.0-beta2": "npm:@msgpack/msgpack@3.0.0-beta2", + "npm:@rocket.chat/ui-kit@^0.31.22": "npm:@rocket.chat/ui-kit@0.31.25_@rocket.chat+icons@0.32.0", + "npm:acorn-walk@8.2.0": "npm:acorn-walk@8.2.0", + "npm:acorn@8.10.0": "npm:acorn@8.10.0", + "npm:astring@1.8.6": "npm:astring@1.8.6", + "npm:jsonrpc-lite@2.2.0": "npm:jsonrpc-lite@2.2.0", + "npm:stack-trace": "npm:stack-trace@0.0.10", + "npm:stack-trace@0.0.10": "npm:stack-trace@0.0.10", + "npm:uuid@8.3.2": "npm:uuid@8.3.2" }, - "undici-types@6.21.0": { - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==" + "jsr": { + "@std/cli@1.0.13": { + "integrity": "5db2d95ab2dca3bca9fb6ad3c19908c314e93d6391c8b026725e4892d4615a69" + } }, - "uuid@8.3.2": { - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "bin": true + "npm": { + "@msgpack/msgpack@3.0.0-beta2": { + "integrity": "sha512-y+l1PNV0XDyY8sM3YtuMLK5vE3/hkfId+Do8pLo/OPxfxuFAUwcGz3oiiUuV46/aBpwTzZ+mRWVMtlSKbradhw==", + "dependencies": {} + }, + "@rocket.chat/icons@0.32.0": { + "integrity": "sha512-7yhhELKNLb9kUtXCvau0V+iMXraV2bOsxcPjc/ZtLR5VeeIDTeaflqRWGtLroX6f3bE+J1n5qB5zi8A4YXuH2g==", + "dependencies": {} + }, + "@rocket.chat/ui-kit@0.31.25_@rocket.chat+icons@0.32.0": { + "integrity": "sha512-yTgTKDw9SMlJ6p8n0PDO6zSvox/nHYUrwCIvILQeAK6PvTrgSe/u9CvU7ATTYjnQiQ603yEGR6dxjF4euCGdNA==", + "dependencies": { + "@rocket.chat/icons": "@rocket.chat/icons@0.32.0" + } + }, + "acorn-walk@8.2.0": { + "integrity": "sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==", + "dependencies": {} + }, + "acorn@8.10.0": { + "integrity": "sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw==", + "dependencies": {} + }, + "astring@1.8.6": { + "integrity": "sha512-ISvCdHdlTDlH5IpxQJIex7BWBywFWgjJSVdwst+/iQCoEYnyOaQ95+X1JGshuBjGp6nxKUy1jMgE3zPqN7fQdg==", + "dependencies": {} + }, + "jsonrpc-lite@2.2.0": { + "integrity": "sha512-/cbbSxtZWs1O7R4tWqabrCM/t3N8qKUZMAg9IUqpPvUs6UyRvm6pCNYkskyKN/XU0UgffW+NY2ZRr8t0AknX7g==", + "dependencies": {} + }, + "stack-trace@0.0.10": { + "integrity": "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==", + "dependencies": {} + }, + "uuid@8.3.2": { + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "dependencies": {} + } } }, "remote": { @@ -107,7 +103,7 @@ "dependencies": [ "jsr:@std/cli@^1.0.9", "npm:@msgpack/msgpack@3.0.0-beta2", - "npm:@rocket.chat/ui-kit@~0.31.22", + "npm:@rocket.chat/ui-kit@^0.31.22", "npm:acorn-walk@8.2.0", "npm:acorn@8.10.0", "npm:astring@1.8.6", @@ -116,4 +112,4 @@ "npm:uuid@8.3.2" ] } -} +} \ No newline at end of file From 0e43ce397a3f125dd747dda6d78c9d83b9a2e640 Mon Sep 17 00:00:00 2001 From: yiwei gao Date: Wed, 29 Oct 2025 23:44:34 -0400 Subject: [PATCH 13/14] feat: static & live location sharing (draft) --- apps/meteor/app/api/server/v1/liveLocation.ts | 44 +++---- .../message/content/location/MapView.tsx | 2 +- .../views/room/ShareLocation/MapLibreMap.tsx | 44 +++---- .../useBrowserLiveLocation.spec.tsx | 4 +- packages/apps-engine/deno-runtime/deno.lock | 120 +++++++++--------- packages/models/src/models/Messages.ts | 45 ++++--- 6 files changed, 132 insertions(+), 127 deletions(-) diff --git a/apps/meteor/app/api/server/v1/liveLocation.ts b/apps/meteor/app/api/server/v1/liveLocation.ts index ee3ff181724f9..2ae54c7c81ce9 100644 --- a/apps/meteor/app/api/server/v1/liveLocation.ts +++ b/apps/meteor/app/api/server/v1/liveLocation.ts @@ -56,12 +56,6 @@ interface ILiveLocationAttachment { }; } -declare module 'meteor/meteor' { - interface Meteor { - user(): IUser | null; - } -} - API.v1.addRoute( 'liveLocation.start', { authRequired: true }, @@ -105,9 +99,9 @@ API.v1.addRoute( const existing = await Messages.findOne({ rid, 'u._id': uid, - attachments: { + 'attachments': { $elemMatch: { - type: 'live-location', + 'type': 'live-location', 'live.isActive': true, }, }, @@ -176,9 +170,9 @@ API.v1.addRoute( const alreadyActive = await Messages.findOne({ rid, 'u._id': uid, - attachments: { + 'attachments': { $elemMatch: { - type: 'live-location', + 'type': 'live-location', 'live.isActive': true, }, }, @@ -227,12 +221,12 @@ API.v1.addRoute( } const msg = await Messages.findOne({ - _id: msgId, + '_id': msgId, rid, 'u._id': uid, - attachments: { + 'attachments': { $elemMatch: { - type: 'live-location', + 'type': 'live-location', 'live.isActive': true, }, }, @@ -250,7 +244,7 @@ API.v1.addRoute( const updateTime = new Date(); const res = await Messages.updateOne( - { _id: msgId, rid, 'u._id': uid }, + { '_id': msgId, rid, 'u._id': uid }, { $set: { 'attachments.$[liveAtt].live.coords': coords, @@ -316,7 +310,7 @@ API.v1.addRoute( } const selector = { - _id: msgId, + '_id': msgId, rid, 'u._id': uid, }; @@ -330,18 +324,14 @@ API.v1.addRoute( $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 res = await Messages.updateOne(selector, { $set }, { + arrayFilters: [ + { + 'liveAtt.type': 'live-location', + 'liveAtt.live.isActive': true, + }, + ], + } as any); const success = Boolean(res.modifiedCount); diff --git a/apps/meteor/client/components/message/content/location/MapView.tsx b/apps/meteor/client/components/message/content/location/MapView.tsx index ad9cf65a8419d..0337b36195f28 100644 --- a/apps/meteor/client/components/message/content/location/MapView.tsx +++ b/apps/meteor/client/components/message/content/location/MapView.tsx @@ -32,4 +32,4 @@ const MapView = ({ latitude, longitude }: MapViewProps) => { return ; }; -export default memo(MapView); \ No newline at end of file +export default memo(MapView); diff --git a/apps/meteor/client/views/room/ShareLocation/MapLibreMap.tsx b/apps/meteor/client/views/room/ShareLocation/MapLibreMap.tsx index 8b8e10fdc1ad5..2a4e9e9dad26f 100644 --- a/apps/meteor/client/views/room/ShareLocation/MapLibreMap.tsx +++ b/apps/meteor/client/views/room/ShareLocation/MapLibreMap.tsx @@ -49,6 +49,10 @@ export default function MapLibreMap({ lat, lon, zoom = 15, height = 360, liveCoo 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', @@ -82,11 +86,7 @@ export default function MapLibreMap({ lat, lon, zoom = 15, height = 360, liveCoo trailRef.current = null; trailCoordsRef.current = []; }; - map.on('error', (e) => { - console.error('MapLibre error:', e); - }); }, []); - useEffect(() => { const map = mapRef.current; if (!map) return; @@ -95,24 +95,24 @@ export default function MapLibreMap({ lat, lon, zoom = 15, height = 360, liveCoo }, [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]); + 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; diff --git a/apps/meteor/client/views/room/ShareLocation/useBrowserLiveLocation.spec.tsx b/apps/meteor/client/views/room/ShareLocation/useBrowserLiveLocation.spec.tsx index bb87ac75e0a44..2fad93e5a638d 100644 --- a/apps/meteor/client/views/room/ShareLocation/useBrowserLiveLocation.spec.tsx +++ b/apps/meteor/client/views/room/ShareLocation/useBrowserLiveLocation.spec.tsx @@ -19,8 +19,8 @@ function Harness({ throttleMs = 10000 }: { throttleMs?: number }) { describe('useBrowserLiveLocation', () => { const originalGeo = global.navigator.geolocation; afterEach(() => { - jest.useRealTimers(); - }); + jest.useRealTimers(); + }); let watchSuccess: PositionCallback | null; let _watchError: PositionErrorCallback | null; diff --git a/packages/apps-engine/deno-runtime/deno.lock b/packages/apps-engine/deno-runtime/deno.lock index 92349e3131039..e2a816f68a4a9 100644 --- a/packages/apps-engine/deno-runtime/deno.lock +++ b/packages/apps-engine/deno-runtime/deno.lock @@ -1,62 +1,66 @@ { - "version": "3", - "packages": { - "specifiers": { - "jsr:@std/cli@^1.0.9": "jsr:@std/cli@1.0.13", - "npm:@msgpack/msgpack@3.0.0-beta2": "npm:@msgpack/msgpack@3.0.0-beta2", - "npm:@rocket.chat/ui-kit@^0.31.22": "npm:@rocket.chat/ui-kit@0.31.25_@rocket.chat+icons@0.32.0", - "npm:acorn-walk@8.2.0": "npm:acorn-walk@8.2.0", - "npm:acorn@8.10.0": "npm:acorn@8.10.0", - "npm:astring@1.8.6": "npm:astring@1.8.6", - "npm:jsonrpc-lite@2.2.0": "npm:jsonrpc-lite@2.2.0", - "npm:stack-trace": "npm:stack-trace@0.0.10", - "npm:stack-trace@0.0.10": "npm:stack-trace@0.0.10", - "npm:uuid@8.3.2": "npm:uuid@8.3.2" + "version": "5", + "specifiers": { + "jsr:@std/cli@^1.0.9": "1.0.13", + "npm:@msgpack/msgpack@3.0.0-beta2": "3.0.0-beta2", + "npm:@rocket.chat/ui-kit@~0.31.22": "0.31.25_@rocket.chat+icons@0.32.0", + "npm:@types/node@*": "22.15.15", + "npm:acorn-walk@8.2.0": "8.2.0", + "npm:acorn@8.10.0": "8.10.0", + "npm:astring@1.8.6": "1.8.6", + "npm:jsonrpc-lite@2.2.0": "2.2.0", + "npm:stack-trace@*": "0.0.10", + "npm:stack-trace@0.0.10": "0.0.10", + "npm:uuid@8.3.2": "8.3.2" + }, + "jsr": { + "@std/cli@1.0.13": { + "integrity": "5db2d95ab2dca3bca9fb6ad3c19908c314e93d6391c8b026725e4892d4615a69" + } + }, + "npm": { + "@msgpack/msgpack@3.0.0-beta2": { + "integrity": "sha512-y+l1PNV0XDyY8sM3YtuMLK5vE3/hkfId+Do8pLo/OPxfxuFAUwcGz3oiiUuV46/aBpwTzZ+mRWVMtlSKbradhw==", + "deprecated": true + }, + "@rocket.chat/icons@0.32.0": { + "integrity": "sha512-7yhhELKNLb9kUtXCvau0V+iMXraV2bOsxcPjc/ZtLR5VeeIDTeaflqRWGtLroX6f3bE+J1n5qB5zi8A4YXuH2g==" + }, + "@rocket.chat/ui-kit@0.31.25_@rocket.chat+icons@0.32.0": { + "integrity": "sha512-yTgTKDw9SMlJ6p8n0PDO6zSvox/nHYUrwCIvILQeAK6PvTrgSe/u9CvU7ATTYjnQiQ603yEGR6dxjF4euCGdNA==", + "dependencies": [ + "@rocket.chat/icons" + ] + }, + "@types/node@22.15.15": { + "integrity": "sha512-R5muMcZob3/Jjchn5LcO8jdKwSCbzqmPB6ruBxMcf9kbxtniZHP327s6C37iOfuw8mbKK3cAQa7sEl7afLrQ8A==", + "dependencies": [ + "undici-types" + ] + }, + "acorn-walk@8.2.0": { + "integrity": "sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==" + }, + "acorn@8.10.0": { + "integrity": "sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw==", + "bin": true + }, + "astring@1.8.6": { + "integrity": "sha512-ISvCdHdlTDlH5IpxQJIex7BWBywFWgjJSVdwst+/iQCoEYnyOaQ95+X1JGshuBjGp6nxKUy1jMgE3zPqN7fQdg==", + "bin": true + }, + "jsonrpc-lite@2.2.0": { + "integrity": "sha512-/cbbSxtZWs1O7R4tWqabrCM/t3N8qKUZMAg9IUqpPvUs6UyRvm6pCNYkskyKN/XU0UgffW+NY2ZRr8t0AknX7g==" + }, + "stack-trace@0.0.10": { + "integrity": "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==" }, - "jsr": { - "@std/cli@1.0.13": { - "integrity": "5db2d95ab2dca3bca9fb6ad3c19908c314e93d6391c8b026725e4892d4615a69" - } + "undici-types@6.21.0": { + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==" }, - "npm": { - "@msgpack/msgpack@3.0.0-beta2": { - "integrity": "sha512-y+l1PNV0XDyY8sM3YtuMLK5vE3/hkfId+Do8pLo/OPxfxuFAUwcGz3oiiUuV46/aBpwTzZ+mRWVMtlSKbradhw==", - "dependencies": {} - }, - "@rocket.chat/icons@0.32.0": { - "integrity": "sha512-7yhhELKNLb9kUtXCvau0V+iMXraV2bOsxcPjc/ZtLR5VeeIDTeaflqRWGtLroX6f3bE+J1n5qB5zi8A4YXuH2g==", - "dependencies": {} - }, - "@rocket.chat/ui-kit@0.31.25_@rocket.chat+icons@0.32.0": { - "integrity": "sha512-yTgTKDw9SMlJ6p8n0PDO6zSvox/nHYUrwCIvILQeAK6PvTrgSe/u9CvU7ATTYjnQiQ603yEGR6dxjF4euCGdNA==", - "dependencies": { - "@rocket.chat/icons": "@rocket.chat/icons@0.32.0" - } - }, - "acorn-walk@8.2.0": { - "integrity": "sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==", - "dependencies": {} - }, - "acorn@8.10.0": { - "integrity": "sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw==", - "dependencies": {} - }, - "astring@1.8.6": { - "integrity": "sha512-ISvCdHdlTDlH5IpxQJIex7BWBywFWgjJSVdwst+/iQCoEYnyOaQ95+X1JGshuBjGp6nxKUy1jMgE3zPqN7fQdg==", - "dependencies": {} - }, - "jsonrpc-lite@2.2.0": { - "integrity": "sha512-/cbbSxtZWs1O7R4tWqabrCM/t3N8qKUZMAg9IUqpPvUs6UyRvm6pCNYkskyKN/XU0UgffW+NY2ZRr8t0AknX7g==", - "dependencies": {} - }, - "stack-trace@0.0.10": { - "integrity": "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==", - "dependencies": {} - }, - "uuid@8.3.2": { - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "dependencies": {} - } + "uuid@8.3.2": { + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "bin": true } }, "remote": { @@ -103,7 +107,7 @@ "dependencies": [ "jsr:@std/cli@^1.0.9", "npm:@msgpack/msgpack@3.0.0-beta2", - "npm:@rocket.chat/ui-kit@^0.31.22", + "npm:@rocket.chat/ui-kit@~0.31.22", "npm:acorn-walk@8.2.0", "npm:acorn@8.10.0", "npm:astring@1.8.6", @@ -112,4 +116,4 @@ "npm:uuid@8.3.2" ] } -} \ No newline at end of file +} diff --git a/packages/models/src/models/Messages.ts b/packages/models/src/models/Messages.ts index f11aeee1bdd2f..2f1cba7e60838 100644 --- a/packages/models/src/models/Messages.ts +++ b/packages/models/src/models/Messages.ts @@ -63,25 +63,36 @@ export class MessagesRaw extends BaseRaw implements IMessagesModel { { key: { 'pinnedBy._id': 1 }, sparse: true }, { key: { 'starred._id': 1 }, sparse: true }, - // 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: { 'attachments.0.live.expiresAt': 1 }, - expireAfterSeconds: 0, - partialFilterExpression: { - 'attachments.0.type': 'live-location', - 'attachments.0.live.expiresAt': { $type: 'date' }, + // 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: 'liveLocation_expiresAt_TTL', - }, - { - key: { 'attachments.0.live.lastUpdateAt': 1 }, - partialFilterExpression: { 'attachments.0.type': 'live-location' }, - name: 'liveLocation_lastUpdateAt_idx', }, - - // discussions + 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 From 808fd1c88a09b94ea40337bd0c64519ed067a681 Mon Sep 17 00:00:00 2001 From: yiwei gao Date: Wed, 29 Oct 2025 23:55:35 -0400 Subject: [PATCH 14/14] feat: static & live location sharing (draft) --- packages/apps-engine/deno-runtime/deno.lock | 120 ++++++++++---------- 1 file changed, 58 insertions(+), 62 deletions(-) diff --git a/packages/apps-engine/deno-runtime/deno.lock b/packages/apps-engine/deno-runtime/deno.lock index e2a816f68a4a9..92349e3131039 100644 --- a/packages/apps-engine/deno-runtime/deno.lock +++ b/packages/apps-engine/deno-runtime/deno.lock @@ -1,66 +1,62 @@ { - "version": "5", - "specifiers": { - "jsr:@std/cli@^1.0.9": "1.0.13", - "npm:@msgpack/msgpack@3.0.0-beta2": "3.0.0-beta2", - "npm:@rocket.chat/ui-kit@~0.31.22": "0.31.25_@rocket.chat+icons@0.32.0", - "npm:@types/node@*": "22.15.15", - "npm:acorn-walk@8.2.0": "8.2.0", - "npm:acorn@8.10.0": "8.10.0", - "npm:astring@1.8.6": "1.8.6", - "npm:jsonrpc-lite@2.2.0": "2.2.0", - "npm:stack-trace@*": "0.0.10", - "npm:stack-trace@0.0.10": "0.0.10", - "npm:uuid@8.3.2": "8.3.2" - }, - "jsr": { - "@std/cli@1.0.13": { - "integrity": "5db2d95ab2dca3bca9fb6ad3c19908c314e93d6391c8b026725e4892d4615a69" - } - }, - "npm": { - "@msgpack/msgpack@3.0.0-beta2": { - "integrity": "sha512-y+l1PNV0XDyY8sM3YtuMLK5vE3/hkfId+Do8pLo/OPxfxuFAUwcGz3oiiUuV46/aBpwTzZ+mRWVMtlSKbradhw==", - "deprecated": true - }, - "@rocket.chat/icons@0.32.0": { - "integrity": "sha512-7yhhELKNLb9kUtXCvau0V+iMXraV2bOsxcPjc/ZtLR5VeeIDTeaflqRWGtLroX6f3bE+J1n5qB5zi8A4YXuH2g==" - }, - "@rocket.chat/ui-kit@0.31.25_@rocket.chat+icons@0.32.0": { - "integrity": "sha512-yTgTKDw9SMlJ6p8n0PDO6zSvox/nHYUrwCIvILQeAK6PvTrgSe/u9CvU7ATTYjnQiQ603yEGR6dxjF4euCGdNA==", - "dependencies": [ - "@rocket.chat/icons" - ] - }, - "@types/node@22.15.15": { - "integrity": "sha512-R5muMcZob3/Jjchn5LcO8jdKwSCbzqmPB6ruBxMcf9kbxtniZHP327s6C37iOfuw8mbKK3cAQa7sEl7afLrQ8A==", - "dependencies": [ - "undici-types" - ] - }, - "acorn-walk@8.2.0": { - "integrity": "sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==" - }, - "acorn@8.10.0": { - "integrity": "sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw==", - "bin": true - }, - "astring@1.8.6": { - "integrity": "sha512-ISvCdHdlTDlH5IpxQJIex7BWBywFWgjJSVdwst+/iQCoEYnyOaQ95+X1JGshuBjGp6nxKUy1jMgE3zPqN7fQdg==", - "bin": true - }, - "jsonrpc-lite@2.2.0": { - "integrity": "sha512-/cbbSxtZWs1O7R4tWqabrCM/t3N8qKUZMAg9IUqpPvUs6UyRvm6pCNYkskyKN/XU0UgffW+NY2ZRr8t0AknX7g==" - }, - "stack-trace@0.0.10": { - "integrity": "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==" + "version": "3", + "packages": { + "specifiers": { + "jsr:@std/cli@^1.0.9": "jsr:@std/cli@1.0.13", + "npm:@msgpack/msgpack@3.0.0-beta2": "npm:@msgpack/msgpack@3.0.0-beta2", + "npm:@rocket.chat/ui-kit@^0.31.22": "npm:@rocket.chat/ui-kit@0.31.25_@rocket.chat+icons@0.32.0", + "npm:acorn-walk@8.2.0": "npm:acorn-walk@8.2.0", + "npm:acorn@8.10.0": "npm:acorn@8.10.0", + "npm:astring@1.8.6": "npm:astring@1.8.6", + "npm:jsonrpc-lite@2.2.0": "npm:jsonrpc-lite@2.2.0", + "npm:stack-trace": "npm:stack-trace@0.0.10", + "npm:stack-trace@0.0.10": "npm:stack-trace@0.0.10", + "npm:uuid@8.3.2": "npm:uuid@8.3.2" }, - "undici-types@6.21.0": { - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==" + "jsr": { + "@std/cli@1.0.13": { + "integrity": "5db2d95ab2dca3bca9fb6ad3c19908c314e93d6391c8b026725e4892d4615a69" + } }, - "uuid@8.3.2": { - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "bin": true + "npm": { + "@msgpack/msgpack@3.0.0-beta2": { + "integrity": "sha512-y+l1PNV0XDyY8sM3YtuMLK5vE3/hkfId+Do8pLo/OPxfxuFAUwcGz3oiiUuV46/aBpwTzZ+mRWVMtlSKbradhw==", + "dependencies": {} + }, + "@rocket.chat/icons@0.32.0": { + "integrity": "sha512-7yhhELKNLb9kUtXCvau0V+iMXraV2bOsxcPjc/ZtLR5VeeIDTeaflqRWGtLroX6f3bE+J1n5qB5zi8A4YXuH2g==", + "dependencies": {} + }, + "@rocket.chat/ui-kit@0.31.25_@rocket.chat+icons@0.32.0": { + "integrity": "sha512-yTgTKDw9SMlJ6p8n0PDO6zSvox/nHYUrwCIvILQeAK6PvTrgSe/u9CvU7ATTYjnQiQ603yEGR6dxjF4euCGdNA==", + "dependencies": { + "@rocket.chat/icons": "@rocket.chat/icons@0.32.0" + } + }, + "acorn-walk@8.2.0": { + "integrity": "sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==", + "dependencies": {} + }, + "acorn@8.10.0": { + "integrity": "sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw==", + "dependencies": {} + }, + "astring@1.8.6": { + "integrity": "sha512-ISvCdHdlTDlH5IpxQJIex7BWBywFWgjJSVdwst+/iQCoEYnyOaQ95+X1JGshuBjGp6nxKUy1jMgE3zPqN7fQdg==", + "dependencies": {} + }, + "jsonrpc-lite@2.2.0": { + "integrity": "sha512-/cbbSxtZWs1O7R4tWqabrCM/t3N8qKUZMAg9IUqpPvUs6UyRvm6pCNYkskyKN/XU0UgffW+NY2ZRr8t0AknX7g==", + "dependencies": {} + }, + "stack-trace@0.0.10": { + "integrity": "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==", + "dependencies": {} + }, + "uuid@8.3.2": { + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "dependencies": {} + } } }, "remote": { @@ -107,7 +103,7 @@ "dependencies": [ "jsr:@std/cli@^1.0.9", "npm:@msgpack/msgpack@3.0.0-beta2", - "npm:@rocket.chat/ui-kit@~0.31.22", + "npm:@rocket.chat/ui-kit@^0.31.22", "npm:acorn-walk@8.2.0", "npm:acorn@8.10.0", "npm:astring@1.8.6", @@ -116,4 +112,4 @@ "npm:uuid@8.3.2" ] } -} +} \ No newline at end of file