fuzzyworld

north-star / commit

ttl for friends

e487099340fbfff85919f0056f9e1eacd375c00a · Pavel Safronov · 2026-03-15 20:47:33 -0700 · parents b7a75d8d9b4c

ttl for friends
 src/app.ts           | 27 ++++++++++++++++++++++----
 src/friends-panel.ts | 47 +++++++++++++++++++++++++++++++++++++++++++--
 src/renderer.ts      | 15 ++++++++++++---
 src/style.css        | 54 ++++++++++++++++++++++++++++++++++++++++++++++++++++
 src/utils.ts         | 20 +++++++++++++++++++
 5 files changed, 154 insertions(+), 9 deletions(-)

diff --git a/src/app.ts b/src/app.ts
index 516b007..8fe447d 100644
--- a/src/app.ts
+++ b/src/app.ts
@@ -1,4 +1,4 @@
-import { onDisconnect, ref, set } from 'firebase/database';
+import { ref, set } from 'firebase/database';
 import { getReadableTextColor, getArrowDisplayColor } from './utils';
 import { encryptJson } from './crypto';
 import { initDarkMode } from './darkmode';
@@ -35,6 +35,15 @@ document.head.appendChild(Object.assign(document.createElement('link'), { rel: '
 
 const RENDER_INTERVAL_MS = 100;
 const LOCATION_SYNC_INTERVAL_MS = 3000;
+const FRIEND_ONLINE_GRACE_MS = 20_000;
+const DEFAULT_FRIEND_RECENTLY_OFFLINE_MS = 60 * 60 * 1000;
+const FRIEND_RECENTLY_OFFLINE_MS = (() => {
+    const raw = Number(localStorage.getItem('friend-compass-recently-offline-ms'));
+    if (Number.isFinite(raw) && raw > FRIEND_ONLINE_GRACE_MS) {
+        return raw;
+    }
+    return DEFAULT_FRIEND_RECENTLY_OFFLINE_MS;
+})();
 const HEADING_CORRECTION_STORAGE_KEY = 'friend-compass-heading-correction-deg';
 const NORTH_MARKER_ID = 'north-marker';
 const GUARDIAN_ANGEL_ID = 'test';
@@ -80,7 +89,6 @@ const { roomId, roomKey } = await initSession({
 });
 
 const friendRef = ref(db, `rooms/${roomId}/friends/${uid}`);
-onDisconnect(friendRef).remove();
 
 // selfFriend is assigned below by initSelfProfile; syncSelfFriend is
 // only ever called after that point (from event handlers / intervals).
@@ -141,7 +149,11 @@ const friendsController = createFriendsPanelController({
     northMarkerId: NORTH_MARKER_ID,
     guardianAngelId: GUARDIAN_ANGEL_ID,
     selfFriend,
-    selfPosition
+    selfPosition,
+    presenceThresholds: {
+        onlineGraceMs: FRIEND_ONLINE_GRACE_MS,
+        recentlyOfflineMs: FRIEND_RECENTLY_OFFLINE_MS
+    }
 });
 
 navigator.geolocation.watchPosition(async (pos) => {
@@ -174,8 +186,11 @@ const { friends } = subscribeToFriends({
 });
 
 const render = () => {
+    const now = Date.now();
     const friendsToRender = friendsController.getFriendsToRender(friends);
 
+    friendsController.refreshPresenceInPanel(now);
+
     renderArrows({
         content,
         arrow,
@@ -183,7 +198,11 @@ const render = () => {
         selfFriend,
         selfHeading: compassController.getSelfHeading(),
         isFriendVisible: friendsController.isFriendVisible,
-        getArrowDisplayColor
+        getArrowDisplayColor,
+        presenceThresholds: {
+            onlineGraceMs: FRIEND_ONLINE_GRACE_MS,
+            recentlyOfflineMs: FRIEND_RECENTLY_OFFLINE_MS
+        }
     });
 
     logDebug(`Commit: ${isLocalCommit ? 'local' : `<a href="https://gitlab.com/PavelSafronov/north-star/-/commit/${GIT_COMMIT}" target="_blank">${GIT_COMMIT}</a>`} | Repo: <a href="https://gitlab.com/PavelSafronov/north-star" target="_blank">north-star</a>`);
diff --git a/src/friends-panel.ts b/src/friends-panel.ts
index 3082328..50daa4f 100644
--- a/src/friends-panel.ts
+++ b/src/friends-panel.ts
@@ -1,3 +1,6 @@
+import { getFriendPresenceState, PRESENCE_EMOJI } from './utils';
+import type { PresenceThresholds } from './utils';
+
 export const createFriendsPanelController = ({
     friendsPanel,
     friendsToggle,
@@ -6,7 +9,18 @@ export const createFriendsPanelController = ({
     northMarkerId,
     guardianAngelId,
     selfFriend,
-    selfPosition
+    selfPosition,
+    presenceThresholds
+}: {
+    friendsPanel: HTMLElement | null;
+    friendsToggle: HTMLElement | null;
+    requestRender: () => void;
+    getReadableTextColor: (color: string) => string;
+    northMarkerId: string;
+    guardianAngelId: string;
+    selfFriend: any;
+    selfPosition: { lat: number; lon: number };
+    presenceThresholds: PresenceThresholds;
 }) => {
     const PARTICIPANT_VISIBILITY_STORAGE_KEY = 'friend-compass-friend-visibility';
     const PARTICIPANTS_PANEL_VISIBLE_STORAGE_KEY = 'friend-compass-friends-panel-visible';
@@ -287,6 +301,20 @@ export const createFriendsPanelController = ({
             nameWrapper.style.backgroundColor = friend.color;
             nameWrapper.style.color = getReadableTextColor(friend.color);
 
+            const isNorth = friend.id === northMarkerId;
+
+            if (!isNorth) {
+                const presenceStatus = document.createElement('span');
+                presenceStatus.className = 'friend-presence-status';
+                presenceStatus.dataset.friendId = friend.id;
+                const initialPresence = getFriendPresenceState(friend, Date.now(), presenceThresholds);
+                presenceStatus.textContent = PRESENCE_EMOJI[initialPresence] ?? '🟡';
+                presenceStatus.title = initialPresence === 'online' ? 'Online'
+                    : initialPresence === 'recent-offline' ? 'Recently seen'
+                        : 'Offline';
+                nameWrapper.appendChild(presenceStatus);
+            }
+
             const nameText = document.createElement('span');
             nameText.className = 'friend-name';
             nameText.textContent = friend.name;
@@ -334,6 +362,21 @@ export const createFriendsPanelController = ({
     return {
         isFriendVisible,
         getFriendsToRender,
-        renderFriendControls
+        renderFriendControls,
+        refreshPresenceInPanel: (now: number) => {
+            if (!friendsPanel) return;
+            friendsPanel.querySelectorAll<HTMLElement>('.friend-presence-status[data-friend-id]').forEach((el) => {
+                const friendId = el.dataset.friendId;
+                const friend = lastFriendsToRender.find((f) => f.id === friendId);
+                if (!friend) return;
+                const state = getFriendPresenceState(friend, now, presenceThresholds);
+                const emoji = PRESENCE_EMOJI[state] ?? '🟡';
+                if (el.textContent !== emoji) el.textContent = emoji;
+                const title = state === 'online' ? 'Online'
+                    : state === 'recent-offline' ? 'Recently seen'
+                        : 'Offline';
+                if (el.title !== title) el.title = title;
+            });
+        }
     };
 };
diff --git a/src/renderer.ts b/src/renderer.ts
index c4565a1..df1b3b4 100644
--- a/src/renderer.ts
+++ b/src/renderer.ts
@@ -1,4 +1,5 @@
-import { getShortestDelta, normalizeAngle } from './utils';
+import { getShortestDelta, normalizeAngle, getFriendPresenceState, PRESENCE_EMOJI } from './utils';
+import type { PresenceThresholds } from './utils';
 
 type ArrowElement = HTMLElement & { currentAngle: number | null };
 
@@ -9,8 +10,10 @@ export const renderArrows = ({
     selfFriend,
     selfHeading,
     isFriendVisible,
-    getArrowDisplayColor
+    getArrowDisplayColor,
+    presenceThresholds
 }) => {
+    const now = Date.now();
     const activeArrowIds = new Set(
         friendsToRender
             .filter((target) => target.id !== selfFriend.id)
@@ -65,9 +68,15 @@ export const renderArrows = ({
         existingArrow.style.transform = `rotate(${currentArrowAngle}deg)`;
         existingArrow.style.color = getArrowDisplayColor(target.color);
 
+        const presenceState = getFriendPresenceState(target, now, presenceThresholds);
+        existingArrow.classList.toggle('is-online', presenceState === 'online');
+        existingArrow.classList.toggle('is-recent-offline', presenceState === 'recent-offline');
+        existingArrow.classList.toggle('is-stale-offline', presenceState === 'stale-offline');
+
         const arrowLabel = existingArrow.querySelector('.arrow-label') as HTMLElement | null;
         if (arrowLabel) {
-            arrowLabel.textContent = target.name;
+            const emoji = PRESENCE_EMOJI[presenceState];
+            arrowLabel.textContent = emoji ? `${emoji} ${target.name}` : target.name;
             arrowLabel.style.transform = `rotate(${-currentArrowAngle}deg)`;
         }
     }
diff --git a/src/style.css b/src/style.css
index f6540ac..e892b71 100644
--- a/src/style.css
+++ b/src/style.css
@@ -356,6 +356,12 @@ body.dark .dark-mode-toggle {
     text-overflow: ellipsis;
 }
 
+.friend-presence-status {
+    flex: 0 0 auto;
+    font-size: 13px;
+    line-height: 1;
+}
+
 .friend-map-link {
     font-size: 17px;
     line-height: 1;
@@ -404,12 +410,15 @@ body.dark .dark-mode-toggle {
     pointer-events: none;
     z-index: 1;
     color: #ffffff;
+    opacity: 1;
+    transition: opacity 200ms ease-out, filter 220ms ease-out;
 }
 
 .arrow-img {
     width: 100%;
     height: 100%;
     background-color: currentColor;
+    background-image: none;
     -webkit-mask-image: url('./image.png');
     mask-image: url('./image.png');
     -webkit-mask-size: contain;
@@ -419,6 +428,7 @@ body.dark .dark-mode-toggle {
     -webkit-mask-position: center;
     mask-position: center;
     filter: drop-shadow(0 0 8px rgba(0, 0, 0, 0.35));
+    transition: opacity 220ms ease-out, filter 260ms ease-out, background-image 260ms ease-out;
 }
 
 .arrow-label-anchor {
@@ -430,6 +440,42 @@ body.dark .dark-mode-toggle {
     height: 0;
 }
 
+.arrow.is-online {
+    opacity: 1;
+}
+
+.arrow.is-online .arrow-img {
+    opacity: 1;
+}
+
+.arrow.is-recent-offline {
+    opacity: 0.58;
+}
+
+.arrow.is-recent-offline .arrow-img {
+    opacity: 0.75;
+    filter: drop-shadow(0 0 4px rgba(0, 0, 0, 0.22));
+}
+
+.arrow.is-recent-offline .arrow-label {
+    opacity: 0.84;
+}
+
+.arrow.is-stale-offline {
+    opacity: 0.42;
+}
+
+.arrow.is-stale-offline .arrow-img {
+    opacity: 0.55;
+    background-color: transparent;
+    background-image: linear-gradient(to top, transparent 0%, transparent 28%, currentColor 68%, currentColor 100%);
+    filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.12));
+}
+
+.arrow.is-stale-offline .arrow-label {
+    opacity: 0.6;
+}
+
 .arrow-label {
     position: absolute;
     top: 0;
@@ -548,6 +594,14 @@ body.dark .arrow-img {
     filter: drop-shadow(0 0 2px #fff) drop-shadow(0 0 6px rgba(255, 255, 255, 0.5));
 }
 
+body.dark .arrow.is-recent-offline .arrow-img {
+    filter: drop-shadow(0 0 1px #fff) drop-shadow(0 0 3px rgba(255, 255, 255, 0.35));
+}
+
+body.dark .arrow.is-stale-offline .arrow-img {
+    filter: drop-shadow(0 0 1px rgba(255, 255, 255, 0.35));
+}
+
 body.dark .arrow-label {
     -webkit-text-stroke: 0.25px rgba(255, 255, 255, 0.9);
     text-shadow: 0 1px 4px rgba(0, 0, 0, 0.95);
diff --git a/src/utils.ts b/src/utils.ts
index 9425f6e..113281b 100644
--- a/src/utils.ts
+++ b/src/utils.ts
@@ -38,6 +38,26 @@ export const getArrowDisplayColor = (hexColor) => {
     return luminance < 0.2 ? '#ffffff' : hexColor;
 };
 
+export type PresenceThresholds = {
+    onlineGraceMs: number;
+    recentlyOfflineMs: number;
+};
+
+export const getFriendPresenceState = (friend, now: number, thresholds: PresenceThresholds): 'online' | 'recent-offline' | 'stale-offline' => {
+    const friendTimestamp = Number(friend.timestamp);
+    if (!Number.isFinite(friendTimestamp)) return 'stale-offline';
+    const ageMs = Math.max(0, now - friendTimestamp);
+    if (ageMs <= thresholds.onlineGraceMs) return 'online';
+    if (ageMs <= thresholds.recentlyOfflineMs) return 'recent-offline';
+    return 'stale-offline';
+};
+
+export const PRESENCE_EMOJI: Record<string, string> = {
+    online: '🟢',
+    'recent-offline': '🟡',
+    'stale-offline': '👻'
+};
+
 export const getDeviceHint = async () => {
     if (navigator.userAgentData) {
         try {