north-star / mobile / files / src / app.ts
Branch mobile · default branch · all branches
app.ts
cf7471ca 2026-03-16 Pavel Safronov | 1 | import { get, ref, remove, set, serverTimestamp } from 'firebase/database'; |
f32f5e95 2026-03-16 Pavel Safronov | 2 | import { getReadableTextColor, getArrowDisplayColor } from './core/utils'; |
| 3 | import { encryptJson } from './core/crypto'; | |
| 4 | import { initDarkMode } from './platform/darkmode'; | |
| 5 | import { initWakeLockController } from './features/ui'; | |
| 6 | import { initFirebaseSession } from './platform/firebase'; | |
| 7 | import { initSession, initSelfProfile, bumpQrAutoCollapseCountdown } from './features/session'; | |
764d68de 2026-03-16 Pavel Safronov | 8 | import { buildRoomHash, parseRoomFromHash } from './core/room'; |
f32f5e95 2026-03-16 Pavel Safronov | 9 | import { subscribeToFriends, createFriendsPanelController, renderArrows } from './features/friends'; |
| 10 | import { initCompassController } from './features/compass'; | |
32796e53 2026-03-16 Pavel Safronov | 11 | import { PUBLIC_SETTINGS } from './public_settings'; |
dcd59aa1 2026-03-14 Pavel Safronov | 12 | |
1e87ff1c 2026-03-16 Pavel Safronov | 13 | const debugCommit = document.getElementById('debug-commit'); |
dcd59aa1 2026-03-14 Pavel Safronov | 14 | const nameForm = document.getElementById('name-form'); |
b4e7f682 2026-03-16 Pavel Safronov | 15 | const selfNameInput = document.getElementById('self-name-input') as HTMLInputElement | null; |
| 16 | const selfColorInput = document.getElementById('self-color-input') as HTMLInputElement | null; | |
b7d4b972 2026-03-14 Pavel Safronov | 17 | const roomCurrent = document.getElementById('room-current'); |
6cb482fe 2026-03-15 Pavel Safronov | 18 | const inviteRoomQrList = document.getElementById('invite-room-qr-list'); |
30e810b3 2026-03-15 Pavel Safronov | 19 | const calibrateHeadingLink = document.getElementById('calibrate-heading-link'); |
d9978ceb 2026-03-15 Pavel Safronov | 20 | const darkModeToggle = document.getElementById('dark-mode-toggle'); |
f495a654 2026-03-16 Pavel Safronov | 21 | const wakeLockToggle = document.getElementById('wake-lock-toggle'); |
65034d4e 2026-03-16 Pavel Safronov | 22 | const cleanModeToggle = document.getElementById('clean-mode-toggle'); |
b4e7f682 2026-03-16 Pavel Safronov | 23 | const createRoomLink = document.getElementById('create-room-link') as HTMLAnchorElement | null; |
07aa3903 2026-03-16 Pavel Safronov | 24 | const roomLinks = document.getElementById('room-links'); |
e790982a 2026-03-16 Pavel Safronov | 25 | const friendsPanel = document.getElementById('friends-panel'); |
| 26 | const friendsToggle = document.getElementById('friends-toggle'); | |
36d64b37 2026-03-15 Pavel Safronov | 27 | const content = document.getElementById('content'); |
| 28 | const arrow = document.getElementById('default-arrow'); | |
07aa3903 2026-03-16 Pavel Safronov | 29 | const startRoomButton = document.getElementById('start-room-button'); |
| 30 | const resumeRoomButton = document.getElementById('resume-room-button'); | |
36d64b37 2026-03-15 Pavel Safronov | 31 | |
0b65bf68 2026-03-16 Pavel Safronov | 32 | const LOCATION_CHANGE_THRESHOLD_METERS = PUBLIC_SETTINGS.location.changeThresholdMeters; |
e4870993 2026-03-16 Pavel Safronov | 33 | const FRIEND_RECENTLY_OFFLINE_MS = (() => { |
0b65bf68 2026-03-16 Pavel Safronov | 34 | const raw = Number(localStorage.getItem('friend-compass-recently-offline-ms')); |
9fda5271 2026-03-16 Pavel Safronov | 35 | if (Number.isFinite(raw) && raw > PUBLIC_SETTINGS.presence.onlineGraceMs) { |
e4870993 2026-03-16 Pavel Safronov | 36 | return raw; |
| 37 | } | |
9fda5271 2026-03-16 Pavel Safronov | 38 | return PUBLIC_SETTINGS.presence.defaultRecentlyOfflineMs; |
e4870993 2026-03-16 Pavel Safronov | 39 | })(); |
07aa3903 2026-03-16 Pavel Safronov | 40 | const LAST_ROOM_HASH_STORAGE_KEY = 'friend-compass-last-room-hash'; |
36d64b37 2026-03-15 Pavel Safronov | 41 | |
30e810b3 2026-03-15 Pavel Safronov | 42 | let headingCorrectionDeg = (() => { |
0b65bf68 2026-03-16 Pavel Safronov | 43 | const parsed = Number(localStorage.getItem('friend-compass-heading-correction-deg')); |
30e810b3 2026-03-15 Pavel Safronov | 44 | return Number.isFinite(parsed) ? parsed : 0; |
| 45 | })(); | |
36d64b37 2026-03-15 Pavel Safronov | 46 | |
12fa94c5 2026-03-15 Pavel Safronov | 47 | let isLocalCommit = true; |
f7854852 2026-03-15 Pavel Safronov | 48 | let GIT_COMMIT = 'local-dev'; |
d9978ceb 2026-03-15 Pavel Safronov | 49 | |
07aa3903 2026-03-16 Pavel Safronov | 50 | let db: any = null; |
| 51 | let roomId = ''; | |
| 52 | let roomKey: CryptoKey | null = null; | |
| 53 | let friendRef: ReturnType<typeof ref> | null = null; | |
| 54 | let friends: any[] = []; | |
| 55 | let sessionStarted = false; | |
| 56 | let startInProgress = false; | |
1e87ff1c 2026-03-16 Pavel Safronov | 57 | let previousRemoteFriendCount = 0; |
764d68de 2026-03-16 Pavel Safronov | 58 | let activeSessionRoomHash: string | null = null; |
07aa3903 2026-03-16 Pavel Safronov | 59 | |
36d64b37 2026-03-15 Pavel Safronov | 60 | initDarkMode(darkModeToggle); |
f495a654 2026-03-16 Pavel Safronov | 61 | const wakeLockController = initWakeLockController({ |
| 62 | wakeLockToggle, | |
| 63 | isSessionStarted: () => sessionStarted, | |
| 64 | isFullscreen: () => document.body.classList.contains('clean-mode') | |
| 65 | }); | |
0a0cb749 2026-03-15 Pavel Safronov | 66 | |
65034d4e 2026-03-16 Pavel Safronov | 67 | const setCleanMode = (enabled: boolean) => { |
| 68 | document.body.classList.toggle('clean-mode', enabled); | |
| 69 | if (cleanModeToggle instanceof HTMLButtonElement) { | |
cf7471ca 2026-03-16 Pavel Safronov | 70 | cleanModeToggle.textContent = enabled ? 'Back' : 'Fullscreen'; |
65034d4e 2026-03-16 Pavel Safronov | 71 | cleanModeToggle.setAttribute('aria-pressed', String(enabled)); |
cf7471ca 2026-03-16 Pavel Safronov | 72 | cleanModeToggle.setAttribute('aria-label', enabled ? 'Back to full mode' : 'Enter fullscreen mode'); |
65034d4e 2026-03-16 Pavel Safronov | 73 | } |
f495a654 2026-03-16 Pavel Safronov | 74 | |
| 75 | wakeLockController.syncWakeLockPreference({ announce: true, source: 'fullscreen' }); | |
65034d4e 2026-03-16 Pavel Safronov | 76 | }; |
| 77 | ||
| 78 | if (cleanModeToggle instanceof HTMLButtonElement) { | |
| 79 | cleanModeToggle.addEventListener('click', () => { | |
| 80 | setCleanMode(!document.body.classList.contains('clean-mode')); | |
| 81 | }); | |
| 82 | } | |
| 83 | ||
f7854852 2026-03-15 Pavel Safronov | 84 | try { |
5be7df9b 2026-03-16 Pavel Safronov | 85 | const commitModule = await import('./commit.js'); |
07aa3903 2026-03-16 Pavel Safronov | 86 | GIT_COMMIT = commitModule.GIT_COMMIT; |
| 87 | isLocalCommit = commitModule.IS_LOCAL_BUILD; | |
f7854852 2026-03-15 Pavel Safronov | 88 | } catch { |
07aa3903 2026-03-16 Pavel Safronov | 89 | // Should not normally fail — commit.ts always exists as a dev fallback. |
| 90 | } | |
| 91 | ||
| 92 | if (createRoomLink instanceof HTMLAnchorElement) createRoomLink.style.display = 'none'; | |
| 93 | if (roomLinks) roomLinks.classList.add('is-hidden'); | |
| 94 | if (friendsPanel) friendsPanel.classList.add('is-hidden'); | |
| 95 | if (friendsToggle instanceof HTMLButtonElement) { | |
| 96 | friendsToggle.disabled = true; | |
| 97 | friendsToggle.title = 'Start a session first to show friends.'; | |
f7854852 2026-03-15 Pavel Safronov | 98 | } |
07aa3903 2026-03-16 Pavel Safronov | 99 | if (inviteRoomQrList) inviteRoomQrList.textContent = ''; |
a425df77 2026-03-14 Pavel Safronov | 100 | |
0adcad67 2026-03-14 Pavel Safronov | 101 | const selfPosition = { |
| 102 | lat: 47.62055039884746, | |
| 103 | lon: -122.34928833200904 | |
| 104 | }; | |
05c8dab2 2026-03-15 Pavel Safronov | 105 | |
84bc5868 2026-03-15 Pavel Safronov | 106 | const BASE_ROOM_URL = `${window.location.origin}${window.location.pathname}`; |
c18940ae 2026-03-16 Pavel Safronov | 107 | const CREATE_ROOM_URL = `${BASE_ROOM_URL}?new`; |
764d68de 2026-03-16 Pavel Safronov | 108 | |
| 109 | const shouldAutoCreateRoomFromUrl = () => { | |
| 110 | const raw = new URLSearchParams(location.search).get('new'); | |
| 111 | if (raw === null) return false; | |
c18940ae 2026-03-16 Pavel Safronov | 112 | return true; |
| 113 | }; | |
| 114 | ||
| 115 | const removeAutoCreateQueryParamFromUrl = () => { | |
| 116 | const url = new URL(window.location.href); | |
| 117 | if (!url.searchParams.has('new')) return; | |
| 118 | ||
| 119 | url.searchParams.delete('new'); | |
| 120 | const nextSearch = url.searchParams.toString(); | |
| 121 | const nextUrl = `${BASE_ROOM_URL}${nextSearch ? `?${nextSearch}` : ''}${window.location.hash}`; | |
| 122 | window.history.replaceState(null, '', nextUrl); | |
764d68de 2026-03-16 Pavel Safronov | 123 | }; |
faeb8ad8 2026-03-15 Pavel Safronov | 124 | |
07aa3903 2026-03-16 Pavel Safronov | 125 | const appTitleLink = document.getElementById('app-title-link'); |
| 126 | if (appTitleLink instanceof HTMLAnchorElement) { | |
| 127 | appTitleLink.href = isLocalCommit ? BASE_ROOM_URL : PUBLIC_SETTINGS.links.gitlabPagesBaseUrl; | |
| 128 | } | |
1e87ff1c 2026-03-16 Pavel Safronov | 129 | if (debugCommit instanceof HTMLAnchorElement) { |
| 130 | if (isLocalCommit) { | |
| 131 | debugCommit.textContent = 'Local Build'; | |
| 132 | debugCommit.href = '#'; | |
| 133 | debugCommit.addEventListener('click', (event) => { | |
| 134 | event.preventDefault(); | |
| 135 | alert(`This is a local development build, you have no reason to click this.`); | |
| 136 | }); | |
| 137 | } else { | |
| 138 | debugCommit.textContent = GIT_COMMIT; | |
| 139 | debugCommit.href = `https://gitlab.com/PavelSafronov/north-star/-/commit/${GIT_COMMIT}`; | |
| 140 | debugCommit.target = '_blank'; | |
| 141 | debugCommit.rel = 'noopener noreferrer'; | |
| 142 | } | |
| 143 | } | |
dcd59aa1 2026-03-14 Pavel Safronov | 144 | |
e790982a 2026-03-16 Pavel Safronov | 145 | // selfFriend is assigned below by initSelfProfile; syncSelfFriend is |
36d64b37 2026-03-15 Pavel Safronov | 146 | // only ever called after that point (from event handlers / intervals). |
b4e7f682 2026-03-16 Pavel Safronov | 147 | let selfFriend: any; |
9fda5271 2026-03-16 Pavel Safronov | 148 | let lastSyncedPosition: { lat: number; lon: number } | null = null; |
07aa3903 2026-03-16 Pavel Safronov | 149 | let lastLocationSyncAt = 0; |
| 150 | let geolocationWatchId: number | null = null; | |
| 151 | let renderIntervalId: number | null = null; | |
36d64b37 2026-03-15 Pavel Safronov | 152 | |
e790982a 2026-03-16 Pavel Safronov | 153 | const syncSelfFriend = async () => { |
07aa3903 2026-03-16 Pavel Safronov | 154 | if (!friendRef) return; |
| 155 | ||
55ab7a53 2026-03-16 Pavel Safronov | 156 | const now = Date.now(); |
| 157 | selfFriend.timestamp = now; | |
36d64b37 2026-03-15 Pavel Safronov | 158 | const dbRecord = roomKey |
cf7471ca 2026-03-16 Pavel Safronov | 159 | ? { encryptedState: await encryptJson(roomKey, selfFriend), lastSeen: serverTimestamp() } |
| 160 | : { ...selfFriend, lastSeen: serverTimestamp() }; | |
dcd59aa1 2026-03-14 Pavel Safronov | 161 | try { |
e790982a 2026-03-16 Pavel Safronov | 162 | await set(friendRef, dbRecord); |
9fda5271 2026-03-16 Pavel Safronov | 163 | lastLocationSyncAt = now; |
| 164 | lastSyncedPosition = { ...selfPosition }; | |
dcd59aa1 2026-03-14 Pavel Safronov | 165 | } catch (err) { |
e790982a 2026-03-16 Pavel Safronov | 166 | console.error('Error syncing friend in database', err); |
dcd59aa1 2026-03-14 Pavel Safronov | 167 | } |
e263c431 2026-03-15 Pavel Safronov | 168 | }; |
| 169 | ||
55ab7a53 2026-03-16 Pavel Safronov | 170 | const cleanupStaleFriends = async () => { |
07aa3903 2026-03-16 Pavel Safronov | 171 | if (!db || !roomId) return; |
| 172 | ||
55ab7a53 2026-03-16 Pavel Safronov | 173 | try { |
| 174 | const roomFriendsRef = ref(db, `rooms/${roomId}/friends`); | |
| 175 | const snapshot = await get(roomFriendsRef); | |
| 176 | if (!snapshot.exists()) return; | |
| 177 | const now = Date.now(); | |
| 178 | const deletions: Promise<void>[] = []; | |
| 179 | snapshot.forEach((child) => { | |
| 180 | const val = child.val(); | |
| 181 | const lastSeen = Number(val?.lastSeen); | |
9fda5271 2026-03-16 Pavel Safronov | 182 | if (Number.isFinite(lastSeen) && now - lastSeen > PUBLIC_SETTINGS.cleanup.staleFriendTtlMs) { |
55ab7a53 2026-03-16 Pavel Safronov | 183 | deletions.push(remove(child.ref)); |
| 184 | } | |
| 185 | }); | |
| 186 | if (deletions.length > 0) { | |
| 187 | console.debug(`Removing ${deletions.length} stale friend record(s)`); | |
| 188 | await Promise.all(deletions); | |
| 189 | } | |
| 190 | } catch (err) { | |
| 191 | console.error('Error cleaning up stale friends', err); | |
| 192 | } | |
| 193 | }; | |
| 194 | ||
9fda5271 2026-03-16 Pavel Safronov | 195 | const distanceMetersBetween = (a: { lat: number; lon: number }, b: { lat: number; lon: number }) => { |
| 196 | const earthRadiusM = 6371000; | |
| 197 | const toRad = (deg: number) => deg * (Math.PI / 180); | |
| 198 | const dLat = toRad(a.lat - b.lat); | |
| 199 | const dLon = toRad(a.lon - b.lon); | |
| 200 | const lat1 = toRad(a.lat); | |
| 201 | const lat2 = toRad(b.lat); | |
| 202 | ||
| 203 | const sinDLat = Math.sin(dLat / 2); | |
| 204 | const sinDLon = Math.sin(dLon / 2); | |
| 205 | const hav = sinDLat * sinDLat + Math.cos(lat1) * Math.cos(lat2) * sinDLon * sinDLon; | |
| 206 | const c = 2 * Math.atan2(Math.sqrt(hav), Math.sqrt(1 - hav)); | |
| 207 | return earthRadiusM * c; | |
| 208 | }; | |
| 209 | ||
| 210 | const hasMeaningfulLocationChange = () => { | |
| 211 | if (!lastSyncedPosition) return true; | |
| 212 | const deltaMeters = distanceMetersBetween(selfPosition, lastSyncedPosition); | |
| 213 | return deltaMeters > LOCATION_CHANGE_THRESHOLD_METERS; | |
| 214 | }; | |
| 215 | ||
4661a9b6 2026-03-14 Pavel Safronov | 216 | const maybeSyncLocation = () => { |
07aa3903 2026-03-16 Pavel Safronov | 217 | if (!sessionStarted) return; |
| 218 | ||
4661a9b6 2026-03-14 Pavel Safronov | 219 | const now = Date.now(); |
9fda5271 2026-03-16 Pavel Safronov | 220 | const locationChanged = hasMeaningfulLocationChange(); |
| 221 | if (locationChanged && now - lastLocationSyncAt < PUBLIC_SETTINGS.location.movingSyncIntervalMs) return; | |
| 222 | if (!locationChanged && now - lastLocationSyncAt < PUBLIC_SETTINGS.location.stationarySyncIntervalMs) return; | |
e790982a 2026-03-16 Pavel Safronov | 223 | void syncSelfFriend(); |
4661a9b6 2026-03-14 Pavel Safronov | 224 | }; |
| 225 | ||
36d64b37 2026-03-15 Pavel Safronov | 226 | let renderFrameRequested = false; |
| 227 | const requestRender = () => { | |
| 228 | if (renderFrameRequested) return; | |
| 229 | renderFrameRequested = true; | |
| 230 | requestAnimationFrame(() => { | |
| 231 | renderFrameRequested = false; | |
| 232 | render(); | |
| 233 | }); | |
dcd59aa1 2026-03-14 Pavel Safronov | 234 | }; |
| 235 | ||
e790982a 2026-03-16 Pavel Safronov | 236 | ({ selfFriend } = await initSelfProfile({ |
36d64b37 2026-03-15 Pavel Safronov | 237 | nameForm, |
| 238 | selfNameInput, | |
| 239 | selfColorInput, | |
07aa3903 2026-03-16 Pavel Safronov | 240 | uid: 'pending-user', |
36d64b37 2026-03-15 Pavel Safronov | 241 | selfPosition, |
e790982a 2026-03-16 Pavel Safronov | 242 | syncSelf: syncSelfFriend, |
36d64b37 2026-03-15 Pavel Safronov | 243 | requestRender |
| 244 | })); | |
| 245 | ||
| 246 | const compassController = initCompassController({ | |
| 247 | calibrateHeadingLink, | |
| 248 | initialHeadingCorrectionDeg: headingCorrectionDeg, | |
b4e7f682 2026-03-16 Pavel Safronov | 249 | onHeadingCorrectionDegChange: (next: number) => { headingCorrectionDeg = next; }, |
36d64b37 2026-03-15 Pavel Safronov | 250 | requestRender |
| 251 | }); | |
dcd59aa1 2026-03-14 Pavel Safronov | 252 | |
e790982a 2026-03-16 Pavel Safronov | 253 | const friendsController = createFriendsPanelController({ |
| 254 | friendsPanel, | |
| 255 | friendsToggle, | |
36d64b37 2026-03-15 Pavel Safronov | 256 | requestRender, |
| 257 | getReadableTextColor, | |
e790982a 2026-03-16 Pavel Safronov | 258 | selfFriend, |
e4870993 2026-03-16 Pavel Safronov | 259 | selfPosition, |
| 260 | presenceThresholds: { | |
9fda5271 2026-03-16 Pavel Safronov | 261 | onlineGraceMs: PUBLIC_SETTINGS.presence.onlineGraceMs, |
e4870993 2026-03-16 Pavel Safronov | 262 | recentlyOfflineMs: FRIEND_RECENTLY_OFFLINE_MS |
| 263 | } | |
36d64b37 2026-03-15 Pavel Safronov | 264 | }); |
dcd59aa1 2026-03-14 Pavel Safronov | 265 | |
07aa3903 2026-03-16 Pavel Safronov | 266 | const startSession = async () => { |
| 267 | if (sessionStarted || startInProgress) return; | |
| 268 | startInProgress = true; | |
| 269 | ||
| 270 | if (startRoomButton instanceof HTMLButtonElement) { | |
| 271 | startRoomButton.disabled = true; | |
| 272 | startRoomButton.textContent = 'Starting...'; | |
| 273 | } | |
| 274 | ||
| 275 | try { | |
| 276 | const firebaseSession = await initFirebaseSession(); | |
| 277 | db = firebaseSession.db; | |
| 278 | selfFriend.id = firebaseSession.uid; | |
| 279 | ||
| 280 | const session = await initSession({ | |
| 281 | roomCurrent, | |
| 282 | createRoomLink, | |
| 283 | inviteRoomQrList, | |
| 284 | isLocalCommit, | |
| 285 | BASE_ROOM_URL, | |
764d68de 2026-03-16 Pavel Safronov | 286 | CREATE_ROOM_URL, |
07aa3903 2026-03-16 Pavel Safronov | 287 | GITLAB_PAGES_BASE_URL: PUBLIC_SETTINGS.links.gitlabPagesBaseUrl |
| 288 | }); | |
| 289 | roomId = session.roomId; | |
| 290 | roomKey = session.roomKey; | |
764d68de 2026-03-16 Pavel Safronov | 291 | activeSessionRoomHash = buildRoomHash(session.roomId, session.roomKeyBase64); |
07aa3903 2026-03-16 Pavel Safronov | 292 | |
| 293 | friendRef = ref(db, `rooms/${roomId}/friends/${selfFriend.id}`); | |
| 294 | ||
| 295 | await syncSelfFriend(); | |
| 296 | void cleanupStaleFriends(); | |
| 297 | ||
| 298 | if (createRoomLink instanceof HTMLAnchorElement) createRoomLink.style.display = ''; | |
| 299 | if (roomLinks) roomLinks.classList.remove('is-hidden'); | |
| 300 | if (friendsToggle instanceof HTMLButtonElement) { | |
| 301 | friendsToggle.disabled = false; | |
| 302 | friendsToggle.title = ''; | |
| 303 | } | |
| 304 | ||
| 305 | const subscription = subscribeToFriends({ | |
| 306 | db, | |
| 307 | roomId, | |
| 308 | roomKey, | |
b4e7f682 2026-03-16 Pavel Safronov | 309 | onUpdate: (updated: any[]) => { |
| 310 | const remoteFriendCount = updated.filter((friend: any) => friend?.id && friend.id !== selfFriend.id).length; | |
1e87ff1c 2026-03-16 Pavel Safronov | 311 | if (remoteFriendCount > previousRemoteFriendCount && inviteRoomQrList instanceof HTMLElement) { |
| 312 | bumpQrAutoCollapseCountdown(inviteRoomQrList); | |
| 313 | } | |
| 314 | previousRemoteFriendCount = remoteFriendCount; | |
| 315 | ||
07aa3903 2026-03-16 Pavel Safronov | 316 | const friendsToRender = friendsController.getFriendsToRender(updated); |
| 317 | friendsController.renderFriendControls(friendsToRender); | |
| 318 | requestRender(); | |
| 319 | } | |
| 320 | }); | |
| 321 | friends = subscription.friends; | |
| 322 | ||
| 323 | if (geolocationWatchId === null) { | |
| 324 | geolocationWatchId = navigator.geolocation.watchPosition(async (pos) => { | |
| 325 | selfPosition.lat = pos.coords.latitude; | |
| 326 | selfPosition.lon = pos.coords.longitude; | |
| 327 | selfFriend.position = { ...selfPosition }; | |
| 328 | maybeSyncLocation(); | |
| 329 | requestRender(); | |
| 330 | }, (err) => { | |
| 331 | console.error('Error getting geolocation', err); | |
| 332 | }); | |
| 333 | } | |
| 334 | ||
| 335 | if (renderIntervalId === null) { | |
| 336 | renderIntervalId = window.setInterval(render, PUBLIC_SETTINGS.app.renderIntervalMs); | |
| 337 | } | |
| 338 | ||
| 339 | sessionStarted = true; | |
c18940ae 2026-03-16 Pavel Safronov | 340 | removeAutoCreateQueryParamFromUrl(); |
07aa3903 2026-03-16 Pavel Safronov | 341 | localStorage.setItem(LAST_ROOM_HASH_STORAGE_KEY, location.hash); |
| 342 | if (startRoomButton) startRoomButton.classList.add('is-hidden'); | |
| 343 | if (resumeRoomButton) resumeRoomButton.classList.add('is-hidden'); | |
f495a654 2026-03-16 Pavel Safronov | 344 | wakeLockController.syncWakeLockPreference(); |
07aa3903 2026-03-16 Pavel Safronov | 345 | requestRender(); |
| 346 | } catch (err) { | |
| 347 | console.error('Failed to start session', err); | |
| 348 | if (startRoomButton instanceof HTMLButtonElement) { | |
| 349 | startRoomButton.disabled = false; | |
| 350 | startRoomButton.textContent = 'Start'; | |
| 351 | } | |
| 352 | } finally { | |
| 353 | startInProgress = false; | |
| 354 | } | |
| 355 | }; | |
| 356 | ||
| 357 | if (startRoomButton instanceof HTMLButtonElement) { | |
| 358 | startRoomButton.addEventListener('click', () => { | |
| 359 | void startSession(); | |
| 360 | }); | |
| 361 | } | |
| 362 | ||
764d68de 2026-03-16 Pavel Safronov | 363 | if (parseRoomFromHash(location.hash) || shouldAutoCreateRoomFromUrl()) { |
07aa3903 2026-03-16 Pavel Safronov | 364 | void startSession(); |
| 365 | } else { | |
| 366 | const savedRoomHash = localStorage.getItem(LAST_ROOM_HASH_STORAGE_KEY); | |
| 367 | if (savedRoomHash && parseRoomFromHash(savedRoomHash) && resumeRoomButton instanceof HTMLButtonElement) { | |
| 368 | resumeRoomButton.classList.remove('is-hidden'); | |
| 369 | resumeRoomButton.addEventListener('click', () => { | |
| 370 | location.hash = savedRoomHash; | |
| 371 | void startSession(); | |
| 372 | }); | |
| 373 | if (startRoomButton instanceof HTMLButtonElement) { | |
| 374 | startRoomButton.textContent = 'New Room'; | |
| 375 | } | |
| 376 | } | |
| 377 | } | |
| 378 | ||
| 379 | window.addEventListener('hashchange', () => { | |
764d68de 2026-03-16 Pavel Safronov | 380 | const parsedRoom = parseRoomFromHash(location.hash); |
| 381 | ||
| 382 | // If the URL hash changes while a session is active, reload so runtime state | |
| 383 | // (subscriptions/refs/key) is rebuilt against the room in the URL. | |
| 384 | if (sessionStarted) { | |
| 385 | if (!parsedRoom) { | |
| 386 | window.location.reload(); | |
| 387 | return; | |
| 388 | } | |
| 389 | ||
| 390 | const nextRoomHash = buildRoomHash(parsedRoom.roomId, parsedRoom.roomKeyBase64); | |
| 391 | if (activeSessionRoomHash && nextRoomHash !== activeSessionRoomHash) { | |
| 392 | window.location.reload(); | |
| 393 | return; | |
| 394 | } | |
07aa3903 2026-03-16 Pavel Safronov | 395 | } |
| 396 | ||
| 397 | // Forward-navigation can restore a valid room hash while still on landing. | |
| 398 | // Start the room session to match the URL state. | |
764d68de 2026-03-16 Pavel Safronov | 399 | if (!sessionStarted && parsedRoom) { |
07aa3903 2026-03-16 Pavel Safronov | 400 | void startSession(); |
| 401 | } | |
2c1002f3 2026-03-14 Pavel Safronov | 402 | }); |
2b712221 2026-03-15 Pavel Safronov | 403 | |
| 404 | document.addEventListener('visibilitychange', () => { | |
| 405 | if (document.visibilityState !== 'visible') return; | |
36d64b37 2026-03-15 Pavel Safronov | 406 | compassController.scheduleCompassProbe(); |
f495a654 2026-03-16 Pavel Safronov | 407 | wakeLockController.syncWakeLockPreference(); |
dcd59aa1 2026-03-14 Pavel Safronov | 408 | }); |
0adcad67 2026-03-14 Pavel Safronov | 409 | |
fb3b6f32 2026-03-14 Pavel Safronov | 410 | const render = () => { |
07aa3903 2026-03-16 Pavel Safronov | 411 | if (!sessionStarted) return; |
b4e7f682 2026-03-16 Pavel Safronov | 412 | if (!content || !arrow) return; |
07aa3903 2026-03-16 Pavel Safronov | 413 | |
e4870993 2026-03-16 Pavel Safronov | 414 | const now = Date.now(); |
e790982a 2026-03-16 Pavel Safronov | 415 | const friendsToRender = friendsController.getFriendsToRender(friends); |
36d64b37 2026-03-15 Pavel Safronov | 416 | |
e4870993 2026-03-16 Pavel Safronov | 417 | friendsController.refreshPresenceInPanel(now); |
| 418 | ||
36d64b37 2026-03-15 Pavel Safronov | 419 | renderArrows({ |
| 420 | content, | |
| 421 | arrow, | |
e790982a 2026-03-16 Pavel Safronov | 422 | friendsToRender, |
| 423 | selfFriend, | |
36d64b37 2026-03-15 Pavel Safronov | 424 | selfHeading: compassController.getSelfHeading(), |
e790982a 2026-03-16 Pavel Safronov | 425 | isFriendVisible: friendsController.isFriendVisible, |
e4870993 2026-03-16 Pavel Safronov | 426 | getArrowDisplayColor, |
| 427 | presenceThresholds: { | |
9fda5271 2026-03-16 Pavel Safronov | 428 | onlineGraceMs: PUBLIC_SETTINGS.presence.onlineGraceMs, |
e4870993 2026-03-16 Pavel Safronov | 429 | recentlyOfflineMs: FRIEND_RECENTLY_OFFLINE_MS |
| 430 | } | |
b6dcc912 2026-03-15 Pavel Safronov | 431 | }); |
| 432 | ||
0adcad67 2026-03-14 Pavel Safronov | 433 | }; |