fix(chat): hydrate prefs and align pagination
- commit
- ceeb452ff4330891b38f7e4b290063fcd04be16a
- author
- Kit · · 2026-01-24 07:29 UTC
- parent
- 2919a84
- signature
- SSH Unverified
SHA256:H91DFuEG4jpk5edzLA4aUKkpv4zv9Q339ipRmkph6TkThe key is unknown
9 files · +201 -132
M src/convex/queries.ts +46 -3
| @@ -35,8 +35,9 @@ export const getMessagesPublic = query({ | |||
| 35 | 35 | limit: v.optional(v.number()) | |
| 36 | 36 | }, | |
| 37 | 37 | handler: async (ctx, args) => { | |
| 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); | |
| 38 | + | // Enforce max limit and clamp to a safe positive integer | |
| 39 | + | const rawLimit = args.limit ?? PUBLIC_MESSAGE_LIMIT; | |
| 40 | + | const limit = Math.max(1, Math.min(PUBLIC_MESSAGE_LIMIT, Math.floor(rawLimit))); | |
| 40 | 41 | ||
| 41 | 42 | const messages = await ctx.db | |
| 42 | 43 | .query('messages') | |
| @@ -74,6 +75,46 @@ export const getMessagesPublic = query({ | |||
| 74 | 75 | } | |
| 75 | 76 | }); | |
| 76 | 77 | ||
| 78 | + | // Public query: Get paginated messages for a chat room (older history) | |
| 79 | + | export const getMessagesPublicPage = query({ | |
| 80 | + | args: { | |
| 81 | + | roomId: v.id('chatRooms'), | |
| 82 | + | limit: v.optional(v.number()), | |
| 83 | + | beforeTimestamp: v.optional(v.number()) | |
| 84 | + | }, | |
| 85 | + | handler: async (ctx, args) => { | |
| 86 | + | const rawLimit = args.limit ?? 50; | |
| 87 | + | const limit = Math.max(1, Math.min(PUBLIC_MESSAGE_LIMIT, Math.floor(rawLimit))); | |
| 88 | + | ||
| 89 | + | let query = ctx.db | |
| 90 | + | .query('messages') | |
| 91 | + | .withIndex('by_chatRoom', (q) => q.eq('chatRoomId', args.roomId)) | |
| 92 | + | .order('desc'); | |
| 93 | + | ||
| 94 | + | const beforeTimestamp = args.beforeTimestamp; | |
| 95 | + | if (beforeTimestamp !== undefined) { | |
| 96 | + | query = query.filter((q) => q.lt(q.field('timestamp'), beforeTimestamp)); | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | const messages = await query.take(limit + 1); | |
| 100 | + | const page = messages.slice(0, limit); | |
| 101 | + | ||
| 102 | + | return { | |
| 103 | + | messages: page.map((msg) => ({ | |
| 104 | + | id: msg._id, | |
| 105 | + | chatRoomId: msg.chatRoomId, | |
| 106 | + | senderId: msg.senderId, | |
| 107 | + | content: msg.content, | |
| 108 | + | type: msg.type, | |
| 109 | + | timestamp: msg.timestamp, | |
| 110 | + | styleData: msg.styleData, | |
| 111 | + | hasFormatting: msg.hasFormatting | |
| 112 | + | })), | |
| 113 | + | hasMore: messages.length > limit | |
| 114 | + | }; | |
| 115 | + | } | |
| 116 | + | }); | |
| 117 | + | ||
| 77 | 118 | // Public query: Get all users (real-time subscription for buddy list) | |
| 78 | 119 | export const getUsersPublic = query({ | |
| 79 | 120 | args: {}, | |
| @@ -131,12 +172,14 @@ export const getMessages = authQuery({ | |||
| 131 | 172 | limit: v.optional(v.number()) | |
| 132 | 173 | }, | |
| 133 | 174 | handler: async (ctx, args) => { | |
| 175 | + | const rawLimit = args.limit ?? 100; | |
| 176 | + | const limit = Math.max(1, Math.floor(rawLimit)); | |
| 134 | 177 | const query = ctx.db | |
| 135 | 178 | .query('messages') | |
| 136 | 179 | .withIndex('by_chatRoom', (q) => q.eq('chatRoomId', args.roomId)) | |
| 137 | 180 | .order('desc'); | |
| 138 | 181 | ||
| 139 | - | const messages = args.limit ? await query.take(args.limit) : await query.collect(); | |
| 182 | + | const messages = await query.take(limit); | |
| 140 | 183 | ||
| 141 | 184 | // Enrich messages with sender info | |
| 142 | 185 | const enrichedMessages = await Promise.all( | |
| @@ -35,8 +35,9 @@ export const getMessagesPublic = query({ | |||||
| 35 | limit: v.optional(v.number()) | 35 | limit: v.optional(v.number()) | ||
| 36 | }, | 36 | }, | ||
| 37 | handler: async (ctx, args) => { | 37 | handler: async (ctx, args) => { | ||
| 38 | - | // Enforce max limit - client cannot request more than PUBLIC_MESSAGE_LIMIT | 38 | + | // Enforce max limit and clamp to a safe positive integer |
| 39 | - | const limit = Math.min(args.limit ?? PUBLIC_MESSAGE_LIMIT, PUBLIC_MESSAGE_LIMIT); | 39 | + | const rawLimit = args.limit ?? PUBLIC_MESSAGE_LIMIT; |
| 40 | + | const limit = Math.max(1, Math.min(PUBLIC_MESSAGE_LIMIT, Math.floor(rawLimit))); | |||
| 40 | 41 | ||||
| 41 | const messages = await ctx.db | 42 | const messages = await ctx.db | ||
| 42 | .query('messages') | 43 | .query('messages') | ||
| @@ -74,6 +75,46 @@ export const getMessagesPublic = query({ | |||||
| 74 | } | 75 | } | ||
| 75 | }); | 76 | }); | ||
| 76 | 77 | ||||
| 78 | + | // Public query: Get paginated messages for a chat room (older history) | |||
| 79 | + | export const getMessagesPublicPage = query({ | |||
| 80 | + | args: { | |||
| 81 | + | roomId: v.id('chatRooms'), | |||
| 82 | + | limit: v.optional(v.number()), | |||
| 83 | + | beforeTimestamp: v.optional(v.number()) | |||
| 84 | + | }, | |||
| 85 | + | handler: async (ctx, args) => { | |||
| 86 | + | const rawLimit = args.limit ?? 50; | |||
| 87 | + | const limit = Math.max(1, Math.min(PUBLIC_MESSAGE_LIMIT, Math.floor(rawLimit))); | |||
| 88 | + | ||||
| 89 | + | let query = ctx.db | |||
| 90 | + | .query('messages') | |||
| 91 | + | .withIndex('by_chatRoom', (q) => q.eq('chatRoomId', args.roomId)) | |||
| 92 | + | .order('desc'); | |||
| 93 | + | ||||
| 94 | + | const beforeTimestamp = args.beforeTimestamp; | |||
| 95 | + | if (beforeTimestamp !== undefined) { | |||
| 96 | + | query = query.filter((q) => q.lt(q.field('timestamp'), beforeTimestamp)); | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | const messages = await query.take(limit + 1); | |||
| 100 | + | const page = messages.slice(0, limit); | |||
| 101 | + | ||||
| 102 | + | return { | |||
| 103 | + | messages: page.map((msg) => ({ | |||
| 104 | + | id: msg._id, | |||
| 105 | + | chatRoomId: msg.chatRoomId, | |||
| 106 | + | senderId: msg.senderId, | |||
| 107 | + | content: msg.content, | |||
| 108 | + | type: msg.type, | |||
| 109 | + | timestamp: msg.timestamp, | |||
| 110 | + | styleData: msg.styleData, | |||
| 111 | + | hasFormatting: msg.hasFormatting | |||
| 112 | + | })), | |||
| 113 | + | hasMore: messages.length > limit | |||
| 114 | + | }; | |||
| 115 | + | } | |||
| 116 | + | }); | |||
| 117 | + | ||||
| 77 | // Public query: Get all users (real-time subscription for buddy list) | 118 | // Public query: Get all users (real-time subscription for buddy list) | ||
| 78 | export const getUsersPublic = query({ | 119 | export const getUsersPublic = query({ | ||
| 79 | args: {}, | 120 | args: {}, | ||
| @@ -131,12 +172,14 @@ export const getMessages = authQuery({ | |||||
| 131 | limit: v.optional(v.number()) | 172 | limit: v.optional(v.number()) | ||
| 132 | }, | 173 | }, | ||
| 133 | handler: async (ctx, args) => { | 174 | handler: async (ctx, args) => { | ||
| 175 | + | const rawLimit = args.limit ?? 100; | |||
| 176 | + | const limit = Math.max(1, Math.floor(rawLimit)); | |||
| 134 | const query = ctx.db | 177 | const query = ctx.db | ||
| 135 | .query('messages') | 178 | .query('messages') | ||
| 136 | .withIndex('by_chatRoom', (q) => q.eq('chatRoomId', args.roomId)) | 179 | .withIndex('by_chatRoom', (q) => q.eq('chatRoomId', args.roomId)) | ||
| 137 | .order('desc'); | 180 | .order('desc'); | ||
| 138 | 181 | ||||
| 139 | - | const messages = args.limit ? await query.take(args.limit) : await query.collect(); | 182 | + | const messages = await query.take(limit); |
| 140 | 183 | ||||
| 141 | // Enrich messages with sender info | 184 | // Enrich messages with sender info | ||
| 142 | const enrichedMessages = await Promise.all( | 185 | const enrichedMessages = await Promise.all( | ||
M src/lib/api/client.ts +26 -34
| @@ -3,6 +3,7 @@ | |||
| 3 | 3 | * Provides typed helper functions for all API calls | |
| 4 | 4 | */ | |
| 5 | 5 | ||
| 6 | + | import type { SendMessageRequest, SendMessageResponse } from '$lib/types/payloads'; | |
| 6 | 7 | import type { TextStyle, UserTextPreferences } from '$lib/types/text-formatting'; | |
| 7 | 8 | ||
| 8 | 9 | // ============ TYPES ============ | |
| @@ -23,12 +24,12 @@ export interface TextPreferencesResponse { | |||
| 23 | 24 | async function request<T>(endpoint: string, options: RequestInit = {}): Promise<ApiResponse<T>> { | |
| 24 | 25 | try { | |
| 25 | 26 | const response = await fetch(endpoint, { | |
| 27 | + | ...options, | |
| 26 | 28 | credentials: 'include', | |
| 27 | 29 | headers: { | |
| 28 | 30 | 'Content-Type': 'application/json', | |
| 29 | - | ...options.headers | |
| 30 | - | }, | |
| 31 | - | ...options | |
| 31 | + | ...(options.headers ?? {}) | |
| 32 | + | } | |
| 32 | 33 | }); | |
| 33 | 34 | ||
| 34 | 35 | const data = await response.json(); | |
| @@ -36,7 +37,8 @@ async function request<T>(endpoint: string, options: RequestInit = {}): Promise< | |||
| 36 | 37 | if (!response.ok) { | |
| 37 | 38 | return { | |
| 38 | 39 | success: false, | |
| 39 | - | error: data.error || `Request failed with status ${response.status}` | |
| 40 | + | error: data.error || `Request failed with status ${response.status}`, | |
| 41 | + | data | |
| 40 | 42 | }; | |
| 41 | 43 | } | |
| 42 | 44 | ||
| @@ -64,12 +66,12 @@ export const textPreferences = { | |||
| 64 | 66 | }, | |
| 65 | 67 | ||
| 66 | 68 | /** | |
| 67 | - | * Save user's text preferences (partial update supported) | |
| 69 | + | * Save user's text preferences | |
| 68 | 70 | */ | |
| 69 | 71 | async save(preferences: { | |
| 70 | 72 | defaultStyle: TextStyle; | |
| 71 | - | allowFormatting?: boolean; | |
| 72 | - | maxMessageLength?: number; | |
| 73 | + | allowFormatting: boolean; | |
| 74 | + | maxMessageLength: number; | |
| 73 | 75 | }): Promise<boolean> { | |
| 74 | 76 | const result = await request('/api/user/text-preferences', { | |
| 75 | 77 | method: 'POST', | |
| @@ -124,14 +126,18 @@ export const session = { | |||
| 124 | 126 | async register( | |
| 125 | 127 | username: string, | |
| 126 | 128 | password: string, | |
| 127 | - | confirmPassword: string | |
| 129 | + | confirmPassword: string, | |
| 130 | + | captchaAnswer: string, | |
| 131 | + | turnstileToken?: string | |
| 128 | 132 | ): Promise<LoginResponse> { | |
| 129 | 133 | const result = await request<LoginResponse>('/api/register', { | |
| 130 | 134 | method: 'POST', | |
| 131 | 135 | body: JSON.stringify({ | |
| 132 | 136 | suUsername: username, | |
| 133 | 137 | suPassword: password, | |
| 134 | - | suConfirmPassword: confirmPassword | |
| 138 | + | suConfirmPassword: confirmPassword, | |
| 139 | + | captchaAnswer, | |
| 140 | + | ...(turnstileToken ? { turnstileToken } : {}) | |
| 135 | 141 | }) | |
| 136 | 142 | }); | |
| 137 | 143 | return result.data ?? { success: false, error: result.error }; | |
| @@ -142,42 +148,28 @@ export const session = { | |||
| 142 | 148 | ||
| 143 | 149 | export interface SendMessageParams { | |
| 144 | 150 | content: string; | |
| 145 | - | roomId: string; | |
| 151 | + | userId: string; | |
| 152 | + | chatRoomId?: string; | |
| 153 | + | type?: SendMessageRequest['type']; | |
| 146 | 154 | styleData?: string; | |
| 147 | - | hasFormatting?: boolean; | |
| 148 | 155 | } | |
| 149 | 156 | ||
| 150 | 157 | export const chat = { | |
| 151 | 158 | /** | |
| 152 | 159 | * Send a message to a chat room | |
| 153 | 160 | */ | |
| 154 | - | async sendMessage(params: SendMessageParams): Promise<boolean> { | |
| 155 | - | const result = await request('/api/chat/messages', { | |
| 161 | + | async sendMessage(params: SendMessageParams): Promise<SendMessageResponse> { | |
| 162 | + | const result = await request<SendMessageResponse>('/api/chat/messages', { | |
| 156 | 163 | method: 'POST', | |
| 157 | 164 | body: JSON.stringify(params) | |
| 158 | 165 | }); | |
| 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 }; | |
| 166 | + | if (result.data) { | |
| 167 | + | return result.data; | |
| 179 | 168 | } | |
| 180 | - | return null; | |
| 169 | + | return { | |
| 170 | + | success: false, | |
| 171 | + | error: result.error ?? 'Failed to send message' | |
| 172 | + | }; | |
| 181 | 173 | } | |
| 182 | 174 | }; | |
| 183 | 175 | ||
| @@ -3,6 +3,7 @@ | |||||
| 3 | * Provides typed helper functions for all API calls | 3 | * Provides typed helper functions for all API calls | ||
| 4 | */ | 4 | */ | ||
| 5 | 5 | ||||
| 6 | + | import type { SendMessageRequest, SendMessageResponse } from '$lib/types/payloads'; | |||
| 6 | import type { TextStyle, UserTextPreferences } from '$lib/types/text-formatting'; | 7 | import type { TextStyle, UserTextPreferences } from '$lib/types/text-formatting'; | ||
| 7 | 8 | ||||
| 8 | // ============ TYPES ============ | 9 | // ============ TYPES ============ | ||
| @@ -23,12 +24,12 @@ export interface TextPreferencesResponse { | |||||
| 23 | async function request<T>(endpoint: string, options: RequestInit = {}): Promise<ApiResponse<T>> { | 24 | async function request<T>(endpoint: string, options: RequestInit = {}): Promise<ApiResponse<T>> { | ||
| 24 | try { | 25 | try { | ||
| 25 | const response = await fetch(endpoint, { | 26 | const response = await fetch(endpoint, { | ||
| 27 | + | ...options, | |||
| 26 | credentials: 'include', | 28 | credentials: 'include', | ||
| 27 | headers: { | 29 | headers: { | ||
| 28 | 'Content-Type': 'application/json', | 30 | 'Content-Type': 'application/json', | ||
| 29 | - | ...options.headers | 31 | + | ...(options.headers ?? {}) |
| 30 | - | }, | 32 | + | } |
| 31 | - | ...options | |||
| 32 | }); | 33 | }); | ||
| 33 | 34 | ||||
| 34 | const data = await response.json(); | 35 | const data = await response.json(); | ||
| @@ -36,7 +37,8 @@ async function request<T>(endpoint: string, options: RequestInit = {}): Promise< | |||||
| 36 | if (!response.ok) { | 37 | if (!response.ok) { | ||
| 37 | return { | 38 | return { | ||
| 38 | success: false, | 39 | success: false, | ||
| 39 | - | error: data.error || `Request failed with status ${response.status}` | 40 | + | error: data.error || `Request failed with status ${response.status}`, |
| 41 | + | data | |||
| 40 | }; | 42 | }; | ||
| 41 | } | 43 | } | ||
| 42 | 44 | ||||
| @@ -64,12 +66,12 @@ export const textPreferences = { | |||||
| 64 | }, | 66 | }, | ||
| 65 | 67 | ||||
| 66 | /** | 68 | /** | ||
| 67 | - | * Save user's text preferences (partial update supported) | 69 | + | * Save user's text preferences |
| 68 | */ | 70 | */ | ||
| 69 | async save(preferences: { | 71 | async save(preferences: { | ||
| 70 | defaultStyle: TextStyle; | 72 | defaultStyle: TextStyle; | ||
| 71 | - | allowFormatting?: boolean; | 73 | + | allowFormatting: boolean; |
| 72 | - | maxMessageLength?: number; | 74 | + | maxMessageLength: number; |
| 73 | }): Promise<boolean> { | 75 | }): Promise<boolean> { | ||
| 74 | const result = await request('/api/user/text-preferences', { | 76 | const result = await request('/api/user/text-preferences', { | ||
| 75 | method: 'POST', | 77 | method: 'POST', | ||
| @@ -124,14 +126,18 @@ export const session = { | |||||
| 124 | async register( | 126 | async register( | ||
| 125 | username: string, | 127 | username: string, | ||
| 126 | password: string, | 128 | password: string, | ||
| 127 | - | confirmPassword: string | 129 | + | confirmPassword: string, |
| 130 | + | captchaAnswer: string, | |||
| 131 | + | turnstileToken?: string | |||
| 128 | ): Promise<LoginResponse> { | 132 | ): Promise<LoginResponse> { | ||
| 129 | const result = await request<LoginResponse>('/api/register', { | 133 | const result = await request<LoginResponse>('/api/register', { | ||
| 130 | method: 'POST', | 134 | method: 'POST', | ||
| 131 | body: JSON.stringify({ | 135 | body: JSON.stringify({ | ||
| 132 | suUsername: username, | 136 | suUsername: username, | ||
| 133 | suPassword: password, | 137 | suPassword: password, | ||
| 134 | - | suConfirmPassword: confirmPassword | 138 | + | suConfirmPassword: confirmPassword, |
| 139 | + | captchaAnswer, | |||
| 140 | + | ...(turnstileToken ? { turnstileToken } : {}) | |||
| 135 | }) | 141 | }) | ||
| 136 | }); | 142 | }); | ||
| 137 | return result.data ?? { success: false, error: result.error }; | 143 | return result.data ?? { success: false, error: result.error }; | ||
| @@ -142,42 +148,28 @@ export const session = { | |||||
| 142 | 148 | ||||
| 143 | export interface SendMessageParams { | 149 | export interface SendMessageParams { | ||
| 144 | content: string; | 150 | content: string; | ||
| 145 | - | roomId: string; | 151 | + | userId: string; |
| 152 | + | chatRoomId?: string; | |||
| 153 | + | type?: SendMessageRequest['type']; | |||
| 146 | styleData?: string; | 154 | styleData?: string; | ||
| 147 | - | hasFormatting?: boolean; | |||
| 148 | } | 155 | } | ||
| 149 | 156 | ||||
| 150 | export const chat = { | 157 | export const chat = { | ||
| 151 | /** | 158 | /** | ||
| 152 | * Send a message to a chat room | 159 | * Send a message to a chat room | ||
| 153 | */ | 160 | */ | ||
| 154 | - | async sendMessage(params: SendMessageParams): Promise<boolean> { | 161 | + | async sendMessage(params: SendMessageParams): Promise<SendMessageResponse> { |
| 155 | - | const result = await request('/api/chat/messages', { | 162 | + | const result = await request<SendMessageResponse>('/api/chat/messages', { |
| 156 | method: 'POST', | 163 | method: 'POST', | ||
| 157 | body: JSON.stringify(params) | 164 | body: JSON.stringify(params) | ||
| 158 | }); | 165 | }); | ||
| 159 | - | return result.success; | 166 | + | if (result.data) { |
| 160 | - | }, | 167 | + | return result.data; |
| 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 | } | 168 | } | ||
| 180 | - | return null; | 169 | + | return { |
| 170 | + | success: false, | |||
| 171 | + | error: result.error ?? 'Failed to send message' | |||
| 172 | + | }; | |||
| 181 | } | 173 | } | ||
| 182 | }; | 174 | }; | ||
| 183 | 175 | ||||
M src/lib/components/aim-login.svelte +7 -3
| @@ -2,7 +2,7 @@ | |||
| 2 | 2 | <script lang="ts"> | |
| 3 | 3 | import { draggable } from '$lib/actions/draggable'; | |
| 4 | 4 | import type { RegisterResponse, RegisterResponseError } from '$lib/types/payloads'; | |
| 5 | - | import { invalidateAll } from '$app/navigation'; | |
| 5 | + | import { invalidate } from '$app/navigation'; | |
| 6 | 6 | import { chatState } from '$lib/states/chat.svelte'; | |
| 7 | 7 | import { createSafeUser } from '$lib/types/chat'; | |
| 8 | 8 | import { onMount } from 'svelte'; | |
| @@ -105,12 +105,15 @@ | |||
| 105 | 105 | await chatState.setCurrentUser(data.user ? createSafeUser(data.user) : null); | |
| 106 | 106 | loginStatus = 'success'; | |
| 107 | 107 | ||
| 108 | + | // Refresh session-dependent data | |
| 109 | + | await invalidate('app:session'); | |
| 110 | + | await invalidate('app:chat'); | |
| 111 | + | ||
| 108 | 112 | // Call the callback prop if provided | |
| 109 | 113 | onLoginSuccess?.(data.user ? createSafeUser(data.user) : null); | |
| 110 | 114 | // Auto close login window after 3 seconds on successful login | |
| 111 | 115 | setTimeout(() => { | |
| 112 | 116 | console.debug('Auto closing AIM Login component after successful login'); | |
| 113 | - | invalidateAll(); | |
| 114 | 117 | handleClose(); | |
| 115 | 118 | }, 3000); | |
| 116 | 119 | ||
| @@ -222,10 +225,11 @@ | |||
| 222 | 225 | } | |
| 223 | 226 | console.debug('Auto login after registration succeeded:', data); | |
| 224 | 227 | await chatState.setCurrentUser(data.user ? createSafeUser(data.user) : null); | |
| 228 | + | await invalidate('app:session'); | |
| 229 | + | await invalidate('app:chat'); | |
| 225 | 230 | onLoginSuccess?.(data.user ? createSafeUser(data.user) : null); | |
| 226 | 231 | setTimeout(() => { | |
| 227 | 232 | console.debug('Auto closing AIM Login component after auto login on registration'); | |
| 228 | - | invalidateAll(); | |
| 229 | 233 | handleClose(); | |
| 230 | 234 | }, 3000); | |
| 231 | 235 | } catch (err) { | |
| @@ -2,7 +2,7 @@ | |||||
| 2 | <script lang="ts"> | 2 | <script lang="ts"> | ||
| 3 | import { draggable } from '$lib/actions/draggable'; | 3 | import { draggable } from '$lib/actions/draggable'; | ||
| 4 | import type { RegisterResponse, RegisterResponseError } from '$lib/types/payloads'; | 4 | import type { RegisterResponse, RegisterResponseError } from '$lib/types/payloads'; | ||
| 5 | - | import { invalidateAll } from '$app/navigation'; | 5 | + | import { invalidate } from '$app/navigation'; |
| 6 | import { chatState } from '$lib/states/chat.svelte'; | 6 | import { chatState } from '$lib/states/chat.svelte'; | ||
| 7 | import { createSafeUser } from '$lib/types/chat'; | 7 | import { createSafeUser } from '$lib/types/chat'; | ||
| 8 | import { onMount } from 'svelte'; | 8 | import { onMount } from 'svelte'; | ||
| @@ -105,12 +105,15 @@ | |||||
| 105 | await chatState.setCurrentUser(data.user ? createSafeUser(data.user) : null); | 105 | await chatState.setCurrentUser(data.user ? createSafeUser(data.user) : null); | ||
| 106 | loginStatus = 'success'; | 106 | loginStatus = 'success'; | ||
| 107 | 107 | ||||
| 108 | + | // Refresh session-dependent data | |||
| 109 | + | await invalidate('app:session'); | |||
| 110 | + | await invalidate('app:chat'); | |||
| 111 | + | ||||
| 108 | // Call the callback prop if provided | 112 | // Call the callback prop if provided | ||
| 109 | onLoginSuccess?.(data.user ? createSafeUser(data.user) : null); | 113 | onLoginSuccess?.(data.user ? createSafeUser(data.user) : null); | ||
| 110 | // Auto close login window after 3 seconds on successful login | 114 | // Auto close login window after 3 seconds on successful login | ||
| 111 | setTimeout(() => { | 115 | setTimeout(() => { | ||
| 112 | console.debug('Auto closing AIM Login component after successful login'); | 116 | console.debug('Auto closing AIM Login component after successful login'); | ||
| 113 | - | invalidateAll(); | |||
| 114 | handleClose(); | 117 | handleClose(); | ||
| 115 | }, 3000); | 118 | }, 3000); | ||
| 116 | 119 | ||||
| @@ -222,10 +225,11 @@ | |||||
| 222 | } | 225 | } | ||
| 223 | console.debug('Auto login after registration succeeded:', data); | 226 | console.debug('Auto login after registration succeeded:', data); | ||
| 224 | await chatState.setCurrentUser(data.user ? createSafeUser(data.user) : null); | 227 | await chatState.setCurrentUser(data.user ? createSafeUser(data.user) : null); | ||
| 228 | + | await invalidate('app:session'); | |||
| 229 | + | await invalidate('app:chat'); | |||
| 225 | onLoginSuccess?.(data.user ? createSafeUser(data.user) : null); | 230 | onLoginSuccess?.(data.user ? createSafeUser(data.user) : null); | ||
| 226 | setTimeout(() => { | 231 | setTimeout(() => { | ||
| 227 | console.debug('Auto closing AIM Login component after auto login on registration'); | 232 | console.debug('Auto closing AIM Login component after auto login on registration'); | ||
| 228 | - | invalidateAll(); | |||
| 229 | handleClose(); | 233 | handleClose(); | ||
| 230 | }, 3000); | 234 | }, 3000); | ||
| 231 | } catch (err) { | 235 | } catch (err) { | ||
M src/lib/components/chat-room.svelte +102 -66
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | <script lang="ts"> | |
| 2 | 2 | import { chatState } from '../states/chat.svelte'; | |
| 3 | - | import { useQuery } from 'convex-svelte'; | |
| 3 | + | import { useConvexClient, useQuery } from 'convex-svelte'; | |
| 4 | 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'; | |
| @@ -34,7 +34,10 @@ | |||
| 34 | 34 | import { MAX_MESSAGE_LENGTH } from '$lib/validation/message'; | |
| 35 | 35 | ||
| 36 | 36 | // Props | |
| 37 | - | let { showChatRoom = $bindable() } = $props(); | |
| 37 | + | let { showChatRoom = $bindable(), initialTextStyle = DEFAULT_TEXT_STYLE } = $props<{ | |
| 38 | + | showChatRoom?: boolean; | |
| 39 | + | initialTextStyle?: TextStyle; | |
| 40 | + | }>(); | |
| 38 | 41 | ||
| 39 | 42 | // ============ CONVEX REAL-TIME SUBSCRIPTIONS ============ | |
| 40 | 43 | ||
| @@ -52,6 +55,8 @@ | |||
| 52 | 55 | roomId ? useQuery(convexApi.queries.getMessagesPublic, () => ({ roomId: roomId! })) : null | |
| 53 | 56 | ); | |
| 54 | 57 | ||
| 58 | + | const convexClient = useConvexClient(); | |
| 59 | + | ||
| 55 | 60 | // Transform Convex users to SafeUser format | |
| 56 | 61 | const onlineUsers = $derived.by<SafeUser[]>(() => { | |
| 57 | 62 | if (!usersQuery.data) return []; | |
| @@ -154,10 +159,14 @@ | |||
| 154 | 159 | ||
| 155 | 160 | let currentMessage = $state(''); | |
| 156 | 161 | let inputScrollLeft = $state(0); | |
| 157 | - | // Initialize text style with defaults - prop value applied via effect below | |
| 162 | + | // Initialize text style with defaults - user-specific style applied after API fetch | |
| 158 | 163 | let currentTextStyle = $state<TextStyle>({ ...DEFAULT_TEXT_STYLE }); | |
| 159 | - | // Track if we've applied user's style from DB (to avoid resetting on every prop change) | |
| 164 | + | // Track if we've applied the user's style from the API/DB (to avoid repeated fetch resets) | |
| 160 | 165 | let hasAppliedUserStyle = $state(false); | |
| 166 | + | let lastInitialStyleJson = $state<string | null>(null); | |
| 167 | + | let styleFetchNonce = $state(0); | |
| 168 | + | let styleFetchAttempts = 0; | |
| 169 | + | let styleFetchInFlight = false; | |
| 161 | 170 | let lastUserId = $state<string | null>(null); | |
| 162 | 171 | ||
| 163 | 172 | // ============ MENTION STATE ============ | |
| @@ -270,28 +279,70 @@ | |||
| 270 | 279 | ||
| 271 | 280 | // Update text style when user logs in - fetch preferences directly from API | |
| 272 | 281 | $effect(() => { | |
| 282 | + | styleFetchNonce; | |
| 273 | 283 | const userId = currentUser?.id ?? null; | |
| 284 | + | const defaultStyleJson = JSON.stringify(DEFAULT_TEXT_STYLE); | |
| 285 | + | const initialStyleJson = JSON.stringify(initialTextStyle ?? DEFAULT_TEXT_STYLE); | |
| 286 | + | ||
| 287 | + | if (initialStyleJson !== lastInitialStyleJson) { | |
| 288 | + | lastInitialStyleJson = initialStyleJson; | |
| 289 | + | currentTextStyle = { | |
| 290 | + | ...DEFAULT_TEXT_STYLE, | |
| 291 | + | ...(initialTextStyle ?? DEFAULT_TEXT_STYLE), | |
| 292 | + | color: (initialTextStyle?.color ?? DEFAULT_TEXT_STYLE.color) || '#000000' | |
| 293 | + | }; | |
| 294 | + | if (!currentUser) { | |
| 295 | + | hasAppliedUserStyle = false; | |
| 296 | + | } else if (initialStyleJson !== defaultStyleJson) { | |
| 297 | + | hasAppliedUserStyle = true; | |
| 298 | + | } | |
| 299 | + | } | |
| 274 | 300 | ||
| 275 | 301 | // User changed (logged in or different user) | |
| 276 | 302 | if (userId !== lastUserId) { | |
| 277 | 303 | lastUserId = userId; | |
| 278 | 304 | hasAppliedUserStyle = false; | |
| 305 | + | styleFetchAttempts = 0; | |
| 306 | + | styleFetchInFlight = false; | |
| 279 | 307 | } | |
| 280 | 308 | ||
| 281 | 309 | // Fetch and apply user's saved style when they log in | |
| 282 | - | if (currentUser && !hasAppliedUserStyle && browser) { | |
| 283 | - | hasAppliedUserStyle = true; | |
| 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 | - | }; | |
| 310 | + | if (!currentUser || hasAppliedUserStyle || styleFetchInFlight || !browser) return; | |
| 311 | + | ||
| 312 | + | let isActive = true; | |
| 313 | + | const activeUserId = currentUser.id; | |
| 314 | + | const attempt = styleFetchAttempts + 1; | |
| 315 | + | styleFetchAttempts = attempt; | |
| 316 | + | styleFetchInFlight = true; | |
| 317 | + | // Fetch user preferences directly to avoid timing issues with invalidateAll | |
| 318 | + | api.textPreferences | |
| 319 | + | .get() | |
| 320 | + | .then((prefs) => { | |
| 321 | + | if (!isActive || currentUser?.id !== activeUserId) return; | |
| 322 | + | if (!prefs?.defaultStyle) return; | |
| 323 | + | currentTextStyle = { | |
| 324 | + | ...DEFAULT_TEXT_STYLE, | |
| 325 | + | ...prefs.defaultStyle, | |
| 326 | + | color: prefs.defaultStyle.color || '#000000' | |
| 327 | + | }; | |
| 328 | + | hasAppliedUserStyle = true; | |
| 329 | + | }) | |
| 330 | + | .finally(() => { | |
| 331 | + | if (!isActive || currentUser?.id !== activeUserId) return; | |
| 332 | + | if (!hasAppliedUserStyle && attempt < 3) { | |
| 333 | + | const retryDelay = Math.min(1500, 300 * attempt); | |
| 334 | + | window.setTimeout(() => { | |
| 335 | + | styleFetchInFlight = false; | |
| 336 | + | styleFetchNonce += 1; | |
| 337 | + | }, retryDelay); | |
| 338 | + | return; | |
| 292 | 339 | } | |
| 340 | + | styleFetchInFlight = false; | |
| 293 | 341 | }); | |
| 294 | - | } | |
| 342 | + | ||
| 343 | + | return () => { | |
| 344 | + | isActive = false; | |
| 345 | + | }; | |
| 295 | 346 | }); | |
| 296 | 347 | ||
| 297 | 348 | // Save text style to database when it changes (after initial load) | |
| @@ -822,26 +873,19 @@ | |||
| 822 | 873 | ||
| 823 | 874 | isSendingMessage = true; | |
| 824 | 875 | try { | |
| 825 | - | const response = await fetch('/api/chat/messages', { | |
| 826 | - | method: 'POST', | |
| 827 | - | headers: { 'Content-Type': 'application/json' }, | |
| 828 | - | credentials: 'include', | |
| 829 | - | body: JSON.stringify({ | |
| 830 | - | content: currentMessage, | |
| 831 | - | type: 'chat', | |
| 832 | - | userId: currentUser.id, | |
| 833 | - | chatRoomId: roomId, | |
| 834 | - | styleData: currentTextStyle ? JSON.stringify(currentTextStyle) : undefined | |
| 835 | - | }) | |
| 876 | + | const result = await api.chat.sendMessage({ | |
| 877 | + | content: currentMessage, | |
| 878 | + | type: 'chat', | |
| 879 | + | userId: currentUser.id, | |
| 880 | + | chatRoomId: roomId, | |
| 881 | + | styleData: currentTextStyle ? JSON.stringify(currentTextStyle) : undefined | |
| 836 | 882 | }); | |
| 837 | 883 | ||
| 838 | - | const data = await response.json(); | |
| 839 | - | ||
| 840 | - | if (!data.success && data.isRateLimited && data.retryAfter) { | |
| 841 | - | startCooldownTimer(data.retryAfter); | |
| 884 | + | if (!result.success && result.isRateLimited && result.retryAfter) { | |
| 885 | + | startCooldownTimer(result.retryAfter); | |
| 842 | 886 | rateLimitWarning = "Whoa there! You're sending messages too quickly. Take a breather..."; | |
| 843 | - | } else if (!data.success) { | |
| 844 | - | rateLimitWarning = data.error || 'Failed to send message. Please try again.'; | |
| 887 | + | } else if (!result.success) { | |
| 888 | + | rateLimitWarning = result.error || 'Failed to send message. Please try again.'; | |
| 845 | 889 | setTimeout(() => (rateLimitWarning = null), 3000); | |
| 846 | 890 | } else { | |
| 847 | 891 | currentMessage = ''; | |
| @@ -905,45 +949,37 @@ | |||
| 905 | 949 | const target = event.target as HTMLElement; | |
| 906 | 950 | const { scrollTop } = target; | |
| 907 | 951 | ||
| 908 | - | if (scrollTop < 100 && !isLoadingMore && hasMoreMessages && roomId && chatArea) { | |
| 952 | + | if (scrollTop < 100 && !isLoadingMore && hasMoreMessages && roomId && chatArea && currentUser) { | |
| 909 | 953 | isLoadingMore = true; | |
| 910 | 954 | const prevScrollHeight = chatArea.scrollHeight; | |
| 911 | 955 | const prevScrollTop = chatArea.scrollTop; | |
| 912 | 956 | ||
| 913 | 957 | try { | |
| 914 | - | // Build query string manually to avoid URLSearchParams (svelte reactivity compliance) | |
| 915 | - | const queryParts = [ | |
| 916 | - | `before=${encodeURIComponent(oldestMessageTimestamp?.toString() || '')}`, | |
| 917 | - | `roomId=${encodeURIComponent(roomId)}` | |
| 918 | - | ]; | |
| 919 | - | if (!currentUser) queryParts.push('public=true'); | |
| 920 | - | const queryString = queryParts.join('&'); | |
| 921 | - | ||
| 922 | - | const response = await fetch(`/api/chat/messages?${queryString}`); | |
| 923 | - | ||
| 924 | - | if (response.ok) { | |
| 925 | - | const data = await response.json(); | |
| 926 | - | if (data.success) { | |
| 927 | - | if (data.messages.length === 0) { | |
| 928 | - | hasMoreMessages = false; | |
| 929 | - | } else { | |
| 930 | - | const fetchedMessages = data.messages as Message[]; | |
| 931 | - | mergePagedMessages(fetchedMessages); | |
| 932 | - | ||
| 933 | - | requestAnimationFrame(() => { | |
| 934 | - | if (!chatArea) return; | |
| 935 | - | const newScrollHeight = chatArea.scrollHeight; | |
| 936 | - | const heightDifference = newScrollHeight - prevScrollHeight; | |
| 937 | - | chatArea.scrollTop = prevScrollTop + heightDifference; | |
| 938 | - | }); | |
| 939 | - | ||
| 940 | - | const newOldest = Math.min(...fetchedMessages.map((m) => m.timestamp)); | |
| 941 | - | oldestMessageTimestamp = oldestMessageTimestamp | |
| 942 | - | ? Math.min(oldestMessageTimestamp, newOldest) | |
| 943 | - | : newOldest; | |
| 944 | - | hasMoreMessages = data.hasMore; | |
| 945 | - | } | |
| 946 | - | } | |
| 958 | + | const fetchLimit = currentUser ? 100 : 50; | |
| 959 | + | const data = await convexClient.query(convexApi.queries.getMessagesPublicPage, { | |
| 960 | + | roomId, | |
| 961 | + | limit: fetchLimit, | |
| 962 | + | beforeTimestamp: oldestMessageTimestamp ?? undefined | |
| 963 | + | }); | |
| 964 | + | ||
| 965 | + | if (data.messages.length === 0) { | |
| 966 | + | hasMoreMessages = false; | |
| 967 | + | } else { | |
| 968 | + | const fetchedMessages = data.messages as Message[]; | |
| 969 | + | mergePagedMessages(fetchedMessages); | |
| 970 | + | ||
| 971 | + | requestAnimationFrame(() => { | |
| 972 | + | if (!chatArea) return; | |
| 973 | + | const newScrollHeight = chatArea.scrollHeight; | |
| 974 | + | const heightDifference = newScrollHeight - prevScrollHeight; | |
| 975 | + | chatArea.scrollTop = prevScrollTop + heightDifference; | |
| 976 | + | }); | |
| 977 | + | ||
| 978 | + | const newOldest = Math.min(...fetchedMessages.map((m) => m.timestamp)); | |
| 979 | + | oldestMessageTimestamp = oldestMessageTimestamp | |
| 980 | + | ? Math.min(oldestMessageTimestamp, newOldest) | |
| 981 | + | : newOldest; | |
| 982 | + | hasMoreMessages = data.hasMore; | |
| 947 | 983 | } | |
| 948 | 984 | } catch (error) { | |
| 949 | 985 | console.error('Error loading more messages:', error); | |
| @@ -1,6 +1,6 @@ | |||||
| 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 { useConvexClient, useQuery } from 'convex-svelte'; |
| 4 | import { api as convexApi } 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'; | ||
| @@ -34,7 +34,10 @@ | |||||
| 34 | import { MAX_MESSAGE_LENGTH } from '$lib/validation/message'; | 34 | import { MAX_MESSAGE_LENGTH } from '$lib/validation/message'; | ||
| 35 | 35 | ||||
| 36 | // Props | 36 | // Props | ||
| 37 | - | let { showChatRoom = $bindable() } = $props(); | 37 | + | let { showChatRoom = $bindable(), initialTextStyle = DEFAULT_TEXT_STYLE } = $props<{ |
| 38 | + | showChatRoom?: boolean; | |||
| 39 | + | initialTextStyle?: TextStyle; | |||
| 40 | + | }>(); | |||
| 38 | 41 | ||||
| 39 | // ============ CONVEX REAL-TIME SUBSCRIPTIONS ============ | 42 | // ============ CONVEX REAL-TIME SUBSCRIPTIONS ============ | ||
| 40 | 43 | ||||
| @@ -52,6 +55,8 @@ | |||||
| 52 | roomId ? useQuery(convexApi.queries.getMessagesPublic, () => ({ roomId: roomId! })) : null | 55 | roomId ? useQuery(convexApi.queries.getMessagesPublic, () => ({ roomId: roomId! })) : null | ||
| 53 | ); | 56 | ); | ||
| 54 | 57 | ||||
| 58 | + | const convexClient = useConvexClient(); | |||
| 59 | + | ||||
| 55 | // Transform Convex users to SafeUser format | 60 | // Transform Convex users to SafeUser format | ||
| 56 | const onlineUsers = $derived.by<SafeUser[]>(() => { | 61 | const onlineUsers = $derived.by<SafeUser[]>(() => { | ||
| 57 | if (!usersQuery.data) return []; | 62 | if (!usersQuery.data) return []; | ||
| @@ -154,10 +159,14 @@ | |||||
| 154 | 159 | ||||
| 155 | let currentMessage = $state(''); | 160 | let currentMessage = $state(''); | ||
| 156 | let inputScrollLeft = $state(0); | 161 | let inputScrollLeft = $state(0); | ||
| 157 | - | // Initialize text style with defaults - prop value applied via effect below | 162 | + | // Initialize text style with defaults - user-specific style applied after API fetch |
| 158 | let currentTextStyle = $state<TextStyle>({ ...DEFAULT_TEXT_STYLE }); | 163 | let currentTextStyle = $state<TextStyle>({ ...DEFAULT_TEXT_STYLE }); | ||
| 159 | - | // Track if we've applied user's style from DB (to avoid resetting on every prop change) | 164 | + | // Track if we've applied the user's style from the API/DB (to avoid repeated fetch resets) |
| 160 | let hasAppliedUserStyle = $state(false); | 165 | let hasAppliedUserStyle = $state(false); | ||
| 166 | + | let lastInitialStyleJson = $state<string | null>(null); | |||
| 167 | + | let styleFetchNonce = $state(0); | |||
| 168 | + | let styleFetchAttempts = 0; | |||
| 169 | + | let styleFetchInFlight = false; | |||
| 161 | let lastUserId = $state<string | null>(null); | 170 | let lastUserId = $state<string | null>(null); | ||
| 162 | 171 | ||||
| 163 | // ============ MENTION STATE ============ | 172 | // ============ MENTION STATE ============ | ||
| @@ -270,28 +279,70 @@ | |||||
| 270 | 279 | ||||
| 271 | // Update text style when user logs in - fetch preferences directly from API | 280 | // Update text style when user logs in - fetch preferences directly from API | ||
| 272 | $effect(() => { | 281 | $effect(() => { | ||
| 282 | + | styleFetchNonce; | |||
| 273 | const userId = currentUser?.id ?? null; | 283 | const userId = currentUser?.id ?? null; | ||
| 284 | + | const defaultStyleJson = JSON.stringify(DEFAULT_TEXT_STYLE); | |||
| 285 | + | const initialStyleJson = JSON.stringify(initialTextStyle ?? DEFAULT_TEXT_STYLE); | |||
| 286 | + | ||||
| 287 | + | if (initialStyleJson !== lastInitialStyleJson) { | |||
| 288 | + | lastInitialStyleJson = initialStyleJson; | |||
| 289 | + | currentTextStyle = { | |||
| 290 | + | ...DEFAULT_TEXT_STYLE, | |||
| 291 | + | ...(initialTextStyle ?? DEFAULT_TEXT_STYLE), | |||
| 292 | + | color: (initialTextStyle?.color ?? DEFAULT_TEXT_STYLE.color) || '#000000' | |||
| 293 | + | }; | |||
| 294 | + | if (!currentUser) { | |||
| 295 | + | hasAppliedUserStyle = false; | |||
| 296 | + | } else if (initialStyleJson !== defaultStyleJson) { | |||
| 297 | + | hasAppliedUserStyle = true; | |||
| 298 | + | } | |||
| 299 | + | } | |||
| 274 | 300 | ||||
| 275 | // User changed (logged in or different user) | 301 | // User changed (logged in or different user) | ||
| 276 | if (userId !== lastUserId) { | 302 | if (userId !== lastUserId) { | ||
| 277 | lastUserId = userId; | 303 | lastUserId = userId; | ||
| 278 | hasAppliedUserStyle = false; | 304 | hasAppliedUserStyle = false; | ||
| 305 | + | styleFetchAttempts = 0; | |||
| 306 | + | styleFetchInFlight = false; | |||
| 279 | } | 307 | } | ||
| 280 | 308 | ||||
| 281 | // Fetch and apply user's saved style when they log in | 309 | // Fetch and apply user's saved style when they log in | ||
| 282 | - | if (currentUser && !hasAppliedUserStyle && browser) { | 310 | + | if (!currentUser || hasAppliedUserStyle || styleFetchInFlight || !browser) return; |
| 283 | - | hasAppliedUserStyle = true; | 311 | + | |
| 284 | - | // Fetch user preferences directly to avoid timing issues with invalidateAll | 312 | + | let isActive = true; |
| 285 | - | api.textPreferences.get().then((prefs) => { | 313 | + | const activeUserId = currentUser.id; |
| 286 | - | if (prefs?.defaultStyle) { | 314 | + | const attempt = styleFetchAttempts + 1; |
| 287 | - | currentTextStyle = { | 315 | + | styleFetchAttempts = attempt; |
| 288 | - | ...DEFAULT_TEXT_STYLE, | 316 | + | styleFetchInFlight = true; |
| 289 | - | ...prefs.defaultStyle, | 317 | + | // Fetch user preferences directly to avoid timing issues with invalidateAll |
| 290 | - | color: prefs.defaultStyle.color || '#000000' | 318 | + | api.textPreferences |
| 291 | - | }; | 319 | + | .get() |
| 320 | + | .then((prefs) => { | |||
| 321 | + | if (!isActive || currentUser?.id !== activeUserId) return; | |||
| 322 | + | if (!prefs?.defaultStyle) return; | |||
| 323 | + | currentTextStyle = { | |||
| 324 | + | ...DEFAULT_TEXT_STYLE, | |||
| 325 | + | ...prefs.defaultStyle, | |||
| 326 | + | color: prefs.defaultStyle.color || '#000000' | |||
| 327 | + | }; | |||
| 328 | + | hasAppliedUserStyle = true; | |||
| 329 | + | }) | |||
| 330 | + | .finally(() => { | |||
| 331 | + | if (!isActive || currentUser?.id !== activeUserId) return; | |||
| 332 | + | if (!hasAppliedUserStyle && attempt < 3) { | |||
| 333 | + | const retryDelay = Math.min(1500, 300 * attempt); | |||
| 334 | + | window.setTimeout(() => { | |||
| 335 | + | styleFetchInFlight = false; | |||
| 336 | + | styleFetchNonce += 1; | |||
| 337 | + | }, retryDelay); | |||
| 338 | + | return; | |||
| 292 | } | 339 | } | ||
| 340 | + | styleFetchInFlight = false; | |||
| 293 | }); | 341 | }); | ||
| 294 | - | } | 342 | + | |
| 343 | + | return () => { | |||
| 344 | + | isActive = false; | |||
| 345 | + | }; | |||
| 295 | }); | 346 | }); | ||
| 296 | 347 | ||||
| 297 | // Save text style to database when it changes (after initial load) | 348 | // Save text style to database when it changes (after initial load) | ||
| @@ -822,26 +873,19 @@ | |||||
| 822 | 873 | ||||
| 823 | isSendingMessage = true; | 874 | isSendingMessage = true; | ||
| 824 | try { | 875 | try { | ||
| 825 | - | const response = await fetch('/api/chat/messages', { | 876 | + | const result = await api.chat.sendMessage({ |
| 826 | - | method: 'POST', | 877 | + | content: currentMessage, |
| 827 | - | headers: { 'Content-Type': 'application/json' }, | 878 | + | type: 'chat', |
| 828 | - | credentials: 'include', | 879 | + | userId: currentUser.id, |
| 829 | - | body: JSON.stringify({ | 880 | + | chatRoomId: roomId, |
| 830 | - | content: currentMessage, | 881 | + | styleData: currentTextStyle ? JSON.stringify(currentTextStyle) : undefined |
| 831 | - | type: 'chat', | |||
| 832 | - | userId: currentUser.id, | |||
| 833 | - | chatRoomId: roomId, | |||
| 834 | - | styleData: currentTextStyle ? JSON.stringify(currentTextStyle) : undefined | |||
| 835 | - | }) | |||
| 836 | }); | 882 | }); | ||
| 837 | 883 | ||||
| 838 | - | const data = await response.json(); | 884 | + | if (!result.success && result.isRateLimited && result.retryAfter) { |
| 839 | - | 885 | + | startCooldownTimer(result.retryAfter); | |
| 840 | - | if (!data.success && data.isRateLimited && data.retryAfter) { | |||
| 841 | - | startCooldownTimer(data.retryAfter); | |||
| 842 | rateLimitWarning = "Whoa there! You're sending messages too quickly. Take a breather..."; | 886 | rateLimitWarning = "Whoa there! You're sending messages too quickly. Take a breather..."; | ||
| 843 | - | } else if (!data.success) { | 887 | + | } else if (!result.success) { |
| 844 | - | rateLimitWarning = data.error || 'Failed to send message. Please try again.'; | 888 | + | rateLimitWarning = result.error || 'Failed to send message. Please try again.'; |
| 845 | setTimeout(() => (rateLimitWarning = null), 3000); | 889 | setTimeout(() => (rateLimitWarning = null), 3000); | ||
| 846 | } else { | 890 | } else { | ||
| 847 | currentMessage = ''; | 891 | currentMessage = ''; | ||
| @@ -905,45 +949,37 @@ | |||||
| 905 | const target = event.target as HTMLElement; | 949 | const target = event.target as HTMLElement; | ||
| 906 | const { scrollTop } = target; | 950 | const { scrollTop } = target; | ||
| 907 | 951 | ||||
| 908 | - | if (scrollTop < 100 && !isLoadingMore && hasMoreMessages && roomId && chatArea) { | 952 | + | if (scrollTop < 100 && !isLoadingMore && hasMoreMessages && roomId && chatArea && currentUser) { |
| 909 | isLoadingMore = true; | 953 | isLoadingMore = true; | ||
| 910 | const prevScrollHeight = chatArea.scrollHeight; | 954 | const prevScrollHeight = chatArea.scrollHeight; | ||
| 911 | const prevScrollTop = chatArea.scrollTop; | 955 | const prevScrollTop = chatArea.scrollTop; | ||
| 912 | 956 | ||||
| 913 | try { | 957 | try { | ||
| 914 | - | // Build query string manually to avoid URLSearchParams (svelte reactivity compliance) | 958 | + | const fetchLimit = currentUser ? 100 : 50; |
| 915 | - | const queryParts = [ | 959 | + | const data = await convexClient.query(convexApi.queries.getMessagesPublicPage, { |
| 916 | - | `before=${encodeURIComponent(oldestMessageTimestamp?.toString() || '')}`, | 960 | + | roomId, |
| 917 | - | `roomId=${encodeURIComponent(roomId)}` | 961 | + | limit: fetchLimit, |
| 918 | - | ]; | 962 | + | beforeTimestamp: oldestMessageTimestamp ?? undefined |
| 919 | - | if (!currentUser) queryParts.push('public=true'); | 963 | + | }); |
| 920 | - | const queryString = queryParts.join('&'); | 964 | + | |
| 921 | - | 965 | + | if (data.messages.length === 0) { | |
| 922 | - | const response = await fetch(`/api/chat/messages?${queryString}`); | 966 | + | hasMoreMessages = false; |
| 923 | - | 967 | + | } else { | |
| 924 | - | if (response.ok) { | 968 | + | const fetchedMessages = data.messages as Message[]; |
| 925 | - | const data = await response.json(); | 969 | + | mergePagedMessages(fetchedMessages); |
| 926 | - | if (data.success) { | 970 | + | |
| 927 | - | if (data.messages.length === 0) { | 971 | + | requestAnimationFrame(() => { |
| 928 | - | hasMoreMessages = false; | 972 | + | if (!chatArea) return; |
| 929 | - | } else { | 973 | + | const newScrollHeight = chatArea.scrollHeight; |
| 930 | - | const fetchedMessages = data.messages as Message[]; | 974 | + | const heightDifference = newScrollHeight - prevScrollHeight; |
| 931 | - | mergePagedMessages(fetchedMessages); | 975 | + | chatArea.scrollTop = prevScrollTop + heightDifference; |
| 932 | - | 976 | + | }); | |
| 933 | - | requestAnimationFrame(() => { | 977 | + | |
| 934 | - | if (!chatArea) return; | 978 | + | const newOldest = Math.min(...fetchedMessages.map((m) => m.timestamp)); |
| 935 | - | const newScrollHeight = chatArea.scrollHeight; | 979 | + | oldestMessageTimestamp = oldestMessageTimestamp |
| 936 | - | const heightDifference = newScrollHeight - prevScrollHeight; | 980 | + | ? Math.min(oldestMessageTimestamp, newOldest) |
| 937 | - | chatArea.scrollTop = prevScrollTop + heightDifference; | 981 | + | : newOldest; |
| 938 | - | }); | 982 | + | hasMoreMessages = data.hasMore; |
| 939 | - | ||||
| 940 | - | const newOldest = Math.min(...fetchedMessages.map((m) => m.timestamp)); | |||
| 941 | - | oldestMessageTimestamp = oldestMessageTimestamp | |||
| 942 | - | ? Math.min(oldestMessageTimestamp, newOldest) | |||
| 943 | - | : newOldest; | |||
| 944 | - | hasMoreMessages = data.hasMore; | |||
| 945 | - | } | |||
| 946 | - | } | |||
| 947 | } | 983 | } | ||
| 948 | } catch (error) { | 984 | } catch (error) { | ||
| 949 | console.error('Error loading more messages:', error); | 985 | console.error('Error loading more messages:', error); | ||
M src/routes/+layout.svelte +1 -3
| @@ -55,8 +55,7 @@ | |||
| 55 | 55 | reason: status !== lastSentStatus ? 'status_changed' : 'force_update', | |
| 56 | 56 | timeSinceLastUpdate, | |
| 57 | 57 | trigger: new Error().stack?.split('\n')[2]?.trim(), | |
| 58 | - | hasSession: true, | |
| 59 | - | cookiePresent: browser ? document.cookie.includes('session=') : false | |
| 58 | + | hasSession: true | |
| 60 | 59 | }); | |
| 61 | 60 | ||
| 62 | 61 | try { | |
| @@ -220,7 +219,6 @@ | |||
| 220 | 219 | expiresAt: new Date(data.session.expiresAt) | |
| 221 | 220 | } | |
| 222 | 221 | : null, | |
| 223 | - | cookiePresent: document.cookie.includes('session='), | |
| 224 | 222 | isVisible | |
| 225 | 223 | }); | |
| 226 | 224 | ||
| @@ -55,8 +55,7 @@ | |||||
| 55 | reason: status !== lastSentStatus ? 'status_changed' : 'force_update', | 55 | reason: status !== lastSentStatus ? 'status_changed' : 'force_update', | ||
| 56 | timeSinceLastUpdate, | 56 | timeSinceLastUpdate, | ||
| 57 | trigger: new Error().stack?.split('\n')[2]?.trim(), | 57 | trigger: new Error().stack?.split('\n')[2]?.trim(), | ||
| 58 | - | hasSession: true, | 58 | + | hasSession: true |
| 59 | - | cookiePresent: browser ? document.cookie.includes('session=') : false | |||
| 60 | }); | 59 | }); | ||
| 61 | 60 | ||||
| 62 | try { | 61 | try { | ||
| @@ -220,7 +219,6 @@ | |||||
| 220 | expiresAt: new Date(data.session.expiresAt) | 219 | expiresAt: new Date(data.session.expiresAt) | ||
| 221 | } | 220 | } | ||
| 222 | : null, | 221 | : null, | ||
| 223 | - | cookiePresent: document.cookie.includes('session='), | |||
| 224 | isVisible | 222 | isVisible | ||
| 225 | }); | 223 | }); | ||
| 226 | 224 | ||||
M src/routes/+layout.ts +0 -19
| @@ -4,25 +4,6 @@ export const load: LayoutLoad = async ({ data, depends }) => { | |||
| 4 | 4 | // Register client-side dependencies for invalidation | |
| 5 | 5 | depends('app:session'); | |
| 6 | 6 | depends('app:chat'); | |
| 7 | - | // Debug log for session data with more details | |
| 8 | - | console.debug('Layout load - Client session data:', { | |
| 9 | - | hasUser: !!data.user, | |
| 10 | - | userDetails: data.user | |
| 11 | - | ? { | |
| 12 | - | nickname: data.user.nickname, | |
| 13 | - | status: data.user.status, | |
| 14 | - | id: data.user.id | |
| 15 | - | } | |
| 16 | - | : null, | |
| 17 | - | hasSession: !!data.session, | |
| 18 | - | sessionDetails: data.session | |
| 19 | - | ? { | |
| 20 | - | id: data.session.id, | |
| 21 | - | expiresAt: new Date(data.session.expiresAt) | |
| 22 | - | } | |
| 23 | - | : null | |
| 24 | - | }); | |
| 25 | - | ||
| 26 | 7 | // Return the data from the server load function | |
| 27 | 8 | return { | |
| 28 | 9 | user: data.user, | |
| @@ -4,25 +4,6 @@ export const load: LayoutLoad = async ({ data, depends }) => { | |||||
| 4 | // Register client-side dependencies for invalidation | 4 | // Register client-side dependencies for invalidation | ||
| 5 | depends('app:session'); | 5 | depends('app:session'); | ||
| 6 | depends('app:chat'); | 6 | depends('app:chat'); | ||
| 7 | - | // Debug log for session data with more details | |||
| 8 | - | console.debug('Layout load - Client session data:', { | |||
| 9 | - | hasUser: !!data.user, | |||
| 10 | - | userDetails: data.user | |||
| 11 | - | ? { | |||
| 12 | - | nickname: data.user.nickname, | |||
| 13 | - | status: data.user.status, | |||
| 14 | - | id: data.user.id | |||
| 15 | - | } | |||
| 16 | - | : null, | |||
| 17 | - | hasSession: !!data.session, | |||
| 18 | - | sessionDetails: data.session | |||
| 19 | - | ? { | |||
| 20 | - | id: data.session.id, | |||
| 21 | - | expiresAt: new Date(data.session.expiresAt) | |||
| 22 | - | } | |||
| 23 | - | : null | |||
| 24 | - | }); | |||
| 25 | - | ||||
| 26 | // Return the data from the server load function | 7 | // Return the data from the server load function | ||
| 27 | return { | 8 | return { | ||
| 28 | user: data.user, | 9 | user: data.user, | ||
M src/routes/+page.server.ts +2 -1
| @@ -7,7 +7,8 @@ import { | |||
| 7 | 7 | } from '$lib/types/text-formatting'; | |
| 8 | 8 | import convex from '$lib/db/convex.server'; | |
| 9 | 9 | ||
| 10 | - | export const load: PageServerLoad = async ({ cookies }) => { | |
| 10 | + | export const load: PageServerLoad = async ({ cookies, depends }) => { | |
| 11 | + | depends('app:session'); | |
| 11 | 12 | const token = cookies.get('session'); | |
| 12 | 13 | ||
| 13 | 14 | if (!token) { | |
| @@ -7,7 +7,8 @@ import { | |||||
| 7 | } from '$lib/types/text-formatting'; | 7 | } from '$lib/types/text-formatting'; | ||
| 8 | import convex from '$lib/db/convex.server'; | 8 | import convex from '$lib/db/convex.server'; | ||
| 9 | 9 | ||||
| 10 | - | export const load: PageServerLoad = async ({ cookies }) => { | 10 | + | export const load: PageServerLoad = async ({ cookies, depends }) => { |
| 11 | + | depends('app:session'); | |||
| 11 | const token = cookies.get('session'); | 12 | const token = cookies.get('session'); | ||
| 12 | 13 | ||||
| 13 | if (!token) { | 14 | if (!token) { | ||
M src/routes/+page.svelte +3 -1
| @@ -3,6 +3,8 @@ | |||
| 3 | 3 | import DesktopIcons from '$lib/components/desktop-icons.svelte'; | |
| 4 | 4 | import AimLogin from '$lib/components/aim-login.svelte'; | |
| 5 | 5 | ||
| 6 | + | const props = $props(); | |
| 7 | + | const pageData = $derived(props.data); | |
| 6 | 8 | let showChatRoom = $state(true); | |
| 7 | 9 | let showAuth = $state(false); | |
| 8 | 10 | ||
| @@ -15,7 +17,7 @@ | |||
| 15 | 17 | <DesktopIcons bind:showChatRoom bind:showAuth /> | |
| 16 | 18 | ||
| 17 | 19 | {#if showChatRoom} | |
| 18 | - | <ChatRoom bind:showChatRoom /> | |
| 20 | + | <ChatRoom bind:showChatRoom initialTextStyle={pageData.textStyle} /> | |
| 19 | 21 | {/if} | |
| 20 | 22 | ||
| 21 | 23 | {#if showAuth} | |
| @@ -3,6 +3,8 @@ | |||||
| 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 | 5 | ||||
| 6 | + | const props = $props(); | |||
| 7 | + | const pageData = $derived(props.data); | |||
| 6 | let showChatRoom = $state(true); | 8 | let showChatRoom = $state(true); | ||
| 7 | let showAuth = $state(false); | 9 | let showAuth = $state(false); | ||
| 8 | 10 | ||||
| @@ -15,7 +17,7 @@ | |||||
| 15 | <DesktopIcons bind:showChatRoom bind:showAuth /> | 17 | <DesktopIcons bind:showChatRoom bind:showAuth /> | ||
| 16 | 18 | ||||
| 17 | {#if showChatRoom} | 19 | {#if showChatRoom} | ||
| 18 | - | <ChatRoom bind:showChatRoom /> | 20 | + | <ChatRoom bind:showChatRoom initialTextStyle={pageData.textStyle} /> |
| 19 | {/if} | 21 | {/if} | ||
| 20 | 22 | ||||
| 21 | {#if showAuth} | 23 | {#if showAuth} | ||
M src/routes/api/chat/messages/+server.ts +14 -2
| @@ -49,7 +49,7 @@ function updateUserCooldown(userId: string): { canSend: boolean; retryAfter?: nu | |||
| 49 | 49 | export const GET: RequestHandler = async ({ url, locals }) => { | |
| 50 | 50 | const beforeTimestamp = url.searchParams.get('before'); | |
| 51 | 51 | const roomIdParam = url.searchParams.get('roomId'); | |
| 52 | - | const isPublic = url.searchParams.get('public') === 'true'; | |
| 52 | + | const isPublic = url.searchParams.get('public') === 'true' || !locals.session; | |
| 53 | 53 | ||
| 54 | 54 | // Use default room if not specified | |
| 55 | 55 | const roomId = roomIdParam || getDefaultChatRoomId(); | |
| @@ -67,7 +67,19 @@ export const GET: RequestHandler = async ({ url, locals }) => { | |||
| 67 | 67 | isPublic | |
| 68 | 68 | }); | |
| 69 | 69 | ||
| 70 | - | const fetchLimit = isPublic || !locals.session ? 50 : 100; | |
| 70 | + | if (isPublic && beforeTimestamp) { | |
| 71 | + | const response: GetMessagesResponse = { | |
| 72 | + | success: true, | |
| 73 | + | messages: [], | |
| 74 | + | hasMore: false | |
| 75 | + | }; | |
| 76 | + | ||
| 77 | + | return new Response(JSON.stringify(response), { | |
| 78 | + | headers: { 'Content-Type': 'application/json' } | |
| 79 | + | }); | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | const fetchLimit = 100; | |
| 71 | 83 | ||
| 72 | 84 | const result = await messages.getByRoom( | |
| 73 | 85 | roomId, | |
| @@ -49,7 +49,7 @@ function updateUserCooldown(userId: string): { canSend: boolean; retryAfter?: nu | |||||
| 49 | export const GET: RequestHandler = async ({ url, locals }) => { | 49 | export const GET: RequestHandler = async ({ url, locals }) => { | ||
| 50 | const beforeTimestamp = url.searchParams.get('before'); | 50 | const beforeTimestamp = url.searchParams.get('before'); | ||
| 51 | const roomIdParam = url.searchParams.get('roomId'); | 51 | const roomIdParam = url.searchParams.get('roomId'); | ||
| 52 | - | const isPublic = url.searchParams.get('public') === 'true'; | 52 | + | const isPublic = url.searchParams.get('public') === 'true' || !locals.session; |
| 53 | 53 | ||||
| 54 | // Use default room if not specified | 54 | // Use default room if not specified | ||
| 55 | const roomId = roomIdParam || getDefaultChatRoomId(); | 55 | const roomId = roomIdParam || getDefaultChatRoomId(); | ||
| @@ -67,7 +67,19 @@ export const GET: RequestHandler = async ({ url, locals }) => { | |||||
| 67 | isPublic | 67 | isPublic | ||
| 68 | }); | 68 | }); | ||
| 69 | 69 | ||||
| 70 | - | const fetchLimit = isPublic || !locals.session ? 50 : 100; | 70 | + | if (isPublic && beforeTimestamp) { |
| 71 | + | const response: GetMessagesResponse = { | |||
| 72 | + | success: true, | |||
| 73 | + | messages: [], | |||
| 74 | + | hasMore: false | |||
| 75 | + | }; | |||
| 76 | + | ||||
| 77 | + | return new Response(JSON.stringify(response), { | |||
| 78 | + | headers: { 'Content-Type': 'application/json' } | |||
| 79 | + | }); | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | const fetchLimit = 100; | |||
| 71 | 83 | ||||
| 72 | const result = await messages.getByRoom( | 84 | const result = await messages.getByRoom( | ||
| 73 | roomId, | 85 | roomId, | ||