index.js (15796B)
1 import md5 from 'blueimp-md5'; 2 import ko from 'knockout'; 3 import L from 'leaflet'; 4 5 import {makeButton} from '~/lib/leaflet.control.commons'; 6 import {parseNktkSequence} from '~/lib/leaflet.control.track-list/lib/parsers/nktk'; 7 import {bindHashStateReadOnly} from '~/lib/leaflet.hashState/hashState'; 8 import {notify} from '~/lib/notifications'; 9 import { 10 activeSessionsMonitor, 11 EVENT_ACTIVE_SESSIONS_CHANGED, 12 EVENT_STORED_SESSIONS_CHANGED, 13 session, 14 sessionRepository, 15 } from '~/lib/session-state'; 16 17 import './style.css'; 18 19 function formatDateTime(ts) { 20 const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; 21 const date = new Date(ts); 22 23 const month = months[date.getMonth()]; 24 const hours = date.getHours().toString().padStart(2, '0'); 25 const minutes = date.getMinutes().toString().padStart(2, '0'); 26 const seconds = date.getSeconds().toString().padStart(2, '0'); 27 return `${month} ${date.getDate()} ${date.getFullYear()} ${hours}:${minutes}:${seconds}`; 28 } 29 30 function temporaryDisableAfterInvocation(f, delay) { 31 let lastCalledAt = null; 32 33 function wrapper(...args) { 34 if (lastCalledAt === null || lastCalledAt + delay < Date.now()) { 35 try { 36 f(...args); 37 } finally { 38 lastCalledAt = Date.now(); 39 } 40 } 41 } 42 43 return wrapper; 44 } 45 46 const SessionsControl = L.Control.extend({ 47 includes: L.Mixin.Events, 48 49 initialize: function (trackListControl, options) { 50 L.Control.prototype.initialize.call(this, options); 51 this.trackListControl = trackListControl; 52 this.sessionListWindowVisible = false; 53 this.loadingState = false; 54 this.channel = new BroadcastChannel('session-control'); 55 this.channel.addEventListener('message', (e) => this.onChannelMessage(e)); 56 this.saveCurrentState = L.Util.throttle(this.saveCurrentStateImmediate, 1000, this); 57 this.trackListControl.on('trackschanged', () => this.onCurrentStateChange()); 58 window.addEventListener('hashchange', () => this.onCurrentStateChange()); 59 window.addEventListener('unload', () => this.saveCurrentStateImmediate()); 60 window.addEventListener('pagehide', () => this.saveCurrentStateImmediate()); 61 this.canSwitchFocus = !L.Browser.mobile; 62 63 this.sessionListWindowModel = { 64 activeSessions: ko.observableArray([]), 65 inactiveSessions: ko.observableArray([]), 66 visible: ko.observable(false), 67 formatDateTime, 68 maxTrackLines: 4, 69 requestSwitchFocus: (sessionData) => this.requestSwitchFocus(sessionData.sessionId), 70 openStoredSession: temporaryDisableAfterInvocation( 71 (sessionData) => this.openStoredSession(sessionData.sessionId), 72 200 73 ), 74 closeWindow: () => this.hideSessionListWindow(), 75 canSwitchFocus: this.canSwitchFocus, 76 }; 77 }, 78 79 setupSessionListWindow: function () { 80 const layout = ` 81 <div data-bind="visible: visible" class="leaflet-control-session-list-wrapper"> 82 <div class="leaflet-control-session-list-window"> 83 <div class="leaflet-control-session-list-window-header"> 84 <div class="button-close" data-bind="click: closeWindow"></div> 85 </div> 86 <div class="leaflet-control-session-list-scrollbox"> 87 <!-- ko if: activeSessions().length --> 88 <div class="leaflet-control-session-list-header"> 89 Active sessions with tracks in other tabs 90 </div> 91 <!-- ko if: canSwitchFocus --> 92 <div class="leaflet-control-session-list-header-info">Click to switch tab</div> 93 <!-- /ko --> 94 <!-- /ko --> 95 <!-- ko foreach: activeSessions --> 96 <div 97 class="leaflet-control-session-list-item-active" 98 data-bind=" 99 attr: {title: data.trackNames.join('\\n')}, 100 click: $root.requestSwitchFocus, 101 css: {'click-disabled': !$root.canSwitchFocus}"> 102 <!-- ko foreach: data.trackNames.length <= $root.maxTrackLines 103 ? data.trackNames 104 : data.trackNames.slice(0, $root.maxTrackLines - 1) 105 --> 106 <div class="leaflet-control-session-list-item-track" data-bind="text: $data"></div> 107 <!-- /ko --> 108 <!-- ko if: data.trackNames.length > $root.maxTrackLines --> 109 <div> 110 …total <span data-bind="text: data.trackNames.length"></span> 111 tracks… 112 </div> 113 <!-- /ko --> 114 </div> 115 <!-- /ko --> 116 117 <!-- ko if: inactiveSessions().length --> 118 <div class="leaflet-control-session-list-header">Recently opened sessions with tracks</div> 119 <div class="leaflet-control-session-list-header-info">Click to open in new tab</div> 120 <!-- /ko --> 121 122 <!-- ko foreach: inactiveSessions --> 123 <div 124 class="leaflet-control-session-list-item-inactive" 125 data-bind="attr: {title: data.trackNames.join('\\n')}, click: $root.openStoredSession" 126 > 127 <div class="leaflet-control-session-list-item-date"> 128 Last used at <span data-bind="text: $root.formatDateTime($data.mtime)"></span> 129 </div> 130 <!-- ko foreach: data.trackNames.length <= $root.maxTrackLines 131 ? data.trackNames 132 : data.trackNames.slice(0, $root.maxTrackLines - 1) 133 --> 134 <div class="leaflet-control-session-list-item-track" data-bind="text: $data"></div> 135 <!-- /ko --> 136 <!-- ko if: data.trackNames.length > $root.maxTrackLines --> 137 <div> 138 …total <span data-bind="text: data.trackNames.length"></span> 139 tracks… 140 </div> 141 <!-- /ko --> 142 143 </div> 144 <!-- /ko --> 145 <!-- ko if: !activeSessions().length && !inactiveSessions().length --> 146 <div class="leaflet-control-session-list-header">No recent sessions with tracks</div> 147 <!-- /ko --> 148 </div> 149 </div> 150 </div> 151 `; 152 153 const container = L.DomUtil.create('div'); 154 container.innerHTML = layout; 155 const sessionListWindow = container.querySelector('.leaflet-control-session-list-window'); 156 L.DomEvent.disableClickPropagation(sessionListWindow); 157 L.DomEvent.disableScrollPropagation(sessionListWindow); 158 ko.applyBindings(this.sessionListWindowModel, container); 159 this._map._controlContainer.appendChild(container); 160 }, 161 162 onAdd: function () { 163 let container; 164 if (this.options.noButton) { 165 container = L.DomUtil.create('div'); 166 container.style.margin = '0'; 167 } else { 168 const {container: buttonContainer, link} = makeButton(null, 'Recent sessions'); 169 L.DomEvent.on(link, 'click', () => this.toggleSessionListsVisible()); 170 container = buttonContainer; 171 } 172 this.setupSessionListWindow(); 173 return container; 174 }, 175 176 onChannelMessage: function (e) { 177 const messageData = e.data; 178 if (messageData.message === 'focus' && messageData.sessionId === session.sessionId) { 179 this.switchFocus(); 180 } 181 }, 182 183 onActiveSessionsChange: function () { 184 this.updateSessionLists(); 185 }, 186 187 onStoredSessionsChange: function () { 188 this.updateSessionLists(); 189 }, 190 191 onCurrentStateChange: function () { 192 if (!this.loadingState) { 193 this.saveCurrentState(); 194 } 195 }, 196 197 toggleSessionListsVisible: function () { 198 if (this.sessionListWindowVisible) { 199 this.hideSessionListWindow(); 200 } else { 201 this.showSessionListWindow(); 202 } 203 }, 204 205 showSessionListWindow: function () { 206 if (this.sessionListWindowVisible) { 207 return; 208 } 209 this.sessionListWindowVisible = true; 210 this.updateSessionLists(); 211 this.sessionListWindowModel.visible(true); 212 this.setupEventsForSessionListWindow(true); 213 activeSessionsMonitor.startMonitor(); 214 }, 215 216 hideSessionListWindow: function () { 217 if (!this.sessionListWindowVisible) { 218 return; 219 } 220 this.sessionListWindowVisible = false; 221 this.sessionListWindowModel.visible(false); 222 activeSessionsMonitor.stopMonitor(); 223 this.setupEventsForSessionListWindow(false); 224 }, 225 226 setupEventsForSessionListWindow: function (on) { 227 L.DomEvent[on ? 'on' : 'off']( 228 window, 229 { 230 keydown: this.onKeyDown, 231 [EVENT_ACTIVE_SESSIONS_CHANGED]: this.onActiveSessionsChange, 232 [EVENT_STORED_SESSIONS_CHANGED]: this.onStoredSessionsChange, 233 }, 234 this 235 ); 236 }, 237 238 onKeyDown: function (e) { 239 if (e.keyCode === 27) { 240 this.hideSessionListWindow(); 241 } 242 }, 243 244 requestSwitchFocus: async function (sessionId) { 245 if (!this.canSwitchFocus) { 246 return; 247 } 248 if (!window.Notification) { 249 notify('Can not switch to another window, your browser does not support notifications.'); 250 return; 251 } 252 if (window.Notification.permission !== 'granted') { 253 notify('Please allow notifications to be able to switch to other sessions.'); 254 await Notification.requestPermission(); 255 } 256 this.channel.postMessage({message: 'focus', sessionId}); 257 }, 258 259 openStoredSession: async function (sessionId) { 260 // Opening window before await-ing for promise helps to avoid new window being blocked in Firefox 261 const newWindow = window.open('', '_blank'); 262 const sessionData = await sessionRepository.getSessionState(sessionId); 263 if (!sessionData) { 264 newWindow.close(); 265 notify("Saved session was lost. Maybe it's a bug."); 266 return; 267 } 268 const {origin, pathname} = window.location; // eslint-disable-line no-shadow 269 newWindow.location = `${origin}${pathname}${sessionData.hash}&sid=${sessionId}`; 270 newWindow.focus(); 271 }, 272 273 switchFocus: function () { 274 const notification = new Notification('Switch nakarte.me window', { 275 body: 'Click here to switch nakarte.me window.', 276 }); 277 notification.addEventListener('click', () => { 278 parent.focus(); 279 window.focus(); 280 notification.close(); 281 }); 282 }, 283 284 saveCurrentStateImmediate: function () { 285 const tracks = this.trackListControl.tracks(); 286 if (!tracks.length) { 287 session.clearState(); 288 return; 289 } 290 const {hash} = window.location; 291 const trackNames = tracks.map((track) => track.name()); 292 const tracksSerialized = this.trackListControl.serializeTracks(tracks); 293 session.saveState({hash, tracks: tracksSerialized, trackNames}); 294 }, 295 296 loadSession: async function () { 297 const sessionSavedTracks = (await session.loadState())?.tracks; 298 if (sessionSavedTracks) { 299 this.loadingState = true; 300 try { 301 this.trackListControl.loadTracksFromString(sessionSavedTracks, true); 302 } finally { 303 this.loadingState = false; 304 } 305 // if session state is not found, it can be caused by 306 // 1. tab was restored using Ctrl-Shift-T but session was pruned (very unlikely) 307 // 2. session was opened using session list control in another tab 308 // 3. there were no tracks in this session (most likely). 309 // If we want to show notification for case 2. then we will need to store sessions without 310 // tracks as well. 311 } 312 this.saveCurrentStateImmediate(); 313 }, 314 315 consumeSessionFromHash: function () { 316 bindHashStateReadOnly( 317 'sid', 318 async (params) => { 319 if (!params) { 320 return; 321 } 322 const sessionId = params[0]; 323 const sessionState = await sessionRepository.getSessionState(sessionId); 324 if (!sessionState) { 325 notify("Saved session was lost. Maybe it's a bug."); 326 return; 327 } 328 this.loadingState = true; 329 try { 330 this.trackListControl.loadTracksFromString(sessionState.tracks, true); 331 } finally { 332 this.loadingState = false; 333 } 334 this.saveCurrentStateImmediate(); 335 await sessionRepository.clearSessionState(sessionId); 336 }, 337 true 338 ); 339 }, 340 341 importOldSessions: async function () { 342 const oldDataPrefix = '#nktk='; 343 let imported = false; 344 for (const [key, value] of Object.entries(window.localStorage)) { 345 const m = key.match(/^trackList_\d+$/u); 346 if (m && value.startsWith(oldDataPrefix)) { 347 const tracksSerialized = value.slice(oldDataPrefix.length); 348 const geodata = parseNktkSequence(tracksSerialized); 349 const trackNames = geodata.map((track) => track.name); 350 const sessionId = 'imported_' + md5(tracksSerialized); 351 await sessionRepository.setSessionState(sessionId, {hash: '#', tracks: tracksSerialized, trackNames}); 352 delete window.localStorage[key]; 353 imported = true; 354 } 355 } 356 return imported; 357 }, 358 359 updateSessionLists: async function () { 360 const storedSessions = (await sessionRepository.listSessionStates()).filter( 361 (sess) => sess.sessionId !== session.sessionId 362 ); 363 const activeSessionIds = activeSessionsMonitor.getActiveSessions(); 364 const activeSessions = storedSessions.filter((sess) => activeSessionIds.includes(sess.sessionId)); 365 const inactiveSessions = storedSessions.filter((sess) => !activeSessionIds.includes(sess.sessionId)); 366 inactiveSessions.sort((sess1, sess2) => sess2.mtime - sess1.mtime); 367 // no need to sort active sessions, IndexedDb returns them sorted by sessionId which is fine. 368 this.sessionListWindowModel.activeSessions(activeSessions); 369 this.sessionListWindowModel.inactiveSessions(inactiveSessions); 370 }, 371 }); 372 export {SessionsControl};