feat(api): add centralized API client and enforce public message limit
- Create API client with typed helpers for text preferences, mentions, session, and chat operations - Fetch user text style preferences directly from API after login - Remove unused initialTextStyle prop from chat-room component - Enforce 100 message limit for public Convex query (server-side) - Remove default limit from authenticated query
- commit
- 2919a84e33877c00eb4d445cde9c167342398bf0
- author
- Kit · · 2026-01-24 06:11 UTC
- parent
- 5a6bd11
- signature
- SSH Unverified
SHA256:H91DFuEG4jpk5edzLA4aUKkpv4zv9Q339ipRmkph6TkThe key is unknown
4 files · +255 -78
M src/convex/queries.ts +9 -6
| @@ -26,13 +26,17 @@ function computeEffectiveStatus( | |||
| 26 | 26 | // ============ PUBLIC QUERIES (no auth required) ============ | |
| 27 | 27 | ||
| 28 | 28 | // Public query: Get messages for a chat room (real-time subscription) | |
| 29 | + | // Max 100 messages for public/unauthenticated users | |
| 30 | + | const PUBLIC_MESSAGE_LIMIT = 100; | |
| 31 | + | ||
| 29 | 32 | export const getMessagesPublic = query({ | |
| 30 | 33 | args: { | |
| 31 | 34 | roomId: v.id('chatRooms'), | |
| 32 | 35 | limit: v.optional(v.number()) | |
| 33 | 36 | }, | |
| 34 | 37 | handler: async (ctx, args) => { | |
| 35 | - | const limit = args.limit ?? 50; | |
| 38 | + | // Enforce max limit - client cannot request more than PUBLIC_MESSAGE_LIMIT | |
| 39 | + | const limit = Math.min(args.limit ?? PUBLIC_MESSAGE_LIMIT, PUBLIC_MESSAGE_LIMIT); | |
| 36 | 40 | ||
| 37 | 41 | const messages = await ctx.db | |
| 38 | 42 | .query('messages') | |
| @@ -127,13 +131,12 @@ export const getMessages = authQuery({ | |||
| 127 | 131 | limit: v.optional(v.number()) | |
| 128 | 132 | }, | |
| 129 | 133 | handler: async (ctx, args) => { | |
| 130 | - | const limit = args.limit ?? 100; | |
| 131 | - | ||
| 132 | - | const messages = await ctx.db | |
| 134 | + | const query = ctx.db | |
| 133 | 135 | .query('messages') | |
| 134 | 136 | .withIndex('by_chatRoom', (q) => q.eq('chatRoomId', args.roomId)) | |
| 135 | - | .order('desc') | |
| 136 | - | .take(limit); | |
| 137 | + | .order('desc'); | |
| 138 | + | ||
| 139 | + | const messages = args.limit ? await query.take(args.limit) : await query.collect(); | |
| 137 | 140 | ||
| 138 | 141 | // Enrich messages with sender info | |
| 139 | 142 | const enrichedMessages = await Promise.all( | |
| @@ -26,13 +26,17 @@ function computeEffectiveStatus( | |||||
| 26 | // ============ PUBLIC QUERIES (no auth required) ============ | 26 | // ============ PUBLIC QUERIES (no auth required) ============ | ||
| 27 | 27 | ||||
| 28 | // Public query: Get messages for a chat room (real-time subscription) | 28 | // Public query: Get messages for a chat room (real-time subscription) | ||
| 29 | + | // Max 100 messages for public/unauthenticated users | |||
| 30 | + | const PUBLIC_MESSAGE_LIMIT = 100; | |||
| 31 | + | ||||
| 29 | export const getMessagesPublic = query({ | 32 | export const getMessagesPublic = query({ | ||
| 30 | args: { | 33 | args: { | ||
| 31 | roomId: v.id('chatRooms'), | 34 | roomId: v.id('chatRooms'), | ||
| 32 | limit: v.optional(v.number()) | 35 | limit: v.optional(v.number()) | ||
| 33 | }, | 36 | }, | ||
| 34 | handler: async (ctx, args) => { | 37 | handler: async (ctx, args) => { | ||
| 35 | - | const limit = args.limit ?? 50; | 38 | + | // Enforce max limit - client cannot request more than PUBLIC_MESSAGE_LIMIT |
| 39 | + | const limit = Math.min(args.limit ?? PUBLIC_MESSAGE_LIMIT, PUBLIC_MESSAGE_LIMIT); | |||
| 36 | 40 | ||||
| 37 | const messages = await ctx.db | 41 | const messages = await ctx.db | ||
| 38 | .query('messages') | 42 | .query('messages') | ||
| @@ -127,13 +131,12 @@ export const getMessages = authQuery({ | |||||
| 127 | limit: v.optional(v.number()) | 131 | limit: v.optional(v.number()) | ||
| 128 | }, | 132 | }, | ||
| 129 | handler: async (ctx, args) => { | 133 | handler: async (ctx, args) => { | ||
| 130 | - | const limit = args.limit ?? 100; | 134 | + | const query = ctx.db |
| 131 | - | ||||
| 132 | - | const messages = await ctx.db | |||
| 133 | .query('messages') | 135 | .query('messages') | ||
| 134 | .withIndex('by_chatRoom', (q) => q.eq('chatRoomId', args.roomId)) | 136 | .withIndex('by_chatRoom', (q) => q.eq('chatRoomId', args.roomId)) | ||
| 135 | - | .order('desc') | 137 | + | .order('desc'); |
| 136 | - | .take(limit); | 138 | + | |
| 139 | + | const messages = args.limit ? await query.take(args.limit) : await query.collect(); | |||
| 137 | 140 | ||||
| 138 | // Enrich messages with sender info | 141 | // Enrich messages with sender info | ||
| 139 | const enrichedMessages = await Promise.all( | 142 | const enrichedMessages = await Promise.all( | ||
A src/lib/api/client.ts +193 -0
| @@ -0,0 +1,193 @@ | |||
| 1 | + | /** | |
| 2 | + | * API Client for SvelteKit endpoints | |
| 3 | + | * Provides typed helper functions for all API calls | |
| 4 | + | */ | |
| 5 | + | ||
| 6 | + | import type { TextStyle, UserTextPreferences } from '$lib/types/text-formatting'; | |
| 7 | + | ||
| 8 | + | // ============ TYPES ============ | |
| 9 | + | ||
| 10 | + | export interface ApiResponse<T> { | |
| 11 | + | success: boolean; | |
| 12 | + | data?: T; | |
| 13 | + | error?: string; | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | export interface TextPreferencesResponse { | |
| 17 | + | success: boolean; | |
| 18 | + | preferences: UserTextPreferences | null; | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | // ============ BASE CLIENT ============ | |
| 22 | + | ||
| 23 | + | async function request<T>(endpoint: string, options: RequestInit = {}): Promise<ApiResponse<T>> { | |
| 24 | + | try { | |
| 25 | + | const response = await fetch(endpoint, { | |
| 26 | + | credentials: 'include', | |
| 27 | + | headers: { | |
| 28 | + | 'Content-Type': 'application/json', | |
| 29 | + | ...options.headers | |
| 30 | + | }, | |
| 31 | + | ...options | |
| 32 | + | }); | |
| 33 | + | ||
| 34 | + | const data = await response.json(); | |
| 35 | + | ||
| 36 | + | if (!response.ok) { | |
| 37 | + | return { | |
| 38 | + | success: false, | |
| 39 | + | error: data.error || `Request failed with status ${response.status}` | |
| 40 | + | }; | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | return { success: true, data }; | |
| 44 | + | } catch (error) { | |
| 45 | + | return { | |
| 46 | + | success: false, | |
| 47 | + | error: error instanceof Error ? error.message : 'Unknown error' | |
| 48 | + | }; | |
| 49 | + | } | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | // ============ TEXT PREFERENCES ============ | |
| 53 | + | ||
| 54 | + | export const textPreferences = { | |
| 55 | + | /** | |
| 56 | + | * Get current user's text preferences | |
| 57 | + | */ | |
| 58 | + | async get(): Promise<UserTextPreferences | null> { | |
| 59 | + | const result = await request<TextPreferencesResponse>('/api/user/text-preferences'); | |
| 60 | + | if (result.success && result.data?.preferences) { | |
| 61 | + | return result.data.preferences; | |
| 62 | + | } | |
| 63 | + | return null; | |
| 64 | + | }, | |
| 65 | + | ||
| 66 | + | /** | |
| 67 | + | * Save user's text preferences (partial update supported) | |
| 68 | + | */ | |
| 69 | + | async save(preferences: { | |
| 70 | + | defaultStyle: TextStyle; | |
| 71 | + | allowFormatting?: boolean; | |
| 72 | + | maxMessageLength?: number; | |
| 73 | + | }): Promise<boolean> { | |
| 74 | + | const result = await request('/api/user/text-preferences', { | |
| 75 | + | method: 'POST', | |
| 76 | + | body: JSON.stringify(preferences) | |
| 77 | + | }); | |
| 78 | + | return result.success; | |
| 79 | + | } | |
| 80 | + | }; | |
| 81 | + | ||
| 82 | + | // ============ MENTIONS ============ | |
| 83 | + | ||
| 84 | + | export const mentions = { | |
| 85 | + | /** | |
| 86 | + | * Update the timestamp of the last read mention | |
| 87 | + | */ | |
| 88 | + | async markRead(timestamp: number): Promise<boolean> { | |
| 89 | + | const result = await request('/api/chat/mention-read', { | |
| 90 | + | method: 'POST', | |
| 91 | + | body: JSON.stringify({ timestamp }) | |
| 92 | + | }); | |
| 93 | + | return result.success; | |
| 94 | + | } | |
| 95 | + | }; | |
| 96 | + | ||
| 97 | + | // ============ SESSION ============ | |
| 98 | + | ||
| 99 | + | export interface LoginResponse { | |
| 100 | + | success: boolean; | |
| 101 | + | user?: { | |
| 102 | + | id: string; | |
| 103 | + | nickname: string; | |
| 104 | + | status: string; | |
| 105 | + | }; | |
| 106 | + | error?: string; | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | export const session = { | |
| 110 | + | /** | |
| 111 | + | * Login with username and password | |
| 112 | + | */ | |
| 113 | + | async login(username: string, password: string): Promise<LoginResponse> { | |
| 114 | + | const result = await request<LoginResponse>('/api/session/login', { | |
| 115 | + | method: 'POST', | |
| 116 | + | body: JSON.stringify({ username, password }) | |
| 117 | + | }); | |
| 118 | + | return result.data ?? { success: false, error: result.error }; | |
| 119 | + | }, | |
| 120 | + | ||
| 121 | + | /** | |
| 122 | + | * Register a new user | |
| 123 | + | */ | |
| 124 | + | async register( | |
| 125 | + | username: string, | |
| 126 | + | password: string, | |
| 127 | + | confirmPassword: string | |
| 128 | + | ): Promise<LoginResponse> { | |
| 129 | + | const result = await request<LoginResponse>('/api/register', { | |
| 130 | + | method: 'POST', | |
| 131 | + | body: JSON.stringify({ | |
| 132 | + | suUsername: username, | |
| 133 | + | suPassword: password, | |
| 134 | + | suConfirmPassword: confirmPassword | |
| 135 | + | }) | |
| 136 | + | }); | |
| 137 | + | return result.data ?? { success: false, error: result.error }; | |
| 138 | + | } | |
| 139 | + | }; | |
| 140 | + | ||
| 141 | + | // ============ CHAT ============ | |
| 142 | + | ||
| 143 | + | export interface SendMessageParams { | |
| 144 | + | content: string; | |
| 145 | + | roomId: string; | |
| 146 | + | styleData?: string; | |
| 147 | + | hasFormatting?: boolean; | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | export const chat = { | |
| 151 | + | /** | |
| 152 | + | * Send a message to a chat room | |
| 153 | + | */ | |
| 154 | + | async sendMessage(params: SendMessageParams): Promise<boolean> { | |
| 155 | + | const result = await request('/api/chat/messages', { | |
| 156 | + | method: 'POST', | |
| 157 | + | body: JSON.stringify(params) | |
| 158 | + | }); | |
| 159 | + | return result.success; | |
| 160 | + | }, | |
| 161 | + | ||
| 162 | + | /** | |
| 163 | + | * Load more messages (pagination) | |
| 164 | + | */ | |
| 165 | + | async loadMessages( | |
| 166 | + | roomId: string, | |
| 167 | + | beforeTimestamp?: number, | |
| 168 | + | isPublic = false | |
| 169 | + | ): Promise<{ messages: unknown[]; hasMore: boolean } | null> { | |
| 170 | + | const params = new URLSearchParams({ roomId }); | |
| 171 | + | if (beforeTimestamp) params.set('before', beforeTimestamp.toString()); | |
| 172 | + | if (isPublic) params.set('public', 'true'); | |
| 173 | + | ||
| 174 | + | const result = await request<{ success: boolean; messages: unknown[]; hasMore: boolean }>( | |
| 175 | + | `/api/chat/messages?${params.toString()}` | |
| 176 | + | ); | |
| 177 | + | if (result.success && result.data) { | |
| 178 | + | return { messages: result.data.messages, hasMore: result.data.hasMore }; | |
| 179 | + | } | |
| 180 | + | return null; | |
| 181 | + | } | |
| 182 | + | }; | |
| 183 | + | ||
| 184 | + | // ============ EXPORT ============ | |
| 185 | + | ||
| 186 | + | export const api = { | |
| 187 | + | textPreferences, | |
| 188 | + | mentions, | |
| 189 | + | session, | |
| 190 | + | chat | |
| 191 | + | }; | |
| 192 | + | ||
| 193 | + | export default api; | |
| @@ -0,0 +1,193 @@ | |||||
| 1 | + | /** | |||
| 2 | + | * API Client for SvelteKit endpoints | |||
| 3 | + | * Provides typed helper functions for all API calls | |||
| 4 | + | */ | |||
| 5 | + | ||||
| 6 | + | import type { TextStyle, UserTextPreferences } from '$lib/types/text-formatting'; | |||
| 7 | + | ||||
| 8 | + | // ============ TYPES ============ | |||
| 9 | + | ||||
| 10 | + | export interface ApiResponse<T> { | |||
| 11 | + | success: boolean; | |||
| 12 | + | data?: T; | |||
| 13 | + | error?: string; | |||
| 14 | + | } | |||
| 15 | + | ||||
| 16 | + | export interface TextPreferencesResponse { | |||
| 17 | + | success: boolean; | |||
| 18 | + | preferences: UserTextPreferences | null; | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | // ============ BASE CLIENT ============ | |||
| 22 | + | ||||
| 23 | + | async function request<T>(endpoint: string, options: RequestInit = {}): Promise<ApiResponse<T>> { | |||
| 24 | + | try { | |||
| 25 | + | const response = await fetch(endpoint, { | |||
| 26 | + | credentials: 'include', | |||
| 27 | + | headers: { | |||
| 28 | + | 'Content-Type': 'application/json', | |||
| 29 | + | ...options.headers | |||
| 30 | + | }, | |||
| 31 | + | ...options | |||
| 32 | + | }); | |||
| 33 | + | ||||
| 34 | + | const data = await response.json(); | |||
| 35 | + | ||||
| 36 | + | if (!response.ok) { | |||
| 37 | + | return { | |||
| 38 | + | success: false, | |||
| 39 | + | error: data.error || `Request failed with status ${response.status}` | |||
| 40 | + | }; | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | return { success: true, data }; | |||
| 44 | + | } catch (error) { | |||
| 45 | + | return { | |||
| 46 | + | success: false, | |||
| 47 | + | error: error instanceof Error ? error.message : 'Unknown error' | |||
| 48 | + | }; | |||
| 49 | + | } | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | // ============ TEXT PREFERENCES ============ | |||
| 53 | + | ||||
| 54 | + | export const textPreferences = { | |||
| 55 | + | /** | |||
| 56 | + | * Get current user's text preferences | |||
| 57 | + | */ | |||
| 58 | + | async get(): Promise<UserTextPreferences | null> { | |||
| 59 | + | const result = await request<TextPreferencesResponse>('/api/user/text-preferences'); | |||
| 60 | + | if (result.success && result.data?.preferences) { | |||
| 61 | + | return result.data.preferences; | |||
| 62 | + | } | |||
| 63 | + | return null; | |||
| 64 | + | }, | |||
| 65 | + | ||||
| 66 | + | /** | |||
| 67 | + | * Save user's text preferences (partial update supported) | |||
| 68 | + | */ | |||
| 69 | + | async save(preferences: { | |||
| 70 | + | defaultStyle: TextStyle; | |||
| 71 | + | allowFormatting?: boolean; | |||
| 72 | + | maxMessageLength?: number; | |||
| 73 | + | }): Promise<boolean> { | |||
| 74 | + | const result = await request('/api/user/text-preferences', { | |||
| 75 | + | method: 'POST', | |||
| 76 | + | body: JSON.stringify(preferences) | |||
| 77 | + | }); | |||
| 78 | + | return result.success; | |||
| 79 | + | } | |||
| 80 | + | }; | |||
| 81 | + | ||||
| 82 | + | // ============ MENTIONS ============ | |||
| 83 | + | ||||
| 84 | + | export const mentions = { | |||
| 85 | + | /** | |||
| 86 | + | * Update the timestamp of the last read mention | |||
| 87 | + | */ | |||
| 88 | + | async markRead(timestamp: number): Promise<boolean> { | |||
| 89 | + | const result = await request('/api/chat/mention-read', { | |||
| 90 | + | method: 'POST', | |||
| 91 | + | body: JSON.stringify({ timestamp }) | |||
| 92 | + | }); | |||
| 93 | + | return result.success; | |||
| 94 | + | } | |||
| 95 | + | }; | |||
| 96 | + | ||||
| 97 | + | // ============ SESSION ============ | |||
| 98 | + | ||||
| 99 | + | export interface LoginResponse { | |||
| 100 | + | success: boolean; | |||
| 101 | + | user?: { | |||
| 102 | + | id: string; | |||
| 103 | + | nickname: string; | |||
| 104 | + | status: string; | |||
| 105 | + | }; | |||
| 106 | + | error?: string; | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | export const session = { | |||
| 110 | + | /** | |||
| 111 | + | * Login with username and password | |||
| 112 | + | */ | |||
| 113 | + | async login(username: string, password: string): Promise<LoginResponse> { | |||
| 114 | + | const result = await request<LoginResponse>('/api/session/login', { | |||
| 115 | + | method: 'POST', | |||
| 116 | + | body: JSON.stringify({ username, password }) | |||
| 117 | + | }); | |||
| 118 | + | return result.data ?? { success: false, error: result.error }; | |||
| 119 | + | }, | |||
| 120 | + | ||||
| 121 | + | /** | |||
| 122 | + | * Register a new user | |||
| 123 | + | */ | |||
| 124 | + | async register( | |||
| 125 | + | username: string, | |||
| 126 | + | password: string, | |||
| 127 | + | confirmPassword: string | |||
| 128 | + | ): Promise<LoginResponse> { | |||
| 129 | + | const result = await request<LoginResponse>('/api/register', { | |||
| 130 | + | method: 'POST', | |||
| 131 | + | body: JSON.stringify({ | |||
| 132 | + | suUsername: username, | |||
| 133 | + | suPassword: password, | |||
| 134 | + | suConfirmPassword: confirmPassword | |||
| 135 | + | }) | |||
| 136 | + | }); | |||
| 137 | + | return result.data ?? { success: false, error: result.error }; | |||
| 138 | + | } | |||
| 139 | + | }; | |||
| 140 | + | ||||
| 141 | + | // ============ CHAT ============ | |||
| 142 | + | ||||
| 143 | + | export interface SendMessageParams { | |||
| 144 | + | content: string; | |||
| 145 | + | roomId: string; | |||
| 146 | + | styleData?: string; | |||
| 147 | + | hasFormatting?: boolean; | |||
| 148 | + | } | |||
| 149 | + | ||||
| 150 | + | export const chat = { | |||
| 151 | + | /** | |||
| 152 | + | * Send a message to a chat room | |||
| 153 | + | */ | |||
| 154 | + | async sendMessage(params: SendMessageParams): Promise<boolean> { | |||
| 155 | + | const result = await request('/api/chat/messages', { | |||
| 156 | + | method: 'POST', | |||
| 157 | + | body: JSON.stringify(params) | |||
| 158 | + | }); | |||
| 159 | + | return result.success; | |||
| 160 | + | }, | |||
| 161 | + | ||||
| 162 | + | /** | |||
| 163 | + | * Load more messages (pagination) | |||
| 164 | + | */ | |||
| 165 | + | async loadMessages( | |||
| 166 | + | roomId: string, | |||
| 167 | + | beforeTimestamp?: number, | |||
| 168 | + | isPublic = false | |||
| 169 | + | ): Promise<{ messages: unknown[]; hasMore: boolean } | null> { | |||
| 170 | + | const params = new URLSearchParams({ roomId }); | |||
| 171 | + | if (beforeTimestamp) params.set('before', beforeTimestamp.toString()); | |||
| 172 | + | if (isPublic) params.set('public', 'true'); | |||
| 173 | + | ||||
| 174 | + | const result = await request<{ success: boolean; messages: unknown[]; hasMore: boolean }>( | |||
| 175 | + | `/api/chat/messages?${params.toString()}` | |||
| 176 | + | ); | |||
| 177 | + | if (result.success && result.data) { | |||
| 178 | + | return { messages: result.data.messages, hasMore: result.data.hasMore }; | |||
| 179 | + | } | |||
| 180 | + | return null; | |||
| 181 | + | } | |||
| 182 | + | }; | |||
| 183 | + | ||||
| 184 | + | // ============ EXPORT ============ | |||
| 185 | + | ||||
| 186 | + | export const api = { | |||
| 187 | + | textPreferences, | |||
| 188 | + | mentions, | |||
| 189 | + | session, | |||
| 190 | + | chat | |||
| 191 | + | }; | |||
| 192 | + | ||||
| 193 | + | export default api; | |||
M src/lib/components/chat-room.svelte +52 -68
| @@ -1,12 +1,13 @@ | |||
| 1 | 1 | <script lang="ts"> | |
| 2 | 2 | import { chatState } from '../states/chat.svelte'; | |
| 3 | 3 | import { useQuery } from 'convex-svelte'; | |
| 4 | - | import { api } from '../../convex/_generated/api'; | |
| 4 | + | import { api as convexApi } from '../../convex/_generated/api'; | |
| 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 | 8 | import { SvelteSet, SvelteMap } from 'svelte/reactivity'; | |
| 9 | 9 | import { browser } from '$app/environment'; | |
| 10 | + | import { api } from '$lib/api/client'; | |
| 10 | 11 | import { draggable } from '$lib/actions/draggable'; | |
| 11 | 12 | import { resizable } from '$lib/actions/resizable'; | |
| 12 | 13 | import { maximizable } from '$lib/actions/maximizable'; | |
| @@ -33,22 +34,22 @@ | |||
| 33 | 34 | import { MAX_MESSAGE_LENGTH } from '$lib/validation/message'; | |
| 34 | 35 | ||
| 35 | 36 | // Props | |
| 36 | - | let { showChatRoom = $bindable(), initialTextStyle = DEFAULT_TEXT_STYLE } = $props(); | |
| 37 | + | let { showChatRoom = $bindable() } = $props(); | |
| 37 | 38 | ||
| 38 | 39 | // ============ CONVEX REAL-TIME SUBSCRIPTIONS ============ | |
| 39 | 40 | ||
| 40 | 41 | // Subscribe to default room (to get room ID) | |
| 41 | - | const defaultRoomQuery = useQuery(api.queries.getDefaultRoomPublic, {}); | |
| 42 | + | const defaultRoomQuery = useQuery(convexApi.queries.getDefaultRoomPublic, {}); | |
| 42 | 43 | ||
| 43 | 44 | // Subscribe to users (buddy list) - real-time updates | |
| 44 | - | const usersQuery = useQuery(api.queries.getUsersPublic, {}); | |
| 45 | + | const usersQuery = useQuery(convexApi.queries.getUsersPublic, {}); | |
| 45 | 46 | ||
| 46 | 47 | // Room ID from Convex query | |
| 47 | 48 | const roomId = $derived(defaultRoomQuery.data?.id as Id<'chatRooms'> | undefined); | |
| 48 | 49 | ||
| 49 | 50 | // Subscribe to messages - only when room ID is available | |
| 50 | 51 | const messagesQuery = $derived( | |
| 51 | - | roomId ? useQuery(api.queries.getMessagesPublic, () => ({ roomId: roomId!, limit: 100 })) : null | |
| 52 | + | roomId ? useQuery(convexApi.queries.getMessagesPublic, () => ({ roomId: roomId! })) : null | |
| 52 | 53 | ); | |
| 53 | 54 | ||
| 54 | 55 | // Transform Convex users to SafeUser format | |
| @@ -153,12 +154,8 @@ | |||
| 153 | 154 | ||
| 154 | 155 | let currentMessage = $state(''); | |
| 155 | 156 | let inputScrollLeft = $state(0); | |
| 156 | - | // Initialize text style with prop value | |
| 157 | - | let currentTextStyle = $state<TextStyle>({ | |
| 158 | - | ...DEFAULT_TEXT_STYLE, | |
| 159 | - | ...initialTextStyle, | |
| 160 | - | color: initialTextStyle.color || '#000000' | |
| 161 | - | }); | |
| 157 | + | // Initialize text style with defaults - prop value applied via effect below | |
| 158 | + | let currentTextStyle = $state<TextStyle>({ ...DEFAULT_TEXT_STYLE }); | |
| 162 | 159 | // Track if we've applied user's style from DB (to avoid resetting on every prop change) | |
| 163 | 160 | let hasAppliedUserStyle = $state(false); | |
| 164 | 161 | let lastUserId = $state<string | null>(null); | |
| @@ -202,7 +199,7 @@ | |||
| 202 | 199 | let isLoadingMore = $state(false); | |
| 203 | 200 | let hasMoreMessages = $state(true); | |
| 204 | 201 | let oldestMessageTimestamp = $state<number | null>(null); | |
| 205 | - | let lastMessageCount = $state(0); | |
| 202 | + | let lastMessageSignature = $state<string | null>(null); | |
| 206 | 203 | let hasInitialScrolled = $state(false); | |
| 207 | 204 | ||
| 208 | 205 | // ============ DERIVED STATE ============ | |
| @@ -271,7 +268,7 @@ | |||
| 271 | 268 | ||
| 272 | 269 | // ============ EFFECTS ============ | |
| 273 | 270 | ||
| 274 | - | // Update text style when user logs in and their saved style is loaded from DB | |
| 271 | + | // Update text style when user logs in - fetch preferences directly from API | |
| 275 | 272 | $effect(() => { | |
| 276 | 273 | const userId = currentUser?.id ?? null; | |
| 277 | 274 | ||
| @@ -281,14 +278,19 @@ | |||
| 281 | 278 | hasAppliedUserStyle = false; | |
| 282 | 279 | } | |
| 283 | 280 | ||
| 284 | - | // Apply user's saved style when they log in (initialTextStyle prop updated via invalidateAll) | |
| 285 | - | if (currentUser && !hasAppliedUserStyle && initialTextStyle) { | |
| 281 | + | // Fetch and apply user's saved style when they log in | |
| 282 | + | if (currentUser && !hasAppliedUserStyle && browser) { | |
| 286 | 283 | hasAppliedUserStyle = true; | |
| 287 | - | currentTextStyle = { | |
| 288 | - | ...DEFAULT_TEXT_STYLE, | |
| 289 | - | ...initialTextStyle, | |
| 290 | - | color: initialTextStyle.color || '#000000' | |
| 291 | - | }; | |
| 284 | + | // Fetch user preferences directly to avoid timing issues with invalidateAll | |
| 285 | + | api.textPreferences.get().then((prefs) => { | |
| 286 | + | if (prefs?.defaultStyle) { | |
| 287 | + | currentTextStyle = { | |
| 288 | + | ...DEFAULT_TEXT_STYLE, | |
| 289 | + | ...prefs.defaultStyle, | |
| 290 | + | color: prefs.defaultStyle.color || '#000000' | |
| 291 | + | }; | |
| 292 | + | } | |
| 293 | + | }); | |
| 292 | 294 | } | |
| 293 | 295 | }); | |
| 294 | 296 | ||
| @@ -313,27 +315,26 @@ | |||
| 313 | 315 | } | |
| 314 | 316 | }); | |
| 315 | 317 | ||
| 316 | - | // Auto-scroll to bottom when new messages arrive | |
| 317 | - | $effect(() => { | |
| 318 | - | if (messages.length > lastMessageCount && messages.length > 0) { | |
| 319 | - | lastMessageCount = messages.length; | |
| 320 | - | if (browser && chatArea) { | |
| 321 | - | tick().then(() => { | |
| 322 | - | if (chatArea) { | |
| 323 | - | chatArea.scrollTop = chatArea.scrollHeight; | |
| 324 | - | } | |
| 325 | - | }); | |
| 326 | - | } | |
| 327 | - | } | |
| 328 | - | }); | |
| 318 | + | // Auto-scroll to bottom when new messages arrive (Svelte 5 recommended pattern) | |
| 319 | + | $effect.pre(() => { | |
| 320 | + | if (!browser || !chatArea) return; | |
| 329 | 321 | ||
| 330 | - | // Scroll to bottom on initial load (when chatArea becomes available and messages exist) | |
| 331 | - | $effect(() => { | |
| 332 | - | if (browser && chatArea && messages.length > 0 && !isInitialLoading && !hasInitialScrolled) { | |
| 322 | + | // Reference visibleMessages so effect re-runs when messages change | |
| 323 | + | const latestMessage = visibleMessages[visibleMessages.length - 1]; | |
| 324 | + | if (!latestMessage) return; | |
| 325 | + | const firstMessage = visibleMessages[0]; | |
| 326 | + | const messageSignature = `${firstMessage?.id ?? 'none'}:${latestMessage.id}:${visibleMessages.length}`; | |
| 327 | + | const hasMessageUpdate = messageSignature !== lastMessageSignature; | |
| 328 | + | lastMessageSignature = messageSignature; | |
| 329 | + | ||
| 330 | + | const isNearBottom = chatArea.scrollTop + chatArea.offsetHeight >= chatArea.scrollHeight - 40; | |
| 331 | + | ||
| 332 | + | // Scroll to bottom on initial load OR when messages update (if user was near bottom) | |
| 333 | + | if (!hasInitialScrolled || (hasMessageUpdate && isNearBottom)) { | |
| 333 | 334 | hasInitialScrolled = true; | |
| 334 | 335 | tick().then(() => { | |
| 335 | 336 | if (chatArea) { | |
| 336 | - | chatArea.scrollTop = chatArea.scrollHeight; | |
| 337 | + | chatArea.scrollTo(0, chatArea.scrollHeight); | |
| 337 | 338 | } | |
| 338 | 339 | }); | |
| 339 | 340 | } | |
| @@ -579,35 +580,17 @@ | |||
| 579 | 580 | // Debounced function to update the server with last read mention timestamp | |
| 580 | 581 | const debouncedUpdateMentionTimestamp = debounce(async (timestamp: number) => { | |
| 581 | 582 | if (!currentUser || !browser) return; | |
| 582 | - | try { | |
| 583 | - | await fetch('/api/chat/mention-read', { | |
| 584 | - | method: 'POST', | |
| 585 | - | headers: { 'Content-Type': 'application/json' }, | |
| 586 | - | credentials: 'include', | |
| 587 | - | body: JSON.stringify({ timestamp }) | |
| 588 | - | }); | |
| 589 | - | } catch (error) { | |
| 590 | - | console.error('Failed to update mention read timestamp:', error); | |
| 591 | - | } | |
| 583 | + | await api.mentions.markRead(timestamp); | |
| 592 | 584 | }, 1000); | |
| 593 | 585 | ||
| 594 | 586 | // Debounced function to save text style preferences to the database | |
| 595 | 587 | const debouncedSaveTextStyle = debounce(async (style: TextStyle) => { | |
| 596 | 588 | if (!currentUser || !browser) return; | |
| 597 | - | try { | |
| 598 | - | await fetch('/api/user/text-preferences', { | |
| 599 | - | method: 'POST', | |
| 600 | - | headers: { 'Content-Type': 'application/json' }, | |
| 601 | - | credentials: 'include', | |
| 602 | - | body: JSON.stringify({ | |
| 603 | - | defaultStyle: style, | |
| 604 | - | allowFormatting: true, | |
| 605 | - | maxMessageLength: MAX_MESSAGE_LENGTH | |
| 606 | - | }) | |
| 607 | - | }); | |
| 608 | - | } catch (error) { | |
| 609 | - | console.error('Failed to save text style preferences:', error); | |
| 610 | - | } | |
| 589 | + | await api.textPreferences.save({ | |
| 590 | + | defaultStyle: style, | |
| 591 | + | allowFormatting: true, | |
| 592 | + | maxMessageLength: MAX_MESSAGE_LENGTH | |
| 593 | + | }); | |
| 611 | 594 | }, 1500); | |
| 612 | 595 | ||
| 613 | 596 | function setsEqual(a: Set<string>, b: Set<string>) { | |
| @@ -919,13 +902,13 @@ | |||
| 919 | 902 | } | |
| 920 | 903 | ||
| 921 | 904 | async function handleScroll(event: Event) { | |
| 922 | - | const chatArea = event.target as HTMLElement; | |
| 923 | - | const { scrollTop } = chatArea; | |
| 905 | + | const target = event.target as HTMLElement; | |
| 906 | + | const { scrollTop } = target; | |
| 924 | 907 | ||
| 925 | - | if (scrollTop < 100 && !isLoadingMore && hasMoreMessages && roomId) { | |
| 908 | + | if (scrollTop < 100 && !isLoadingMore && hasMoreMessages && roomId && chatArea) { | |
| 926 | 909 | isLoadingMore = true; | |
| 927 | - | const scrollHeight = chatArea.scrollHeight; | |
| 928 | - | const currentScrollTop = chatArea.scrollTop; | |
| 910 | + | const prevScrollHeight = chatArea.scrollHeight; | |
| 911 | + | const prevScrollTop = chatArea.scrollTop; | |
| 929 | 912 | ||
| 930 | 913 | try { | |
| 931 | 914 | // Build query string manually to avoid URLSearchParams (svelte reactivity compliance) | |
| @@ -948,9 +931,10 @@ | |||
| 948 | 931 | mergePagedMessages(fetchedMessages); | |
| 949 | 932 | ||
| 950 | 933 | requestAnimationFrame(() => { | |
| 934 | + | if (!chatArea) return; | |
| 951 | 935 | const newScrollHeight = chatArea.scrollHeight; | |
| 952 | - | const heightDifference = newScrollHeight - scrollHeight; | |
| 953 | - | chatArea.scrollTop = currentScrollTop + heightDifference; | |
| 936 | + | const heightDifference = newScrollHeight - prevScrollHeight; | |
| 937 | + | chatArea.scrollTop = prevScrollTop + heightDifference; | |
| 954 | 938 | }); | |
| 955 | 939 | ||
| 956 | 940 | const newOldest = Math.min(...fetchedMessages.map((m) => m.timestamp)); | |
| @@ -1,12 +1,13 @@ | |||||
| 1 | <script lang="ts"> | 1 | <script lang="ts"> | ||
| 2 | import { chatState } from '../states/chat.svelte'; | 2 | import { chatState } from '../states/chat.svelte'; | ||
| 3 | import { useQuery } from 'convex-svelte'; | 3 | import { useQuery } from 'convex-svelte'; | ||
| 4 | - | import { api } from '../../convex/_generated/api'; | 4 | + | import { api as convexApi } from '../../convex/_generated/api'; |
| 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, SvelteMap } from 'svelte/reactivity'; | 8 | import { SvelteSet, SvelteMap } from 'svelte/reactivity'; | ||
| 9 | import { browser } from '$app/environment'; | 9 | import { browser } from '$app/environment'; | ||
| 10 | + | import { api } from '$lib/api/client'; | |||
| 10 | import { draggable } from '$lib/actions/draggable'; | 11 | import { draggable } from '$lib/actions/draggable'; | ||
| 11 | import { resizable } from '$lib/actions/resizable'; | 12 | import { resizable } from '$lib/actions/resizable'; | ||
| 12 | import { maximizable } from '$lib/actions/maximizable'; | 13 | import { maximizable } from '$lib/actions/maximizable'; | ||
| @@ -33,22 +34,22 @@ | |||||
| 33 | import { MAX_MESSAGE_LENGTH } from '$lib/validation/message'; | 34 | import { MAX_MESSAGE_LENGTH } from '$lib/validation/message'; | ||
| 34 | 35 | ||||
| 35 | // Props | 36 | // Props | ||
| 36 | - | let { showChatRoom = $bindable(), initialTextStyle = DEFAULT_TEXT_STYLE } = $props(); | 37 | + | let { showChatRoom = $bindable() } = $props(); |
| 37 | 38 | ||||
| 38 | // ============ CONVEX REAL-TIME SUBSCRIPTIONS ============ | 39 | // ============ CONVEX REAL-TIME SUBSCRIPTIONS ============ | ||
| 39 | 40 | ||||
| 40 | // Subscribe to default room (to get room ID) | 41 | // Subscribe to default room (to get room ID) | ||
| 41 | - | const defaultRoomQuery = useQuery(api.queries.getDefaultRoomPublic, {}); | 42 | + | const defaultRoomQuery = useQuery(convexApi.queries.getDefaultRoomPublic, {}); |
| 42 | 43 | ||||
| 43 | // Subscribe to users (buddy list) - real-time updates | 44 | // Subscribe to users (buddy list) - real-time updates | ||
| 44 | - | const usersQuery = useQuery(api.queries.getUsersPublic, {}); | 45 | + | const usersQuery = useQuery(convexApi.queries.getUsersPublic, {}); |
| 45 | 46 | ||||
| 46 | // Room ID from Convex query | 47 | // Room ID from Convex query | ||
| 47 | const roomId = $derived(defaultRoomQuery.data?.id as Id<'chatRooms'> | undefined); | 48 | const roomId = $derived(defaultRoomQuery.data?.id as Id<'chatRooms'> | undefined); | ||
| 48 | 49 | ||||
| 49 | // Subscribe to messages - only when room ID is available | 50 | // Subscribe to messages - only when room ID is available | ||
| 50 | const messagesQuery = $derived( | 51 | const messagesQuery = $derived( | ||
| 51 | - | roomId ? useQuery(api.queries.getMessagesPublic, () => ({ roomId: roomId!, limit: 100 })) : null | 52 | + | roomId ? useQuery(convexApi.queries.getMessagesPublic, () => ({ roomId: roomId! })) : null |
| 52 | ); | 53 | ); | ||
| 53 | 54 | ||||
| 54 | // Transform Convex users to SafeUser format | 55 | // Transform Convex users to SafeUser format | ||
| @@ -153,12 +154,8 @@ | |||||
| 153 | 154 | ||||
| 154 | let currentMessage = $state(''); | 155 | let currentMessage = $state(''); | ||
| 155 | let inputScrollLeft = $state(0); | 156 | let inputScrollLeft = $state(0); | ||
| 156 | - | // Initialize text style with prop value | 157 | + | // Initialize text style with defaults - prop value applied via effect below |
| 157 | - | let currentTextStyle = $state<TextStyle>({ | 158 | + | let currentTextStyle = $state<TextStyle>({ ...DEFAULT_TEXT_STYLE }); |
| 158 | - | ...DEFAULT_TEXT_STYLE, | |||
| 159 | - | ...initialTextStyle, | |||
| 160 | - | color: initialTextStyle.color || '#000000' | |||
| 161 | - | }); | |||
| 162 | // Track if we've applied user's style from DB (to avoid resetting on every prop change) | 159 | // Track if we've applied user's style from DB (to avoid resetting on every prop change) | ||
| 163 | let hasAppliedUserStyle = $state(false); | 160 | let hasAppliedUserStyle = $state(false); | ||
| 164 | let lastUserId = $state<string | null>(null); | 161 | let lastUserId = $state<string | null>(null); | ||
| @@ -202,7 +199,7 @@ | |||||
| 202 | let isLoadingMore = $state(false); | 199 | let isLoadingMore = $state(false); | ||
| 203 | let hasMoreMessages = $state(true); | 200 | let hasMoreMessages = $state(true); | ||
| 204 | let oldestMessageTimestamp = $state<number | null>(null); | 201 | let oldestMessageTimestamp = $state<number | null>(null); | ||
| 205 | - | let lastMessageCount = $state(0); | 202 | + | let lastMessageSignature = $state<string | null>(null); |
| 206 | let hasInitialScrolled = $state(false); | 203 | let hasInitialScrolled = $state(false); | ||
| 207 | 204 | ||||
| 208 | // ============ DERIVED STATE ============ | 205 | // ============ DERIVED STATE ============ | ||
| @@ -271,7 +268,7 @@ | |||||
| 271 | 268 | ||||
| 272 | // ============ EFFECTS ============ | 269 | // ============ EFFECTS ============ | ||
| 273 | 270 | ||||
| 274 | - | // Update text style when user logs in and their saved style is loaded from DB | 271 | + | // Update text style when user logs in - fetch preferences directly from API |
| 275 | $effect(() => { | 272 | $effect(() => { | ||
| 276 | const userId = currentUser?.id ?? null; | 273 | const userId = currentUser?.id ?? null; | ||
| 277 | 274 | ||||
| @@ -281,14 +278,19 @@ | |||||
| 281 | hasAppliedUserStyle = false; | 278 | hasAppliedUserStyle = false; | ||
| 282 | } | 279 | } | ||
| 283 | 280 | ||||
| 284 | - | // Apply user's saved style when they log in (initialTextStyle prop updated via invalidateAll) | 281 | + | // Fetch and apply user's saved style when they log in |
| 285 | - | if (currentUser && !hasAppliedUserStyle && initialTextStyle) { | 282 | + | if (currentUser && !hasAppliedUserStyle && browser) { |
| 286 | hasAppliedUserStyle = true; | 283 | hasAppliedUserStyle = true; | ||
| 287 | - | currentTextStyle = { | 284 | + | // Fetch user preferences directly to avoid timing issues with invalidateAll |
| 288 | - | ...DEFAULT_TEXT_STYLE, | 285 | + | api.textPreferences.get().then((prefs) => { |
| 289 | - | ...initialTextStyle, | 286 | + | if (prefs?.defaultStyle) { |
| 290 | - | color: initialTextStyle.color || '#000000' | 287 | + | currentTextStyle = { |
| 291 | - | }; | 288 | + | ...DEFAULT_TEXT_STYLE, |
| 289 | + | ...prefs.defaultStyle, | |||
| 290 | + | color: prefs.defaultStyle.color || '#000000' | |||
| 291 | + | }; | |||
| 292 | + | } | |||
| 293 | + | }); | |||
| 292 | } | 294 | } | ||
| 293 | }); | 295 | }); | ||
| 294 | 296 | ||||
| @@ -313,27 +315,26 @@ | |||||
| 313 | } | 315 | } | ||
| 314 | }); | 316 | }); | ||
| 315 | 317 | ||||
| 316 | - | // Auto-scroll to bottom when new messages arrive | 318 | + | // Auto-scroll to bottom when new messages arrive (Svelte 5 recommended pattern) |
| 317 | - | $effect(() => { | 319 | + | $effect.pre(() => { |
| 318 | - | if (messages.length > lastMessageCount && messages.length > 0) { | 320 | + | if (!browser || !chatArea) return; |
| 319 | - | lastMessageCount = messages.length; | |||
| 320 | - | if (browser && chatArea) { | |||
| 321 | - | tick().then(() => { | |||
| 322 | - | if (chatArea) { | |||
| 323 | - | chatArea.scrollTop = chatArea.scrollHeight; | |||
| 324 | - | } | |||
| 325 | - | }); | |||
| 326 | - | } | |||
| 327 | - | } | |||
| 328 | - | }); | |||
| 329 | 321 | ||||
| 330 | - | // Scroll to bottom on initial load (when chatArea becomes available and messages exist) | 322 | + | // Reference visibleMessages so effect re-runs when messages change |
| 331 | - | $effect(() => { | 323 | + | const latestMessage = visibleMessages[visibleMessages.length - 1]; |
| 332 | - | if (browser && chatArea && messages.length > 0 && !isInitialLoading && !hasInitialScrolled) { | 324 | + | if (!latestMessage) return; |
| 325 | + | const firstMessage = visibleMessages[0]; | |||
| 326 | + | const messageSignature = `${firstMessage?.id ?? 'none'}:${latestMessage.id}:${visibleMessages.length}`; | |||
| 327 | + | const hasMessageUpdate = messageSignature !== lastMessageSignature; | |||
| 328 | + | lastMessageSignature = messageSignature; | |||
| 329 | + | ||||
| 330 | + | const isNearBottom = chatArea.scrollTop + chatArea.offsetHeight >= chatArea.scrollHeight - 40; | |||
| 331 | + | ||||
| 332 | + | // Scroll to bottom on initial load OR when messages update (if user was near bottom) | |||
| 333 | + | if (!hasInitialScrolled || (hasMessageUpdate && isNearBottom)) { | |||
| 333 | hasInitialScrolled = true; | 334 | hasInitialScrolled = true; | ||
| 334 | tick().then(() => { | 335 | tick().then(() => { | ||
| 335 | if (chatArea) { | 336 | if (chatArea) { | ||
| 336 | - | chatArea.scrollTop = chatArea.scrollHeight; | 337 | + | chatArea.scrollTo(0, chatArea.scrollHeight); |
| 337 | } | 338 | } | ||
| 338 | }); | 339 | }); | ||
| 339 | } | 340 | } | ||
| @@ -579,35 +580,17 @@ | |||||
| 579 | // Debounced function to update the server with last read mention timestamp | 580 | // Debounced function to update the server with last read mention timestamp | ||
| 580 | const debouncedUpdateMentionTimestamp = debounce(async (timestamp: number) => { | 581 | const debouncedUpdateMentionTimestamp = debounce(async (timestamp: number) => { | ||
| 581 | if (!currentUser || !browser) return; | 582 | if (!currentUser || !browser) return; | ||
| 582 | - | try { | 583 | + | await api.mentions.markRead(timestamp); |
| 583 | - | await fetch('/api/chat/mention-read', { | |||
| 584 | - | method: 'POST', | |||
| 585 | - | headers: { 'Content-Type': 'application/json' }, | |||
| 586 | - | credentials: 'include', | |||
| 587 | - | body: JSON.stringify({ timestamp }) | |||
| 588 | - | }); | |||
| 589 | - | } catch (error) { | |||
| 590 | - | console.error('Failed to update mention read timestamp:', error); | |||
| 591 | - | } | |||
| 592 | }, 1000); | 584 | }, 1000); | ||
| 593 | 585 | ||||
| 594 | // Debounced function to save text style preferences to the database | 586 | // Debounced function to save text style preferences to the database | ||
| 595 | const debouncedSaveTextStyle = debounce(async (style: TextStyle) => { | 587 | const debouncedSaveTextStyle = debounce(async (style: TextStyle) => { | ||
| 596 | if (!currentUser || !browser) return; | 588 | if (!currentUser || !browser) return; | ||
| 597 | - | try { | 589 | + | await api.textPreferences.save({ |
| 598 | - | await fetch('/api/user/text-preferences', { | 590 | + | defaultStyle: style, |
| 599 | - | method: 'POST', | 591 | + | allowFormatting: true, |
| 600 | - | headers: { 'Content-Type': 'application/json' }, | 592 | + | maxMessageLength: MAX_MESSAGE_LENGTH |
| 601 | - | credentials: 'include', | 593 | + | }); |
| 602 | - | body: JSON.stringify({ | |||
| 603 | - | defaultStyle: style, | |||
| 604 | - | allowFormatting: true, | |||
| 605 | - | maxMessageLength: MAX_MESSAGE_LENGTH | |||
| 606 | - | }) | |||
| 607 | - | }); | |||
| 608 | - | } catch (error) { | |||
| 609 | - | console.error('Failed to save text style preferences:', error); | |||
| 610 | - | } | |||
| 611 | }, 1500); | 594 | }, 1500); | ||
| 612 | 595 | ||||
| 613 | function setsEqual(a: Set<string>, b: Set<string>) { | 596 | function setsEqual(a: Set<string>, b: Set<string>) { | ||
| @@ -919,13 +902,13 @@ | |||||
| 919 | } | 902 | } | ||
| 920 | 903 | ||||
| 921 | async function handleScroll(event: Event) { | 904 | async function handleScroll(event: Event) { | ||
| 922 | - | const chatArea = event.target as HTMLElement; | 905 | + | const target = event.target as HTMLElement; |
| 923 | - | const { scrollTop } = chatArea; | 906 | + | const { scrollTop } = target; |
| 924 | 907 | ||||
| 925 | - | if (scrollTop < 100 && !isLoadingMore && hasMoreMessages && roomId) { | 908 | + | if (scrollTop < 100 && !isLoadingMore && hasMoreMessages && roomId && chatArea) { |
| 926 | isLoadingMore = true; | 909 | isLoadingMore = true; | ||
| 927 | - | const scrollHeight = chatArea.scrollHeight; | 910 | + | const prevScrollHeight = chatArea.scrollHeight; |
| 928 | - | const currentScrollTop = chatArea.scrollTop; | 911 | + | const prevScrollTop = chatArea.scrollTop; |
| 929 | 912 | ||||
| 930 | try { | 913 | try { | ||
| 931 | // Build query string manually to avoid URLSearchParams (svelte reactivity compliance) | 914 | // Build query string manually to avoid URLSearchParams (svelte reactivity compliance) | ||
| @@ -948,9 +931,10 @@ | |||||
| 948 | mergePagedMessages(fetchedMessages); | 931 | mergePagedMessages(fetchedMessages); | ||
| 949 | 932 | ||||
| 950 | requestAnimationFrame(() => { | 933 | requestAnimationFrame(() => { | ||
| 934 | + | if (!chatArea) return; | |||
| 951 | const newScrollHeight = chatArea.scrollHeight; | 935 | const newScrollHeight = chatArea.scrollHeight; | ||
| 952 | - | const heightDifference = newScrollHeight - scrollHeight; | 936 | + | const heightDifference = newScrollHeight - prevScrollHeight; |
| 953 | - | chatArea.scrollTop = currentScrollTop + heightDifference; | 937 | + | chatArea.scrollTop = prevScrollTop + heightDifference; |
| 954 | }); | 938 | }); | ||
| 955 | 939 | ||||
| 956 | const newOldest = Math.min(...fetchedMessages.map((m) => m.timestamp)); | 940 | const newOldest = Math.min(...fetchedMessages.map((m) => m.timestamp)); | ||
M src/routes/+page.svelte +1 -4
| @@ -2,9 +2,6 @@ | |||
| 2 | 2 | import ChatRoom from '$lib/components/chat-room.svelte'; | |
| 3 | 3 | import DesktopIcons from '$lib/components/desktop-icons.svelte'; | |
| 4 | 4 | import AimLogin from '$lib/components/aim-login.svelte'; | |
| 5 | - | import type { PageData } from './$types'; | |
| 6 | - | ||
| 7 | - | let { data }: { data: PageData } = $props(); | |
| 8 | 5 | ||
| 9 | 6 | let showChatRoom = $state(true); | |
| 10 | 7 | let showAuth = $state(false); | |
| @@ -18,7 +15,7 @@ | |||
| 18 | 15 | <DesktopIcons bind:showChatRoom bind:showAuth /> | |
| 19 | 16 | ||
| 20 | 17 | {#if showChatRoom} | |
| 21 | - | <ChatRoom bind:showChatRoom initialTextStyle={data.textStyle} /> | |
| 18 | + | <ChatRoom bind:showChatRoom /> | |
| 22 | 19 | {/if} | |
| 23 | 20 | ||
| 24 | 21 | {#if showAuth} | |
| @@ -2,9 +2,6 @@ | |||||
| 2 | import ChatRoom from '$lib/components/chat-room.svelte'; | 2 | import ChatRoom from '$lib/components/chat-room.svelte'; | ||
| 3 | import DesktopIcons from '$lib/components/desktop-icons.svelte'; | 3 | import DesktopIcons from '$lib/components/desktop-icons.svelte'; | ||
| 4 | import AimLogin from '$lib/components/aim-login.svelte'; | 4 | import AimLogin from '$lib/components/aim-login.svelte'; | ||
| 5 | - | import type { PageData } from './$types'; | |||
| 6 | - | ||||
| 7 | - | let { data }: { data: PageData } = $props(); | |||
| 8 | 5 | ||||
| 9 | let showChatRoom = $state(true); | 6 | let showChatRoom = $state(true); | ||
| 10 | let showAuth = $state(false); | 7 | let showAuth = $state(false); | ||
| @@ -18,7 +15,7 @@ | |||||
| 18 | <DesktopIcons bind:showChatRoom bind:showAuth /> | 15 | <DesktopIcons bind:showChatRoom bind:showAuth /> | ||
| 19 | 16 | ||||
| 20 | {#if showChatRoom} | 17 | {#if showChatRoom} | ||
| 21 | - | <ChatRoom bind:showChatRoom initialTextStyle={data.textStyle} /> | 18 | + | <ChatRoom bind:showChatRoom /> |
| 22 | {/if} | 19 | {/if} | ||
| 23 | 20 | ||||
| 24 | {#if showAuth} | 21 | {#if showAuth} | ||