fuzzyworld

north-star / files / src / app.ts

app.ts

15.7 KB · history · blame

1import { get, ref, remove, set, serverTimestamp } from 'firebase/database';
2import { getReadableTextColor, getArrowDisplayColor } from './core/utils';
3import { encryptJson } from './core/crypto';
4import { initDarkMode } from './platform/darkmode';
5import { initWakeLockController } from './features/ui';
6import { initFirebaseSession } from './platform/firebase';
7import { initSession, initSelfProfile, bumpQrAutoCollapseCountdown } from './features/session';
8import { buildRoomHash, parseRoomFromHash } from './core/room';
9import { subscribeToFriends, createFriendsPanelController, renderArrows } from './features/friends';
10import { initCompassController } from './features/compass';
11import { PUBLIC_SETTINGS } from './public_settings';
12
13const debugCommit = document.getElementById('debug-commit');
14const nameForm = document.getElementById('name-form');
15const selfNameInput = document.getElementById('self-name-input') as HTMLInputElement | null;
16const selfColorInput = document.getElementById('self-color-input') as HTMLInputElement | null;
17const roomCurrent = document.getElementById('room-current');
18const inviteRoomQrList = document.getElementById('invite-room-qr-list');
19const calibrateHeadingLink = document.getElementById('calibrate-heading-link');
20const darkModeToggle = document.getElementById('dark-mode-toggle');
21const wakeLockToggle = document.getElementById('wake-lock-toggle');
22const cleanModeToggle = document.getElementById('clean-mode-toggle');
23const createRoomLink = document.getElementById('create-room-link') as HTMLAnchorElement | null;
24const roomLinks = document.getElementById('room-links');
25const friendsPanel = document.getElementById('friends-panel');
26const friendsToggle = document.getElementById('friends-toggle');
27const content = document.getElementById('content');
28const arrow = document.getElementById('default-arrow');
29const startRoomButton = document.getElementById('start-room-button');
30const resumeRoomButton = document.getElementById('resume-room-button');
31
32const LOCATION_CHANGE_THRESHOLD_METERS = PUBLIC_SETTINGS.location.changeThresholdMeters;
33const 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})();
40const LAST_ROOM_HASH_STORAGE_KEY = 'friend-compass-last-room-hash';
41
42let headingCorrectionDeg = (() => {
43 const parsed = Number(localStorage.getItem('friend-compass-heading-correction-deg'));
44 return Number.isFinite(parsed) ? parsed : 0;
45})();
46
47let isLocalCommit = true;
48let GIT_COMMIT = 'local-dev';
49
50let db: any = null;
51let roomId = '';
52let roomKey: CryptoKey | null = null;
53let friendRef: ReturnType<typeof ref> | null = null;
54let friends: any[] = [];
55let sessionStarted = false;
56let startInProgress = false;
57let previousRemoteFriendCount = 0;
58let activeSessionRoomHash: string | null = null;
59
60initDarkMode(darkModeToggle);
61const wakeLockController = initWakeLockController({
62 wakeLockToggle,
63 isSessionStarted: () => sessionStarted,
64 isFullscreen: () => document.body.classList.contains('clean-mode')
65});
66
67const 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
78if (cleanModeToggle instanceof HTMLButtonElement) {
79 cleanModeToggle.addEventListener('click', () => {
80 setCleanMode(!document.body.classList.contains('clean-mode'));
81 });
82}
83
84try {
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
92if (createRoomLink instanceof HTMLAnchorElement) createRoomLink.style.display = 'none';
93if (roomLinks) roomLinks.classList.add('is-hidden');
94if (friendsPanel) friendsPanel.classList.add('is-hidden');
95if (friendsToggle instanceof HTMLButtonElement) {
96 friendsToggle.disabled = true;
97 friendsToggle.title = 'Start a session first to show friends.';
98}
99if (inviteRoomQrList) inviteRoomQrList.textContent = '';
100
101const selfPosition = {
102 lat: 47.62055039884746,
103 lon: -122.34928833200904
104};
105
106const BASE_ROOM_URL = `${window.location.origin}${window.location.pathname}`;
107const CREATE_ROOM_URL = `${BASE_ROOM_URL}?new`;
108
109const shouldAutoCreateRoomFromUrl = () => {
110 const raw = new URLSearchParams(location.search).get('new');
111 if (raw === null) return false;
112 return true;
113};
114
115const 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
125const appTitleLink = document.getElementById('app-title-link');
126if (appTitleLink instanceof HTMLAnchorElement) {
127 appTitleLink.href = isLocalCommit ? BASE_ROOM_URL : PUBLIC_SETTINGS.links.gitlabPagesBaseUrl;
128}
129if (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).
147let selfFriend: any;
148let lastSyncedPosition: { lat: number; lon: number } | null = null;
149let lastLocationSyncAt = 0;
150let geolocationWatchId: number | null = null;
151let renderIntervalId: number | null = null;
152
153const 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
170const 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
195const 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
210const hasMeaningfulLocationChange = () => {
211 if (!lastSyncedPosition) return true;
212 const deltaMeters = distanceMetersBetween(selfPosition, lastSyncedPosition);
213 return deltaMeters > LOCATION_CHANGE_THRESHOLD_METERS;
214};
215
216const 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
226let renderFrameRequested = false;
227const 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
246const compassController = initCompassController({
247 calibrateHeadingLink,
248 initialHeadingCorrectionDeg: headingCorrectionDeg,
249 onHeadingCorrectionDegChange: (next: number) => { headingCorrectionDeg = next; },
250 requestRender
251});
252
253const 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
266const 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
357if (startRoomButton instanceof HTMLButtonElement) {
358 startRoomButton.addEventListener('click', () => {
359 void startSession();
360 });
361}
362
363if (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
379window.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
404document.addEventListener('visibilitychange', () => {
405 if (document.visibilityState !== 'visible') return;
406 compassController.scheduleCompassProbe();
407 wakeLockController.syncWakeLockPreference();
408});
409
410const 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};