detached 787d679

fix(chat): improve UX with scroll, notifications, and font style persistence

- Fix chat not scrolling to bottom on page load/refresh
- Persist mention read state in database instead of memory
- Apply user's saved font style after login

Adds lastReadMentionTimestamp field to users table to track which
mentions have been read across sessions and devices. When a mention
becomes visible, the timestamp is updated server-side via debounced
API call.
commit
787d6798216dc9dec35a1c6f2810ade14cc9cc36
author
Kit · · 2026-01-24 05:04 UTC
parent
aea0b31
signature
SSH UnverifiedSHA256:H91DFuEG4jpk5edzLA4aUKkpv4zv9Q339ipRmkph6Tk The key is unknown
10 files · +208 -6
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/mutations.ts +19 -0
@@ -113,3 +113,22 @@ export const getOrCreateDefaultRoom = mutation({
113113 };
114114 }
115115 });
116+
117+// Authenticated mutation: Update the timestamp of the last read mention
118+export const updateLastReadMentionTimestamp = authMutation({
119+ args: {
120+ timestamp: v.number()
121+ },
122+ handler: async (ctx, args) => {
123+ const currentTimestamp = ctx.user.lastReadMentionTimestamp ?? 0;
124+
125+ // Only update if the new timestamp is greater (more recent)
126+ if (args.timestamp > currentTimestamp) {
127+ await ctx.db.patch(ctx.user._id, {
128+ lastReadMentionTimestamp: args.timestamp
129+ });
130+ }
131+
132+ return { success: true };
133+ }
134+});
@@ -113,3 +113,22 @@ export const getOrCreateDefaultRoom = mutation({
113 };113 };
114 }114 }
115 });115 });
116+
117+// Authenticated mutation: Update the timestamp of the last read mention
118+export const updateLastReadMentionTimestamp = authMutation({
119+ args: {
120+ timestamp: v.number()
121+ },
122+ handler: async (ctx, args) => {
123+ const currentTimestamp = ctx.user.lastReadMentionTimestamp ?? 0;
124+
125+ // Only update if the new timestamp is greater (more recent)
126+ if (args.timestamp > currentTimestamp) {
127+ await ctx.db.patch(ctx.user._id, {
128+ lastReadMentionTimestamp: args.timestamp
129+ });
130+ }
131+
132+ return { success: true };
133+ }
134+});
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/components/chat-room.svelte +75 -1
@@ -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) {
@@ -317,9 +356,15 @@
317356 return;
318357 }
319358 const mentionIds = new SvelteSet<string>();
359+ const lastReadTimestamp = currentUser.lastReadMentionTimestamp ?? 0;
360+
320361 for (const message of visibleMessages) {
321362 if (shouldHighlightMention(message)) {
322363 mentionIds.add(message.id);
364+ // Pre-mark as observed if message is older than lastReadMentionTimestamp
365+ if (message.timestamp <= lastReadTimestamp && !mentionObservedIds.has(message.id)) {
366+ mentionObservedIds = new SvelteSet([...mentionObservedIds, message.id]);
367+ }
323368 }
324369 }
325370 const nextMentionIds = mentionIds;
@@ -347,6 +392,8 @@
347392 let changed = false;
348393 const currentObserved = mentionObservedIds;
349394 const currentUnread = mentionUnreadCount;
395+ let maxTimestamp = pendingMentionTimestamp ?? 0;
396+
350397 for (const entry of entries) {
351398 const target = entry.target as HTMLElement;
352399 const messageId = target.dataset.messageId;
@@ -354,6 +401,12 @@
354401 if (entry.isIntersecting && !updatedObserved.has(messageId)) {
355402 updatedObserved.add(messageId);
356403 changed = true;
404+
405+ // Find the message to get its timestamp
406+ const message = visibleMessages.find((m) => m.id === messageId);
407+ if (message && message.timestamp > maxTimestamp) {
408+ maxTimestamp = message.timestamp;
409+ }
357410 }
358411 }
359412 if (changed && !setsEqual(updatedObserved, currentObserved)) {
@@ -362,6 +415,12 @@
362415 if (currentUnread !== nextUnread) {
363416 mentionUnreadCount = nextUnread;
364417 }
418+
419+ // Update the server with the new max timestamp
420+ if (maxTimestamp > (pendingMentionTimestamp ?? 0)) {
421+ pendingMentionTimestamp = maxTimestamp;
422+ debouncedUpdateMentionTimestamp(maxTimestamp);
423+ }
365424 }
366425 },
367426 { root: chatArea, threshold: 0.6 }
@@ -485,6 +544,21 @@
485544
486545 const debouncedSaveWindowState = debounce(saveWindowState, 300);
487546
547+ // Debounced function to update the server with last read mention timestamp
548+ const debouncedUpdateMentionTimestamp = debounce(async (timestamp: number) => {
549+ if (!currentUser || !browser) return;
550+ try {
551+ await fetch('/api/chat/mention-read', {
552+ method: 'POST',
553+ headers: { 'Content-Type': 'application/json' },
554+ credentials: 'include',
555+ body: JSON.stringify({ timestamp })
556+ });
557+ } catch (error) {
558+ console.error('Failed to update mention read timestamp:', error);
559+ }
560+ }, 1000);
561+
488562 function setsEqual(a: Set<string>, b: Set<string>) {
489563 if (a.size !== b.size) return false;
490564 for (const value of a) {
@@ -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) {
@@ -317,9 +356,15 @@
317 return;356 return;
318 }357 }
319 const mentionIds = new SvelteSet<string>();358 const mentionIds = new SvelteSet<string>();
359+ const lastReadTimestamp = currentUser.lastReadMentionTimestamp ?? 0;
360+
320 for (const message of visibleMessages) {361 for (const message of visibleMessages) {
321 if (shouldHighlightMention(message)) {362 if (shouldHighlightMention(message)) {
322 mentionIds.add(message.id);363 mentionIds.add(message.id);
364+ // Pre-mark as observed if message is older than lastReadMentionTimestamp
365+ if (message.timestamp <= lastReadTimestamp && !mentionObservedIds.has(message.id)) {
366+ mentionObservedIds = new SvelteSet([...mentionObservedIds, message.id]);
367+ }
323 }368 }
324 }369 }
325 const nextMentionIds = mentionIds;370 const nextMentionIds = mentionIds;
@@ -347,6 +392,8 @@
347 let changed = false;392 let changed = false;
348 const currentObserved = mentionObservedIds;393 const currentObserved = mentionObservedIds;
349 const currentUnread = mentionUnreadCount;394 const currentUnread = mentionUnreadCount;
395+ let maxTimestamp = pendingMentionTimestamp ?? 0;
396+
350 for (const entry of entries) {397 for (const entry of entries) {
351 const target = entry.target as HTMLElement;398 const target = entry.target as HTMLElement;
352 const messageId = target.dataset.messageId;399 const messageId = target.dataset.messageId;
@@ -354,6 +401,12 @@
354 if (entry.isIntersecting && !updatedObserved.has(messageId)) {401 if (entry.isIntersecting && !updatedObserved.has(messageId)) {
355 updatedObserved.add(messageId);402 updatedObserved.add(messageId);
356 changed = true;403 changed = true;
404+
405+ // Find the message to get its timestamp
406+ const message = visibleMessages.find((m) => m.id === messageId);
407+ if (message && message.timestamp > maxTimestamp) {
408+ maxTimestamp = message.timestamp;
409+ }
357 }410 }
358 }411 }
359 if (changed && !setsEqual(updatedObserved, currentObserved)) {412 if (changed && !setsEqual(updatedObserved, currentObserved)) {
@@ -362,6 +415,12 @@
362 if (currentUnread !== nextUnread) {415 if (currentUnread !== nextUnread) {
363 mentionUnreadCount = nextUnread;416 mentionUnreadCount = nextUnread;
364 }417 }
418+
419+ // Update the server with the new max timestamp
420+ if (maxTimestamp > (pendingMentionTimestamp ?? 0)) {
421+ pendingMentionTimestamp = maxTimestamp;
422+ debouncedUpdateMentionTimestamp(maxTimestamp);
423+ }
365 }424 }
366 },425 },
367 { root: chatArea, threshold: 0.6 }426 { root: chatArea, threshold: 0.6 }
@@ -485,6 +544,21 @@
485 544
486 const debouncedSaveWindowState = debounce(saveWindowState, 300);545 const debouncedSaveWindowState = debounce(saveWindowState, 300);
487 546
547+ // Debounced function to update the server with last read mention timestamp
548+ const debouncedUpdateMentionTimestamp = debounce(async (timestamp: number) => {
549+ if (!currentUser || !browser) return;
550+ try {
551+ await fetch('/api/chat/mention-read', {
552+ method: 'POST',
553+ headers: { 'Content-Type': 'application/json' },
554+ credentials: 'include',
555+ body: JSON.stringify({ timestamp })
556+ });
557+ } catch (error) {
558+ console.error('Failed to update mention read timestamp:', error);
559+ }
560+ }, 1000);
561+
488 function setsEqual(a: Set<string>, b: Set<string>) {562 function setsEqual(a: Set<string>, b: Set<string>) {
489 if (a.size !== b.size) return false;563 if (a.size !== b.size) return false;
490 for (const value of a) {564 for (const value of a) {
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 +48 -0
@@ -0,0 +1,48 @@
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+ await users.updateLastReadMentionTimestamp(user.id, timestamp);
36+
37+ return new Response(JSON.stringify({ success: true }), {
38+ status: 200,
39+ headers: { 'Content-Type': 'application/json' }
40+ });
41+ } catch (error) {
42+ console.error('Error updating mention read timestamp:', error);
43+ return new Response(JSON.stringify({ error: 'Internal server error' }), {
44+ status: 500,
45+ headers: { 'Content-Type': 'application/json' }
46+ });
47+ }
48+};
@@ -0,0 +1,48 @@
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+ await users.updateLastReadMentionTimestamp(user.id, timestamp);
36+
37+ return new Response(JSON.stringify({ success: true }), {
38+ status: 200,
39+ headers: { 'Content-Type': 'application/json' }
40+ });
41+ } catch (error) {
42+ console.error('Error updating mention read timestamp:', error);
43+ return new Response(JSON.stringify({ error: 'Internal server error' }), {
44+ status: 500,
45+ headers: { 'Content-Type': 'application/json' }
46+ });
47+ }
48+};
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 8

↑↓ moveenter openesc close