detached 18beec2

Merge pull request #82 from kitpdr/fix/chat-ux-improvements

fix(chat): improve UX with scroll, notifications, and font style persistence
commit
18beec2b8a06d48b7a599aff2886d4923cca1dfd
author
KiT · · 2026-01-24 05:30 UTC
committer
GitHub · · 2026-01-24 05:30 UTC
merge of
aea0b31 + 27eb0fa · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
11 files · +217 -11
M src/convex/http.ts +24 -0
@@ -160,6 +160,30 @@ http.route({
160160 })
161161 });
162162
163+// Update last read mention timestamp
164+http.route({
165+ path: '/api/users/updateLastReadMentionTimestamp',
166+ method: 'POST',
167+ handler: httpAction(async (ctx, request) => {
168+ if (!validateSecret(request)) return unauthorizedResponse();
169+
170+ const body = await parseJsonBody<{ userId: string; timestamp: number }>(request);
171+ if (!body) return errorResponse('Invalid JSON', 400);
172+
173+ try {
174+ const result = await ctx.runMutation(internal.usersInternal.updateLastReadMentionTimestamp, {
175+ id: body.userId as Id<'users'>,
176+ timestamp: body.timestamp
177+ });
178+ return new Response(JSON.stringify(result), {
179+ headers: { 'Content-Type': 'application/json' }
180+ });
181+ } catch (e) {
182+ return errorResponse(e instanceof Error ? e.message : 'Failed to update timestamp');
183+ }
184+ })
185+});
186+
163187 // ============ SESSIONS (server-side only) ============
164188
165189 // Create session - for login
@@ -160,6 +160,30 @@ http.route({
160 })160 })
161 });161 });
162 162
163+// Update last read mention timestamp
164+http.route({
165+ path: '/api/users/updateLastReadMentionTimestamp',
166+ method: 'POST',
167+ handler: httpAction(async (ctx, request) => {
168+ if (!validateSecret(request)) return unauthorizedResponse();
169+
170+ const body = await parseJsonBody<{ userId: string; timestamp: number }>(request);
171+ if (!body) return errorResponse('Invalid JSON', 400);
172+
173+ try {
174+ const result = await ctx.runMutation(internal.usersInternal.updateLastReadMentionTimestamp, {
175+ id: body.userId as Id<'users'>,
176+ timestamp: body.timestamp
177+ });
178+ return new Response(JSON.stringify(result), {
179+ headers: { 'Content-Type': 'application/json' }
180+ });
181+ } catch (e) {
182+ return errorResponse(e instanceof Error ? e.message : 'Failed to update timestamp');
183+ }
184+ })
185+});
186+
163 // ============ SESSIONS (server-side only) ============187 // ============ SESSIONS (server-side only) ============
164 188
165 // Create session - for login189 // Create session - for login
M src/convex/queries.ts +2 -1
@@ -237,7 +237,8 @@ export const getCurrentUser = authQuery({
237237 nickname: ctx.user.nickname,
238238 status: ctx.user.status,
239239 avatarUrl: ctx.user.avatarUrl,
240- lastSeen: ctx.user.lastSeen
240+ lastSeen: ctx.user.lastSeen,
241+ lastReadMentionTimestamp: ctx.user.lastReadMentionTimestamp
241242 };
242243 }
243244 });
@@ -237,7 +237,8 @@ export const getCurrentUser = authQuery({
237 nickname: ctx.user.nickname,237 nickname: ctx.user.nickname,
238 status: ctx.user.status,238 status: ctx.user.status,
239 avatarUrl: ctx.user.avatarUrl,239 avatarUrl: ctx.user.avatarUrl,
240- lastSeen: ctx.user.lastSeen240+ lastSeen: ctx.user.lastSeen,
241+ lastReadMentionTimestamp: ctx.user.lastReadMentionTimestamp
241 };242 };
242 }243 }
243 });244 });
M src/convex/schema.ts +2 -1
@@ -8,7 +8,8 @@ export default defineSchema({
88 status: v.string(), // 'offline' | 'online' | 'away' | 'busy'
99 avatarUrl: v.optional(v.string()),
1010 createdAt: v.number(),
11- lastSeen: v.optional(v.number())
11+ lastSeen: v.optional(v.number()),
12+ lastReadMentionTimestamp: v.optional(v.number()) // Timestamp of last read mention
1213 })
1314 .index('by_nickname', ['nickname'])
1415 .index('by_status', ['status']),
@@ -8,7 +8,8 @@ export default defineSchema({
8 status: v.string(), // 'offline' | 'online' | 'away' | 'busy'8 status: v.string(), // 'offline' | 'online' | 'away' | 'busy'
9 avatarUrl: v.optional(v.string()),9 avatarUrl: v.optional(v.string()),
10 createdAt: v.number(),10 createdAt: v.number(),
11- lastSeen: v.optional(v.number())11+ lastSeen: v.optional(v.number()),
12+ lastReadMentionTimestamp: v.optional(v.number()) // Timestamp of last read mention
12 })13 })
13 .index('by_nickname', ['nickname'])14 .index('by_nickname', ['nickname'])
14 .index('by_status', ['status']),15 .index('by_status', ['status']),
M src/convex/usersInternal.ts +25 -0
@@ -94,6 +94,31 @@ export const setAllOffline = internalMutation({
9494 }
9595 });
9696
97+// Update last read mention timestamp (internal)
98+export const updateLastReadMentionTimestamp = internalMutation({
99+ args: {
100+ id: v.id('users'),
101+ timestamp: v.number()
102+ },
103+ handler: async (ctx, args) => {
104+ const user = await ctx.db.get(args.id);
105+ if (!user) {
106+ throw new Error('User not found');
107+ }
108+
109+ const currentTimestamp = user.lastReadMentionTimestamp ?? 0;
110+
111+ // Only update if the new timestamp is greater (more recent)
112+ if (args.timestamp > currentTimestamp) {
113+ await ctx.db.patch(args.id, {
114+ lastReadMentionTimestamp: args.timestamp
115+ });
116+ }
117+
118+ return { success: true };
119+ }
120+});
121+
97122 // Cleanup stale users who haven't sent a heartbeat in 2 minutes
98123 export const cleanupStaleUsers = internalMutation({
99124 args: {},
@@ -94,6 +94,31 @@ export const setAllOffline = internalMutation({
94 }94 }
95 });95 });
96 96
97+// Update last read mention timestamp (internal)
98+export const updateLastReadMentionTimestamp = internalMutation({
99+ args: {
100+ id: v.id('users'),
101+ timestamp: v.number()
102+ },
103+ handler: async (ctx, args) => {
104+ const user = await ctx.db.get(args.id);
105+ if (!user) {
106+ throw new Error('User not found');
107+ }
108+
109+ const currentTimestamp = user.lastReadMentionTimestamp ?? 0;
110+
111+ // Only update if the new timestamp is greater (more recent)
112+ if (args.timestamp > currentTimestamp) {
113+ await ctx.db.patch(args.id, {
114+ lastReadMentionTimestamp: args.timestamp
115+ });
116+ }
117+
118+ return { success: true };
119+ }
120+});
121+
97 // Cleanup stale users who haven't sent a heartbeat in 2 minutes122 // Cleanup stale users who haven't sent a heartbeat in 2 minutes
98 export const cleanupStaleUsers = internalMutation({123 export const cleanupStaleUsers = internalMutation({
99 args: {},124 args: {},
M src/lib/api/session.server.ts +2 -1
@@ -92,7 +92,8 @@ export async function validateSessionToken(token: string): Promise<SessionValida
9292 nickname: result.user.nickname,
9393 status: result.user.status as SafeUser['status'],
9494 avatarUrl: result.user.avatarUrl,
95- lastSeen: result.user.lastSeen
95+ lastSeen: result.user.lastSeen,
96+ lastReadMentionTimestamp: result.user.lastReadMentionTimestamp
9697 };
9798
9899 log.debug('Session validated', { tokenHash, userId: user.id });
@@ -92,7 +92,8 @@ export async function validateSessionToken(token: string): Promise<SessionValida
92 nickname: result.user.nickname,92 nickname: result.user.nickname,
93 status: result.user.status as SafeUser['status'],93 status: result.user.status as SafeUser['status'],
94 avatarUrl: result.user.avatarUrl,94 avatarUrl: result.user.avatarUrl,
95- lastSeen: result.user.lastSeen95+ lastSeen: result.user.lastSeen,
96+ lastReadMentionTimestamp: result.user.lastReadMentionTimestamp
96 };97 };
97 98
98 log.debug('Session validated', { tokenHash, userId: user.id });99 log.debug('Session validated', { tokenHash, userId: user.id });
M src/lib/components/chat-room.svelte +89 -3
@@ -5,7 +5,7 @@
55 import type { Id } from '../../convex/_generated/dataModel';
66 import type { Message, EnrichedMessage, SafeUser } from '../types/chat';
77 import { onMount, tick } from 'svelte';
8- import { SvelteSet } from 'svelte/reactivity';
8+ import { SvelteSet, SvelteMap } from 'svelte/reactivity';
99 import { browser } from '$app/environment';
1010 import { draggable } from '$lib/actions/draggable';
1111 import { resizable } from '$lib/actions/resizable';
@@ -153,12 +153,15 @@
153153
154154 let currentMessage = $state('');
155155 let inputScrollLeft = $state(0);
156- // Initialize text style with prop value (intentionally capturing initial value only)
156+ // Initialize text style with prop value
157157 let currentTextStyle = $state<TextStyle>({
158158 ...DEFAULT_TEXT_STYLE,
159159 ...initialTextStyle,
160160 color: initialTextStyle.color || '#000000'
161161 });
162+ // Track if we've applied user's style from DB (to avoid resetting on every prop change)
163+ let hasAppliedUserStyle = $state(false);
164+ let lastUserId = $state<string | null>(null);
162165
163166 // ============ MENTION STATE ============
164167
@@ -172,6 +175,7 @@
172175 let mentionUnreadCount = $state(0);
173176 let mentionDismissKey = $state<string | null>(null);
174177 let mentionObserver: IntersectionObserver | null = null;
178+ let pendingMentionTimestamp = $state<number | null>(null);
175179
176180 let baseTitle = $state('');
177181 const mentionListBoxId = `mentions-${Math.random().toString(36).slice(2, 8)}`;
@@ -266,6 +270,27 @@
266270
267271 // ============ EFFECTS ============
268272
273+ // Update text style when user logs in and their saved style is loaded from DB
274+ $effect(() => {
275+ const userId = currentUser?.id ?? null;
276+
277+ // User changed (logged in or different user)
278+ if (userId !== lastUserId) {
279+ lastUserId = userId;
280+ hasAppliedUserStyle = false;
281+ }
282+
283+ // Apply user's saved style when they log in (initialTextStyle prop updated via invalidateAll)
284+ if (currentUser && !hasAppliedUserStyle && initialTextStyle) {
285+ hasAppliedUserStyle = true;
286+ currentTextStyle = {
287+ ...DEFAULT_TEXT_STYLE,
288+ ...initialTextStyle,
289+ color: initialTextStyle.color || '#000000'
290+ };
291+ }
292+ });
293+
269294 // Auto-scroll to bottom when new messages arrive
270295 $effect(() => {
271296 if (messages.length > lastMessageCount && messages.length > 0) {
@@ -280,6 +305,20 @@
280305 }
281306 });
282307
308+ // Scroll to bottom on initial load (when chatArea becomes available and messages exist)
309+ $effect(() => {
310+ if (browser && chatArea && messages.length > 0 && !isInitialLoading) {
311+ // Only run once on initial load by checking if we haven't scrolled yet
312+ if (chatArea.scrollTop === 0 && chatArea.scrollHeight > chatArea.clientHeight) {
313+ tick().then(() => {
314+ if (chatArea) {
315+ chatArea.scrollTop = chatArea.scrollHeight;
316+ }
317+ });
318+ }
319+ }
320+ });
321+
283322 // Update oldest timestamp for pagination
284323 $effect(() => {
285324 if (allMessages.length > 0) {
@@ -309,6 +348,8 @@
309348 if (mentionUnreadCount !== 0) {
310349 mentionUnreadCount = 0;
311350 }
351+ // Reset pending timestamp when user logs out
352+ pendingMentionTimestamp = null;
312353 // mention observer reset
313354 if (mentionObserver) {
314355 mentionObserver.disconnect();
@@ -317,11 +358,22 @@
317358 return;
318359 }
319360 const mentionIds = new SvelteSet<string>();
361+ const lastReadTimestamp = currentUser.lastReadMentionTimestamp ?? 0;
362+ const mentionIdsToPreObserve: string[] = [];
363+
320364 for (const message of visibleMessages) {
321365 if (shouldHighlightMention(message)) {
322366 mentionIds.add(message.id);
367+ // Pre-mark as observed if message is older than lastReadMentionTimestamp
368+ if (message.timestamp <= lastReadTimestamp && !mentionObservedIds.has(message.id)) {
369+ mentionIdsToPreObserve.push(message.id);
370+ }
323371 }
324372 }
373+ // Batch update observed IDs to avoid repeated Set allocations
374+ if (mentionIdsToPreObserve.length > 0) {
375+ mentionObservedIds = new SvelteSet([...mentionObservedIds, ...mentionIdsToPreObserve]);
376+ }
325377 const nextMentionIds = mentionIds;
326378 if (!setsEqual(nextMentionIds, mentionMessageIds)) {
327379 mentionMessageIds = nextMentionIds;
@@ -341,12 +393,19 @@
341393 if (mentionObserver) {
342394 mentionObserver.disconnect();
343395 }
396+ // Precompute message timestamp map to avoid O(n) lookup in callback
397+ const messageTimestampMap = new SvelteMap<string, number>();
398+ for (const msg of visibleMessages) {
399+ messageTimestampMap.set(msg.id, msg.timestamp);
400+ }
344401 mentionObserver = new IntersectionObserver(
345402 (entries) => {
346403 const updatedObserved = new SvelteSet(mentionObservedIds);
347404 let changed = false;
348405 const currentObserved = mentionObservedIds;
349406 const currentUnread = mentionUnreadCount;
407+ let maxTimestamp = pendingMentionTimestamp ?? 0;
408+
350409 for (const entry of entries) {
351410 const target = entry.target as HTMLElement;
352411 const messageId = target.dataset.messageId;
@@ -354,6 +413,12 @@
354413 if (entry.isIntersecting && !updatedObserved.has(messageId)) {
355414 updatedObserved.add(messageId);
356415 changed = true;
416+
417+ // Get timestamp from precomputed map (O(1) instead of O(n))
418+ const timestamp = messageTimestampMap.get(messageId);
419+ if (timestamp !== undefined && timestamp > maxTimestamp) {
420+ maxTimestamp = timestamp;
421+ }
357422 }
358423 }
359424 if (changed && !setsEqual(updatedObserved, currentObserved)) {
@@ -362,6 +427,12 @@
362427 if (currentUnread !== nextUnread) {
363428 mentionUnreadCount = nextUnread;
364429 }
430+
431+ // Update the server with the new max timestamp
432+ if (maxTimestamp > (pendingMentionTimestamp ?? 0)) {
433+ pendingMentionTimestamp = maxTimestamp;
434+ debouncedUpdateMentionTimestamp(maxTimestamp);
435+ }
365436 }
366437 },
367438 { root: chatArea, threshold: 0.6 }
@@ -485,6 +556,21 @@
485556
486557 const debouncedSaveWindowState = debounce(saveWindowState, 300);
487558
559+ // Debounced function to update the server with last read mention timestamp
560+ const debouncedUpdateMentionTimestamp = debounce(async (timestamp: number) => {
561+ if (!currentUser || !browser) return;
562+ try {
563+ await fetch('/api/chat/mention-read', {
564+ method: 'POST',
565+ headers: { 'Content-Type': 'application/json' },
566+ credentials: 'include',
567+ body: JSON.stringify({ timestamp })
568+ });
569+ } catch (error) {
570+ console.error('Failed to update mention read timestamp:', error);
571+ }
572+ }, 1000);
573+
488574 function setsEqual(a: Set<string>, b: Set<string>) {
489575 if (a.size !== b.size) return false;
490576 for (const value of a) {
@@ -1451,7 +1537,7 @@
14511537 }
14521538
14531539 .message-content :global(.mention-token) {
1454- color: #1e2a72;
1540+ color: inherit;
14551541 font-weight: bold;
14561542 }
14571543
@@ -5,7 +5,7 @@
5 import type { Id } from '../../convex/_generated/dataModel';5 import type { Id } from '../../convex/_generated/dataModel';
6 import type { Message, EnrichedMessage, SafeUser } from '../types/chat';6 import type { Message, EnrichedMessage, SafeUser } from '../types/chat';
7 import { onMount, tick } from 'svelte';7 import { onMount, tick } from 'svelte';
8- import { SvelteSet } from 'svelte/reactivity';8+ import { SvelteSet, SvelteMap } from 'svelte/reactivity';
9 import { browser } from '$app/environment';9 import { browser } from '$app/environment';
10 import { draggable } from '$lib/actions/draggable';10 import { draggable } from '$lib/actions/draggable';
11 import { resizable } from '$lib/actions/resizable';11 import { resizable } from '$lib/actions/resizable';
@@ -153,12 +153,15 @@
153 153
154 let currentMessage = $state('');154 let currentMessage = $state('');
155 let inputScrollLeft = $state(0);155 let inputScrollLeft = $state(0);
156- // Initialize text style with prop value (intentionally capturing initial value only)156+ // Initialize text style with prop value
157 let currentTextStyle = $state<TextStyle>({157 let currentTextStyle = $state<TextStyle>({
158 ...DEFAULT_TEXT_STYLE,158 ...DEFAULT_TEXT_STYLE,
159 ...initialTextStyle,159 ...initialTextStyle,
160 color: initialTextStyle.color || '#000000'160 color: initialTextStyle.color || '#000000'
161 });161 });
162+ // Track if we've applied user's style from DB (to avoid resetting on every prop change)
163+ let hasAppliedUserStyle = $state(false);
164+ let lastUserId = $state<string | null>(null);
162 165
163 // ============ MENTION STATE ============166 // ============ MENTION STATE ============
164 167
@@ -172,6 +175,7 @@
172 let mentionUnreadCount = $state(0);175 let mentionUnreadCount = $state(0);
173 let mentionDismissKey = $state<string | null>(null);176 let mentionDismissKey = $state<string | null>(null);
174 let mentionObserver: IntersectionObserver | null = null;177 let mentionObserver: IntersectionObserver | null = null;
178+ let pendingMentionTimestamp = $state<number | null>(null);
175 179
176 let baseTitle = $state('');180 let baseTitle = $state('');
177 const mentionListBoxId = `mentions-${Math.random().toString(36).slice(2, 8)}`;181 const mentionListBoxId = `mentions-${Math.random().toString(36).slice(2, 8)}`;
@@ -266,6 +270,27 @@
266 270
267 // ============ EFFECTS ============271 // ============ EFFECTS ============
268 272
273+ // Update text style when user logs in and their saved style is loaded from DB
274+ $effect(() => {
275+ const userId = currentUser?.id ?? null;
276+
277+ // User changed (logged in or different user)
278+ if (userId !== lastUserId) {
279+ lastUserId = userId;
280+ hasAppliedUserStyle = false;
281+ }
282+
283+ // Apply user's saved style when they log in (initialTextStyle prop updated via invalidateAll)
284+ if (currentUser && !hasAppliedUserStyle && initialTextStyle) {
285+ hasAppliedUserStyle = true;
286+ currentTextStyle = {
287+ ...DEFAULT_TEXT_STYLE,
288+ ...initialTextStyle,
289+ color: initialTextStyle.color || '#000000'
290+ };
291+ }
292+ });
293+
269 // Auto-scroll to bottom when new messages arrive294 // Auto-scroll to bottom when new messages arrive
270 $effect(() => {295 $effect(() => {
271 if (messages.length > lastMessageCount && messages.length > 0) {296 if (messages.length > lastMessageCount && messages.length > 0) {
@@ -280,6 +305,20 @@
280 }305 }
281 });306 });
282 307
308+ // Scroll to bottom on initial load (when chatArea becomes available and messages exist)
309+ $effect(() => {
310+ if (browser && chatArea && messages.length > 0 && !isInitialLoading) {
311+ // Only run once on initial load by checking if we haven't scrolled yet
312+ if (chatArea.scrollTop === 0 && chatArea.scrollHeight > chatArea.clientHeight) {
313+ tick().then(() => {
314+ if (chatArea) {
315+ chatArea.scrollTop = chatArea.scrollHeight;
316+ }
317+ });
318+ }
319+ }
320+ });
321+
283 // Update oldest timestamp for pagination322 // Update oldest timestamp for pagination
284 $effect(() => {323 $effect(() => {
285 if (allMessages.length > 0) {324 if (allMessages.length > 0) {
@@ -309,6 +348,8 @@
309 if (mentionUnreadCount !== 0) {348 if (mentionUnreadCount !== 0) {
310 mentionUnreadCount = 0;349 mentionUnreadCount = 0;
311 }350 }
351+ // Reset pending timestamp when user logs out
352+ pendingMentionTimestamp = null;
312 // mention observer reset353 // mention observer reset
313 if (mentionObserver) {354 if (mentionObserver) {
314 mentionObserver.disconnect();355 mentionObserver.disconnect();
@@ -317,11 +358,22 @@
317 return;358 return;
318 }359 }
319 const mentionIds = new SvelteSet<string>();360 const mentionIds = new SvelteSet<string>();
361+ const lastReadTimestamp = currentUser.lastReadMentionTimestamp ?? 0;
362+ const mentionIdsToPreObserve: string[] = [];
363+
320 for (const message of visibleMessages) {364 for (const message of visibleMessages) {
321 if (shouldHighlightMention(message)) {365 if (shouldHighlightMention(message)) {
322 mentionIds.add(message.id);366 mentionIds.add(message.id);
367+ // Pre-mark as observed if message is older than lastReadMentionTimestamp
368+ if (message.timestamp <= lastReadTimestamp && !mentionObservedIds.has(message.id)) {
369+ mentionIdsToPreObserve.push(message.id);
370+ }
323 }371 }
324 }372 }
373+ // Batch update observed IDs to avoid repeated Set allocations
374+ if (mentionIdsToPreObserve.length > 0) {
375+ mentionObservedIds = new SvelteSet([...mentionObservedIds, ...mentionIdsToPreObserve]);
376+ }
325 const nextMentionIds = mentionIds;377 const nextMentionIds = mentionIds;
326 if (!setsEqual(nextMentionIds, mentionMessageIds)) {378 if (!setsEqual(nextMentionIds, mentionMessageIds)) {
327 mentionMessageIds = nextMentionIds;379 mentionMessageIds = nextMentionIds;
@@ -341,12 +393,19 @@
341 if (mentionObserver) {393 if (mentionObserver) {
342 mentionObserver.disconnect();394 mentionObserver.disconnect();
343 }395 }
396+ // Precompute message timestamp map to avoid O(n) lookup in callback
397+ const messageTimestampMap = new SvelteMap<string, number>();
398+ for (const msg of visibleMessages) {
399+ messageTimestampMap.set(msg.id, msg.timestamp);
400+ }
344 mentionObserver = new IntersectionObserver(401 mentionObserver = new IntersectionObserver(
345 (entries) => {402 (entries) => {
346 const updatedObserved = new SvelteSet(mentionObservedIds);403 const updatedObserved = new SvelteSet(mentionObservedIds);
347 let changed = false;404 let changed = false;
348 const currentObserved = mentionObservedIds;405 const currentObserved = mentionObservedIds;
349 const currentUnread = mentionUnreadCount;406 const currentUnread = mentionUnreadCount;
407+ let maxTimestamp = pendingMentionTimestamp ?? 0;
408+
350 for (const entry of entries) {409 for (const entry of entries) {
351 const target = entry.target as HTMLElement;410 const target = entry.target as HTMLElement;
352 const messageId = target.dataset.messageId;411 const messageId = target.dataset.messageId;
@@ -354,6 +413,12 @@
354 if (entry.isIntersecting && !updatedObserved.has(messageId)) {413 if (entry.isIntersecting && !updatedObserved.has(messageId)) {
355 updatedObserved.add(messageId);414 updatedObserved.add(messageId);
356 changed = true;415 changed = true;
416+
417+ // Get timestamp from precomputed map (O(1) instead of O(n))
418+ const timestamp = messageTimestampMap.get(messageId);
419+ if (timestamp !== undefined && timestamp > maxTimestamp) {
420+ maxTimestamp = timestamp;
421+ }
357 }422 }
358 }423 }
359 if (changed && !setsEqual(updatedObserved, currentObserved)) {424 if (changed && !setsEqual(updatedObserved, currentObserved)) {
@@ -362,6 +427,12 @@
362 if (currentUnread !== nextUnread) {427 if (currentUnread !== nextUnread) {
363 mentionUnreadCount = nextUnread;428 mentionUnreadCount = nextUnread;
364 }429 }
430+
431+ // Update the server with the new max timestamp
432+ if (maxTimestamp > (pendingMentionTimestamp ?? 0)) {
433+ pendingMentionTimestamp = maxTimestamp;
434+ debouncedUpdateMentionTimestamp(maxTimestamp);
435+ }
365 }436 }
366 },437 },
367 { root: chatArea, threshold: 0.6 }438 { root: chatArea, threshold: 0.6 }
@@ -485,6 +556,21 @@
485 556
486 const debouncedSaveWindowState = debounce(saveWindowState, 300);557 const debouncedSaveWindowState = debounce(saveWindowState, 300);
487 558
559+ // Debounced function to update the server with last read mention timestamp
560+ const debouncedUpdateMentionTimestamp = debounce(async (timestamp: number) => {
561+ if (!currentUser || !browser) return;
562+ try {
563+ await fetch('/api/chat/mention-read', {
564+ method: 'POST',
565+ headers: { 'Content-Type': 'application/json' },
566+ credentials: 'include',
567+ body: JSON.stringify({ timestamp })
568+ });
569+ } catch (error) {
570+ console.error('Failed to update mention read timestamp:', error);
571+ }
572+ }, 1000);
573+
488 function setsEqual(a: Set<string>, b: Set<string>) {574 function setsEqual(a: Set<string>, b: Set<string>) {
489 if (a.size !== b.size) return false;575 if (a.size !== b.size) return false;
490 for (const value of a) {576 for (const value of a) {
@@ -1451,7 +1537,7 @@
1451 }1537 }
1452 1538
1453 .message-content :global(.mention-token) {1539 .message-content :global(.mention-token) {
1454- color: #1e2a72;1540+ color: inherit;
1455 font-weight: bold;1541 font-weight: bold;
1456 }1542 }
1457 1543
M src/lib/components/formatted-message.svelte +2 -2
@@ -236,9 +236,9 @@
236236 color: inherit;
237237 }
238238
239- /* Mention token styling */
239+ /* Mention token styling - inherits message color/font, just adds bold */
240240 :global(.mention-token) {
241- color: #1e2a72;
241+ color: inherit;
242242 font-weight: bold;
243243 }
244244
@@ -236,9 +236,9 @@
236 color: inherit;236 color: inherit;
237 }237 }
238 238
239- /* Mention token styling */239+ /* Mention token styling - inherits message color/font, just adds bold */
240 :global(.mention-token) {240 :global(.mention-token) {
241- color: #1e2a72;241+ color: inherit;
242 font-weight: bold;242 font-weight: bold;
243 }243 }
244 244
M src/lib/db/convex.server.ts +8 -1
@@ -43,7 +43,13 @@ export const users = {
4343
4444 list: () => convexFetch<ConvexUser[]>('/api/users/list', {}),
4545
46- setAllOffline: () => convexFetch<{ updated: number }>('/api/users/setAllOffline', {})
46+ setAllOffline: () => convexFetch<{ updated: number }>('/api/users/setAllOffline', {}),
47+
48+ updateLastReadMentionTimestamp: (userId: string, timestamp: number) =>
49+ convexFetch<{ success: boolean }>('/api/users/updateLastReadMentionTimestamp', {
50+ userId,
51+ timestamp
52+ })
4753 };
4854
4955 // ============ SESSIONS ============
@@ -113,6 +119,7 @@ export interface ConvexUser {
113119 avatarUrl?: string;
114120 createdAt: number;
115121 lastSeen?: number;
122+ lastReadMentionTimestamp?: number;
116123 }
117124
118125 export interface ConvexSession {
@@ -43,7 +43,13 @@ export const users = {
43 43
44 list: () => convexFetch<ConvexUser[]>('/api/users/list', {}),44 list: () => convexFetch<ConvexUser[]>('/api/users/list', {}),
45 45
46- setAllOffline: () => convexFetch<{ updated: number }>('/api/users/setAllOffline', {})46+ setAllOffline: () => convexFetch<{ updated: number }>('/api/users/setAllOffline', {}),
47+
48+ updateLastReadMentionTimestamp: (userId: string, timestamp: number) =>
49+ convexFetch<{ success: boolean }>('/api/users/updateLastReadMentionTimestamp', {
50+ userId,
51+ timestamp
52+ })
47 };53 };
48 54
49 // ============ SESSIONS ============55 // ============ SESSIONS ============
@@ -113,6 +119,7 @@ export interface ConvexUser {
113 avatarUrl?: string;119 avatarUrl?: string;
114 createdAt: number;120 createdAt: number;
115 lastSeen?: number;121 lastSeen?: number;
122+ lastReadMentionTimestamp?: number;
116 }123 }
117 124
118 export interface ConvexSession {125 export interface ConvexSession {
M src/lib/types/chat.ts +3 -1
@@ -7,6 +7,7 @@ export interface User {
77 avatarUrl?: string | null;
88 createdAt: number; // Timestamp (e.g., Unix timestamp)
99 lastSeen?: number | null; // Optional last active timestamp
10+ lastReadMentionTimestamp?: number | null; // Timestamp of last read mention
1011 }
1112
1213 export type UserStatus = 'offline' | 'online' | 'away' | string;
@@ -43,7 +44,8 @@ export function createSafeUser(
4344 nickname: user.nickname,
4445 status: user.status,
4546 avatarUrl: user.avatarUrl,
46- lastSeen: user.lastSeen ?? 0
47+ lastSeen: user.lastSeen ?? 0,
48+ lastReadMentionTimestamp: user.lastReadMentionTimestamp ?? null
4749 };
4850 return safeUser;
4951 }
@@ -7,6 +7,7 @@ export interface User {
7 avatarUrl?: string | null;7 avatarUrl?: string | null;
8 createdAt: number; // Timestamp (e.g., Unix timestamp)8 createdAt: number; // Timestamp (e.g., Unix timestamp)
9 lastSeen?: number | null; // Optional last active timestamp9 lastSeen?: number | null; // Optional last active timestamp
10+ lastReadMentionTimestamp?: number | null; // Timestamp of last read mention
10 }11 }
11 12
12 export type UserStatus = 'offline' | 'online' | 'away' | string;13 export type UserStatus = 'offline' | 'online' | 'away' | string;
@@ -43,7 +44,8 @@ export function createSafeUser(
43 nickname: user.nickname,44 nickname: user.nickname,
44 status: user.status,45 status: user.status,
45 avatarUrl: user.avatarUrl,46 avatarUrl: user.avatarUrl,
46- lastSeen: user.lastSeen ?? 047+ lastSeen: user.lastSeen ?? 0,
48+ lastReadMentionTimestamp: user.lastReadMentionTimestamp ?? null
47 };49 };
48 return safeUser;50 return safeUser;
49 }51 }
A src/routes/api/chat/mention-read/+server.ts +58 -0
@@ -0,0 +1,58 @@
1+import type { RequestHandler } from '@sveltejs/kit';
2+import { users } from '$lib/db/convex.server';
3+import { validateSessionToken } from '$lib/api/session.server';
4+
5+export const POST: RequestHandler = async ({ request, cookies }) => {
6+ const token = cookies.get('session');
7+
8+ if (!token) {
9+ return new Response(JSON.stringify({ error: 'Unauthorized' }), {
10+ status: 401,
11+ headers: { 'Content-Type': 'application/json' }
12+ });
13+ }
14+
15+ try {
16+ const { user } = await validateSessionToken(token);
17+
18+ if (!user) {
19+ return new Response(JSON.stringify({ error: 'Unauthorized' }), {
20+ status: 401,
21+ headers: { 'Content-Type': 'application/json' }
22+ });
23+ }
24+
25+ const body = await request.json();
26+ const { timestamp } = body as { timestamp: number };
27+
28+ if (typeof timestamp !== 'number' || timestamp <= 0) {
29+ return new Response(JSON.stringify({ error: 'Invalid timestamp' }), {
30+ status: 400,
31+ headers: { 'Content-Type': 'application/json' }
32+ });
33+ }
34+
35+ // Prevent far-future timestamps that could suppress notifications permanently
36+ const now = Date.now();
37+ const maxSkewMs = 5 * 60 * 1000; // 5 minutes clock skew tolerance
38+ if (timestamp > now + maxSkewMs) {
39+ return new Response(JSON.stringify({ error: 'Timestamp too far in the future' }), {
40+ status: 400,
41+ headers: { 'Content-Type': 'application/json' }
42+ });
43+ }
44+
45+ await users.updateLastReadMentionTimestamp(user.id, timestamp);
46+
47+ return new Response(JSON.stringify({ success: true }), {
48+ status: 200,
49+ headers: { 'Content-Type': 'application/json' }
50+ });
51+ } catch (error) {
52+ console.error('Error updating mention read timestamp:', error);
53+ return new Response(JSON.stringify({ error: 'Internal server error' }), {
54+ status: 500,
55+ headers: { 'Content-Type': 'application/json' }
56+ });
57+ }
58+};
@@ -0,0 +1,58 @@
1+import type { RequestHandler } from '@sveltejs/kit';
2+import { users } from '$lib/db/convex.server';
3+import { validateSessionToken } from '$lib/api/session.server';
4+
5+export const POST: RequestHandler = async ({ request, cookies }) => {
6+ const token = cookies.get('session');
7+
8+ if (!token) {
9+ return new Response(JSON.stringify({ error: 'Unauthorized' }), {
10+ status: 401,
11+ headers: { 'Content-Type': 'application/json' }
12+ });
13+ }
14+
15+ try {
16+ const { user } = await validateSessionToken(token);
17+
18+ if (!user) {
19+ return new Response(JSON.stringify({ error: 'Unauthorized' }), {
20+ status: 401,
21+ headers: { 'Content-Type': 'application/json' }
22+ });
23+ }
24+
25+ const body = await request.json();
26+ const { timestamp } = body as { timestamp: number };
27+
28+ if (typeof timestamp !== 'number' || timestamp <= 0) {
29+ return new Response(JSON.stringify({ error: 'Invalid timestamp' }), {
30+ status: 400,
31+ headers: { 'Content-Type': 'application/json' }
32+ });
33+ }
34+
35+ // Prevent far-future timestamps that could suppress notifications permanently
36+ const now = Date.now();
37+ const maxSkewMs = 5 * 60 * 1000; // 5 minutes clock skew tolerance
38+ if (timestamp > now + maxSkewMs) {
39+ return new Response(JSON.stringify({ error: 'Timestamp too far in the future' }), {
40+ status: 400,
41+ headers: { 'Content-Type': 'application/json' }
42+ });
43+ }
44+
45+ await users.updateLastReadMentionTimestamp(user.id, timestamp);
46+
47+ return new Response(JSON.stringify({ success: true }), {
48+ status: 200,
49+ headers: { 'Content-Type': 'application/json' }
50+ });
51+ } catch (error) {
52+ console.error('Error updating mention read timestamp:', error);
53+ return new Response(JSON.stringify({ error: 'Internal server error' }), {
54+ status: 500,
55+ headers: { 'Content-Type': 'application/json' }
56+ });
57+ }
58+};
M src/routes/api/session/login/+server.ts +2 -1
@@ -167,7 +167,8 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
167167 nickname: user.nickname,
168168 status: 'online',
169169 avatarUrl: user.avatarUrl,
170- lastSeen: user.lastSeen
170+ lastSeen: user.lastSeen,
171+ lastReadMentionTimestamp: user.lastReadMentionTimestamp
171172 })
172173 } as LoginResponseSuccess),
173174 {
@@ -167,7 +167,8 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
167 nickname: user.nickname,167 nickname: user.nickname,
168 status: 'online',168 status: 'online',
169 avatarUrl: user.avatarUrl,169 avatarUrl: user.avatarUrl,
170- lastSeen: user.lastSeen170+ lastSeen: user.lastSeen,
171+ lastReadMentionTimestamp: user.lastReadMentionTimestamp
171 })172 })
172 } as LoginResponseSuccess),173 } as LoginResponseSuccess),
173 {174 {

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