fuzzyworld

north-star / files / src / features / friends / panel.ts

panel.ts

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

32796e53 2026-03-16 Pavel Safronov1import { getFriendPresenceState } from '../../core/utils';
f32f5e95 2026-03-16 Pavel Safronov2import type { PresenceThresholds } from '../../core/utils';
32796e53 2026-03-16 Pavel Safronov3import { PUBLIC_SETTINGS } from '../../public_settings';
e4870993 2026-03-16 Pavel Safronov4
b4e7f682 2026-03-16 Pavel Safronov5type FriendModel = {
6 id: string;
7 name: string;
8 color: string;
9 position: { lat: number; lon: number };
10 timestamp?: number;
11 lastSeen?: number;
12};
13
e790982a 2026-03-16 Pavel Safronov14export const createFriendsPanelController = ({
15 friendsPanel,
16 friendsToggle,
17 requestRender,
18 getReadableTextColor,
19 selfFriend,
e4870993 2026-03-16 Pavel Safronov20 selfPosition,
21 presenceThresholds
22}: {
23 friendsPanel: HTMLElement | null;
24 friendsToggle: HTMLElement | null;
25 requestRender: () => void;
26 getReadableTextColor: (color: string) => string;
27 selfFriend: any;
28 selfPosition: { lat: number; lon: number };
29 presenceThresholds: PresenceThresholds;
e790982a 2026-03-16 Pavel Safronov30}) => {
efc31a14 2026-03-16 Pavel Safronov31 const guardianAngelEnabled = PUBLIC_SETTINGS.features.guardianAngelEnabled;
32796e53 2026-03-16 Pavel Safronov32 const presenceEmoji = {
33 online: PUBLIC_SETTINGS.ui.emojis.presence.online,
34 'recent-offline': PUBLIC_SETTINGS.ui.emojis.presence.recentlyOffline,
35 'stale-offline': PUBLIC_SETTINGS.ui.emojis.presence.staleOffline
36 } as const;
efc31a14 2026-03-16 Pavel Safronov37 const northMarkerId = 'north-marker';
38 const guardianAngelId = 'guardian-angel';
39 const GUARDIAN_ORBIT_RADIUS_METERS = 75;
40 const GUARDIAN_ORBIT_PERIOD_MS = 90 * 1000;
41
e790982a 2026-03-16 Pavel Safronov42 const PARTICIPANT_VISIBILITY_STORAGE_KEY = 'friend-compass-friend-visibility';
43 const PARTICIPANTS_PANEL_VISIBLE_STORAGE_KEY = 'friend-compass-friends-panel-visible';
44 const PARTICIPANT_ORDER_STORAGE_KEY = 'friend-compass-friend-order';
45
46 const friendVisibility = (() => {
47 try {
48 const raw = localStorage.getItem(PARTICIPANT_VISIBILITY_STORAGE_KEY);
b4e7f682 2026-03-16 Pavel Safronov49 return raw ? JSON.parse(raw) as Record<string, boolean> : {};
e790982a 2026-03-16 Pavel Safronov50 } catch {
b4e7f682 2026-03-16 Pavel Safronov51 return {} as Record<string, boolean>;
e790982a 2026-03-16 Pavel Safronov52 }
53 })();
54
b4e7f682 2026-03-16 Pavel Safronov55 let friendOrder: string[] = (() => {
e790982a 2026-03-16 Pavel Safronov56 try {
57 const raw = localStorage.getItem(PARTICIPANT_ORDER_STORAGE_KEY);
58 const parsed = raw ? JSON.parse(raw) : [];
59 return Array.isArray(parsed) ? parsed.filter((id) => typeof id === 'string') : [];
60 } catch {
61 return [];
62 }
63 })();
64
65 const saveFriendOrder = () => {
66 localStorage.setItem(PARTICIPANT_ORDER_STORAGE_KEY, JSON.stringify(friendOrder));
67 };
68
b4e7f682 2026-03-16 Pavel Safronov69 const getFriendDefaultRank = (friendId: string) => {
e790982a 2026-03-16 Pavel Safronov70 if (friendId === northMarkerId) return 0;
efc31a14 2026-03-16 Pavel Safronov71 if (guardianAngelEnabled && friendId === guardianAngelId) return 1;
72 return 2;
e790982a 2026-03-16 Pavel Safronov73 };
74
efc31a14 2026-03-16 Pavel Safronov75 const getGuardianAngelPosition = (nowMs: number) => {
76 const latRad = selfPosition.lat * (Math.PI / 180);
77 const metersPerDegLat = 111320;
78 const metersPerDegLon = Math.max(111320 * Math.cos(latRad), 1);
79 const theta = (nowMs / GUARDIAN_ORBIT_PERIOD_MS) * (2 * Math.PI);
80
81 const latOffsetDeg = (GUARDIAN_ORBIT_RADIUS_METERS * Math.cos(theta)) / metersPerDegLat;
82 const lonOffsetDeg = (GUARDIAN_ORBIT_RADIUS_METERS * Math.sin(theta)) / metersPerDegLon;
83
84 return {
85 lat: selfPosition.lat + latOffsetDeg,
86 lon: selfPosition.lon + lonOffsetDeg,
87 };
e790982a 2026-03-16 Pavel Safronov88 };
89
b4e7f682 2026-03-16 Pavel Safronov90 const ensureFriendOrder = (friendsToRender: FriendModel[]) => {
91 const ids = friendsToRender.map((friend: FriendModel) => friend.id);
e790982a 2026-03-16 Pavel Safronov92 const idSet = new Set(ids);
b4e7f682 2026-03-16 Pavel Safronov93 const preserved = friendOrder.filter((id: string) => idSet.has(id));
e790982a 2026-03-16 Pavel Safronov94 const preservedSet = new Set(preserved);
b4e7f682 2026-03-16 Pavel Safronov95 const missing = ids.filter((id: string) => !preservedSet.has(id));
e790982a 2026-03-16 Pavel Safronov96
b4e7f682 2026-03-16 Pavel Safronov97 missing.sort((a: string, b: string) => {
e790982a 2026-03-16 Pavel Safronov98 const rankDelta = getFriendDefaultRank(a) - getFriendDefaultRank(b);
99 if (rankDelta !== 0) return rankDelta;
100 return a.localeCompare(b);
101 });
102
103 const nextOrder = [...preserved, ...missing];
104 const changed =
105 nextOrder.length !== friendOrder.length ||
b4e7f682 2026-03-16 Pavel Safronov106 nextOrder.some((id: string, index: number) => id !== friendOrder[index]);
e790982a 2026-03-16 Pavel Safronov107
108 if (changed) {
109 friendOrder = nextOrder;
110 saveFriendOrder();
111 }
112
113 return friendOrder;
114 };
115
b4e7f682 2026-03-16 Pavel Safronov116 const sortFriendsForDisplay = (friendsToRender: FriendModel[]) => {
e790982a 2026-03-16 Pavel Safronov117 const order = ensureFriendOrder(friendsToRender);
b4e7f682 2026-03-16 Pavel Safronov118 const orderIndex = new Map<string, number>(order.map((id: string, index: number) => [id, index]));
e790982a 2026-03-16 Pavel Safronov119
120 return [...friendsToRender].sort((a, b) => {
121 const aIndex = orderIndex.get(a.id);
122 const bIndex = orderIndex.get(b.id);
b4e7f682 2026-03-16 Pavel Safronov123 return (aIndex ?? Number.MAX_SAFE_INTEGER) - (bIndex ?? Number.MAX_SAFE_INTEGER);
e790982a 2026-03-16 Pavel Safronov124 });
125 };
126
b4e7f682 2026-03-16 Pavel Safronov127 let draggedFriendId: string | null = null;
128 let draggedPointerId: number | null = null;
129 let dropTargetFriendId: string | null = null;
e790982a 2026-03-16 Pavel Safronov130 let dropAfterTarget = false;
b4e7f682 2026-03-16 Pavel Safronov131 let lastFriendsToRender: FriendModel[] = [];
e790982a 2026-03-16 Pavel Safronov132
133 const clearFriendDragVisuals = () => {
134 if (!friendsPanel) return;
135
136 friendsPanel.querySelectorAll('.friend-row').forEach((row) => {
137 row.classList.remove('is-dragging', 'drag-over-before', 'drag-over-after');
138 });
139 };
140
b4e7f682 2026-03-16 Pavel Safronov141 const moveFriendToIndex = (friendId: string, targetIndex: number) => {
e790982a 2026-03-16 Pavel Safronov142 const fromIndex = friendOrder.indexOf(friendId);
143 if (fromIndex < 0) return false;
144
145 const boundedIndex = Math.max(0, Math.min(targetIndex, friendOrder.length - 1));
146 if (fromIndex === boundedIndex) return false;
147
148 friendOrder.splice(fromIndex, 1);
149 friendOrder.splice(boundedIndex, 0, friendId);
150 saveFriendOrder();
151 return true;
152 };
153
154 const endFriendDrag = () => {
155 draggedFriendId = null;
156 draggedPointerId = null;
157 dropTargetFriendId = null;
158 dropAfterTarget = false;
159 clearFriendDragVisuals();
160 };
161
162 const getInitialFriendsPanelVisible = () => {
163 const saved = localStorage.getItem(PARTICIPANTS_PANEL_VISIBLE_STORAGE_KEY);
164 if (saved === 'true' || saved === 'false') {
165 return saved === 'true';
166 }
167
168 return !window.matchMedia('(max-width: 720px)').matches;
169 };
170
171 let friendsPanelVisible = getInitialFriendsPanelVisible();
172 const applyFriendsPanelVisibility = () => {
173 if (friendsPanel) {
174 friendsPanel.classList.toggle('is-hidden', !friendsPanelVisible);
175 }
176 if (friendsToggle) {
177 friendsToggle.textContent = friendsPanelVisible ? 'Hide Friends' : 'Show Friends';
178 friendsToggle.setAttribute('aria-expanded', String(friendsPanelVisible));
179 }
180
181 localStorage.setItem(PARTICIPANTS_PANEL_VISIBLE_STORAGE_KEY, String(friendsPanelVisible));
182 };
183
184 if (friendsToggle) {
185 friendsToggle.addEventListener('click', () => {
186 friendsPanelVisible = !friendsPanelVisible;
187 applyFriendsPanelVisibility();
188 });
189 }
190
191 document.addEventListener('pointerdown', (event) => {
192 if (!friendsPanelVisible) return;
193
c5543782 2026-03-16 Pavel Safronov194 if (document.body.classList.contains('dark')) return;
195
e790982a 2026-03-16 Pavel Safronov196 const target = event.target;
197 if (!(target instanceof Node)) return;
198
199 const clickedInsidePanel = friendsPanel ? friendsPanel.contains(target) : false;
200 const clickedToggle = friendsToggle ? friendsToggle.contains(target) : false;
c5543782 2026-03-16 Pavel Safronov201 const darkModeToggle = document.getElementById('dark-mode-toggle');
202 const clickedDarkModeToggle = darkModeToggle ? darkModeToggle.contains(target) : false;
e790982a 2026-03-16 Pavel Safronov203
c5543782 2026-03-16 Pavel Safronov204 if (clickedInsidePanel || clickedToggle || clickedDarkModeToggle) return;
e790982a 2026-03-16 Pavel Safronov205
206 friendsPanelVisible = false;
207 applyFriendsPanelVisibility();
208 });
209
210 document.addEventListener('pointermove', (event) => {
211 if (!draggedFriendId || event.pointerId !== draggedPointerId) return;
212
213 event.preventDefault();
214
215 const pointed = document.elementFromPoint(event.clientX, event.clientY);
216 if (!(pointed instanceof Element) || !friendsPanel) {
217 dropTargetFriendId = null;
218 dropAfterTarget = false;
219 clearFriendDragVisuals();
220 return;
221 }
222
223 const targetRow = pointed.closest('.friend-row');
224 clearFriendDragVisuals();
225
226 const draggedRow = friendsPanel.querySelector(`[data-friend-id="${draggedFriendId}"]`);
227 if (draggedRow) {
228 draggedRow.classList.add('is-dragging');
229 }
230
231 if (!(targetRow instanceof HTMLElement) || !friendsPanel.contains(targetRow)) {
232 dropTargetFriendId = null;
233 dropAfterTarget = false;
234 return;
235 }
236
237 const targetId = targetRow.dataset.friendId;
238 if (!targetId || targetId === draggedFriendId) {
239 dropTargetFriendId = null;
240 dropAfterTarget = false;
241 return;
242 }
243
244 const rect = targetRow.getBoundingClientRect();
245 const after = event.clientY > rect.top + rect.height / 2;
246
247 dropTargetFriendId = targetId;
248 dropAfterTarget = after;
249 targetRow.classList.add(after ? 'drag-over-after' : 'drag-over-before');
250 });
251
252 document.addEventListener('pointerup', (event) => {
253 if (!draggedFriendId || event.pointerId !== draggedPointerId) return;
254
255 const draggedId = draggedFriendId;
256 const targetId = dropTargetFriendId;
257 const insertAfter = dropAfterTarget;
258 endFriendDrag();
259
260 if (!targetId) return;
261
262 const targetIndex = friendOrder.indexOf(targetId);
263 if (targetIndex < 0) return;
264
265 let nextIndex = insertAfter ? targetIndex + 1 : targetIndex;
266 const currentIndex = friendOrder.indexOf(draggedId);
267 if (currentIndex >= 0 && currentIndex < nextIndex) {
268 nextIndex -= 1;
269 }
270
271 if (!moveFriendToIndex(draggedId, nextIndex)) return;
272
273 renderFriendControls(lastFriendsToRender);
274 requestRender();
275 });
276
277 document.addEventListener('pointercancel', () => {
278 if (!draggedFriendId) return;
279 endFriendDrag();
280 });
281
282 applyFriendsPanelVisibility();
283
b4e7f682 2026-03-16 Pavel Safronov284 const isFriendVisible = (friendId: string) => friendVisibility[friendId] !== false;
285 const setFriendVisibility = (friendId: string, visible: boolean) => {
e790982a 2026-03-16 Pavel Safronov286 friendVisibility[friendId] = visible;
287 localStorage.setItem(PARTICIPANT_VISIBILITY_STORAGE_KEY, JSON.stringify(friendVisibility));
288 };
289
b4e7f682 2026-03-16 Pavel Safronov290 const getFriendsToRender = (friends: FriendModel[]) => {
291 const friendsToRender = friends.filter((p: FriendModel) => p.id !== selfFriend.id);
e790982a 2026-03-16 Pavel Safronov292
293 if (friendsToRender.findIndex((p) => p.id === northMarkerId) === -1) {
294 friendsToRender.push({
295 id: northMarkerId,
296 name: 'North',
297 color: '#000000',
298 position: {
299 lat: selfPosition.lat + 0.001,
300 lon: selfPosition.lon
301 },
302 timestamp: Date.now()
303 });
304 }
305
efc31a14 2026-03-16 Pavel Safronov306 if (guardianAngelEnabled && friendsToRender.findIndex((p) => p.id === guardianAngelId) === -1) {
307 const guardianPosition = getGuardianAngelPosition(Date.now());
308 friendsToRender.push({
309 id: guardianAngelId,
310 name: 'Guardian Angel',
311 color: '#f59e0b',
312 position: guardianPosition,
313 timestamp: Date.now()
314 });
315 }
316
e790982a 2026-03-16 Pavel Safronov317 return sortFriendsForDisplay(friendsToRender);
318 };
319
b4e7f682 2026-03-16 Pavel Safronov320 const renderFriendControls = (friendsToRender: FriendModel[]) => {
c5543782 2026-03-16 Pavel Safronov321 const orderedFriends = sortFriendsForDisplay(friendsToRender);
322 lastFriendsToRender = orderedFriends;
e790982a 2026-03-16 Pavel Safronov323 if (!friendsPanel) return;
324
325 friendsPanel.textContent = '';
326 const heading = document.createElement('h3');
327 heading.textContent = 'Friends';
328 friendsPanel.appendChild(heading);
329
c5543782 2026-03-16 Pavel Safronov330 for (const friend of orderedFriends) {
e790982a 2026-03-16 Pavel Safronov331 const row = document.createElement('div');
332 row.className = 'friend-row';
333 row.dataset.friendId = friend.id;
334
335 const checkbox = document.createElement('input');
336 checkbox.type = 'checkbox';
337 checkbox.checked = isFriendVisible(friend.id);
338 checkbox.addEventListener('change', () => {
339 setFriendVisibility(friend.id, checkbox.checked);
340 requestRender();
341 });
342
343 const nameWrapper = document.createElement('div');
344 nameWrapper.className = 'friend-name-badge';
345 nameWrapper.style.backgroundColor = friend.color;
346 nameWrapper.style.color = getReadableTextColor(friend.color);
347
e4870993 2026-03-16 Pavel Safronov348 const isNorth = friend.id === northMarkerId;
efc31a14 2026-03-16 Pavel Safronov349 const isGuardianAngel = guardianAngelEnabled && friend.id === guardianAngelId;
350 const isSpecialMarker = isNorth || isGuardianAngel;
e4870993 2026-03-16 Pavel Safronov351
efc31a14 2026-03-16 Pavel Safronov352 if (!isSpecialMarker) {
e4870993 2026-03-16 Pavel Safronov353 const presenceStatus = document.createElement('span');
354 presenceStatus.className = 'friend-presence-status';
355 presenceStatus.dataset.friendId = friend.id;
356 const initialPresence = getFriendPresenceState(friend, Date.now(), presenceThresholds);
32796e53 2026-03-16 Pavel Safronov357 presenceStatus.textContent = presenceEmoji[initialPresence] ?? PUBLIC_SETTINGS.ui.emojis.presence.recentlyOffline;
e4870993 2026-03-16 Pavel Safronov358 presenceStatus.title = initialPresence === 'online' ? 'Online'
359 : initialPresence === 'recent-offline' ? 'Recently seen'
360 : 'Offline';
361 nameWrapper.appendChild(presenceStatus);
362 }
363
e790982a 2026-03-16 Pavel Safronov364 const nameText = document.createElement('span');
365 nameText.className = 'friend-name';
366 nameText.textContent = friend.name;
367 nameWrapper.appendChild(nameText);
368
369 row.appendChild(checkbox);
370
371 const dragHandle = document.createElement('button');
372 dragHandle.type = 'button';
373 dragHandle.className = 'friend-drag-handle';
32796e53 2026-03-16 Pavel Safronov374 dragHandle.textContent = PUBLIC_SETTINGS.ui.emojis.dragHandle;
e790982a 2026-03-16 Pavel Safronov375 dragHandle.title = `Drag to reorder ${friend.name}`;
376 dragHandle.setAttribute('aria-label', `Drag to reorder ${friend.name}`);
b4e7f682 2026-03-16 Pavel Safronov377 dragHandle.addEventListener('pointerdown', (event: PointerEvent) => {
e790982a 2026-03-16 Pavel Safronov378 if (event.button !== undefined && event.button !== 0) return;
379
380 event.preventDefault();
381 draggedFriendId = friend.id;
382 draggedPointerId = event.pointerId;
383 dropTargetFriendId = null;
384 dropAfterTarget = false;
385
386 clearFriendDragVisuals();
387 row.classList.add('is-dragging');
388 });
389
390 row.appendChild(dragHandle);
391 row.appendChild(nameWrapper);
392 friendsPanel.appendChild(row);
393 }
394 };
395
396 return {
397 isFriendVisible,
398 getFriendsToRender,
e4870993 2026-03-16 Pavel Safronov399 renderFriendControls,
400 refreshPresenceInPanel: (now: number) => {
401 if (!friendsPanel) return;
402 friendsPanel.querySelectorAll<HTMLElement>('.friend-presence-status[data-friend-id]').forEach((el) => {
403 const friendId = el.dataset.friendId;
b4e7f682 2026-03-16 Pavel Safronov404 const friend = lastFriendsToRender.find((f: FriendModel) => f.id === friendId);
e4870993 2026-03-16 Pavel Safronov405 if (!friend) return;
406 const state = getFriendPresenceState(friend, now, presenceThresholds);
32796e53 2026-03-16 Pavel Safronov407 const emoji = presenceEmoji[state] ?? PUBLIC_SETTINGS.ui.emojis.presence.recentlyOffline;
e4870993 2026-03-16 Pavel Safronov408 if (el.textContent !== emoji) el.textContent = emoji;
409 const title = state === 'online' ? 'Online'
410 : state === 'recent-offline' ? 'Recently seen'
411 : 'Offline';
412 if (el.title !== title) el.title = title;
413 });
414 }
e790982a 2026-03-16 Pavel Safronov415 };
416};