diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index 34c433a..10246b5 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -12,6 +12,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, isOnline } from './services/presence.js'; import { cleanupStaleSockets, reconcileBoot, @@ -106,7 +107,6 @@ io.use(socketAuthMiddleware); io.on('connection', async (socket: AuthSocket) => { const userId = socket.auth!.userId; - const deviceId = socket.auth!.deviceId; console.log('User connected:', userId, socket.id); socket.data['userId'] = userId; @@ -173,12 +173,6 @@ io.on('connection', async (socket: AuthSocket) => { await socket.join(m.conversationId); } - const user = await db.query.users.findFirst({ - where: eq(users.id, userId), - columns: { presenceVisible: true }, - }); - const presenceVisible = user?.presenceVisible ?? true; - if (appRedis) { await registerPresenceSocket(appRedis, userId, deviceId, socket.id); await cleanupStaleSockets(io, appRedis, userId, socket.id); @@ -187,7 +181,12 @@ io.on('connection', async (socket: AuthSocket) => { if (becameOnline && presenceVisible) { for (const m of memberships) { io.to(m.conversationId).emit('user_online', { userId }); - io.to(m.conversationId).emit('presence_update', { userId, online: true }); + io.to(m.conversationId).emit('presence_update', { + userId, + online: true, + status: 'online', + lastSeen: Date.now(), + }); } await recordPresenceForCoMembers( userId, diff --git a/apps/backend/src/services/presence.ts b/apps/backend/src/services/presence.ts index 80ab847..6c712cd 100644 --- a/apps/backend/src/services/presence.ts +++ b/apps/backend/src/services/presence.ts @@ -103,6 +103,22 @@ export async function refreshPresenceSocket( await registerPresenceSocket(redis, userId, deviceId, socketId); } +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; /** * Remove a socket mapping. Returns true when that device has no remaining * tracked sockets, so callers may safely remove the device-level presence entry. diff --git a/apps/web/src/components/conversations/ConversationListSidebar.tsx b/apps/web/src/components/conversations/ConversationListSidebar.tsx index 46871c8..305d0a0 100644 --- a/apps/web/src/components/conversations/ConversationListSidebar.tsx +++ b/apps/web/src/components/conversations/ConversationListSidebar.tsx @@ -241,8 +241,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);