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 Unverified
SHA256:H91DFuEG4jpk5edzLA4aUKkpv4zv9Q339ipRmkph6TkThe key is unknown
10 files · +208 -6
M src/convex/http.ts +24 -0
| @@ -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 | 187 | // ============ SESSIONS (server-side only) ============ | |
| 164 | 188 | ||
| 165 | 189 | // 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 login | 189 | // Create session - for login | ||
M src/convex/mutations.ts +19 -0
| @@ -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 | + | }); | |
| @@ -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({ | |||
| 237 | 237 | nickname: ctx.user.nickname, | |
| 238 | 238 | status: ctx.user.status, | |
| 239 | 239 | avatarUrl: ctx.user.avatarUrl, | |
| 240 | - | lastSeen: ctx.user.lastSeen | |
| 240 | + | lastSeen: ctx.user.lastSeen, | |
| 241 | + | lastReadMentionTimestamp: ctx.user.lastReadMentionTimestamp | |
| 241 | 242 | }; | |
| 242 | 243 | } | |
| 243 | 244 | }); | |
| @@ -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.lastSeen | 240 | + | 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({ | |||
| 8 | 8 | status: v.string(), // 'offline' | 'online' | 'away' | 'busy' | |
| 9 | 9 | avatarUrl: v.optional(v.string()), | |
| 10 | 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 | 14 | .index('by_nickname', ['nickname']) | |
| 14 | 15 | .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({ | |||
| 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 | 122 | // Cleanup stale users who haven't sent a heartbeat in 2 minutes | |
| 98 | 123 | export const cleanupStaleUsers = internalMutation({ | |
| 99 | 124 | 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 minutes | 122 | // 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 @@ | |||
| 153 | 153 | ||
| 154 | 154 | let currentMessage = $state(''); | |
| 155 | 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 | 157 | let currentTextStyle = $state<TextStyle>({ | |
| 158 | 158 | ...DEFAULT_TEXT_STYLE, | |
| 159 | 159 | ...initialTextStyle, | |
| 160 | 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 | 166 | // ============ MENTION STATE ============ | |
| 164 | 167 | ||
| @@ -172,6 +175,7 @@ | |||
| 172 | 175 | let mentionUnreadCount = $state(0); | |
| 173 | 176 | let mentionDismissKey = $state<string | null>(null); | |
| 174 | 177 | let mentionObserver: IntersectionObserver | null = null; | |
| 178 | + | let pendingMentionTimestamp = $state<number | null>(null); | |
| 175 | 179 | ||
| 176 | 180 | let baseTitle = $state(''); | |
| 177 | 181 | const mentionListBoxId = `mentions-${Math.random().toString(36).slice(2, 8)}`; | |
| @@ -266,6 +270,27 @@ | |||
| 266 | 270 | ||
| 267 | 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 | 294 | // Auto-scroll to bottom when new messages arrive | |
| 270 | 295 | $effect(() => { | |
| 271 | 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 | 322 | // Update oldest timestamp for pagination | |
| 284 | 323 | $effect(() => { | |
| 285 | 324 | if (allMessages.length > 0) { | |
| @@ -317,9 +356,15 @@ | |||
| 317 | 356 | return; | |
| 318 | 357 | } | |
| 319 | 358 | const mentionIds = new SvelteSet<string>(); | |
| 359 | + | const lastReadTimestamp = currentUser.lastReadMentionTimestamp ?? 0; | |
| 360 | + | ||
| 320 | 361 | for (const message of visibleMessages) { | |
| 321 | 362 | if (shouldHighlightMention(message)) { | |
| 322 | 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 | 370 | const nextMentionIds = mentionIds; | |
| @@ -347,6 +392,8 @@ | |||
| 347 | 392 | let changed = false; | |
| 348 | 393 | const currentObserved = mentionObservedIds; | |
| 349 | 394 | const currentUnread = mentionUnreadCount; | |
| 395 | + | let maxTimestamp = pendingMentionTimestamp ?? 0; | |
| 396 | + | ||
| 350 | 397 | for (const entry of entries) { | |
| 351 | 398 | const target = entry.target as HTMLElement; | |
| 352 | 399 | const messageId = target.dataset.messageId; | |
| @@ -354,6 +401,12 @@ | |||
| 354 | 401 | if (entry.isIntersecting && !updatedObserved.has(messageId)) { | |
| 355 | 402 | updatedObserved.add(messageId); | |
| 356 | 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 | 412 | if (changed && !setsEqual(updatedObserved, currentObserved)) { | |
| @@ -362,6 +415,12 @@ | |||
| 362 | 415 | if (currentUnread !== nextUnread) { | |
| 363 | 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 | 426 | { root: chatArea, threshold: 0.6 } | |
| @@ -485,6 +544,21 @@ | |||
| 485 | 544 | ||
| 486 | 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 | 562 | function setsEqual(a: Set<string>, b: Set<string>) { | |
| 489 | 563 | if (a.size !== b.size) return false; | |
| 490 | 564 | 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 arrive | 294 | // 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 pagination | 322 | // 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 = { | |||
| 43 | 43 | ||
| 44 | 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 | 55 | // ============ SESSIONS ============ | |
| @@ -113,6 +119,7 @@ export interface ConvexUser { | |||
| 113 | 119 | avatarUrl?: string; | |
| 114 | 120 | createdAt: number; | |
| 115 | 121 | lastSeen?: number; | |
| 122 | + | lastReadMentionTimestamp?: number; | |
| 116 | 123 | } | |
| 117 | 124 | ||
| 118 | 125 | 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 { | |||
| 7 | 7 | avatarUrl?: string | null; | |
| 8 | 8 | createdAt: number; // Timestamp (e.g., Unix timestamp) | |
| 9 | 9 | lastSeen?: number | null; // Optional last active timestamp | |
| 10 | + | lastReadMentionTimestamp?: number | null; // Timestamp of last read mention | |
| 10 | 11 | } | |
| 11 | 12 | ||
| 12 | 13 | export type UserStatus = 'offline' | 'online' | 'away' | string; | |
| @@ -43,7 +44,8 @@ export function createSafeUser( | |||
| 43 | 44 | nickname: user.nickname, | |
| 44 | 45 | status: user.status, | |
| 45 | 46 | avatarUrl: user.avatarUrl, | |
| 46 | - | lastSeen: user.lastSeen ?? 0 | |
| 47 | + | lastSeen: user.lastSeen ?? 0, | |
| 48 | + | lastReadMentionTimestamp: user.lastReadMentionTimestamp ?? null | |
| 47 | 49 | }; | |
| 48 | 50 | return safeUser; | |
| 49 | 51 | } | |
| @@ -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 timestamp | 9 | 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 ?? 0 | 47 | + | 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 }) => { | |||
| 167 | 167 | nickname: user.nickname, | |
| 168 | 168 | status: 'online', | |
| 169 | 169 | avatarUrl: user.avatarUrl, | |
| 170 | - | lastSeen: user.lastSeen | |
| 170 | + | lastSeen: user.lastSeen, | |
| 171 | + | lastReadMentionTimestamp: user.lastReadMentionTimestamp | |
| 171 | 172 | }) | |
| 172 | 173 | } as LoginResponseSuccess), | |
| 173 | 174 | { | |
| @@ -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.lastSeen | 170 | + | lastSeen: user.lastSeen, |
| 171 | + | lastReadMentionTimestamp: user.lastReadMentionTimestamp | |||
| 171 | }) | 172 | }) | ||
| 172 | } as LoginResponseSuccess), | 173 | } as LoginResponseSuccess), | ||
| 173 | { | 174 | { | ||