From 15a2eb38b30a5b9c2b11b2d33fd3c86d78ad86d4 Mon Sep 17 00:00:00 2001 From: Kseniia Velychko Date: Wed, 27 May 2026 14:09:32 +0300 Subject: [PATCH 01/40] Add live track browser simulator --- map/package.json | 1 + map/src/index.js | 6 + map/src/test/liveTrackSimulator.js | 236 +++++++++++++++++++++++++++++ map/yarn.lock | 8 + 4 files changed, 251 insertions(+) create mode 100644 map/src/test/liveTrackSimulator.js diff --git a/map/package.json b/map/package.json index 0a58446162..f07f26264c 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", 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/test/liveTrackSimulator.js b/map/src/test/liveTrackSimulator.js new file mode 100644 index 0000000000..352524a0a7 --- /dev/null +++ b/map/src/test/liveTrackSimulator.js @@ -0,0 +1,236 @@ +/** + * 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 }); + * + * --- Join an existing translation --- + * const sim = await window.__liveTrackSim.start({ tid: 'abc123', speed: 30 }); + * + * --- Pause (after maxPoints or manually) --- + * sim.pause(); + * + * --- Resume --- + * sim.resume(); + * + * --- Stop and disconnect --- + * sim.stop(); + * // or: window.__liveTrackSim.stop(sim); + * + * --- Options --- + * tid — join an existing translation (default: create a new one) + * alias — display name (default: 'WebSimulator') + * lat — start latitude (default: 50.4501) + * lon — start longitude (default: 30.5234) + * speed — km/h (default: 30) + * bearing — direction 0-360° (default: 45) + * interval — ms between points (default: 2000) + * eleProfile — 'flat' | 'hilly' | 'alpine' (default: 'hilly') + * maxPoints — stop after N points, then call sim.resume() (default: 0 = infinite) + */ + +import { Client } from '@stomp/stompjs'; + +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 ?? 30, + bearing: opts.bearing ?? 45, + interval: opts.interval ?? 2000, + eleProfile: opts.eleProfile ?? 'hilly', + maxPoints: opts.maxPoints ?? 0, + }; + + const brokerURL = 'ws://localhost:8080/osmand-websocket'; + + const speedMs = options.speed / 3.6; + const getEle = makeElevationGenerator(options.eleProfile); + + let currentLat = options.lat; + let currentLon = options.lon; + let translationId = options.tid; + let intervalHandle = null; + let pointCount = 0; + let paused = false; + let started = 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 && !translationId) { + translationId = msg.data.id; + console.log('%c📍 Translation ready!', 'color: blue; font-weight: bold'); + console.log(` tid: ${translationId}`); + console.log( + ` Share URL: ${globalThis.location.origin}/map/live/?tid=${translationId}&name=${encodeURIComponent(options.alias)}` + ); + 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 translation...'); + client.publish({ destination: '/app/translation/create', body: '{}' }); + } + }, + + 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 distStep = speedMs * (options.interval / 1000); + const next = movePoint(currentLat, currentLon, distStep, options.bearing); + currentLat = next.lat; + currentLon = next.lon; + const ele = getEle(); + pointCount++; + + const params = new URLSearchParams({ + lat: currentLat, + lon: currentLon, + timestamp: Date.now(), + speed: speedMs, + altitude: ele, + }); + fetch(`/mapapi/translation/msg?${params}`).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); + } + + function subscribeAndSimulate(tid) { + client.subscribe(`/topic/translation/${tid}`, (message) => { + const msg = JSON.parse(message.body); + if (msg.type === 'LOCATION') { + const pt = msg.content?.point; + if (pt) { + const spd = Number.isFinite(pt.speed) ? (pt.speed * 3.6).toFixed(1) + 'km/h' : '-'; + const ele = Number.isFinite(pt.ele) ? pt.ele + 'm' : '-'; + console.log( + `📍 ${msg.sender}: lat=${pt.lat?.toFixed(5)} lon=${pt.lon?.toFixed(5)} spd=${spd} ele=${ele}` + ); + } + } + 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 | Bearing: ${options.bearing}° | Profile: ${options.eleProfile}${limitMsg}` + ); + + 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); + client.publish({ destination: `/app/translation/${tid}/stopSharing`, body: '{}' }); + console.log(`%c⏹ Stopped after ${pointCount} points`, 'color: red; font-weight: bold'); + setTimeout(() => client.deactivate(), 500); + }, + }); + } + + client.activate(); + }); +} + +export function stop(handle) { + handle?.stop(); +} 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 From 03e15dfc2a773702a7d2523fe68c944d0488507a Mon Sep 17 00:00:00 2001 From: Kseniia Velychko Date: Thu, 28 May 2026 18:47:58 +0300 Subject: [PATCH 02/40] Add live location sharing: WebSocket tracking, map layer, context menu with participant stats --- map/.env.development | 1 + map/.env.production | 1 + map/.env.staging | 1 + map/package.json | 2 +- map/src/App.js | 2 + .../icons/ic_action_folder_location.svg | 3 + map/src/context/AppContext.js | 25 ++ map/src/manager/GlobalManager.js | 1 + map/src/map/OsmAndMap.jsx | 2 + map/src/map/layers/LiveTrackLayer.js | 134 +++++++ map/src/menu/MainMenu.js | 14 +- map/src/menu/actions/LiveTrackItemActions.jsx | 29 ++ .../menu/analyzer/util/SegmentColorizer.js | 2 +- map/src/menu/tracks/TracksMenu.jsx | 19 +- .../tracks/liveTrack/LiveTrackContextMenu.jsx | 330 ++++++++++++++++++ .../menu/tracks/liveTrack/LiveTrackFolder.jsx | 59 ++++ .../menu/tracks/liveTrack/LiveTrackGroup.jsx | 38 ++ .../menu/tracks/liveTrack/LiveTrackItem.jsx | 72 ++++ .../translations/en/web-translation.json | 17 +- map/src/setupProxy.js | 11 +- map/src/test/liveTrackSimulator.js | 41 ++- map/src/util/hooks/live/useLiveTrackUrl.js | 36 ++ map/src/util/hooks/live/useLiveTracking.js | 191 ++++++++++ 23 files changed, 1008 insertions(+), 23 deletions(-) create mode 100644 map/src/assets/icons/ic_action_folder_location.svg create mode 100644 map/src/map/layers/LiveTrackLayer.js create mode 100644 map/src/menu/actions/LiveTrackItemActions.jsx create mode 100644 map/src/menu/tracks/liveTrack/LiveTrackContextMenu.jsx create mode 100644 map/src/menu/tracks/liveTrack/LiveTrackFolder.jsx create mode 100644 map/src/menu/tracks/liveTrack/LiveTrackGroup.jsx create mode 100644 map/src/menu/tracks/liveTrack/LiveTrackItem.jsx create mode 100644 map/src/util/hooks/live/useLiveTrackUrl.js create mode 100644 map/src/util/hooks/live/useLiveTracking.js 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 f07f26264c..b445aeba3e 100644 --- a/map/package.json +++ b/map/package.json @@ -58,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..605b6c6466 100644 --- a/map/src/App.js +++ b/map/src/App.js @@ -20,6 +20,7 @@ import { PLANROUTE_URL, SETTINGS_URL, TRACKS_URL, + LIVE_TRACKS_URL, VISIBLE_TRACKS_URL, WEATHER_URL, EXPLORE_URL, @@ -167,6 +168,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 e50aea38f0..ec34a47a2c 100644 --- a/map/src/context/AppContext.js +++ b/map/src/context/AppContext.js @@ -55,6 +55,8 @@ export const PREVIOUS_ROUTE_STORAGE_KEY = 'previousRoute'; export const OBJECT_TYPE_TRAVEL = 'travel'; export const OBJECT_TYPE_SHARE_FILE = 'share_file'; +export const LIVE_TRACKS_STORAGE_KEY = 'liveTranslations'; + export const MAX_RECENT_OBJS = 5; export const defaultConfigureMapStateValues = { @@ -186,6 +188,19 @@ export const AppContextProvider = (props) => { const [fitBoundsShareTracks, setFitBoundsShareTracks] = useState(null); const [smartFoldersCache, setSmartFoldersCache] = useState(null); + + // live tracks + const [liveTranslations, setLiveTranslations] = useState(() => { + try { + return JSON.parse(localStorage.getItem(LIVE_TRACKS_STORAGE_KEY)) ?? []; + } catch { + return []; + } + }); + const [liveParticipants, setLiveParticipants] = useState({}); + const [liveViewers, setLiveViewers] = useState({}); + const [selectedLiveTranslation, setSelectedLiveTranslation] = useState(null); + const [followLiveLocation, setFollowLiveLocation] = useState(null); // selected track const [selectedGpxFile, setSelectedGpxFile] = useState({}); const [unverifiedGpxFile, setUnverifiedGpxFile] = useState(null); // see Effect in LocalClientTrackLayer @@ -738,6 +753,16 @@ export const AppContextProvider = (props) => { setStopByUrl, closeMapObj, setCloseMapObj, + liveTranslations, + setLiveTranslations, + liveParticipants, + setLiveParticipants, + liveViewers, + setLiveViewers, + selectedLiveTranslation, + setSelectedLiveTranslation, + followLiveLocation, + setFollowLiveLocation, saveTrackToCloud, setSaveTrackToCloud, selectedLocalTrackObj, diff --git a/map/src/manager/GlobalManager.js b/map/src/manager/GlobalManager.js index d921b94070..9fab39569f 100644 --- a/map/src/manager/GlobalManager.js +++ b/map/src/manager/GlobalManager.js @@ -38,6 +38,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..45c952efc2 100644 --- a/map/src/map/OsmAndMap.jsx +++ b/map/src/map/OsmAndMap.jsx @@ -25,6 +25,7 @@ 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'; @@ -224,6 +225,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..6d76b86014 --- /dev/null +++ b/map/src/map/layers/LiveTrackLayer.js @@ -0,0 +1,134 @@ +import { useContext, useEffect, useRef } from 'react'; +import { useMap } from 'react-leaflet'; +import L from 'leaflet'; +import AppContext from '../../context/AppContext'; +import { panToVisibleCenter } from './MapStateLayer'; + +export default function LiveTrackLayer() { + const ctx = useContext(AppContext); + const map = useMap(); + + // { [translationId]: { [nickname]: { polyline, marker } } } + const layersRef = useRef({}); + // track whether we've already panned for the current selection + const pannedForRef = useRef(null); + + function removeTidLayers(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() { + Object.keys(layersRef.current).forEach((tid) => removeTidLayers(tid)); + } + + useEffect(() => { + const selectedTid = ctx.selectedLiveTranslation?.id ?? null; + + // Remove layers for any translation that is not currently selected + Object.keys(layersRef.current).forEach((tid) => { + if (tid !== selectedTid) removeTidLayers(tid); + }); + + if (!selectedTid) return; + + const byNickname = ctx.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; + + // newest at index 0, Leaflet needs [lat, lon] + const latLngs = locations.map((l) => [l.lat, l.lon]); + const lastLoc = locations[0]; + + const existing = layersRef.current[selectedTid][nickname]; + + if (existing) { + existing.polyline.setLatLngs(latLngs); + existing.marker.setLatLng([lastLoc.lat, lastLoc.lon]); + } else { + const polyline = L.polyline(latLngs, { 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); + marker.bindTooltip(nickname, { permanent: false, direction: 'top', offset: [0, -10] }); + layersRef.current[selectedTid][nickname] = { polyline, marker }; + } + }); + }, [ctx.liveParticipants, ctx.selectedLiveTranslation]); + + function panToTranslation(translationId) { + const participants = ctx.liveParticipants?.[translationId]; + if (!participants) return false; + + const locs = Object.values(participants) + .map((p) => p.locations?.[0]) + .filter(Boolean); + + if (locs.length === 0) return false; + + if (locs.length === 1) { + map.setView([locs[0].lat, locs[0].lon], Math.max(map.getZoom() || 0, 15)); + } else { + const bounds = L.latLngBounds(locs.map((l) => [l.lat, l.lon])); + map.fitBounds(bounds, { padding: [40, 40] }); + } + return true; + } + + // Center map when a translation is selected (if data already loaded) + useEffect(() => { + const translation = ctx.selectedLiveTranslation; + if (!translation) { + pannedForRef.current = null; + return; + } + if (pannedForRef.current === translation.id) return; + const panned = panToTranslation(translation.id); + if (panned) pannedForRef.current = translation.id; + }, [ctx.selectedLiveTranslation]); + + // Center map when data arrives for the selected translation (if not panned yet) + useEffect(() => { + const translation = ctx.selectedLiveTranslation; + if (!translation) return; + if (pannedForRef.current === translation.id) return; + const panned = panToTranslation(translation.id); + if (panned) pannedForRef.current = translation.id; + }, [ctx.liveParticipants]); + + // Pan to location when Follow button is clicked in context menu. + useEffect(() => { + if (!ctx.followLiveLocation) return; + const infoBlockWidthPx = Number.parseInt(String(ctx.infoBlockWidth), 10); + panToVisibleCenter(map, ctx.followLiveLocation, infoBlockWidthPx); + ctx.setFollowLiveLocation(null); + }, [ctx.followLiveLocation]); + + // Cleanup on unmount + useEffect(() => { + return removeAllLayers; + }, []); + + return null; +} diff --git a/map/src/menu/MainMenu.js b/map/src/menu/MainMenu.js index 4febb545df..f09849f8c1 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, @@ -370,8 +371,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 }); @@ -389,7 +396,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); @@ -445,6 +452,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'), @@ -885,7 +893,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..904ea4204b --- /dev/null +++ b/map/src/menu/actions/LiveTrackItemActions.jsx @@ -0,0 +1,29 @@ +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 styles from '../trackfavmenu.module.css'; + +const LiveTrackItemActions = forwardRef(({ handleDelete }, ref) => { + const { t } = useTranslation(); + + return ( + + + + + + + + + {t('web:live_track_delete')} + + + + + + ); +}); +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/tracks/TracksMenu.jsx b/map/src/menu/tracks/TracksMenu.jsx index e21f3c7116..51a42936ae 100644 --- a/map/src/menu/tracks/TracksMenu.jsx +++ b/map/src/menu/tracks/TracksMenu.jsx @@ -17,8 +17,13 @@ import VisibleTracks, { getCountVisibleTracks } from '../visibletracks/VisibleTr import { useTranslation } from 'react-i18next'; import SmartFolder from '../components/SmartFolder'; import LoginContext from '../../context/LoginContext'; -import { SHARE_TYPE, SMART_TYPE } from '../share/shareConstants'; +import { SHARE_TYPE } from '../share/shareConstants'; import TrackGroupFolder from './TrackGroupFolder'; +import LiveTrackGroup from './liveTrack/LiveTrackGroup'; +import LiveTrackFolder from './liveTrack/LiveTrackFolder'; +import LiveTrackContextMenu from './liveTrack/LiveTrackContextMenu'; +import useLiveTracking from '../../util/hooks/live/useLiveTracking'; +import useLiveTrackUrl from '../../util/hooks/live/useLiveTrackUrl'; import { MAIN_URL_WITH_SLASH, MENU_IDS, VISIBLE_TRACKS_URL } from '../../manager/GlobalManager'; import { useLocation, useNavigate } from 'react-router-dom'; @@ -41,6 +46,9 @@ export default function TracksMenu() { const { t } = useTranslation(); + const { addTranslation, removeTranslation } = useLiveTracking(); + const { openLiveTracks, selectedLiveTranslation } = useLiveTrackUrl({ addTranslation }); + const checkHasFiles = () => ctx.tracksGroups?.length > 0 || defaultGroup?.length > 0 || !isEmpty(ctx.shareWithMeFiles?.tracks); @@ -101,6 +109,14 @@ export default function TracksMenu() { return ; } + // live tracks folder / context menu + 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 +174,7 @@ export default function TracksMenu() { + {!isEmpty(ctx.shareWithMeFiles?.tracks) && ( )} diff --git a/map/src/menu/tracks/liveTrack/LiveTrackContextMenu.jsx b/map/src/menu/tracks/liveTrack/LiveTrackContextMenu.jsx new file mode 100644 index 0000000000..996b53ff76 --- /dev/null +++ b/map/src/menu/tracks/liveTrack/LiveTrackContextMenu.jsx @@ -0,0 +1,330 @@ +import React, { useContext } from 'react'; +import { Box, Icon, IconButton, ListItemText, Tooltip } from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router-dom'; +import AppContext from '../../../context/AppContext'; +import { HEADER_SIZE, LIVE_TRACKS_URL, MAIN_URL_WITH_SLASH } from '../../../manager/GlobalManager'; +import { useWindowSize } from '../../../util/hooks/useWindowSize'; +import { getDistance } 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 trackFavStyles from '../../trackfavmenu.module.css'; +import gStyles from '../../gstylesmenu.module.css'; +import errorStyles from '../../errors/errors.module.css'; + +const ZONE_COLORS = { UPHILL: '#d35400', DOWNHILL: '#27ae60', FLAT: '#7f8c8d' }; + +export default function LiveTrackContextMenu() { + const ctx = useContext(AppContext); + const { t } = useTranslation(); + const navigate = useNavigate(); + const [, height] = useWindowSize(); + + const translation = ctx.selectedLiveTranslation; + const participants = translation ? (ctx.liveParticipants?.[translation.id] ?? {}) : {}; + const participantList = Object.values(participants).filter((p) => p.locations?.length > 0); + const viewers = translation ? (ctx.liveViewers?.[translation.id] ?? {}) : {}; + const viewerCount = Object.keys(viewers).length; + const followLocation = participantList[0]?.locations?.[0]; + + function handleBack() { + ctx.setSelectedLiveTranslation(null); + navigate(MAIN_URL_WITH_SLASH + LIVE_TRACKS_URL); + } + + function handleFollow() { + if (followLocation?.lat != null && followLocation?.lon != null) { + ctx.setFollowLiveLocation(followLocation); + } + } + + return ( + + + + + + + ) + } + /> + {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, i) => ( + + )) + )} + + + ); +} + +function LiveParticipantCard({ participant, isLast }) { + const { t } = useTranslation(); + const locs = participant.locations; + const last = locs[0]; + const speedKmh = last?.speed != null ? (last.speed * 3.6).toFixed(1) : '0.0'; + const altitudeM = last?.ele != null ? `${last.ele.toFixed(0)} m` : '—'; + let totalDist = 0; + let maxSpeed = 0; + for (let i = 0; i < locs.length - 1; i++) { + totalDist += getDistance(locs[i].lat, locs[i].lon, locs[i + 1].lat, locs[i + 1].lon); + const kmh = locs[i].speed != null ? locs[i].speed * 3.6 : 0; + if (kmh > maxSpeed) maxSpeed = kmh; + } + if (locs.length > 0) { + const lastKmh = locs.at(-1).speed != null ? locs.at(-1).speed * 3.6 : 0; + if (lastKmh > maxSpeed) maxSpeed = lastKmh; + } + const distKm = (totalDist / 1000).toFixed(2); + const duration = Date.now() - participant.startTime; + const zones = computeZones(locs); + const elevGain = zones.filter((z) => z.eleDiff > 0).reduce((s, z) => s + z.eleDiff, 0); + const elevLoss = zones.filter((z) => z.eleDiff < 0).reduce((s, z) => s + z.eleDiff, 0); + + 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'); + } + + return ( + <> + + } + name={t('shared_string_speed')} + additionalInfo={`${speedKmh} km/h · ${t('web:live_track_updated')} ${getTimeAgo(last?.time)}`} + /> + + } name={t('web:active_state')} additionalInfo={formatTime(duration)} /> + + } name={t('distance')} additionalInfo={`${distKm} km`} /> + + } + name={t('shared_string_max_speed')} + additionalInfo={`${maxSpeed.toFixed(1)} km/h`} + /> + + } name={t('altitude')} additionalInfo={altitudeM} /> + {(elevGain > 0 || elevLoss < 0) && ( + <> + + } + name={t('web:live_track_elevation_gain')} + additionalInfo={`+${elevGain.toFixed(0)} m`} + /> + + } + name={t('web:live_track_elevation_loss')} + additionalInfo={`${elevLoss.toFixed(0)} m`} + /> + + )} + {zones.length > 0 && ( + <> + + + {[...zones].reverse().map((z, i) => ( + + } + name={`${zones.length - i}. ${zoneTypeLabel(z.type)}`} + additionalInfo={`${(z.distance / 1000).toFixed(2)} km · ${z.eleDiff > 0 ? '+' : ''}${z.eleDiff.toFixed(0)} m`} + /> + {i < zones.length - 1 && } + + ))} + + )} + {!isLast && } + + ); +} + +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]]; +} + +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; +} + +function formatTime(ms) { + if (ms < 0) ms = 0; + const h = Math.floor(ms / 3600000); + const m = Math.floor((ms % 3600000) / 60000); + const s = Math.floor((ms % 60000) / 1000); + if (h > 0) return `${h}h ${m}m`; + if (m > 0) return `${m}m ${s}s`; + + return `${s}s`; +} + +function getTimeAgo(timestamp) { + if (!timestamp) return '—'; + const diff = Math.floor((Date.now() - timestamp) / 1000); + if (diff < 10) return 'just now'; + if (diff < 60) return `${diff}s ago`; + if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; + + return `${Math.floor(diff / 3600)}h ago`; +} diff --git a/map/src/menu/tracks/liveTrack/LiveTrackFolder.jsx b/map/src/menu/tracks/liveTrack/LiveTrackFolder.jsx new file mode 100644 index 0000000000..b565febac6 --- /dev/null +++ b/map/src/menu/tracks/liveTrack/LiveTrackFolder.jsx @@ -0,0 +1,59 @@ +import React, { useContext } from 'react'; +import { AppBar, Box, IconButton, Toolbar, Typography } from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router-dom'; +import AppContext from '../../../context/AppContext'; +import { ReactComponent as BackIcon } from '../../../assets/icons/ic_arrow_back.svg'; +import LiveTrackItem from './LiveTrackItem'; +import Empty from '../../errors/Empty'; +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({ removeTranslation }) { + const ctx = useContext(AppContext); + const { t } = useTranslation(); + const navigate = useNavigate(); + const [, height] = useWindowSize(); + + function handleBack() { + ctx.setSelectedLiveTranslation(null); + navigate(MAIN_URL_WITH_SLASH + TRACKS_URL); + } + + return ( + + + + + + + + {t('web:live_tracks')} + + + + {ctx.liveTranslations.length === 0 ? ( + + ) : ( + + {ctx.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..898c98211a --- /dev/null +++ b/map/src/menu/tracks/liveTrack/LiveTrackGroup.jsx @@ -0,0 +1,38 @@ +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 AppContext from '../../../context/AppContext'; +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 ctx = useContext(AppContext); + const { t } = useTranslation(); + const navigate = useNavigate(); + + const count = ctx.liveTranslations.length; + const infoText = count > 0 ? `${count} ${t('shared_string_gpx_files').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..abc5e8b148 --- /dev/null +++ b/map/src/menu/tracks/liveTrack/LiveTrackItem.jsx @@ -0,0 +1,72 @@ +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 AppContext from '../../../context/AppContext'; +import { LIVE_TRACKS_URL, MAIN_URL_WITH_SLASH } from '../../../manager/GlobalManager'; +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, removeTranslation }) { + const ctx = useContext(AppContext); + const { t } = useTranslation(); + const navigate = useNavigate(); + + const [openActions, setOpenActions] = useState(false); + const anchorEl = useRef(null); + + const participants = ctx.liveParticipants?.[translation.id]; + const participantCount = participants ? Object.keys(participants).length : 0; + const infoText = participantCount > 0 ? `${participantCount} online` : t('web:live_track_inactive'); + + function handleClick(e) { + if (anchorEl.current?.contains(e.target)) return; + ctx.setSelectedLiveTranslation(translation); + const params = new URLSearchParams({ tid: translation.id, name: translation.name }); + navigate(`${MAIN_URL_WITH_SLASH}${LIVE_TRACKS_URL}?${params}`); + } + + function handleDelete() { + setOpenActions(false); + removeTranslation(translation.id); + } + + 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 3a8e760c47..321f2177c2 100644 --- a/map/src/resources/translations/en/web-translation.json +++ b/map/src/resources/translations/en/web-translation.json @@ -404,5 +404,20 @@ "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_viewers": "Viewers", + "live_track_follow": "Follow on map", + "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_intervals": "Track intervals", + "live_track_elevation_gain": "Elevation gain", + "live_track_elevation_loss": "Elevation loss", + "live_track_updated": "updated", + "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 index 352524a0a7..faa375a492 100644 --- a/map/src/test/liveTrackSimulator.js +++ b/map/src/test/liveTrackSimulator.js @@ -9,13 +9,15 @@ * --- Start with a point limit (pause after 1000 points) --- * const sim = await window.__liveTrackSim.start({ speed: 30, maxPoints: 1000 }); * - * --- Join an existing translation --- - * const sim = await window.__liveTrackSim.start({ tid: 'abc123', speed: 30 }); + * --- Join an existing translation (e.g. after page refresh or sim.stop()) --- + * const sim = await window.__liveTrackSim.start({ tid: 'abc123' }); + * // tid is printed in the console when the translation is first created, + * // or grab it from the share URL: ?tid= * - * --- Pause (after maxPoints or manually) --- + * --- Pause sending points (sim keeps connected) --- * sim.pause(); * - * --- Resume --- + * --- Resume sending points after pause --- * sim.resume(); * * --- Stop and disconnect --- @@ -27,10 +29,10 @@ * alias — display name (default: 'WebSimulator') * lat — start latitude (default: 50.4501) * lon — start longitude (default: 30.5234) - * speed — km/h (default: 30) + * speed — km/h (default: 20, bicycle pace) * bearing — direction 0-360° (default: 45) * interval — ms between points (default: 2000) - * eleProfile — 'flat' | 'hilly' | 'alpine' (default: 'hilly') + * eleProfile — 'flat' | 'hilly' | 'alpine' (default: 'flat') * maxPoints — stop after N points, then call sim.resume() (default: 0 = infinite) */ @@ -72,20 +74,20 @@ export function start(opts = {}) { alias: opts.alias ?? 'WebSimulator', lat: opts.lat ?? 50.4501, lon: opts.lon ?? 30.5234, - speed: opts.speed ?? 30, + speed: opts.speed ?? 20, bearing: opts.bearing ?? 45, interval: opts.interval ?? 2000, - eleProfile: opts.eleProfile ?? 'hilly', + eleProfile: opts.eleProfile ?? 'flat', maxPoints: opts.maxPoints ?? 0, }; const brokerURL = 'ws://localhost:8080/osmand-websocket'; - const speedMs = options.speed / 3.6; const getEle = makeElevationGenerator(options.eleProfile); let currentLat = options.lat; let currentLon = options.lon; + let currentBearing = options.bearing; let translationId = options.tid; let intervalHandle = null; let pointCount = 0; @@ -149,8 +151,13 @@ export function start(opts = {}) { intervalHandle = setInterval(() => { if (paused) return; - const distStep = speedMs * (options.interval / 1000); - const next = movePoint(currentLat, currentLon, distStep, options.bearing); + 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(); @@ -160,7 +167,7 @@ export function start(opts = {}) { lat: currentLat, lon: currentLon, timestamp: Date.now(), - speed: speedMs, + speed: speedVariation, altitude: ele, }); fetch(`/mapapi/translation/msg?${params}`).catch(() => {}); @@ -202,7 +209,7 @@ export function start(opts = {}) { 'color: orange; font-weight: bold' ); console.log( - ` Speed: ${options.speed} km/h | Bearing: ${options.bearing}° | Profile: ${options.eleProfile}${limitMsg}` + ` Speed: ~${options.speed} km/h (±30% variation) | Bearing: ${options.bearing}° (±20° wander) | Profile: ${options.eleProfile}${limitMsg}` ); startInterval(tid); @@ -220,9 +227,13 @@ export function start(opts = {}) { }, stop: () => { clearInterval(intervalHandle); - client.publish({ destination: `/app/translation/${tid}/stopSharing`, body: '{}' }); + 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'); - setTimeout(() => client.deactivate(), 500); }, }); } diff --git a/map/src/util/hooks/live/useLiveTrackUrl.js b/map/src/util/hooks/live/useLiveTrackUrl.js new file mode 100644 index 0000000000..f2bcd94c69 --- /dev/null +++ b/map/src/util/hooks/live/useLiveTrackUrl.js @@ -0,0 +1,36 @@ +import { useContext, useEffect } from 'react'; +import { useLocation, useSearchParams } from 'react-router-dom'; +import AppContext from '../../../context/AppContext'; +import { LIVE_TRACKS_URL, MAIN_URL_WITH_SLASH } from '../../../manager/GlobalManager'; + +const TID_PARAM = 'tid'; +const NAME_PARAM = 'name'; + +// Derives live track state from the URL and syncs selectedLiveTranslation into context. +// Also handles share URL (?tid=...&name=...) on mount. +export default function useLiveTrackUrl({ addTranslation }) { + const ctx = useContext(AppContext); + const location = useLocation(); + const [searchParams] = useSearchParams(); + + const livePath = MAIN_URL_WITH_SLASH + LIVE_TRACKS_URL; + const openLiveTracks = location.pathname.startsWith(livePath); + const liveTid = openLiveTracks ? new URLSearchParams(location.search).get(TID_PARAM) : null; + const selectedLiveTranslation = liveTid ? (ctx.liveTranslations.find((tr) => tr.id === liveTid) ?? null) : null; + + // Sync derived selectedLiveTranslation into context for LiveTrackLayer. + useEffect(() => { + ctx.setSelectedLiveTranslation(selectedLiveTranslation); + }, [selectedLiveTranslation?.id]); + + // Handle share URL: ?tid=...&name=... (adds translation to list if not yet present). + useEffect(() => { + const tid = searchParams.get(TID_PARAM); + if (tid) { + const name = searchParams.get(NAME_PARAM) ?? ''; + addTranslation(tid, name); + } + }, []); + + return { openLiveTracks, selectedLiveTranslation }; +} diff --git a/map/src/util/hooks/live/useLiveTracking.js b/map/src/util/hooks/live/useLiveTracking.js new file mode 100644 index 0000000000..cebc5c0737 --- /dev/null +++ b/map/src/util/hooks/live/useLiveTracking.js @@ -0,0 +1,191 @@ +import { useContext, useEffect, useRef, useCallback, useState } from 'react'; +import { Client } from '@stomp/stompjs'; +import AppContext, { LIVE_TRACKS_STORAGE_KEY } from '../../../context/AppContext'; +import { getColorByIndex } from '../../../menu/analyzer/util/SegmentColorizer'; + +export default function useLiveTracking() { + const ctx = useContext(AppContext); + + const clientRef = useRef(null); + const subscribedRef = useRef(new Set()); + + const [connected, setConnected] = useState(false); + + // Prepends a new location point to the participant's history. + // Newest point is always 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 ?? []; + return { + ...prev, + [translationId]: { + ...byTranslation, + [nickname]: { + nickname, + color, + startTime: existing?.startTime ?? Date.now(), + locations: [point, ...locations], + }, + }, + }; + }); + }, + [ctx.setLiveParticipants] + ); + + // Handles METADATA message: server's initial snapshot sent in response to /load. + // Sets all participants at once with full track history (allLocations) or last known point. + const handleMetadata = useCallback( + (translationId, data) => { + if (!Array.isArray(data.shareLocations)) return; + + ctx.setLiveParticipants((prev) => { + const byTranslation = { ...(prev[translationId] ?? {}) }; + data.shareLocations.forEach((loc, index) => { + let locations = []; + if (Array.isArray(loc.allLocations)) { + locations = loc.allLocations; + } else if (loc.lastLocation) { + locations = [loc.lastLocation]; + } + const existing = byTranslation[loc.nickname]; + const color = existing?.color ?? getColorByIndex(index, data.shareLocations.length); + byTranslation[loc.nickname] = { + nickname: loc.nickname, + color, + startTime: loc.startTime ?? existing?.startTime ?? Date.now(), + locations, + }; + }); + return { ...prev, [translationId]: byTranslation }; + }); + }, + [ctx.setLiveParticipants] + ); + + // Subscribes to a STOMP topic for the given translation and requests initial data. + // Skips silently if already subscribed. + const subscribeToTranslation = useCallback( + (client, translationId) => { + if (subscribedRef.current.has(translationId)) { + return; + } + + subscribedRef.current.add(translationId); + + client.subscribe(`/topic/translation/${translationId}`, (message) => { + const msg = JSON.parse(message.body); + if (msg.type === 'LOCATION') { + const point = msg.content?.point; + if (msg.sender && point) { + updateParticipant(translationId, msg.sender, point); + } + } 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 }; + }); + } + }); + + client.publish({ destination: `/app/translation/${translationId}/load`, body: '{}' }); + }, + [updateParticipant, handleMetadata, ctx.setLiveViewers] + ); + + // Connect once on mount + useEffect(() => { + const client = new Client({ + brokerURL: process.env.REACT_APP_WS_URL, + reconnectDelay: 5000, + onConnect: () => { + setConnected(true); + }, + onDisconnect: () => setConnected(false), + }); + + client.activate(); + clientRef.current = client; + + return () => { + client.deactivate(); + clientRef.current = null; + subscribedRef.current.clear(); + setConnected(false); + }; + }, []); + + // Subscribe to translations whenever connected or list changes + useEffect(() => { + if (!connected) return; + const client = clientRef.current; + if (!client?.connected) return; + ctx.liveTranslations.forEach((t) => subscribeToTranslation(client, t.id)); + }, [connected, ctx.liveTranslations, subscribeToTranslation]); + + // Adds a new translation from a share link and opens its context menu. + // If the translation already exists, just selects it. + const addTranslation = useCallback( + (id, name) => { + const autoName = name?.trim() || `Live Track ${ctx.liveTranslations.length + 1}`; + const exists = ctx.liveTranslations.some((t) => t.id === id); + if (exists) { + const existing = ctx.liveTranslations.find((t) => t.id === id); + ctx.setSelectedLiveTranslation(existing); + return; + } + + const newTranslation = { id, name: autoName }; + const updated = [...ctx.liveTranslations, newTranslation]; + ctx.setLiveTranslations(updated); + localStorage.setItem(LIVE_TRACKS_STORAGE_KEY, JSON.stringify(updated)); + ctx.setSelectedLiveTranslation(newTranslation); + + const client = clientRef.current; + if (client?.connected) { + subscribeToTranslation(client, id); + } + }, + [ctx.liveTranslations, ctx.setLiveTranslations, ctx.setSelectedLiveTranslation, subscribeToTranslation] + ); + + // Removes a translation from the list, clears its participants, and unsubscribes. + const removeTranslation = useCallback( + (id) => { + const updated = ctx.liveTranslations.filter((t) => t.id !== id); + ctx.setLiveTranslations(updated); + localStorage.setItem(LIVE_TRACKS_STORAGE_KEY, JSON.stringify(updated)); + subscribedRef.current.delete(id); + ctx.setLiveParticipants((prev) => { + const next = { ...prev }; + delete next[id]; + return next; + }); + if (ctx.selectedLiveTranslation?.id === id) { + ctx.setSelectedLiveTranslation(null); + } + }, + [ + ctx.liveTranslations, + ctx.setLiveTranslations, + ctx.setLiveParticipants, + ctx.selectedLiveTranslation, + ctx.setSelectedLiveTranslation, + ] + ); + + return { addTranslation, removeTranslation }; +} From 469497db65737e877831f65d1fb27f80885702b0 Mon Sep 17 00:00:00 2001 From: Kseniia Velychko Date: Thu, 28 May 2026 19:11:26 +0300 Subject: [PATCH 03/40] Move follow button to participant row for per-user map pan --- .../frame/components/titles/SubTitleMenu.jsx | 7 +- map/src/map/layers/LiveTrackLayer.js | 89 ++++++++++--------- .../tracks/liveTrack/LiveTrackContextMenu.jsx | 43 ++++----- 3 files changed, 71 insertions(+), 68 deletions(-) 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/map/layers/LiveTrackLayer.js b/map/src/map/layers/LiveTrackLayer.js index 6d76b86014..55d5543a8b 100644 --- a/map/src/map/layers/LiveTrackLayer.js +++ b/map/src/map/layers/LiveTrackLayer.js @@ -1,4 +1,4 @@ -import { useContext, useEffect, useRef } from 'react'; +import { useContext, useEffect, useRef, useState } from 'react'; import { useMap } from 'react-leaflet'; import L from 'leaflet'; import AppContext from '../../context/AppContext'; @@ -10,28 +10,14 @@ export default function LiveTrackLayer() { // { [translationId]: { [nickname]: { polyline, marker } } } const layersRef = useRef({}); - // track whether we've already panned for the current selection - const pannedForRef = useRef(null); - - function removeTidLayers(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() { - Object.keys(layersRef.current).forEach((tid) => removeTidLayers(tid)); - } + const [pannedFor, setPannedFor] = useState(null); useEffect(() => { const selectedTid = ctx.selectedLiveTranslation?.id ?? null; // Remove layers for any translation that is not currently selected Object.keys(layersRef.current).forEach((tid) => { - if (tid !== selectedTid) removeTidLayers(tid); + if (tid !== selectedTid) removeTidLayers(map, layersRef, tid); }); if (!selectedTid) return; @@ -77,44 +63,25 @@ export default function LiveTrackLayer() { }); }, [ctx.liveParticipants, ctx.selectedLiveTranslation]); - function panToTranslation(translationId) { - const participants = ctx.liveParticipants?.[translationId]; - if (!participants) return false; - - const locs = Object.values(participants) - .map((p) => p.locations?.[0]) - .filter(Boolean); - - if (locs.length === 0) return false; - - if (locs.length === 1) { - map.setView([locs[0].lat, locs[0].lon], Math.max(map.getZoom() || 0, 15)); - } else { - const bounds = L.latLngBounds(locs.map((l) => [l.lat, l.lon])); - map.fitBounds(bounds, { padding: [40, 40] }); - } - return true; - } - // Center map when a translation is selected (if data already loaded) useEffect(() => { const translation = ctx.selectedLiveTranslation; if (!translation) { - pannedForRef.current = null; + setPannedFor(null); return; } - if (pannedForRef.current === translation.id) return; - const panned = panToTranslation(translation.id); - if (panned) pannedForRef.current = translation.id; + if (pannedFor === translation.id) return; + const panned = panToTranslation(map, ctx.liveParticipants, translation.id, ctx.infoBlockWidth); + if (panned) setPannedFor(translation.id); }, [ctx.selectedLiveTranslation]); // Center map when data arrives for the selected translation (if not panned yet) useEffect(() => { const translation = ctx.selectedLiveTranslation; if (!translation) return; - if (pannedForRef.current === translation.id) return; - const panned = panToTranslation(translation.id); - if (panned) pannedForRef.current = translation.id; + if (pannedFor === translation.id) return; + const panned = panToTranslation(map, ctx.liveParticipants, translation.id, ctx.infoBlockWidth); + if (panned) setPannedFor(translation.id); }, [ctx.liveParticipants]); // Pan to location when Follow button is clicked in context menu. @@ -127,8 +94,42 @@ export default function LiveTrackLayer() { // Cleanup on unmount useEffect(() => { - return removeAllLayers; + return () => removeAllLayers(map, layersRef); }, []); return null; } + +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/tracks/liveTrack/LiveTrackContextMenu.jsx b/map/src/menu/tracks/liveTrack/LiveTrackContextMenu.jsx index 996b53ff76..2306cb453b 100644 --- a/map/src/menu/tracks/liveTrack/LiveTrackContextMenu.jsx +++ b/map/src/menu/tracks/liveTrack/LiveTrackContextMenu.jsx @@ -39,19 +39,12 @@ export default function LiveTrackContextMenu() { const participantList = Object.values(participants).filter((p) => p.locations?.length > 0); const viewers = translation ? (ctx.liveViewers?.[translation.id] ?? {}) : {}; const viewerCount = Object.keys(viewers).length; - const followLocation = participantList[0]?.locations?.[0]; function handleBack() { ctx.setSelectedLiveTranslation(null); navigate(MAIN_URL_WITH_SLASH + LIVE_TRACKS_URL); } - function handleFollow() { - if (followLocation?.lat != null && followLocation?.lon != null) { - ctx.setFollowLiveLocation(followLocation); - } - } - return ( - - - - - ) - } /> {participantList.length > 0 && ( <> @@ -112,11 +96,11 @@ export default function LiveTrackContextMenu() { } function LiveParticipantCard({ participant, isLast }) { + const ctx = useContext(AppContext); const { t } = useTranslation(); const locs = participant.locations; - const last = locs[0]; - const speedKmh = last?.speed != null ? (last.speed * 3.6).toFixed(1) : '0.0'; - const altitudeM = last?.ele != null ? `${last.ele.toFixed(0)} m` : '—'; + const speedKmh = locs[0]?.speed != null ? (locs[0].speed * 3.6).toFixed(1) : '0.0'; + const altitudeM = locs[0]?.ele != null ? `${locs[0].ele.toFixed(0)} m` : '—'; let totalDist = 0; let maxSpeed = 0; for (let i = 0; i < locs.length - 1; i++) { @@ -140,13 +124,30 @@ function LiveParticipantCard({ participant, isLast }) { return t('web:shared_string_flat'); } + const lastLoc = locs[0]; + + function handleFollow() { + if (lastLoc?.lat != null && lastLoc?.lon != null) { + ctx.setFollowLiveLocation(lastLoc); + } + } + return ( <> - + + + + + + } + /> } name={t('shared_string_speed')} - additionalInfo={`${speedKmh} km/h · ${t('web:live_track_updated')} ${getTimeAgo(last?.time)}`} + additionalInfo={`${speedKmh} km/h · ${t('web:live_track_updated')} ${getTimeAgo(lastLoc?.time)}`} /> } name={t('web:active_state')} additionalInfo={formatTime(duration)} /> From 4a1982e4ff0774f39fc3979da906257f52ff41a9 Mon Sep 17 00:00:00 2001 From: Kseniia Velychko Date: Fri, 29 May 2026 11:58:34 +0300 Subject: [PATCH 04/40] Add save to Live Tracks button --- map/src/menu/tracks/TracksMenu.jsx | 6 +- .../tracks/liveTrack/LiveTrackContextMenu.jsx | 15 +++- .../menu/tracks/liveTrack/LiveTrackItem.jsx | 6 +- .../translations/en/web-translation.json | 2 + map/src/util/hooks/live/useLiveTrackUrl.js | 30 ++++---- map/src/util/hooks/live/useLiveTracking.js | 77 ++++++++++--------- 6 files changed, 78 insertions(+), 58 deletions(-) diff --git a/map/src/menu/tracks/TracksMenu.jsx b/map/src/menu/tracks/TracksMenu.jsx index 51a42936ae..4b1ac0e527 100644 --- a/map/src/menu/tracks/TracksMenu.jsx +++ b/map/src/menu/tracks/TracksMenu.jsx @@ -47,7 +47,7 @@ export default function TracksMenu() { const { t } = useTranslation(); const { addTranslation, removeTranslation } = useLiveTracking(); - const { openLiveTracks, selectedLiveTranslation } = useLiveTrackUrl({ addTranslation }); + const { openLiveTracks } = useLiveTrackUrl(); const checkHasFiles = () => ctx.tracksGroups?.length > 0 || defaultGroup?.length > 0 || !isEmpty(ctx.shareWithMeFiles?.tracks); @@ -111,8 +111,8 @@ export default function TracksMenu() { // live tracks folder / context menu if (openLiveTracks) { - if (selectedLiveTranslation) { - return ; + if (ctx.selectedLiveTranslation) { + return ; } return ; } diff --git a/map/src/menu/tracks/liveTrack/LiveTrackContextMenu.jsx b/map/src/menu/tracks/liveTrack/LiveTrackContextMenu.jsx index 2306cb453b..924481a253 100644 --- a/map/src/menu/tracks/liveTrack/LiveTrackContextMenu.jsx +++ b/map/src/menu/tracks/liveTrack/LiveTrackContextMenu.jsx @@ -22,13 +22,14 @@ import { ReactComponent as AltitudeIcon } from '../../../assets/icons/ic_action_ 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 trackFavStyles from '../../trackfavmenu.module.css'; import gStyles from '../../gstylesmenu.module.css'; import errorStyles from '../../errors/errors.module.css'; const ZONE_COLORS = { UPHILL: '#d35400', DOWNHILL: '#27ae60', FLAT: '#7f8c8d' }; -export default function LiveTrackContextMenu() { +export default function LiveTrackContextMenu({ addTranslation }) { const ctx = useContext(AppContext); const { t } = useTranslation(); const navigate = useNavigate(); @@ -55,6 +56,18 @@ export default function LiveTrackContextMenu() { title={translation?.name ?? t('web:live_tracks')} onClose={handleBack} showBackButton={true} + rightContent={ + !ctx.liveTranslations.some((t) => t.id === translation?.id) && ( + + addTranslation(translation.id, translation.name)} + > + + + + ) + } /> {participantList.length > 0 && ( <> diff --git a/map/src/menu/tracks/liveTrack/LiveTrackItem.jsx b/map/src/menu/tracks/liveTrack/LiveTrackItem.jsx index abc5e8b148..145cbdca10 100644 --- a/map/src/menu/tracks/liveTrack/LiveTrackItem.jsx +++ b/map/src/menu/tracks/liveTrack/LiveTrackItem.jsx @@ -17,12 +17,14 @@ export default function LiveTrackItem({ translation, isLastItem, removeTranslati const { t } = useTranslation(); const navigate = useNavigate(); - const [openActions, setOpenActions] = useState(false); const anchorEl = useRef(null); + const [openActions, setOpenActions] = useState(false); + const participants = ctx.liveParticipants?.[translation.id]; const participantCount = participants ? Object.keys(participants).length : 0; - const infoText = participantCount > 0 ? `${participantCount} online` : t('web:live_track_inactive'); + 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; diff --git a/map/src/resources/translations/en/web-translation.json b/map/src/resources/translations/en/web-translation.json index 321f2177c2..c304f19da4 100644 --- a/map/src/resources/translations/en/web-translation.json +++ b/map/src/resources/translations/en/web-translation.json @@ -410,8 +410,10 @@ "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_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_intervals": "Track intervals", diff --git a/map/src/util/hooks/live/useLiveTrackUrl.js b/map/src/util/hooks/live/useLiveTrackUrl.js index f2bcd94c69..b09f4f5ec1 100644 --- a/map/src/util/hooks/live/useLiveTrackUrl.js +++ b/map/src/util/hooks/live/useLiveTrackUrl.js @@ -7,30 +7,30 @@ const TID_PARAM = 'tid'; const NAME_PARAM = 'name'; // Derives live track state from the URL and syncs selectedLiveTranslation into context. -// Also handles share URL (?tid=...&name=...) on mount. -export default function useLiveTrackUrl({ addTranslation }) { +// For saved translations — picks from liveTranslations list. +// For preview (share URL, not yet saved) — constructs from URL params directly. +export default function useLiveTrackUrl() { const ctx = useContext(AppContext); const location = useLocation(); const [searchParams] = useSearchParams(); const livePath = MAIN_URL_WITH_SLASH + LIVE_TRACKS_URL; const openLiveTracks = location.pathname.startsWith(livePath); - const liveTid = openLiveTracks ? new URLSearchParams(location.search).get(TID_PARAM) : null; - const selectedLiveTranslation = liveTid ? (ctx.liveTranslations.find((tr) => tr.id === liveTid) ?? null) : null; + const liveTid = openLiveTracks ? searchParams.get(TID_PARAM) : null; - // Sync derived selectedLiveTranslation into context for LiveTrackLayer. useEffect(() => { - ctx.setSelectedLiveTranslation(selectedLiveTranslation); - }, [selectedLiveTranslation?.id]); - - // Handle share URL: ?tid=...&name=... (adds translation to list if not yet present). - useEffect(() => { - const tid = searchParams.get(TID_PARAM); - if (tid) { + if (!liveTid) { + ctx.setSelectedLiveTranslation(null); + return; + } + const fromList = ctx.liveTranslations.find((t) => t.id === liveTid); + if (fromList) { + ctx.setSelectedLiveTranslation(fromList); + } else { const name = searchParams.get(NAME_PARAM) ?? ''; - addTranslation(tid, name); + ctx.setSelectedLiveTranslation({ id: liveTid, name }); } - }, []); + }, [liveTid]); - return { openLiveTracks, selectedLiveTranslation }; + return { openLiveTracks }; } diff --git a/map/src/util/hooks/live/useLiveTracking.js b/map/src/util/hooks/live/useLiveTracking.js index cebc5c0737..c379fcafde 100644 --- a/map/src/util/hooks/live/useLiveTracking.js +++ b/map/src/util/hooks/live/useLiveTracking.js @@ -106,48 +106,17 @@ export default function useLiveTracking() { [updateParticipant, handleMetadata, ctx.setLiveViewers] ); - // Connect once on mount - useEffect(() => { - const client = new Client({ - brokerURL: process.env.REACT_APP_WS_URL, - reconnectDelay: 5000, - onConnect: () => { - setConnected(true); - }, - onDisconnect: () => setConnected(false), - }); - - client.activate(); - clientRef.current = client; - - return () => { - client.deactivate(); - clientRef.current = null; - subscribedRef.current.clear(); - setConnected(false); - }; - }, []); - - // Subscribe to translations whenever connected or list changes - useEffect(() => { - if (!connected) return; - const client = clientRef.current; - if (!client?.connected) return; - ctx.liveTranslations.forEach((t) => subscribeToTranslation(client, t.id)); - }, [connected, ctx.liveTranslations, subscribeToTranslation]); - - // Adds a new translation from a share link and opens its context menu. - // If the translation already exists, just selects it. + // Adds a translation to the saved list and persists to localStorage. + // If already saved, just selects it. const addTranslation = useCallback( (id, name) => { - const autoName = name?.trim() || `Live Track ${ctx.liveTranslations.length + 1}`; - const exists = ctx.liveTranslations.some((t) => t.id === id); - if (exists) { - const existing = ctx.liveTranslations.find((t) => t.id === id); + const existing = ctx.liveTranslations.find((t) => t.id === id); + if (existing) { ctx.setSelectedLiveTranslation(existing); return; } + const autoName = name?.trim() || `Live Track ${ctx.liveTranslations.length + 1}`; const newTranslation = { id, name: autoName }; const updated = [...ctx.liveTranslations, newTranslation]; ctx.setLiveTranslations(updated); @@ -162,7 +131,7 @@ export default function useLiveTracking() { [ctx.liveTranslations, ctx.setLiveTranslations, ctx.setSelectedLiveTranslation, subscribeToTranslation] ); - // Removes a translation from the list, clears its participants, and unsubscribes. + // Removes a translation from the saved list, clears its participants, and unsubscribes. const removeTranslation = useCallback( (id) => { const updated = ctx.liveTranslations.filter((t) => t.id !== id); @@ -187,5 +156,39 @@ export default function useLiveTracking() { ] ); + // Connect once on mount + useEffect(() => { + const client = new Client({ + brokerURL: process.env.REACT_APP_WS_URL, + reconnectDelay: 5000, + onConnect: () => { + setConnected(true); + }, + onDisconnect: () => setConnected(false), + }); + + client.activate(); + clientRef.current = client; + + return () => { + client.deactivate(); + clientRef.current = null; + subscribedRef.current.clear(); + setConnected(false); + }; + }, []); + + // Subscribe to saved translations and to the currently selected (preview) translation + 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.find((t) => t.id === sel.id)) { + subscribeToTranslation(client, sel.id); + } + }, [connected, ctx.liveTranslations, ctx.selectedLiveTranslation, subscribeToTranslation]); + return { addTranslation, removeTranslation }; } From c1bad084c9e60035cbf8b70fd2a72061a0197d5d Mon Sep 17 00:00:00 2001 From: Kseniia Velychko Date: Fri, 29 May 2026 17:29:56 +0300 Subject: [PATCH 05/40] Fix live track history loading --- map/src/map/layers/LiveTrackLayer.js | 6 ++-- map/src/menu/trackfavmenu.module.css | 14 ++++++++- .../tracks/liveTrack/LiveTrackContextMenu.jsx | 10 ++++++- .../menu/tracks/liveTrack/LiveTrackItem.jsx | 2 +- map/src/util/hooks/live/useLiveTracking.js | 30 ++++++++++++++++--- 5 files changed, 53 insertions(+), 9 deletions(-) diff --git a/map/src/map/layers/LiveTrackLayer.js b/map/src/map/layers/LiveTrackLayer.js index 55d5543a8b..30a8d8a055 100644 --- a/map/src/map/layers/LiveTrackLayer.js +++ b/map/src/map/layers/LiveTrackLayer.js @@ -43,8 +43,10 @@ export default function LiveTrackLayer() { const { nickname, color, locations } = participant; if (!locations || locations.length === 0) return; - // newest at index 0, Leaflet needs [lat, lon] - const latLngs = locations.map((l) => [l.lat, l.lon]); + const latLngs = locations + .slice() + .reverse() + .map((l) => [l.lat, l.lon]); const lastLoc = locations[0]; const existing = layersRef.current[selectedTid][nickname]; diff --git a/map/src/menu/trackfavmenu.module.css b/map/src/menu/trackfavmenu.module.css index 857103a5cb..58681bf857 100644 --- a/map/src/menu/trackfavmenu.module.css +++ b/map/src/menu/trackfavmenu.module.css @@ -148,10 +148,22 @@ .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; +} .menuButtonContainer { position: absolute; top: 50%; transform: translateY(-50%); - right: 78px; + margin-left: 20px; } diff --git a/map/src/menu/tracks/liveTrack/LiveTrackContextMenu.jsx b/map/src/menu/tracks/liveTrack/LiveTrackContextMenu.jsx index 924481a253..ded3ece594 100644 --- a/map/src/menu/tracks/liveTrack/LiveTrackContextMenu.jsx +++ b/map/src/menu/tracks/liveTrack/LiveTrackContextMenu.jsx @@ -148,7 +148,15 @@ function LiveParticipantCard({ participant, isLast }) { return ( <> + + {participant.nickname} + + } rightContent={ diff --git a/map/src/menu/tracks/liveTrack/LiveTrackItem.jsx b/map/src/menu/tracks/liveTrack/LiveTrackItem.jsx index 145cbdca10..d96d298551 100644 --- a/map/src/menu/tracks/liveTrack/LiveTrackItem.jsx +++ b/map/src/menu/tracks/liveTrack/LiveTrackItem.jsx @@ -22,7 +22,7 @@ export default function LiveTrackItem({ translation, isLastItem, removeTranslati const [openActions, setOpenActions] = useState(false); const participants = ctx.liveParticipants?.[translation.id]; - const participantCount = participants ? Object.keys(participants).length : 0; + 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'); diff --git a/map/src/util/hooks/live/useLiveTracking.js b/map/src/util/hooks/live/useLiveTracking.js index c379fcafde..416fd07651 100644 --- a/map/src/util/hooks/live/useLiveTracking.js +++ b/map/src/util/hooks/live/useLiveTracking.js @@ -27,6 +27,7 @@ export default function useLiveTracking() { [nickname]: { nickname, color, + active: existing?.active ?? true, startTime: existing?.startTime ?? Date.now(), locations: [point, ...locations], }, @@ -45,22 +46,36 @@ export default function useLiveTracking() { ctx.setLiveParticipants((prev) => { const byTranslation = { ...(prev[translationId] ?? {}) }; + const activeNicknames = new Set(); data.shareLocations.forEach((loc, index) => { - let locations = []; + activeNicknames.add(loc.nickname); + let historyLocations = []; if (Array.isArray(loc.allLocations)) { - locations = loc.allLocations; + historyLocations = loc.allLocations; } else if (loc.lastLocation) { - locations = [loc.lastLocation]; + historyLocations = [loc.lastLocation]; } const existing = byTranslation[loc.nickname]; const color = existing?.color ?? getColorByIndex(index, data.shareLocations.length); + // Keep live points that arrived before history loaded and are newer than history head + const historyHeadTime = historyLocations[0]?.time ?? 0; + const livePoints = (existing?.locations ?? []).filter((p) => (p.time ?? 0) > historyHeadTime); + const combined = [...livePoints, ...historyLocations]; + combined.sort((a, b) => (b.time ?? 0) - (a.time ?? 0)); byTranslation[loc.nickname] = { nickname: loc.nickname, color, + active: true, startTime: loc.startTime ?? existing?.startTime ?? Date.now(), - locations, + locations: combined, }; }); + // 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 }; }); }, @@ -162,6 +177,13 @@ export default function useLiveTracking() { brokerURL: process.env.REACT_APP_WS_URL, reconnectDelay: 5000, onConnect: () => { + // Subscribe to private queue to receive responses to /load (history snapshot) + client.subscribe('/user/queue/updates', (message) => { + const msg = JSON.parse(message.body); + if (msg.type === 'TRANSLATION' && msg.data?.id) { + handleMetadata(msg.data.id, msg.data); + } + }); setConnected(true); }, onDisconnect: () => setConnected(false), From e84b041472071d7f7fc0700157cacada7fda815a Mon Sep 17 00:00:00 2001 From: Kseniia Velychko Date: Tue, 2 Jun 2026 14:14:08 +0300 Subject: [PATCH 06/40] Add live track creation and fix owner sharing controls --- map/src/context/AppContext.js | 6 + map/src/menu/actions/LiveTrackItemActions.jsx | 122 +++++++++-- map/src/menu/trackfavmenu.module.css | 21 ++ map/src/menu/tracks/TracksMenu.jsx | 19 +- .../liveTrack/CreateLiveTrackDialog.jsx | 199 ++++++++++++++++++ .../menu/tracks/liveTrack/LiveTrackFolder.jsx | 34 ++- .../menu/tracks/liveTrack/LiveTrackItem.jsx | 46 +++- .../translations/en/web-translation.json | 17 ++ map/src/util/hooks/live/useLiveTracking.js | 164 ++++++++++++++- 9 files changed, 597 insertions(+), 31 deletions(-) create mode 100644 map/src/menu/tracks/liveTrack/CreateLiveTrackDialog.jsx diff --git a/map/src/context/AppContext.js b/map/src/context/AppContext.js index ec34a47a2c..87dbdbfcd2 100644 --- a/map/src/context/AppContext.js +++ b/map/src/context/AppContext.js @@ -201,6 +201,8 @@ export const AppContextProvider = (props) => { const [liveViewers, setLiveViewers] = useState({}); const [selectedLiveTranslation, setSelectedLiveTranslation] = useState(null); const [followLiveLocation, setFollowLiveLocation] = useState(null); + const [myBroadcastTid, setMyBroadcastTid] = useState(null); + const [isMyBroadcastPaused, setIsMyBroadcastPaused] = useState(false); // selected track const [selectedGpxFile, setSelectedGpxFile] = useState({}); const [unverifiedGpxFile, setUnverifiedGpxFile] = useState(null); // see Effect in LocalClientTrackLayer @@ -763,6 +765,10 @@ export const AppContextProvider = (props) => { setSelectedLiveTranslation, followLiveLocation, setFollowLiveLocation, + myBroadcastTid, + setMyBroadcastTid, + isMyBroadcastPaused, + setIsMyBroadcastPaused, saveTrackToCloud, setSaveTrackToCloud, selectedLocalTrackObj, diff --git a/map/src/menu/actions/LiveTrackItemActions.jsx b/map/src/menu/actions/LiveTrackItemActions.jsx index 904ea4204b..056937168d 100644 --- a/map/src/menu/actions/LiveTrackItemActions.jsx +++ b/map/src/menu/actions/LiveTrackItemActions.jsx @@ -1,29 +1,111 @@ -import React, { forwardRef } from 'react'; +import React, { forwardRef, useContext } from 'react'; import { Box, ListItemIcon, ListItemText, MenuItem, Paper, Typography } from '@mui/material'; import { useTranslation } from 'react-i18next'; +import AppContext from '../../context/AppContext'; 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 styles from '../trackfavmenu.module.css'; -const LiveTrackItemActions = forwardRef(({ handleDelete }, ref) => { - const { t } = useTranslation(); +const LiveTrackItemActions = forwardRef( + ( + { + isOwner, + isSharing, + isParticipant, + handleOwnerSharingAction, + handleParticipantStop, + handleRemoveBookmark, + handleDeleteForAll, + }, + ref + ) => { + const ctx = useContext(AppContext); + const { t } = useTranslation(); - return ( - - - - - - - - - {t('web:live_track_delete')} - - - - - - ); -}); + const ownerSharingLabel = + !isSharing && !ctx.isMyBroadcastPaused + ? 'web:live_track_start_sharing' + : ctx.isMyBroadcastPaused + ? 'web:live_track_resume_sharing' + : 'web:live_track_pause_sharing'; + + const ownerSharingIcon = + (!isSharing && !ctx.isMyBroadcastPaused) || ctx.isMyBroadcastPaused ? : ; + + 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')} + + + + )} + {/* 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/trackfavmenu.module.css b/map/src/menu/trackfavmenu.module.css index 58681bf857..d7d69a16eb 100644 --- a/map/src/menu/trackfavmenu.module.css +++ b/map/src/menu/trackfavmenu.module.css @@ -160,6 +160,27 @@ border-radius: 50%; flex-shrink: 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; diff --git a/map/src/menu/tracks/TracksMenu.jsx b/map/src/menu/tracks/TracksMenu.jsx index 4b1ac0e527..7d2c95feda 100644 --- a/map/src/menu/tracks/TracksMenu.jsx +++ b/map/src/menu/tracks/TracksMenu.jsx @@ -46,7 +46,14 @@ export default function TracksMenu() { const { t } = useTranslation(); - const { addTranslation, removeTranslation } = useLiveTracking(); + const { + addTranslation, + removeTranslation, + createTranslation, + deleteTranslationForAll, + startSharing, + pauseSharing, + } = useLiveTracking(); const { openLiveTracks } = useLiveTrackUrl(); const checkHasFiles = () => @@ -114,7 +121,15 @@ export default function TracksMenu() { if (ctx.selectedLiveTranslation) { return ; } - return ; + return ( + + ); } // open folders diff --git a/map/src/menu/tracks/liveTrack/CreateLiveTrackDialog.jsx b/map/src/menu/tracks/liveTrack/CreateLiveTrackDialog.jsx new file mode 100644 index 0000000000..8f65dc457a --- /dev/null +++ b/map/src/menu/tracks/liveTrack/CreateLiveTrackDialog.jsx @@ -0,0 +1,199 @@ +import React, { 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 dialogStyles from '../../../dialogs/dialog.module.css'; +import styles from '../../trackfavmenu.module.css'; + +const DURATION_MARKS = [{ value: 0 }, { value: 1 }, { value: 4 }, { value: 8 }, { value: 24 }]; + +function durationLabel(value, t) { + if (value === 0) return t('web:live_track_duration_permanent'); + 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'); +} + +function generateKey() { + return Array.from(crypto.getRandomValues(new Uint8Array(16))) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); +} + +export default function CreateLiveTrackDialog({ open, onClose, createTranslation }) { + const { t } = useTranslation(); + const navigate = useNavigate(); + + const [name, setName] = useState(''); + const [duration, setDuration] = useState(0); + 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 (_) { + // permissions API not supported — proceed and let watchPosition handle it + } + } + + setCreating(true); + const key = generateKey(); + createTranslation( + name.trim() || null, + (translation) => { + const urlParams = new URLSearchParams({ tid: translation.id, key }); + if (translation.name) { + urlParams.set('name', translation.name); + } + setShareUrl(`${window.location.origin}/map/live/?${urlParams}`); + setCreating(false); + navigate( + `${MAIN_URL_WITH_SLASH}${LIVE_TRACKS_URL}?tid=${translation.id}&name=${encodeURIComponent(translation.name)}` + ); + }, + (errCode) => { + setGeoError(toGeoErrorKey(errCode)); + setCreating(false); + } + ); + } + + function handleCopy() { + navigator.clipboard.writeText(shareUrl); + setCopied(true); + } + + function handleClose() { + setName(''); + setDuration(0); + 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={0} + max={24} + step={null} + marks={DURATION_MARKS} + size="small" + valueLabelDisplay="off" + sx={{ color: '#237BFF' }} + /> +
+
+ )} +
+ + + {!shareUrl && ( + + )} + +
+ ); +} + +function toGeoErrorKey(code) { + if (code === 'geolocation_denied') return 'web:live_track_geo_denied'; + if (code === 'geolocation_unavailable') return 'web:live_track_geo_unavailable'; + return 'web:live_track_geo_not_supported'; +} diff --git a/map/src/menu/tracks/liveTrack/LiveTrackFolder.jsx b/map/src/menu/tracks/liveTrack/LiveTrackFolder.jsx index b565febac6..40a0955544 100644 --- a/map/src/menu/tracks/liveTrack/LiveTrackFolder.jsx +++ b/map/src/menu/tracks/liveTrack/LiveTrackFolder.jsx @@ -1,21 +1,30 @@ -import React, { useContext } from 'react'; -import { AppBar, Box, IconButton, Toolbar, Typography } from '@mui/material'; +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 AppContext from '../../../context/AppContext'; 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({ removeTranslation }) { +export default function LiveTrackFolder({ + removeTranslation, + createTranslation, + deleteTranslationForAll, + startSharing, + pauseSharing, +}) { const ctx = useContext(AppContext); const { t } = useTranslation(); const navigate = useNavigate(); const [, height] = useWindowSize(); + const [dialogOpen, setDialogOpen] = useState(false); function handleBack() { ctx.setSelectedLiveTranslation(null); @@ -38,18 +47,35 @@ export default function LiveTrackFolder({ removeTranslation }) { {t('web:live_tracks')} + + setDialogOpen(true)} + > + + + + setDialogOpen(false)} + createTranslation={createTranslation} + /> {ctx.liveTranslations.length === 0 ? ( ) : ( - + {ctx.liveTranslations.map((translation, index) => ( ))} diff --git a/map/src/menu/tracks/liveTrack/LiveTrackItem.jsx b/map/src/menu/tracks/liveTrack/LiveTrackItem.jsx index d96d298551..d54938d44b 100644 --- a/map/src/menu/tracks/liveTrack/LiveTrackItem.jsx +++ b/map/src/menu/tracks/liveTrack/LiveTrackItem.jsx @@ -12,7 +12,14 @@ import LiveTrackItemActions from '../../actions/LiveTrackItemActions'; import DividerWithMargin from '../../../frame/components/dividers/DividerWithMargin'; import MenuItemWithLines from '../../components/MenuItemWithLines'; -export default function LiveTrackItem({ translation, isLastItem, removeTranslation }) { +export default function LiveTrackItem({ + translation, + isLastItem, + removeTranslation, + deleteTranslationForAll, + startSharing, + pauseSharing, +}) { const ctx = useContext(AppContext); const { t } = useTranslation(); const navigate = useNavigate(); @@ -21,6 +28,10 @@ export default function LiveTrackItem({ translation, isLastItem, removeTranslati const [openActions, setOpenActions] = useState(false); + const isOwner = translation.isOwner === true; + const isSharing = ctx.myBroadcastTid === translation.id; + const isParticipant = isSharing && !isOwner; + const participants = ctx.liveParticipants?.[translation.id]; const participantCount = participants ? Object.values(participants).filter((p) => p.active !== false).length : 0; const infoText = @@ -33,11 +44,30 @@ export default function LiveTrackItem({ translation, isLastItem, removeTranslati navigate(`${MAIN_URL_WITH_SLASH}${LIVE_TRACKS_URL}?${params}`); } - function handleDelete() { + function handleRemoveBookmark() { setOpenActions(false); removeTranslation(translation.id); } + function handleOwnerSharingAction() { + setOpenActions(false); + if (!isSharing || ctx.isMyBroadcastPaused) { + startSharing(translation.id); + } else { + pauseSharing(); + } + } + + function handleParticipantStop() { + setOpenActions(false); + pauseSharing(); + } + + function handleDeleteForAll() { + setOpenActions(false); + deleteTranslationForAll(translation.id); + } + return ( <> } + actions={ + + } /> ); diff --git a/map/src/resources/translations/en/web-translation.json b/map/src/resources/translations/en/web-translation.json index c304f19da4..18583e2c87 100644 --- a/map/src/resources/translations/en/web-translation.json +++ b/map/src/resources/translations/en/web-translation.json @@ -420,6 +420,23 @@ "live_track_elevation_gain": "Elevation gain", "live_track_elevation_loss": "Elevation loss", "live_track_updated": "updated", + "live_track_create": "Create Live Track", + "live_track_duration_permanent": "Permanent", + "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", "shared_string_flat": "Flat", "shared_string_no_data": "No data" } diff --git a/map/src/util/hooks/live/useLiveTracking.js b/map/src/util/hooks/live/useLiveTracking.js index 416fd07651..fea0609f28 100644 --- a/map/src/util/hooks/live/useLiveTracking.js +++ b/map/src/util/hooks/live/useLiveTracking.js @@ -9,8 +9,35 @@ export default function useLiveTracking() { const clientRef = useRef(null); const subscribedRef = useRef(new Set()); + // Stores { onSuccess, onError } for a pending /create request. + const pendingCreateRef = useRef(null); + const [connected, setConnected] = useState(false); + // Manages geolocation watch: starts when myBroadcastTid is set and not paused, stops otherwise. + useEffect(() => { + if (!ctx.myBroadcastTid || ctx.isMyBroadcastPaused || !navigator.geolocation) return; + + const watchId = navigator.geolocation.watchPosition( + (position) => { + const { latitude, longitude, altitude, speed, accuracy } = position.coords; + const params = new URLSearchParams({ + lat: latitude, + lon: longitude, + timestamp: position.timestamp, + }); + if (speed != null) params.set('speed', speed); + if (altitude != null) params.set('altitude', altitude); + if (accuracy != null) params.set('hdop', accuracy); + fetch(`/mapapi/translation/msg?${params}`).catch(() => {}); + }, + () => {}, + { enableHighAccuracy: true, maximumAge: 5000 } + ); + + return () => navigator.geolocation.clearWatch(watchId); + }, [ctx.myBroadcastTid, ctx.isMyBroadcastPaused]); + // Prepends a new location point to the participant's history. // Newest point is always at index 0. const updateParticipant = useCallback( @@ -113,6 +140,20 @@ export default function useLiveTracking() { delete byTranslation[msg.content]; return { ...prev, [translationId]: byTranslation }; }); + } else if (msg.type === 'DELETE') { + // Owner deleted the translation — remove it from all 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)); + subscribedRef.current.delete(translationId); } }); @@ -171,6 +212,107 @@ export default function useLiveTracking() { ] ); + // Starts (or resumes) sharing for the given translation. + // Also stops sharing any previously active translation. + const startSharing = useCallback( + (translationId) => { + if (ctx.myBroadcastTid && ctx.myBroadcastTid !== translationId) { + clientRef.current?.publish({ + destination: `/app/translation/${ctx.myBroadcastTid}/stopSharing`, + body: '{}', + }); + } + clientRef.current?.publish({ + destination: `/app/translation/${translationId}/startSharing`, + body: '{}', + }); + ctx.setMyBroadcastTid(translationId); + ctx.setIsMyBroadcastPaused(false); + }, + [ctx.myBroadcastTid, ctx.setMyBroadcastTid, ctx.setIsMyBroadcastPaused] + ); + + // Pauses sharing: notifies server and stops geo broadcast, but keeps myBroadcastTid + // so the owner can resume. Also used by participants to stop sharing their location. + const pauseSharing = useCallback(() => { + if (ctx.myBroadcastTid) { + clientRef.current?.publish({ + destination: `/app/translation/${ctx.myBroadcastTid}/stopSharing`, + body: '{}', + }); + } + ctx.setIsMyBroadcastPaused(true); + }, [ctx.myBroadcastTid, ctx.setIsMyBroadcastPaused]); + + // Creates a new translation on the server, saves it to the list, starts + // sharing the user's location, and calls onCreated(translation). + // onGeoError(errorKey) is called if geolocation is denied or unavailable. + const createTranslation = useCallback( + (name, onCreated, onGeoError, onCreateError) => { + // Stop any active sharing before creating a new translation. + if (ctx.myBroadcastTid) { + clientRef.current?.publish({ + destination: `/app/translation/${ctx.myBroadcastTid}/stopSharing`, + body: '{}', + }); + ctx.setMyBroadcastTid(null); + ctx.setIsMyBroadcastPaused(false); + } + + pendingCreateRef.current = { + onSuccess: (id) => { + const autoName = name?.trim() || `Live Track ${ctx.liveTranslations.length + 1}`; + // isOwner: true marks this client as the creator of this translation. + const newTranslation = { id, name: autoName, isOwner: true }; + const updated = [...ctx.liveTranslations, newTranslation]; + ctx.setLiveTranslations(updated); + localStorage.setItem(LIVE_TRACKS_STORAGE_KEY, JSON.stringify(updated)); + ctx.setSelectedLiveTranslation(newTranslation); + const client = clientRef.current; + if (client?.connected) { + subscribeToTranslation(client, id); + } + // Set sharing state — geo watch starts automatically via useEffect. + ctx.setMyBroadcastTid(id); + ctx.setIsMyBroadcastPaused(false); + clientRef.current?.publish({ + destination: `/app/translation/${id}/startSharing`, + body: '{}', + }); + onCreated?.(newTranslation); + }, + onError: onCreateError, + }; + + clientRef.current?.publish({ destination: '/app/translation/create', body: '{}' }); + }, + [ + ctx.myBroadcastTid, + ctx.setMyBroadcastTid, + ctx.setIsMyBroadcastPaused, + ctx.liveTranslations, + ctx.setLiveTranslations, + ctx.setSelectedLiveTranslation, + subscribeToTranslation, + ] + ); + + // Deletes the translation for all viewers. Only works if the current user is the owner. + const deleteTranslationForAll = useCallback( + (id) => { + if (ctx.myBroadcastTid === id) { + clientRef.current?.publish({ + destination: `/app/translation/${id}/stopSharing`, + body: '{}', + }); + ctx.setMyBroadcastTid(null); + ctx.setIsMyBroadcastPaused(false); + } + clientRef.current?.publish({ destination: `/app/translation/${id}/delete`, body: '{}' }); + }, + [ctx.myBroadcastTid, ctx.setMyBroadcastTid, ctx.setIsMyBroadcastPaused] + ); + // Connect once on mount useEffect(() => { const client = new Client({ @@ -178,10 +320,21 @@ export default function useLiveTracking() { reconnectDelay: 5000, onConnect: () => { // Subscribe to private queue to receive responses to /load (history snapshot) + // and to /create (new translation confirmation). client.subscribe('/user/queue/updates', (message) => { const msg = JSON.parse(message.body); if (msg.type === 'TRANSLATION' && msg.data?.id) { - handleMetadata(msg.data.id, msg.data); + if (pendingCreateRef.current && msg.data.shareLocations == null) { + // Response to /create — fire the callback and clear it. + pendingCreateRef.current.onSuccess(msg.data.id); + pendingCreateRef.current = null; + } else { + handleMetadata(msg.data.id, msg.data); + } + } else if (msg.type === 'ERROR' && pendingCreateRef.current) { + // Server rejected /create (e.g. not authenticated). + pendingCreateRef.current.onError?.(msg.data); + pendingCreateRef.current = null; } }); setConnected(true); @@ -212,5 +365,12 @@ export default function useLiveTracking() { } }, [connected, ctx.liveTranslations, ctx.selectedLiveTranslation, subscribeToTranslation]); - return { addTranslation, removeTranslation }; + return { + addTranslation, + removeTranslation, + createTranslation, + deleteTranslationForAll, + startSharing, + pauseSharing, + }; } From 331c18f7dc4580749f4c8d7734a2a9fc3db0d4a9 Mon Sep 17 00:00:00 2001 From: Kseniia Velychko Date: Wed, 3 Jun 2026 12:29:05 +0300 Subject: [PATCH 07/40] Add live track sharing duration parameter --- map/src/menu/tracks/liveTrack/CreateLiveTrackDialog.jsx | 1 + map/src/util/hooks/live/useLiveTracking.js | 7 +++++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/map/src/menu/tracks/liveTrack/CreateLiveTrackDialog.jsx b/map/src/menu/tracks/liveTrack/CreateLiveTrackDialog.jsx index 8f65dc457a..678dd7b5c7 100644 --- a/map/src/menu/tracks/liveTrack/CreateLiveTrackDialog.jsx +++ b/map/src/menu/tracks/liveTrack/CreateLiveTrackDialog.jsx @@ -81,6 +81,7 @@ export default function CreateLiveTrackDialog({ open, onClose, createTranslation const key = generateKey(); createTranslation( name.trim() || null, + duration, (translation) => { const urlParams = new URLSearchParams({ tid: translation.id, key }); if (translation.name) { diff --git a/map/src/util/hooks/live/useLiveTracking.js b/map/src/util/hooks/live/useLiveTracking.js index fea0609f28..cc76afded1 100644 --- a/map/src/util/hooks/live/useLiveTracking.js +++ b/map/src/util/hooks/live/useLiveTracking.js @@ -248,7 +248,7 @@ export default function useLiveTracking() { // sharing the user's location, and calls onCreated(translation). // onGeoError(errorKey) is called if geolocation is denied or unavailable. const createTranslation = useCallback( - (name, onCreated, onGeoError, onCreateError) => { + (name, durationHours, onCreated, onGeoError, onCreateError) => { // Stop any active sharing before creating a new translation. if (ctx.myBroadcastTid) { clientRef.current?.publish({ @@ -284,7 +284,10 @@ export default function useLiveTracking() { onError: onCreateError, }; - clientRef.current?.publish({ destination: '/app/translation/create', body: '{}' }); + clientRef.current?.publish({ + destination: '/app/translation/create', + body: JSON.stringify({ durationHours }), + }); }, [ ctx.myBroadcastTid, From 15aefffa8254b7b567910d7d9a5c7dbe7c892d9c Mon Sep 17 00:00:00 2001 From: Kseniia Velychko Date: Wed, 3 Jun 2026 19:13:32 +0300 Subject: [PATCH 08/40] Live track E2E encryption --- map/src/map/OsmAndMap.jsx | 7 +- map/src/menu/actions/LiveTrackItemActions.jsx | 20 +++ .../liveTrack/CreateLiveTrackDialog.jsx | 25 +-- .../tracks/liveTrack/LiveTrackContextMenu.jsx | 42 +++-- .../menu/tracks/liveTrack/LiveTrackItem.jsx | 9 ++ .../translations/en/web-translation.json | 3 + map/src/test/liveTrackSimulator.js | 78 +++++++--- map/src/util/hooks/live/useLiveTrackUrl.js | 36 +++-- map/src/util/hooks/live/useLiveTracking.js | 147 +++++++++++++++--- map/src/util/livetracks/liveTrackCrypto.js | 69 ++++++++ map/src/util/livetracks/liveTrackUtils.js | 26 ++++ 11 files changed, 394 insertions(+), 68 deletions(-) create mode 100644 map/src/util/livetracks/liveTrackCrypto.js create mode 100644 map/src/util/livetracks/liveTrackUtils.js diff --git a/map/src/map/OsmAndMap.jsx b/map/src/map/OsmAndMap.jsx index 45c952efc2..0f63e15799 100644 --- a/map/src/map/OsmAndMap.jsx +++ b/map/src/map/OsmAndMap.jsx @@ -28,11 +28,16 @@ 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); diff --git a/map/src/menu/actions/LiveTrackItemActions.jsx b/map/src/menu/actions/LiveTrackItemActions.jsx index 056937168d..289fd9fc35 100644 --- a/map/src/menu/actions/LiveTrackItemActions.jsx +++ b/map/src/menu/actions/LiveTrackItemActions.jsx @@ -6,6 +6,7 @@ import { ReactComponent as DeleteIcon } from '../../assets/icons/ic_action_delet 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 styles from '../trackfavmenu.module.css'; const LiveTrackItemActions = forwardRef( @@ -18,6 +19,8 @@ const LiveTrackItemActions = forwardRef( handleParticipantStop, handleRemoveBookmark, handleDeleteForAll, + handleCopyShareLink, + hasShareLink, }, ref ) => { @@ -69,6 +72,23 @@ const LiveTrackItemActions = forwardRef( )} + {/* Owner: copy share link (only if key is available) */} + {isOwner && hasShareLink && ( + + + + + + + {t('web:live_track_copy_share_link')} + + + + )} {/* Everyone: remove from bookmarks */} b.toString(16).padStart(2, '0')) - .join(''); -} - export default function CreateLiveTrackDialog({ open, onClose, createTranslation }) { const { t } = useTranslation(); const navigate = useNavigate(); @@ -78,16 +73,28 @@ export default function CreateLiveTrackDialog({ open, onClose, createTranslation } setCreating(true); - const key = generateKey(); + + let key, translationId; + try { + key = await generateTranslationKey(); + translationId = await computeTranslationId(key); + } catch (_) { + setCreateError(t('web:live_track_key_gen_error')); + setCreating(false); + return; + } + createTranslation( + translationId, + key, name.trim() || null, duration, (translation) => { - const urlParams = new URLSearchParams({ tid: translation.id, key }); + const urlParams = new URLSearchParams({ tid: translation.id }); if (translation.name) { urlParams.set('name', translation.name); } - setShareUrl(`${window.location.origin}/map/live/?${urlParams}`); + setShareUrl(`${globalThis.location.origin}/map/live/?${urlParams}#${key}`); setCreating(false); navigate( `${MAIN_URL_WITH_SLASH}${LIVE_TRACKS_URL}?tid=${translation.id}&name=${encodeURIComponent(translation.name)}` diff --git a/map/src/menu/tracks/liveTrack/LiveTrackContextMenu.jsx b/map/src/menu/tracks/liveTrack/LiveTrackContextMenu.jsx index ded3ece594..7a7e50aee2 100644 --- a/map/src/menu/tracks/liveTrack/LiveTrackContextMenu.jsx +++ b/map/src/menu/tracks/liveTrack/LiveTrackContextMenu.jsx @@ -1,9 +1,11 @@ -import React, { useContext } from 'react'; +import React, { useContext, useState } from 'react'; import { Box, Icon, IconButton, ListItemText, Tooltip } from '@mui/material'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import AppContext from '../../../context/AppContext'; import { HEADER_SIZE, LIVE_TRACKS_URL, MAIN_URL_WITH_SLASH } from '../../../manager/GlobalManager'; +import { buildLiveTrackShareUrl } from '../../../util/livetracks/liveTrackUtils'; +import { ReactComponent as ShareLinkIcon } from '../../../assets/icons/ic_action_link.svg'; import { useWindowSize } from '../../../util/hooks/useWindowSize'; import { getDistance } from '../../../util/Utils'; import HeaderNoUnderline from '../../../frame/components/header/HeaderNoUnderline'; @@ -34,6 +36,7 @@ export default function LiveTrackContextMenu({ addTranslation }) { const { t } = useTranslation(); const navigate = useNavigate(); const [, height] = useWindowSize(); + const [linkCopied, setLinkCopied] = useState(false); const translation = ctx.selectedLiveTranslation; const participants = translation ? (ctx.liveParticipants?.[translation.id] ?? {}) : {}; @@ -46,6 +49,12 @@ export default function LiveTrackContextMenu({ addTranslation }) { navigate(MAIN_URL_WITH_SLASH + LIVE_TRACKS_URL); } + function handleCopyShareLink() { + const url = buildLiveTrackShareUrl(translation); + if (!url) return; + navigator.clipboard.writeText(url).then(() => setLinkCopied(true)); + } + return ( t.id === translation?.id) && ( - - addTranslation(translation.id, translation.name)} + <> + {translation?.isOwner && translation?.key && ( + - - - - ) + + + +
+ )} + {!ctx.liveTranslations.some((t) => t.id === translation?.id) && ( + + addTranslation(translation.id, translation.name, translation.key)} + > + + + + )} + } /> {participantList.length > 0 && ( diff --git a/map/src/menu/tracks/liveTrack/LiveTrackItem.jsx b/map/src/menu/tracks/liveTrack/LiveTrackItem.jsx index d54938d44b..94642e4bd1 100644 --- a/map/src/menu/tracks/liveTrack/LiveTrackItem.jsx +++ b/map/src/menu/tracks/liveTrack/LiveTrackItem.jsx @@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import AppContext from '../../../context/AppContext'; import { LIVE_TRACKS_URL, MAIN_URL_WITH_SLASH } from '../../../manager/GlobalManager'; +import { buildLiveTrackShareUrl } 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'; @@ -68,6 +69,12 @@ export default function LiveTrackItem({ deleteTranslationForAll(translation.id); } + function handleCopyShareLink() { + setOpenActions(false); + const url = buildLiveTrackShareUrl(translation); + if (url) navigator.clipboard.writeText(url).catch(() => {}); + } + return ( <> } /> diff --git a/map/src/resources/translations/en/web-translation.json b/map/src/resources/translations/en/web-translation.json index 62a3bd4647..aaffaaecc0 100644 --- a/map/src/resources/translations/en/web-translation.json +++ b/map/src/resources/translations/en/web-translation.json @@ -438,6 +438,9 @@ "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_link_copied": "Link copied", + "live_track_key_gen_error": "Failed to generate encryption key. Please try again.", "shared_string_flat": "Flat", "shared_string_no_data": "No data" } diff --git a/map/src/test/liveTrackSimulator.js b/map/src/test/liveTrackSimulator.js index faa375a492..1cbff572e4 100644 --- a/map/src/test/liveTrackSimulator.js +++ b/map/src/test/liveTrackSimulator.js @@ -37,6 +37,12 @@ */ import { Client } from '@stomp/stompjs'; +import { + generateTranslationKey, + computeTranslationId, + encryptLocation, + decryptLocation, +} from '../util/livetracks/liveTrackCrypto'; function movePoint(lat, lon, distanceMeters, bearingDeg) { const R = 6371000; @@ -89,10 +95,12 @@ export function start(opts = {}) { 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({ @@ -109,15 +117,18 @@ export function start(opts = {}) { client.subscribe('/user/queue/updates', (message) => { const msg = JSON.parse(message.body); - - if (msg.type === 'TRANSLATION' && msg.data?.id && !translationId) { - translationId = msg.data.id; - console.log('%c📍 Translation ready!', 'color: blue; font-weight: bold'); - console.log(` tid: ${translationId}`); - console.log( - ` Share URL: ${globalThis.location.origin}/map/live/?tid=${translationId}&name=${encodeURIComponent(options.alias)}` - ); - subscribeAndSimulate(translationId); + 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') { @@ -135,8 +146,21 @@ export function start(opts = {}) { console.log(`🔗 Joining translation: ${options.tid}`); subscribeAndSimulate(options.tid); } else { - console.log('📡 Creating new translation...'); - client.publish({ destination: '/app/translation/create', body: '{}' }); + console.log('📡 Creating new encrypted translation...'); + generateTranslationKey() + .then((key) => { + encKey = key; + return computeTranslationId(key); + }) + .then((tid) => { + translationId = tid; + pendingConfirmation = true; + client.publish({ + destination: '/app/translation/create', + body: JSON.stringify({ translationId: tid }), + }); + }) + .catch((err) => console.error('❌ Key generation failed:', err)); } }, @@ -163,14 +187,19 @@ export function start(opts = {}) { const ele = getEle(); pointCount++; - const params = new URLSearchParams({ + if (!encKey) return; + const locationData = { lat: currentLat, lon: currentLon, - timestamp: Date.now(), + time: Date.now(), speed: speedVariation, - altitude: ele, - }); - fetch(`/mapapi/translation/msg?${params}`).catch(() => {}); + ele, + }; + encryptLocation(encKey, locationData) + .then((encData) => { + fetch(`/mapapi/translation/msg?encryptedData=${encodeURIComponent(encData)}`).catch(() => {}); + }) + .catch(() => {}); if (options.maxPoints > 0 && pointCount >= options.maxPoints) { paused = true; @@ -186,14 +215,23 @@ export function start(opts = {}) { 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; - if (pt) { - const spd = Number.isFinite(pt.speed) ? (pt.speed * 3.6).toFixed(1) + 'km/h' : '-'; - const ele = Number.isFinite(pt.ele) ? pt.ele + 'm' : '-'; + 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=${pt.lat?.toFixed(5)} lon=${pt.lon?.toFixed(5)} spd=${spd} ele=${ele}` + `📍 ${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`); diff --git a/map/src/util/hooks/live/useLiveTrackUrl.js b/map/src/util/hooks/live/useLiveTrackUrl.js index b09f4f5ec1..546a34b671 100644 --- a/map/src/util/hooks/live/useLiveTrackUrl.js +++ b/map/src/util/hooks/live/useLiveTrackUrl.js @@ -2,13 +2,15 @@ import { useContext, useEffect } from 'react'; import { useLocation, useSearchParams } from 'react-router-dom'; import AppContext from '../../../context/AppContext'; import { LIVE_TRACKS_URL, MAIN_URL_WITH_SLASH } from '../../../manager/GlobalManager'; +import { LIVE_TRACK_KEY_SESSION } from '../../livetracks/liveTrackUtils'; const TID_PARAM = 'tid'; const NAME_PARAM = 'name'; +const KEY_HEX_RE = /^[0-9a-f]{64}$/; -// Derives live track state from the URL and syncs selectedLiveTranslation into context. -// For saved translations — picks from liveTranslations list. -// For preview (share URL, not yet saved) — constructs from URL params directly. +// Share URL format: /map/live/?tid=<16chars>&name=# +// Key is extracted from the fragment by OsmAndMap before leaflet-hash runs, +// saved to sessionStorage, and read here. export default function useLiveTrackUrl() { const ctx = useContext(AppContext); const location = useLocation(); @@ -16,21 +18,37 @@ export default function useLiveTrackUrl() { const livePath = MAIN_URL_WITH_SLASH + LIVE_TRACKS_URL; const openLiveTracks = location.pathname.startsWith(livePath); - const liveTid = openLiveTracks ? searchParams.get(TID_PARAM) : null; useEffect(() => { - if (!liveTid) { + if (!openLiveTracks) { ctx.setSelectedLiveTranslation(null); return; } - const fromList = ctx.liveTranslations.find((t) => t.id === liveTid); + + const tid = searchParams.get(TID_PARAM); + if (!tid) { + ctx.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 = ctx.liveTranslations.find((t) => t.id === tid); if (fromList) { - ctx.setSelectedLiveTranslation(fromList); + const entry = key && !fromList.key ? { ...fromList, key } : fromList; + ctx.setSelectedLiveTranslation(entry); } else { const name = searchParams.get(NAME_PARAM) ?? ''; - ctx.setSelectedLiveTranslation({ id: liveTid, name }); + ctx.setSelectedLiveTranslation({ id: tid, name, ...(key ? { key } : {}) }); } - }, [liveTid]); + }, [openLiveTracks, location.search]); return { openLiveTracks }; } diff --git a/map/src/util/hooks/live/useLiveTracking.js b/map/src/util/hooks/live/useLiveTracking.js index cc76afded1..8e9b903807 100644 --- a/map/src/util/hooks/live/useLiveTracking.js +++ b/map/src/util/hooks/live/useLiveTracking.js @@ -2,6 +2,10 @@ import { useContext, useEffect, useRef, useCallback, useState } from 'react'; import { Client } from '@stomp/stompjs'; import AppContext, { LIVE_TRACKS_STORAGE_KEY } from '../../../context/AppContext'; import { getColorByIndex } from '../../../menu/analyzer/util/SegmentColorizer'; +import { encryptLocation, decryptLocation } from '../../livetracks/liveTrackCrypto'; + +// sessionStorage key to restore the active broadcast tid after page refresh. +const BROADCAST_TID_SESSION = '__liveTrackBroadcastTid__'; export default function useLiveTracking() { const ctx = useContext(AppContext); @@ -14,6 +18,18 @@ export default function useLiveTracking() { const [connected, setConnected] = useState(false); + // Maps translationId → encryption key (hex string). + const keysRef = useRef({}); + + // Sync keysRef from saved translations and the current preview translation. + // Runs whenever either changes 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]); + // Manages geolocation watch: starts when myBroadcastTid is set and not paused, stops otherwise. useEffect(() => { if (!ctx.myBroadcastTid || ctx.isMyBroadcastPaused || !navigator.geolocation) return; @@ -21,15 +37,21 @@ export default function useLiveTracking() { const watchId = navigator.geolocation.watchPosition( (position) => { const { latitude, longitude, altitude, speed, accuracy } = position.coords; - const params = new URLSearchParams({ + const key = keysRef.current[ctx.myBroadcastTid]; + if (!key) return; + const locationData = { lat: latitude, lon: longitude, - timestamp: position.timestamp, - }); - if (speed != null) params.set('speed', speed); - if (altitude != null) params.set('altitude', altitude); - if (accuracy != null) params.set('hdop', accuracy); - fetch(`/mapapi/translation/msg?${params}`).catch(() => {}); + time: position.timestamp, + ...(speed != null && { speed }), + ...(altitude != null && { ele: altitude }), + ...(accuracy != null && { hdop: accuracy }), + }; + encryptLocation(key, locationData) + .then((encData) => { + fetch(`/mapapi/translation/msg?encryptedData=${encodeURIComponent(encData)}`).catch(() => {}); + }) + .catch(() => {}); }, () => {}, { enableHighAccuracy: true, maximumAge: 5000 } @@ -122,9 +144,16 @@ export default function useLiveTracking() { client.subscribe(`/topic/translation/${translationId}`, (message) => { const msg = JSON.parse(message.body); if (msg.type === 'LOCATION') { - const point = msg.content?.point; - if (msg.sender && point) { - updateParticipant(translationId, msg.sender, point); + 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); @@ -154,6 +183,7 @@ export default function useLiveTracking() { }); ctx.setSelectedLiveTranslation((sel) => (sel?.id === translationId ? null : sel)); subscribedRef.current.delete(translationId); + delete keysRef.current[translationId]; } }); @@ -163,17 +193,28 @@ export default function useLiveTracking() { ); // Adds a translation to the saved list and persists to localStorage. - // If already saved, just selects it. + // key (hex string) is optional — required for decryption; stored with the translation. + // If already saved, just selects it (and updates the key if a better one is provided). const addTranslation = useCallback( - (id, name) => { + (id, name, key) => { const existing = ctx.liveTranslations.find((t) => t.id === id); if (existing) { - ctx.setSelectedLiveTranslation(existing); + // Update key if we now have one but the stored translation doesn't. + if (key && !existing.key) { + const updated = ctx.liveTranslations.map((t) => (t.id === id ? { ...t, key } : t)); + ctx.setLiveTranslations(updated); + localStorage.setItem(LIVE_TRACKS_STORAGE_KEY, JSON.stringify(updated)); + 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 }; + const newTranslation = { id, name: autoName, ...(key ? { key } : {}) }; + if (key) keysRef.current[id] = key; const updated = [...ctx.liveTranslations, newTranslation]; ctx.setLiveTranslations(updated); localStorage.setItem(LIVE_TRACKS_STORAGE_KEY, JSON.stringify(updated)); @@ -194,6 +235,7 @@ export default function useLiveTracking() { ctx.setLiveTranslations(updated); localStorage.setItem(LIVE_TRACKS_STORAGE_KEY, JSON.stringify(updated)); subscribedRef.current.delete(id); + delete keysRef.current[id]; ctx.setLiveParticipants((prev) => { const next = { ...prev }; delete next[id]; @@ -226,6 +268,7 @@ export default function useLiveTracking() { destination: `/app/translation/${translationId}/startSharing`, body: '{}', }); + sessionStorage.setItem(BROADCAST_TID_SESSION, translationId); ctx.setMyBroadcastTid(translationId); ctx.setIsMyBroadcastPaused(false); }, @@ -241,14 +284,16 @@ export default function useLiveTracking() { body: '{}', }); } + sessionStorage.removeItem(BROADCAST_TID_SESSION); ctx.setIsMyBroadcastPaused(true); }, [ctx.myBroadcastTid, ctx.setIsMyBroadcastPaused]); - // Creates a new translation on the server, saves it to the list, starts - // sharing the user's location, and calls onCreated(translation). + // Creates a new translation on the server with a client-generated translationId and key. + // The translationId MUST be SHA-256(key) so that the server ID is derivable from the key. + // Saves the translation (with key) to the list, starts sharing, and calls onCreated(translation). // onGeoError(errorKey) is called if geolocation is denied or unavailable. const createTranslation = useCallback( - (name, durationHours, onCreated, onGeoError, onCreateError) => { + (translationId, key, name, durationHours, onCreated, onGeoError, onCreateError) => { // Stop any active sharing before creating a new translation. if (ctx.myBroadcastTid) { clientRef.current?.publish({ @@ -263,7 +308,8 @@ export default function useLiveTracking() { onSuccess: (id) => { const autoName = name?.trim() || `Live Track ${ctx.liveTranslations.length + 1}`; // isOwner: true marks this client as the creator of this translation. - const newTranslation = { id, name: autoName, isOwner: true }; + const newTranslation = { id, name: autoName, key, isOwner: true }; + keysRef.current[id] = key; const updated = [...ctx.liveTranslations, newTranslation]; ctx.setLiveTranslations(updated); localStorage.setItem(LIVE_TRACKS_STORAGE_KEY, JSON.stringify(updated)); @@ -273,6 +319,7 @@ export default function useLiveTracking() { subscribeToTranslation(client, id); } // Set sharing state — geo watch starts automatically via useEffect. + sessionStorage.setItem(BROADCAST_TID_SESSION, id); ctx.setMyBroadcastTid(id); ctx.setIsMyBroadcastPaused(false); clientRef.current?.publish({ @@ -286,7 +333,7 @@ export default function useLiveTracking() { clientRef.current?.publish({ destination: '/app/translation/create', - body: JSON.stringify({ durationHours }), + body: JSON.stringify({ translationId, durationHours }), }); }, [ @@ -308,6 +355,7 @@ export default function useLiveTracking() { destination: `/app/translation/${id}/stopSharing`, body: '{}', }); + sessionStorage.removeItem(BROADCAST_TID_SESSION); ctx.setMyBroadcastTid(null); ctx.setIsMyBroadcastPaused(false); } @@ -316,6 +364,57 @@ export default function useLiveTracking() { [ctx.myBroadcastTid, ctx.setMyBroadcastTid, ctx.setIsMyBroadcastPaused] ); + // Decrypts encrypted LOCATION messages from the /load history snapshot and + // populates liveParticipants so the last known positions are visible immediately. + const processEncryptedHistory = useCallback( + (translationId, history) => { + 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; + + 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, + color, + active: existing?.active ?? true, + startTime: existing?.startTime ?? Date.now(), + locations: combined, + }; + }); + return { ...prev, [translationId]: byTranslation }; + }); + }); + }, + [ctx.setLiveParticipants] + ); + // Connect once on mount useEffect(() => { const client = new Client({ @@ -333,6 +432,7 @@ export default function useLiveTracking() { pendingCreateRef.current = null; } else { handleMetadata(msg.data.id, msg.data); + processEncryptedHistory(msg.data.id, msg.data.history); } } else if (msg.type === 'ERROR' && pendingCreateRef.current) { // Server rejected /create (e.g. not authenticated). @@ -366,6 +466,15 @@ export default function useLiveTracking() { if (sel && !ctx.liveTranslations.find((t) => t.id === sel.id)) { subscribeToTranslation(client, sel.id); } + // Restore active sharing session after page refresh + if (!ctx.myBroadcastTid) { + const savedTid = sessionStorage.getItem(BROADCAST_TID_SESSION); + if (savedTid && ctx.liveTranslations.find((t) => t.id === savedTid)) { + ctx.setMyBroadcastTid(savedTid); + ctx.setIsMyBroadcastPaused(false); + client.publish({ destination: `/app/translation/${savedTid}/startSharing`, body: '{}' }); + } + } }, [connected, ctx.liveTranslations, ctx.selectedLiveTranslation, subscribeToTranslation]); return { diff --git a/map/src/util/livetracks/liveTrackCrypto.js b/map/src/util/livetracks/liveTrackCrypto.js new file mode 100644 index 0000000000..2f7459f365 --- /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 SHA-256 of that 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..8dc3732740 --- /dev/null +++ b/map/src/util/livetracks/liveTrackUtils.js @@ -0,0 +1,26 @@ +import { LIVE_TRACKS_URL, MAIN_URL_WITH_SLASH } from '../../manager/GlobalManager'; + +// sessionStorage key used to pass the live-track share key across the +// leaflet-hash initialization that would otherwise overwrite the URL fragment. +export const LIVE_TRACK_KEY_SESSION = '__liveTrackShareKey__'; + +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: translation.id }); + if (translation.name) params.set('name', 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); + } catch (_) {} + history.replaceState(null, '', globalThis.location.pathname + globalThis.location.search); + return true; +} From 5911bc72d1f52d2e54eaa1f91335ba666edda622 Mon Sep 17 00:00:00 2001 From: Kseniia Velychko Date: Thu, 4 Jun 2026 13:36:11 +0300 Subject: [PATCH 09/40] Fix live tracking reconnect and adapt to encrypted-only location protocol --- map/src/util/hooks/live/useLiveTracking.js | 33 +++++++++------------- 1 file changed, 13 insertions(+), 20 deletions(-) diff --git a/map/src/util/hooks/live/useLiveTracking.js b/map/src/util/hooks/live/useLiveTracking.js index 8e9b903807..cb0acd14d6 100644 --- a/map/src/util/hooks/live/useLiveTracking.js +++ b/map/src/util/hooks/live/useLiveTracking.js @@ -30,9 +30,10 @@ export default function useLiveTracking() { if (ctx.selectedLiveTranslation?.key) keysRef.current[ctx.selectedLiveTranslation.id] = ctx.selectedLiveTranslation.key; }, [ctx.liveTranslations, ctx.selectedLiveTranslation]); - // Manages geolocation watch: starts when myBroadcastTid is set and not paused, stops otherwise. + // Manages geolocation watch: starts when myBroadcastTid is set, not paused, and STOMP is connected. + // Stopping when disconnected prevents 404s during server restart / STOMP reconnect. useEffect(() => { - if (!ctx.myBroadcastTid || ctx.isMyBroadcastPaused || !navigator.geolocation) return; + if (!ctx.myBroadcastTid || ctx.isMyBroadcastPaused || !navigator.geolocation || !connected) return; const watchId = navigator.geolocation.watchPosition( (position) => { @@ -58,7 +59,7 @@ export default function useLiveTracking() { ); return () => navigator.geolocation.clearWatch(watchId); - }, [ctx.myBroadcastTid, ctx.isMyBroadcastPaused]); + }, [ctx.myBroadcastTid, ctx.isMyBroadcastPaused, connected]); // Prepends a new location point to the participant's history. // Newest point is always at index 0. @@ -88,7 +89,8 @@ export default function useLiveTracking() { ); // Handles METADATA message: server's initial snapshot sent in response to /load. - // Sets all participants at once with full track history (allLocations) or last known point. + // Marks participants as active/inactive. Encrypted location history is populated + // separately by processEncryptedHistory. const handleMetadata = useCallback( (translationId, data) => { if (!Array.isArray(data.shareLocations)) return; @@ -98,25 +100,13 @@ export default function useLiveTracking() { const activeNicknames = new Set(); data.shareLocations.forEach((loc, index) => { activeNicknames.add(loc.nickname); - let historyLocations = []; - if (Array.isArray(loc.allLocations)) { - historyLocations = loc.allLocations; - } else if (loc.lastLocation) { - historyLocations = [loc.lastLocation]; - } const existing = byTranslation[loc.nickname]; - const color = existing?.color ?? getColorByIndex(index, data.shareLocations.length); - // Keep live points that arrived before history loaded and are newer than history head - const historyHeadTime = historyLocations[0]?.time ?? 0; - const livePoints = (existing?.locations ?? []).filter((p) => (p.time ?? 0) > historyHeadTime); - const combined = [...livePoints, ...historyLocations]; - combined.sort((a, b) => (b.time ?? 0) - (a.time ?? 0)); byTranslation[loc.nickname] = { nickname: loc.nickname, - color, + color: existing?.color ?? getColorByIndex(index, data.shareLocations.length), active: true, startTime: loc.startTime ?? existing?.startTime ?? Date.now(), - locations: combined, + locations: existing?.locations ?? [], }; }); // Mark participants no longer sharing as inactive @@ -466,8 +456,11 @@ export default function useLiveTracking() { if (sel && !ctx.liveTranslations.find((t) => t.id === sel.id)) { subscribeToTranslation(client, sel.id); } - // Restore active sharing session after page refresh - if (!ctx.myBroadcastTid) { + // Re-register sharing session with the server on every reconnect + if (ctx.myBroadcastTid && !ctx.isMyBroadcastPaused) { + client.publish({ destination: `/app/translation/${ctx.myBroadcastTid}/startSharing`, body: '{}' }); + } else if (!ctx.myBroadcastTid) { + // Restore from sessionStorage after page refresh. const savedTid = sessionStorage.getItem(BROADCAST_TID_SESSION); if (savedTid && ctx.liveTranslations.find((t) => t.id === savedTid)) { ctx.setMyBroadcastTid(savedTid); From 5ae18141ef6d765125337b697510fe2ffeb86f24 Mon Sep 17 00:00:00 2001 From: Kseniia Velychko Date: Fri, 5 Jun 2026 11:54:59 +0300 Subject: [PATCH 10/40] Rename live track functions and restrict live tracks page for unauthenticated users --- map/src/menu/tracks/TracksMenu.jsx | 30 +++++++++---------- .../liveTrack/CreateLiveTrackDialog.jsx | 4 +-- .../tracks/liveTrack/LiveTrackContextMenu.jsx | 29 ++++++++++++------ .../menu/tracks/liveTrack/LiveTrackFolder.jsx | 12 ++++---- .../menu/tracks/liveTrack/LiveTrackItem.jsx | 8 ++--- .../translations/en/web-translation.json | 1 + map/src/util/hooks/live/useLiveTracking.js | 16 +++++----- 7 files changed, 56 insertions(+), 44 deletions(-) diff --git a/map/src/menu/tracks/TracksMenu.jsx b/map/src/menu/tracks/TracksMenu.jsx index 1de1fb50e9..8720eff135 100644 --- a/map/src/menu/tracks/TracksMenu.jsx +++ b/map/src/menu/tracks/TracksMenu.jsx @@ -24,8 +24,8 @@ import LiveTrackFolder from './liveTrack/LiveTrackFolder'; import LiveTrackContextMenu from './liveTrack/LiveTrackContextMenu'; import useLiveTracking from '../../util/hooks/live/useLiveTracking'; import useLiveTrackUrl from '../../util/hooks/live/useLiveTrackUrl'; -import { MAIN_URL_WITH_SLASH, MENU_IDS, VISIBLE_TRACKS_URL, liveHash } from '../../manager/GlobalManager'; -import { useLocation, useNavigate } from 'react-router-dom'; +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'; @@ -41,19 +41,14 @@ export default function TracksMenu() { const navigate = useNavigate(); const location = useLocation(); + const [searchParams] = useSearchParams(); const [, height] = useWindowSize(); const { t } = useTranslation(); - const { - addTranslation, - removeTranslation, - createTranslation, - deleteTranslationForAll, - startSharing, - pauseSharing, - } = useLiveTracking(); + const { addLiveTrack, removeLiveTrack, createLiveTrack, deleteLiveTrack, startSharing, pauseSharing } = + useLiveTracking(); const { openLiveTracks } = useLiveTrackUrl(); const checkHasFiles = () => @@ -116,16 +111,21 @@ export default function TracksMenu() { return ; } - // live tracks folder / context menu + // live tracks folder + if (openLiveTracks && !ltx.loginUser && !searchParams.get('tid')) { + navigate(MAIN_URL_WITH_SLASH + LOGIN_URL + location.search + location.hash, { replace: true }); + return null; + } + if (openLiveTracks) { if (ctx.selectedLiveTranslation) { - return ; + return ; } return ( diff --git a/map/src/menu/tracks/liveTrack/CreateLiveTrackDialog.jsx b/map/src/menu/tracks/liveTrack/CreateLiveTrackDialog.jsx index 6e25780d17..3054283763 100644 --- a/map/src/menu/tracks/liveTrack/CreateLiveTrackDialog.jsx +++ b/map/src/menu/tracks/liveTrack/CreateLiveTrackDialog.jsx @@ -31,7 +31,7 @@ function durationLabel(value, t) { return t('web:live_track_duration_24h'); } -export default function CreateLiveTrackDialog({ open, onClose, createTranslation }) { +export default function CreateLiveTrackDialog({ open, onClose, createLiveTrack }) { const { t } = useTranslation(); const navigate = useNavigate(); @@ -84,7 +84,7 @@ export default function CreateLiveTrackDialog({ open, onClose, createTranslation return; } - createTranslation( + createLiveTrack( translationId, key, name.trim() || null, diff --git a/map/src/menu/tracks/liveTrack/LiveTrackContextMenu.jsx b/map/src/menu/tracks/liveTrack/LiveTrackContextMenu.jsx index 7a7e50aee2..92edb6abe0 100644 --- a/map/src/menu/tracks/liveTrack/LiveTrackContextMenu.jsx +++ b/map/src/menu/tracks/liveTrack/LiveTrackContextMenu.jsx @@ -3,6 +3,7 @@ import { Box, Icon, IconButton, ListItemText, Tooltip } from '@mui/material'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import AppContext from '../../../context/AppContext'; +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 { ReactComponent as ShareLinkIcon } from '../../../assets/icons/ic_action_link.svg'; @@ -31,8 +32,9 @@ import errorStyles from '../../errors/errors.module.css'; const ZONE_COLORS = { UPHILL: '#d35400', DOWNHILL: '#27ae60', FLAT: '#7f8c8d' }; -export default function LiveTrackContextMenu({ addTranslation }) { +export default function LiveTrackContextMenu({ addLiveTrack }) { const ctx = useContext(AppContext); + const ltx = useContext(LoginContext); const { t } = useTranslation(); const navigate = useNavigate(); const [, height] = useWindowSize(); @@ -78,14 +80,23 @@ export default function LiveTrackContextMenu({ addTranslation }) { )} - {!ctx.liveTranslations.some((t) => t.id === translation?.id) && ( - - addTranslation(translation.id, translation.name, translation.key)} - > - - + {(!ltx.loginUser || !ctx.liveTranslations.some((t) => t.id === translation?.id)) && ( + + + addLiveTrack(translation.id, translation.name, translation.key)} + disabled={!ltx.loginUser} + > + + + )} diff --git a/map/src/menu/tracks/liveTrack/LiveTrackFolder.jsx b/map/src/menu/tracks/liveTrack/LiveTrackFolder.jsx index 40a0955544..e74da2b958 100644 --- a/map/src/menu/tracks/liveTrack/LiveTrackFolder.jsx +++ b/map/src/menu/tracks/liveTrack/LiveTrackFolder.jsx @@ -14,9 +14,9 @@ import { HEADER_SIZE, MAIN_URL_WITH_SLASH, TRACKS_URL } from '../../../manager/G import gStyles from '../../gstylesmenu.module.css'; export default function LiveTrackFolder({ - removeTranslation, - createTranslation, - deleteTranslationForAll, + removeLiveTrack, + createLiveTrack, + deleteLiveTrack, startSharing, pauseSharing, }) { @@ -61,7 +61,7 @@ export default function LiveTrackFolder({ setDialogOpen(false)} - createTranslation={createTranslation} + createLiveTrack={createLiveTrack} /> {ctx.liveTranslations.length === 0 ? ( @@ -72,8 +72,8 @@ export default function LiveTrackFolder({ key={translation.id} translation={translation} isLastItem={index === ctx.liveTranslations.length - 1} - removeTranslation={removeTranslation} - deleteTranslationForAll={deleteTranslationForAll} + removeLiveTrack={removeLiveTrack} + deleteLiveTrack={deleteLiveTrack} startSharing={startSharing} pauseSharing={pauseSharing} /> diff --git a/map/src/menu/tracks/liveTrack/LiveTrackItem.jsx b/map/src/menu/tracks/liveTrack/LiveTrackItem.jsx index 94642e4bd1..3773c5d3a4 100644 --- a/map/src/menu/tracks/liveTrack/LiveTrackItem.jsx +++ b/map/src/menu/tracks/liveTrack/LiveTrackItem.jsx @@ -16,8 +16,8 @@ import MenuItemWithLines from '../../components/MenuItemWithLines'; export default function LiveTrackItem({ translation, isLastItem, - removeTranslation, - deleteTranslationForAll, + removeLiveTrack, + deleteLiveTrack, startSharing, pauseSharing, }) { @@ -47,7 +47,7 @@ export default function LiveTrackItem({ function handleRemoveBookmark() { setOpenActions(false); - removeTranslation(translation.id); + removeLiveTrack(translation.id); } function handleOwnerSharingAction() { @@ -66,7 +66,7 @@ export default function LiveTrackItem({ function handleDeleteForAll() { setOpenActions(false); - deleteTranslationForAll(translation.id); + deleteLiveTrack(translation.id); } function handleCopyShareLink() { diff --git a/map/src/resources/translations/en/web-translation.json b/map/src/resources/translations/en/web-translation.json index aaffaaecc0..8cd2e9702f 100644 --- a/map/src/resources/translations/en/web-translation.json +++ b/map/src/resources/translations/en/web-translation.json @@ -415,6 +415,7 @@ "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_intervals": "Track intervals", diff --git a/map/src/util/hooks/live/useLiveTracking.js b/map/src/util/hooks/live/useLiveTracking.js index cb0acd14d6..9913f7853d 100644 --- a/map/src/util/hooks/live/useLiveTracking.js +++ b/map/src/util/hooks/live/useLiveTracking.js @@ -185,7 +185,7 @@ export default function useLiveTracking() { // Adds a translation to the saved list and persists to localStorage. // key (hex string) is optional — required for decryption; stored with the translation. // If already saved, just selects it (and updates the key if a better one is provided). - const addTranslation = useCallback( + const addLiveTrack = useCallback( (id, name, key) => { const existing = ctx.liveTranslations.find((t) => t.id === id); if (existing) { @@ -219,7 +219,7 @@ export default function useLiveTracking() { ); // Removes a translation from the saved list, clears its participants, and unsubscribes. - const removeTranslation = useCallback( + const removeLiveTrack = useCallback( (id) => { const updated = ctx.liveTranslations.filter((t) => t.id !== id); ctx.setLiveTranslations(updated); @@ -282,7 +282,7 @@ export default function useLiveTracking() { // The translationId MUST be SHA-256(key) so that the server ID is derivable from the key. // Saves the translation (with key) to the list, starts sharing, and calls onCreated(translation). // onGeoError(errorKey) is called if geolocation is denied or unavailable. - const createTranslation = useCallback( + const createLiveTrack = useCallback( (translationId, key, name, durationHours, onCreated, onGeoError, onCreateError) => { // Stop any active sharing before creating a new translation. if (ctx.myBroadcastTid) { @@ -338,7 +338,7 @@ export default function useLiveTracking() { ); // Deletes the translation for all viewers. Only works if the current user is the owner. - const deleteTranslationForAll = useCallback( + const deleteLiveTrack = useCallback( (id) => { if (ctx.myBroadcastTid === id) { clientRef.current?.publish({ @@ -471,10 +471,10 @@ export default function useLiveTracking() { }, [connected, ctx.liveTranslations, ctx.selectedLiveTranslation, subscribeToTranslation]); return { - addTranslation, - removeTranslation, - createTranslation, - deleteTranslationForAll, + addLiveTrack, + removeLiveTrack, + createLiveTrack, + deleteLiveTrack, startSharing, pauseSharing, }; From 7262ddd9c59c1f42e6b97f4fe8b1a5f0083ece31 Mon Sep 17 00:00:00 2001 From: Kseniia Velychko Date: Fri, 5 Jun 2026 13:52:55 +0300 Subject: [PATCH 11/40] Add live track history loading by time interval and load-earlier button --- map/src/menu/tracks/TracksMenu.jsx | 20 +- .../tracks/liveTrack/LiveTrackContextMenu.jsx | 16 +- .../translations/en/web-translation.json | 1 + map/src/test/liveTrackSimulator.js | 7 +- map/src/util/hooks/live/useLiveTracking.js | 242 +++++++++++------- 5 files changed, 181 insertions(+), 105 deletions(-) diff --git a/map/src/menu/tracks/TracksMenu.jsx b/map/src/menu/tracks/TracksMenu.jsx index 8720eff135..0c298fd191 100644 --- a/map/src/menu/tracks/TracksMenu.jsx +++ b/map/src/menu/tracks/TracksMenu.jsx @@ -47,8 +47,16 @@ export default function TracksMenu() { const { t } = useTranslation(); - const { addLiveTrack, removeLiveTrack, createLiveTrack, deleteLiveTrack, startSharing, pauseSharing } = - useLiveTracking(); + const { + addLiveTrack, + removeLiveTrack, + createLiveTrack, + deleteLiveTrack, + startSharing, + pauseSharing, + loadEarlier, + historyExhausted, + } = useLiveTracking(); const { openLiveTracks } = useLiveTrackUrl(); const checkHasFiles = () => @@ -119,7 +127,13 @@ export default function TracksMenu() { if (openLiveTracks) { if (ctx.selectedLiveTranslation) { - return ; + return ( + + ); } return ( + {loadEarlier && participantList.length > 0 && ( + + + loadEarlier(translation.id)} + disabled={!!historyExhausted?.[translation.id]} + > + + + + + )} {translation?.isOwner && translation?.key && ( + * 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(); @@ -26,6 +25,7 @@ * * --- 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) @@ -101,6 +101,7 @@ export function start(opts = {}) { let paused = false; let started = false; let pendingConfirmation = false; + let warnedNoKey = false; return new Promise((resolve) => { const client = new Client({ diff --git a/map/src/util/hooks/live/useLiveTracking.js b/map/src/util/hooks/live/useLiveTracking.js index 9913f7853d..c7b7f3e108 100644 --- a/map/src/util/hooks/live/useLiveTracking.js +++ b/map/src/util/hooks/live/useLiveTracking.js @@ -4,34 +4,68 @@ import AppContext, { LIVE_TRACKS_STORAGE_KEY } from '../../../context/AppContext import { getColorByIndex } from '../../../menu/analyzer/util/SegmentColorizer'; import { encryptLocation, decryptLocation } from '../../livetracks/liveTrackCrypto'; -// sessionStorage key to restore the active broadcast tid after page refresh. +// sessionStorage key: my broadcast tid, restored after page refresh. const BROADCAST_TID_SESSION = '__liveTrackBroadcastTid__'; +// Initial /load fetches the last 6h only (fast open); older windows via loadEarlier(). +const INITIAL_LOAD_WINDOW_MS = 6 * 60 * 60 * 1000; + export default function useLiveTracking() { const ctx = useContext(AppContext); const clientRef = useRef(null); - const subscribedRef = useRef(new Set()); + const subscribedRef = useRef(new Set()); // translationIds we've already subscribed to + const pendingCreateRef = useRef(null); // { onSuccess, onError } for the in-flight /create - // Stores { onSuccess, onError } for a pending /create request. - const pendingCreateRef = useRef(null); + // 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({}); + + // 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] + ); - // Maps translationId → encryption key (hex string). - const keysRef = useRef({}); + // Drop all client-side state for one translation. + const forgetTranslation = useCallback((id) => { + subscribedRef.current.delete(id); + delete keysRef.current[id]; + delete lastTimeRef.current[id]; + delete earliestFromRef.current[id]; + setHistoryExhausted((prev) => { + if (!(id in prev)) return prev; + const next = { ...prev }; + delete next[id]; + return next; + }); + }, []); - // Sync keysRef from saved translations and the current preview translation. - // Runs whenever either changes so the LOCATION handler can always decrypt. + // 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; + if (ctx.selectedLiveTranslation?.key) { + keysRef.current[ctx.selectedLiveTranslation.id] = ctx.selectedLiveTranslation.key; + } }, [ctx.liveTranslations, ctx.selectedLiveTranslation]); - // Manages geolocation watch: starts when myBroadcastTid is set, not paused, and STOMP is connected. - // Stopping when disconnected prevents 404s during server restart / STOMP reconnect. + // Broadcast my position: watch geolocation while sharing is active and connected. + // Gated on `connected` so we don't POST during a STOMP reconnect. useEffect(() => { if (!ctx.myBroadcastTid || ctx.isMyBroadcastPaused || !navigator.geolocation || !connected) return; @@ -61,8 +95,7 @@ export default function useLiveTracking() { return () => navigator.geolocation.clearWatch(watchId); }, [ctx.myBroadcastTid, ctx.isMyBroadcastPaused, connected]); - // Prepends a new location point to the participant's history. - // Newest point is always at index 0. + // Add a live point to a participant (newest at index 0). const updateParticipant = useCallback( (translationId, nickname, point) => { ctx.setLiveParticipants((prev) => { @@ -88,9 +121,8 @@ export default function useLiveTracking() { [ctx.setLiveParticipants] ); - // Handles METADATA message: server's initial snapshot sent in response to /load. - // Marks participants as active/inactive. Encrypted location history is populated - // separately by processEncryptedHistory. + // 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; @@ -121,8 +153,7 @@ export default function useLiveTracking() { [ctx.setLiveParticipants] ); - // Subscribes to a STOMP topic for the given translation and requests initial data. - // Skips silently if already subscribed. + // Subscribe to a translation's topic (once) and request its initial history. const subscribeToTranslation = useCallback( (client, translationId) => { if (subscribedRef.current.has(translationId)) { @@ -133,6 +164,10 @@ export default function useLiveTracking() { 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]; @@ -160,7 +195,7 @@ export default function useLiveTracking() { return { ...prev, [translationId]: byTranslation }; }); } else if (msg.type === 'DELETE') { - // Owner deleted the translation — remove it from all client state. + // 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)); @@ -172,28 +207,50 @@ export default function useLiveTracking() { return next; }); ctx.setSelectedLiveTranslation((sel) => (sel?.id === translationId ? null : sel)); - subscribedRef.current.delete(translationId); - delete keysRef.current[translationId]; + forgetTranslation(translationId); } }); + // 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] + ); - client.publish({ destination: `/app/translation/${translationId}/load`, body: '{}' }); + // Fetch the previous INITIAL_LOAD_WINDOW_MS of history (merged + de-duped on arrival). + const loadEarlier = useCallback( + (translationId) => { + if (historyExhausted[translationId]) { + return; + } + const currentFrom = earliestFromRef.current[translationId] ?? Date.now() - INITIAL_LOAD_WINDOW_MS; + const newFrom = currentFrom - INITIAL_LOAD_WINDOW_MS; + earliestFromRef.current[translationId] = newFrom; + clientRef.current?.publish({ + destination: `/app/translation/${translationId}/load`, + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ fromTime: newFrom, toTime: currentFrom }), + }); }, - [updateParticipant, handleMetadata, ctx.setLiveViewers] + [historyExhausted] ); - // Adds a translation to the saved list and persists to localStorage. - // key (hex string) is optional — required for decryption; stored with the translation. - // If already saved, just selects it (and updates the key if a better one is provided). + // 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) { - // Update key if we now have one but the stored translation doesn't. if (key && !existing.key) { - const updated = ctx.liveTranslations.map((t) => (t.id === id ? { ...t, key } : t)); - ctx.setLiveTranslations(updated); - localStorage.setItem(LIVE_TRACKS_STORAGE_KEY, JSON.stringify(updated)); + saveTranslations(ctx.liveTranslations.map((t) => (t.id === id ? { ...t, key } : t))); keysRef.current[id] = key; ctx.setSelectedLiveTranslation({ ...existing, key }); } else { @@ -205,9 +262,7 @@ export default function useLiveTracking() { const autoName = name?.trim() || `Live Track ${ctx.liveTranslations.length + 1}`; const newTranslation = { id, name: autoName, ...(key ? { key } : {}) }; if (key) keysRef.current[id] = key; - const updated = [...ctx.liveTranslations, newTranslation]; - ctx.setLiveTranslations(updated); - localStorage.setItem(LIVE_TRACKS_STORAGE_KEY, JSON.stringify(updated)); + saveTranslations([...ctx.liveTranslations, newTranslation]); ctx.setSelectedLiveTranslation(newTranslation); const client = clientRef.current; @@ -215,17 +270,14 @@ export default function useLiveTracking() { subscribeToTranslation(client, id); } }, - [ctx.liveTranslations, ctx.setLiveTranslations, ctx.setSelectedLiveTranslation, subscribeToTranslation] + [ctx.liveTranslations, saveTranslations, ctx.setSelectedLiveTranslation, subscribeToTranslation] ); - // Removes a translation from the saved list, clears its participants, and unsubscribes. + // Remove a translation from the list and drop all its client state. const removeLiveTrack = useCallback( (id) => { - const updated = ctx.liveTranslations.filter((t) => t.id !== id); - ctx.setLiveTranslations(updated); - localStorage.setItem(LIVE_TRACKS_STORAGE_KEY, JSON.stringify(updated)); - subscribedRef.current.delete(id); - delete keysRef.current[id]; + saveTranslations(ctx.liveTranslations.filter((t) => t.id !== id)); + forgetTranslation(id); ctx.setLiveParticipants((prev) => { const next = { ...prev }; delete next[id]; @@ -237,59 +289,44 @@ export default function useLiveTracking() { }, [ ctx.liveTranslations, - ctx.setLiveTranslations, + saveTranslations, + forgetTranslation, ctx.setLiveParticipants, ctx.selectedLiveTranslation, ctx.setSelectedLiveTranslation, ] ); - // Starts (or resumes) sharing for the given translation. - // Also stops sharing any previously active translation. + // Start (or resume) sharing this translation, stopping any other active one first. const startSharing = useCallback( (translationId) => { if (ctx.myBroadcastTid && ctx.myBroadcastTid !== translationId) { - clientRef.current?.publish({ - destination: `/app/translation/${ctx.myBroadcastTid}/stopSharing`, - body: '{}', - }); + sendCommand(`/app/translation/${ctx.myBroadcastTid}/stopSharing`); } - clientRef.current?.publish({ - destination: `/app/translation/${translationId}/startSharing`, - body: '{}', - }); + sendCommand(`/app/translation/${translationId}/startSharing`); sessionStorage.setItem(BROADCAST_TID_SESSION, translationId); ctx.setMyBroadcastTid(translationId); ctx.setIsMyBroadcastPaused(false); }, - [ctx.myBroadcastTid, ctx.setMyBroadcastTid, ctx.setIsMyBroadcastPaused] + [ctx.myBroadcastTid, sendCommand, ctx.setMyBroadcastTid, ctx.setIsMyBroadcastPaused] ); - // Pauses sharing: notifies server and stops geo broadcast, but keeps myBroadcastTid - // so the owner can resume. Also used by participants to stop sharing their location. + // Pause sharing but keep myBroadcastTid so the owner can resume later. const pauseSharing = useCallback(() => { if (ctx.myBroadcastTid) { - clientRef.current?.publish({ - destination: `/app/translation/${ctx.myBroadcastTid}/stopSharing`, - body: '{}', - }); + sendCommand(`/app/translation/${ctx.myBroadcastTid}/stopSharing`); } sessionStorage.removeItem(BROADCAST_TID_SESSION); ctx.setIsMyBroadcastPaused(true); - }, [ctx.myBroadcastTid, ctx.setIsMyBroadcastPaused]); + }, [ctx.myBroadcastTid, sendCommand, ctx.setIsMyBroadcastPaused]); - // Creates a new translation on the server with a client-generated translationId and key. - // The translationId MUST be SHA-256(key) so that the server ID is derivable from the key. - // Saves the translation (with key) to the list, starts sharing, and calls onCreated(translation). - // onGeoError(errorKey) is called if geolocation is denied or unavailable. + // Create a new translation on the server (id must equal SHA-256(key)). 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). const createLiveTrack = useCallback( (translationId, key, name, durationHours, onCreated, onGeoError, onCreateError) => { - // Stop any active sharing before creating a new translation. if (ctx.myBroadcastTid) { - clientRef.current?.publish({ - destination: `/app/translation/${ctx.myBroadcastTid}/stopSharing`, - body: '{}', - }); + sendCommand(`/app/translation/${ctx.myBroadcastTid}/stopSharing`); ctx.setMyBroadcastTid(null); ctx.setIsMyBroadcastPaused(false); } @@ -297,25 +334,19 @@ export default function useLiveTracking() { pendingCreateRef.current = { onSuccess: (id) => { const autoName = name?.trim() || `Live Track ${ctx.liveTranslations.length + 1}`; - // isOwner: true marks this client as the creator of this translation. const newTranslation = { id, name: autoName, key, isOwner: true }; keysRef.current[id] = key; - const updated = [...ctx.liveTranslations, newTranslation]; - ctx.setLiveTranslations(updated); - localStorage.setItem(LIVE_TRACKS_STORAGE_KEY, JSON.stringify(updated)); + saveTranslations([...ctx.liveTranslations, newTranslation]); ctx.setSelectedLiveTranslation(newTranslation); const client = clientRef.current; if (client?.connected) { subscribeToTranslation(client, id); } - // Set sharing state — geo watch starts automatically via useEffect. + // Mark as my broadcast — the geolocation watch starts via useEffect. sessionStorage.setItem(BROADCAST_TID_SESSION, id); ctx.setMyBroadcastTid(id); ctx.setIsMyBroadcastPaused(false); - clientRef.current?.publish({ - destination: `/app/translation/${id}/startSharing`, - body: '{}', - }); + sendCommand(`/app/translation/${id}/startSharing`); onCreated?.(newTranslation); }, onError: onCreateError, @@ -331,33 +362,38 @@ export default function useLiveTracking() { ctx.setMyBroadcastTid, ctx.setIsMyBroadcastPaused, ctx.liveTranslations, - ctx.setLiveTranslations, + saveTranslations, ctx.setSelectedLiveTranslation, + sendCommand, subscribeToTranslation, ] ); - // Deletes the translation for all viewers. Only works if the current user is the owner. + // Delete the translation for everyone (owner only, enforced server-side). const deleteLiveTrack = useCallback( (id) => { if (ctx.myBroadcastTid === id) { - clientRef.current?.publish({ - destination: `/app/translation/${id}/stopSharing`, - body: '{}', - }); + sendCommand(`/app/translation/${id}/stopSharing`); sessionStorage.removeItem(BROADCAST_TID_SESSION); ctx.setMyBroadcastTid(null); ctx.setIsMyBroadcastPaused(false); } - clientRef.current?.publish({ destination: `/app/translation/${id}/delete`, body: '{}' }); + sendCommand(`/app/translation/${id}/delete`); }, - [ctx.myBroadcastTid, ctx.setMyBroadcastTid, ctx.setIsMyBroadcastPaused] + [ctx.myBroadcastTid, sendCommand, ctx.setMyBroadcastTid, ctx.setIsMyBroadcastPaused] ); - // Decrypts encrypted LOCATION messages from the /load history snapshot and - // populates liveParticipants so the last known positions are visible immediately. + // 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; @@ -405,27 +441,36 @@ export default function useLiveTracking() { [ctx.setLiveParticipants] ); - // Connect once on mount + // Connect once on mount. useEffect(() => { const client = new Client({ brokerURL: process.env.REACT_APP_WS_URL, reconnectDelay: 5000, onConnect: () => { - // Subscribe to private queue to receive responses to /load (history snapshot) - // and to /create (new translation confirmation). + // 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) { - // Response to /create — fire the callback and clear it. + // /create reply — fire the callback and clear it. pendingCreateRef.current.onSuccess(msg.data.id); pendingCreateRef.current = null; } else { handleMetadata(msg.data.id, msg.data); processEncryptedHistory(msg.data.id, msg.data.history); + // 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 === 'ERROR' && pendingCreateRef.current) { - // Server rejected /create (e.g. not authenticated). + // /create rejected (e.g. not authenticated). pendingCreateRef.current.onError?.(msg.data); pendingCreateRef.current = null; } @@ -446,7 +491,7 @@ export default function useLiveTracking() { }; }, []); - // Subscribe to saved translations and to the currently selected (preview) translation + // On (re)connect: subscribe to saved + selected translations and re-register my sharing. useEffect(() => { if (!connected) return; const client = clientRef.current; @@ -456,19 +501,18 @@ export default function useLiveTracking() { if (sel && !ctx.liveTranslations.find((t) => t.id === sel.id)) { subscribeToTranslation(client, sel.id); } - // Re-register sharing session with the server on every reconnect if (ctx.myBroadcastTid && !ctx.isMyBroadcastPaused) { - client.publish({ destination: `/app/translation/${ctx.myBroadcastTid}/startSharing`, body: '{}' }); + sendCommand(`/app/translation/${ctx.myBroadcastTid}/startSharing`); } else if (!ctx.myBroadcastTid) { - // Restore from sessionStorage after page refresh. + // Resume my broadcast saved before a page refresh. const savedTid = sessionStorage.getItem(BROADCAST_TID_SESSION); if (savedTid && ctx.liveTranslations.find((t) => t.id === savedTid)) { ctx.setMyBroadcastTid(savedTid); ctx.setIsMyBroadcastPaused(false); - client.publish({ destination: `/app/translation/${savedTid}/startSharing`, body: '{}' }); + sendCommand(`/app/translation/${savedTid}/startSharing`); } } - }, [connected, ctx.liveTranslations, ctx.selectedLiveTranslation, subscribeToTranslation]); + }, [connected, ctx.liveTranslations, ctx.selectedLiveTranslation, subscribeToTranslation, sendCommand]); return { addLiveTrack, @@ -477,5 +521,7 @@ export default function useLiveTracking() { deleteLiveTrack, startSharing, pauseSharing, + loadEarlier, + historyExhausted, }; } From 84f58f1ade489d0905127d9505b6b0f8fdf12779 Mon Sep 17 00:00:00 2001 From: Kseniia Velychko Date: Fri, 5 Jun 2026 14:24:59 +0300 Subject: [PATCH 12/40] Add live track link regeneration with old-link revocation --- map/src/menu/actions/LiveTrackItemActions.jsx | 19 +++++++++ map/src/menu/tracks/TracksMenu.jsx | 2 + .../menu/tracks/liveTrack/LiveTrackFolder.jsx | 2 + .../menu/tracks/liveTrack/LiveTrackItem.jsx | 10 +++++ .../translations/en/web-translation.json | 1 + map/src/util/hooks/live/useLiveTracking.js | 40 +++++++++++++++++-- 6 files changed, 71 insertions(+), 3 deletions(-) diff --git a/map/src/menu/actions/LiveTrackItemActions.jsx b/map/src/menu/actions/LiveTrackItemActions.jsx index 289fd9fc35..cc215a6b2e 100644 --- a/map/src/menu/actions/LiveTrackItemActions.jsx +++ b/map/src/menu/actions/LiveTrackItemActions.jsx @@ -7,6 +7,7 @@ import { ReactComponent as RemoveIcon } from '../../assets/icons/ic_action_remov 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( @@ -20,6 +21,7 @@ const LiveTrackItemActions = forwardRef( handleRemoveBookmark, handleDeleteForAll, handleCopyShareLink, + handleRegenerate, hasShareLink, }, ref @@ -89,6 +91,23 @@ const LiveTrackItemActions = forwardRef( )} + {/* Owner: regenerate the key — new permanent link, old one revoked */} + {isOwner && ( + + + + + + + {t('web:live_track_regenerate_link')} + + + + )} {/* Everyone: remove from bookmarks */} ); } diff --git a/map/src/menu/tracks/liveTrack/LiveTrackFolder.jsx b/map/src/menu/tracks/liveTrack/LiveTrackFolder.jsx index e74da2b958..52ede84c66 100644 --- a/map/src/menu/tracks/liveTrack/LiveTrackFolder.jsx +++ b/map/src/menu/tracks/liveTrack/LiveTrackFolder.jsx @@ -19,6 +19,7 @@ export default function LiveTrackFolder({ deleteLiveTrack, startSharing, pauseSharing, + regenerateLiveTrack, }) { const ctx = useContext(AppContext); const { t } = useTranslation(); @@ -76,6 +77,7 @@ export default function LiveTrackFolder({ deleteLiveTrack={deleteLiveTrack} startSharing={startSharing} pauseSharing={pauseSharing} + regenerateLiveTrack={regenerateLiveTrack} /> ))}
diff --git a/map/src/menu/tracks/liveTrack/LiveTrackItem.jsx b/map/src/menu/tracks/liveTrack/LiveTrackItem.jsx index 3773c5d3a4..acf475a2d7 100644 --- a/map/src/menu/tracks/liveTrack/LiveTrackItem.jsx +++ b/map/src/menu/tracks/liveTrack/LiveTrackItem.jsx @@ -20,6 +20,7 @@ export default function LiveTrackItem({ deleteLiveTrack, startSharing, pauseSharing, + regenerateLiveTrack, }) { const ctx = useContext(AppContext); const { t } = useTranslation(); @@ -75,6 +76,14 @@ export default function LiveTrackItem({ if (url) navigator.clipboard.writeText(url).catch(() => {}); } + function handleRegenerate() { + setOpenActions(false); + regenerateLiveTrack(translation.id, (newTranslation) => { + const params = new URLSearchParams({ tid: newTranslation.id, name: newTranslation.name }); + navigate(`${MAIN_URL_WITH_SLASH}${LIVE_TRACKS_URL}?${params}`); + }); + } + return ( <> } diff --git a/map/src/resources/translations/en/web-translation.json b/map/src/resources/translations/en/web-translation.json index ecd3933b04..b6138c545c 100644 --- a/map/src/resources/translations/en/web-translation.json +++ b/map/src/resources/translations/en/web-translation.json @@ -419,6 +419,7 @@ "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_intervals": "Track intervals", "live_track_elevation_gain": "Elevation gain", "live_track_elevation_loss": "Elevation loss", diff --git a/map/src/util/hooks/live/useLiveTracking.js b/map/src/util/hooks/live/useLiveTracking.js index c7b7f3e108..dbc897165b 100644 --- a/map/src/util/hooks/live/useLiveTracking.js +++ b/map/src/util/hooks/live/useLiveTracking.js @@ -2,7 +2,12 @@ import { useContext, useEffect, useRef, useCallback, useState } from 'react'; import { Client } from '@stomp/stompjs'; import AppContext, { LIVE_TRACKS_STORAGE_KEY } from '../../../context/AppContext'; import { getColorByIndex } from '../../../menu/analyzer/util/SegmentColorizer'; -import { encryptLocation, decryptLocation } from '../../livetracks/liveTrackCrypto'; +import { + encryptLocation, + decryptLocation, + generateTranslationKey, + computeTranslationId, +} from '../../livetracks/liveTrackCrypto'; // sessionStorage key: my broadcast tid, restored after page refresh. const BROADCAST_TID_SESSION = '__liveTrackBroadcastTid__'; @@ -323,8 +328,9 @@ export default function useLiveTracking() { // Create a new translation on the server (id must equal SHA-256(key)). 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) => { + (translationId, key, name, durationHours, onCreated, onGeoError, onCreateError, replaceId) => { if (ctx.myBroadcastTid) { sendCommand(`/app/translation/${ctx.myBroadcastTid}/stopSharing`); ctx.setMyBroadcastTid(null); @@ -336,7 +342,12 @@ export default function useLiveTracking() { const autoName = name?.trim() || `Live Track ${ctx.liveTranslations.length + 1}`; const newTranslation = { id, name: autoName, key, isOwner: true }; keysRef.current[id] = key; - saveTranslations([...ctx.liveTranslations, newTranslation]); + // 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) { @@ -347,6 +358,11 @@ export default function useLiveTracking() { ctx.setMyBroadcastTid(id); ctx.setIsMyBroadcastPaused(false); sendCommand(`/app/translation/${id}/startSharing`); + // Regenerate: revoke the old translation (its viewers get DELETE). + if (replaceId) { + sendCommand(`/app/translation/${replaceId}/delete`); + forgetTranslation(replaceId); + } onCreated?.(newTranslation); }, onError: onCreateError, @@ -365,10 +381,27 @@ export default function useLiveTracking() { 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) => { @@ -521,6 +554,7 @@ export default function useLiveTracking() { deleteLiveTrack, startSharing, pauseSharing, + regenerateLiveTrack, loadEarlier, historyExhausted, }; From 3dd085479eac41e514c3426a562194c550e4e992 Mon Sep 17 00:00:00 2001 From: Kseniia Velychko Date: Fri, 5 Jun 2026 17:13:11 +0300 Subject: [PATCH 13/40] Show extra live track fields from mobile broadcaster --- .../tracks/liveTrack/LiveTrackContextMenu.jsx | 70 +++++++++++++++++++ .../translations/en/web-translation.json | 6 ++ 2 files changed, 76 insertions(+) diff --git a/map/src/menu/tracks/liveTrack/LiveTrackContextMenu.jsx b/map/src/menu/tracks/liveTrack/LiveTrackContextMenu.jsx index a703f4b356..de09be629b 100644 --- a/map/src/menu/tracks/liveTrack/LiveTrackContextMenu.jsx +++ b/map/src/menu/tracks/liveTrack/LiveTrackContextMenu.jsx @@ -26,6 +26,9 @@ import { ReactComponent as AscentIcon } from '../../../assets/icons/ic_action_al 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 trackFavStyles from '../../trackfavmenu.module.css'; import gStyles from '../../gstylesmenu.module.css'; import errorStyles from '../../errors/errors.module.css'; @@ -185,6 +188,13 @@ function LiveParticipantCard({ participant, isLast }) { } const lastLoc = locs[0]; + // Optional fields sent by the mobile broadcaster (absent for web broadcasts). + const bearingDeg = lastLoc?.bearing; + 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) { @@ -245,6 +255,66 @@ function LiveParticipantCard({ participant, isLast }) { /> )} + {Number.isFinite(bearingDeg) && ( + <> + + } + name={t('web:live_track_direction')} + additionalInfo={`${Math.round(bearingDeg)}°`} + /> + + )} + {battery > 0 && ( + <> + + } + name={t('web:live_track_battery')} + additionalInfo={`${Math.round(battery)}%`} + /> + + )} + {timeToArrival > 0 && ( + <> + + } + name={t('web:live_track_eta')} + additionalInfo={formatTime(timeToArrival)} + /> + + )} + {distToArrival > 0 && ( + <> + + } + name={t('web:live_track_distance_to_destination')} + additionalInfo={`${(distToArrival / 1000).toFixed(2)} km`} + /> + + )} + {timeToIntermediate > 0 && ( + <> + + } + name={t('web:live_track_eta_intermediate')} + additionalInfo={formatTime(timeToIntermediate)} + /> + + )} + {distToIntermediate > 0 && ( + <> + + } + name={t('web:live_track_distance_intermediate')} + additionalInfo={`${(distToIntermediate / 1000).toFixed(2)} km`} + /> + + )} {zones.length > 0 && ( <> diff --git a/map/src/resources/translations/en/web-translation.json b/map/src/resources/translations/en/web-translation.json index b6138c545c..746908e362 100644 --- a/map/src/resources/translations/en/web-translation.json +++ b/map/src/resources/translations/en/web-translation.json @@ -420,6 +420,12 @@ "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_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", From 386fa59fe8e1147b7d34f4dd451567ba72fdbf25 Mon Sep 17 00:00:00 2001 From: Kseniia Velychko Date: Mon, 8 Jun 2026 13:25:15 +0300 Subject: [PATCH 14/40] Add request/approve sharing UI for live tracks --- map/src/context/AppContext.js | 8 + map/src/frame/GlobalFrame.js | 2 + .../frame/components/LiveShareRequests.jsx | 61 ++++ .../components/liveShareRequests.module.css | 19 ++ map/src/menu/trackfavmenu.module.css | 13 + map/src/menu/tracks/TracksMenu.jsx | 2 + .../tracks/liveTrack/LiveTrackContextMenu.jsx | 319 ++++++++++-------- .../translations/en/web-translation.json | 3 + map/src/util/hooks/live/useLiveTracking.js | 94 ++++++ 9 files changed, 384 insertions(+), 137 deletions(-) create mode 100644 map/src/frame/components/LiveShareRequests.jsx create mode 100644 map/src/frame/components/liveShareRequests.module.css diff --git a/map/src/context/AppContext.js b/map/src/context/AppContext.js index 31210557b9..c64130769c 100644 --- a/map/src/context/AppContext.js +++ b/map/src/context/AppContext.js @@ -200,6 +200,10 @@ export const AppContextProvider = (props) => { }); 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 [myBroadcastTid, setMyBroadcastTid] = useState(null); @@ -762,6 +766,10 @@ export const AppContextProvider = (props) => { setLiveParticipants, liveViewers, setLiveViewers, + liveShareRequests, + setLiveShareRequests, + liveShareActions, + setLiveShareActions, selectedLiveTranslation, setSelectedLiveTranslation, followLiveLocation, 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 && } + + ctx.liveTranslations?.find((tr) => tr.id === translationId)?.name ?? translationId; + + return ( +
+ {requests.map((req) => ( + + + ctx.liveShareActions?.approve(req.translationId, req.userId)} + > + + + + + ctx.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/menu/trackfavmenu.module.css b/map/src/menu/trackfavmenu.module.css index d7d69a16eb..09e1f920ad 100644 --- a/map/src/menu/trackfavmenu.module.css +++ b/map/src/menu/trackfavmenu.module.css @@ -160,6 +160,19 @@ 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; diff --git a/map/src/menu/tracks/TracksMenu.jsx b/map/src/menu/tracks/TracksMenu.jsx index 8f6bd0d3a8..7a86c60750 100644 --- a/map/src/menu/tracks/TracksMenu.jsx +++ b/map/src/menu/tracks/TracksMenu.jsx @@ -57,6 +57,7 @@ export default function TracksMenu() { regenerateLiveTrack, loadEarlier, historyExhausted, + requestShare, } = useLiveTracking(); const { openLiveTracks } = useLiveTrackUrl(); @@ -133,6 +134,7 @@ export default function TracksMenu() { addLiveTrack={addLiveTrack} loadEarlier={loadEarlier} historyExhausted={historyExhausted} + requestShare={requestShare} /> ); } diff --git a/map/src/menu/tracks/liveTrack/LiveTrackContextMenu.jsx b/map/src/menu/tracks/liveTrack/LiveTrackContextMenu.jsx index de09be629b..b76fad94c7 100644 --- a/map/src/menu/tracks/liveTrack/LiveTrackContextMenu.jsx +++ b/map/src/menu/tracks/liveTrack/LiveTrackContextMenu.jsx @@ -1,5 +1,5 @@ -import React, { useContext, useState } from 'react'; -import { Box, Icon, IconButton, ListItemText, Tooltip } from '@mui/material'; +import React, { useContext, useEffect, useState } from 'react'; +import { Box, 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'; @@ -35,17 +35,22 @@ import errorStyles from '../../errors/errors.module.css'; const ZONE_COLORS = { UPHILL: '#d35400', DOWNHILL: '#27ae60', FLAT: '#7f8c8d' }; -export default function LiveTrackContextMenu({ addLiveTrack, loadEarlier, historyExhausted }) { +export default function LiveTrackContextMenu({ addLiveTrack, loadEarlier, historyExhausted, requestShare }) { const ctx = useContext(AppContext); 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 = ctx.selectedLiveTranslation; const participants = translation ? (ctx.liveParticipants?.[translation.id] ?? {}) : {}; - const participantList = Object.values(participants).filter((p) => p.locations?.length > 0); + // 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 ? (ctx.liveViewers?.[translation.id] ?? {}) : {}; const viewerCount = Object.keys(viewers).length; @@ -60,6 +65,18 @@ export default function LiveTrackContextMenu({ addLiveTrack, loadEarlier, histor navigator.clipboard.writeText(url).then(() => setLinkCopied(true)); } + function handleRequestShare() { + requestShare(translation.id); + setRequestSent(true); + } + + const canRequestShare = + ltx.loginUser && + translation && + !translation.isOwner && + !!translation.key && + ctx.myBroadcastTid !== translation.id; + return ( )} + {canRequestShare && ( + + + + + + + + )} {(!ltx.loginUser || !ctx.liveTranslations.some((t) => t.id === translation?.id)) && ( - )} @@ -145,11 +179,11 @@ export default function LiveTrackContextMenu({ addLiveTrack, loadEarlier, histor ) : ( - participantList.map((p, i) => ( + participantList.map((p) => ( )) )} @@ -158,9 +192,17 @@ export default function LiveTrackContextMenu({ addLiveTrack, loadEarlier, histor ); } -function LiveParticipantCard({ participant, isLast }) { +function LiveParticipantCard({ participant, defaultExpanded = true }) { const ctx = useContext(AppContext); const { t } = useTranslation(); + // Owner / own card start expanded, others start collapsed; all stay clickable to toggle. + const [expanded, setExpanded] = useState(defaultExpanded); + // owner/userId flags may arrive after the card mounts — open it once they do. + useEffect(() => { + if (defaultExpanded) { + setExpanded(true); + } + }, [defaultExpanded]); const locs = participant.locations; const speedKmh = locs[0]?.speed != null ? (locs[0].speed * 3.6).toFixed(1) : '0.0'; const altitudeM = locs[0]?.ele != null ? `${locs[0].ele.toFixed(0)} m` : '—'; @@ -203,136 +245,139 @@ function LiveParticipantCard({ participant, isLast }) { } return ( - <> - - - {participant.nickname} - - } - rightContent={ - - - - - - } - /> - } - name={t('shared_string_speed')} - additionalInfo={`${speedKmh} km/h · ${t('web:live_track_updated')} ${getTimeAgo(lastLoc?.time)}`} - /> - - } name={t('web:active_state')} additionalInfo={formatTime(duration)} /> - - } name={t('distance')} additionalInfo={`${distKm} km`} /> - - } - name={t('shared_string_max_speed')} - additionalInfo={`${maxSpeed.toFixed(1)} km/h`} - /> - - } name={t('altitude')} additionalInfo={altitudeM} /> - {(elevGain > 0 || elevLoss < 0) && ( - <> - - } - name={t('web:live_track_elevation_gain')} - additionalInfo={`+${elevGain.toFixed(0)} m`} - /> - - } - name={t('web:live_track_elevation_loss')} - additionalInfo={`${elevLoss.toFixed(0)} m`} - /> - - )} - {Number.isFinite(bearingDeg) && ( - <> - - } - name={t('web:live_track_direction')} - additionalInfo={`${Math.round(bearingDeg)}°`} - /> - - )} - {battery > 0 && ( - <> - - } - name={t('web:live_track_battery')} - additionalInfo={`${Math.round(battery)}%`} - /> - - )} - {timeToArrival > 0 && ( - <> - - } - name={t('web:live_track_eta')} - additionalInfo={formatTime(timeToArrival)} + + setExpanded((v) => !v)}> + + - - )} - {distToArrival > 0 && ( - <> - - } - name={t('web:live_track_distance_to_destination')} - additionalInfo={`${(distToArrival / 1000).toFixed(2)} km`} - /> - - )} - {timeToIntermediate > 0 && ( - <> - - } - name={t('web:live_track_eta_intermediate')} - additionalInfo={formatTime(timeToIntermediate)} - /> - - )} - {distToIntermediate > 0 && ( - <> - - } - name={t('web:live_track_distance_intermediate')} - additionalInfo={`${(distToIntermediate / 1000).toFixed(2)} km`} - /> - - )} - {zones.length > 0 && ( - <> - - - {[...zones].reverse().map((z, i) => ( - - } - name={`${zones.length - i}. ${zoneTypeLabel(z.type)}`} - additionalInfo={`${(z.distance / 1000).toFixed(2)} km · ${z.eleDiff > 0 ? '+' : ''}${z.eleDiff.toFixed(0)} m`} - /> - {i < zones.length - 1 && } - - ))} - - )} - {!isLast && } - + {participant.nickname} + + + { + e.stopPropagation(); + handleFollow(); + }} + > + + + + + + } + name={t('shared_string_speed')} + additionalInfo={`${speedKmh} km/h · ${t('web:live_track_updated')} ${getTimeAgo(lastLoc?.time)}`} + /> + + } name={t('web:active_state')} additionalInfo={formatTime(duration)} /> + + } name={t('distance')} additionalInfo={`${distKm} km`} /> + + } + name={t('shared_string_max_speed')} + additionalInfo={`${maxSpeed.toFixed(1)} km/h`} + /> + + } name={t('altitude')} additionalInfo={altitudeM} /> + {(elevGain > 0 || elevLoss < 0) && ( + <> + + } + name={t('web:live_track_elevation_gain')} + additionalInfo={`+${elevGain.toFixed(0)} m`} + /> + + } + name={t('web:live_track_elevation_loss')} + additionalInfo={`${elevLoss.toFixed(0)} m`} + /> + + )} + {Number.isFinite(bearingDeg) && ( + <> + + } + name={t('web:live_track_direction')} + additionalInfo={`${Math.round(bearingDeg)}°`} + /> + + )} + {battery > 0 && ( + <> + + } + name={t('web:live_track_battery')} + additionalInfo={`${Math.round(battery)}%`} + /> + + )} + {timeToArrival > 0 && ( + <> + + } + name={t('web:live_track_eta')} + additionalInfo={formatTime(timeToArrival)} + /> + + )} + {distToArrival > 0 && ( + <> + + } + name={t('web:live_track_distance_to_destination')} + additionalInfo={`${(distToArrival / 1000).toFixed(2)} km`} + /> + + )} + {timeToIntermediate > 0 && ( + <> + + } + name={t('web:live_track_eta_intermediate')} + additionalInfo={formatTime(timeToIntermediate)} + /> + + )} + {distToIntermediate > 0 && ( + <> + + } + name={t('web:live_track_distance_intermediate')} + additionalInfo={`${(distToIntermediate / 1000).toFixed(2)} km`} + /> + + )} + {zones.length > 0 && ( + <> + + + {[...zones].reverse().map((z, i) => ( + + } + name={`${zones.length - i}. ${zoneTypeLabel(z.type)}`} + additionalInfo={`${(z.distance / 1000).toFixed(2)} km · ${z.eleDiff > 0 ? '+' : ''}${z.eleDiff.toFixed(0)} m`} + /> + {i < zones.length - 1 && } + + ))} + + )} + + ); } diff --git a/map/src/resources/translations/en/web-translation.json b/map/src/resources/translations/en/web-translation.json index 746908e362..e28c81960c 100644 --- a/map/src/resources/translations/en/web-translation.json +++ b/map/src/resources/translations/en/web-translation.json @@ -448,6 +448,9 @@ "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.", "shared_string_flat": "Flat", diff --git a/map/src/util/hooks/live/useLiveTracking.js b/map/src/util/hooks/live/useLiveTracking.js index dbc897165b..bfaf15bfc8 100644 --- a/map/src/util/hooks/live/useLiveTracking.js +++ b/map/src/util/hooks/live/useLiveTracking.js @@ -114,6 +114,8 @@ export default function useLiveTracking() { ...byTranslation, [nickname]: { nickname, + owner: existing?.owner, + mine: existing?.mine, color, active: existing?.active ?? true, startTime: existing?.startTime ?? Date.now(), @@ -140,6 +142,9 @@ export default function useLiveTracking() { 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(), @@ -325,6 +330,55 @@ export default function useLiveTracking() { ctx.setIsMyBroadcastPaused(true); }, [ctx.myBroadcastTid, sendCommand, ctx.setIsMyBroadcastPaused]); + // 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 must equal SHA-256(key)). 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). @@ -461,6 +515,8 @@ export default function useLiveTracking() { 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(), @@ -491,6 +547,29 @@ export default function useLiveTracking() { } else { handleMetadata(msg.data.id, msg.data); processEncryptedHistory(msg.data.id, msg.data.history); + // 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]; @@ -502,6 +581,20 @@ export default function useLiveTracking() { ); } } + } 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; + sessionStorage.setItem(BROADCAST_TID_SESSION, tid); + ctx.setMyBroadcastTid(tid); + ctx.setIsMyBroadcastPaused(false); } else if (msg.type === 'ERROR' && pendingCreateRef.current) { // /create rejected (e.g. not authenticated). pendingCreateRef.current.onError?.(msg.data); @@ -557,5 +650,6 @@ export default function useLiveTracking() { regenerateLiveTrack, loadEarlier, historyExhausted, + requestShare, }; } From 13208dd0c22513c8ca886d0d58e7aa545ea4a122 Mon Sep 17 00:00:00 2001 From: Kseniia Velychko Date: Mon, 8 Jun 2026 18:12:45 +0300 Subject: [PATCH 15/40] Use translations for live track count and time-ago labels --- .../menu/tracks/liveTrack/LiveTrackContextMenu.jsx | 12 ++++++------ map/src/menu/tracks/liveTrack/LiveTrackGroup.jsx | 2 +- .../resources/translations/en/web-translation.json | 4 ++++ map/src/util/hooks/live/useLiveTracking.js | 1 + 4 files changed, 12 insertions(+), 7 deletions(-) diff --git a/map/src/menu/tracks/liveTrack/LiveTrackContextMenu.jsx b/map/src/menu/tracks/liveTrack/LiveTrackContextMenu.jsx index b76fad94c7..9fb9f12755 100644 --- a/map/src/menu/tracks/liveTrack/LiveTrackContextMenu.jsx +++ b/map/src/menu/tracks/liveTrack/LiveTrackContextMenu.jsx @@ -270,7 +270,7 @@ function LiveParticipantCard({ participant, defaultExpanded = true }) { } name={t('shared_string_speed')} - additionalInfo={`${speedKmh} km/h · ${t('web:live_track_updated')} ${getTimeAgo(lastLoc?.time)}`} + additionalInfo={`${speedKmh} km/h · ${t('web:live_track_updated')} ${getTimeAgo(lastLoc?.time, t)}`} /> } name={t('web:active_state')} additionalInfo={formatTime(duration)} /> @@ -503,12 +503,12 @@ function formatTime(ms) { return `${s}s`; } -function getTimeAgo(timestamp) { +function getTimeAgo(timestamp, t) { if (!timestamp) return '—'; const diff = Math.floor((Date.now() - timestamp) / 1000); - if (diff < 10) return 'just now'; - if (diff < 60) return `${diff}s ago`; - if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; + 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 `${Math.floor(diff / 3600)}h ago`; + return t('web:live_track_hours_ago', { value: Math.floor(diff / 3600) }); } diff --git a/map/src/menu/tracks/liveTrack/LiveTrackGroup.jsx b/map/src/menu/tracks/liveTrack/LiveTrackGroup.jsx index 898c98211a..ce6fb197db 100644 --- a/map/src/menu/tracks/liveTrack/LiveTrackGroup.jsx +++ b/map/src/menu/tracks/liveTrack/LiveTrackGroup.jsx @@ -14,7 +14,7 @@ export default function LiveTrackGroup() { const navigate = useNavigate(); const count = ctx.liveTranslations.length; - const infoText = count > 0 ? `${count} ${t('shared_string_gpx_files').toLowerCase()}` : ''; + const infoText = count > 0 ? `${count} ${t('web:live_tracks').toLowerCase()}` : ''; function handleClick() { navigate(MAIN_URL_WITH_SLASH + LIVE_TRACKS_URL); diff --git a/map/src/resources/translations/en/web-translation.json b/map/src/resources/translations/en/web-translation.json index e28c81960c..1c4f639e3c 100644 --- a/map/src/resources/translations/en/web-translation.json +++ b/map/src/resources/translations/en/web-translation.json @@ -430,6 +430,10 @@ "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_permanent": "Permanent", "live_track_duration_1h": "1 hour", diff --git a/map/src/util/hooks/live/useLiveTracking.js b/map/src/util/hooks/live/useLiveTracking.js index bfaf15bfc8..aac61c2a2c 100644 --- a/map/src/util/hooks/live/useLiveTracking.js +++ b/map/src/util/hooks/live/useLiveTracking.js @@ -424,6 +424,7 @@ export default function useLiveTracking() { clientRef.current?.publish({ destination: '/app/translation/create', + headers: { 'content-type': 'application/json' }, body: JSON.stringify({ translationId, durationHours }), }); }, From d9bba3827a84203dcce5b5deddf485344219e8eb Mon Sep 17 00:00:00 2001 From: Kseniia Velychko Date: Mon, 8 Jun 2026 18:13:53 +0300 Subject: [PATCH 16/40] Remove unused variable in live track simulator --- map/src/test/liveTrackSimulator.js | 1 - 1 file changed, 1 deletion(-) diff --git a/map/src/test/liveTrackSimulator.js b/map/src/test/liveTrackSimulator.js index 5df961686b..4b0770d56b 100644 --- a/map/src/test/liveTrackSimulator.js +++ b/map/src/test/liveTrackSimulator.js @@ -101,7 +101,6 @@ export function start(opts = {}) { let paused = false; let started = false; let pendingConfirmation = false; - let warnedNoKey = false; return new Promise((resolve) => { const client = new Client({ From b35f70e2f313b816376abda7d3484687f97d1d7a Mon Sep 17 00:00:00 2001 From: Kseniia Velychko Date: Mon, 8 Jun 2026 18:24:23 +0300 Subject: [PATCH 17/40] Redirect live tracks login from effect instead of during render --- map/src/menu/tracks/TracksMenu.jsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/map/src/menu/tracks/TracksMenu.jsx b/map/src/menu/tracks/TracksMenu.jsx index 7a86c60750..f6afbf4049 100644 --- a/map/src/menu/tracks/TracksMenu.jsx +++ b/map/src/menu/tracks/TracksMenu.jsx @@ -117,13 +117,18 @@ export default function TracksMenu() { } }, [defaultGroup?.groupFiles]); + const needLiveLogin = openLiveTracks && !ltx.loginUser && !searchParams.get('tid'); + 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 ; } - // live tracks folder - if (openLiveTracks && !ltx.loginUser && !searchParams.get('tid')) { - navigate(MAIN_URL_WITH_SLASH + LOGIN_URL + location.search + location.hash, { replace: true }); + if (needLiveLogin) { return null; } From 1b87ed5f86e31e555826d9c87a8d55f6c312084e Mon Sep 17 00:00:00 2001 From: Kseniia Velychko Date: Mon, 8 Jun 2026 18:26:41 +0300 Subject: [PATCH 18/40] Use route constants for live track share URL --- map/src/menu/tracks/liveTrack/CreateLiveTrackDialog.jsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/map/src/menu/tracks/liveTrack/CreateLiveTrackDialog.jsx b/map/src/menu/tracks/liveTrack/CreateLiveTrackDialog.jsx index 3054283763..2ea55dba2d 100644 --- a/map/src/menu/tracks/liveTrack/CreateLiveTrackDialog.jsx +++ b/map/src/menu/tracks/liveTrack/CreateLiveTrackDialog.jsx @@ -94,7 +94,9 @@ export default function CreateLiveTrackDialog({ open, onClose, createLiveTrack } if (translation.name) { urlParams.set('name', translation.name); } - setShareUrl(`${globalThis.location.origin}/map/live/?${urlParams}#${key}`); + setShareUrl( + `${globalThis.location.origin}${MAIN_URL_WITH_SLASH}${LIVE_TRACKS_URL}?${urlParams}#${key}` + ); setCreating(false); navigate( `${MAIN_URL_WITH_SLASH}${LIVE_TRACKS_URL}?tid=${translation.id}&name=${encodeURIComponent(translation.name)}` From 0d3e32ee4e664f12b3d195909da5e5a057f30cd7 Mon Sep 17 00:00:00 2001 From: Kseniia Velychko Date: Tue, 9 Jun 2026 08:17:40 +0300 Subject: [PATCH 19/40] Show create error in live track dialog instead of hanging --- map/src/menu/tracks/liveTrack/CreateLiveTrackDialog.jsx | 4 ++++ map/src/resources/translations/en/web-translation.json | 1 + 2 files changed, 5 insertions(+) diff --git a/map/src/menu/tracks/liveTrack/CreateLiveTrackDialog.jsx b/map/src/menu/tracks/liveTrack/CreateLiveTrackDialog.jsx index 2ea55dba2d..8f73b526ba 100644 --- a/map/src/menu/tracks/liveTrack/CreateLiveTrackDialog.jsx +++ b/map/src/menu/tracks/liveTrack/CreateLiveTrackDialog.jsx @@ -105,6 +105,10 @@ export default function CreateLiveTrackDialog({ open, onClose, createLiveTrack } (errCode) => { setGeoError(toGeoErrorKey(errCode)); setCreating(false); + }, + (err) => { + setCreateError(typeof err === 'string' && err ? err : t('web:live_track_create_error')); + setCreating(false); } ); } diff --git a/map/src/resources/translations/en/web-translation.json b/map/src/resources/translations/en/web-translation.json index 1c4f639e3c..20e6531fd1 100644 --- a/map/src/resources/translations/en/web-translation.json +++ b/map/src/resources/translations/en/web-translation.json @@ -457,6 +457,7 @@ "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" } From cc7843d3ebee9d42a4019065a6b12c1b7533dc74 Mon Sep 17 00:00:00 2001 From: Kseniia Velychko Date: Tue, 9 Jun 2026 08:18:29 +0300 Subject: [PATCH 20/40] Re-subscribe live tracks after websocket reconnect --- map/src/util/hooks/live/useLiveTracking.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/map/src/util/hooks/live/useLiveTracking.js b/map/src/util/hooks/live/useLiveTracking.js index aac61c2a2c..8a971bc33b 100644 --- a/map/src/util/hooks/live/useLiveTracking.js +++ b/map/src/util/hooks/live/useLiveTracking.js @@ -604,7 +604,11 @@ export default function useLiveTracking() { }); setConnected(true); }, - onDisconnect: () => setConnected(false), + onDisconnect: () => { + // Clear so the reconnect effect re-subscribes to topics on the new STOMP session. + subscribedRef.current.clear(); + setConnected(false); + }, }); client.activate(); From 719171e6e2cbfd0a282f79b5e150abd62d688e72 Mon Sep 17 00:00:00 2001 From: Kseniia Velychko Date: Tue, 9 Jun 2026 08:20:47 +0300 Subject: [PATCH 21/40] Fix translationId description in live track crypto header --- map/src/util/hooks/live/useLiveTracking.js | 2 +- map/src/util/livetracks/liveTrackCrypto.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/map/src/util/hooks/live/useLiveTracking.js b/map/src/util/hooks/live/useLiveTracking.js index 8a971bc33b..5fe6b83106 100644 --- a/map/src/util/hooks/live/useLiveTracking.js +++ b/map/src/util/hooks/live/useLiveTracking.js @@ -379,7 +379,7 @@ export default function useLiveTracking() { ctx.setLiveShareActions({ approve: approveShare, deny: denyShare }); }, [approveShare, denyShare, ctx.setLiveShareActions]); - // Create a new translation on the server (id must equal SHA-256(key)). On success: + // 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. diff --git a/map/src/util/livetracks/liveTrackCrypto.js b/map/src/util/livetracks/liveTrackCrypto.js index 2f7459f365..a789c8a83b 100644 --- a/map/src/util/livetracks/liveTrackCrypto.js +++ b/map/src/util/livetracks/liveTrackCrypto.js @@ -1,5 +1,5 @@ // Utilities for live track symmetric encryption (AES-256-GCM). -// Key is a 32-byte hex string; translationId is SHA-256 of that key. +// 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' }; From 86d22060f55dfb036ad0e8a350b090a0d5c458c6 Mon Sep 17 00:00:00 2001 From: Kseniia Velychko Date: Tue, 9 Jun 2026 08:22:49 +0300 Subject: [PATCH 22/40] Handle clipboard write failure in live track create dialog --- map/src/menu/tracks/liveTrack/CreateLiveTrackDialog.jsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/map/src/menu/tracks/liveTrack/CreateLiveTrackDialog.jsx b/map/src/menu/tracks/liveTrack/CreateLiveTrackDialog.jsx index 8f73b526ba..8172e73c65 100644 --- a/map/src/menu/tracks/liveTrack/CreateLiveTrackDialog.jsx +++ b/map/src/menu/tracks/liveTrack/CreateLiveTrackDialog.jsx @@ -114,8 +114,11 @@ export default function CreateLiveTrackDialog({ open, onClose, createLiveTrack } } function handleCopy() { - navigator.clipboard.writeText(shareUrl); - setCopied(true); + if (!shareUrl) return; + navigator.clipboard + .writeText(shareUrl) + .then(() => setCopied(true)) + .catch(() => {}); } function handleClose() { From 80aca3cf4b77b50ba02baa2c5e9b6aaf49e42aa5 Mon Sep 17 00:00:00 2001 From: Kseniia Velychko Date: Tue, 9 Jun 2026 08:24:31 +0300 Subject: [PATCH 23/40] Escape live track nickname in map tooltip to prevent XSS --- map/src/map/layers/LiveTrackLayer.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/map/src/map/layers/LiveTrackLayer.js b/map/src/map/layers/LiveTrackLayer.js index 30a8d8a055..6cfe7b9931 100644 --- a/map/src/map/layers/LiveTrackLayer.js +++ b/map/src/map/layers/LiveTrackLayer.js @@ -59,7 +59,9 @@ export default function LiveTrackLayer() { 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); - marker.bindTooltip(nickname, { permanent: false, direction: 'top', offset: [0, -10] }); + const tooltipNode = document.createElement('span'); + tooltipNode.textContent = nickname; + marker.bindTooltip(tooltipNode, { permanent: false, direction: 'top', offset: [0, -10] }); layersRef.current[selectedTid][nickname] = { polyline, marker }; } }); From d0645d5f0325ca80fc06e38792202e34e5263777 Mon Sep 17 00:00:00 2001 From: Kseniia Velychko Date: Tue, 9 Jun 2026 08:32:48 +0300 Subject: [PATCH 24/40] Use shared toHHMMSS formatter for live track durations --- .../tracks/liveTrack/LiveTrackContextMenu.jsx | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/map/src/menu/tracks/liveTrack/LiveTrackContextMenu.jsx b/map/src/menu/tracks/liveTrack/LiveTrackContextMenu.jsx index 9fb9f12755..b692cb59ab 100644 --- a/map/src/menu/tracks/liveTrack/LiveTrackContextMenu.jsx +++ b/map/src/menu/tracks/liveTrack/LiveTrackContextMenu.jsx @@ -8,7 +8,7 @@ import { HEADER_SIZE, LIVE_TRACKS_URL, MAIN_URL_WITH_SLASH } from '../../../mana import { buildLiveTrackShareUrl } from '../../../util/livetracks/liveTrackUtils'; import { ReactComponent as ShareLinkIcon } from '../../../assets/icons/ic_action_link.svg'; import { useWindowSize } from '../../../util/hooks/useWindowSize'; -import { getDistance } from '../../../util/Utils'; +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'; @@ -273,7 +273,7 @@ function LiveParticipantCard({ participant, defaultExpanded = true }) { additionalInfo={`${speedKmh} km/h · ${t('web:live_track_updated')} ${getTimeAgo(lastLoc?.time, t)}`} /> - } name={t('web:active_state')} additionalInfo={formatTime(duration)} /> + } name={t('web:active_state')} additionalInfo={toHHMMSS(duration)} /> } name={t('distance')} additionalInfo={`${distKm} km`} /> @@ -326,7 +326,7 @@ function LiveParticipantCard({ participant, defaultExpanded = true }) { } name={t('web:live_track_eta')} - additionalInfo={formatTime(timeToArrival)} + additionalInfo={toHHMMSS(timeToArrival)} /> )} @@ -346,7 +346,7 @@ function LiveParticipantCard({ participant, defaultExpanded = true }) { } name={t('web:live_track_eta_intermediate')} - additionalInfo={formatTime(timeToIntermediate)} + additionalInfo={toHHMMSS(timeToIntermediate)} /> )} @@ -492,16 +492,6 @@ function computeZones(locations, minEleDiff = 7) { return zones; } -function formatTime(ms) { - if (ms < 0) ms = 0; - const h = Math.floor(ms / 3600000); - const m = Math.floor((ms % 3600000) / 60000); - const s = Math.floor((ms % 60000) / 1000); - if (h > 0) return `${h}h ${m}m`; - if (m > 0) return `${m}m ${s}s`; - - return `${s}s`; -} function getTimeAgo(timestamp, t) { if (!timestamp) return '—'; From 8860fd5c360c1a213bf8f35ce5489ca0bfdb89ae Mon Sep 17 00:00:00 2001 From: Kseniia Velychko Date: Tue, 9 Jun 2026 09:00:34 +0300 Subject: [PATCH 25/40] Fix live track geolocation errors not reaching the UI --- .../tracks/liveTrack/CreateLiveTrackDialog.jsx | 5 +++-- map/src/util/hooks/live/useLiveTracking.js | 15 ++++++++++++--- map/src/util/livetracks/liveTrackUtils.js | 3 +++ 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/map/src/menu/tracks/liveTrack/CreateLiveTrackDialog.jsx b/map/src/menu/tracks/liveTrack/CreateLiveTrackDialog.jsx index 8172e73c65..76168bc2f9 100644 --- a/map/src/menu/tracks/liveTrack/CreateLiveTrackDialog.jsx +++ b/map/src/menu/tracks/liveTrack/CreateLiveTrackDialog.jsx @@ -18,6 +18,7 @@ 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 { GEO_ERROR_DENIED, GEO_ERROR_UNAVAILABLE } from '../../../util/livetracks/liveTrackUtils'; import dialogStyles from '../../../dialogs/dialog.module.css'; import styles from '../../trackfavmenu.module.css'; @@ -210,7 +211,7 @@ export default function CreateLiveTrackDialog({ open, onClose, createLiveTrack } } function toGeoErrorKey(code) { - if (code === 'geolocation_denied') return 'web:live_track_geo_denied'; - if (code === 'geolocation_unavailable') return 'web:live_track_geo_unavailable'; + 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/util/hooks/live/useLiveTracking.js b/map/src/util/hooks/live/useLiveTracking.js index 5fe6b83106..140d95d501 100644 --- a/map/src/util/hooks/live/useLiveTracking.js +++ b/map/src/util/hooks/live/useLiveTracking.js @@ -8,6 +8,7 @@ import { generateTranslationKey, computeTranslationId, } from '../../livetracks/liveTrackCrypto'; +import { GEO_ERROR_DENIED, GEO_ERROR_UNAVAILABLE } from '../../livetracks/liveTrackUtils'; // sessionStorage key: my broadcast tid, restored after page refresh. const BROADCAST_TID_SESSION = '__liveTrackBroadcastTid__'; @@ -15,12 +16,16 @@ const BROADCAST_TID_SESSION = '__liveTrackBroadcastTid__'; // 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() { const ctx = useContext(AppContext); const clientRef = useRef(null); const subscribedRef = useRef(new Set()); // translationIds we've already subscribed to 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) @@ -93,7 +98,10 @@ export default function useLiveTracking() { }) .catch(() => {}); }, - () => {}, + (error) => { + const code = error?.code === error?.PERMISSION_DENIED ? GEO_ERROR_DENIED : GEO_ERROR_UNAVAILABLE; + geoErrorRef.current?.(code); + }, { enableHighAccuracy: true, maximumAge: 5000 } ); @@ -119,7 +127,7 @@ export default function useLiveTracking() { color, active: existing?.active ?? true, startTime: existing?.startTime ?? Date.now(), - locations: [point, ...locations], + locations: [point, ...locations].slice(0, MAX_PARTICIPANT_POINTS), }, }, }; @@ -385,6 +393,7 @@ export default function useLiveTracking() { // 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; if (ctx.myBroadcastTid) { sendCommand(`/app/translation/${ctx.myBroadcastTid}/stopSharing`); ctx.setMyBroadcastTid(null); @@ -521,7 +530,7 @@ export default function useLiveTracking() { color, active: existing?.active ?? true, startTime: existing?.startTime ?? Date.now(), - locations: combined, + locations: combined.slice(0, MAX_PARTICIPANT_POINTS), }; }); return { ...prev, [translationId]: byTranslation }; diff --git a/map/src/util/livetracks/liveTrackUtils.js b/map/src/util/livetracks/liveTrackUtils.js index 8dc3732740..a9c3adcc3c 100644 --- a/map/src/util/livetracks/liveTrackUtils.js +++ b/map/src/util/livetracks/liveTrackUtils.js @@ -4,6 +4,9 @@ import { LIVE_TRACKS_URL, MAIN_URL_WITH_SLASH } from '../../manager/GlobalManage // leaflet-hash initialization that would otherwise overwrite the URL fragment. export const LIVE_TRACK_KEY_SESSION = '__liveTrackShareKey__'; +export const GEO_ERROR_DENIED = 'geolocation_denied'; +export const GEO_ERROR_UNAVAILABLE = 'geolocation_unavailable'; + const KEY_HEX_RE = /^[0-9a-f]{64}$/; // Builds the share URL for a live track translation. From 4f0dfe50931c4c05477fc050a1a780da156a04e4 Mon Sep 17 00:00:00 2001 From: Kseniia Velychko Date: Tue, 9 Jun 2026 11:39:11 +0300 Subject: [PATCH 26/40] Fix removed live tracks still receiving STOMP updates --- map/src/util/hooks/live/useLiveTracking.js | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/map/src/util/hooks/live/useLiveTracking.js b/map/src/util/hooks/live/useLiveTracking.js index 140d95d501..4826566347 100644 --- a/map/src/util/hooks/live/useLiveTracking.js +++ b/map/src/util/hooks/live/useLiveTracking.js @@ -23,7 +23,7 @@ export default function useLiveTracking() { const ctx = useContext(AppContext); const clientRef = useRef(null); - const subscribedRef = useRef(new Set()); // translationIds we've already subscribed to + 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 @@ -52,6 +52,8 @@ export default function useLiveTracking() { // 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]; @@ -178,9 +180,7 @@ export default function useLiveTracking() { return; } - subscribedRef.current.add(translationId); - - client.subscribe(`/topic/translation/${translationId}`, (message) => { + 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)) { @@ -228,6 +228,7 @@ export default function useLiveTracking() { 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; From a8f72a7f633e2dd49c0f56a65fd16dbbca6dd138 Mon Sep 17 00:00:00 2001 From: Kseniia Velychko Date: Tue, 9 Jun 2026 11:42:23 +0300 Subject: [PATCH 27/40] Fix wrong Resume label on non-broadcasting live tracks --- map/src/menu/actions/LiveTrackItemActions.jsx | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/map/src/menu/actions/LiveTrackItemActions.jsx b/map/src/menu/actions/LiveTrackItemActions.jsx index cc215a6b2e..66de71123a 100644 --- a/map/src/menu/actions/LiveTrackItemActions.jsx +++ b/map/src/menu/actions/LiveTrackItemActions.jsx @@ -28,16 +28,12 @@ const LiveTrackItemActions = forwardRef( ) => { const ctx = useContext(AppContext); const { t } = useTranslation(); - - const ownerSharingLabel = - !isSharing && !ctx.isMyBroadcastPaused - ? 'web:live_track_start_sharing' - : ctx.isMyBroadcastPaused - ? 'web:live_track_resume_sharing' - : 'web:live_track_pause_sharing'; - - const ownerSharingIcon = - (!isSharing && !ctx.isMyBroadcastPaused) || ctx.isMyBroadcastPaused ? : ; + const ownerSharingLabel = !isSharing + ? 'web:live_track_start_sharing' + : ctx.isMyBroadcastPaused + ? 'web:live_track_resume_sharing' + : 'web:live_track_pause_sharing'; + const ownerSharingIcon = isSharing && !ctx.isMyBroadcastPaused ? : ; return ( From bf7e337c422c87668f719514caa65017a48d4ee9 Mon Sep 17 00:00:00 2001 From: Kseniia Velychko Date: Tue, 9 Jun 2026 12:11:20 +0300 Subject: [PATCH 28/40] Memoize live participant card stats to avoid recompute on every render --- .../tracks/liveTrack/LiveTrackContextMenu.jsx | 45 +++++++++++-------- 1 file changed, 26 insertions(+), 19 deletions(-) diff --git a/map/src/menu/tracks/liveTrack/LiveTrackContextMenu.jsx b/map/src/menu/tracks/liveTrack/LiveTrackContextMenu.jsx index b692cb59ab..708ff82e09 100644 --- a/map/src/menu/tracks/liveTrack/LiveTrackContextMenu.jsx +++ b/map/src/menu/tracks/liveTrack/LiveTrackContextMenu.jsx @@ -1,4 +1,4 @@ -import React, { useContext, useEffect, useState } from 'react'; +import React, { useContext, useEffect, useMemo, useState } from 'react'; import { Box, Collapse, Icon, IconButton, ListItemText, MenuItem, Tooltip } from '@mui/material'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; @@ -197,31 +197,39 @@ function LiveParticipantCard({ participant, defaultExpanded = true }) { const { t } = useTranslation(); // Owner / own card start expanded, others start collapsed; all stay clickable to toggle. const [expanded, setExpanded] = useState(defaultExpanded); + // owner/userId flags may arrive after the card mounts — open it once they do. useEffect(() => { if (defaultExpanded) { setExpanded(true); } }, [defaultExpanded]); + const locs = participant.locations; - const speedKmh = locs[0]?.speed != null ? (locs[0].speed * 3.6).toFixed(1) : '0.0'; - const altitudeM = locs[0]?.ele != null ? `${locs[0].ele.toFixed(0)} m` : '—'; - let totalDist = 0; - let maxSpeed = 0; - for (let i = 0; i < locs.length - 1; i++) { - totalDist += getDistance(locs[i].lat, locs[i].lon, locs[i + 1].lat, locs[i + 1].lon); - const kmh = locs[i].speed != null ? locs[i].speed * 3.6 : 0; - if (kmh > maxSpeed) maxSpeed = kmh; - } - if (locs.length > 0) { - const lastKmh = locs.at(-1).speed != null ? locs.at(-1).speed * 3.6 : 0; - if (lastKmh > maxSpeed) maxSpeed = lastKmh; - } - const distKm = (totalDist / 1000).toFixed(2); + + const { speedKmh, altitudeM, maxSpeed, distKm, zones, elevGain, elevLoss } = useMemo(() => { + const speedKmh = locs[0]?.speed != null ? (locs[0].speed * 3.6).toFixed(1) : '0.0'; + const altitudeM = locs[0]?.ele != null ? `${locs[0].ele.toFixed(0)} m` : '—'; + let totalDist = 0; + let maxSpeed = 0; + for (let i = 0; i < locs.length - 1; i++) { + totalDist += getDistance(locs[i].lat, locs[i].lon, locs[i + 1].lat, locs[i + 1].lon); + const kmh = locs[i].speed != null ? locs[i].speed * 3.6 : 0; + if (kmh > maxSpeed) maxSpeed = kmh; + } + if (locs.length > 0) { + const lastKmh = locs.at(-1).speed != null ? locs.at(-1).speed * 3.6 : 0; + if (lastKmh > maxSpeed) maxSpeed = lastKmh; + } + const distKm = (totalDist / 1000).toFixed(2); + const zones = computeZones(locs); + const elevGain = zones.filter((z) => z.eleDiff > 0).reduce((s, z) => s + z.eleDiff, 0); + const elevLoss = zones.filter((z) => z.eleDiff < 0).reduce((s, z) => s + z.eleDiff, 0); + + return { speedKmh, altitudeM, maxSpeed, distKm, zones, elevGain, elevLoss }; + }, [locs]); + const duration = Date.now() - participant.startTime; - const zones = computeZones(locs); - const elevGain = zones.filter((z) => z.eleDiff > 0).reduce((s, z) => s + z.eleDiff, 0); - const elevLoss = zones.filter((z) => z.eleDiff < 0).reduce((s, z) => s + z.eleDiff, 0); function zoneTypeLabel(type) { if (type === 'UPHILL') return t('shared_string_uphill'); @@ -492,7 +500,6 @@ function computeZones(locations, minEleDiff = 7) { return zones; } - function getTimeAgo(timestamp, t) { if (!timestamp) return '—'; const diff = Math.floor((Date.now() - timestamp) / 1000); From f8928f2e54df5b0923353cfad989d49fa618b2c9 Mon Sep 17 00:00:00 2001 From: Kseniia Velychko Date: Tue, 9 Jun 2026 12:23:31 +0300 Subject: [PATCH 29/40] Show GPS accuracy and HDOP in live track participant card --- .../tracks/liveTrack/LiveTrackContextMenu.jsx | 23 +++++++++++++++++++ .../translations/en/web-translation.json | 2 ++ map/src/util/hooks/live/useLiveTracking.js | 21 ++++++++++++----- 3 files changed, 40 insertions(+), 6 deletions(-) diff --git a/map/src/menu/tracks/liveTrack/LiveTrackContextMenu.jsx b/map/src/menu/tracks/liveTrack/LiveTrackContextMenu.jsx index 708ff82e09..80f895bd66 100644 --- a/map/src/menu/tracks/liveTrack/LiveTrackContextMenu.jsx +++ b/map/src/menu/tracks/liveTrack/LiveTrackContextMenu.jsx @@ -29,6 +29,7 @@ import { ReactComponent as FolderAddIcon } from '../../../assets/icons/ic_action 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'; @@ -240,6 +241,8 @@ function LiveParticipantCard({ participant, defaultExpanded = true }) { 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; @@ -318,6 +321,26 @@ function LiveParticipantCard({ participant, defaultExpanded = true }) { /> )} + {Number.isFinite(accuracyM) && accuracyM > 0 && ( + <> + + } + name={t('web:live_track_accuracy')} + additionalInfo={`±${Math.round(accuracyM)} m`} + /> + + )} + {Number.isFinite(hdop) && hdop > 0 && ( + <> + + } + name={t('web:live_track_hdop')} + additionalInfo={hdop.toFixed(1)} + /> + + )} {battery > 0 && ( <> diff --git a/map/src/resources/translations/en/web-translation.json b/map/src/resources/translations/en/web-translation.json index 20e6531fd1..18e59cf888 100644 --- a/map/src/resources/translations/en/web-translation.json +++ b/map/src/resources/translations/en/web-translation.json @@ -421,6 +421,8 @@ "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", diff --git a/map/src/util/hooks/live/useLiveTracking.js b/map/src/util/hooks/live/useLiveTracking.js index 4826566347..94bba7fdaa 100644 --- a/map/src/util/hooks/live/useLiveTracking.js +++ b/map/src/util/hooks/live/useLiveTracking.js @@ -92,7 +92,9 @@ export default function useLiveTracking() { time: position.timestamp, ...(speed != null && { speed }), ...(altitude != null && { ele: altitude }), - ...(accuracy != null && { hdop: accuracy }), + // 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 }), }; encryptLocation(key, locationData) .then((encData) => { @@ -118,6 +120,11 @@ export default function useLiveTracking() { 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]: { @@ -632,28 +639,30 @@ export default function useLiveTracking() { }; }, []); - // On (re)connect: subscribe to saved + selected translations and re-register my sharing. 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.find((t) => t.id === sel.id)) { + if (sel && !ctx.liveTranslations.some((t) => t.id === sel.id)) { subscribeToTranslation(client, sel.id); } + }, [connected, ctx.liveTranslations, ctx.selectedLiveTranslation, subscribeToTranslation]); + + useEffect(() => { + if (!connected) return; if (ctx.myBroadcastTid && !ctx.isMyBroadcastPaused) { sendCommand(`/app/translation/${ctx.myBroadcastTid}/startSharing`); } else if (!ctx.myBroadcastTid) { - // Resume my broadcast saved before a page refresh. const savedTid = sessionStorage.getItem(BROADCAST_TID_SESSION); - if (savedTid && ctx.liveTranslations.find((t) => t.id === savedTid)) { + if (savedTid && ctx.liveTranslations.some((t) => t.id === savedTid)) { ctx.setMyBroadcastTid(savedTid); ctx.setIsMyBroadcastPaused(false); sendCommand(`/app/translation/${savedTid}/startSharing`); } } - }, [connected, ctx.liveTranslations, ctx.selectedLiveTranslation, subscribeToTranslation, sendCommand]); + }, [connected, sendCommand]); return { addLiveTrack, From e6b07e5f8fe5ddde3c00d82a540cb21bd8f18f37 Mon Sep 17 00:00:00 2001 From: Kseniia Velychko Date: Tue, 9 Jun 2026 12:38:15 +0300 Subject: [PATCH 30/40] Extract live track zone logic and harden context menu UX --- .../tracks/liveTrack/LiveTrackContextMenu.jsx | 121 +----------------- map/src/util/livetracks/liveTrackZones.js | 121 ++++++++++++++++++ 2 files changed, 128 insertions(+), 114 deletions(-) create mode 100644 map/src/util/livetracks/liveTrackZones.js diff --git a/map/src/menu/tracks/liveTrack/LiveTrackContextMenu.jsx b/map/src/menu/tracks/liveTrack/LiveTrackContextMenu.jsx index 80f895bd66..3e46b87d0f 100644 --- a/map/src/menu/tracks/liveTrack/LiveTrackContextMenu.jsx +++ b/map/src/menu/tracks/liveTrack/LiveTrackContextMenu.jsx @@ -6,6 +6,7 @@ import AppContext from '../../../context/AppContext'; 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 { ReactComponent as ShareLinkIcon } from '../../../assets/icons/ic_action_link.svg'; import { useWindowSize } from '../../../util/hooks/useWindowSize'; import { getDistance, toHHMMSS } from '../../../util/Utils'; @@ -34,8 +35,6 @@ import trackFavStyles from '../../trackfavmenu.module.css'; import gStyles from '../../gstylesmenu.module.css'; import errorStyles from '../../errors/errors.module.css'; -const ZONE_COLORS = { UPHILL: '#d35400', DOWNHILL: '#27ae60', FLAT: '#7f8c8d' }; - export default function LiveTrackContextMenu({ addLiveTrack, loadEarlier, historyExhausted, requestShare }) { const ctx = useContext(AppContext); const ltx = useContext(LoginContext); @@ -63,7 +62,10 @@ export default function LiveTrackContextMenu({ addLiveTrack, loadEarlier, histor function handleCopyShareLink() { const url = buildLiveTrackShareUrl(translation); if (!url) return; - navigator.clipboard.writeText(url).then(() => setLinkCopied(true)); + navigator.clipboard + .writeText(url) + .then(() => setLinkCopied(true)) + .catch(() => {}); } function handleRequestShare() { @@ -109,6 +111,7 @@ export default function LiveTrackContextMenu({ addLiveTrack, loadEarlier, histor title={t(linkCopied ? 'web:live_track_link_copied' : 'web:live_track_copy_share_link')} arrow placement="bottom" + onClose={() => setLinkCopied(false)} > @@ -120,6 +123,7 @@ export default function LiveTrackContextMenu({ addLiveTrack, loadEarlier, histor title={t(requestSent ? 'web:live_track_request_sent' : 'web:live_track_request_share')} arrow placement="bottom" + onClose={() => setRequestSent(false)} > 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]]; -} - -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; -} - function getTimeAgo(timestamp, t) { if (!timestamp) return '—'; const diff = Math.floor((Date.now() - timestamp) / 1000); 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; +} From db1c6b9922518f4cbe5c6e610c17e89ff33394e4 Mon Sep 17 00:00:00 2001 From: Kseniia Velychko Date: Tue, 9 Jun 2026 14:32:13 +0300 Subject: [PATCH 31/40] Use unit settings for live track card values instead of hardcoded km/m --- .../tracks/liveTrack/LiveTrackContextMenu.jsx | 71 ++++++++++++------- map/src/util/livetracks/liveTrackUtils.js | 9 ++- 2 files changed, 51 insertions(+), 29 deletions(-) diff --git a/map/src/menu/tracks/liveTrack/LiveTrackContextMenu.jsx b/map/src/menu/tracks/liveTrack/LiveTrackContextMenu.jsx index 3e46b87d0f..0009c32e7b 100644 --- a/map/src/menu/tracks/liveTrack/LiveTrackContextMenu.jsx +++ b/map/src/menu/tracks/liveTrack/LiveTrackContextMenu.jsx @@ -7,6 +7,14 @@ 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'; @@ -212,30 +220,37 @@ function LiveParticipantCard({ participant, defaultExpanded = true }) { const locs = participant.locations; - const { speedKmh, altitudeM, maxSpeed, distKm, zones, elevGain, elevLoss } = useMemo(() => { - const speedKmh = locs[0]?.speed != null ? (locs[0].speed * 3.6).toFixed(1) : '0.0'; - const altitudeM = locs[0]?.ele != null ? `${locs[0].ele.toFixed(0)} m` : '—'; - let totalDist = 0; - let maxSpeed = 0; + // Raw SI values (metres, m/s) — converted to the user's units at render time. + const { totalDistM, maxSpeedMS, zones, elevGainM, elevLossM } = useMemo(() => { + let totalDistM = 0; + let maxSpeedMS = 0; for (let i = 0; i < locs.length - 1; i++) { - totalDist += getDistance(locs[i].lat, locs[i].lon, locs[i + 1].lat, locs[i + 1].lon); - const kmh = locs[i].speed != null ? locs[i].speed * 3.6 : 0; - if (kmh > maxSpeed) maxSpeed = kmh; + totalDistM += getDistance(locs[i].lat, locs[i].lon, locs[i + 1].lat, locs[i + 1].lon); + if ((locs[i].speed ?? 0) > maxSpeedMS) maxSpeedMS = locs[i].speed; } - if (locs.length > 0) { - const lastKmh = locs.at(-1).speed != null ? locs.at(-1).speed * 3.6 : 0; - if (lastKmh > maxSpeed) maxSpeed = lastKmh; + if (locs.length > 0 && (locs.at(-1).speed ?? 0) > maxSpeedMS) { + maxSpeedMS = locs.at(-1).speed; } - const distKm = (totalDist / 1000).toFixed(2); const zones = computeZones(locs); - const elevGain = zones.filter((z) => z.eleDiff > 0).reduce((s, z) => s + z.eleDiff, 0); - const elevLoss = zones.filter((z) => z.eleDiff < 0).reduce((s, z) => s + z.eleDiff, 0); + const elevGainM = zones.filter((z) => z.eleDiff > 0).reduce((s, z) => s + z.eleDiff, 0); + const elevLossM = zones.filter((z) => z.eleDiff < 0).reduce((s, z) => s + z.eleDiff, 0); - return { speedKmh, altitudeM, maxSpeed, distKm, zones, elevGain, elevLoss }; + return { totalDistM, maxSpeedMS, zones, elevGainM, elevLossM }; }, [locs]); const duration = Date.now() - participant.startTime; + // Unit labels + formatters, driven by the user's units settings (metric / imperial / nautical). + 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'); @@ -285,33 +300,37 @@ function LiveParticipantCard({ participant, defaultExpanded = true }) { } name={t('shared_string_speed')} - additionalInfo={`${speedKmh} km/h · ${t('web:live_track_updated')} ${getTimeAgo(lastLoc?.time, t)}`} + additionalInfo={`${fmtSpeed(lastLoc?.speed)} ${speedUnit} · ${t('web:live_track_updated')} ${getTimeAgo(lastLoc?.time, t)}`} /> } name={t('web:active_state')} additionalInfo={toHHMMSS(duration)} /> - } name={t('distance')} additionalInfo={`${distKm} km`} /> + } + name={t('distance')} + additionalInfo={`${fmtLarge(totalDistM)} ${largeUnit}`} + /> } name={t('shared_string_max_speed')} - additionalInfo={`${maxSpeed.toFixed(1)} km/h`} + additionalInfo={`${fmtSpeed(maxSpeedMS)} ${speedUnit}`} /> - } name={t('altitude')} additionalInfo={altitudeM} /> - {(elevGain > 0 || elevLoss < 0) && ( + } name={t('altitude')} additionalInfo={altitude} /> + {(elevGainM > 0 || elevLossM < 0) && ( <> } name={t('web:live_track_elevation_gain')} - additionalInfo={`+${elevGain.toFixed(0)} m`} + additionalInfo={`+${fmtSmall(elevGainM)} ${smallUnit}`} /> } name={t('web:live_track_elevation_loss')} - additionalInfo={`${elevLoss.toFixed(0)} m`} + additionalInfo={`${fmtSmall(Math.abs(elevLossM))} ${smallUnit}`} /> )} @@ -331,7 +350,7 @@ function LiveParticipantCard({ participant, defaultExpanded = true }) { } name={t('web:live_track_accuracy')} - additionalInfo={`±${Math.round(accuracyM)} m`} + additionalInfo={`±${fmtSmall(accuracyM)} ${smallUnit}`} /> )} @@ -371,7 +390,7 @@ function LiveParticipantCard({ participant, defaultExpanded = true }) { } name={t('web:live_track_distance_to_destination')} - additionalInfo={`${(distToArrival / 1000).toFixed(2)} km`} + additionalInfo={`${fmtLarge(distToArrival)} ${largeUnit}`} /> )} @@ -391,7 +410,7 @@ function LiveParticipantCard({ participant, defaultExpanded = true }) { } name={t('web:live_track_distance_intermediate')} - additionalInfo={`${(distToIntermediate / 1000).toFixed(2)} km`} + additionalInfo={`${fmtLarge(distToIntermediate)} ${largeUnit}`} /> )} @@ -404,7 +423,7 @@ function LiveParticipantCard({ participant, defaultExpanded = true }) { } name={`${zones.length - i}. ${zoneTypeLabel(z.type)}`} - additionalInfo={`${(z.distance / 1000).toFixed(2)} km · ${z.eleDiff > 0 ? '+' : ''}${z.eleDiff.toFixed(0)} m`} + additionalInfo={`${fmtLarge(z.distance)} ${largeUnit} · ${z.eleDiff >= 0 ? '+' : '-'}${fmtSmall(Math.abs(z.eleDiff))} ${smallUnit}`} /> {i < zones.length - 1 && } diff --git a/map/src/util/livetracks/liveTrackUtils.js b/map/src/util/livetracks/liveTrackUtils.js index a9c3adcc3c..d2d43c5ce4 100644 --- a/map/src/util/livetracks/liveTrackUtils.js +++ b/map/src/util/livetracks/liveTrackUtils.js @@ -23,7 +23,10 @@ export function extractAndSaveLiveTrackKey(raw) { if (!KEY_HEX_RE.test(raw)) return false; try { sessionStorage.setItem(LIVE_TRACK_KEY_SESSION, raw); - } catch (_) {} - history.replaceState(null, '', globalThis.location.pathname + globalThis.location.search); - return true; + // 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; + } } From b7877ea0820db2b706ca44261a158926a0eed902 Mon Sep 17 00:00:00 2001 From: Kseniia Velychko Date: Tue, 9 Jun 2026 16:00:39 +0300 Subject: [PATCH 32/40] Move live track state into LiveTrackingContext and gate its WebSocket --- map/src/App.js | 5 +- map/src/context/AppContext.js | 36 ------ map/src/context/LiveTrackingContext.js | 112 ++++++++++++++++++ .../frame/components/LiveShareRequests.jsx | 12 +- map/src/map/layers/LiveTrackLayer.js | 28 +++-- map/src/menu/actions/LiveTrackItemActions.jsx | 8 +- map/src/menu/tracks/TracksMenu.jsx | 39 +----- .../liveTrack/CreateLiveTrackDialog.jsx | 6 +- .../tracks/liveTrack/LiveTrackContextMenu.jsx | 21 ++-- .../menu/tracks/liveTrack/LiveTrackFolder.jsx | 32 ++--- .../menu/tracks/liveTrack/LiveTrackGroup.jsx | 6 +- .../menu/tracks/liveTrack/LiveTrackItem.jsx | 36 +++--- map/src/util/hooks/live/useLiveTrackUrl.js | 54 --------- map/src/util/hooks/live/useLiveTracking.js | 13 +- 14 files changed, 192 insertions(+), 216 deletions(-) create mode 100644 map/src/context/LiveTrackingContext.js delete mode 100644 map/src/util/hooks/live/useLiveTrackUrl.js diff --git a/map/src/App.js b/map/src/App.js index 605b6c6466..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'; @@ -115,11 +116,11 @@ const App = () => { { path: '/', element: ( - <> + - + ), children: [ { diff --git a/map/src/context/AppContext.js b/map/src/context/AppContext.js index c64130769c..37b019bc3c 100644 --- a/map/src/context/AppContext.js +++ b/map/src/context/AppContext.js @@ -190,24 +190,6 @@ export const AppContextProvider = (props) => { const [smartFoldersCache, setSmartFoldersCache] = useState(null); - // live tracks - 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 [myBroadcastTid, setMyBroadcastTid] = useState(null); - const [isMyBroadcastPaused, setIsMyBroadcastPaused] = useState(false); // selected track const [selectedGpxFile, setSelectedGpxFile] = useState({}); const [unverifiedGpxFile, setUnverifiedGpxFile] = useState(null); // see Effect in LocalClientTrackLayer @@ -760,24 +742,6 @@ export const AppContextProvider = (props) => { setStopByUrl, closeMapObj, setCloseMapObj, - liveTranslations, - setLiveTranslations, - liveParticipants, - setLiveParticipants, - liveViewers, - setLiveViewers, - liveShareRequests, - setLiveShareRequests, - liveShareActions, - setLiveShareActions, - selectedLiveTranslation, - setSelectedLiveTranslation, - followLiveLocation, - setFollowLiveLocation, - myBroadcastTid, - setMyBroadcastTid, - isMyBroadcastPaused, - setIsMyBroadcastPaused, saveTrackToCloud, setSaveTrackToCloud, selectedLocalTrackObj, diff --git a/map/src/context/LiveTrackingContext.js b/map/src/context/LiveTrackingContext.js new file mode 100644 index 0000000000..6be918cba1 --- /dev/null +++ b/map/src/context/LiveTrackingContext.js @@ -0,0 +1,112 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { useLocation, useSearchParams } from 'react-router-dom'; +import { LIVE_TRACKS_STORAGE_KEY } from './AppContext'; +import { LIVE_TRACKS_URL, MAIN_URL_WITH_SLASH } from '../manager/GlobalManager'; +import { LIVE_TRACK_KEY_SESSION } from '../util/livetracks/liveTrackUtils'; +import useLiveTracking from '../util/hooks/live/useLiveTracking'; + +const LiveTrackingContext = React.createContext(); + +const TID_PARAM = 'tid'; +const NAME_PARAM = 'name'; +const KEY_HEX_RE = /^[0-9a-f]{64}$/; + +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 [myBroadcastTid, setMyBroadcastTid] = useState(null); + const [isMyBroadcastPaused, setIsMyBroadcastPaused] = useState(false); + + 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, + myBroadcastTid, + setMyBroadcastTid, + isMyBroadcastPaused, + setIsMyBroadcastPaused, + }), + [ + liveTranslations, + liveParticipants, + liveViewers, + liveShareRequests, + liveShareActions, + selectedLiveTranslation, + followLiveLocation, + myBroadcastTid, + isMyBroadcastPaused, + ] + ); + + // Hold the WebSocket only when live tracks are in use: viewing the page, broadcasting, or having bookmarked translations + const enabled = openLiveTracks || !!myBroadcastTid || 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/components/LiveShareRequests.jsx b/map/src/frame/components/LiveShareRequests.jsx index 826f84e8eb..428aa27706 100644 --- a/map/src/frame/components/LiveShareRequests.jsx +++ b/map/src/frame/components/LiveShareRequests.jsx @@ -1,7 +1,7 @@ import React, { useContext } from 'react'; import { Alert, IconButton, Tooltip } from '@mui/material'; import { useTranslation } from 'react-i18next'; -import AppContext from '../../context/AppContext'; +import LiveTrackingContext from '../../context/LiveTrackingContext'; import { ReactComponent as DoneIcon } from '../../assets/icons/ic_action_done.svg'; import { ReactComponent as CloseIcon } from '../../assets/icons/ic_action_close.svg'; import styles from './liveShareRequests.module.css'; @@ -10,16 +10,16 @@ import styles from './liveShareRequests.module.css'; // ✓ approves, ✗ (dismiss) denies. Pending requests are restored from the server on reload, // so they reappear until the owner reacts. export default function LiveShareRequests() { - const ctx = useContext(AppContext); + const lttx = useContext(LiveTrackingContext); const { t } = useTranslation(); - const requests = ctx.liveShareRequests; + const requests = lttx.liveShareRequests; if (!requests?.length) { return null; } const trackName = (translationId) => - ctx.liveTranslations?.find((tr) => tr.id === translationId)?.name ?? translationId; + lttx.liveTranslations?.find((tr) => tr.id === translationId)?.name ?? translationId; return (
@@ -33,7 +33,7 @@ export default function LiveShareRequests() { ctx.liveShareActions?.approve(req.translationId, req.userId)} + onClick={() => lttx.liveShareActions?.approve(req.translationId, req.userId)} > @@ -42,7 +42,7 @@ export default function LiveShareRequests() { ctx.liveShareActions?.deny(req.translationId, req.userId)} + onClick={() => lttx.liveShareActions?.deny(req.translationId, req.userId)} > diff --git a/map/src/map/layers/LiveTrackLayer.js b/map/src/map/layers/LiveTrackLayer.js index 6cfe7b9931..843e929b70 100644 --- a/map/src/map/layers/LiveTrackLayer.js +++ b/map/src/map/layers/LiveTrackLayer.js @@ -2,10 +2,12 @@ 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 } } } @@ -13,7 +15,7 @@ export default function LiveTrackLayer() { const [pannedFor, setPannedFor] = useState(null); useEffect(() => { - const selectedTid = ctx.selectedLiveTranslation?.id ?? null; + const selectedTid = lttx.selectedLiveTranslation?.id ?? null; // Remove layers for any translation that is not currently selected Object.keys(layersRef.current).forEach((tid) => { @@ -22,7 +24,7 @@ export default function LiveTrackLayer() { if (!selectedTid) return; - const byNickname = ctx.liveParticipants?.[selectedTid]; + const byNickname = lttx.liveParticipants?.[selectedTid]; if (!byNickname) return; if (!layersRef.current[selectedTid]) layersRef.current[selectedTid] = {}; @@ -65,36 +67,36 @@ export default function LiveTrackLayer() { layersRef.current[selectedTid][nickname] = { polyline, marker }; } }); - }, [ctx.liveParticipants, ctx.selectedLiveTranslation]); + }, [lttx.liveParticipants, lttx.selectedLiveTranslation]); // Center map when a translation is selected (if data already loaded) useEffect(() => { - const translation = ctx.selectedLiveTranslation; + const translation = lttx.selectedLiveTranslation; if (!translation) { setPannedFor(null); return; } if (pannedFor === translation.id) return; - const panned = panToTranslation(map, ctx.liveParticipants, translation.id, ctx.infoBlockWidth); + const panned = panToTranslation(map, lttx.liveParticipants, translation.id, ctx.infoBlockWidth); if (panned) setPannedFor(translation.id); - }, [ctx.selectedLiveTranslation]); + }, [lttx.selectedLiveTranslation]); // Center map when data arrives for the selected translation (if not panned yet) useEffect(() => { - const translation = ctx.selectedLiveTranslation; + const translation = lttx.selectedLiveTranslation; if (!translation) return; if (pannedFor === translation.id) return; - const panned = panToTranslation(map, ctx.liveParticipants, translation.id, ctx.infoBlockWidth); + const panned = panToTranslation(map, lttx.liveParticipants, translation.id, ctx.infoBlockWidth); if (panned) setPannedFor(translation.id); - }, [ctx.liveParticipants]); + }, [lttx.liveParticipants]); // Pan to location when Follow button is clicked in context menu. useEffect(() => { - if (!ctx.followLiveLocation) return; + if (!lttx.followLiveLocation) return; const infoBlockWidthPx = Number.parseInt(String(ctx.infoBlockWidth), 10); - panToVisibleCenter(map, ctx.followLiveLocation, infoBlockWidthPx); - ctx.setFollowLiveLocation(null); - }, [ctx.followLiveLocation]); + panToVisibleCenter(map, lttx.followLiveLocation, infoBlockWidthPx); + lttx.setFollowLiveLocation(null); + }, [lttx.followLiveLocation]); // Cleanup on unmount useEffect(() => { diff --git a/map/src/menu/actions/LiveTrackItemActions.jsx b/map/src/menu/actions/LiveTrackItemActions.jsx index 66de71123a..a2a8b9f37a 100644 --- a/map/src/menu/actions/LiveTrackItemActions.jsx +++ b/map/src/menu/actions/LiveTrackItemActions.jsx @@ -1,7 +1,7 @@ import React, { forwardRef, useContext } from 'react'; import { Box, ListItemIcon, ListItemText, MenuItem, Paper, Typography } from '@mui/material'; import { useTranslation } from 'react-i18next'; -import AppContext from '../../context/AppContext'; +import LiveTrackingContext from '../../context/LiveTrackingContext'; 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'; @@ -26,14 +26,14 @@ const LiveTrackItemActions = forwardRef( }, ref ) => { - const ctx = useContext(AppContext); + const lttx = useContext(LiveTrackingContext); const { t } = useTranslation(); const ownerSharingLabel = !isSharing ? 'web:live_track_start_sharing' - : ctx.isMyBroadcastPaused + : lttx.isMyBroadcastPaused ? 'web:live_track_resume_sharing' : 'web:live_track_pause_sharing'; - const ownerSharingIcon = isSharing && !ctx.isMyBroadcastPaused ? : ; + const ownerSharingIcon = isSharing && !lttx.isMyBroadcastPaused ? : ; return ( diff --git a/map/src/menu/tracks/TracksMenu.jsx b/map/src/menu/tracks/TracksMenu.jsx index f6afbf4049..2a4417fea6 100644 --- a/map/src/menu/tracks/TracksMenu.jsx +++ b/map/src/menu/tracks/TracksMenu.jsx @@ -22,8 +22,7 @@ import TrackGroupFolder from './TrackGroupFolder'; import LiveTrackGroup from './liveTrack/LiveTrackGroup'; import LiveTrackFolder from './liveTrack/LiveTrackFolder'; import LiveTrackContextMenu from './liveTrack/LiveTrackContextMenu'; -import useLiveTracking from '../../util/hooks/live/useLiveTracking'; -import useLiveTrackUrl from '../../util/hooks/live/useLiveTrackUrl'; +import LiveTrackingContext from '../../context/LiveTrackingContext'; import { LOGIN_URL, MAIN_URL_WITH_SLASH, MENU_IDS, VISIBLE_TRACKS_URL, liveHash } from '../../manager/GlobalManager'; import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'; @@ -47,19 +46,7 @@ export default function TracksMenu() { const { t } = useTranslation(); - const { - addLiveTrack, - removeLiveTrack, - createLiveTrack, - deleteLiveTrack, - startSharing, - pauseSharing, - regenerateLiveTrack, - loadEarlier, - historyExhausted, - requestShare, - } = useLiveTracking(); - const { openLiveTracks } = useLiveTrackUrl(); + const { openLiveTracks, selectedLiveTranslation } = useContext(LiveTrackingContext); const checkHasFiles = () => ctx.tracksGroups?.length > 0 || defaultGroup?.length > 0 || !isEmpty(ctx.shareWithMeFiles?.tracks); @@ -133,26 +120,10 @@ export default function TracksMenu() { } if (openLiveTracks) { - if (ctx.selectedLiveTranslation) { - return ( - - ); + if (selectedLiveTranslation) { + return ; } - return ( - - ); + return ; } // open folders diff --git a/map/src/menu/tracks/liveTrack/CreateLiveTrackDialog.jsx b/map/src/menu/tracks/liveTrack/CreateLiveTrackDialog.jsx index 76168bc2f9..a053e39867 100644 --- a/map/src/menu/tracks/liveTrack/CreateLiveTrackDialog.jsx +++ b/map/src/menu/tracks/liveTrack/CreateLiveTrackDialog.jsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useContext, useState } from 'react'; import { Alert, Button, @@ -19,6 +19,7 @@ import { ReactComponent as CopyIcon } from '../../../assets/icons/ic_action_copy import { LIVE_TRACKS_URL, MAIN_URL_WITH_SLASH } from '../../../manager/GlobalManager'; import { generateTranslationKey, computeTranslationId } from '../../../util/livetracks/liveTrackCrypto'; import { GEO_ERROR_DENIED, GEO_ERROR_UNAVAILABLE } from '../../../util/livetracks/liveTrackUtils'; +import LiveTrackingContext from '../../../context/LiveTrackingContext'; import dialogStyles from '../../../dialogs/dialog.module.css'; import styles from '../../trackfavmenu.module.css'; @@ -32,7 +33,8 @@ function durationLabel(value, t) { return t('web:live_track_duration_24h'); } -export default function CreateLiveTrackDialog({ open, onClose, createLiveTrack }) { +export default function CreateLiveTrackDialog({ open, onClose }) { + const { createLiveTrack } = useContext(LiveTrackingContext); const { t } = useTranslation(); const navigate = useNavigate(); diff --git a/map/src/menu/tracks/liveTrack/LiveTrackContextMenu.jsx b/map/src/menu/tracks/liveTrack/LiveTrackContextMenu.jsx index 0009c32e7b..3e952b7f4d 100644 --- a/map/src/menu/tracks/liveTrack/LiveTrackContextMenu.jsx +++ b/map/src/menu/tracks/liveTrack/LiveTrackContextMenu.jsx @@ -3,6 +3,7 @@ import { Box, Collapse, Icon, IconButton, ListItemText, MenuItem, Tooltip } from 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'; @@ -43,8 +44,9 @@ import trackFavStyles from '../../trackfavmenu.module.css'; import gStyles from '../../gstylesmenu.module.css'; import errorStyles from '../../errors/errors.module.css'; -export default function LiveTrackContextMenu({ addLiveTrack, loadEarlier, historyExhausted, requestShare }) { - const ctx = useContext(AppContext); +export default function LiveTrackContextMenu() { + const lttx = useContext(LiveTrackingContext); + const { addLiveTrack, loadEarlier, historyExhausted, requestShare } = lttx; const ltx = useContext(LoginContext); const { t } = useTranslation(); const navigate = useNavigate(); @@ -52,18 +54,18 @@ export default function LiveTrackContextMenu({ addLiveTrack, loadEarlier, histor const [linkCopied, setLinkCopied] = useState(false); const [requestSent, setRequestSent] = useState(false); - const translation = ctx.selectedLiveTranslation; - const participants = translation ? (ctx.liveParticipants?.[translation.id] ?? {}) : {}; + 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 ? (ctx.liveViewers?.[translation.id] ?? {}) : {}; + const viewers = translation ? (lttx.liveViewers?.[translation.id] ?? {}) : {}; const viewerCount = Object.keys(viewers).length; function handleBack() { - ctx.setSelectedLiveTranslation(null); + lttx.setSelectedLiveTranslation(null); navigate(MAIN_URL_WITH_SLASH + LIVE_TRACKS_URL); } @@ -86,7 +88,7 @@ export default function LiveTrackContextMenu({ addLiveTrack, loadEarlier, histor translation && !translation.isOwner && !!translation.key && - ctx.myBroadcastTid !== translation.id; + lttx.myBroadcastTid !== translation.id; return ( )} - {(!ltx.loginUser || !ctx.liveTranslations.some((t) => t.id === translation?.id)) && ( + {(!ltx.loginUser || !lttx.liveTranslations.some((t) => t.id === translation?.id)) && ( - setDialogOpen(false)} - createLiveTrack={createLiveTrack} - /> - {ctx.liveTranslations.length === 0 ? ( + setDialogOpen(false)} /> + {lttx.liveTranslations.length === 0 ? ( ) : ( - {ctx.liveTranslations.map((translation, index) => ( + {lttx.liveTranslations.map((translation, index) => ( ))} diff --git a/map/src/menu/tracks/liveTrack/LiveTrackGroup.jsx b/map/src/menu/tracks/liveTrack/LiveTrackGroup.jsx index ce6fb197db..e9a9fbb105 100644 --- a/map/src/menu/tracks/liveTrack/LiveTrackGroup.jsx +++ b/map/src/menu/tracks/liveTrack/LiveTrackGroup.jsx @@ -2,18 +2,18 @@ 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 AppContext from '../../../context/AppContext'; +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 ctx = useContext(AppContext); + const lttx = useContext(LiveTrackingContext); const { t } = useTranslation(); const navigate = useNavigate(); - const count = ctx.liveTranslations.length; + const count = lttx.liveTranslations.length; const infoText = count > 0 ? `${count} ${t('web:live_tracks').toLowerCase()}` : ''; function handleClick() { diff --git a/map/src/menu/tracks/liveTrack/LiveTrackItem.jsx b/map/src/menu/tracks/liveTrack/LiveTrackItem.jsx index acf475a2d7..4c97210b0c 100644 --- a/map/src/menu/tracks/liveTrack/LiveTrackItem.jsx +++ b/map/src/menu/tracks/liveTrack/LiveTrackItem.jsx @@ -2,7 +2,7 @@ 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 AppContext from '../../../context/AppContext'; +import LiveTrackingContext from '../../../context/LiveTrackingContext'; import { LIVE_TRACKS_URL, MAIN_URL_WITH_SLASH } from '../../../manager/GlobalManager'; import { buildLiveTrackShareUrl } from '../../../util/livetracks/liveTrackUtils'; import { ReactComponent as LocationIcon } from '../../../assets/icons/ic_action_location_marker_outlined.svg'; @@ -13,16 +13,8 @@ import LiveTrackItemActions from '../../actions/LiveTrackItemActions'; import DividerWithMargin from '../../../frame/components/dividers/DividerWithMargin'; import MenuItemWithLines from '../../components/MenuItemWithLines'; -export default function LiveTrackItem({ - translation, - isLastItem, - removeLiveTrack, - deleteLiveTrack, - startSharing, - pauseSharing, - regenerateLiveTrack, -}) { - const ctx = useContext(AppContext); +export default function LiveTrackItem({ translation, isLastItem }) { + const lttx = useContext(LiveTrackingContext); const { t } = useTranslation(); const navigate = useNavigate(); @@ -31,43 +23,43 @@ export default function LiveTrackItem({ const [openActions, setOpenActions] = useState(false); const isOwner = translation.isOwner === true; - const isSharing = ctx.myBroadcastTid === translation.id; + const isSharing = lttx.myBroadcastTid === translation.id; const isParticipant = isSharing && !isOwner; - const participants = ctx.liveParticipants?.[translation.id]; + 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; - ctx.setSelectedLiveTranslation(translation); + lttx.setSelectedLiveTranslation(translation); const params = new URLSearchParams({ tid: translation.id, name: translation.name }); navigate(`${MAIN_URL_WITH_SLASH}${LIVE_TRACKS_URL}?${params}`); } function handleRemoveBookmark() { setOpenActions(false); - removeLiveTrack(translation.id); + lttx.removeLiveTrack(translation.id); } function handleOwnerSharingAction() { setOpenActions(false); - if (!isSharing || ctx.isMyBroadcastPaused) { - startSharing(translation.id); + if (!isSharing || lttx.isMyBroadcastPaused) { + lttx.startSharing(translation.id); } else { - pauseSharing(); + lttx.pauseSharing(); } } function handleParticipantStop() { setOpenActions(false); - pauseSharing(); + lttx.pauseSharing(); } function handleDeleteForAll() { setOpenActions(false); - deleteLiveTrack(translation.id); + lttx.deleteLiveTrack(translation.id); } function handleCopyShareLink() { @@ -78,7 +70,7 @@ export default function LiveTrackItem({ function handleRegenerate() { setOpenActions(false); - regenerateLiveTrack(translation.id, (newTranslation) => { + lttx.regenerateLiveTrack(translation.id, (newTranslation) => { const params = new URLSearchParams({ tid: newTranslation.id, name: newTranslation.name }); navigate(`${MAIN_URL_WITH_SLASH}${LIVE_TRACKS_URL}?${params}`); }); @@ -89,7 +81,7 @@ export default function LiveTrackItem({ 0 ? '#4CAF50' : '#F44336' }} /> diff --git a/map/src/util/hooks/live/useLiveTrackUrl.js b/map/src/util/hooks/live/useLiveTrackUrl.js deleted file mode 100644 index 546a34b671..0000000000 --- a/map/src/util/hooks/live/useLiveTrackUrl.js +++ /dev/null @@ -1,54 +0,0 @@ -import { useContext, useEffect } from 'react'; -import { useLocation, useSearchParams } from 'react-router-dom'; -import AppContext from '../../../context/AppContext'; -import { LIVE_TRACKS_URL, MAIN_URL_WITH_SLASH } from '../../../manager/GlobalManager'; -import { LIVE_TRACK_KEY_SESSION } from '../../livetracks/liveTrackUtils'; - -const TID_PARAM = 'tid'; -const NAME_PARAM = 'name'; -const KEY_HEX_RE = /^[0-9a-f]{64}$/; - -// Share URL format: /map/live/?tid=<16chars>&name=# -// Key is extracted from the fragment by OsmAndMap before leaflet-hash runs, -// saved to sessionStorage, and read here. -export default function useLiveTrackUrl() { - const ctx = useContext(AppContext); - const location = useLocation(); - const [searchParams] = useSearchParams(); - - const livePath = MAIN_URL_WITH_SLASH + LIVE_TRACKS_URL; - const openLiveTracks = location.pathname.startsWith(livePath); - - useEffect(() => { - if (!openLiveTracks) { - ctx.setSelectedLiveTranslation(null); - return; - } - - const tid = searchParams.get(TID_PARAM); - if (!tid) { - ctx.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 = ctx.liveTranslations.find((t) => t.id === tid); - if (fromList) { - const entry = key && !fromList.key ? { ...fromList, key } : fromList; - ctx.setSelectedLiveTranslation(entry); - } else { - const name = searchParams.get(NAME_PARAM) ?? ''; - ctx.setSelectedLiveTranslation({ id: tid, name, ...(key ? { key } : {}) }); - } - }, [openLiveTracks, location.search]); - - return { openLiveTracks }; -} diff --git a/map/src/util/hooks/live/useLiveTracking.js b/map/src/util/hooks/live/useLiveTracking.js index 94bba7fdaa..d6e51ec9cb 100644 --- a/map/src/util/hooks/live/useLiveTracking.js +++ b/map/src/util/hooks/live/useLiveTracking.js @@ -1,6 +1,6 @@ -import { useContext, useEffect, useRef, useCallback, useState } from 'react'; +import { useEffect, useRef, useCallback, useState } from 'react'; import { Client } from '@stomp/stompjs'; -import AppContext, { LIVE_TRACKS_STORAGE_KEY } from '../../../context/AppContext'; +import { LIVE_TRACKS_STORAGE_KEY } from '../../../context/AppContext'; import { getColorByIndex } from '../../../menu/analyzer/util/SegmentColorizer'; import { encryptLocation, @@ -19,9 +19,7 @@ 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() { - const ctx = useContext(AppContext); - +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 @@ -548,8 +546,9 @@ export default function useLiveTracking() { [ctx.setLiveParticipants] ); - // Connect once on mount. + // 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, @@ -637,7 +636,7 @@ export default function useLiveTracking() { subscribedRef.current.clear(); setConnected(false); }; - }, []); + }, [enabled]); useEffect(() => { if (!connected) return; From f1af2e444abdf3d955d988c35ce80f5343b8a21d Mon Sep 17 00:00:00 2001 From: Kseniia Velychko Date: Tue, 9 Jun 2026 16:15:50 +0300 Subject: [PATCH 33/40] Move LIVE_TRACKS_STORAGE_KEY out of AppContext into liveTrackUtils --- map/src/context/AppContext.js | 2 -- map/src/context/LiveTrackingContext.js | 3 +-- map/src/util/hooks/live/useLiveTracking.js | 3 +-- map/src/util/livetracks/liveTrackUtils.js | 3 +-- 4 files changed, 3 insertions(+), 8 deletions(-) diff --git a/map/src/context/AppContext.js b/map/src/context/AppContext.js index 37b019bc3c..40fcc2579d 100644 --- a/map/src/context/AppContext.js +++ b/map/src/context/AppContext.js @@ -56,8 +56,6 @@ export const PREVIOUS_ROUTE_STORAGE_KEY = 'previousRoute'; export const OBJECT_TYPE_TRAVEL = 'travel'; export const OBJECT_TYPE_SHARE_FILE = 'share_file'; -export const LIVE_TRACKS_STORAGE_KEY = 'liveTranslations'; - export const MAX_RECENT_OBJS = 5; export const defaultConfigureMapStateValues = { diff --git a/map/src/context/LiveTrackingContext.js b/map/src/context/LiveTrackingContext.js index 6be918cba1..011adba46b 100644 --- a/map/src/context/LiveTrackingContext.js +++ b/map/src/context/LiveTrackingContext.js @@ -1,8 +1,7 @@ import React, { useEffect, useMemo, useState } from 'react'; import { useLocation, useSearchParams } from 'react-router-dom'; -import { LIVE_TRACKS_STORAGE_KEY } from './AppContext'; import { LIVE_TRACKS_URL, MAIN_URL_WITH_SLASH } from '../manager/GlobalManager'; -import { LIVE_TRACK_KEY_SESSION } from '../util/livetracks/liveTrackUtils'; +import { LIVE_TRACK_KEY_SESSION, LIVE_TRACKS_STORAGE_KEY } from '../util/livetracks/liveTrackUtils'; import useLiveTracking from '../util/hooks/live/useLiveTracking'; const LiveTrackingContext = React.createContext(); diff --git a/map/src/util/hooks/live/useLiveTracking.js b/map/src/util/hooks/live/useLiveTracking.js index d6e51ec9cb..2a3b767c82 100644 --- a/map/src/util/hooks/live/useLiveTracking.js +++ b/map/src/util/hooks/live/useLiveTracking.js @@ -1,6 +1,5 @@ import { useEffect, useRef, useCallback, useState } from 'react'; import { Client } from '@stomp/stompjs'; -import { LIVE_TRACKS_STORAGE_KEY } from '../../../context/AppContext'; import { getColorByIndex } from '../../../menu/analyzer/util/SegmentColorizer'; import { encryptLocation, @@ -8,7 +7,7 @@ import { generateTranslationKey, computeTranslationId, } from '../../livetracks/liveTrackCrypto'; -import { GEO_ERROR_DENIED, GEO_ERROR_UNAVAILABLE } from '../../livetracks/liveTrackUtils'; +import { GEO_ERROR_DENIED, GEO_ERROR_UNAVAILABLE, LIVE_TRACKS_STORAGE_KEY } from '../../livetracks/liveTrackUtils'; // sessionStorage key: my broadcast tid, restored after page refresh. const BROADCAST_TID_SESSION = '__liveTrackBroadcastTid__'; diff --git a/map/src/util/livetracks/liveTrackUtils.js b/map/src/util/livetracks/liveTrackUtils.js index d2d43c5ce4..d0e40acb75 100644 --- a/map/src/util/livetracks/liveTrackUtils.js +++ b/map/src/util/livetracks/liveTrackUtils.js @@ -1,7 +1,6 @@ import { LIVE_TRACKS_URL, MAIN_URL_WITH_SLASH } from '../../manager/GlobalManager'; -// sessionStorage key used to pass the live-track share key across the -// leaflet-hash initialization that would otherwise overwrite the URL fragment. +export const LIVE_TRACKS_STORAGE_KEY = 'liveTranslations'; export const LIVE_TRACK_KEY_SESSION = '__liveTrackShareKey__'; export const GEO_ERROR_DENIED = 'geolocation_denied'; From cff3c56173aeb0524ca905718f7720f23a1acfde Mon Sep 17 00:00:00 2001 From: Kseniia Velychko Date: Tue, 9 Jun 2026 16:42:59 +0300 Subject: [PATCH 34/40] Formatting --- map/src/menu/tracks/liveTrack/CreateLiveTrackDialog.jsx | 5 ++--- map/src/menu/tracks/liveTrack/LiveTrackContextMenu.jsx | 8 ++++---- map/src/menu/tracks/liveTrack/LiveTrackFolder.jsx | 2 ++ map/src/menu/tracks/liveTrack/LiveTrackGroup.jsx | 1 + 4 files changed, 9 insertions(+), 7 deletions(-) diff --git a/map/src/menu/tracks/liveTrack/CreateLiveTrackDialog.jsx b/map/src/menu/tracks/liveTrack/CreateLiveTrackDialog.jsx index a053e39867..aca3dd1dae 100644 --- a/map/src/menu/tracks/liveTrack/CreateLiveTrackDialog.jsx +++ b/map/src/menu/tracks/liveTrack/CreateLiveTrackDialog.jsx @@ -30,6 +30,7 @@ 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'); } @@ -70,9 +71,7 @@ export default function CreateLiveTrackDialog({ open, onClose }) { setGeoError('web:live_track_geo_denied'); return; } - } catch (_) { - // permissions API not supported — proceed and let watchPosition handle it - } + } catch {} } setCreating(true); diff --git a/map/src/menu/tracks/liveTrack/LiveTrackContextMenu.jsx b/map/src/menu/tracks/liveTrack/LiveTrackContextMenu.jsx index 3e952b7f4d..8b01a188e0 100644 --- a/map/src/menu/tracks/liveTrack/LiveTrackContextMenu.jsx +++ b/map/src/menu/tracks/liveTrack/LiveTrackContextMenu.jsx @@ -48,8 +48,10 @@ export default function LiveTrackContextMenu() { const lttx = useContext(LiveTrackingContext); const { addLiveTrack, loadEarlier, 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); @@ -211,10 +213,9 @@ function LiveParticipantCard({ participant, defaultExpanded = true }) { const ctx = useContext(AppContext); const lttx = useContext(LiveTrackingContext); const { t } = useTranslation(); - // Owner / own card start expanded, others start collapsed; all stay clickable to toggle. + const [expanded, setExpanded] = useState(defaultExpanded); - // owner/userId flags may arrive after the card mounts — open it once they do. useEffect(() => { if (defaultExpanded) { setExpanded(true); @@ -223,7 +224,6 @@ function LiveParticipantCard({ participant, defaultExpanded = true }) { const locs = participant.locations; - // Raw SI values (metres, m/s) — converted to the user's units at render time. const { totalDistM, maxSpeedMS, zones, elevGainM, elevLossM } = useMemo(() => { let totalDistM = 0; let maxSpeedMS = 0; @@ -243,10 +243,10 @@ function LiveParticipantCard({ participant, defaultExpanded = true }) { const duration = Date.now() - participant.startTime; - // Unit labels + formatters, driven by the user's units settings (metric / imperial / nautical). 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); diff --git a/map/src/menu/tracks/liveTrack/LiveTrackFolder.jsx b/map/src/menu/tracks/liveTrack/LiveTrackFolder.jsx index e27f4cf549..aa43bdd0a7 100644 --- a/map/src/menu/tracks/liveTrack/LiveTrackFolder.jsx +++ b/map/src/menu/tracks/liveTrack/LiveTrackFolder.jsx @@ -16,7 +16,9 @@ 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); diff --git a/map/src/menu/tracks/liveTrack/LiveTrackGroup.jsx b/map/src/menu/tracks/liveTrack/LiveTrackGroup.jsx index e9a9fbb105..f42281adbd 100644 --- a/map/src/menu/tracks/liveTrack/LiveTrackGroup.jsx +++ b/map/src/menu/tracks/liveTrack/LiveTrackGroup.jsx @@ -11,6 +11,7 @@ import { LIVE_TRACKS_URL, MAIN_URL_WITH_SLASH } from '../../../manager/GlobalMan export default function LiveTrackGroup() { const lttx = useContext(LiveTrackingContext); const { t } = useTranslation(); + const navigate = useNavigate(); const count = lttx.liveTranslations.length; From fd5f5ca589d04bc2f593d5d3759e306db984fc05 Mon Sep 17 00:00:00 2001 From: Kseniia Velychko Date: Tue, 9 Jun 2026 16:52:43 +0300 Subject: [PATCH 35/40] Deduplicate live track URL params and helpers --- map/src/context/LiveTrackingContext.js | 12 +++++++----- map/src/menu/tracks/TracksMenu.jsx | 3 ++- .../tracks/liveTrack/CreateLiveTrackDialog.jsx | 18 +++++++++--------- .../menu/tracks/liveTrack/LiveTrackItem.jsx | 6 +++--- map/src/util/livetracks/liveTrackUtils.js | 11 ++++++++--- 5 files changed, 29 insertions(+), 21 deletions(-) diff --git a/map/src/context/LiveTrackingContext.js b/map/src/context/LiveTrackingContext.js index 011adba46b..cee944d45e 100644 --- a/map/src/context/LiveTrackingContext.js +++ b/map/src/context/LiveTrackingContext.js @@ -1,15 +1,17 @@ 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 { LIVE_TRACK_KEY_SESSION, LIVE_TRACKS_STORAGE_KEY } from '../util/livetracks/liveTrackUtils'; +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(); -const TID_PARAM = 'tid'; -const NAME_PARAM = 'name'; -const KEY_HEX_RE = /^[0-9a-f]{64}$/; - export const LiveTrackingProvider = ({ children }) => { const location = useLocation(); const [searchParams] = useSearchParams(); diff --git a/map/src/menu/tracks/TracksMenu.jsx b/map/src/menu/tracks/TracksMenu.jsx index 2a4417fea6..cf40c51c29 100644 --- a/map/src/menu/tracks/TracksMenu.jsx +++ b/map/src/menu/tracks/TracksMenu.jsx @@ -23,6 +23,7 @@ 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'; @@ -104,7 +105,7 @@ export default function TracksMenu() { } }, [defaultGroup?.groupFiles]); - const needLiveLogin = openLiveTracks && !ltx.loginUser && !searchParams.get('tid'); + 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 }); diff --git a/map/src/menu/tracks/liveTrack/CreateLiveTrackDialog.jsx b/map/src/menu/tracks/liveTrack/CreateLiveTrackDialog.jsx index aca3dd1dae..ce933ff573 100644 --- a/map/src/menu/tracks/liveTrack/CreateLiveTrackDialog.jsx +++ b/map/src/menu/tracks/liveTrack/CreateLiveTrackDialog.jsx @@ -18,7 +18,13 @@ 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 { GEO_ERROR_DENIED, GEO_ERROR_UNAVAILABLE } from '../../../util/livetracks/liveTrackUtils'; +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'; @@ -92,16 +98,10 @@ export default function CreateLiveTrackDialog({ open, onClose }) { name.trim() || null, duration, (translation) => { - const urlParams = new URLSearchParams({ tid: translation.id }); - if (translation.name) { - urlParams.set('name', translation.name); - } - setShareUrl( - `${globalThis.location.origin}${MAIN_URL_WITH_SLASH}${LIVE_TRACKS_URL}?${urlParams}#${key}` - ); + setShareUrl(buildLiveTrackShareUrl(translation)); setCreating(false); navigate( - `${MAIN_URL_WITH_SLASH}${LIVE_TRACKS_URL}?tid=${translation.id}&name=${encodeURIComponent(translation.name)}` + `${MAIN_URL_WITH_SLASH}${LIVE_TRACKS_URL}?${TID_PARAM}=${translation.id}&${NAME_PARAM}=${encodeURIComponent(translation.name)}` ); }, (errCode) => { diff --git a/map/src/menu/tracks/liveTrack/LiveTrackItem.jsx b/map/src/menu/tracks/liveTrack/LiveTrackItem.jsx index 4c97210b0c..b0bcd6ddbd 100644 --- a/map/src/menu/tracks/liveTrack/LiveTrackItem.jsx +++ b/map/src/menu/tracks/liveTrack/LiveTrackItem.jsx @@ -4,7 +4,7 @@ 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 } from '../../../util/livetracks/liveTrackUtils'; +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'; @@ -34,7 +34,7 @@ export default function LiveTrackItem({ translation, isLastItem }) { function handleClick(e) { if (anchorEl.current?.contains(e.target)) return; lttx.setSelectedLiveTranslation(translation); - const params = new URLSearchParams({ tid: translation.id, name: translation.name }); + const params = new URLSearchParams({ [TID_PARAM]: translation.id, [NAME_PARAM]: translation.name }); navigate(`${MAIN_URL_WITH_SLASH}${LIVE_TRACKS_URL}?${params}`); } @@ -71,7 +71,7 @@ export default function LiveTrackItem({ translation, isLastItem }) { function handleRegenerate() { setOpenActions(false); lttx.regenerateLiveTrack(translation.id, (newTranslation) => { - const params = new URLSearchParams({ tid: newTranslation.id, name: newTranslation.name }); + const params = new URLSearchParams({ [TID_PARAM]: newTranslation.id, [NAME_PARAM]: newTranslation.name }); navigate(`${MAIN_URL_WITH_SLASH}${LIVE_TRACKS_URL}?${params}`); }); } diff --git a/map/src/util/livetracks/liveTrackUtils.js b/map/src/util/livetracks/liveTrackUtils.js index d0e40acb75..092a8b5860 100644 --- a/map/src/util/livetracks/liveTrackUtils.js +++ b/map/src/util/livetracks/liveTrackUtils.js @@ -3,16 +3,21 @@ import { LIVE_TRACKS_URL, MAIN_URL_WITH_SLASH } from '../../manager/GlobalManage 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'; -const KEY_HEX_RE = /^[0-9a-f]{64}$/; +// 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: translation.id }); - if (translation.name) params.set('name', translation.name); + 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}`; } From c42fb85cce986c4b265f3f42af267e476a929eab Mon Sep 17 00:00:00 2001 From: Kseniia Velychko Date: Wed, 10 Jun 2026 13:06:08 +0300 Subject: [PATCH 36/40] Broadcast location into multiple live translations --- map/src/context/LiveTrackingContext.js | 14 +- map/src/menu/actions/LiveTrackItemActions.jsx | 12 +- .../tracks/liveTrack/LiveTrackContextMenu.jsx | 2 +- .../menu/tracks/liveTrack/LiveTrackItem.jsx | 10 +- map/src/util/hooks/live/useLiveTracking.js | 150 +++++++++++------- 5 files changed, 104 insertions(+), 84 deletions(-) diff --git a/map/src/context/LiveTrackingContext.js b/map/src/context/LiveTrackingContext.js index cee944d45e..aa4f576cdc 100644 --- a/map/src/context/LiveTrackingContext.js +++ b/map/src/context/LiveTrackingContext.js @@ -31,8 +31,7 @@ export const LiveTrackingProvider = ({ children }) => { const [liveShareActions, setLiveShareActions] = useState(null); const [selectedLiveTranslation, setSelectedLiveTranslation] = useState(null); const [followLiveLocation, setFollowLiveLocation] = useState(null); - const [myBroadcastTid, setMyBroadcastTid] = useState(null); - const [isMyBroadcastPaused, setIsMyBroadcastPaused] = useState(false); + const [myBroadcastTids, setMyBroadcastTids] = useState([]); const livePath = MAIN_URL_WITH_SLASH + LIVE_TRACKS_URL; const openLiveTracks = location.pathname.startsWith(livePath); @@ -84,10 +83,8 @@ export const LiveTrackingProvider = ({ children }) => { setSelectedLiveTranslation, followLiveLocation, setFollowLiveLocation, - myBroadcastTid, - setMyBroadcastTid, - isMyBroadcastPaused, - setIsMyBroadcastPaused, + myBroadcastTids, + setMyBroadcastTids, }), [ liveTranslations, @@ -97,13 +94,12 @@ export const LiveTrackingProvider = ({ children }) => { liveShareActions, selectedLiveTranslation, followLiveLocation, - myBroadcastTid, - isMyBroadcastPaused, + myBroadcastTids, ] ); // Hold the WebSocket only when live tracks are in use: viewing the page, broadcasting, or having bookmarked translations - const enabled = openLiveTracks || !!myBroadcastTid || liveTranslations.length > 0; + const enabled = openLiveTracks || myBroadcastTids.length > 0 || liveTranslations.length > 0; const api = useLiveTracking(liveState, enabled); const value = useMemo(() => ({ ...liveState, ...api, openLiveTracks }), [liveState, api, openLiveTracks]); diff --git a/map/src/menu/actions/LiveTrackItemActions.jsx b/map/src/menu/actions/LiveTrackItemActions.jsx index a2a8b9f37a..2491187d81 100644 --- a/map/src/menu/actions/LiveTrackItemActions.jsx +++ b/map/src/menu/actions/LiveTrackItemActions.jsx @@ -1,7 +1,6 @@ -import React, { forwardRef, useContext } from 'react'; +import React, { forwardRef } from 'react'; import { Box, ListItemIcon, ListItemText, MenuItem, Paper, Typography } from '@mui/material'; import { useTranslation } from 'react-i18next'; -import LiveTrackingContext from '../../context/LiveTrackingContext'; 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'; @@ -26,14 +25,9 @@ const LiveTrackItemActions = forwardRef( }, ref ) => { - const lttx = useContext(LiveTrackingContext); const { t } = useTranslation(); - const ownerSharingLabel = !isSharing - ? 'web:live_track_start_sharing' - : lttx.isMyBroadcastPaused - ? 'web:live_track_resume_sharing' - : 'web:live_track_pause_sharing'; - const ownerSharingIcon = isSharing && !lttx.isMyBroadcastPaused ? : ; + const ownerSharingLabel = isSharing ? 'web:live_track_pause_sharing' : 'web:live_track_start_sharing'; + const ownerSharingIcon = isSharing ? : ; return ( diff --git a/map/src/menu/tracks/liveTrack/LiveTrackContextMenu.jsx b/map/src/menu/tracks/liveTrack/LiveTrackContextMenu.jsx index 8b01a188e0..32c13e8857 100644 --- a/map/src/menu/tracks/liveTrack/LiveTrackContextMenu.jsx +++ b/map/src/menu/tracks/liveTrack/LiveTrackContextMenu.jsx @@ -90,7 +90,7 @@ export default function LiveTrackContextMenu() { translation && !translation.isOwner && !!translation.key && - lttx.myBroadcastTid !== translation.id; + !lttx.myBroadcastTids.includes(translation.id); return ( { - if (!ctx.myBroadcastTid || ctx.isMyBroadcastPaused || !navigator.geolocation || !connected) return; + 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 key = keysRef.current[ctx.myBroadcastTid]; - if (!key) return; const locationData = { lat: latitude, lon: longitude, @@ -93,11 +95,17 @@ export default function useLiveTracking(ctx, enabled = true) { // under `acc` rather than mislabeling it as the mobile broadcaster's `hdop`. ...(accuracy != null && { acc: accuracy }), }; - encryptLocation(key, locationData) - .then((encData) => { - fetch(`/mapapi/translation/msg?encryptedData=${encodeURIComponent(encData)}`).catch(() => {}); - }) - .catch(() => {}); + for (const tid of tids) { + const key = keysRef.current[tid]; + if (!key) continue; + encryptLocation(key, locationData) + .then((encData) => { + fetch( + `/mapapi/translation/msg?translationId=${encodeURIComponent(tid)}&encryptedData=${encodeURIComponent(encData)}` + ).catch(() => {}); + }) + .catch(() => {}); + } }, (error) => { const code = error?.code === error?.PERMISSION_DENIED ? GEO_ERROR_DENIED : GEO_ERROR_UNAVAILABLE; @@ -107,7 +115,7 @@ export default function useLiveTracking(ctx, enabled = true) { ); return () => navigator.geolocation.clearWatch(watchId); - }, [ctx.myBroadcastTid, ctx.isMyBroadcastPaused, connected]); + }, [ctx.myBroadcastTids, connected]); // Add a live point to a participant (newest at index 0). const updateParticipant = useCallback( @@ -320,28 +328,32 @@ export default function useLiveTracking(ctx, enabled = true) { ] ); - // Start (or resume) sharing this translation, stopping any other active one first. + // Start broadcasting into this translation. Added to the active set — other broadcasts keep going. const startSharing = useCallback( (translationId) => { - if (ctx.myBroadcastTid && ctx.myBroadcastTid !== translationId) { - sendCommand(`/app/translation/${ctx.myBroadcastTid}/stopSharing`); - } sendCommand(`/app/translation/${translationId}/startSharing`); - sessionStorage.setItem(BROADCAST_TID_SESSION, translationId); - ctx.setMyBroadcastTid(translationId); - ctx.setIsMyBroadcastPaused(false); + ctx.setMyBroadcastTids((prev) => { + const next = prev.includes(translationId) ? prev : [...prev, translationId]; + saveBroadcastTids(next); + return next; + }); }, - [ctx.myBroadcastTid, sendCommand, ctx.setMyBroadcastTid, ctx.setIsMyBroadcastPaused] + [sendCommand, ctx.setMyBroadcastTids] ); - // Pause sharing but keep myBroadcastTid so the owner can resume later. - const pauseSharing = useCallback(() => { - if (ctx.myBroadcastTid) { - sendCommand(`/app/translation/${ctx.myBroadcastTid}/stopSharing`); - } - sessionStorage.removeItem(BROADCAST_TID_SESSION); - ctx.setIsMyBroadcastPaused(true); - }, [ctx.myBroadcastTid, sendCommand, ctx.setIsMyBroadcastPaused]); + // 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( @@ -399,11 +411,6 @@ export default function useLiveTracking(ctx, enabled = true) { const createLiveTrack = useCallback( (translationId, key, name, durationHours, onCreated, onGeoError, onCreateError, replaceId) => { geoErrorRef.current = onGeoError ?? null; - if (ctx.myBroadcastTid) { - sendCommand(`/app/translation/${ctx.myBroadcastTid}/stopSharing`); - ctx.setMyBroadcastTid(null); - ctx.setIsMyBroadcastPaused(false); - } pendingCreateRef.current = { onSuccess: (id) => { @@ -421,11 +428,15 @@ export default function useLiveTracking(ctx, enabled = true) { if (client?.connected) { subscribeToTranslation(client, id); } - // Mark as my broadcast — the geolocation watch starts via useEffect. - sessionStorage.setItem(BROADCAST_TID_SESSION, id); - ctx.setMyBroadcastTid(id); - ctx.setIsMyBroadcastPaused(false); + // 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`); @@ -443,9 +454,7 @@ export default function useLiveTracking(ctx, enabled = true) { }); }, [ - ctx.myBroadcastTid, - ctx.setMyBroadcastTid, - ctx.setIsMyBroadcastPaused, + ctx.setMyBroadcastTids, ctx.liveTranslations, saveTranslations, ctx.setSelectedLiveTranslation, @@ -474,15 +483,16 @@ export default function useLiveTracking(ctx, enabled = true) { // Delete the translation for everyone (owner only, enforced server-side). const deleteLiveTrack = useCallback( (id) => { - if (ctx.myBroadcastTid === id) { + ctx.setMyBroadcastTids((prev) => { + if (!prev.includes(id)) return prev; sendCommand(`/app/translation/${id}/stopSharing`); - sessionStorage.removeItem(BROADCAST_TID_SESSION); - ctx.setMyBroadcastTid(null); - ctx.setIsMyBroadcastPaused(false); - } + const next = prev.filter((t) => t !== id); + saveBroadcastTids(next); + return next; + }); sendCommand(`/app/translation/${id}/delete`); }, - [ctx.myBroadcastTid, sendCommand, ctx.setMyBroadcastTid, ctx.setIsMyBroadcastPaused] + [ctx.setMyBroadcastTids, sendCommand] ); // Decrypt a /load history batch into participant tracks (newest-first, de-duped by time). @@ -608,9 +618,11 @@ export default function useLiveTracking(ctx, enabled = true) { } else if (msg.type === 'SHARE_APPROVED' && msg.data) { // Requester side: approved — start broadcasting into that translation. const tid = msg.data; - sessionStorage.setItem(BROADCAST_TID_SESSION, tid); - ctx.setMyBroadcastTid(tid); - ctx.setIsMyBroadcastPaused(false); + 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); @@ -648,18 +660,18 @@ export default function useLiveTracking(ctx, enabled = true) { } }, [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; - if (ctx.myBroadcastTid && !ctx.isMyBroadcastPaused) { - sendCommand(`/app/translation/${ctx.myBroadcastTid}/startSharing`); - } else if (!ctx.myBroadcastTid) { - const savedTid = sessionStorage.getItem(BROADCAST_TID_SESSION); - if (savedTid && ctx.liveTranslations.some((t) => t.id === savedTid)) { - ctx.setMyBroadcastTid(savedTid); - ctx.setIsMyBroadcastPaused(false); - sendCommand(`/app/translation/${savedTid}/startSharing`); - } + 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 { @@ -668,10 +680,28 @@ export default function useLiveTracking(ctx, enabled = true) { createLiveTrack, deleteLiveTrack, startSharing, - pauseSharing, + stopSharing, regenerateLiveTrack, loadEarlier, 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 []; + } +} From 5f01f3d52ff182cdc1a4a37267cf5ab4c9aa2a65 Mon Sep 17 00:00:00 2001 From: Kseniia Velychko Date: Wed, 10 Jun 2026 13:58:16 +0300 Subject: [PATCH 37/40] Use apiPost wrapper for live track point requests --- map/src/menu/tracks/liveTrack/LiveTrackContextMenu.jsx | 10 +++++++--- map/src/test/liveTrackSimulator.js | 7 ++++++- map/src/util/hooks/live/useLiveTracking.js | 7 +++++-- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/map/src/menu/tracks/liveTrack/LiveTrackContextMenu.jsx b/map/src/menu/tracks/liveTrack/LiveTrackContextMenu.jsx index 32c13e8857..e933631ca5 100644 --- a/map/src/menu/tracks/liveTrack/LiveTrackContextMenu.jsx +++ b/map/src/menu/tracks/liveTrack/LiveTrackContextMenu.jsx @@ -306,7 +306,11 @@ function LiveParticipantCard({ participant, defaultExpanded = true }) { additionalInfo={`${fmtSpeed(lastLoc?.speed)} ${speedUnit} · ${t('web:live_track_updated')} ${getTimeAgo(lastLoc?.time, t)}`} /> - } name={t('web:active_state')} additionalInfo={toHHMMSS(duration)} /> + } + name={t('web:active_state')} + additionalInfo={toHHMMSS(duration).split('.')[0]} + /> } @@ -383,7 +387,7 @@ function LiveParticipantCard({ participant, defaultExpanded = true }) { } name={t('web:live_track_eta')} - additionalInfo={toHHMMSS(timeToArrival)} + additionalInfo={toHHMMSS(timeToArrival).split('.')[0]} /> )} @@ -403,7 +407,7 @@ function LiveParticipantCard({ participant, defaultExpanded = true }) { } name={t('web:live_track_eta_intermediate')} - additionalInfo={toHHMMSS(timeToIntermediate)} + additionalInfo={toHHMMSS(timeToIntermediate).split('.')[0]} /> )} diff --git a/map/src/test/liveTrackSimulator.js b/map/src/test/liveTrackSimulator.js index 4b0770d56b..2f920e9ff3 100644 --- a/map/src/test/liveTrackSimulator.js +++ b/map/src/test/liveTrackSimulator.js @@ -43,6 +43,7 @@ import { encryptLocation, decryptLocation, } from '../util/livetracks/liveTrackCrypto'; +import { apiPost } from '../util/HttpApi'; function movePoint(lat, lon, distanceMeters, bearingDeg) { const R = 6371000; @@ -197,7 +198,11 @@ export function start(opts = {}) { }; encryptLocation(encKey, locationData) .then((encData) => { - fetch(`/mapapi/translation/msg?encryptedData=${encodeURIComponent(encData)}`).catch(() => {}); + apiPost( + '/mapapi/translation/msg', + `translationId=${encodeURIComponent(translationId)}&encryptedData=${encodeURIComponent(encData)}`, + { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } } + ).catch(() => {}); }) .catch(() => {}); diff --git a/map/src/util/hooks/live/useLiveTracking.js b/map/src/util/hooks/live/useLiveTracking.js index 43fe1faaae..54b781cd21 100644 --- a/map/src/util/hooks/live/useLiveTracking.js +++ b/map/src/util/hooks/live/useLiveTracking.js @@ -8,6 +8,7 @@ import { 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__'; @@ -100,8 +101,10 @@ export default function useLiveTracking(ctx, enabled = true) { if (!key) continue; encryptLocation(key, locationData) .then((encData) => { - fetch( - `/mapapi/translation/msg?translationId=${encodeURIComponent(tid)}&encryptedData=${encodeURIComponent(encData)}` + apiPost( + '/mapapi/translation/msg', + `translationId=${encodeURIComponent(tid)}&encryptedData=${encodeURIComponent(encData)}`, + { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } } ).catch(() => {}); }) .catch(() => {}); From 7f5920dbc1b9bc09f8cb1b3bd69eddcdf182bef2 Mon Sep 17 00:00:00 2001 From: Kseniia Velychko Date: Wed, 10 Jun 2026 17:05:35 +0300 Subject: [PATCH 38/40] Optimize live track updates for large histories --- map/src/map/layers/LiveTrackLayer.js | 47 ++++++-- .../tracks/liveTrack/LiveTrackContextMenu.jsx | 110 ++++++++++++++---- map/src/test/liveTrackSimulator.js | 89 +++++++++++++- map/src/util/hooks/live/useLiveTracking.js | 56 ++++++--- 4 files changed, 259 insertions(+), 43 deletions(-) diff --git a/map/src/map/layers/LiveTrackLayer.js b/map/src/map/layers/LiveTrackLayer.js index 843e929b70..8897c43a32 100644 --- a/map/src/map/layers/LiveTrackLayer.js +++ b/map/src/map/layers/LiveTrackLayer.js @@ -45,26 +45,48 @@ export default function LiveTrackLayer() { const { nickname, color, locations } = participant; if (!locations || locations.length === 0) return; - const latLngs = locations - .slice() - .reverse() - .map((l) => [l.lat, l.lon]); const lastLoc = locations[0]; + const newestTime = locations[0]?.time; + const oldestTime = locations[locations.length - 1]?.time; const existing = layersRef.current[selectedTid][nickname]; if (existing) { - existing.polyline.setLatLngs(latLngs); + 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(latLngs, { color, weight: 4, opacity: 0.85 }).addTo(map); + 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 }; + layersRef.current[selectedTid][nickname] = { + polyline, + marker, + len: locations.length, + newestTime, + oldestTime, + }; } }); }, [lttx.liveParticipants, lttx.selectedLiveTranslation]); @@ -106,6 +128,17 @@ export default function LiveTrackLayer() { 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 }) => { diff --git a/map/src/menu/tracks/liveTrack/LiveTrackContextMenu.jsx b/map/src/menu/tracks/liveTrack/LiveTrackContextMenu.jsx index e933631ca5..ff4b255b47 100644 --- a/map/src/menu/tracks/liveTrack/LiveTrackContextMenu.jsx +++ b/map/src/menu/tracks/liveTrack/LiveTrackContextMenu.jsx @@ -1,5 +1,5 @@ -import React, { useContext, useEffect, useMemo, useState } from 'react'; -import { Box, Collapse, Icon, IconButton, ListItemText, MenuItem, Tooltip } from '@mui/material'; +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'; @@ -44,9 +44,13 @@ 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, historyExhausted, requestShare } = lttx; + const { addLiveTrack, loadEarlier, loadingEarlier, historyExhausted, requestShare } = lttx; const ltx = useContext(LoginContext); const { t } = useTranslation(); @@ -111,9 +115,15 @@ export default function LiveTrackContextMenu() { id="se-live-track-load-earlier" className={trackFavStyles.sortIcon} onClick={() => loadEarlier(translation.id)} - disabled={!!historyExhausted?.[translation.id]} + disabled={ + !!historyExhausted?.[translation.id] || !!loadingEarlier?.[translation.id] + } > - + {loadingEarlier?.[translation.id] ? ( + + ) : ( + + )}
@@ -216,6 +226,9 @@ function LiveParticipantCard({ participant, defaultExpanded = true }) { 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); @@ -224,22 +237,16 @@ function LiveParticipantCard({ participant, defaultExpanded = true }) { const locs = participant.locations; - const { totalDistM, maxSpeedMS, zones, elevGainM, elevLossM } = useMemo(() => { - let totalDistM = 0; - let maxSpeedMS = 0; - for (let i = 0; i < locs.length - 1; i++) { - totalDistM += getDistance(locs[i].lat, locs[i].lon, locs[i + 1].lat, locs[i + 1].lon); - if ((locs[i].speed ?? 0) > maxSpeedMS) maxSpeedMS = locs[i].speed; - } - if (locs.length > 0 && (locs.at(-1).speed ?? 0) > maxSpeedMS) { - maxSpeedMS = locs.at(-1).speed; - } - const zones = computeZones(locs); - const elevGainM = zones.filter((z) => z.eleDiff > 0).reduce((s, z) => s + z.eleDiff, 0); - const elevLossM = zones.filter((z) => z.eleDiff < 0).reduce((s, z) => s + z.eleDiff, 0); + const { totalDistM, maxSpeedMS, zonesData } = useMemo( + () => computeParticipantStats(locs, statsCacheRef.current, zonesCacheRef.current), + [locs] + ); + const { zones, elevGainM, elevLossM } = zonesData; - return { totalDistM, maxSpeedMS, zones, elevGainM, elevLossM }; - }, [locs]); + useEffect(() => { + statsCacheRef.current = { locs, totalDistM, maxSpeedMS }; + zonesCacheRef.current = zonesData; + }, [locs, totalDistM, maxSpeedMS, zonesData]); const duration = Date.now() - participant.startTime; @@ -451,3 +458,66 @@ function getTimeAgo(timestamp, t) { 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/test/liveTrackSimulator.js b/map/src/test/liveTrackSimulator.js index 2f920e9ff3..1820e0d33c 100644 --- a/map/src/test/liveTrackSimulator.js +++ b/map/src/test/liveTrackSimulator.js @@ -9,6 +9,9 @@ * --- 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 @@ -34,6 +37,9 @@ * 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'; @@ -86,6 +92,8 @@ export function start(opts = {}) { 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'; @@ -156,9 +164,13 @@ export function start(opts = {}) { .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 }), + body: JSON.stringify({ translationId: tid, creationDate }), }); }) .catch((err) => console.error('❌ Key generation failed:', err)); @@ -216,6 +228,75 @@ export function start(opts = {}) { }, 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); @@ -255,7 +336,11 @@ export function start(opts = {}) { ` Speed: ~${options.speed} km/h (±30% variation) | Bearing: ${options.bearing}° (±20° wander) | Profile: ${options.eleProfile}${limitMsg}` ); - startInterval(tid); + if (options.backfillHours > 0) { + backfill().then(() => startInterval(tid)); + } else { + startInterval(tid); + } resolve({ translationId: tid, diff --git a/map/src/util/hooks/live/useLiveTracking.js b/map/src/util/hooks/live/useLiveTracking.js index 54b781cd21..edafda90ce 100644 --- a/map/src/util/hooks/live/useLiveTracking.js +++ b/map/src/util/hooks/live/useLiveTracking.js @@ -34,6 +34,9 @@ export default function useLiveTracking(ctx, enabled = true) { // 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: '{}' }); @@ -48,22 +51,36 @@ export default function useLiveTracking(ctx, enabled = true) { [ctx.setLiveTranslations] ); - // 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]; - setHistoryExhausted((prev) => { - if (!(id in prev)) return prev; + 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) => { @@ -260,21 +277,24 @@ export default function useLiveTracking(ctx, enabled = true) { ); // 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]) { + 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] + [historyExhausted, loadingEarlier] ); // Save a translation to the list (key needed to decrypt). If already saved, just @@ -517,7 +537,7 @@ export default function useLiveTracking(ctx, enabled = true) { const encMessages = history.filter((m) => m.type === 'LOCATION' && m.content?.encryptedData && m.sender); if (encMessages.length === 0) return; - Promise.all( + return Promise.all( encMessages.map((m) => decryptLocation(key, m.content.encryptedData).then((pt) => (pt ? { sender: m.sender, pt } : null)) ) @@ -575,7 +595,9 @@ export default function useLiveTracking(ctx, enabled = true) { pendingCreateRef.current = null; } else { handleMetadata(msg.data.id, msg.data); - processEncryptedHistory(msg.data.id, msg.data.history); + Promise.resolve(processEncryptedHistory(msg.data.id, msg.data.history)).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; @@ -637,6 +659,11 @@ export default function useLiveTracking(ctx, enabled = 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); }, }); @@ -686,6 +713,7 @@ export default function useLiveTracking(ctx, enabled = true) { stopSharing, regenerateLiveTrack, loadEarlier, + loadingEarlier, historyExhausted, requestShare, }; From 86995b81fe9694cf94a67aa39293434bf4b2e65a Mon Sep 17 00:00:00 2001 From: Kseniia Velychko Date: Wed, 10 Jun 2026 17:57:55 +0300 Subject: [PATCH 39/40] Handle chunked live track history load --- map/src/util/hooks/live/useLiveTracking.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/map/src/util/hooks/live/useLiveTracking.js b/map/src/util/hooks/live/useLiveTracking.js index edafda90ce..09cccedd95 100644 --- a/map/src/util/hooks/live/useLiveTracking.js +++ b/map/src/util/hooks/live/useLiveTracking.js @@ -595,9 +595,11 @@ export default function useLiveTracking(ctx, enabled = true) { pendingCreateRef.current = null; } else { handleMetadata(msg.data.id, msg.data); - Promise.resolve(processEncryptedHistory(msg.data.id, msg.data.history)).then(() => - finishLoadingEarlier(msg.data.id) - ); + // 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; From 8d6baa7775af8cc325fbed11b93353bc90f94284 Mon Sep 17 00:00:00 2001 From: Kseniia Velychko Date: Thu, 11 Jun 2026 07:38:54 +0300 Subject: [PATCH 40/40] Remove permanent live track duration, cap at 24h --- map/src/menu/tracks/liveTrack/CreateLiveTrackDialog.jsx | 9 ++++----- map/src/resources/translations/en/web-translation.json | 1 - 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/map/src/menu/tracks/liveTrack/CreateLiveTrackDialog.jsx b/map/src/menu/tracks/liveTrack/CreateLiveTrackDialog.jsx index ce933ff573..b3c2d2012f 100644 --- a/map/src/menu/tracks/liveTrack/CreateLiveTrackDialog.jsx +++ b/map/src/menu/tracks/liveTrack/CreateLiveTrackDialog.jsx @@ -29,10 +29,9 @@ import LiveTrackingContext from '../../../context/LiveTrackingContext'; import dialogStyles from '../../../dialogs/dialog.module.css'; import styles from '../../trackfavmenu.module.css'; -const DURATION_MARKS = [{ value: 0 }, { value: 1 }, { value: 4 }, { value: 8 }, { value: 24 }]; +const DURATION_MARKS = [{ value: 1 }, { value: 4 }, { value: 8 }, { value: 24 }]; function durationLabel(value, t) { - if (value === 0) return t('web:live_track_duration_permanent'); 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'); @@ -46,7 +45,7 @@ export default function CreateLiveTrackDialog({ open, onClose }) { const navigate = useNavigate(); const [name, setName] = useState(''); - const [duration, setDuration] = useState(0); + const [duration, setDuration] = useState(1); const [shareUrl, setShareUrl] = useState(null); const [creating, setCreating] = useState(false); const [copied, setCopied] = useState(false); @@ -125,7 +124,7 @@ export default function CreateLiveTrackDialog({ open, onClose }) { function handleClose() { setName(''); - setDuration(0); + setDuration(1); setShareUrl(null); setCreating(false); setCopied(false); @@ -185,7 +184,7 @@ export default function CreateLiveTrackDialog({ open, onClose }) { setDuration(v)} - min={0} + min={1} max={24} step={null} marks={DURATION_MARKS} diff --git a/map/src/resources/translations/en/web-translation.json b/map/src/resources/translations/en/web-translation.json index 18e59cf888..719f6250ce 100644 --- a/map/src/resources/translations/en/web-translation.json +++ b/map/src/resources/translations/en/web-translation.json @@ -437,7 +437,6 @@ "live_track_minutes_ago": "{{value}}m ago", "live_track_hours_ago": "{{value}}h ago", "live_track_create": "Create Live Track", - "live_track_duration_permanent": "Permanent", "live_track_duration_1h": "1 hour", "live_track_duration_4h": "4 hours", "live_track_duration_8h": "8 hours",