fuzzyworld

north-star / mobile / files / src / app.ts

Branch mobile · default branch · all branches

app.ts

Blame: the commit that last changed each line · file · history

cf7471ca 2026-03-16 Pavel Safronov1import { get, ref, remove, set, serverTimestamp } from 'firebase/database';
f32f5e95 2026-03-16 Pavel Safronov2import { 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';
764d68de 2026-03-16 Pavel Safronov8import { buildRoomHash, parseRoomFromHash } from './core/room';
f32f5e95 2026-03-16 Pavel Safronov9import { subscribeToFriends, createFriendsPanelController, renderArrows } from './features/friends';
10import { initCompassController } from './features/compass';
32796e53 2026-03-16 Pavel Safronov11import { PUBLIC_SETTINGS } from './public_settings';
dcd59aa1 2026-03-14 Pavel Safronov12
1e87ff1c 2026-03-16 Pavel Safronov13const debugCommit = document.getElementById('debug-commit');
dcd59aa1 2026-03-14 Pavel Safronov14const nameForm = document.getElementById('name-form');
b4e7f682 2026-03-16 Pavel Safronov15const selfNameInput = document.getElementById('self-name-input') as HTMLInputElement | null;
16const selfColorInput = document.getElementById('self-color-input') as HTMLInputElement | null;
b7d4b972 2026-03-14 Pavel Safronov17const roomCurrent = document.getElementById('room-current');
6cb482fe 2026-03-15 Pavel Safronov18const inviteRoomQrList = document.getElementById('invite-room-qr-list');
30e810b3 2026-03-15 Pavel Safronov19const calibrateHeadingLink = document.getElementById('calibrate-heading-link');
d9978ceb 2026-03-15 Pavel Safronov20const darkModeToggle = document.getElementById('dark-mode-toggle');
f495a654 2026-03-16 Pavel Safronov21const wakeLockToggle = document.getElementById('wake-lock-toggle');
65034d4e 2026-03-16 Pavel Safronov22const cleanModeToggle = document.getElementById('clean-mode-toggle');
b4e7f682 2026-03-16 Pavel Safronov23const createRoomLink = document.getElementById('create-room-link') as HTMLAnchorElement | null;
07aa3903 2026-03-16 Pavel Safronov24const roomLinks = document.getElementById('room-links');
e790982a 2026-03-16 Pavel Safronov25const friendsPanel = document.getElementById('friends-panel');
26const friendsToggle = document.getElementById('friends-toggle');
36d64b37 2026-03-15 Pavel Safronov27const content = document.getElementById('content');
28const arrow = document.getElementById('default-arrow');
07aa3903 2026-03-16 Pavel Safronov29const startRoomButton = document.getElementById('start-room-button');
30const resumeRoomButton = document.getElementById('resume-room-button');
36d64b37 2026-03-15 Pavel Safronov31
0b65bf68 2026-03-16 Pavel Safronov32const LOCATION_CHANGE_THRESHOLD_METERS = PUBLIC_SETTINGS.location.changeThresholdMeters;
e4870993 2026-03-16 Pavel Safronov33const FRIEND_RECENTLY_OFFLINE_MS = (() => {
0b65bf68 2026-03-16 Pavel Safronov34 const raw = Number(localStorage.getItem('friend-compass-recently-offline-ms'));
9fda5271 2026-03-16 Pavel Safronov35 if (Number.isFinite(raw) && raw > PUBLIC_SETTINGS.presence.onlineGraceMs) {
e4870993 2026-03-16 Pavel Safronov36 return raw;
37 }
9fda5271 2026-03-16 Pavel Safronov38 return PUBLIC_SETTINGS.presence.defaultRecentlyOfflineMs;
e4870993 2026-03-16 Pavel Safronov39})();
07aa3903 2026-03-16 Pavel Safronov40const LAST_ROOM_HASH_STORAGE_KEY = 'friend-compass-last-room-hash';
36d64b37 2026-03-15 Pavel Safronov41
30e810b3 2026-03-15 Pavel Safronov42let headingCorrectionDeg = (() => {
0b65bf68 2026-03-16 Pavel Safronov43 const parsed = Number(localStorage.getItem('friend-compass-heading-correction-deg'));
30e810b3 2026-03-15 Pavel Safronov44 return Number.isFinite(parsed) ? parsed : 0;
45})();
36d64b37 2026-03-15 Pavel Safronov46
12fa94c5 2026-03-15 Pavel Safronov47let isLocalCommit = true;
f7854852 2026-03-15 Pavel Safronov48let GIT_COMMIT = 'local-dev';
d9978ceb 2026-03-15 Pavel Safronov49
07aa3903 2026-03-16 Pavel Safronov50let 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;
1e87ff1c 2026-03-16 Pavel Safronov57let previousRemoteFriendCount = 0;
764d68de 2026-03-16 Pavel Safronov58let activeSessionRoomHash: string | null = null;
07aa3903 2026-03-16 Pavel Safronov59
36d64b37 2026-03-15 Pavel Safronov60initDarkMode(darkModeToggle);
f495a654 2026-03-16 Pavel Safronov61const wakeLockController = initWakeLockController({
62 wakeLockToggle,
63 isSessionStarted: () => sessionStarted,
64 isFullscreen: () => document.body.classList.contains('clean-mode')
65});
0a0cb749 2026-03-15 Pavel Safronov66
65034d4e 2026-03-16 Pavel Safronov67const setCleanMode = (enabled: boolean) => {
68 document.body.classList.toggle('clean-mode', enabled);
69 if (cleanModeToggle instanceof HTMLButtonElement) {
cf7471ca 2026-03-16 Pavel Safronov70 cleanModeToggle.textContent = enabled ? 'Back' : 'Fullscreen';
65034d4e 2026-03-16 Pavel Safronov71 cleanModeToggle.setAttribute('aria-pressed', String(enabled));
cf7471ca 2026-03-16 Pavel Safronov72 cleanModeToggle.setAttribute('aria-label', enabled ? 'Back to full mode' : 'Enter fullscreen mode');
65034d4e 2026-03-16 Pavel Safronov73 }
f495a654 2026-03-16 Pavel Safronov74
75 wakeLockController.syncWakeLockPreference({ announce: true, source: 'fullscreen' });
65034d4e 2026-03-16 Pavel Safronov76};
77
78if (cleanModeToggle instanceof HTMLButtonElement) {
79 cleanModeToggle.addEventListener('click', () => {
80 setCleanMode(!document.body.classList.contains('clean-mode'));
81 });
82}
83
f7854852 2026-03-15 Pavel Safronov84try {
5be7df9b 2026-03-16 Pavel Safronov85 const commitModule = await import('./commit.js');
07aa3903 2026-03-16 Pavel Safronov86 GIT_COMMIT = commitModule.GIT_COMMIT;
87 isLocalCommit = commitModule.IS_LOCAL_BUILD;
f7854852 2026-03-15 Pavel Safronov88} catch {
07aa3903 2026-03-16 Pavel Safronov89 // 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.';
f7854852 2026-03-15 Pavel Safronov98}
07aa3903 2026-03-16 Pavel Safronov99if (inviteRoomQrList) inviteRoomQrList.textContent = '';
a425df77 2026-03-14 Pavel Safronov100
0adcad67 2026-03-14 Pavel Safronov101const selfPosition = {
102 lat: 47.62055039884746,
103 lon: -122.34928833200904
104};
05c8dab2 2026-03-15 Pavel Safronov105
84bc5868 2026-03-15 Pavel Safronov106const BASE_ROOM_URL = `${window.location.origin}${window.location.pathname}`;
c18940ae 2026-03-16 Pavel Safronov107const CREATE_ROOM_URL = `${BASE_ROOM_URL}?new`;
764d68de 2026-03-16 Pavel Safronov108
109const shouldAutoCreateRoomFromUrl = () => {
110 const raw = new URLSearchParams(location.search).get('new');
111 if (raw === null) return false;
c18940ae 2026-03-16 Pavel Safronov112 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);
764d68de 2026-03-16 Pavel Safronov123};
faeb8ad8 2026-03-15 Pavel Safronov124
07aa3903 2026-03-16 Pavel Safronov125const appTitleLink = document.getElementById('app-title-link');
126if (appTitleLink instanceof HTMLAnchorElement) {
127 appTitleLink.href = isLocalCommit ? BASE_ROOM_URL : PUBLIC_SETTINGS.links.gitlabPagesBaseUrl;
128}
1e87ff1c 2026-03-16 Pavel Safronov129if (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 Safronov144
e790982a 2026-03-16 Pavel Safronov145// selfFriend is assigned below by initSelfProfile; syncSelfFriend is
36d64b37 2026-03-15 Pavel Safronov146// only ever called after that point (from event handlers / intervals).
b4e7f682 2026-03-16 Pavel Safronov147let selfFriend: any;
9fda5271 2026-03-16 Pavel Safronov148let lastSyncedPosition: { lat: number; lon: number } | null = null;
07aa3903 2026-03-16 Pavel Safronov149let lastLocationSyncAt = 0;
150let geolocationWatchId: number | null = null;
151let renderIntervalId: number | null = null;
36d64b37 2026-03-15 Pavel Safronov152
e790982a 2026-03-16 Pavel Safronov153const syncSelfFriend = async () => {
07aa3903 2026-03-16 Pavel Safronov154 if (!friendRef) return;
155
55ab7a53 2026-03-16 Pavel Safronov156 const now = Date.now();
157 selfFriend.timestamp = now;
36d64b37 2026-03-15 Pavel Safronov158 const dbRecord = roomKey
cf7471ca 2026-03-16 Pavel Safronov159 ? { encryptedState: await encryptJson(roomKey, selfFriend), lastSeen: serverTimestamp() }
160 : { ...selfFriend, lastSeen: serverTimestamp() };
dcd59aa1 2026-03-14 Pavel Safronov161 try {
e790982a 2026-03-16 Pavel Safronov162 await set(friendRef, dbRecord);
9fda5271 2026-03-16 Pavel Safronov163 lastLocationSyncAt = now;
164 lastSyncedPosition = { ...selfPosition };
dcd59aa1 2026-03-14 Pavel Safronov165 } catch (err) {
e790982a 2026-03-16 Pavel Safronov166 console.error('Error syncing friend in database', err);
dcd59aa1 2026-03-14 Pavel Safronov167 }
e263c431 2026-03-15 Pavel Safronov168};
169
55ab7a53 2026-03-16 Pavel Safronov170const cleanupStaleFriends = async () => {
07aa3903 2026-03-16 Pavel Safronov171 if (!db || !roomId) return;
172
55ab7a53 2026-03-16 Pavel Safronov173 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 Safronov182 if (Number.isFinite(lastSeen) && now - lastSeen > PUBLIC_SETTINGS.cleanup.staleFriendTtlMs) {
55ab7a53 2026-03-16 Pavel Safronov183 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 Safronov195const 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
4661a9b6 2026-03-14 Pavel Safronov216const maybeSyncLocation = () => {
07aa3903 2026-03-16 Pavel Safronov217 if (!sessionStarted) return;
218
4661a9b6 2026-03-14 Pavel Safronov219 const now = Date.now();
9fda5271 2026-03-16 Pavel Safronov220 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 Safronov223 void syncSelfFriend();
4661a9b6 2026-03-14 Pavel Safronov224};
225
36d64b37 2026-03-15 Pavel Safronov226let renderFrameRequested = false;
227const requestRender = () => {
228 if (renderFrameRequested) return;
229 renderFrameRequested = true;
230 requestAnimationFrame(() => {
231 renderFrameRequested = false;
232 render();
233 });
dcd59aa1 2026-03-14 Pavel Safronov234};
235
e790982a 2026-03-16 Pavel Safronov236({ selfFriend } = await initSelfProfile({
36d64b37 2026-03-15 Pavel Safronov237 nameForm,
238 selfNameInput,
239 selfColorInput,
07aa3903 2026-03-16 Pavel Safronov240 uid: 'pending-user',
36d64b37 2026-03-15 Pavel Safronov241 selfPosition,
e790982a 2026-03-16 Pavel Safronov242 syncSelf: syncSelfFriend,
36d64b37 2026-03-15 Pavel Safronov243 requestRender
244}));
245
246const compassController = initCompassController({
247 calibrateHeadingLink,
248 initialHeadingCorrectionDeg: headingCorrectionDeg,
b4e7f682 2026-03-16 Pavel Safronov249 onHeadingCorrectionDegChange: (next: number) => { headingCorrectionDeg = next; },
36d64b37 2026-03-15 Pavel Safronov250 requestRender
251});
dcd59aa1 2026-03-14 Pavel Safronov252
e790982a 2026-03-16 Pavel Safronov253const friendsController = createFriendsPanelController({
254 friendsPanel,
255 friendsToggle,
36d64b37 2026-03-15 Pavel Safronov256 requestRender,
257 getReadableTextColor,
e790982a 2026-03-16 Pavel Safronov258 selfFriend,
e4870993 2026-03-16 Pavel Safronov259 selfPosition,
260 presenceThresholds: {
9fda5271 2026-03-16 Pavel Safronov261 onlineGraceMs: PUBLIC_SETTINGS.presence.onlineGraceMs,
e4870993 2026-03-16 Pavel Safronov262 recentlyOfflineMs: FRIEND_RECENTLY_OFFLINE_MS
263 }
36d64b37 2026-03-15 Pavel Safronov264});
dcd59aa1 2026-03-14 Pavel Safronov265
07aa3903 2026-03-16 Pavel Safronov266const 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 Safronov286 CREATE_ROOM_URL,
07aa3903 2026-03-16 Pavel Safronov287 GITLAB_PAGES_BASE_URL: PUBLIC_SETTINGS.links.gitlabPagesBaseUrl
288 });
289 roomId = session.roomId;
290 roomKey = session.roomKey;
764d68de 2026-03-16 Pavel Safronov291 activeSessionRoomHash = buildRoomHash(session.roomId, session.roomKeyBase64);
07aa3903 2026-03-16 Pavel Safronov292
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 Safronov309 onUpdate: (updated: any[]) => {
310 const remoteFriendCount = updated.filter((friend: any) => friend?.id && friend.id !== selfFriend.id).length;
1e87ff1c 2026-03-16 Pavel Safronov311 if (remoteFriendCount > previousRemoteFriendCount && inviteRoomQrList instanceof HTMLElement) {
312 bumpQrAutoCollapseCountdown(inviteRoomQrList);
313 }
314 previousRemoteFriendCount = remoteFriendCount;
315
07aa3903 2026-03-16 Pavel Safronov316 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 Safronov340 removeAutoCreateQueryParamFromUrl();
07aa3903 2026-03-16 Pavel Safronov341 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 Safronov344 wakeLockController.syncWakeLockPreference();
07aa3903 2026-03-16 Pavel Safronov345 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
764d68de 2026-03-16 Pavel Safronov363if (parseRoomFromHash(location.hash) || shouldAutoCreateRoomFromUrl()) {
07aa3903 2026-03-16 Pavel Safronov364 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', () => {
764d68de 2026-03-16 Pavel Safronov380 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 Safronov395 }
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 Safronov399 if (!sessionStarted && parsedRoom) {
07aa3903 2026-03-16 Pavel Safronov400 void startSession();
401 }
2c1002f3 2026-03-14 Pavel Safronov402});
2b712221 2026-03-15 Pavel Safronov403
404document.addEventListener('visibilitychange', () => {
405 if (document.visibilityState !== 'visible') return;
36d64b37 2026-03-15 Pavel Safronov406 compassController.scheduleCompassProbe();
f495a654 2026-03-16 Pavel Safronov407 wakeLockController.syncWakeLockPreference();
dcd59aa1 2026-03-14 Pavel Safronov408});
0adcad67 2026-03-14 Pavel Safronov409
fb3b6f32 2026-03-14 Pavel Safronov410const render = () => {
07aa3903 2026-03-16 Pavel Safronov411 if (!sessionStarted) return;
b4e7f682 2026-03-16 Pavel Safronov412 if (!content || !arrow) return;
07aa3903 2026-03-16 Pavel Safronov413
e4870993 2026-03-16 Pavel Safronov414 const now = Date.now();
e790982a 2026-03-16 Pavel Safronov415 const friendsToRender = friendsController.getFriendsToRender(friends);
36d64b37 2026-03-15 Pavel Safronov416
e4870993 2026-03-16 Pavel Safronov417 friendsController.refreshPresenceInPanel(now);
418
36d64b37 2026-03-15 Pavel Safronov419 renderArrows({
420 content,
421 arrow,
e790982a 2026-03-16 Pavel Safronov422 friendsToRender,
423 selfFriend,
36d64b37 2026-03-15 Pavel Safronov424 selfHeading: compassController.getSelfHeading(),
e790982a 2026-03-16 Pavel Safronov425 isFriendVisible: friendsController.isFriendVisible,
e4870993 2026-03-16 Pavel Safronov426 getArrowDisplayColor,
427 presenceThresholds: {
9fda5271 2026-03-16 Pavel Safronov428 onlineGraceMs: PUBLIC_SETTINGS.presence.onlineGraceMs,
e4870993 2026-03-16 Pavel Safronov429 recentlyOfflineMs: FRIEND_RECENTLY_OFFLINE_MS
430 }
b6dcc912 2026-03-15 Pavel Safronov431 });
432
0adcad67 2026-03-14 Pavel Safronov433};