detached 3d77915

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 UnverifiedSHA256:H91DFuEG4jpk5edzLA4aUKkpv4zv9Q339ipRmkph6Tk The key is unknown
2 files · +96 -50
M src/lib/components/chat-room.svelte +30 -0
@@ -30,6 +30,9 @@ let cooldownProgress = $state(0);
3030 let cooldownInterval: ReturnType<typeof setInterval> | null = null;
3131 let rateLimitWarning = $state<string | null>(null);
3232
33+// At the top along with other variable declarations, add:
34+let roomPollInterval: ReturnType<typeof setInterval> | null = null;
35+
3336 function updateCooldownProgress() {
3437 if (!cooldownEndTime) return;
3538
@@ -193,9 +196,36 @@ onMount(() => {
193196 // Add resize listener
194197 window.addEventListener('resize', handleResize);
195198
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+
196225 return () => {
197226 window.removeEventListener('resize', handleResize);
198227 if (cooldownInterval) clearInterval(cooldownInterval);
228+ if (roomPollInterval) clearInterval(roomPollInterval);
199229 };
200230 });
201231
@@ -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 listener196 // 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';
21 import db from '$lib/db/db.server';
32 import { users } from '$lib/db/schema';
4-import { eq } from 'drizzle-orm';
3+import { eq } from 'drizzle-orm/sql';
54 import { validateSessionToken, generateSessionToken, createSession } from '$lib/api/session.server';
65 import { setSessionTokenCookie } from '$lib/api/session.cookie';
76 import { createSafeUser } from '$lib/types/chat';
87 import { createLogger } from '$lib/utils/logger.server';
8+import type { RequestHandler } from './$types';
99
1010 const log = createLogger('status-server');
1111
12-export async function POST({ request, cookies, locals }) {
12+export const POST: RequestHandler = async ({ request, cookies, locals }) => {
1313 if (!locals.user) {
1414 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+ });
1619 }
1720
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+ }
2431
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 });
2835
29- const now = Date.now();
36+ const now = Date.now();
3037
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 });
4249
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+ }
5967 }
6068 }
6169 }
62- }
6370
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 database38+ // Update the user status in the database
32- // Always update lastSeen to support timeout detection39+ // 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 detection43+ 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 expiry50+ // 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 milliseconds57+ 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+};⏎̸

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 3

Tags 9

↑↓ moveenter openesc close