north-star / files / src / app.ts
app.ts
| 1 | import { get, ref, remove, set, serverTimestamp } from 'firebase/database'; |
| 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'; |
| 8 | import { buildRoomHash, parseRoomFromHash } from './core/room'; |
| 9 | import { subscribeToFriends, createFriendsPanelController, renderArrows } from './features/friends'; |
| 10 | import { initCompassController } from './features/compass'; |
| 11 | import { PUBLIC_SETTINGS } from './public_settings'; |
| 12 | |
| 13 | const debugCommit = document.getElementById('debug-commit'); |
| 14 | const nameForm = document.getElementById('name-form'); |
| 15 | const selfNameInput = document.getElementById('self-name-input') as HTMLInputElement | null; |
| 16 | const selfColorInput = document.getElementById('self-color-input') as HTMLInputElement | null; |
| 17 | const roomCurrent = document.getElementById('room-current'); |
| 18 | const inviteRoomQrList = document.getElementById('invite-room-qr-list'); |
| 19 | const calibrateHeadingLink = document.getElementById('calibrate-heading-link'); |
| 20 | const darkModeToggle = document.getElementById('dark-mode-toggle'); |
| 21 | const wakeLockToggle = document.getElementById('wake-lock-toggle'); |
| 22 | const cleanModeToggle = document.getElementById('clean-mode-toggle'); |
| 23 | const createRoomLink = document.getElementById('create-room-link') as HTMLAnchorElement | null; |
| 24 | const roomLinks = document.getElementById('room-links'); |
| 25 | const friendsPanel = document.getElementById('friends-panel'); |
| 26 | const friendsToggle = document.getElementById('friends-toggle'); |
| 27 | const content = document.getElementById('content'); |
| 28 | const arrow = document.getElementById('default-arrow'); |
| 29 | const startRoomButton = document.getElementById('start-room-button'); |
| 30 | const resumeRoomButton = document.getElementById('resume-room-button'); |
| 31 | |
| 32 | const LOCATION_CHANGE_THRESHOLD_METERS = PUBLIC_SETTINGS.location.changeThresholdMeters; |
| 33 | const FRIEND_RECENTLY_OFFLINE_MS = (() => { |
| 34 | const raw = Number(localStorage.getItem('friend-compass-recently-offline-ms')); |
| 35 | if (Number.isFinite(raw) && raw > PUBLIC_SETTINGS.presence.onlineGraceMs) { |
| 36 | return raw; |
| 37 | } |
| 38 | return PUBLIC_SETTINGS.presence.defaultRecentlyOfflineMs; |
| 39 | })(); |
| 40 | const LAST_ROOM_HASH_STORAGE_KEY = 'friend-compass-last-room-hash'; |
| 41 | |
| 42 | let headingCorrectionDeg = (() => { |
| 43 | const parsed = Number(localStorage.getItem('friend-compass-heading-correction-deg')); |
| 44 | return Number.isFinite(parsed) ? parsed : 0; |
| 45 | })(); |
| 46 | |
| 47 | let isLocalCommit = true; |
| 48 | let GIT_COMMIT = 'local-dev'; |
| 49 | |
| 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; |
| 57 | let previousRemoteFriendCount = 0; |
| 58 | let activeSessionRoomHash: string | null = null; |
| 59 | |
| 60 | initDarkMode(darkModeToggle); |
| 61 | const wakeLockController = initWakeLockController({ |
| 62 | wakeLockToggle, |
| 63 | isSessionStarted: () => sessionStarted, |
| 64 | isFullscreen: () => document.body.classList.contains('clean-mode') |
| 65 | }); |
| 66 | |
| 67 | const setCleanMode = (enabled: boolean) => { |
| 68 | document.body.classList.toggle('clean-mode', enabled); |
| 69 | if (cleanModeToggle instanceof HTMLButtonElement) { |
| 70 | cleanModeToggle.textContent = enabled ? 'Back' : 'Fullscreen'; |
| 71 | cleanModeToggle.setAttribute('aria-pressed', String(enabled)); |
| 72 | cleanModeToggle.setAttribute('aria-label', enabled ? 'Back to full mode' : 'Enter fullscreen mode'); |
| 73 | } |
| 74 | |
| 75 | wakeLockController.syncWakeLockPreference({ announce: true, source: 'fullscreen' }); |
| 76 | }; |
| 77 | |
| 78 | if (cleanModeToggle instanceof HTMLButtonElement) { |
| 79 | cleanModeToggle.addEventListener('click', () => { |
| 80 | setCleanMode(!document.body.classList.contains('clean-mode')); |
| 81 | }); |
| 82 | } |
| 83 | |
| 84 | try { |
| 85 | const commitModule = await import('./commit.js'); |
| 86 | GIT_COMMIT = commitModule.GIT_COMMIT; |
| 87 | isLocalCommit = commitModule.IS_LOCAL_BUILD; |
| 88 | } catch { |
| 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.'; |
| 98 | } |
| 99 | if (inviteRoomQrList) inviteRoomQrList.textContent = ''; |
| 100 | |
| 101 | const selfPosition = { |
| 102 | lat: 47.62055039884746, |
| 103 | lon: -122.34928833200904 |
| 104 | }; |
| 105 | |
| 106 | const BASE_ROOM_URL = `${window.location.origin}${window.location.pathname}`; |
| 107 | const CREATE_ROOM_URL = `${BASE_ROOM_URL}?new`; |
| 108 | |
| 109 | const shouldAutoCreateRoomFromUrl = () => { |
| 110 | const raw = new URLSearchParams(location.search).get('new'); |
| 111 | if (raw === null) return false; |
| 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); |
| 123 | }; |
| 124 | |
| 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 | } |
| 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://code.fuzzyworld.net/north-star/commit/${GIT_COMMIT}.html`; |
| 140 | debugCommit.target = '_blank'; |
| 141 | debugCommit.rel = 'noopener noreferrer'; |
| 142 | } |
| 143 | } |
| 144 | |
| 145 | // selfFriend is assigned below by initSelfProfile; syncSelfFriend is |
| 146 | // only ever called after that point (from event handlers / intervals). |
| 147 | let selfFriend: any; |
| 148 | let lastSyncedPosition: { lat: number; lon: number } | null = null; |
| 149 | let lastLocationSyncAt = 0; |
| 150 | let geolocationWatchId: number | null = null; |
| 151 | let renderIntervalId: number | null = null; |
| 152 | |
| 153 | const syncSelfFriend = async () => { |
| 154 | if (!friendRef) return; |
| 155 | |
| 156 | const now = Date.now(); |
| 157 | selfFriend.timestamp = now; |
| 158 | const dbRecord = roomKey |
| 159 | ? { encryptedState: await encryptJson(roomKey, selfFriend), lastSeen: serverTimestamp() } |
| 160 | : { ...selfFriend, lastSeen: serverTimestamp() }; |
| 161 | try { |
| 162 | await set(friendRef, dbRecord); |
| 163 | lastLocationSyncAt = now; |
| 164 | lastSyncedPosition = { ...selfPosition }; |
| 165 | } catch (err) { |
| 166 | console.error('Error syncing friend in database', err); |
| 167 | } |
| 168 | }; |
| 169 | |
| 170 | const cleanupStaleFriends = async () => { |
| 171 | if (!db || !roomId) return; |
| 172 | |
| 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); |
| 182 | if (Number.isFinite(lastSeen) && now - lastSeen > PUBLIC_SETTINGS.cleanup.staleFriendTtlMs) { |
| 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 | |
| 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 | |
| 216 | const maybeSyncLocation = () => { |
| 217 | if (!sessionStarted) return; |
| 218 | |
| 219 | const now = Date.now(); |
| 220 | const locationChanged = hasMeaningfulLocationChange(); |
| 221 | if (locationChanged && now - lastLocationSyncAt < PUBLIC_SETTINGS.location.movingSyncIntervalMs) return; |
| 222 | if (!locationChanged && now - lastLocationSyncAt < PUBLIC_SETTINGS.location.stationarySyncIntervalMs) return; |
| 223 | void syncSelfFriend(); |
| 224 | }; |
| 225 | |
| 226 | let renderFrameRequested = false; |
| 227 | const requestRender = () => { |
| 228 | if (renderFrameRequested) return; |
| 229 | renderFrameRequested = true; |
| 230 | requestAnimationFrame(() => { |
| 231 | renderFrameRequested = false; |
| 232 | render(); |
| 233 | }); |
| 234 | }; |
| 235 | |
| 236 | ({ selfFriend } = await initSelfProfile({ |
| 237 | nameForm, |
| 238 | selfNameInput, |
| 239 | selfColorInput, |
| 240 | uid: 'pending-user', |
| 241 | selfPosition, |
| 242 | syncSelf: syncSelfFriend, |
| 243 | requestRender |
| 244 | })); |
| 245 | |
| 246 | const compassController = initCompassController({ |
| 247 | calibrateHeadingLink, |
| 248 | initialHeadingCorrectionDeg: headingCorrectionDeg, |
| 249 | onHeadingCorrectionDegChange: (next: number) => { headingCorrectionDeg = next; }, |
| 250 | requestRender |
| 251 | }); |
| 252 | |
| 253 | const friendsController = createFriendsPanelController({ |
| 254 | friendsPanel, |
| 255 | friendsToggle, |
| 256 | requestRender, |
| 257 | getReadableTextColor, |
| 258 | selfFriend, |
| 259 | selfPosition, |
| 260 | presenceThresholds: { |
| 261 | onlineGraceMs: PUBLIC_SETTINGS.presence.onlineGraceMs, |
| 262 | recentlyOfflineMs: FRIEND_RECENTLY_OFFLINE_MS |
| 263 | } |
| 264 | }); |
| 265 | |
| 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, |
| 286 | CREATE_ROOM_URL, |
| 287 | GITLAB_PAGES_BASE_URL: PUBLIC_SETTINGS.links.gitlabPagesBaseUrl |
| 288 | }); |
| 289 | roomId = session.roomId; |
| 290 | roomKey = session.roomKey; |
| 291 | activeSessionRoomHash = buildRoomHash(session.roomId, session.roomKeyBase64); |
| 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, |
| 309 | onUpdate: (updated: any[]) => { |
| 310 | const remoteFriendCount = updated.filter((friend: any) => friend?.id && friend.id !== selfFriend.id).length; |
| 311 | if (remoteFriendCount > previousRemoteFriendCount && inviteRoomQrList instanceof HTMLElement) { |
| 312 | bumpQrAutoCollapseCountdown(inviteRoomQrList); |
| 313 | } |
| 314 | previousRemoteFriendCount = remoteFriendCount; |
| 315 | |
| 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; |
| 340 | removeAutoCreateQueryParamFromUrl(); |
| 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'); |
| 344 | wakeLockController.syncWakeLockPreference(); |
| 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 | |
| 363 | if (parseRoomFromHash(location.hash) || shouldAutoCreateRoomFromUrl()) { |
| 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', () => { |
| 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 | } |
| 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. |
| 399 | if (!sessionStarted && parsedRoom) { |
| 400 | void startSession(); |
| 401 | } |
| 402 | }); |
| 403 | |
| 404 | document.addEventListener('visibilitychange', () => { |
| 405 | if (document.visibilityState !== 'visible') return; |
| 406 | compassController.scheduleCompassProbe(); |
| 407 | wakeLockController.syncWakeLockPreference(); |
| 408 | }); |
| 409 | |
| 410 | const render = () => { |
| 411 | if (!sessionStarted) return; |
| 412 | if (!content || !arrow) return; |
| 413 | |
| 414 | const now = Date.now(); |
| 415 | const friendsToRender = friendsController.getFriendsToRender(friends); |
| 416 | |
| 417 | friendsController.refreshPresenceInPanel(now); |
| 418 | |
| 419 | renderArrows({ |
| 420 | content, |
| 421 | arrow, |
| 422 | friendsToRender, |
| 423 | selfFriend, |
| 424 | selfHeading: compassController.getSelfHeading(), |
| 425 | isFriendVisible: friendsController.isFriendVisible, |
| 426 | getArrowDisplayColor, |
| 427 | presenceThresholds: { |
| 428 | onlineGraceMs: PUBLIC_SETTINGS.presence.onlineGraceMs, |
| 429 | recentlyOfflineMs: FRIEND_RECENTLY_OFFLINE_MS |
| 430 | } |
| 431 | }); |
| 432 | |
| 433 | }; |