feat: Add public room buddy list polling for real-time user status updates
- commit
- 3d7791535e96e369f223b75d0a3eb0d72b9e6a66
- author
- Kit · · 2025-02-17 10:57 UTC
- parent
- ad216c7
- signature
- SSH Unverified
SHA256:H91DFuEG4jpk5edzLA4aUKkpv4zv9Q339ipRmkph6TkThe key is unknown
2 files · +96 -50
M src/lib/components/chat-room.svelte +30 -0
| @@ -30,6 +30,9 @@ let cooldownProgress = $state(0); | |||
| 30 | 30 | let cooldownInterval: ReturnType<typeof setInterval> | null = null; | |
| 31 | 31 | let rateLimitWarning = $state<string | null>(null); | |
| 32 | 32 | ||
| 33 | + | // At the top along with other variable declarations, add: | |
| 34 | + | let roomPollInterval: ReturnType<typeof setInterval> | null = null; | |
| 35 | + | ||
| 33 | 36 | function updateCooldownProgress() { | |
| 34 | 37 | if (!cooldownEndTime) return; | |
| 35 | 38 | ||
| @@ -193,9 +196,36 @@ onMount(() => { | |||
| 193 | 196 | // Add resize listener | |
| 194 | 197 | window.addEventListener('resize', handleResize); | |
| 195 | 198 | ||
| 199 | + | // ---- New: Polling for public buddy list updates ---- | |
| 200 | + | if (!currentUser) { | |
| 201 | + | const fetchPublicRoom = async () => { | |
| 202 | + | const defaultRoomId = chatState.getDefaultChatRoomId(); | |
| 203 | + | try { | |
| 204 | + | const response = await fetch(`/api/rooms/${defaultRoomId}?public=true`); | |
| 205 | + | if (response.ok) { | |
| 206 | + | const roomData = await response.json(); | |
| 207 | + | if (roomData.success && roomData.buddyList) { | |
| 208 | + | // Update both chat state and local onlineUsers list | |
| 209 | + | chatState.updateOnlineUsers(roomData.buddyList); | |
| 210 | + | onlineUsers = roomData.buddyList; | |
| 211 | + | console.debug('Public polling updated buddy list:', roomData.buddyList); | |
| 212 | + | } | |
| 213 | + | } | |
| 214 | + | } catch (error) { | |
| 215 | + | console.error('Error fetching public buddy list:', error); | |
| 216 | + | } | |
| 217 | + | }; | |
| 218 | + | ||
| 219 | + | // Initial fetch then poll every 30 seconds | |
| 220 | + | fetchPublicRoom(); | |
| 221 | + | roomPollInterval = setInterval(fetchPublicRoom, 30000); | |
| 222 | + | } | |
| 223 | + | // ------------------------------------------------------- | |
| 224 | + | ||
| 196 | 225 | return () => { | |
| 197 | 226 | window.removeEventListener('resize', handleResize); | |
| 198 | 227 | if (cooldownInterval) clearInterval(cooldownInterval); | |
| 228 | + | if (roomPollInterval) clearInterval(roomPollInterval); | |
| 199 | 229 | }; | |
| 200 | 230 | }); | |
| 201 | 231 | ||
| @@ -30,6 +30,9 @@ let cooldownProgress = $state(0); | |||||
| 30 | let cooldownInterval: ReturnType<typeof setInterval> | null = null; | 30 | let cooldownInterval: ReturnType<typeof setInterval> | null = null; | ||
| 31 | let rateLimitWarning = $state<string | null>(null); | 31 | let rateLimitWarning = $state<string | null>(null); | ||
| 32 | 32 | ||||
| 33 | + | // At the top along with other variable declarations, add: | |||
| 34 | + | let roomPollInterval: ReturnType<typeof setInterval> | null = null; | |||
| 35 | + | ||||
| 33 | function updateCooldownProgress() { | 36 | function updateCooldownProgress() { | ||
| 34 | if (!cooldownEndTime) return; | 37 | if (!cooldownEndTime) return; | ||
| 35 | 38 | ||||
| @@ -193,9 +196,36 @@ onMount(() => { | |||||
| 193 | // Add resize listener | 196 | // Add resize listener | ||
| 194 | window.addEventListener('resize', handleResize); | 197 | window.addEventListener('resize', handleResize); | ||
| 195 | 198 | ||||
| 199 | + | // ---- New: Polling for public buddy list updates ---- | |||
| 200 | + | if (!currentUser) { | |||
| 201 | + | const fetchPublicRoom = async () => { | |||
| 202 | + | const defaultRoomId = chatState.getDefaultChatRoomId(); | |||
| 203 | + | try { | |||
| 204 | + | const response = await fetch(`/api/rooms/${defaultRoomId}?public=true`); | |||
| 205 | + | if (response.ok) { | |||
| 206 | + | const roomData = await response.json(); | |||
| 207 | + | if (roomData.success && roomData.buddyList) { | |||
| 208 | + | // Update both chat state and local onlineUsers list | |||
| 209 | + | chatState.updateOnlineUsers(roomData.buddyList); | |||
| 210 | + | onlineUsers = roomData.buddyList; | |||
| 211 | + | console.debug('Public polling updated buddy list:', roomData.buddyList); | |||
| 212 | + | } | |||
| 213 | + | } | |||
| 214 | + | } catch (error) { | |||
| 215 | + | console.error('Error fetching public buddy list:', error); | |||
| 216 | + | } | |||
| 217 | + | }; | |||
| 218 | + | ||||
| 219 | + | // Initial fetch then poll every 30 seconds | |||
| 220 | + | fetchPublicRoom(); | |||
| 221 | + | roomPollInterval = setInterval(fetchPublicRoom, 30000); | |||
| 222 | + | } | |||
| 223 | + | // ------------------------------------------------------- | |||
| 224 | + | ||||
| 196 | return () => { | 225 | return () => { | ||
| 197 | window.removeEventListener('resize', handleResize); | 226 | window.removeEventListener('resize', handleResize); | ||
| 198 | if (cooldownInterval) clearInterval(cooldownInterval); | 227 | if (cooldownInterval) clearInterval(cooldownInterval); | ||
| 228 | + | if (roomPollInterval) clearInterval(roomPollInterval); | |||
| 199 | }; | 229 | }; | ||
| 200 | }); | 230 | }); | ||
| 201 | 231 | ||||
M src/routes/api/status/+server.ts +66 -50
| @@ -1,71 +1,87 @@ | |||
| 1 | - | import { error } from '@sveltejs/kit'; | |
| 2 | 1 | import db from '$lib/db/db.server'; | |
| 3 | 2 | import { users } from '$lib/db/schema'; | |
| 4 | - | import { eq } from 'drizzle-orm'; | |
| 3 | + | import { eq } from 'drizzle-orm/sql'; | |
| 5 | 4 | import { validateSessionToken, generateSessionToken, createSession } from '$lib/api/session.server'; | |
| 6 | 5 | import { setSessionTokenCookie } from '$lib/api/session.cookie'; | |
| 7 | 6 | import { createSafeUser } from '$lib/types/chat'; | |
| 8 | 7 | import { createLogger } from '$lib/utils/logger.server'; | |
| 8 | + | import type { RequestHandler } from './$types'; | |
| 9 | 9 | ||
| 10 | 10 | const log = createLogger('status-server'); | |
| 11 | 11 | ||
| 12 | - | export async function POST({ request, cookies, locals }) { | |
| 12 | + | export const POST: RequestHandler = async ({ request, cookies, locals }) => { | |
| 13 | 13 | if (!locals.user) { | |
| 14 | 14 | log.warn('Authentication required'); | |
| 15 | - | throw error(401, 'Authentication required'); | |
| 15 | + | return new Response(JSON.stringify({ success: false, error: 'Unauthorized' }), { | |
| 16 | + | status: 401, | |
| 17 | + | headers: { 'Content-Type': 'application/json' } | |
| 18 | + | }); | |
| 16 | 19 | } | |
| 17 | 20 | ||
| 18 | - | const { status } = await request.json(); | |
| 19 | - | const validStatuses = ['online', 'away', 'busy', 'offline']; | |
| 20 | - | if (!validStatuses.includes(status)) { | |
| 21 | - | log.warn('Invalid status received', { status }); | |
| 22 | - | throw error(400, 'Invalid status'); | |
| 23 | - | } | |
| 21 | + | try { | |
| 22 | + | const { status } = await request.json(); | |
| 23 | + | const validStatuses = ['online', 'away', 'busy', 'offline']; | |
| 24 | + | if (!validStatuses.includes(status)) { | |
| 25 | + | log.warn('Invalid status received', { status }); | |
| 26 | + | return new Response(JSON.stringify({ success: false, error: 'Invalid status' }), { | |
| 27 | + | status: 400, | |
| 28 | + | headers: { 'Content-Type': 'application/json' } | |
| 29 | + | }); | |
| 30 | + | } | |
| 24 | 31 | ||
| 25 | - | const userId = locals.user.id; | |
| 26 | - | const maskedUserId = `${userId.slice(0, 4)}...${userId.slice(-4)}`; | |
| 27 | - | log.debug('Updating user status', { userId: maskedUserId, status }); | |
| 32 | + | const userId = locals.user.id; | |
| 33 | + | const maskedUserId = `${userId.slice(0, 4)}...${userId.slice(-4)}`; | |
| 34 | + | log.debug('Updating user status', { userId: maskedUserId, status }); | |
| 28 | 35 | ||
| 29 | - | const now = Date.now(); | |
| 36 | + | const now = Date.now(); | |
| 30 | 37 | ||
| 31 | - | // Update the user status in the database | |
| 32 | - | // Always update lastSeen to support timeout detection | |
| 33 | - | const updatedUser = await db.update(users) | |
| 34 | - | .set({ | |
| 35 | - | status, | |
| 36 | - | lastSeen: now // Always update lastSeen for proper timeout detection | |
| 37 | - | }) | |
| 38 | - | .where(eq(users.id, userId)) | |
| 39 | - | .returning() | |
| 40 | - | .get(); | |
| 41 | - | log.debug('Database updated successfully', { userId: maskedUserId, status }); | |
| 38 | + | // Update the user status in the database | |
| 39 | + | // Always update lastSeen to support timeout detection | |
| 40 | + | const updatedUser = await db.update(users) | |
| 41 | + | .set({ | |
| 42 | + | status, | |
| 43 | + | lastSeen: now // Always update lastSeen for proper timeout detection | |
| 44 | + | }) | |
| 45 | + | .where(eq(users.id, userId)) | |
| 46 | + | .returning() | |
| 47 | + | .get(); | |
| 48 | + | log.debug('Database updated successfully', { userId: maskedUserId, status }); | |
| 42 | 49 | ||
| 43 | - | // Renew session if status is online and session is near expiry | |
| 44 | - | if (status === 'online') { | |
| 45 | - | const token = cookies.get('session'); | |
| 46 | - | if (token) { | |
| 47 | - | const result = await validateSessionToken(token); | |
| 48 | - | if (result.session) { | |
| 49 | - | const remaining = result.session.expiresAt - now; | |
| 50 | - | const threshold = 15 * 60 * 1000; // 15 minutes in milliseconds | |
| 51 | - | if (remaining < threshold) { | |
| 52 | - | const newToken = generateSessionToken(); | |
| 53 | - | const newSession = await createSession(newToken, userId); | |
| 54 | - | setSessionTokenCookie({ cookies }, newToken, newSession.expiresAt); | |
| 55 | - | log.info('Session renewed', { | |
| 56 | - | userId: maskedUserId, | |
| 57 | - | expiresAt: new Date(newSession.expiresAt).toISOString() | |
| 58 | - | }); | |
| 50 | + | // Renew session if status is online and session is near expiry | |
| 51 | + | if (status === 'online') { | |
| 52 | + | const token = cookies.get('session'); | |
| 53 | + | if (token) { | |
| 54 | + | const result = await validateSessionToken(token); | |
| 55 | + | if (result.session) { | |
| 56 | + | const remaining = result.session.expiresAt - now; | |
| 57 | + | const threshold = 15 * 60 * 1000; // 15 minutes in milliseconds | |
| 58 | + | if (remaining < threshold) { | |
| 59 | + | const newToken = generateSessionToken(); | |
| 60 | + | const newSession = await createSession(newToken, userId); | |
| 61 | + | setSessionTokenCookie({ cookies }, newToken, newSession.expiresAt); | |
| 62 | + | log.info('Session renewed', { | |
| 63 | + | userId: maskedUserId, | |
| 64 | + | expiresAt: new Date(newSession.expiresAt).toISOString() | |
| 65 | + | }); | |
| 66 | + | } | |
| 59 | 67 | } | |
| 60 | 68 | } | |
| 61 | 69 | } | |
| 62 | - | } | |
| 63 | 70 | ||
| 64 | - | return new Response(JSON.stringify({ | |
| 65 | - | success: true, | |
| 66 | - | user: createSafeUser(updatedUser) | |
| 67 | - | }), { | |
| 68 | - | status: 200, | |
| 69 | - | headers: { 'Content-Type': 'application/json' } | |
| 70 | - | }); | |
| 71 | - | }⏎̸ | |
| 71 | + | return new Response(JSON.stringify({ | |
| 72 | + | success: true, | |
| 73 | + | user: createSafeUser(updatedUser) | |
| 74 | + | }), { | |
| 75 | + | status: 200, | |
| 76 | + | headers: { 'Content-Type': 'application/json' } | |
| 77 | + | }); | |
| 78 | + | } catch (error) { | |
| 79 | + | return new Response( | |
| 80 | + | JSON.stringify({ success: false, error: error instanceof Error ? error.message : 'Unknown error' }), | |
| 81 | + | { | |
| 82 | + | status: 500, | |
| 83 | + | headers: { 'Content-Type': 'application/json' } | |
| 84 | + | } | |
| 85 | + | ); | |
| 86 | + | } | |
| 87 | + | };⏎̸ | |
| @@ -1,71 +1,87 @@ | |||||
| 1 | - | import { error } from '@sveltejs/kit'; | |||
| 2 | import db from '$lib/db/db.server'; | 1 | import db from '$lib/db/db.server'; | ||
| 3 | import { users } from '$lib/db/schema'; | 2 | import { users } from '$lib/db/schema'; | ||
| 4 | - | import { eq } from 'drizzle-orm'; | 3 | + | import { eq } from 'drizzle-orm/sql'; |
| 5 | import { validateSessionToken, generateSessionToken, createSession } from '$lib/api/session.server'; | 4 | import { validateSessionToken, generateSessionToken, createSession } from '$lib/api/session.server'; | ||
| 6 | import { setSessionTokenCookie } from '$lib/api/session.cookie'; | 5 | import { setSessionTokenCookie } from '$lib/api/session.cookie'; | ||
| 7 | import { createSafeUser } from '$lib/types/chat'; | 6 | import { createSafeUser } from '$lib/types/chat'; | ||
| 8 | import { createLogger } from '$lib/utils/logger.server'; | 7 | import { createLogger } from '$lib/utils/logger.server'; | ||
| 8 | + | import type { RequestHandler } from './$types'; | |||
| 9 | 9 | ||||
| 10 | const log = createLogger('status-server'); | 10 | const log = createLogger('status-server'); | ||
| 11 | 11 | ||||
| 12 | - | export async function POST({ request, cookies, locals }) { | 12 | + | export const POST: RequestHandler = async ({ request, cookies, locals }) => { |
| 13 | if (!locals.user) { | 13 | if (!locals.user) { | ||
| 14 | log.warn('Authentication required'); | 14 | log.warn('Authentication required'); | ||
| 15 | - | throw error(401, 'Authentication required'); | 15 | + | return new Response(JSON.stringify({ success: false, error: 'Unauthorized' }), { |
| 16 | + | status: 401, | |||
| 17 | + | headers: { 'Content-Type': 'application/json' } | |||
| 18 | + | }); | |||
| 16 | } | 19 | } | ||
| 17 | 20 | ||||
| 18 | - | const { status } = await request.json(); | 21 | + | try { |
| 19 | - | const validStatuses = ['online', 'away', 'busy', 'offline']; | 22 | + | const { status } = await request.json(); |
| 20 | - | if (!validStatuses.includes(status)) { | 23 | + | const validStatuses = ['online', 'away', 'busy', 'offline']; |
| 21 | - | log.warn('Invalid status received', { status }); | 24 | + | if (!validStatuses.includes(status)) { |
| 22 | - | throw error(400, 'Invalid status'); | 25 | + | log.warn('Invalid status received', { status }); |
| 23 | - | } | 26 | + | return new Response(JSON.stringify({ success: false, error: 'Invalid status' }), { |
| 27 | + | status: 400, | |||
| 28 | + | headers: { 'Content-Type': 'application/json' } | |||
| 29 | + | }); | |||
| 30 | + | } | |||
| 24 | 31 | ||||
| 25 | - | const userId = locals.user.id; | 32 | + | const userId = locals.user.id; |
| 26 | - | const maskedUserId = `${userId.slice(0, 4)}...${userId.slice(-4)}`; | 33 | + | const maskedUserId = `${userId.slice(0, 4)}...${userId.slice(-4)}`; |
| 27 | - | log.debug('Updating user status', { userId: maskedUserId, status }); | 34 | + | log.debug('Updating user status', { userId: maskedUserId, status }); |
| 28 | 35 | ||||
| 29 | - | const now = Date.now(); | 36 | + | const now = Date.now(); |
| 30 | 37 | ||||
| 31 | - | // Update the user status in the database | 38 | + | // Update the user status in the database |
| 32 | - | // Always update lastSeen to support timeout detection | 39 | + | // Always update lastSeen to support timeout detection |
| 33 | - | const updatedUser = await db.update(users) | 40 | + | const updatedUser = await db.update(users) |
| 34 | - | .set({ | 41 | + | .set({ |
| 35 | - | status, | 42 | + | status, |
| 36 | - | lastSeen: now // Always update lastSeen for proper timeout detection | 43 | + | lastSeen: now // Always update lastSeen for proper timeout detection |
| 37 | - | }) | 44 | + | }) |
| 38 | - | .where(eq(users.id, userId)) | 45 | + | .where(eq(users.id, userId)) |
| 39 | - | .returning() | 46 | + | .returning() |
| 40 | - | .get(); | 47 | + | .get(); |
| 41 | - | log.debug('Database updated successfully', { userId: maskedUserId, status }); | 48 | + | log.debug('Database updated successfully', { userId: maskedUserId, status }); |
| 42 | 49 | ||||
| 43 | - | // Renew session if status is online and session is near expiry | 50 | + | // Renew session if status is online and session is near expiry |
| 44 | - | if (status === 'online') { | 51 | + | if (status === 'online') { |
| 45 | - | const token = cookies.get('session'); | 52 | + | const token = cookies.get('session'); |
| 46 | - | if (token) { | 53 | + | if (token) { |
| 47 | - | const result = await validateSessionToken(token); | 54 | + | const result = await validateSessionToken(token); |
| 48 | - | if (result.session) { | 55 | + | if (result.session) { |
| 49 | - | const remaining = result.session.expiresAt - now; | 56 | + | const remaining = result.session.expiresAt - now; |
| 50 | - | const threshold = 15 * 60 * 1000; // 15 minutes in milliseconds | 57 | + | const threshold = 15 * 60 * 1000; // 15 minutes in milliseconds |
| 51 | - | if (remaining < threshold) { | 58 | + | if (remaining < threshold) { |
| 52 | - | const newToken = generateSessionToken(); | 59 | + | const newToken = generateSessionToken(); |
| 53 | - | const newSession = await createSession(newToken, userId); | 60 | + | const newSession = await createSession(newToken, userId); |
| 54 | - | setSessionTokenCookie({ cookies }, newToken, newSession.expiresAt); | 61 | + | setSessionTokenCookie({ cookies }, newToken, newSession.expiresAt); |
| 55 | - | log.info('Session renewed', { | 62 | + | log.info('Session renewed', { |
| 56 | - | userId: maskedUserId, | 63 | + | userId: maskedUserId, |
| 57 | - | expiresAt: new Date(newSession.expiresAt).toISOString() | 64 | + | expiresAt: new Date(newSession.expiresAt).toISOString() |
| 58 | - | }); | 65 | + | }); |
| 66 | + | } | |||
| 59 | } | 67 | } | ||
| 60 | } | 68 | } | ||
| 61 | } | 69 | } | ||
| 62 | - | } | |||
| 63 | 70 | ||||
| 64 | - | return new Response(JSON.stringify({ | 71 | + | return new Response(JSON.stringify({ |
| 65 | - | success: true, | 72 | + | success: true, |
| 66 | - | user: createSafeUser(updatedUser) | 73 | + | user: createSafeUser(updatedUser) |
| 67 | - | }), { | 74 | + | }), { |
| 68 | - | status: 200, | 75 | + | status: 200, |
| 69 | - | headers: { 'Content-Type': 'application/json' } | 76 | + | headers: { 'Content-Type': 'application/json' } |
| 70 | - | }); | 77 | + | }); |
| 71 | - | }⏎̸ | 78 | + | } catch (error) { |
| 79 | + | return new Response( | |||
| 80 | + | JSON.stringify({ success: false, error: error instanceof Error ? error.message : 'Unknown error' }), | |||
| 81 | + | { | |||
| 82 | + | status: 500, | |||
| 83 | + | headers: { 'Content-Type': 'application/json' } | |||
| 84 | + | } | |||
| 85 | + | ); | |||
| 86 | + | } | |||
| 87 | + | };⏎̸ | |||