diff --git a/map/.env.development b/map/.env.development index 0db2498407..0a53779e35 100644 --- a/map/.env.development +++ b/map/.env.development @@ -8,3 +8,4 @@ REACT_APP_WEBSITE_NAME='Welcome to Local OsmAnd.' ; REACT_APP_OSM_GPX_URL='https://test.osmand.net' REACT_APP_MAX_APPROXIMATE_KM='100' REACT_APP_DEVEL_FEATURES='yes' +REACT_APP_WS_URL='ws://localhost:8080/osmand-websocket' diff --git a/map/.env.production b/map/.env.production index a75b7e4866..cfde84a531 100644 --- a/map/.env.production +++ b/map/.env.production @@ -8,3 +8,4 @@ REACT_APP_GPX_API='https://maptile.osmand.net' REACT_APP_OSM_GPX_URL='https://test.osmand.net' REACT_APP_MAX_APPROXIMATE_KM='100' REACT_APP_DEVEL_FEATURES='no' +REACT_APP_WS_URL='wss://osmand.net/osmand-websocket' diff --git a/map/.env.staging b/map/.env.staging index c0d1938ba1..0b2d3ddea7 100644 --- a/map/.env.staging +++ b/map/.env.staging @@ -7,3 +7,4 @@ REACT_APP_USER_API_SITE= REACT_APP_GPX_API= REACT_APP_MAX_APPROXIMATE_KM='100' REACT_APP_DEVEL_FEATURES='yes' +REACT_APP_WS_URL='wss://test.osmand.net/osmand-websocket' diff --git a/map/package.json b/map/package.json index 0a58446162..b445aeba3e 100644 --- a/map/package.json +++ b/map/package.json @@ -10,6 +10,7 @@ "@hello-pangea/dnd": "^15.0.0", "@mui/icons-material": "^5.8.3", "@mui/material": "^5.8.3", + "@stomp/stompjs": "^7.3.0", "@tiptap/pm": "^3.22.4", "@tiptap/react": "^3.22.4", "@tiptap/starter-kit": "^3.22.4", @@ -57,7 +58,7 @@ "scripts": { "start": "yarn generate-resources && react-scripts start", "start:mobile": "yarn generate-resources && HOST=0.0.0.0 react-scripts start", - "start:local": "yarn generate-resources && USE_LOCAL_API=yes react-scripts start", + "start:local": "yarn generate-resources && USE_LOCAL_API=yes REACT_APP_WS_URL=ws://localhost:8080/osmand-websocket react-scripts start", "start:fallback": "yarn generate-resources && USE_MAIN_API=yes react-scripts start", "build": "yarn generate-resources && react-scripts build", "build:staging": "yarn generate-resources && env-cmd -f .env.staging react-scripts build", diff --git a/map/src/App.js b/map/src/App.js index f3c133f0a5..867f17b778 100644 --- a/map/src/App.js +++ b/map/src/App.js @@ -5,6 +5,7 @@ import GlobalFrame from './frame/GlobalFrame'; import { AppContextProvider } from './context/AppContext'; import { WeatherContextProvider } from './context/WeatherContext'; import { MapContextProvider } from './context/MapContext'; +import { LiveTrackingProvider } from './context/LiveTrackingContext'; import DeleteAccountDialog from './login/dialogs/DeleteAccountDialog'; import { AppServices } from './services/AppServices'; import './variables.css'; @@ -20,6 +21,7 @@ import { PLANROUTE_URL, SETTINGS_URL, TRACKS_URL, + LIVE_TRACKS_URL, VISIBLE_TRACKS_URL, WEATHER_URL, EXPLORE_URL, @@ -114,11 +116,11 @@ const App = () => { { path: '/', element: ( - <> + - + ), children: [ { @@ -167,6 +169,7 @@ const App = () => { }, ], }, + { path: LIVE_TRACKS_URL, element: }, { path: VISIBLE_TRACKS_URL, element: }, { path: FAVORITES_URL, diff --git a/map/src/assets/icons/ic_action_folder_location.svg b/map/src/assets/icons/ic_action_folder_location.svg new file mode 100644 index 0000000000..7e9b6f2de4 --- /dev/null +++ b/map/src/assets/icons/ic_action_folder_location.svg @@ -0,0 +1,3 @@ + + + diff --git a/map/src/context/AppContext.js b/map/src/context/AppContext.js index 7e42ba04bf..40fcc2579d 100644 --- a/map/src/context/AppContext.js +++ b/map/src/context/AppContext.js @@ -187,6 +187,7 @@ export const AppContextProvider = (props) => { const [fitBoundsShareTracks, setFitBoundsShareTracks] = useState(null); const [smartFoldersCache, setSmartFoldersCache] = useState(null); + // selected track const [selectedGpxFile, setSelectedGpxFile] = useState({}); const [unverifiedGpxFile, setUnverifiedGpxFile] = useState(null); // see Effect in LocalClientTrackLayer diff --git a/map/src/context/LiveTrackingContext.js b/map/src/context/LiveTrackingContext.js new file mode 100644 index 0000000000..aa4f576cdc --- /dev/null +++ b/map/src/context/LiveTrackingContext.js @@ -0,0 +1,109 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { useLocation, useSearchParams } from 'react-router-dom'; +import { LIVE_TRACKS_URL, MAIN_URL_WITH_SLASH } from '../manager/GlobalManager'; +import { + KEY_HEX_RE, + LIVE_TRACK_KEY_SESSION, + LIVE_TRACKS_STORAGE_KEY, + NAME_PARAM, + TID_PARAM, +} from '../util/livetracks/liveTrackUtils'; +import useLiveTracking from '../util/hooks/live/useLiveTracking'; + +const LiveTrackingContext = React.createContext(); + +export const LiveTrackingProvider = ({ children }) => { + const location = useLocation(); + const [searchParams] = useSearchParams(); + + const [liveTranslations, setLiveTranslations] = useState(() => { + try { + return JSON.parse(localStorage.getItem(LIVE_TRACKS_STORAGE_KEY)) ?? []; + } catch { + return []; + } + }); + const [liveParticipants, setLiveParticipants] = useState({}); + const [liveViewers, setLiveViewers] = useState({}); + // Pending share-permission requests shown to the owner as map notifications. + const [liveShareRequests, setLiveShareRequests] = useState([]); // [{ translationId, userId, nickname }] + // approve/deny callbacks published over websocket; wired by useLiveTracking. + const [liveShareActions, setLiveShareActions] = useState(null); + const [selectedLiveTranslation, setSelectedLiveTranslation] = useState(null); + const [followLiveLocation, setFollowLiveLocation] = useState(null); + const [myBroadcastTids, setMyBroadcastTids] = useState([]); + + const livePath = MAIN_URL_WITH_SLASH + LIVE_TRACKS_URL; + const openLiveTracks = location.pathname.startsWith(livePath); + + // Sync the selected translation from the URL (?tid=...#), restoring the AES key the map + // stashed in sessionStorage before leaflet-hash cleared the fragment. + useEffect(() => { + if (!openLiveTracks) { + setSelectedLiveTranslation(null); + return; + } + const tid = searchParams.get(TID_PARAM); + if (!tid) { + setSelectedLiveTranslation(null); + return; + } + let key = null; + try { + const saved = sessionStorage.getItem(LIVE_TRACK_KEY_SESSION); + if (saved && KEY_HEX_RE.test(saved)) { + key = saved; + sessionStorage.removeItem(LIVE_TRACK_KEY_SESSION); + } + } catch {} + + const fromList = liveTranslations.find((t) => t.id === tid); + if (fromList) { + const entry = key && !fromList.key ? { ...fromList, key } : fromList; + setSelectedLiveTranslation(entry); + } else { + const name = searchParams.get(NAME_PARAM) ?? ''; + setSelectedLiveTranslation({ id: tid, name, ...(key ? { key } : {}) }); + } + }, [openLiveTracks, location.search]); + + const liveState = useMemo( + () => ({ + liveTranslations, + setLiveTranslations, + liveParticipants, + setLiveParticipants, + liveViewers, + setLiveViewers, + liveShareRequests, + setLiveShareRequests, + liveShareActions, + setLiveShareActions, + selectedLiveTranslation, + setSelectedLiveTranslation, + followLiveLocation, + setFollowLiveLocation, + myBroadcastTids, + setMyBroadcastTids, + }), + [ + liveTranslations, + liveParticipants, + liveViewers, + liveShareRequests, + liveShareActions, + selectedLiveTranslation, + followLiveLocation, + myBroadcastTids, + ] + ); + + // Hold the WebSocket only when live tracks are in use: viewing the page, broadcasting, or having bookmarked translations + const enabled = openLiveTracks || myBroadcastTids.length > 0 || liveTranslations.length > 0; + const api = useLiveTracking(liveState, enabled); + const value = useMemo(() => ({ ...liveState, ...api, openLiveTracks }), [liveState, api, openLiveTracks]); + + return {children}; +}; + +export default LiveTrackingContext; diff --git a/map/src/frame/GlobalFrame.js b/map/src/frame/GlobalFrame.js index aff1b44464..5945d9228e 100644 --- a/map/src/frame/GlobalFrame.js +++ b/map/src/frame/GlobalFrame.js @@ -22,6 +22,7 @@ import { } from '../manager/GlobalManager'; import { useWindowSize } from '../util/hooks/useWindowSize'; import GlobalAlert from './components/GlobalAlert'; +import LiveShareRequests from './components/LiveShareRequests'; import DialogTitle from '@mui/material/DialogTitle'; import dialogStyles from '../dialogs/dialog.module.css'; import DialogContent from '@mui/material/DialogContent'; @@ -418,6 +419,7 @@ const GlobalFrame = () => { {ctx.globalGraph?.show && } + + lttx.liveTranslations?.find((tr) => tr.id === translationId)?.name ?? translationId; + + return ( +
+ {requests.map((req) => ( + + + lttx.liveShareActions?.approve(req.translationId, req.userId)} + > + + + + + lttx.liveShareActions?.deny(req.translationId, req.userId)} + > + + + + + } + > + {t('web:live_track_share_request', { + nickname: req.nickname, + track: trackName(req.translationId), + })} + + ))} +
+ ); +} diff --git a/map/src/frame/components/liveShareRequests.module.css b/map/src/frame/components/liveShareRequests.module.css new file mode 100644 index 0000000000..874c3568cb --- /dev/null +++ b/map/src/frame/components/liveShareRequests.module.css @@ -0,0 +1,19 @@ +.container { + position: fixed; + top: 68px; /* HEADER_SIZE (60) + 8 */ + right: 16px; + z-index: 1400; + display: flex; + flex-direction: column; + gap: 8px; + max-width: 360px; +} +.approveIcon { + width: 18px; + height: 18px; +} +.denyIcon { + width: 18px; + height: 18px; + fill: #ff595e !important; +} diff --git a/map/src/frame/components/titles/SubTitleMenu.jsx b/map/src/frame/components/titles/SubTitleMenu.jsx index c30c76df1e..13fd152101 100644 --- a/map/src/frame/components/titles/SubTitleMenu.jsx +++ b/map/src/frame/components/titles/SubTitleMenu.jsx @@ -2,12 +2,13 @@ import { MenuItem, Typography } from '@mui/material'; import React from 'react'; import styles from './titles.module.css'; -export default function SubTitleMenu({ text }) { +export default function SubTitleMenu({ text, rightContent }) { return ( - - + + {text} + {rightContent} ); } diff --git a/map/src/index.js b/map/src/index.js index 1bc041ab74..aca882b491 100644 --- a/map/src/index.js +++ b/map/src/index.js @@ -26,3 +26,9 @@ root.render(); // to log results (for example: reportWebVitals(console.log)) // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals reportWebVitals(); + +if (process.env.NODE_ENV === 'development') { + import('./test/liveTrackSimulator').then((sim) => { + globalThis.__liveTrackSim = sim; + }); +} diff --git a/map/src/manager/GlobalManager.js b/map/src/manager/GlobalManager.js index 84dc841a7f..1a93586b49 100644 --- a/map/src/manager/GlobalManager.js +++ b/map/src/manager/GlobalManager.js @@ -47,6 +47,7 @@ export const WEATHER_URL = 'weather/'; export const WEATHER_FORECAST_URL = 'forecast/'; export const TRACKS_URL = 'mydata/tracks/'; +export const LIVE_TRACKS_URL = 'live/'; export const VISIBLE_TRACKS_URL = 'visible-tracks/'; export const FAVORITES_URL = 'mydata/favorites/'; diff --git a/map/src/map/OsmAndMap.jsx b/map/src/map/OsmAndMap.jsx index ea03c58d30..0f63e15799 100644 --- a/map/src/map/OsmAndMap.jsx +++ b/map/src/map/OsmAndMap.jsx @@ -25,13 +25,19 @@ import HeightmapLayer from './layers/HeightmapLayer'; import TravelLayer from './layers/TravelLayer'; import ShareFileLayer from './layers/ShareFileLayer'; import TrackAnalyzerLayer from './layers/TrackAnalyzerLayer'; +import LiveTrackLayer from './layers/LiveTrackLayer'; import { Box } from '@mui/material'; import TransportStopsLayer from './layers/TransportStopsLayer'; +import { extractAndSaveLiveTrackKey } from '../util/livetracks/liveTrackUtils'; function getInitialViewFromHash() { const hash = window.location.hash; if (!hash || hash.length < 2) return null; - const [zoomStr, latStr, lngStr] = hash.slice(1).split('/'); + const raw = hash.slice(1); + + if (extractAndSaveLiveTrackKey(raw)) return null; + + const [zoomStr, latStr, lngStr] = raw.split('/'); const zoom = Number.parseInt(zoomStr, 10); const lat = Number.parseFloat(latStr); const lng = Number.parseFloat(lngStr); @@ -224,6 +230,7 @@ const OsmAndMap = ({ mainMenuWidth, menuInfoWidth }) => { {routersReady && } + diff --git a/map/src/map/layers/LiveTrackLayer.js b/map/src/map/layers/LiveTrackLayer.js new file mode 100644 index 0000000000..8897c43a32 --- /dev/null +++ b/map/src/map/layers/LiveTrackLayer.js @@ -0,0 +1,174 @@ +import { useContext, useEffect, useRef, useState } from 'react'; +import { useMap } from 'react-leaflet'; +import L from 'leaflet'; +import AppContext from '../../context/AppContext'; +import LiveTrackingContext from '../../context/LiveTrackingContext'; +import { panToVisibleCenter } from './MapStateLayer'; + +export default function LiveTrackLayer() { + const ctx = useContext(AppContext); + const lttx = useContext(LiveTrackingContext); + const map = useMap(); + + // { [translationId]: { [nickname]: { polyline, marker } } } + const layersRef = useRef({}); + const [pannedFor, setPannedFor] = useState(null); + + useEffect(() => { + const selectedTid = lttx.selectedLiveTranslation?.id ?? null; + + // Remove layers for any translation that is not currently selected + Object.keys(layersRef.current).forEach((tid) => { + if (tid !== selectedTid) removeTidLayers(map, layersRef, tid); + }); + + if (!selectedTid) return; + + const byNickname = lttx.liveParticipants?.[selectedTid]; + if (!byNickname) return; + + if (!layersRef.current[selectedTid]) layersRef.current[selectedTid] = {}; + + const activeNicks = new Set(Object.keys(byNickname)); + + // Remove layers for participants no longer present in selected translation + Object.keys(layersRef.current[selectedTid]).forEach((nick) => { + if (!activeNicks.has(nick)) { + const { polyline, marker } = layersRef.current[selectedTid][nick]; + if (polyline) map.removeLayer(polyline); + if (marker) map.removeLayer(marker); + delete layersRef.current[selectedTid][nick]; + } + }); + + Object.values(byNickname).forEach((participant) => { + const { nickname, color, locations } = participant; + if (!locations || locations.length === 0) return; + + const lastLoc = locations[0]; + const newestTime = locations[0]?.time; + const oldestTime = locations[locations.length - 1]?.time; + + const existing = layersRef.current[selectedTid][nickname]; + + if (existing) { + if ( + existing.len === locations.length && + existing.newestTime === newestTime && + existing.oldestTime === oldestTime + ) { + return; + } + const oneNewPoint = + locations.length === existing.len + 1 && + locations[1]?.time === existing.newestTime && + existing.oldestTime === oldestTime; + if (oneNewPoint) { + existing.polyline.addLatLng([lastLoc.lat, lastLoc.lon]); + } else { + existing.polyline.setLatLngs(buildLatLngs(locations)); + } + existing.marker.setLatLng([lastLoc.lat, lastLoc.lon]); + existing.len = locations.length; + existing.newestTime = newestTime; + existing.oldestTime = oldestTime; + } else { + const polyline = L.polyline(buildLatLngs(locations), { color, weight: 4, opacity: 0.85 }).addTo(map); + const iconHtml = `
`; + const icon = L.divIcon({ html: iconHtml, className: '', iconSize: [14, 14], iconAnchor: [7, 7] }); + const marker = L.marker([lastLoc.lat, lastLoc.lon], { icon }).addTo(map); + const tooltipNode = document.createElement('span'); + tooltipNode.textContent = nickname; + marker.bindTooltip(tooltipNode, { permanent: false, direction: 'top', offset: [0, -10] }); + layersRef.current[selectedTid][nickname] = { + polyline, + marker, + len: locations.length, + newestTime, + oldestTime, + }; + } + }); + }, [lttx.liveParticipants, lttx.selectedLiveTranslation]); + + // Center map when a translation is selected (if data already loaded) + useEffect(() => { + const translation = lttx.selectedLiveTranslation; + if (!translation) { + setPannedFor(null); + return; + } + if (pannedFor === translation.id) return; + const panned = panToTranslation(map, lttx.liveParticipants, translation.id, ctx.infoBlockWidth); + if (panned) setPannedFor(translation.id); + }, [lttx.selectedLiveTranslation]); + + // Center map when data arrives for the selected translation (if not panned yet) + useEffect(() => { + const translation = lttx.selectedLiveTranslation; + if (!translation) return; + if (pannedFor === translation.id) return; + const panned = panToTranslation(map, lttx.liveParticipants, translation.id, ctx.infoBlockWidth); + if (panned) setPannedFor(translation.id); + }, [lttx.liveParticipants]); + + // Pan to location when Follow button is clicked in context menu. + useEffect(() => { + if (!lttx.followLiveLocation) return; + const infoBlockWidthPx = Number.parseInt(String(ctx.infoBlockWidth), 10); + panToVisibleCenter(map, lttx.followLiveLocation, infoBlockWidthPx); + lttx.setFollowLiveLocation(null); + }, [lttx.followLiveLocation]); + + // Cleanup on unmount + useEffect(() => { + return () => removeAllLayers(map, layersRef); + }, []); + + return null; +} + +// locations are newest-first; the polyline wants oldest-first +function buildLatLngs(locations) { + const latLngs = new Array(locations.length); + for (let i = 0; i < locations.length; i++) { + const l = locations[locations.length - 1 - i]; + latLngs[i] = [l.lat, l.lon]; + } + + return latLngs; +} + +function removeTidLayers(map, layersRef, tid) { + if (!layersRef.current[tid]) return; + Object.values(layersRef.current[tid]).forEach(({ polyline, marker }) => { + if (polyline) map.removeLayer(polyline); + if (marker) map.removeLayer(marker); + }); + delete layersRef.current[tid]; +} + +function removeAllLayers(map, layersRef) { + Object.keys(layersRef.current).forEach((tid) => removeTidLayers(map, layersRef, tid)); +} + +function panToTranslation(map, liveParticipants, translationId, infoBlockWidth) { + const participants = liveParticipants?.[translationId]; + if (!participants) return false; + + const locs = Object.values(participants) + .map((p) => p.locations?.[0]) + .filter(Boolean); + + if (locs.length === 0) return false; + + const infoBlockWidthPx = Number.parseInt(String(infoBlockWidth), 10); + if (locs.length === 1) { + panToVisibleCenter(map, locs[0], infoBlockWidthPx); + } else { + const bounds = L.latLngBounds(locs.map((l) => [l.lat, l.lon])); + map.fitBounds(bounds, { padding: [40, 40] }); + } + + return true; +} diff --git a/map/src/menu/MainMenu.js b/map/src/menu/MainMenu.js index f0f6e561d2..7ae344c4dd 100644 --- a/map/src/menu/MainMenu.js +++ b/map/src/menu/MainMenu.js @@ -69,6 +69,7 @@ import { PLANROUTE_URL, SETTINGS_URL, TRACKS_URL, + LIVE_TRACKS_URL, VISIBLE_TRACKS_URL, WEATHER_URL, TRAVEL_URL, @@ -371,8 +372,14 @@ export default function MainMenu({ } }, [ctx.selectedSort]); + function matchItemByUrl(pathname) { + return items.find( + (item) => pathname.startsWith(item.url) || item.otherUrls?.some((u) => pathname.startsWith(u)) + ); + } + function selectMenuByUrl() { - const item = items.find((item) => location.pathname.startsWith(item.url)); + const item = matchItemByUrl(location.pathname); if (item) { ctx.setInfoBlockWidth(MENU_INFO_OPEN_SIZE + 'px'); return selectMenu({ item, openFromUrl: true }); @@ -390,7 +397,7 @@ export default function MainMenu({ if (ctx.selectedSearchObj) { return; } - const matchedItem = items.find((item) => location.pathname.startsWith(item.url)); + const matchedItem = matchItemByUrl(location.pathname); if (matchedItem && !isSelectedMenuItem(matchedItem)) { setMenuInfo(matchedItem.component); setSelectedType(matchedItem.type); @@ -446,6 +453,7 @@ export default function MainMenu({ show: true, id: MENU_IDS.tracks, url: MAIN_URL_WITH_SLASH + TRACKS_URL, + otherUrls: [MAIN_URL_WITH_SLASH + LIVE_TRACKS_URL], }, { name: t('shared_string_my_favorites'), @@ -886,7 +894,7 @@ export default function MainMenu({ function navigateToUrl({ menu = null, isMain = false, params = null }) { if (menu) { - const isSubroute = location.pathname.startsWith(menu.url) && location.pathname !== menu.url; + const isSubroute = matchItemByUrl(location.pathname) === menu && location.pathname !== menu.url; if (isSubroute) { return; } diff --git a/map/src/menu/actions/LiveTrackItemActions.jsx b/map/src/menu/actions/LiveTrackItemActions.jsx new file mode 100644 index 0000000000..2491187d81 --- /dev/null +++ b/map/src/menu/actions/LiveTrackItemActions.jsx @@ -0,0 +1,140 @@ +import React, { forwardRef } from 'react'; +import { Box, ListItemIcon, ListItemText, MenuItem, Paper, Typography } from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import { ReactComponent as DeleteIcon } from '../../assets/icons/ic_action_delete_outlined.svg'; +import { ReactComponent as RemoveIcon } from '../../assets/icons/ic_action_remove_outlined.svg'; +import { ReactComponent as LocationOffIcon } from '../../assets/icons/ic_action_location_off.svg'; +import { ReactComponent as LocationOnIcon } from '../../assets/icons/ic_action_my_location.svg'; +import { ReactComponent as ShareLinkIcon } from '../../assets/icons/ic_action_link.svg'; +import { ReactComponent as RegenerateIcon } from '../../assets/icons/ic_action_update.svg'; +import styles from '../trackfavmenu.module.css'; + +const LiveTrackItemActions = forwardRef( + ( + { + isOwner, + isSharing, + isParticipant, + handleOwnerSharingAction, + handleParticipantStop, + handleRemoveBookmark, + handleDeleteForAll, + handleCopyShareLink, + handleRegenerate, + hasShareLink, + }, + ref + ) => { + const { t } = useTranslation(); + const ownerSharingLabel = isSharing ? 'web:live_track_pause_sharing' : 'web:live_track_start_sharing'; + const ownerSharingIcon = isSharing ? : ; + + return ( + + + {/* Owner: always show sharing control (start / pause / resume) */} + {isOwner && ( + + {ownerSharingIcon} + + + {t(ownerSharingLabel)} + + + + )} + {/* Participant (non-owner): stop sharing own location */} + {isParticipant && ( + + + + + + + {t('web:live_track_stop_sharing')} + + + + )} + {/* Owner: copy share link (only if key is available) */} + {isOwner && hasShareLink && ( + + + + + + + {t('web:live_track_copy_share_link')} + + + + )} + {/* Owner: regenerate the key — new permanent link, old one revoked */} + {isOwner && ( + + + + + + + {t('web:live_track_regenerate_link')} + + + + )} + {/* Everyone: remove from bookmarks */} + + + + + + + {t('web:live_track_remove_bookmark')} + + + + {/* Owner only: delete translation for all */} + {isOwner && ( + + + + + + + {t('web:live_track_delete_for_all')} + + + + )} + + + ); + } +); +LiveTrackItemActions.displayName = 'LiveTrackItemActions'; + +export default LiveTrackItemActions; diff --git a/map/src/menu/analyzer/util/SegmentColorizer.js b/map/src/menu/analyzer/util/SegmentColorizer.js index b42ec7a595..ac6d2d5379 100644 --- a/map/src/menu/analyzer/util/SegmentColorizer.js +++ b/map/src/menu/analyzer/util/SegmentColorizer.js @@ -7,7 +7,7 @@ * @param {number} total - The total number of items (used for generating HSL colors). * @returns {string} A color in HEX or HSL format. */ -function getColorByIndex(index, total) { +export function getColorByIndex(index, total) { const basePalette = [ '#FF0000', // Red '#00FF00', // Green diff --git a/map/src/menu/trackfavmenu.module.css b/map/src/menu/trackfavmenu.module.css index 857103a5cb..09e1f920ad 100644 --- a/map/src/menu/trackfavmenu.module.css +++ b/map/src/menu/trackfavmenu.module.css @@ -148,10 +148,56 @@ .container { position: relative; } +.participantNickname { + display: inline-flex; + align-items: center; + gap: 8px; +} +.participantStatusDot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} +.participantCard { + border: 1px solid #e0e0e0; + border-radius: 12px; + margin: 0 8px 8px; + overflow: hidden; +} +.participantCardName { + display: inline-flex; + align-items: center; + gap: 8px; + flex: 1; + min-width: 0; +} +.liveTrackDialogContent { + display: flex; + flex-direction: column; + gap: 16px; + width: 300px; + min-height: 160px; + padding-top: 8px; + overflow: visible; +} +.liveTrackSliderBox { + padding: 0 8px; +} +.liveTrackList { + overflow-x: hidden; + overflow-y: auto; +} +.liveTrackDurationLabel { + color: var(--text-secondary) !important; + font-size: 14px !important; + margin-bottom: 4px !important; +} .menuButtonContainer { position: absolute; top: 50%; transform: translateY(-50%); - right: 78px; + margin-left: 20px; } diff --git a/map/src/menu/tracks/TracksMenu.jsx b/map/src/menu/tracks/TracksMenu.jsx index e3a58e9422..cf40c51c29 100644 --- a/map/src/menu/tracks/TracksMenu.jsx +++ b/map/src/menu/tracks/TracksMenu.jsx @@ -19,8 +19,13 @@ import SharedFolder from '../components/SharedFolder'; import LoginContext from '../../context/LoginContext'; import { SHARE_TYPE } from '../share/shareConstants'; import TrackGroupFolder from './TrackGroupFolder'; -import { MAIN_URL_WITH_SLASH, MENU_IDS, VISIBLE_TRACKS_URL, liveHash } from '../../manager/GlobalManager'; -import { useLocation, useNavigate } from 'react-router-dom'; +import LiveTrackGroup from './liveTrack/LiveTrackGroup'; +import LiveTrackFolder from './liveTrack/LiveTrackFolder'; +import LiveTrackContextMenu from './liveTrack/LiveTrackContextMenu'; +import LiveTrackingContext from '../../context/LiveTrackingContext'; +import { TID_PARAM } from '../../util/livetracks/liveTrackUtils'; +import { LOGIN_URL, MAIN_URL_WITH_SLASH, MENU_IDS, VISIBLE_TRACKS_URL, liveHash } from '../../manager/GlobalManager'; +import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'; export const DEFAULT_SORT_METHOD = 'time'; @@ -36,11 +41,14 @@ export default function TracksMenu() { const navigate = useNavigate(); const location = useLocation(); + const [searchParams] = useSearchParams(); const [, height] = useWindowSize(); const { t } = useTranslation(); + const { openLiveTracks, selectedLiveTranslation } = useContext(LiveTrackingContext); + const checkHasFiles = () => ctx.tracksGroups?.length > 0 || defaultGroup?.length > 0 || !isEmpty(ctx.shareWithMeFiles?.tracks); @@ -97,10 +105,28 @@ export default function TracksMenu() { } }, [defaultGroup?.groupFiles]); + const needLiveLogin = openLiveTracks && !ltx.loginUser && !searchParams.get(TID_PARAM); + useEffect(() => { + if (needLiveLogin) { + navigate(MAIN_URL_WITH_SLASH + LOGIN_URL + location.search + location.hash, { replace: true }); + } + }, [needLiveLogin, navigate, location.search, location.hash]); + if (openVisibleTracks) { return ; } + if (needLiveLogin) { + return null; + } + + if (openLiveTracks) { + if (selectedLiveTranslation) { + return ; + } + return ; + } + // open folders if (ctx.openGroups && ctx.openGroups.length > 0) { const lastGroup = ctx.openGroups[ctx.openGroups.length - 1]; @@ -158,6 +184,7 @@ export default function TracksMenu() {
+ {!isEmpty(ctx.shareWithMeFiles?.tracks) && ( )} diff --git a/map/src/menu/tracks/liveTrack/CreateLiveTrackDialog.jsx b/map/src/menu/tracks/liveTrack/CreateLiveTrackDialog.jsx new file mode 100644 index 0000000000..b3c2d2012f --- /dev/null +++ b/map/src/menu/tracks/liveTrack/CreateLiveTrackDialog.jsx @@ -0,0 +1,217 @@ +import React, { useContext, useState } from 'react'; +import { + Alert, + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + IconButton, + InputAdornment, + LinearProgress, + Slider, + TextField, + Typography, +} from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router-dom'; +import { ReactComponent as CopyIcon } from '../../../assets/icons/ic_action_copy.svg'; +import { LIVE_TRACKS_URL, MAIN_URL_WITH_SLASH } from '../../../manager/GlobalManager'; +import { generateTranslationKey, computeTranslationId } from '../../../util/livetracks/liveTrackCrypto'; +import { + buildLiveTrackShareUrl, + GEO_ERROR_DENIED, + GEO_ERROR_UNAVAILABLE, + NAME_PARAM, + TID_PARAM, +} from '../../../util/livetracks/liveTrackUtils'; +import LiveTrackingContext from '../../../context/LiveTrackingContext'; +import dialogStyles from '../../../dialogs/dialog.module.css'; +import styles from '../../trackfavmenu.module.css'; + +const DURATION_MARKS = [{ value: 1 }, { value: 4 }, { value: 8 }, { value: 24 }]; + +function durationLabel(value, t) { + if (value === 1) return t('web:live_track_duration_1h'); + if (value === 4) return t('web:live_track_duration_4h'); + if (value === 8) return t('web:live_track_duration_8h'); + + return t('web:live_track_duration_24h'); +} + +export default function CreateLiveTrackDialog({ open, onClose }) { + const { createLiveTrack } = useContext(LiveTrackingContext); + const { t } = useTranslation(); + const navigate = useNavigate(); + + const [name, setName] = useState(''); + const [duration, setDuration] = useState(1); + const [shareUrl, setShareUrl] = useState(null); + const [creating, setCreating] = useState(false); + const [copied, setCopied] = useState(false); + const [geoError, setGeoError] = useState(null); + const [createError, setCreateError] = useState(null); + + function clearGeoError() { + setGeoError(null); + } + + function clearCreateError() { + setCreateError(null); + } + + async function handleCreate() { + setGeoError(null); + setCreateError(null); + + // Check geolocation permission before creating the translation. + if (!navigator.geolocation) { + setGeoError('web:live_track_geo_not_supported'); + return; + } + if (navigator.permissions) { + try { + const status = await navigator.permissions.query({ name: 'geolocation' }); + if (status.state === 'denied') { + setGeoError('web:live_track_geo_denied'); + return; + } + } catch {} + } + + setCreating(true); + + let key, translationId; + try { + key = await generateTranslationKey(); + translationId = await computeTranslationId(key); + } catch (_) { + setCreateError(t('web:live_track_key_gen_error')); + setCreating(false); + return; + } + + createLiveTrack( + translationId, + key, + name.trim() || null, + duration, + (translation) => { + setShareUrl(buildLiveTrackShareUrl(translation)); + setCreating(false); + navigate( + `${MAIN_URL_WITH_SLASH}${LIVE_TRACKS_URL}?${TID_PARAM}=${translation.id}&${NAME_PARAM}=${encodeURIComponent(translation.name)}` + ); + }, + (errCode) => { + setGeoError(toGeoErrorKey(errCode)); + setCreating(false); + }, + (err) => { + setCreateError(typeof err === 'string' && err ? err : t('web:live_track_create_error')); + setCreating(false); + } + ); + } + + function handleCopy() { + if (!shareUrl) return; + navigator.clipboard + .writeText(shareUrl) + .then(() => setCopied(true)) + .catch(() => {}); + } + + function handleClose() { + setName(''); + setDuration(1); + setShareUrl(null); + setCreating(false); + setCopied(false); + setGeoError(null); + setCreateError(null); + onClose(); + } + + return ( + + {creating && } + {t('web:live_track_create')} + + {shareUrl ? ( + + + + + + ), + }, + }} + /> + ) : ( +
+ {geoError && ( + + {t(geoError)} + + )} + {createError && ( + + {createError} + + )} + setName(e.target.value)} + /> +
+ + {durationLabel(duration, t)} + + setDuration(v)} + min={1} + max={24} + step={null} + marks={DURATION_MARKS} + size="small" + valueLabelDisplay="off" + sx={{ color: '#237BFF' }} + /> +
+
+ )} +
+ + + {!shareUrl && ( + + )} + +
+ ); +} + +function toGeoErrorKey(code) { + if (code === GEO_ERROR_DENIED) return 'web:live_track_geo_denied'; + if (code === GEO_ERROR_UNAVAILABLE) return 'web:live_track_geo_unavailable'; + return 'web:live_track_geo_not_supported'; +} diff --git a/map/src/menu/tracks/liveTrack/LiveTrackContextMenu.jsx b/map/src/menu/tracks/liveTrack/LiveTrackContextMenu.jsx new file mode 100644 index 0000000000..ff4b255b47 --- /dev/null +++ b/map/src/menu/tracks/liveTrack/LiveTrackContextMenu.jsx @@ -0,0 +1,523 @@ +import React, { useContext, useEffect, useMemo, useRef, useState } from 'react'; +import { Box, CircularProgress, Collapse, Icon, IconButton, ListItemText, MenuItem, Tooltip } from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router-dom'; +import AppContext from '../../../context/AppContext'; +import LiveTrackingContext from '../../../context/LiveTrackingContext'; +import LoginContext from '../../../context/LoginContext'; +import { HEADER_SIZE, LIVE_TRACKS_URL, MAIN_URL_WITH_SLASH } from '../../../manager/GlobalManager'; +import { buildLiveTrackShareUrl } from '../../../util/livetracks/liveTrackUtils'; +import { computeZones, ZONE_COLORS } from '../../../util/livetracks/liveTrackZones'; +import { + convertMeters, + convertSpeedMS, + getLargeLengthUnit, + getSmallLengthUnit, + getSpeedUnit, + LARGE_UNIT, +} from '../../settings/units/UnitsConverter'; +import { ReactComponent as ShareLinkIcon } from '../../../assets/icons/ic_action_link.svg'; +import { useWindowSize } from '../../../util/hooks/useWindowSize'; +import { getDistance, toHHMMSS } from '../../../util/Utils'; +import HeaderNoUnderline from '../../../frame/components/header/HeaderNoUnderline'; +import SubTitleMenu from '../../../frame/components/titles/SubTitleMenu'; +import DefaultItem from '../../../frame/components/items/DefaultItem'; +import ThickDivider from '../../../frame/components/dividers/ThickDivider'; +import DividerWithMargin from '../../../frame/components/dividers/DividerWithMargin'; +import { ReactComponent as SpeedIcon } from '../../../assets/icons/ic_action_speed.svg'; +import { ReactComponent as SpeedMaxIcon } from '../../../assets/icons/ic_action_speed_max.svg'; +import { ReactComponent as TimeIcon } from '../../../assets/icons/ic_action_time.svg'; +import { ReactComponent as TerrainIcon } from '../../../assets/icons/ic_action_terrain.svg'; +import { ReactComponent as RouteIcon } from '../../../assets/icons/ic_action_route_direct.svg'; +import { ReactComponent as GroupIcon } from '../../../assets/icons/ic_group.svg'; +import { ReactComponent as LocationOffIcon } from '../../../assets/icons/ic_action_location_off.svg'; +import { ReactComponent as AltitudeIcon } from '../../../assets/icons/ic_action_altitude_average.svg'; +import { ReactComponent as AscentIcon } from '../../../assets/icons/ic_action_altitude_ascent_16.svg'; +import { ReactComponent as DescentIcon } from '../../../assets/icons/ic_action_altitude_descent_16.svg'; +import { ReactComponent as FollowIcon } from '../../../assets/icons/ic_action_my_location.svg'; +import { ReactComponent as FolderAddIcon } from '../../../assets/icons/ic_action_folder_add_outlined.svg'; +import { ReactComponent as DirectionIcon } from '../../../assets/icons/ic_direction_arrow_16.svg'; +import { ReactComponent as DestinationIcon } from '../../../assets/icons/ic_action_point_destination.svg'; +import { ReactComponent as BatteryIcon } from '../../../assets/icons/ic_action_info.svg'; +import { ReactComponent as AccuracyIcon } from '../../../assets/icons/ic_action_coordinates_location.svg'; +import trackFavStyles from '../../trackfavmenu.module.css'; +import gStyles from '../../gstylesmenu.module.css'; +import errorStyles from '../../errors/errors.module.css'; + +// Zones (RDP over the full elevation profile) are O(N): during update bursts (history merge, +// backfill) reuse the last result if it is fresher than the normal live point interval. +const ZONES_MIN_RECOMPUTE_MS = 2000; + +export default function LiveTrackContextMenu() { + const lttx = useContext(LiveTrackingContext); + const { addLiveTrack, loadEarlier, loadingEarlier, historyExhausted, requestShare } = lttx; + const ltx = useContext(LoginContext); + + const { t } = useTranslation(); + const navigate = useNavigate(); + + const [, height] = useWindowSize(); + const [linkCopied, setLinkCopied] = useState(false); + const [requestSent, setRequestSent] = useState(false); + + const translation = lttx.selectedLiveTranslation; + const participants = translation ? (lttx.liveParticipants?.[translation.id] ?? {}) : {}; + // Order: owner first, then my own card (if I share here), then the rest. + const participantRank = (p) => (p.owner ? 0 : p.mine ? 1 : 2); + const participantList = Object.values(participants) + .filter((p) => p.locations?.length > 0) + .sort((a, b) => participantRank(a) - participantRank(b)); + const viewers = translation ? (lttx.liveViewers?.[translation.id] ?? {}) : {}; + const viewerCount = Object.keys(viewers).length; + + function handleBack() { + lttx.setSelectedLiveTranslation(null); + navigate(MAIN_URL_WITH_SLASH + LIVE_TRACKS_URL); + } + + function handleCopyShareLink() { + const url = buildLiveTrackShareUrl(translation); + if (!url) return; + navigator.clipboard + .writeText(url) + .then(() => setLinkCopied(true)) + .catch(() => {}); + } + + function handleRequestShare() { + requestShare(translation.id); + setRequestSent(true); + } + + const canRequestShare = + ltx.loginUser && + translation && + !translation.isOwner && + !!translation.key && + !lttx.myBroadcastTids.includes(translation.id); + + return ( + + + {loadEarlier && participantList.length > 0 && ( + + + loadEarlier(translation.id)} + disabled={ + !!historyExhausted?.[translation.id] || !!loadingEarlier?.[translation.id] + } + > + {loadingEarlier?.[translation.id] ? ( + + ) : ( + + )} + + + + )} + {translation?.isOwner && translation?.key && ( + setLinkCopied(false)} + > + + + + + )} + {canRequestShare && ( + setRequestSent(false)} + > + + + + + + + )} + {(!ltx.loginUser || !lttx.liveTranslations.some((t) => t.id === translation?.id)) && ( + + + addLiveTrack(translation.id, translation.name, translation.key)} + disabled={!ltx.loginUser} + > + + + + + )} + + } + /> + {participantList.length > 0 && ( + <> + } + name={t('web:live_track_viewers')} + additionalInfo={String(viewerCount)} + /> + + )} + + {participantList.length === 0 ? ( + + + + + + + {t('web:live_track_location_paused_title')} + + + {t('web:live_track_location_paused_desc')} + + + + ) : ( + participantList.map((p) => ( + + )) + )} + + + ); +} + +function LiveParticipantCard({ participant, defaultExpanded = true }) { + const ctx = useContext(AppContext); + const lttx = useContext(LiveTrackingContext); + const { t } = useTranslation(); + + const [expanded, setExpanded] = useState(defaultExpanded); + + const statsCacheRef = useRef(null); // locs, totalDistM, maxSpeedMS + const zonesCacheRef = useRef(null); // time, zones, elevGainM, elevLossM + + useEffect(() => { + if (defaultExpanded) { + setExpanded(true); + } + }, [defaultExpanded]); + + const locs = participant.locations; + + const { totalDistM, maxSpeedMS, zonesData } = useMemo( + () => computeParticipantStats(locs, statsCacheRef.current, zonesCacheRef.current), + [locs] + ); + const { zones, elevGainM, elevLossM } = zonesData; + + useEffect(() => { + statsCacheRef.current = { locs, totalDistM, maxSpeedMS }; + zonesCacheRef.current = zonesData; + }, [locs, totalDistM, maxSpeedMS, zonesData]); + + const duration = Date.now() - participant.startTime; + + const smallUnit = t(getSmallLengthUnit(ctx)); + const largeUnit = t(getLargeLengthUnit(ctx)); + const speedUnit = t(getSpeedUnit(ctx)); + + const fmtSpeed = (ms) => (convertSpeedMS(ms, ctx.unitsSettings.speed) ?? 0).toFixed(1); + const fmtLarge = (m) => (convertMeters(m, ctx.unitsSettings.len, LARGE_UNIT) ?? 0).toFixed(2); + const fmtSmall = (m) => Math.round(convertMeters(m, ctx.unitsSettings.len) ?? 0); + + const lastEleM = locs[0]?.ele; + const altitude = lastEleM != null ? `${fmtSmall(lastEleM)} ${smallUnit}` : '—'; + + function zoneTypeLabel(type) { + if (type === 'UPHILL') return t('shared_string_uphill'); + if (type === 'DOWNHILL') return t('shared_string_downhill'); + return t('web:shared_string_flat'); + } + + const lastLoc = locs[0]; + // Optional fields sent by the mobile broadcaster (absent for web broadcasts). + const bearingDeg = lastLoc?.bearing; + const accuracyM = lastLoc?.acc; // web broadcaster: GPS accuracy radius (m) + const hdop = lastLoc?.hdop; // mobile broadcaster: horizontal dilution of precision (unitless) + const battery = lastLoc?.battery; + const timeToArrival = lastLoc?.tta; + const timeToIntermediate = lastLoc?.ttf; + const distToArrival = lastLoc?.dta; + const distToIntermediate = lastLoc?.dtf; + + function handleFollow() { + if (lastLoc?.lat != null && lastLoc?.lon != null) { + lttx.setFollowLiveLocation(lastLoc); + } + } + + return ( + + setExpanded((v) => !v)}> + + + {participant.nickname} + + + { + e.stopPropagation(); + handleFollow(); + }} + > + + + + + + } + name={t('shared_string_speed')} + additionalInfo={`${fmtSpeed(lastLoc?.speed)} ${speedUnit} · ${t('web:live_track_updated')} ${getTimeAgo(lastLoc?.time, t)}`} + /> + + } + name={t('web:active_state')} + additionalInfo={toHHMMSS(duration).split('.')[0]} + /> + + } + name={t('distance')} + additionalInfo={`${fmtLarge(totalDistM)} ${largeUnit}`} + /> + + } + name={t('shared_string_max_speed')} + additionalInfo={`${fmtSpeed(maxSpeedMS)} ${speedUnit}`} + /> + + } name={t('altitude')} additionalInfo={altitude} /> + {(elevGainM > 0 || elevLossM < 0) && ( + <> + + } + name={t('web:live_track_elevation_gain')} + additionalInfo={`+${fmtSmall(elevGainM)} ${smallUnit}`} + /> + + } + name={t('web:live_track_elevation_loss')} + additionalInfo={`${fmtSmall(Math.abs(elevLossM))} ${smallUnit}`} + /> + + )} + {Number.isFinite(bearingDeg) && ( + <> + + } + name={t('web:live_track_direction')} + additionalInfo={`${Math.round(bearingDeg)}°`} + /> + + )} + {Number.isFinite(accuracyM) && accuracyM > 0 && ( + <> + + } + name={t('web:live_track_accuracy')} + additionalInfo={`±${fmtSmall(accuracyM)} ${smallUnit}`} + /> + + )} + {Number.isFinite(hdop) && hdop > 0 && ( + <> + + } + name={t('web:live_track_hdop')} + additionalInfo={hdop.toFixed(1)} + /> + + )} + {battery > 0 && ( + <> + + } + name={t('web:live_track_battery')} + additionalInfo={`${Math.round(battery)}%`} + /> + + )} + {timeToArrival > 0 && ( + <> + + } + name={t('web:live_track_eta')} + additionalInfo={toHHMMSS(timeToArrival).split('.')[0]} + /> + + )} + {distToArrival > 0 && ( + <> + + } + name={t('web:live_track_distance_to_destination')} + additionalInfo={`${fmtLarge(distToArrival)} ${largeUnit}`} + /> + + )} + {timeToIntermediate > 0 && ( + <> + + } + name={t('web:live_track_eta_intermediate')} + additionalInfo={toHHMMSS(timeToIntermediate).split('.')[0]} + /> + + )} + {distToIntermediate > 0 && ( + <> + + } + name={t('web:live_track_distance_intermediate')} + additionalInfo={`${fmtLarge(distToIntermediate)} ${largeUnit}`} + /> + + )} + {zones.length > 0 && ( + <> + + + {[...zones].reverse().map((z, i) => ( + + } + name={`${zones.length - i}. ${zoneTypeLabel(z.type)}`} + additionalInfo={`${fmtLarge(z.distance)} ${largeUnit} · ${z.eleDiff >= 0 ? '+' : '-'}${fmtSmall(Math.abs(z.eleDiff))} ${smallUnit}`} + /> + {i < zones.length - 1 && } + + ))} + + )} + + + ); +} + +function getTimeAgo(timestamp, t) { + if (!timestamp) return '—'; + const diff = Math.floor((Date.now() - timestamp) / 1000); + if (diff < 10) return t('web:live_track_just_now'); + if (diff < 60) return t('web:live_track_seconds_ago', { value: diff }); + if (diff < 3600) return t('web:live_track_minutes_ago', { value: Math.floor(diff / 60) }); + + return t('web:live_track_hours_ago', { value: Math.floor(diff / 3600) }); +} + +function computeParticipantStats(locs, statsCache, zonesCache) { + const { totalDistM, maxSpeedMS } = computeDistanceAndSpeed(locs, statsCache); + const zonesData = computeZonesThrottled(locs, zonesCache, Date.now()); + + return { totalDistM, maxSpeedMS, zonesData }; +} + +function computeDistanceAndSpeed(locs, cache) { + const n = locs.length; + if (cache && cache.locs.length > 0 && n >= cache.locs.length) { + const prev = cache.locs; + const delta = n - prev.length; + const base = { totalDistM: cache.totalDistM, maxSpeedMS: cache.maxSpeedMS }; + if (locs[delta] === prev[0] && locs[n - 1] === prev[prev.length - 1]) { + return addRange(locs, base, 0, delta, 0, delta); + } + if (delta > 0 && locs[0] === prev[0] && locs[prev.length - 1] === prev[prev.length - 1]) { + return addRange(locs, base, prev.length - 1, n - 1, prev.length, n); + } + } + + return addRange(locs, { totalDistM: 0, maxSpeedMS: 0 }, 0, n - 1, 0, n); +} + +function addRange(locs, totals, segFrom, segToExcl, ptFrom, ptToExcl) { + let { totalDistM, maxSpeedMS } = totals; + for (let i = segFrom; i < segToExcl; i++) { + totalDistM += getDistance(locs[i].lat, locs[i].lon, locs[i + 1].lat, locs[i + 1].lon); + } + for (let i = ptFrom; i < ptToExcl; i++) { + const speed = locs[i].speed ?? 0; + if (speed > maxSpeedMS) { + maxSpeedMS = speed; + } + } + + return { totalDistM, maxSpeedMS }; +} + +function computeZonesThrottled(locs, cache, now) { + if (cache && now - cache.time < ZONES_MIN_RECOMPUTE_MS) { + return cache; + } + const zones = computeZones(locs); + const { elevGainM, elevLossM } = computeElevation(zones); + + return { time: now, zones, elevGainM, elevLossM }; +} + +function computeElevation(zones) { + let elevGainM = 0; + let elevLossM = 0; + for (const zone of zones) { + if (zone.eleDiff > 0) { + elevGainM += zone.eleDiff; + } else if (zone.eleDiff < 0) { + elevLossM += zone.eleDiff; + } + } + + return { elevGainM, elevLossM }; +} diff --git a/map/src/menu/tracks/liveTrack/LiveTrackFolder.jsx b/map/src/menu/tracks/liveTrack/LiveTrackFolder.jsx new file mode 100644 index 0000000000..aa43bdd0a7 --- /dev/null +++ b/map/src/menu/tracks/liveTrack/LiveTrackFolder.jsx @@ -0,0 +1,73 @@ +import React, { useContext, useState } from 'react'; +import { AppBar, Box, IconButton, Toolbar, Tooltip, Typography } from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router-dom'; +import LiveTrackingContext from '../../../context/LiveTrackingContext'; +import { ReactComponent as BackIcon } from '../../../assets/icons/ic_arrow_back.svg'; +import { ReactComponent as AddIcon } from '../../../assets/icons/ic_action_add.svg'; +import LiveTrackItem from './LiveTrackItem'; +import Empty from '../../errors/Empty'; +import CreateLiveTrackDialog from './CreateLiveTrackDialog'; +import styles from '../../trackfavmenu.module.css'; +import { useWindowSize } from '../../../util/hooks/useWindowSize'; +import { HEADER_SIZE, MAIN_URL_WITH_SLASH, TRACKS_URL } from '../../../manager/GlobalManager'; +import gStyles from '../../gstylesmenu.module.css'; + +export default function LiveTrackFolder() { + const lttx = useContext(LiveTrackingContext); + const { t } = useTranslation(); + + const navigate = useNavigate(); + + const [, height] = useWindowSize(); + const [dialogOpen, setDialogOpen] = useState(false); + + function handleBack() { + lttx.setSelectedLiveTranslation(null); + navigate(MAIN_URL_WITH_SLASH + TRACKS_URL); + } + + return ( + + + + + + + + {t('web:live_tracks')} + + + setDialogOpen(true)} + > + + + + + + setDialogOpen(false)} /> + {lttx.liveTranslations.length === 0 ? ( + + ) : ( + + {lttx.liveTranslations.map((translation, index) => ( + + ))} + + )} + + ); +} diff --git a/map/src/menu/tracks/liveTrack/LiveTrackGroup.jsx b/map/src/menu/tracks/liveTrack/LiveTrackGroup.jsx new file mode 100644 index 0000000000..f42281adbd --- /dev/null +++ b/map/src/menu/tracks/liveTrack/LiveTrackGroup.jsx @@ -0,0 +1,39 @@ +import React, { useContext } from 'react'; +import { ListItemIcon, ListItemText, MenuItem, Typography } from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router-dom'; +import LiveTrackingContext from '../../../context/LiveTrackingContext'; +import { ReactComponent as LiveIcon } from '../../../assets/icons/ic_action_folder_location.svg'; +import styles from '../../trackfavmenu.module.css'; +import MenuItemWithLines from '../../components/MenuItemWithLines'; +import { LIVE_TRACKS_URL, MAIN_URL_WITH_SLASH } from '../../../manager/GlobalManager'; + +export default function LiveTrackGroup() { + const lttx = useContext(LiveTrackingContext); + const { t } = useTranslation(); + + const navigate = useNavigate(); + + const count = lttx.liveTranslations.length; + const infoText = count > 0 ? `${count} ${t('web:live_tracks').toLowerCase()}` : ''; + + function handleClick() { + navigate(MAIN_URL_WITH_SLASH + LIVE_TRACKS_URL); + } + + return ( + + + + + + + {infoText && ( + + {infoText} + + )} + + + ); +} diff --git a/map/src/menu/tracks/liveTrack/LiveTrackItem.jsx b/map/src/menu/tracks/liveTrack/LiveTrackItem.jsx new file mode 100644 index 0000000000..04ef8df22b --- /dev/null +++ b/map/src/menu/tracks/liveTrack/LiveTrackItem.jsx @@ -0,0 +1,125 @@ +import React, { useContext, useRef, useState } from 'react'; +import { ListItemIcon, ListItemText, MenuItem, Typography } from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router-dom'; +import LiveTrackingContext from '../../../context/LiveTrackingContext'; +import { LIVE_TRACKS_URL, MAIN_URL_WITH_SLASH } from '../../../manager/GlobalManager'; +import { buildLiveTrackShareUrl, NAME_PARAM, TID_PARAM } from '../../../util/livetracks/liveTrackUtils'; +import { ReactComponent as LocationIcon } from '../../../assets/icons/ic_action_location_marker_outlined.svg'; +import styles from '../../trackfavmenu.module.css'; +import ThreeDotsButton from '../../../frame/components/btns/ThreeDotsButton'; +import ActionsMenu from '../../actions/ActionsMenu'; +import LiveTrackItemActions from '../../actions/LiveTrackItemActions'; +import DividerWithMargin from '../../../frame/components/dividers/DividerWithMargin'; +import MenuItemWithLines from '../../components/MenuItemWithLines'; + +export default function LiveTrackItem({ translation, isLastItem }) { + const lttx = useContext(LiveTrackingContext); + const { t } = useTranslation(); + const navigate = useNavigate(); + + const anchorEl = useRef(null); + + const [openActions, setOpenActions] = useState(false); + + const isOwner = translation.isOwner === true; + const isSharing = lttx.myBroadcastTids.includes(translation.id); + const isParticipant = isSharing && !isOwner; + + const participants = lttx.liveParticipants?.[translation.id]; + const participantCount = participants ? Object.values(participants).filter((p) => p.active !== false).length : 0; + const infoText = + participantCount > 0 ? `${participantCount} ${t('web:live_track_online')}` : t('web:live_track_inactive'); + + function handleClick(e) { + if (anchorEl.current?.contains(e.target)) return; + lttx.setSelectedLiveTranslation(translation); + const params = new URLSearchParams({ [TID_PARAM]: translation.id, [NAME_PARAM]: translation.name }); + navigate(`${MAIN_URL_WITH_SLASH}${LIVE_TRACKS_URL}?${params}`); + } + + function handleRemoveBookmark() { + setOpenActions(false); + lttx.removeLiveTrack(translation.id); + } + + function handleOwnerSharingAction() { + setOpenActions(false); + if (isSharing) { + lttx.stopSharing(translation.id); + } else { + lttx.startSharing(translation.id); + } + } + + function handleParticipantStop() { + setOpenActions(false); + lttx.stopSharing(translation.id); + } + + function handleDeleteForAll() { + setOpenActions(false); + lttx.deleteLiveTrack(translation.id); + } + + function handleCopyShareLink() { + setOpenActions(false); + const url = buildLiveTrackShareUrl(translation); + if (url) navigator.clipboard.writeText(url).catch(() => {}); + } + + function handleRegenerate() { + setOpenActions(false); + lttx.regenerateLiveTrack(translation.id, (newTranslation) => { + const params = new URLSearchParams({ [TID_PARAM]: newTranslation.id, [NAME_PARAM]: newTranslation.name }); + navigate(`${MAIN_URL_WITH_SLASH}${LIVE_TRACKS_URL}?${params}`); + }); + } + + return ( + <> + + + 0 ? '#4CAF50' : '#F44336' }} /> + + + + + {infoText} + + + + + {!isLastItem && } + + } + /> + + ); +} diff --git a/map/src/resources/translations/en/web-translation.json b/map/src/resources/translations/en/web-translation.json index b8f63201cc..719f6250ce 100644 --- a/map/src/resources/translations/en/web-translation.json +++ b/map/src/resources/translations/en/web-translation.json @@ -405,5 +405,60 @@ "exit_without_saving": "Exit without saving?", "all_changes_will_be_lost": "All changes will be lost.", "keep_editing": "Keep Editing", - "shared_string_exit": "Exit" + "shared_string_exit": "Exit", + "live_tracks": "Live Tracks", + "live_track_delete": "Delete translation", + "live_track_empty": "No live tracks yet", + "live_track_empty_desc": "Open a share link in the browser to follow live location sharing", + "live_track_inactive": "Inactive", + "live_track_online": "online", + "live_track_viewers": "Viewers", + "live_track_follow": "Follow on map", + "live_track_bookmark": "Add to Live Tracks", + "live_track_bookmark_login_required": "Log in to add to Live Tracks", + "live_track_location_paused_title": "Location sharing paused", + "live_track_location_paused_desc": "The owner has paused sharing their location. You will see updates when they resume.", + "live_track_load_earlier": "Load earlier", + "live_track_regenerate_link": "Regenerate link", + "live_track_direction": "Direction", + "live_track_accuracy": "GPS accuracy", + "live_track_hdop": "HDOP", + "live_track_battery": "Battery", + "live_track_eta": "Time to destination", + "live_track_distance_to_destination": "Distance to destination", + "live_track_eta_intermediate": "Time to intermediate", + "live_track_distance_intermediate": "Distance to intermediate", + "live_track_intervals": "Track intervals", + "live_track_elevation_gain": "Elevation gain", + "live_track_elevation_loss": "Elevation loss", + "live_track_updated": "updated", + "live_track_just_now": "just now", + "live_track_seconds_ago": "{{value}}s ago", + "live_track_minutes_ago": "{{value}}m ago", + "live_track_hours_ago": "{{value}}h ago", + "live_track_create": "Create Live Track", + "live_track_duration_1h": "1 hour", + "live_track_duration_4h": "4 hours", + "live_track_duration_8h": "8 hours", + "live_track_duration_24h": "24 hours", + "live_track_share_link": "Share link", + "live_track_name_hint": "Optional", + "live_track_geo_denied": "Location access is blocked. Enable it in browser settings.", + "live_track_geo_unavailable": "Location is unavailable on this device.", + "live_track_geo_not_supported": "Geolocation is not supported by this browser.", + "live_track_pause_sharing": "Pause location sharing", + "live_track_resume_sharing": "Resume location sharing", + "live_track_remove_bookmark": "Remove from bookmarks", + "live_track_delete_for_all": "Delete translation for all", + "live_track_stop_sharing": "Stop sharing my location", + "live_track_start_sharing": "Start sharing my location", + "live_track_copy_share_link": "Copy share link", + "live_track_request_share": "Request to share my location", + "live_track_request_sent": "Share request sent to the owner", + "live_track_share_request": "{{nickname}} wants to share their location in “{{track}}”", + "live_track_link_copied": "Link copied", + "live_track_key_gen_error": "Failed to generate encryption key. Please try again.", + "live_track_create_error": "Failed to create live track. Please try again.", + "shared_string_flat": "Flat", + "shared_string_no_data": "No data" } diff --git a/map/src/setupProxy.js b/map/src/setupProxy.js index 62325d4eda..62eb5ac6b5 100644 --- a/map/src/setupProxy.js +++ b/map/src/setupProxy.js @@ -1,13 +1,17 @@ const { createProxyMiddleware } = require('http-proxy-middleware'); -module.exports = function (app) { +module.exports = function (app, server) { const prepare = (target) => ({ target, hostRewrite: 'localhost:3000', changeOrigin: true, logLevel: 'debug' }); + const prepareWs = (target) => ({ target, changeOrigin: true, ws: true, logLevel: 'debug' }); const localProxy = createProxyMiddleware(prepare('http://localhost:8080')); + const localWsProxy = createProxyMiddleware(prepareWs('http://localhost:8080')); const testProxy = createProxyMiddleware(prepare('https://test.osmand.net')); + const testWsProxy = createProxyMiddleware(prepareWs('https://test.osmand.net')); const mainProxy = createProxyMiddleware(prepare('https://osmand.net')); + const mainWsProxy = createProxyMiddleware(prepareWs('https://osmand.net')); const maptileProxy = createProxyMiddleware(prepare('https://maptile.osmand.net')); // yarn start:local @@ -22,6 +26,7 @@ module.exports = function (app) { let osmgpx = localProxy; let share = localProxy; let fs = localProxy; + let ws = localWsProxy; // yarn start (test) if (process.env.NODE_ENV === 'development' && !process.env.USE_LOCAL_API) { @@ -36,6 +41,7 @@ module.exports = function (app) { osmgpx = testProxy; share = testProxy; fs = testProxy; + ws = testWsProxy; } // yarn start:fallback (prod) @@ -52,6 +58,7 @@ module.exports = function (app) { osmgpx = testProxy; share = mainProxy; fs = mainProxy; + ws = mainWsProxy; } app.use('/gpx/', gpx); @@ -66,4 +73,6 @@ module.exports = function (app) { app.use('/online-routing-providers.json', others); // osrm-providers app.use('/share/', share); app.use('/fs/', fs); + app.use('/osmand-websocket', ws); + if (server) server.on('upgrade', ws.upgrade); }; diff --git a/map/src/test/liveTrackSimulator.js b/map/src/test/liveTrackSimulator.js new file mode 100644 index 0000000000..1820e0d33c --- /dev/null +++ b/map/src/test/liveTrackSimulator.js @@ -0,0 +1,375 @@ +/** + * Live Track Simulator — browser dev tool + * + * Exposed on window.__liveTrackSim in development mode. + * + * --- Start --- + * const sim = await window.__liveTrackSim.start({ speed: 30, bearing: 45, eleProfile: 'hilly' }); + * + * --- Start with a point limit (pause after 1000 points) --- + * const sim = await window.__liveTrackSim.start({ speed: 30, maxPoints: 1000 }); + * + * --- Stress test: backfill 24h of history, then go live (big-data test) --- + * const sim = await window.__liveTrackSim.start({ backfillHours: 24 }); + * + * --- Join an existing translation (e.g. after page refresh or sim.stop()) --- + * const sim = await window.__liveTrackSim.start({ tid: 'abc123', key: '<64-hex key>' }); + * // tid + key are printed in the console when the translation is first created + * + * --- Pause sending points (sim keeps connected) --- + * sim.pause(); + * + * --- Resume sending points after pause --- + * sim.resume(); + * + * --- Stop and disconnect --- + * sim.stop(); + * // or: window.__liveTrackSim.stop(sim); + * + * --- Options --- + * tid — join an existing translation (default: create a new one) + * key — 64-hex private key, required to SEND when joining via tid + * alias — display name (default: 'WebSimulator') + * lat — start latitude (default: 50.4501) + * lon — start longitude (default: 30.5234) + * speed — km/h (default: 20, bicycle pace) + * bearing — direction 0-360° (default: 45) + * interval — ms between points (default: 2000) + * eleProfile — 'flat' | 'hilly' | 'alpine' (default: 'flat') + * maxPoints — stop after N points, then call sim.resume() (default: 0 = infinite) + * backfillHours — send a burst of back-dated points spanning N hours before going live (default: 0 = off) + * backfillStep — ms between back-dated points (default: 0 = auto, fits the whole window + * under the client 10k-point cap; set explicitly to stress-test the cap) + */ + +import { Client } from '@stomp/stompjs'; +import { + generateTranslationKey, + computeTranslationId, + encryptLocation, + decryptLocation, +} from '../util/livetracks/liveTrackCrypto'; +import { apiPost } from '../util/HttpApi'; + +function movePoint(lat, lon, distanceMeters, bearingDeg) { + const R = 6371000; + const d = distanceMeters / R; + const bearing = (bearingDeg * Math.PI) / 180; + const lat1 = (lat * Math.PI) / 180; + const lon1 = (lon * Math.PI) / 180; + const lat2 = Math.asin(Math.sin(lat1) * Math.cos(d) + Math.cos(lat1) * Math.sin(d) * Math.cos(bearing)); + const lon2 = + lon1 + + Math.atan2(Math.sin(bearing) * Math.sin(d) * Math.cos(lat1), Math.cos(d) - Math.sin(lat1) * Math.sin(lat2)); + + return { lat: (lat2 * 180) / Math.PI, lon: (lon2 * 180) / Math.PI }; +} + +function makeElevationGenerator(profile) { + let step = 0; + const profiles = { + flat: () => 100 + Math.sin(step * 0.05) * 2, + hilly: () => 200 + Math.sin(step * 0.15) * 40 + Math.sin(step * 0.07) * 20, + alpine: () => 800 + Math.sin(step * 0.08) * 150 + Math.sin(step * 0.03) * 80, + }; + const fn = profiles[profile] || profiles.hilly; + + return () => { + const e = fn(); + step++; + return Math.round(e * 10) / 10; + }; +} + +export function start(opts = {}) { + const options = { + tid: opts.tid ?? null, + alias: opts.alias ?? 'WebSimulator', + lat: opts.lat ?? 50.4501, + lon: opts.lon ?? 30.5234, + speed: opts.speed ?? 20, + bearing: opts.bearing ?? 45, + interval: opts.interval ?? 2000, + eleProfile: opts.eleProfile ?? 'flat', + maxPoints: opts.maxPoints ?? 0, + backfillHours: opts.backfillHours ?? 0, + backfillStep: opts.backfillStep ?? 0, // 0 = auto (fit the whole window under the client cap) + }; + + const brokerURL = 'ws://localhost:8080/osmand-websocket'; + + const getEle = makeElevationGenerator(options.eleProfile); + + let currentLat = options.lat; + let currentLon = options.lon; + let currentBearing = options.bearing; + let translationId = options.tid; + let encKey = opts.key ?? null; + let intervalHandle = null; + let pointCount = 0; + let paused = false; + let started = false; + let pendingConfirmation = false; + + return new Promise((resolve) => { + const client = new Client({ + brokerURL, + connectHeaders: { alias: options.alias }, + reconnectDelay: 0, + debug: (str) => console.log('[STOMP]', str), + + onConnect: () => { + if (started) return; + started = true; + + console.log('%c✅ Connected to WebSocket', 'color: green; font-weight: bold'); + + client.subscribe('/user/queue/updates', (message) => { + const msg = JSON.parse(message.body); + if (msg.type === 'TRANSLATION' && msg.data?.id) { + if (pendingConfirmation) { + pendingConfirmation = false; + console.log('%c📍 Translation ready!', 'color: blue; font-weight: bold'); + console.log(` tid: ${translationId}`); + const params = new URLSearchParams({ tid: translationId }); + if (options.alias) params.set('name', options.alias); + const shareUrl = `${globalThis.location.origin}/map/live/?${params}#${encKey}`; + console.log(' Share URL (expand to copy):', { url: shareUrl }); + console.log(` Private key: ${encKey}`); + subscribeAndSimulate(translationId); + } + } + + if (msg.type === 'USER_INFO') { + console.log(`👤 User: ${msg.data.nickname || msg.data.email}`); + } + + if (msg.type === 'ERROR') { + console.error('❌ Server error:', msg.data); + } + }); + + client.publish({ destination: '/app/whoami' }); + + if (options.tid) { + console.log(`🔗 Joining translation: ${options.tid}`); + subscribeAndSimulate(options.tid); + } else { + console.log('📡 Creating new encrypted translation...'); + generateTranslationKey() + .then((key) => { + encKey = key; + return computeTranslationId(key); + }) + .then((tid) => { + translationId = tid; + pendingConfirmation = true; + // Back-date creation so the backfilled history isn't cut off as "before + // creation" (dev-only on the server) — lets loadEarlier paging be tested. + const creationDate = + options.backfillHours > 0 ? Date.now() - options.backfillHours * 3600 * 1000 : 0; + client.publish({ + destination: '/app/translation/create', + body: JSON.stringify({ translationId: tid, creationDate }), + }); + }) + .catch((err) => console.error('❌ Key generation failed:', err)); + } + }, + + onDisconnect: () => { + clearInterval(intervalHandle); + console.log('❌ Disconnected'); + }, + onStompError: (frame) => console.error('STOMP error:', frame.headers?.message || frame), + }); + + function startInterval(tid) { + intervalHandle = setInterval(() => { + if (paused) return; + + const baseSpeedMs = options.speed / 3.6; + const speedVariation = baseSpeedMs * (0.7 + Math.random() * 0.6); + + currentBearing = (currentBearing + (Math.random() - 0.5) * 40 + 360) % 360; + + const distStep = speedVariation * (options.interval / 1000); + const next = movePoint(currentLat, currentLon, distStep, currentBearing); + currentLat = next.lat; + currentLon = next.lon; + const ele = getEle(); + pointCount++; + + if (!encKey) return; + const locationData = { + lat: currentLat, + lon: currentLon, + time: Date.now(), + speed: speedVariation, + ele, + }; + encryptLocation(encKey, locationData) + .then((encData) => { + apiPost( + '/mapapi/translation/msg', + `translationId=${encodeURIComponent(translationId)}&encryptedData=${encodeURIComponent(encData)}`, + { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } } + ).catch(() => {}); + }) + .catch(() => {}); + + if (options.maxPoints > 0 && pointCount >= options.maxPoints) { + paused = true; + console.log( + `%c⏸ Paused after ${pointCount} points. Call sim.resume() to continue.`, + 'color: orange; font-weight: bold' + ); + } + }, options.interval); + } + + // Stress test + async function backfill() { + if (!encKey) return; + const BACKFILL_TARGET_POINTS = 9500; + const windowMs = options.backfillHours * 3600 * 1000; + const stepMs = + options.backfillStep > 0 + ? options.backfillStep + : Math.max(2000, Math.ceil(windowMs / BACKFILL_TARGET_POINTS)); + const end = Date.now(); + const startTime = end - windowMs; + + // Build the path sequentially (coherent track), then send concurrently in chunks. + const points = []; + let lat = currentLat; + let lon = currentLon; + let bearing = currentBearing; + for (let t = startTime; t < end; t += stepMs) { + bearing = (bearing + (Math.random() - 0.5) * 40 + 360) % 360; + const speed = (options.speed / 3.6) * (0.7 + Math.random() * 0.6); + const next = movePoint(lat, lon, speed * (stepMs / 1000), bearing); + lat = next.lat; + lon = next.lon; + points.push({ lat, lon, time: t, speed, ele: getEle() }); + } + currentLat = lat; + currentLon = lon; + currentBearing = bearing; + + console.log( + `%c⏳ Backfilling ${points.length} points over ${options.backfillHours}h (step ${stepMs}ms)...`, + 'color: cyan; font-weight: bold' + ); + if (points.length > 10000) { + console.warn( + `⚠️ ${points.length} points exceed the client cap (10000): the oldest hours will be dropped on merge, loadEarlier won't reach the start of the window` + ); + } + // Give the server a moment to process startSharing (sent over WS just before this), + // otherwise the first chunks are rejected as NOT_SHARED and the oldest points are lost. + await new Promise((r) => setTimeout(r, 500)); + let failed = 0; + const CHUNK = 50; + for (let i = 0; i < points.length; i += CHUNK) { + await Promise.all( + points.slice(i, i + CHUNK).map(async (p) => { + try { + const encData = await encryptLocation(encKey, p); + await apiPost( + '/mapapi/translation/msg', + `translationId=${encodeURIComponent(translationId)}&encryptedData=${encodeURIComponent(encData)}&serverReceiveTime=${p.time}`, + { + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + throwErrors: true, + } + ); + } catch { + failed++; + } + }) + ); + } + pointCount += points.length - failed; + console.log( + `%c✅ Backfill done: ${points.length - failed} points sent${failed ? ` (${failed} FAILED — check the server)` : ''}`, + `color: ${failed ? 'orange' : 'green'}; font-weight: bold` + ); + } + + function subscribeAndSimulate(tid) { + client.subscribe(`/topic/translation/${tid}`, (message) => { + const msg = JSON.parse(message.body); + if (msg.type === 'LOCATION') { + const encryptedData = msg.content?.encryptedData; + const pt = msg.content?.point; + function logPoint(p) { + if (!p) return; + const spd = Number.isFinite(p.speed) ? (p.speed * 3.6).toFixed(1) + 'km/h' : '-'; + const ele = Number.isFinite(p.ele) ? p.ele + 'm' : '-'; + console.log( + `📍 ${msg.sender}: lat=${p.lat?.toFixed(5)} lon=${p.lon?.toFixed(5)} spd=${spd} ele=${ele}` + ); + } + if (encryptedData && encKey) { + decryptLocation(encKey, encryptedData) + .then(logPoint) + .catch(() => {}); + } else if (pt) { + logPoint(pt); + } + } + if (msg.type === 'JOIN') { + console.log(`👤 ${msg.content} joined`); + } + }); + + client.publish({ destination: `/app/translation/${tid}/load`, body: '{}' }); + client.publish({ destination: `/app/translation/${tid}/startSharing`, body: '{}' }); + + const limitMsg = options.maxPoints > 0 ? ` | Limit: ${options.maxPoints} points` : ' | Continuous'; + console.log( + '%c▶️ Simulation started — location output to console only', + 'color: orange; font-weight: bold' + ); + console.log( + ` Speed: ~${options.speed} km/h (±30% variation) | Bearing: ${options.bearing}° (±20° wander) | Profile: ${options.eleProfile}${limitMsg}` + ); + + if (options.backfillHours > 0) { + backfill().then(() => startInterval(tid)); + } else { + startInterval(tid); + } + + resolve({ + translationId: tid, + pause: () => { + paused = true; + console.log('%c⏸ Paused', 'color: orange'); + }, + resume: () => { + if (!paused) return; + paused = false; + console.log('%c▶️ Resumed', 'color: green'); + }, + stop: () => { + clearInterval(intervalHandle); + if (client?.connected) { + client.publish({ destination: `/app/translation/${tid}/stopSharing`, body: '{}' }); + setTimeout(() => client.deactivate(), 500); + } else { + client.deactivate(); + } + console.log(`%c⏹ Stopped after ${pointCount} points`, 'color: red; font-weight: bold'); + }, + }); + } + + client.activate(); + }); +} + +export function stop(handle) { + handle?.stop(); +} diff --git a/map/src/util/hooks/live/useLiveTracking.js b/map/src/util/hooks/live/useLiveTracking.js new file mode 100644 index 0000000000..09cccedd95 --- /dev/null +++ b/map/src/util/hooks/live/useLiveTracking.js @@ -0,0 +1,740 @@ +import { useEffect, useRef, useCallback, useState } from 'react'; +import { Client } from '@stomp/stompjs'; +import { getColorByIndex } from '../../../menu/analyzer/util/SegmentColorizer'; +import { + encryptLocation, + decryptLocation, + generateTranslationKey, + computeTranslationId, +} from '../../livetracks/liveTrackCrypto'; +import { GEO_ERROR_DENIED, GEO_ERROR_UNAVAILABLE, LIVE_TRACKS_STORAGE_KEY } from '../../livetracks/liveTrackUtils'; +import { apiPost } from '../../HttpApi'; + +// sessionStorage key: my active broadcast tids (JSON array), restored after page refresh. +const BROADCAST_TID_SESSION = '__liveTrackBroadcastTids__'; + +// Initial /load fetches the last 6h only (fast open); older windows via loadEarlier(). +const INITIAL_LOAD_WINDOW_MS = 6 * 60 * 60 * 1000; + +// Cap on points kept per participant (newest first) — bounds memory and per-render computations. +const MAX_PARTICIPANT_POINTS = 10000; + +export default function useLiveTracking(ctx, enabled = true) { + const clientRef = useRef(null); + const subscribedRef = useRef(new Map()); // translationId → STOMP subscription (kept so we can unsubscribe) + const pendingCreateRef = useRef(null); // { onSuccess, onError } for the in-flight /create + const geoErrorRef = useRef(null); // onGeoError(errCode) for the active broadcast — fired from the watchPosition error + + // Per-translation maps (refs: change off-render, never displayed). + const keysRef = useRef({}); // tid → AES key (hex) + const lastTimeRef = useRef({}); // tid → newest serverReceiveTime seen; reconnect fetches the delta + const earliestFromRef = useRef({}); // tid → oldest fromTime requested; loadEarlier steps it back + + const [connected, setConnected] = useState(false); + // tid → true when no older history remains (earliest request passed creationDate). Disables "load earlier". + const [historyExhausted, setHistoryExhausted] = useState({}); + + const [loadingEarlier, setLoadingEarlier] = useState({}); + const earlierRequestRef = useRef({}); + + // Publish a body-less command to the server (startSharing / stopSharing / delete). + const sendCommand = useCallback((destination) => { + clientRef.current?.publish({ destination, body: '{}' }); + }, []); + + // Save the translations list to state and localStorage together. + const saveTranslations = useCallback( + (list) => { + ctx.setLiveTranslations(list); + localStorage.setItem(LIVE_TRACKS_STORAGE_KEY, JSON.stringify(list)); + }, + [ctx.setLiveTranslations] + ); + + const finishLoadingEarlier = useCallback((id) => { + delete earlierRequestRef.current[id]; + setLoadingEarlier((prev) => { + if (!prev[id]) return prev; + const next = { ...prev }; + delete next[id]; + return next; + }); + }, []); + + // Drop all client-side state for one translation. + const forgetTranslation = useCallback( + (id) => { + // Unsubscribe from the STOMP topic so the client stops receiving updates for this translation. + subscribedRef.current.get(id)?.unsubscribe(); + subscribedRef.current.delete(id); + delete keysRef.current[id]; + delete lastTimeRef.current[id]; + delete earliestFromRef.current[id]; + finishLoadingEarlier(id); + setHistoryExhausted((prev) => { + if (!(id in prev)) return prev; + const next = { ...prev }; + delete next[id]; + return next; + }); + }, + [finishLoadingEarlier] + ); + + // Keep keysRef in sync so the LOCATION handler can always decrypt. + useEffect(() => { + ctx.liveTranslations.forEach((t) => { + if (t.key) keysRef.current[t.id] = t.key; + }); + if (ctx.selectedLiveTranslation?.key) { + keysRef.current[ctx.selectedLiveTranslation.id] = ctx.selectedLiveTranslation.key; + } + }, [ctx.liveTranslations, ctx.selectedLiveTranslation]); + + // Broadcast my position into every translation I'm sharing into. Each translation has its own + // key, so the point is encrypted per key and addressed (translationId) — the server then routes + // each ciphertext to that one translation. Gated on `connected` so we don't POST during reconnect. + useEffect(() => { + const tids = ctx.myBroadcastTids; + if (!tids.length || !navigator.geolocation || !connected) { + return; + } + + const watchId = navigator.geolocation.watchPosition( + (position) => { + const { latitude, longitude, altitude, speed, accuracy } = position.coords; + const locationData = { + lat: latitude, + lon: longitude, + time: position.timestamp, + ...(speed != null && { speed }), + ...(altitude != null && { ele: altitude }), + // Browser geolocation reports accuracy as a radius in metres (not DOP), so send it + // under `acc` rather than mislabeling it as the mobile broadcaster's `hdop`. + ...(accuracy != null && { acc: accuracy }), + }; + for (const tid of tids) { + const key = keysRef.current[tid]; + if (!key) continue; + encryptLocation(key, locationData) + .then((encData) => { + apiPost( + '/mapapi/translation/msg', + `translationId=${encodeURIComponent(tid)}&encryptedData=${encodeURIComponent(encData)}`, + { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } } + ).catch(() => {}); + }) + .catch(() => {}); + } + }, + (error) => { + const code = error?.code === error?.PERMISSION_DENIED ? GEO_ERROR_DENIED : GEO_ERROR_UNAVAILABLE; + geoErrorRef.current?.(code); + }, + { enableHighAccuracy: true, maximumAge: 5000 } + ); + + return () => navigator.geolocation.clearWatch(watchId); + }, [ctx.myBroadcastTids, connected]); + + // Add a live point to a participant (newest at index 0). + const updateParticipant = useCallback( + (translationId, nickname, point) => { + ctx.setLiveParticipants((prev) => { + const byTranslation = prev[translationId] ?? {}; + const existing = byTranslation[nickname]; + const color = existing?.color ?? getColorByIndex(Object.keys(byTranslation).length, 100); + const locations = existing?.locations ?? []; + // Skip a duplicate of the newest point (geolocation may re-deliver a cached fix); + // returning prev unchanged also avoids a needless re-render. + if (point?.time != null && locations[0]?.time === point.time) { + return prev; + } + return { + ...prev, + [translationId]: { + ...byTranslation, + [nickname]: { + nickname, + owner: existing?.owner, + mine: existing?.mine, + color, + active: existing?.active ?? true, + startTime: existing?.startTime ?? Date.now(), + locations: [point, ...locations].slice(0, MAX_PARTICIPANT_POINTS), + }, + }, + }; + }); + }, + [ctx.setLiveParticipants] + ); + + // Apply a METADATA snapshot: mark who is currently sharing active/inactive. + // Their location history is filled separately by processEncryptedHistory. + const handleMetadata = useCallback( + (translationId, data) => { + if (!Array.isArray(data.shareLocations)) return; + + ctx.setLiveParticipants((prev) => { + const byTranslation = { ...(prev[translationId] ?? {}) }; + const activeNicknames = new Set(); + data.shareLocations.forEach((loc, index) => { + activeNicknames.add(loc.nickname); + const existing = byTranslation[loc.nickname]; + byTranslation[loc.nickname] = { + nickname: loc.nickname, + owner: loc.owner === true, + // mine is only present in the personal load reply; preserve it on broadcasts. + mine: loc.mine ?? existing?.mine ?? false, + color: existing?.color ?? getColorByIndex(index, data.shareLocations.length), + active: true, + startTime: loc.startTime ?? existing?.startTime ?? Date.now(), + locations: existing?.locations ?? [], + }; + }); + // Mark participants no longer sharing as inactive + Object.keys(byTranslation).forEach((nick) => { + if (!activeNicknames.has(nick)) { + byTranslation[nick] = { ...byTranslation[nick], active: false }; + } + }); + return { ...prev, [translationId]: byTranslation }; + }); + }, + [ctx.setLiveParticipants] + ); + + // Subscribe to a translation's topic (once) and request its initial history. + const subscribeToTranslation = useCallback( + (client, translationId) => { + if (subscribedRef.current.has(translationId)) { + return; + } + + const subscription = client.subscribe(`/topic/translation/${translationId}`, (message) => { + const msg = JSON.parse(message.body); + // Track newest server time so reconnect only re-fetches the delta. + if (msg.serverReceiveTime && msg.serverReceiveTime > (lastTimeRef.current[translationId] ?? 0)) { + lastTimeRef.current[translationId] = msg.serverReceiveTime; + } + if (msg.type === 'LOCATION') { + const encryptedData = msg.content?.encryptedData; + const key = keysRef.current[translationId]; + if (encryptedData && key && msg.sender) { + decryptLocation(key, encryptedData) + .then((decryptedPoint) => { + if (decryptedPoint) { + updateParticipant(translationId, msg.sender, decryptedPoint); + } + }) + .catch(() => {}); + } + } else if (msg.type === 'METADATA') { + handleMetadata(translationId, msg.content); + } else if (msg.type === 'JOIN' && msg.content) { + ctx.setLiveViewers((prev) => { + const byTranslation = { ...(prev[translationId] ?? {}) }; + byTranslation[msg.content] = true; + return { ...prev, [translationId]: byTranslation }; + }); + } else if (msg.type === 'LEAVE' && msg.content) { + ctx.setLiveViewers((prev) => { + const byTranslation = { ...(prev[translationId] ?? {}) }; + delete byTranslation[msg.content]; + return { ...prev, [translationId]: byTranslation }; + }); + } else if (msg.type === 'DELETE') { + // Owner deleted the translation — wipe it from client state. + ctx.setLiveTranslations((prev) => { + const updated = prev.filter((t) => t.id !== translationId); + localStorage.setItem(LIVE_TRACKS_STORAGE_KEY, JSON.stringify(updated)); + return updated; + }); + ctx.setLiveParticipants((prev) => { + const next = { ...prev }; + delete next[translationId]; + return next; + }); + ctx.setSelectedLiveTranslation((sel) => (sel?.id === translationId ? null : sel)); + forgetTranslation(translationId); + } + }); + subscribedRef.current.set(translationId, subscription); + // Initial load: delta since the last point seen, or the recent window on first open. + const last = lastTimeRef.current[translationId]; + const fromTime = last ? last + 1 : Date.now() - INITIAL_LOAD_WINDOW_MS; + if (earliestFromRef.current[translationId] == null) { + earliestFromRef.current[translationId] = fromTime; + } + client.publish({ + destination: `/app/translation/${translationId}/load`, + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ fromTime }), + }); + }, + [updateParticipant, handleMetadata, forgetTranslation, ctx.setLiveViewers] + ); + + // Fetch the previous INITIAL_LOAD_WINDOW_MS of history (merged + de-duped on arrival). + // One request in flight per translation: the spinner shows until the reply is merged. + const loadEarlier = useCallback( + (translationId) => { + if (historyExhausted[translationId] || loadingEarlier[translationId]) { + return; + } + const currentFrom = earliestFromRef.current[translationId] ?? Date.now() - INITIAL_LOAD_WINDOW_MS; + const newFrom = currentFrom - INITIAL_LOAD_WINDOW_MS; + earliestFromRef.current[translationId] = newFrom; + earlierRequestRef.current[translationId] = currentFrom; + setLoadingEarlier((prev) => ({ ...prev, [translationId]: true })); + clientRef.current?.publish({ + destination: `/app/translation/${translationId}/load`, + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ fromTime: newFrom, toTime: currentFrom }), + }); + }, + [historyExhausted, loadingEarlier] + ); + + // Save a translation to the list (key needed to decrypt). If already saved, just + // select it, backfilling the key if we now have one. + const addLiveTrack = useCallback( + (id, name, key) => { + const existing = ctx.liveTranslations.find((t) => t.id === id); + if (existing) { + if (key && !existing.key) { + saveTranslations(ctx.liveTranslations.map((t) => (t.id === id ? { ...t, key } : t))); + keysRef.current[id] = key; + ctx.setSelectedLiveTranslation({ ...existing, key }); + } else { + ctx.setSelectedLiveTranslation(existing); + } + return; + } + + const autoName = name?.trim() || `Live Track ${ctx.liveTranslations.length + 1}`; + const newTranslation = { id, name: autoName, ...(key ? { key } : {}) }; + if (key) keysRef.current[id] = key; + saveTranslations([...ctx.liveTranslations, newTranslation]); + ctx.setSelectedLiveTranslation(newTranslation); + + const client = clientRef.current; + if (client?.connected) { + subscribeToTranslation(client, id); + } + }, + [ctx.liveTranslations, saveTranslations, ctx.setSelectedLiveTranslation, subscribeToTranslation] + ); + + // Remove a translation from the list and drop all its client state. + const removeLiveTrack = useCallback( + (id) => { + saveTranslations(ctx.liveTranslations.filter((t) => t.id !== id)); + forgetTranslation(id); + ctx.setLiveParticipants((prev) => { + const next = { ...prev }; + delete next[id]; + return next; + }); + if (ctx.selectedLiveTranslation?.id === id) { + ctx.setSelectedLiveTranslation(null); + } + }, + [ + ctx.liveTranslations, + saveTranslations, + forgetTranslation, + ctx.setLiveParticipants, + ctx.selectedLiveTranslation, + ctx.setSelectedLiveTranslation, + ] + ); + + // Start broadcasting into this translation. Added to the active set — other broadcasts keep going. + const startSharing = useCallback( + (translationId) => { + sendCommand(`/app/translation/${translationId}/startSharing`); + ctx.setMyBroadcastTids((prev) => { + const next = prev.includes(translationId) ? prev : [...prev, translationId]; + saveBroadcastTids(next); + return next; + }); + }, + [sendCommand, ctx.setMyBroadcastTids] + ); + + // Stop broadcasting into this translation. Removed from the active set; the rest keep going. + // The translation stays bookmarked, so the owner can start it again later (acts as pause/resume). + const stopSharing = useCallback( + (translationId) => { + sendCommand(`/app/translation/${translationId}/stopSharing`); + ctx.setMyBroadcastTids((prev) => { + const next = prev.filter((t) => t !== translationId); + saveBroadcastTids(next); + return next; + }); + }, + [sendCommand, ctx.setMyBroadcastTids] + ); + + // Ask the owner of a translation (that I only have view access to) for permission to share. + const requestShare = useCallback( + (translationId) => { + sendCommand(`/app/translation/${translationId}/requestShare`); + }, + [sendCommand] + ); + + // Drop a handled request from the owner's pending list (shown as map notifications). + const dropShareRequest = useCallback( + (translationId, userId) => { + ctx.setLiveShareRequests((prev) => + prev.filter((r) => !(r.translationId === translationId && r.userId === userId)) + ); + }, + [ctx.setLiveShareRequests] + ); + + // Owner approves a pending sharer; the server registers them and notifies the requester. + const approveShare = useCallback( + (translationId, userId) => { + clientRef.current?.publish({ + destination: `/app/translation/${translationId}/approveShare`, + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ userId }), + }); + dropShareRequest(translationId, userId); + }, + [dropShareRequest] + ); + + // Owner denies a pending sharer (also used when the owner dismisses the notification). + const denyShare = useCallback( + (translationId, userId) => { + clientRef.current?.publish({ + destination: `/app/translation/${translationId}/denyShare`, + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ userId }), + }); + dropShareRequest(translationId, userId); + }, + [dropShareRequest] + ); + + // Expose approve/deny so the map-level notifications (rendered in GlobalFrame) can act on them. + useEffect(() => { + ctx.setLiveShareActions({ approve: approveShare, deny: denyShare }); + }, [approveShare, denyShare, ctx.setLiveShareActions]); + + // Create a new translation on the server (id is derived from the key — see computeTranslationId()). On success: + // save it as owner, select it, and start sharing. The server reply arrives on + // /user/queue/updates and is handled in the mount effect (pendingCreateRef). + // replaceId (optional): regenerate — drop that old translation and revoke it server-side. + const createLiveTrack = useCallback( + (translationId, key, name, durationHours, onCreated, onGeoError, onCreateError, replaceId) => { + geoErrorRef.current = onGeoError ?? null; + + pendingCreateRef.current = { + onSuccess: (id) => { + const autoName = name?.trim() || `Live Track ${ctx.liveTranslations.length + 1}`; + const newTranslation = { id, name: autoName, key, isOwner: true }; + keysRef.current[id] = key; + // Brand-new translation: nothing older than now, so disable "load earlier". + setHistoryExhausted((prev) => ({ ...prev, [id]: true })); + const others = replaceId + ? ctx.liveTranslations.filter((t) => t.id !== replaceId) + : ctx.liveTranslations; + saveTranslations([...others, newTranslation]); + ctx.setSelectedLiveTranslation(newTranslation); + const client = clientRef.current; + if (client?.connected) { + subscribeToTranslation(client, id); + } + // Start broadcasting into the new translation (the geolocation watch starts via useEffect). + sendCommand(`/app/translation/${id}/startSharing`); + ctx.setMyBroadcastTids((prev) => { + // Regenerate: drop the revoked old tid and add the new one; otherwise just add. + const without = replaceId ? prev.filter((t) => t !== replaceId) : prev; + const next = without.includes(id) ? without : [...without, id]; + saveBroadcastTids(next); + return next; + }); + // Regenerate: revoke the old translation (its viewers get DELETE). + if (replaceId) { + sendCommand(`/app/translation/${replaceId}/delete`); + forgetTranslation(replaceId); + } + onCreated?.(newTranslation); + }, + onError: onCreateError, + }; + + clientRef.current?.publish({ + destination: '/app/translation/create', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ translationId, durationHours }), + }); + }, + [ + ctx.setMyBroadcastTids, + ctx.liveTranslations, + saveTranslations, + ctx.setSelectedLiveTranslation, + sendCommand, + forgetTranslation, + subscribeToTranslation, + ] + ); + + // Regenerate the key/link for a translation I own: issue a new permanent key and + // revoke the old one (old viewers lose access, my broadcast moves to the new link). + // onDone(newTranslation) lets the caller navigate to the new tid URL. + const regenerateLiveTrack = useCallback( + async (oldId, onDone) => { + const old = ctx.liveTranslations.find((t) => t.id === oldId); + if (!old?.isOwner) { + return; + } + const key = await generateTranslationKey(); + const newId = await computeTranslationId(key); + createLiveTrack(newId, key, old.name, 0, onDone, null, null, oldId); + }, + [ctx.liveTranslations, createLiveTrack] + ); + + // Delete the translation for everyone (owner only, enforced server-side). + const deleteLiveTrack = useCallback( + (id) => { + ctx.setMyBroadcastTids((prev) => { + if (!prev.includes(id)) return prev; + sendCommand(`/app/translation/${id}/stopSharing`); + const next = prev.filter((t) => t !== id); + saveBroadcastTids(next); + return next; + }); + sendCommand(`/app/translation/${id}/delete`); + }, + [ctx.setMyBroadcastTids, sendCommand] + ); + + // Decrypt a /load history batch into participant tracks (newest-first, de-duped by time). + const processEncryptedHistory = useCallback( + (translationId, history) => { + // Advance the delta cursor from server times (even for messages we can't decrypt). + if (Array.isArray(history)) { + for (const m of history) { + if (m.serverReceiveTime && m.serverReceiveTime > (lastTimeRef.current[translationId] ?? 0)) { + lastTimeRef.current[translationId] = m.serverReceiveTime; + } + } + } + const key = keysRef.current[translationId]; + if (!key || !Array.isArray(history) || history.length === 0) { + return; + } + + const encMessages = history.filter((m) => m.type === 'LOCATION' && m.content?.encryptedData && m.sender); + if (encMessages.length === 0) return; + + return Promise.all( + encMessages.map((m) => + decryptLocation(key, m.content.encryptedData).then((pt) => (pt ? { sender: m.sender, pt } : null)) + ) + ).then((results) => { + const pointsBySender = {}; + results.filter(Boolean).forEach(({ sender, pt }) => { + if (!pointsBySender[sender]) pointsBySender[sender] = []; + pointsBySender[sender].push(pt); + }); + if (Object.keys(pointsBySender).length === 0) return; + + ctx.setLiveParticipants((prev) => { + const byTranslation = { ...(prev[translationId] ?? {}) }; + Object.entries(pointsBySender).forEach(([nickname, pts], index) => { + pts.sort((a, b) => (b.time ?? 0) - (a.time ?? 0)); + const existing = byTranslation[nickname]; + const color = + existing?.color ?? getColorByIndex(Object.keys(byTranslation).length + index, 100); + // Merge with existing live points, deduplicate by time. + const existingTimes = new Set((existing?.locations ?? []).map((p) => p.time)); + const newPts = pts.filter((p) => !existingTimes.has(p.time)); + const combined = [...(existing?.locations ?? []), ...newPts]; + combined.sort((a, b) => (b.time ?? 0) - (a.time ?? 0)); + byTranslation[nickname] = { + nickname, + owner: existing?.owner, + mine: existing?.mine, + color, + active: existing?.active ?? true, + startTime: existing?.startTime ?? Date.now(), + locations: combined.slice(0, MAX_PARTICIPANT_POINTS), + }; + }); + return { ...prev, [translationId]: byTranslation }; + }); + }); + }, + [ctx.setLiveParticipants] + ); + + // Connect while live tracks are in use; tears down when no longer enabled. + useEffect(() => { + if (!enabled) return; + const client = new Client({ + brokerURL: process.env.REACT_APP_WS_URL, + reconnectDelay: 5000, + onConnect: () => { + // Private queue: replies to /load (history snapshot) and /create. + client.subscribe('/user/queue/updates', (message) => { + const msg = JSON.parse(message.body); + if (msg.type === 'TRANSLATION' && msg.data?.id) { + if (pendingCreateRef.current && msg.data.shareLocations == null) { + // /create reply — fire the callback and clear it. + pendingCreateRef.current.onSuccess(msg.data.id); + pendingCreateRef.current = null; + } else { + handleMetadata(msg.data.id, msg.data); + // History may arrive split across several chunks + const merged = Promise.resolve(processEncryptedHistory(msg.data.id, msg.data.history)); + if (msg.data.lastChunk !== false) { + merged.then(() => finishLoadingEarlier(msg.data.id)); + } + // Viewer roster snapshot — keeps the count correct after a page refresh. + if (Array.isArray(msg.data.viewers)) { + const tid = msg.data.id; + const roster = {}; + msg.data.viewers.forEach((n) => { + roster[n] = true; + }); + ctx.setLiveViewers((prev) => ({ ...prev, [tid]: roster })); + } + // Owner-only: viewers awaiting approval to share (delivered on load). + // Replace this translation's pending list with the server's, so the + // notifications reappear after a page refresh until handled. + if (Array.isArray(msg.data.pendingRequests)) { + const tid = msg.data.id; + ctx.setLiveShareRequests((prev) => [ + ...prev.filter((r) => r.translationId !== tid), + ...msg.data.pendingRequests.map((r) => ({ + translationId: tid, + userId: r.userId, + nickname: r.nickname, + })), + ]); + } + // No older history once our earliest request predates creation. + // Runs on the first load too, so a fresh translation disables at once. + const earliest = earliestFromRef.current[msg.data.id]; + const created = msg.data.creationDate; + if (created && earliest != null) { + const exhausted = earliest <= created; + setHistoryExhausted((prev) => + prev[msg.data.id] === exhausted ? prev : { ...prev, [msg.data.id]: exhausted } + ); + } + } + } else if (msg.type === 'SHARE_REQUEST' && msg.data?.translationId) { + // Owner side: a viewer asked to share into my translation. + const { translationId, userId, nickname } = msg.data; + ctx.setLiveShareRequests((prev) => + prev.some((r) => r.translationId === translationId && r.userId === userId) + ? prev + : [...prev, { translationId, userId, nickname }] + ); + } else if (msg.type === 'SHARE_APPROVED' && msg.data) { + // Requester side: approved — start broadcasting into that translation. + const tid = msg.data; + ctx.setMyBroadcastTids((prev) => { + const next = prev.includes(tid) ? prev : [...prev, tid]; + saveBroadcastTids(next); + return next; + }); + } else if (msg.type === 'ERROR' && pendingCreateRef.current) { + // /create rejected (e.g. not authenticated). + pendingCreateRef.current.onError?.(msg.data); + pendingCreateRef.current = null; + } + }); + setConnected(true); + }, + onDisconnect: () => { + // Clear so the reconnect effect re-subscribes to topics on the new STOMP session. + subscribedRef.current.clear(); + Object.entries(earlierRequestRef.current).forEach(([tid, prevFrom]) => { + earliestFromRef.current[tid] = prevFrom; + }); + earlierRequestRef.current = {}; + setLoadingEarlier({}); + setConnected(false); + }, + }); + + client.activate(); + clientRef.current = client; + + return () => { + client.deactivate(); + clientRef.current = null; + subscribedRef.current.clear(); + setConnected(false); + }; + }, [enabled]); + + useEffect(() => { + if (!connected) return; + const client = clientRef.current; + if (!client?.connected) return; + ctx.liveTranslations.forEach((t) => subscribeToTranslation(client, t.id)); + const sel = ctx.selectedLiveTranslation; + if (sel && !ctx.liveTranslations.some((t) => t.id === sel.id)) { + subscribeToTranslation(client, sel.id); + } + }, [connected, ctx.liveTranslations, ctx.selectedLiveTranslation, subscribeToTranslation]); + + // On (re)connect: re-register every active broadcast. Restores the set saved before a page + // refresh (kept only for translations that are still bookmarked), then re-sends startSharing. + useEffect(() => { + if (!connected) return; + const active = ctx.myBroadcastTids.length + ? ctx.myBroadcastTids + : loadBroadcastTids().filter((tid) => ctx.liveTranslations.some((t) => t.id === tid)); + if (!active.length) return; + if (!ctx.myBroadcastTids.length) { + ctx.setMyBroadcastTids(active); + } + active.forEach((tid) => sendCommand(`/app/translation/${tid}/startSharing`)); + }, [connected, sendCommand]); + + return { + addLiveTrack, + removeLiveTrack, + createLiveTrack, + deleteLiveTrack, + startSharing, + stopSharing, + regenerateLiveTrack, + loadEarlier, + loadingEarlier, + historyExhausted, + requestShare, + }; +} + +function saveBroadcastTids(tids) { + try { + if (tids.length) { + sessionStorage.setItem(BROADCAST_TID_SESSION, JSON.stringify(tids)); + } else { + sessionStorage.removeItem(BROADCAST_TID_SESSION); + } + } catch {} +} + +function loadBroadcastTids() { + try { + return JSON.parse(sessionStorage.getItem(BROADCAST_TID_SESSION)) ?? []; + } catch { + return []; + } +} diff --git a/map/src/util/livetracks/liveTrackCrypto.js b/map/src/util/livetracks/liveTrackCrypto.js new file mode 100644 index 0000000000..a789c8a83b --- /dev/null +++ b/map/src/util/livetracks/liveTrackCrypto.js @@ -0,0 +1,69 @@ +// Utilities for live track symmetric encryption (AES-256-GCM). +// Key is a 32-byte hex string; translationId is the first TRANSLATION_ID_LENGTH hex chars of SHA-256(key). + +const GENERATE_ALGORITHM = { name: 'AES-GCM', length: 256 }; +const IMPORT_ALGORITHM = { name: 'AES-GCM' }; +const IV_BYTES = 12; // 96-bit IV recommended for AES-GCM + +// Generates a random 256-bit key and returns it as a 64-char hex string. +export async function generateTranslationKey() { + const key = await crypto.subtle.generateKey(GENERATE_ALGORITHM, true, ['encrypt', 'decrypt']); + const raw = await crypto.subtle.exportKey('raw', key); + return hexFromBuffer(raw); +} + +// Number of hex characters to use as the public translation ID (first N chars of SHA-256). +// 16 chars = 64 bits — enough for uniqueness, keeps URLs short. +const TRANSLATION_ID_LENGTH = 16; + +// Computes SHA-256 of the key bytes and returns the first TRANSLATION_ID_LENGTH hex chars. +// Used as the public translation ID — short enough for URLs, no security value (it's public). +export async function computeTranslationId(keyHex) { + const keyBytes = bufferFromHex(keyHex); + const hash = await crypto.subtle.digest('SHA-256', keyBytes); + return hexFromBuffer(hash).slice(0, TRANSLATION_ID_LENGTH); +} + +// Encrypts a plain JS object with the given hex key. +// Returns a Base64 string: <12-byte IV>, suitable for transport. +export async function encryptLocation(keyHex, locationObj) { + const cryptoKey = await importKey(keyHex, 'encrypt'); + const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES)); + const plaintext = new TextEncoder().encode(JSON.stringify(locationObj)); + const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, cryptoKey, plaintext); + const combined = new Uint8Array(IV_BYTES + ciphertext.byteLength); + combined.set(iv, 0); + combined.set(new Uint8Array(ciphertext), IV_BYTES); + return btoa(String.fromCharCode(...combined)); +} + +// Decrypts a Base64 blob produced by encryptLocation. +// Returns the original JS object, or null if decryption fails. +export async function decryptLocation(keyHex, base64Data) { + try { + const combined = Uint8Array.from(atob(base64Data), (c) => c.charCodeAt(0)); + const iv = combined.slice(0, IV_BYTES); + const ciphertext = combined.slice(IV_BYTES); + const cryptoKey = await importKey(keyHex, 'decrypt'); + const plaintext = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, cryptoKey, ciphertext); + return JSON.parse(new TextDecoder().decode(plaintext)); + } catch { + return null; + } +} + +// --- helpers --- + +async function importKey(keyHex, usage) { + return crypto.subtle.importKey('raw', bufferFromHex(keyHex), IMPORT_ALGORITHM, false, [usage]); +} + +function bufferFromHex(hex) { + return new Uint8Array(hex.match(/.{2}/g).map((b) => parseInt(b, 16))); +} + +function hexFromBuffer(buffer) { + return Array.from(new Uint8Array(buffer)) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); +} diff --git a/map/src/util/livetracks/liveTrackUtils.js b/map/src/util/livetracks/liveTrackUtils.js new file mode 100644 index 0000000000..092a8b5860 --- /dev/null +++ b/map/src/util/livetracks/liveTrackUtils.js @@ -0,0 +1,36 @@ +import { LIVE_TRACKS_URL, MAIN_URL_WITH_SLASH } from '../../manager/GlobalManager'; + +export const LIVE_TRACKS_STORAGE_KEY = 'liveTranslations'; +export const LIVE_TRACK_KEY_SESSION = '__liveTrackShareKey__'; + +// Live-track URL query params: /map/live/?tid=&name=# +export const TID_PARAM = 'tid'; +export const NAME_PARAM = 'name'; + +export const GEO_ERROR_DENIED = 'geolocation_denied'; +export const GEO_ERROR_UNAVAILABLE = 'geolocation_unavailable'; + +// A live-track AES key is a 64-char hex string (256-bit). +export const KEY_HEX_RE = /^[0-9a-f]{64}$/; + +// Builds the share URL for a live track translation. +export function buildLiveTrackShareUrl(translation) { + if (!translation?.key) return null; + const params = new URLSearchParams({ [TID_PARAM]: translation.id }); + if (translation.name) params.set(NAME_PARAM, translation.name); + return `${globalThis.location.origin}${MAIN_URL_WITH_SLASH}${LIVE_TRACKS_URL}?${params}#${translation.key}`; +} + +// If the URL fragment contains a live-track AES key, saves it to sessionStorage +// and clears the fragment so leaflet-hash uses the default map position. +export function extractAndSaveLiveTrackKey(raw) { + if (!KEY_HEX_RE.test(raw)) return false; + try { + sessionStorage.setItem(LIVE_TRACK_KEY_SESSION, raw); + // Only drop the fragment once the key is safely persisted — otherwise the key would be lost. + history.replaceState(null, '', globalThis.location.pathname + globalThis.location.search); + return true; + } catch (_) { + return false; + } +} diff --git a/map/src/util/livetracks/liveTrackZones.js b/map/src/util/livetracks/liveTrackZones.js new file mode 100644 index 0000000000..4c3f34cf6e --- /dev/null +++ b/map/src/util/livetracks/liveTrackZones.js @@ -0,0 +1,121 @@ +// Elevation-zone analysis for a live track: splits a participant's point list into +// uphill / downhill / flat intervals using Ramer–Douglas–Peucker simplification of the +// elevation profile. Kept out of the UI so it can be reused and unit-tested. +import { getDistance } from '../Utils'; + +// Colors per zone type — shared with the UI (interval icon fill). +export const ZONE_COLORS = { UPHILL: '#d35400', DOWNHILL: '#27ae60', FLAT: '#7f8c8d' }; + +// Ramer–Douglas–Peucker on the (cumulative distance, elevation) profile. +function simplifyRDP(pts, epsilon) { + if (pts.length <= 2) return pts; + let dmax = 0; + let index = 0; + const end = pts.length - 1; + const x0 = pts[0].cumDist; + const y0 = pts[0].ele; + const x1 = pts[end].cumDist; + const y1 = pts[end].ele; + for (let i = 1; i < end; i++) { + const px = pts[i].cumDist; + const py = pts[i].ele; + const yLine = x1 === x0 ? y0 : y0 + ((y1 - y0) * (px - x0)) / (x1 - x0); + const d = Math.abs(py - yLine); + if (d > dmax) { + index = i; + dmax = d; + } + } + if (dmax > epsilon) { + const left = simplifyRDP(pts.slice(0, index + 1), epsilon); + const right = simplifyRDP(pts.slice(index), epsilon); + return left.slice(0, -1).concat(right); + } + + return [pts[0], pts[end]]; +} + +// Splits `locations` (newest-first) into elevation zones. minEleDiff (m) is both the RDP +// epsilon and the threshold to classify a segment as UPHILL/DOWNHILL vs FLAT. +export function computeZones(locations, minEleDiff = 7) { + if (!locations || locations.length < 2) return []; + const N = locations.length; + const track = []; + for (let i = 0; i < N; i++) { + const loc = locations[N - 1 - i]; + track.push({ + origIdx: N - 1 - i, + lat: loc.lat, + lon: loc.lon, + ele: loc.ele || 0, + kmh: loc.speed != null ? loc.speed * 3.6 : 0, + time: loc.time, + }); + } + track[0].cumDist = 0; + for (let i = 1; i < track.length; i++) { + track[i].cumDist = + track[i - 1].cumDist + getDistance(track[i - 1].lat, track[i - 1].lon, track[i].lat, track[i].lon); + } + const filtered = [track[0]]; + for (let i = 1; i < track.length - 1; i++) { + const prev = track[i - 1]; + const curr = track[i]; + const next = track[i + 1]; + const dx1 = curr.cumDist - prev.cumDist; + const dy1 = curr.ele - prev.ele; + const dx2 = next.cumDist - curr.cumDist; + const dy2 = next.ele - curr.ele; + if (dx1 < 1 || dx2 < 1) { + filtered.push(curr); + continue; + } + const isPeak = dy1 > 0 && dy2 < 0; + const isValley = dy1 < 0 && dy2 > 0; + if ((isPeak || isValley) && Math.abs(dy1 / dx1) > 0.7 && Math.abs(dy2 / dx2) > 0.7) continue; + filtered.push(curr); + } + filtered.push(track.at(-1)); + const extremums = simplifyRDP(filtered, minEleDiff); + const zones = []; + for (let i = 1; i < extremums.length; i++) { + const startPt = extremums[i - 1]; + const endPt = extremums[i]; + const dEle = endPt.ele - startPt.ele; + let type = 'FLAT'; + if (dEle >= minEleDiff) type = 'UPHILL'; + else if (dEle <= -minEleDiff) type = 'DOWNHILL'; + const actualStartIdx = Math.max(startPt.origIdx, endPt.origIdx); + const actualEndIdx = Math.min(startPt.origIdx, endPt.origIdx); + const dist = endPt.cumDist - startPt.cumDist; + let maxSpeed = 0; + for (let j = actualEndIdx; j <= actualStartIdx; j++) { + const kmh = (locations[j]?.speed ?? 0) * 3.6; + if (kmh > maxSpeed) maxSpeed = kmh; + } + const duration = Math.abs((locations[actualEndIdx]?.time ?? 0) - (locations[actualStartIdx]?.time ?? 0)); + const last = zones.at(-1); + if (last?.type === type) { + last.endIdx = actualEndIdx; + last.distance += dist; + last.duration += duration; + last.eleDiff += dEle; + if (maxSpeed > last.maxSpeed) last.maxSpeed = maxSpeed; + last.avgSpeed = last.duration > 0 ? (last.distance / (last.duration / 1000)) * 3.6 : 0; + } else { + const avgSpeed = duration > 0 ? (dist / (duration / 1000)) * 3.6 : 0; + zones.push({ + type, + startIdx: actualStartIdx, + endIdx: actualEndIdx, + distance: dist, + duration, + eleDiff: dEle, + maxSpeed, + avgSpeed, + }); + } + } + + return zones; +} diff --git a/map/yarn.lock b/map/yarn.lock index 2d30d43850..5c3110baaa 100644 --- a/map/yarn.lock +++ b/map/yarn.lock @@ -3163,6 +3163,13 @@ __metadata: languageName: node linkType: hard +"@stomp/stompjs@npm:^7.3.0": + version: 7.3.0 + resolution: "@stomp/stompjs@npm:7.3.0" + checksum: 8bdee303eadd3063141443c79451af5b39386123d9090ffdaeff3cf843da1e1362460e3a34a539a146da023a364ac77491b681bc55d63bfdadf9e4f044212871 + languageName: node + linkType: hard + "@surma/rollup-plugin-off-main-thread@npm:^2.2.3": version: 2.2.3 resolution: "@surma/rollup-plugin-off-main-thread@npm:2.2.3" @@ -12414,6 +12421,7 @@ eslint-plugin-react-compiler@beta: "@mui/icons-material": ^5.8.3 "@mui/lab": ^5.0.0-alpha.85 "@mui/material": ^5.8.3 + "@stomp/stompjs": ^7.3.0 "@tiptap/pm": ^3.22.4 "@tiptap/react": ^3.22.4 "@tiptap/starter-kit": ^3.22.4