From dc41e259b40d08fd9e543533192acf2c901fe609 Mon Sep 17 00:00:00 2001 From: Delightech28 Date: Sun, 28 Jun 2026 10:47:03 +0100 Subject: [PATCH 1/2] feat: debounced presence transition broadcasts (#218) --- apps/backend/src/index.ts | 37 +++++++++++-------- apps/backend/src/services/presence.ts | 25 ++++++++----- .../conversations/ConversationListSidebar.tsx | 5 ++- 3 files changed, 41 insertions(+), 26 deletions(-) diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index f8d60b7..211d1ff 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -11,7 +11,7 @@ import { registerMessagingHandlers } from './socket/messaging.js'; import { app } from './app.js'; import { redis as appRedis } from './lib/redis.js'; import { setSocketServer } from './lib/socket.js'; -import { setOnline, setOffline, refreshPresence } from './services/presence.js'; +import { setOnline, setOffline, refreshPresence, isOnline } from './services/presence.js'; import { buildRpcFetcher, buildTreasuryRpcFetcher, @@ -49,10 +49,12 @@ io.on('connection', async (socket: AuthSocket) => { } if (appRedis) { - await setOnline(appRedis, userId, socket.id); - for (const m of memberships) { - io.to(m.conversationId).emit('user_online', { userId }); - io.to(m.conversationId).emit('presence_update', { userId, online: true }); + const shouldBroadcast = await setOnline(appRedis, userId, socket.id); + if (shouldBroadcast) { + for (const m of memberships) { + io.to(m.conversationId).emit('user_online', { userId }); + io.to(m.conversationId).emit('presence_update', { userId, online: true, status: 'online', lastSeen: Date.now() }); + } } } @@ -67,16 +69,21 @@ io.on('connection', async (socket: AuthSocket) => { socket.on('disconnect', async () => { console.log('User disconnected:', userId); if (appRedis) { - const fullyOffline = await setOffline(appRedis, userId, socket.id); - if (fullyOffline) { - const memberships = await db.query.conversationMembers.findMany({ - where: eq(conversationMembers.userId, userId), - columns: { conversationId: true }, - }); - for (const m of memberships) { - io.to(m.conversationId).emit('user_offline', { userId }); - io.to(m.conversationId).emit('presence_update', { userId, online: false }); - } + const startDebounce = await setOffline(appRedis, userId, socket.id); + if (startDebounce) { + setTimeout(async () => { + const currentlyOnline = await isOnline(appRedis, userId); + if (!currentlyOnline) { + const memberships = await db.query.conversationMembers.findMany({ + where: eq(conversationMembers.userId, userId), + columns: { conversationId: true }, + }); + for (const m of memberships) { + io.to(m.conversationId).emit('user_offline', { userId }); + io.to(m.conversationId).emit('presence_update', { userId, online: false, status: 'offline', lastSeen: Date.now() }); + } + } + }, 3000); } } }); diff --git a/apps/backend/src/services/presence.ts b/apps/backend/src/services/presence.ts index ccda9cd..5c6f58a 100644 --- a/apps/backend/src/services/presence.ts +++ b/apps/backend/src/services/presence.ts @@ -18,14 +18,22 @@ function presenceKey(userId: string): string { return `presence:${userId}`; } -/** - * Register a socket connection for a user. Adds the socketId to the - * user's presence set and sets/refreshes the TTL. - */ -export async function setOnline(redis: Redis, userId: string, socketId: string): Promise { +export async function setOnline(redis: Redis, userId: string, socketId: string): Promise { const key = presenceKey(userId); + const debounceKey = `presence_debounce:${userId}`; + + const count = await redis.scard(key); await redis.sadd(key, socketId); await redis.expire(key, PRESENCE_TTL); + + if (count === 0) { + const debouncing = await redis.del(debounceKey); + if (debouncing === 1) { + return false; // Flap detected, don't broadcast online + } + return true; // First socket connected + } + return false; } /** @@ -39,16 +47,15 @@ export async function refreshPresence(redis: Redis, userId: string): Promise { const key = presenceKey(userId); + const debounceKey = `presence_debounce:${userId}`; + await redis.srem(key, socketId); const remaining = await redis.scard(key); if (remaining === 0) { await redis.del(key); + await redis.set(debounceKey, '1', 'EX', 3); return true; } return false; diff --git a/apps/web/src/components/conversations/ConversationListSidebar.tsx b/apps/web/src/components/conversations/ConversationListSidebar.tsx index a7dc39c..3e5d3fe 100644 --- a/apps/web/src/components/conversations/ConversationListSidebar.tsx +++ b/apps/web/src/components/conversations/ConversationListSidebar.tsx @@ -240,8 +240,9 @@ export function ConversationListSidebar() { handleOffline(data.userId); } - function onPresenceUpdate(data: { userId: string; online: boolean }) { - if (data.online) { + function onPresenceUpdate(data: { userId: string; online?: boolean; status?: 'online' | 'offline'; lastSeen?: number }) { + const isOnline = data.status ? data.status === 'online' : !!data.online; + if (isOnline) { handleOnline(data.userId); } else { handleOffline(data.userId); From cde1d11f90a8714e2036d3614e6b4c991ca0d9a3 Mon Sep 17 00:00:00 2001 From: Delightech28 Date: Sun, 28 Jun 2026 18:40:37 +0100 Subject: [PATCH 2/2] style: fix prettier line-length in presence_update emits --- apps/backend/src/index.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index 211d1ff..aae8a60 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -53,7 +53,12 @@ io.on('connection', async (socket: AuthSocket) => { if (shouldBroadcast) { for (const m of memberships) { io.to(m.conversationId).emit('user_online', { userId }); - io.to(m.conversationId).emit('presence_update', { userId, online: true, status: 'online', lastSeen: Date.now() }); + io.to(m.conversationId).emit('presence_update', { + userId, + online: true, + status: 'online', + lastSeen: Date.now(), + }); } } } @@ -80,7 +85,12 @@ io.on('connection', async (socket: AuthSocket) => { }); for (const m of memberships) { io.to(m.conversationId).emit('user_offline', { userId }); - io.to(m.conversationId).emit('presence_update', { userId, online: false, status: 'offline', lastSeen: Date.now() }); + io.to(m.conversationId).emit('presence_update', { + userId, + online: false, + status: 'offline', + lastSeen: Date.now(), + }); } } }, 3000);