fuzzyworld

north-star / commit

add stale friend cleanup

55ab7a53997f32e9ef8de2ad5df969a3baa40f32 · Pavel Safronov · 2026-03-15 20:58:30 -0700 · parents e487099340fb

add stale friend cleanup
 .vscode/settings.json |  4 ++++
 database.rules.json   |  2 +-
 src/app.ts            | 34 ++++++++++++++++++++++++++++++----
 3 files changed, 35 insertions(+), 5 deletions(-)

diff --git a/.vscode/settings.json b/.vscode/settings.json
index 98fc7a3..fd93652 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -3,6 +3,10 @@
         "/^npx tsc --noEmit && npm run build$/": {
             "approve": true,
             "matchCommandLine": true
+        },
+        "/^npx tsc --noEmit && npm run build 2>&1 \\| tail -10$/": {
+            "approve": true,
+            "matchCommandLine": true
         }
     }
 }
\ No newline at end of file
diff --git a/database.rules.json b/database.rules.json
index 85c888d..099c54b 100644
--- a/database.rules.json
+++ b/database.rules.json
@@ -7,7 +7,7 @@
                 ".read": "auth != null && $roomId.matches(/^[a-z0-9]{8}$/)",
                 "friends": {
                     "$uid": {
-                        ".write": "auth != null && auth.uid === $uid",
+                        ".write": "auth != null && (auth.uid === $uid || (!newData.exists() && data.child('lastSeen').isNumber() && data.child('lastSeen').val() < now - 604800000))",
                         ".validate": "!newData.exists() || (newData.hasChildren(['encryptedState']) && newData.child('encryptedState').isString() && newData.child('encryptedState').val().length > 0 && !newData.child('id').exists() && !newData.child('name').exists() && !newData.child('color').exists() && !newData.child('position').exists() && !newData.child('timestamp').exists()) || (newData.hasChildren(['id','name','color','position','timestamp']) && newData.child('id').isString() && newData.child('id').val() === auth.uid && newData.child('name').isString() && newData.child('name').val().length > 0 && newData.child('name').val().length <= 32 && newData.child('color').isString() && newData.child('color').val().matches(/^#[0-9a-fA-F]{6}$/) && newData.child('position').hasChildren(['lat','lon']) && newData.child('position/lat').isNumber() && newData.child('position/lon').isNumber() && newData.child('timestamp').isNumber())"
                     }
                 }
diff --git a/src/app.ts b/src/app.ts
index 8fe447d..95253c6 100644
--- a/src/app.ts
+++ b/src/app.ts
@@ -1,4 +1,4 @@
-import { ref, set } from 'firebase/database';
+import { get, ref, remove, set } from 'firebase/database';
 import { getReadableTextColor, getArrowDisplayColor } from './utils';
 import { encryptJson } from './crypto';
 import { initDarkMode } from './darkmode';
@@ -37,6 +37,7 @@ 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 STALE_FRIEND_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
 const FRIEND_RECENTLY_OFFLINE_MS = (() => {
     const raw = Number(localStorage.getItem('friend-compass-recently-offline-ms'));
     if (Number.isFinite(raw) && raw > FRIEND_ONLINE_GRACE_MS) {
@@ -95,10 +96,11 @@ const friendRef = ref(db, `rooms/${roomId}/friends/${uid}`);
 let selfFriend;
 
 const syncSelfFriend = async () => {
-    selfFriend.timestamp = Date.now();
+    const now = Date.now();
+    selfFriend.timestamp = now;
     const dbRecord = roomKey
-        ? { encryptedState: await encryptJson(roomKey, selfFriend) }
-        : selfFriend;
+        ? { encryptedState: await encryptJson(roomKey, selfFriend), lastSeen: now }
+        : { ...selfFriend, lastSeen: now };
     try {
         await set(friendRef, dbRecord);
     } catch (err) {
@@ -106,6 +108,29 @@ const syncSelfFriend = async () => {
     }
 };
 
+const cleanupStaleFriends = async () => {
+    try {
+        const roomFriendsRef = ref(db, `rooms/${roomId}/friends`);
+        const snapshot = await get(roomFriendsRef);
+        if (!snapshot.exists()) return;
+        const now = Date.now();
+        const deletions: Promise<void>[] = [];
+        snapshot.forEach((child) => {
+            const val = child.val();
+            const lastSeen = Number(val?.lastSeen);
+            if (Number.isFinite(lastSeen) && now - lastSeen > STALE_FRIEND_TTL_MS) {
+                deletions.push(remove(child.ref));
+            }
+        });
+        if (deletions.length > 0) {
+            console.debug(`Removing ${deletions.length} stale friend record(s)`);
+            await Promise.all(deletions);
+        }
+    } catch (err) {
+        console.error('Error cleaning up stale friends', err);
+    }
+};
+
 let lastLocationSyncAt = 0;
 const maybeSyncLocation = () => {
     const now = Date.now();
@@ -209,4 +234,5 @@ const render = () => {
 };
 
 await syncSelfFriend();
+void cleanupStaleFriends();
 setInterval(render, RENDER_INTERVAL_MS);