Merge pull request #82 from kitpdr/fix/chat-ux-improvements
fix(chat): improve UX with scroll, notifications, and font style persistence
11 files · +217 -11
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/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/api/session.server.ts +2 -1
| @@ -92,7 +92,8 @@ export async function validateSessionToken(token: string): Promise<SessionValida | |||
| 92 | 92 | nickname: result.user.nickname, | |
| 93 | 93 | status: result.user.status as SafeUser['status'], | |
| 94 | 94 | avatarUrl: result.user.avatarUrl, | |
| 95 | - | lastSeen: result.user.lastSeen | |
| 95 | + | lastSeen: result.user.lastSeen, | |
| 96 | + | lastReadMentionTimestamp: result.user.lastReadMentionTimestamp | |
| 96 | 97 | }; | |
| 97 | 98 | ||
| 98 | 99 | 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.lastSeen | 95 | + | 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 @@ | |||
| 5 | 5 | import type { Id } from '../../convex/_generated/dataModel'; | |
| 6 | 6 | import type { Message, EnrichedMessage, SafeUser } from '../types/chat'; | |
| 7 | 7 | import { onMount, tick } from 'svelte'; | |
| 8 | - | import { SvelteSet } from 'svelte/reactivity'; | |
| 8 | + | import { SvelteSet, SvelteMap } from 'svelte/reactivity'; | |
| 9 | 9 | import { browser } from '$app/environment'; | |
| 10 | 10 | import { draggable } from '$lib/actions/draggable'; | |
| 11 | 11 | import { resizable } from '$lib/actions/resizable'; | |
| @@ -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) { | |
| @@ -309,6 +348,8 @@ | |||
| 309 | 348 | if (mentionUnreadCount !== 0) { | |
| 310 | 349 | mentionUnreadCount = 0; | |
| 311 | 350 | } | |
| 351 | + | // Reset pending timestamp when user logs out | |
| 352 | + | pendingMentionTimestamp = null; | |
| 312 | 353 | // mention observer reset | |
| 313 | 354 | if (mentionObserver) { | |
| 314 | 355 | mentionObserver.disconnect(); | |
| @@ -317,11 +358,22 @@ | |||
| 317 | 358 | return; | |
| 318 | 359 | } | |
| 319 | 360 | const mentionIds = new SvelteSet<string>(); | |
| 361 | + | const lastReadTimestamp = currentUser.lastReadMentionTimestamp ?? 0; | |
| 362 | + | const mentionIdsToPreObserve: string[] = []; | |
| 363 | + | ||
| 320 | 364 | for (const message of visibleMessages) { | |
| 321 | 365 | if (shouldHighlightMention(message)) { | |
| 322 | 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 | 377 | const nextMentionIds = mentionIds; | |
| 326 | 378 | if (!setsEqual(nextMentionIds, mentionMessageIds)) { | |
| 327 | 379 | mentionMessageIds = nextMentionIds; | |
| @@ -341,12 +393,19 @@ | |||
| 341 | 393 | if (mentionObserver) { | |
| 342 | 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 | 401 | mentionObserver = new IntersectionObserver( | |
| 345 | 402 | (entries) => { | |
| 346 | 403 | const updatedObserved = new SvelteSet(mentionObservedIds); | |
| 347 | 404 | let changed = false; | |
| 348 | 405 | const currentObserved = mentionObservedIds; | |
| 349 | 406 | const currentUnread = mentionUnreadCount; | |
| 407 | + | let maxTimestamp = pendingMentionTimestamp ?? 0; | |
| 408 | + | ||
| 350 | 409 | for (const entry of entries) { | |
| 351 | 410 | const target = entry.target as HTMLElement; | |
| 352 | 411 | const messageId = target.dataset.messageId; | |
| @@ -354,6 +413,12 @@ | |||
| 354 | 413 | if (entry.isIntersecting && !updatedObserved.has(messageId)) { | |
| 355 | 414 | updatedObserved.add(messageId); | |
| 356 | 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 | 424 | if (changed && !setsEqual(updatedObserved, currentObserved)) { | |
| @@ -362,6 +427,12 @@ | |||
| 362 | 427 | if (currentUnread !== nextUnread) { | |
| 363 | 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 | 438 | { root: chatArea, threshold: 0.6 } | |
| @@ -485,6 +556,21 @@ | |||
| 485 | 556 | ||
| 486 | 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 | 574 | function setsEqual(a: Set<string>, b: Set<string>) { | |
| 489 | 575 | if (a.size !== b.size) return false; | |
| 490 | 576 | for (const value of a) { | |
| @@ -1451,7 +1537,7 @@ | |||
| 1451 | 1537 | } | |
| 1452 | 1538 | ||
| 1453 | 1539 | .message-content :global(.mention-token) { | |
| 1454 | - | color: #1e2a72; | |
| 1540 | + | color: inherit; | |
| 1455 | 1541 | font-weight: bold; | |
| 1456 | 1542 | } | |
| 1457 | 1543 | ||
| @@ -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 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) { | ||
| @@ -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 reset | 353 | // 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 @@ | |||
| 236 | 236 | color: inherit; | |
| 237 | 237 | } | |
| 238 | 238 | ||
| 239 | - | /* Mention token styling */ | |
| 239 | + | /* Mention token styling - inherits message color/font, just adds bold */ | |
| 240 | 240 | :global(.mention-token) { | |
| 241 | - | color: #1e2a72; | |
| 241 | + | color: inherit; | |
| 242 | 242 | font-weight: bold; | |
| 243 | 243 | } | |
| 244 | 244 | ||
| @@ -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 = { | |||
| 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 +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 }) => { | |||
| 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 | { | ||