Merge pull request #83 from kitpdr/fix/chat-ux-improvements
feat(api): add centralized API client and enforce public message limit
9 files · +433 -147
M src/convex/queries.ts +52 -6
| @@ -26,13 +26,18 @@ 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 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))); | |
| 36 | 41 | ||
| 37 | 42 | const messages = await ctx.db | |
| 38 | 43 | .query('messages') | |
| @@ -70,6 +75,46 @@ export const getMessagesPublic = query({ | |||
| 70 | 75 | } | |
| 71 | 76 | }); | |
| 72 | 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 | + | ||
| 73 | 118 | // Public query: Get all users (real-time subscription for buddy list) | |
| 74 | 119 | export const getUsersPublic = query({ | |
| 75 | 120 | args: {}, | |
| @@ -127,13 +172,14 @@ export const getMessages = authQuery({ | |||
| 127 | 172 | limit: v.optional(v.number()) | |
| 128 | 173 | }, | |
| 129 | 174 | handler: async (ctx, args) => { | |
| 130 | - | const limit = args.limit ?? 100; | |
| 131 | - | ||
| 132 | - | const messages = await ctx.db | |
| 175 | + | const rawLimit = args.limit ?? 100; | |
| 176 | + | const limit = Math.max(1, Math.floor(rawLimit)); | |
| 177 | + | const query = ctx.db | |
| 133 | 178 | .query('messages') | |
| 134 | 179 | .withIndex('by_chatRoom', (q) => q.eq('chatRoomId', args.roomId)) | |
| 135 | - | .order('desc') | |
| 136 | - | .take(limit); | |
| 180 | + | .order('desc'); | |
| 181 | + | ||
| 182 | + | const messages = await query.take(limit); | |
| 137 | 183 | ||
| 138 | 184 | // Enrich messages with sender info | |
| 139 | 185 | const enrichedMessages = await Promise.all( | |
| @@ -26,13 +26,18 @@ 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 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))); | |||
| 36 | 41 | ||||
| 37 | const messages = await ctx.db | 42 | const messages = await ctx.db | ||
| 38 | .query('messages') | 43 | .query('messages') | ||
| @@ -70,6 +75,46 @@ export const getMessagesPublic = query({ | |||||
| 70 | } | 75 | } | ||
| 71 | }); | 76 | }); | ||
| 72 | 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 | + | ||||
| 73 | // Public query: Get all users (real-time subscription for buddy list) | 118 | // Public query: Get all users (real-time subscription for buddy list) | ||
| 74 | export const getUsersPublic = query({ | 119 | export const getUsersPublic = query({ | ||
| 75 | args: {}, | 120 | args: {}, | ||
| @@ -127,13 +172,14 @@ export const getMessages = authQuery({ | |||||
| 127 | limit: v.optional(v.number()) | 172 | limit: v.optional(v.number()) | ||
| 128 | }, | 173 | }, | ||
| 129 | handler: async (ctx, args) => { | 174 | handler: async (ctx, args) => { | ||
| 130 | - | const limit = args.limit ?? 100; | 175 | + | const rawLimit = args.limit ?? 100; |
| 131 | - | 176 | + | const limit = Math.max(1, Math.floor(rawLimit)); | |
| 132 | - | const messages = await ctx.db | 177 | + | const query = ctx.db |
| 133 | .query('messages') | 178 | .query('messages') | ||
| 134 | .withIndex('by_chatRoom', (q) => q.eq('chatRoomId', args.roomId)) | 179 | .withIndex('by_chatRoom', (q) => q.eq('chatRoomId', args.roomId)) | ||
| 135 | - | .order('desc') | 180 | + | .order('desc'); |
| 136 | - | .take(limit); | 181 | + | |
| 182 | + | const messages = await query.take(limit); | |||
| 137 | 183 | ||||
| 138 | // Enrich messages with sender info | 184 | // Enrich messages with sender info | ||
| 139 | const enrichedMessages = await Promise.all( | 185 | const enrichedMessages = await Promise.all( | ||
A src/lib/api/client.ts +185 -0
| @@ -0,0 +1,185 @@ | |||
| 1 | + | /** | |
| 2 | + | * API Client for SvelteKit endpoints | |
| 3 | + | * Provides typed helper functions for all API calls | |
| 4 | + | */ | |
| 5 | + | ||
| 6 | + | import type { SendMessageRequest, SendMessageResponse } from '$lib/types/payloads'; | |
| 7 | + | import type { TextStyle, UserTextPreferences } from '$lib/types/text-formatting'; | |
| 8 | + | ||
| 9 | + | // ============ TYPES ============ | |
| 10 | + | ||
| 11 | + | export interface ApiResponse<T> { | |
| 12 | + | success: boolean; | |
| 13 | + | data?: T; | |
| 14 | + | error?: string; | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | export interface TextPreferencesResponse { | |
| 18 | + | success: boolean; | |
| 19 | + | preferences: UserTextPreferences | null; | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | // ============ BASE CLIENT ============ | |
| 23 | + | ||
| 24 | + | async function request<T>(endpoint: string, options: RequestInit = {}): Promise<ApiResponse<T>> { | |
| 25 | + | try { | |
| 26 | + | const response = await fetch(endpoint, { | |
| 27 | + | ...options, | |
| 28 | + | credentials: 'include', | |
| 29 | + | headers: { | |
| 30 | + | 'Content-Type': 'application/json', | |
| 31 | + | ...(options.headers ?? {}) | |
| 32 | + | } | |
| 33 | + | }); | |
| 34 | + | ||
| 35 | + | const data = await response.json(); | |
| 36 | + | ||
| 37 | + | if (!response.ok) { | |
| 38 | + | return { | |
| 39 | + | success: false, | |
| 40 | + | error: data.error || `Request failed with status ${response.status}`, | |
| 41 | + | data | |
| 42 | + | }; | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | return { success: true, data }; | |
| 46 | + | } catch (error) { | |
| 47 | + | return { | |
| 48 | + | success: false, | |
| 49 | + | error: error instanceof Error ? error.message : 'Unknown error' | |
| 50 | + | }; | |
| 51 | + | } | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | // ============ TEXT PREFERENCES ============ | |
| 55 | + | ||
| 56 | + | export const textPreferences = { | |
| 57 | + | /** | |
| 58 | + | * Get current user's text preferences | |
| 59 | + | */ | |
| 60 | + | async get(): Promise<UserTextPreferences | null> { | |
| 61 | + | const result = await request<TextPreferencesResponse>('/api/user/text-preferences'); | |
| 62 | + | if (result.success && result.data?.preferences) { | |
| 63 | + | return result.data.preferences; | |
| 64 | + | } | |
| 65 | + | return null; | |
| 66 | + | }, | |
| 67 | + | ||
| 68 | + | /** | |
| 69 | + | * Save user's text preferences | |
| 70 | + | */ | |
| 71 | + | async save(preferences: { | |
| 72 | + | defaultStyle: TextStyle; | |
| 73 | + | allowFormatting: boolean; | |
| 74 | + | maxMessageLength: number; | |
| 75 | + | }): Promise<boolean> { | |
| 76 | + | const result = await request('/api/user/text-preferences', { | |
| 77 | + | method: 'POST', | |
| 78 | + | body: JSON.stringify(preferences) | |
| 79 | + | }); | |
| 80 | + | return result.success; | |
| 81 | + | } | |
| 82 | + | }; | |
| 83 | + | ||
| 84 | + | // ============ MENTIONS ============ | |
| 85 | + | ||
| 86 | + | export const mentions = { | |
| 87 | + | /** | |
| 88 | + | * Update the timestamp of the last read mention | |
| 89 | + | */ | |
| 90 | + | async markRead(timestamp: number): Promise<boolean> { | |
| 91 | + | const result = await request('/api/chat/mention-read', { | |
| 92 | + | method: 'POST', | |
| 93 | + | body: JSON.stringify({ timestamp }) | |
| 94 | + | }); | |
| 95 | + | return result.success; | |
| 96 | + | } | |
| 97 | + | }; | |
| 98 | + | ||
| 99 | + | // ============ SESSION ============ | |
| 100 | + | ||
| 101 | + | export interface LoginResponse { | |
| 102 | + | success: boolean; | |
| 103 | + | user?: { | |
| 104 | + | id: string; | |
| 105 | + | nickname: string; | |
| 106 | + | status: string; | |
| 107 | + | }; | |
| 108 | + | error?: string; | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | export const session = { | |
| 112 | + | /** | |
| 113 | + | * Login with username and password | |
| 114 | + | */ | |
| 115 | + | async login(username: string, password: string): Promise<LoginResponse> { | |
| 116 | + | const result = await request<LoginResponse>('/api/session/login', { | |
| 117 | + | method: 'POST', | |
| 118 | + | body: JSON.stringify({ username, password }) | |
| 119 | + | }); | |
| 120 | + | return result.data ?? { success: false, error: result.error }; | |
| 121 | + | }, | |
| 122 | + | ||
| 123 | + | /** | |
| 124 | + | * Register a new user | |
| 125 | + | */ | |
| 126 | + | async register( | |
| 127 | + | username: string, | |
| 128 | + | password: string, | |
| 129 | + | confirmPassword: string, | |
| 130 | + | captchaAnswer: string, | |
| 131 | + | turnstileToken?: string | |
| 132 | + | ): Promise<LoginResponse> { | |
| 133 | + | const result = await request<LoginResponse>('/api/register', { | |
| 134 | + | method: 'POST', | |
| 135 | + | body: JSON.stringify({ | |
| 136 | + | suUsername: username, | |
| 137 | + | suPassword: password, | |
| 138 | + | suConfirmPassword: confirmPassword, | |
| 139 | + | captchaAnswer, | |
| 140 | + | ...(turnstileToken ? { turnstileToken } : {}) | |
| 141 | + | }) | |
| 142 | + | }); | |
| 143 | + | return result.data ?? { success: false, error: result.error }; | |
| 144 | + | } | |
| 145 | + | }; | |
| 146 | + | ||
| 147 | + | // ============ CHAT ============ | |
| 148 | + | ||
| 149 | + | export interface SendMessageParams { | |
| 150 | + | content: string; | |
| 151 | + | userId: string; | |
| 152 | + | chatRoomId?: string; | |
| 153 | + | type?: SendMessageRequest['type']; | |
| 154 | + | styleData?: string; | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | export const chat = { | |
| 158 | + | /** | |
| 159 | + | * Send a message to a chat room | |
| 160 | + | */ | |
| 161 | + | async sendMessage(params: SendMessageParams): Promise<SendMessageResponse> { | |
| 162 | + | const result = await request<SendMessageResponse>('/api/chat/messages', { | |
| 163 | + | method: 'POST', | |
| 164 | + | body: JSON.stringify(params) | |
| 165 | + | }); | |
| 166 | + | if (result.data) { | |
| 167 | + | return result.data; | |
| 168 | + | } | |
| 169 | + | return { | |
| 170 | + | success: false, | |
| 171 | + | error: result.error ?? 'Failed to send message' | |
| 172 | + | }; | |
| 173 | + | } | |
| 174 | + | }; | |
| 175 | + | ||
| 176 | + | // ============ EXPORT ============ | |
| 177 | + | ||
| 178 | + | export const api = { | |
| 179 | + | textPreferences, | |
| 180 | + | mentions, | |
| 181 | + | session, | |
| 182 | + | chat | |
| 183 | + | }; | |
| 184 | + | ||
| 185 | + | export default api; | |
| @@ -0,0 +1,185 @@ | |||||
| 1 | + | /** | |||
| 2 | + | * API Client for SvelteKit endpoints | |||
| 3 | + | * Provides typed helper functions for all API calls | |||
| 4 | + | */ | |||
| 5 | + | ||||
| 6 | + | import type { SendMessageRequest, SendMessageResponse } from '$lib/types/payloads'; | |||
| 7 | + | import type { TextStyle, UserTextPreferences } from '$lib/types/text-formatting'; | |||
| 8 | + | ||||
| 9 | + | // ============ TYPES ============ | |||
| 10 | + | ||||
| 11 | + | export interface ApiResponse<T> { | |||
| 12 | + | success: boolean; | |||
| 13 | + | data?: T; | |||
| 14 | + | error?: string; | |||
| 15 | + | } | |||
| 16 | + | ||||
| 17 | + | export interface TextPreferencesResponse { | |||
| 18 | + | success: boolean; | |||
| 19 | + | preferences: UserTextPreferences | null; | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | // ============ BASE CLIENT ============ | |||
| 23 | + | ||||
| 24 | + | async function request<T>(endpoint: string, options: RequestInit = {}): Promise<ApiResponse<T>> { | |||
| 25 | + | try { | |||
| 26 | + | const response = await fetch(endpoint, { | |||
| 27 | + | ...options, | |||
| 28 | + | credentials: 'include', | |||
| 29 | + | headers: { | |||
| 30 | + | 'Content-Type': 'application/json', | |||
| 31 | + | ...(options.headers ?? {}) | |||
| 32 | + | } | |||
| 33 | + | }); | |||
| 34 | + | ||||
| 35 | + | const data = await response.json(); | |||
| 36 | + | ||||
| 37 | + | if (!response.ok) { | |||
| 38 | + | return { | |||
| 39 | + | success: false, | |||
| 40 | + | error: data.error || `Request failed with status ${response.status}`, | |||
| 41 | + | data | |||
| 42 | + | }; | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | return { success: true, data }; | |||
| 46 | + | } catch (error) { | |||
| 47 | + | return { | |||
| 48 | + | success: false, | |||
| 49 | + | error: error instanceof Error ? error.message : 'Unknown error' | |||
| 50 | + | }; | |||
| 51 | + | } | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | // ============ TEXT PREFERENCES ============ | |||
| 55 | + | ||||
| 56 | + | export const textPreferences = { | |||
| 57 | + | /** | |||
| 58 | + | * Get current user's text preferences | |||
| 59 | + | */ | |||
| 60 | + | async get(): Promise<UserTextPreferences | null> { | |||
| 61 | + | const result = await request<TextPreferencesResponse>('/api/user/text-preferences'); | |||
| 62 | + | if (result.success && result.data?.preferences) { | |||
| 63 | + | return result.data.preferences; | |||
| 64 | + | } | |||
| 65 | + | return null; | |||
| 66 | + | }, | |||
| 67 | + | ||||
| 68 | + | /** | |||
| 69 | + | * Save user's text preferences | |||
| 70 | + | */ | |||
| 71 | + | async save(preferences: { | |||
| 72 | + | defaultStyle: TextStyle; | |||
| 73 | + | allowFormatting: boolean; | |||
| 74 | + | maxMessageLength: number; | |||
| 75 | + | }): Promise<boolean> { | |||
| 76 | + | const result = await request('/api/user/text-preferences', { | |||
| 77 | + | method: 'POST', | |||
| 78 | + | body: JSON.stringify(preferences) | |||
| 79 | + | }); | |||
| 80 | + | return result.success; | |||
| 81 | + | } | |||
| 82 | + | }; | |||
| 83 | + | ||||
| 84 | + | // ============ MENTIONS ============ | |||
| 85 | + | ||||
| 86 | + | export const mentions = { | |||
| 87 | + | /** | |||
| 88 | + | * Update the timestamp of the last read mention | |||
| 89 | + | */ | |||
| 90 | + | async markRead(timestamp: number): Promise<boolean> { | |||
| 91 | + | const result = await request('/api/chat/mention-read', { | |||
| 92 | + | method: 'POST', | |||
| 93 | + | body: JSON.stringify({ timestamp }) | |||
| 94 | + | }); | |||
| 95 | + | return result.success; | |||
| 96 | + | } | |||
| 97 | + | }; | |||
| 98 | + | ||||
| 99 | + | // ============ SESSION ============ | |||
| 100 | + | ||||
| 101 | + | export interface LoginResponse { | |||
| 102 | + | success: boolean; | |||
| 103 | + | user?: { | |||
| 104 | + | id: string; | |||
| 105 | + | nickname: string; | |||
| 106 | + | status: string; | |||
| 107 | + | }; | |||
| 108 | + | error?: string; | |||
| 109 | + | } | |||
| 110 | + | ||||
| 111 | + | export const session = { | |||
| 112 | + | /** | |||
| 113 | + | * Login with username and password | |||
| 114 | + | */ | |||
| 115 | + | async login(username: string, password: string): Promise<LoginResponse> { | |||
| 116 | + | const result = await request<LoginResponse>('/api/session/login', { | |||
| 117 | + | method: 'POST', | |||
| 118 | + | body: JSON.stringify({ username, password }) | |||
| 119 | + | }); | |||
| 120 | + | return result.data ?? { success: false, error: result.error }; | |||
| 121 | + | }, | |||
| 122 | + | ||||
| 123 | + | /** | |||
| 124 | + | * Register a new user | |||
| 125 | + | */ | |||
| 126 | + | async register( | |||
| 127 | + | username: string, | |||
| 128 | + | password: string, | |||
| 129 | + | confirmPassword: string, | |||
| 130 | + | captchaAnswer: string, | |||
| 131 | + | turnstileToken?: string | |||
| 132 | + | ): Promise<LoginResponse> { | |||
| 133 | + | const result = await request<LoginResponse>('/api/register', { | |||
| 134 | + | method: 'POST', | |||
| 135 | + | body: JSON.stringify({ | |||
| 136 | + | suUsername: username, | |||
| 137 | + | suPassword: password, | |||
| 138 | + | suConfirmPassword: confirmPassword, | |||
| 139 | + | captchaAnswer, | |||
| 140 | + | ...(turnstileToken ? { turnstileToken } : {}) | |||
| 141 | + | }) | |||
| 142 | + | }); | |||
| 143 | + | return result.data ?? { success: false, error: result.error }; | |||
| 144 | + | } | |||
| 145 | + | }; | |||
| 146 | + | ||||
| 147 | + | // ============ CHAT ============ | |||
| 148 | + | ||||
| 149 | + | export interface SendMessageParams { | |||
| 150 | + | content: string; | |||
| 151 | + | userId: string; | |||
| 152 | + | chatRoomId?: string; | |||
| 153 | + | type?: SendMessageRequest['type']; | |||
| 154 | + | styleData?: string; | |||
| 155 | + | } | |||
| 156 | + | ||||
| 157 | + | export const chat = { | |||
| 158 | + | /** | |||
| 159 | + | * Send a message to a chat room | |||
| 160 | + | */ | |||
| 161 | + | async sendMessage(params: SendMessageParams): Promise<SendMessageResponse> { | |||
| 162 | + | const result = await request<SendMessageResponse>('/api/chat/messages', { | |||
| 163 | + | method: 'POST', | |||
| 164 | + | body: JSON.stringify(params) | |||
| 165 | + | }); | |||
| 166 | + | if (result.data) { | |||
| 167 | + | return result.data; | |||
| 168 | + | } | |||
| 169 | + | return { | |||
| 170 | + | success: false, | |||
| 171 | + | error: result.error ?? 'Failed to send message' | |||
| 172 | + | }; | |||
| 173 | + | } | |||
| 174 | + | }; | |||
| 175 | + | ||||
| 176 | + | // ============ EXPORT ============ | |||
| 177 | + | ||||
| 178 | + | export const api = { | |||
| 179 | + | textPreferences, | |||
| 180 | + | mentions, | |||
| 181 | + | session, | |||
| 182 | + | chat | |||
| 183 | + | }; | |||
| 184 | + | ||||
| 185 | + | export default api; | |||
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 +169 -109
| @@ -1,12 +1,13 @@ | |||
| 1 | 1 | <script lang="ts"> | |
| 2 | 2 | import { chatState } from '../states/chat.svelte'; | |
| 3 | - | import { useQuery } from 'convex-svelte'; | |
| 4 | - | import { api } from '../../convex/_generated/api'; | |
| 3 | + | import { useConvexClient, useQuery } from 'convex-svelte'; | |
| 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,24 +34,29 @@ | |||
| 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(), initialTextStyle = DEFAULT_TEXT_STYLE } = $props<{ | |
| 38 | + | showChatRoom?: boolean; | |
| 39 | + | initialTextStyle?: TextStyle; | |
| 40 | + | }>(); | |
| 37 | 41 | ||
| 38 | 42 | // ============ CONVEX REAL-TIME SUBSCRIPTIONS ============ | |
| 39 | 43 | ||
| 40 | 44 | // Subscribe to default room (to get room ID) | |
| 41 | - | const defaultRoomQuery = useQuery(api.queries.getDefaultRoomPublic, {}); | |
| 45 | + | const defaultRoomQuery = useQuery(convexApi.queries.getDefaultRoomPublic, {}); | |
| 42 | 46 | ||
| 43 | 47 | // Subscribe to users (buddy list) - real-time updates | |
| 44 | - | const usersQuery = useQuery(api.queries.getUsersPublic, {}); | |
| 48 | + | const usersQuery = useQuery(convexApi.queries.getUsersPublic, {}); | |
| 45 | 49 | ||
| 46 | 50 | // Room ID from Convex query | |
| 47 | 51 | const roomId = $derived(defaultRoomQuery.data?.id as Id<'chatRooms'> | undefined); | |
| 48 | 52 | ||
| 49 | 53 | // Subscribe to messages - only when room ID is available | |
| 50 | 54 | const messagesQuery = $derived( | |
| 51 | - | roomId ? useQuery(api.queries.getMessagesPublic, () => ({ roomId: roomId!, limit: 100 })) : null | |
| 55 | + | roomId ? useQuery(convexApi.queries.getMessagesPublic, () => ({ roomId: roomId! })) : null | |
| 52 | 56 | ); | |
| 53 | 57 | ||
| 58 | + | const convexClient = useConvexClient(); | |
| 59 | + | ||
| 54 | 60 | // Transform Convex users to SafeUser format | |
| 55 | 61 | const onlineUsers = $derived.by<SafeUser[]>(() => { | |
| 56 | 62 | if (!usersQuery.data) return []; | |
| @@ -153,14 +159,14 @@ | |||
| 153 | 159 | ||
| 154 | 160 | let currentMessage = $state(''); | |
| 155 | 161 | 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 | - | }); | |
| 162 | - | // Track if we've applied user's style from DB (to avoid resetting on every prop change) | |
| 162 | + | // Initialize text style with defaults - user-specific style applied after API fetch | |
| 163 | + | let currentTextStyle = $state<TextStyle>({ ...DEFAULT_TEXT_STYLE }); | |
| 164 | + | // Track if we've applied the user's style from the API/DB (to avoid repeated fetch resets) | |
| 163 | 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; | |
| 164 | 170 | let lastUserId = $state<string | null>(null); | |
| 165 | 171 | ||
| 166 | 172 | // ============ MENTION STATE ============ | |
| @@ -202,7 +208,8 @@ | |||
| 202 | 208 | let isLoadingMore = $state(false); | |
| 203 | 209 | let hasMoreMessages = $state(true); | |
| 204 | 210 | let oldestMessageTimestamp = $state<number | null>(null); | |
| 205 | - | let lastMessageCount = $state(0); | |
| 211 | + | let lastMessageSignature = $state<string | null>(null); | |
| 212 | + | let hasInitialScrolled = $state(false); | |
| 206 | 213 | ||
| 207 | 214 | // ============ DERIVED STATE ============ | |
| 208 | 215 | ||
| @@ -270,52 +277,118 @@ | |||
| 270 | 277 | ||
| 271 | 278 | // ============ EFFECTS ============ | |
| 272 | 279 | ||
| 273 | - | // Update text style when user logs in and their saved style is loaded from DB | |
| 280 | + | // Update text style when user logs in - fetch preferences directly from API | |
| 274 | 281 | $effect(() => { | |
| 282 | + | const _styleFetchNonce = styleFetchNonce; | |
| 283 | + | if (_styleFetchNonce < 0) return; | |
| 275 | 284 | const userId = currentUser?.id ?? null; | |
| 285 | + | const defaultStyleJson = JSON.stringify(DEFAULT_TEXT_STYLE); | |
| 286 | + | const initialStyleJson = JSON.stringify(initialTextStyle ?? DEFAULT_TEXT_STYLE); | |
| 287 | + | ||
| 288 | + | if (initialStyleJson !== lastInitialStyleJson) { | |
| 289 | + | lastInitialStyleJson = initialStyleJson; | |
| 290 | + | currentTextStyle = { | |
| 291 | + | ...DEFAULT_TEXT_STYLE, | |
| 292 | + | ...(initialTextStyle ?? DEFAULT_TEXT_STYLE), | |
| 293 | + | color: (initialTextStyle?.color ?? DEFAULT_TEXT_STYLE.color) || '#000000' | |
| 294 | + | }; | |
| 295 | + | if (!currentUser) { | |
| 296 | + | hasAppliedUserStyle = false; | |
| 297 | + | } else if (initialStyleJson !== defaultStyleJson) { | |
| 298 | + | hasAppliedUserStyle = true; | |
| 299 | + | } | |
| 300 | + | } | |
| 276 | 301 | ||
| 277 | 302 | // User changed (logged in or different user) | |
| 278 | 303 | if (userId !== lastUserId) { | |
| 279 | 304 | lastUserId = userId; | |
| 280 | 305 | hasAppliedUserStyle = false; | |
| 306 | + | styleFetchAttempts = 0; | |
| 307 | + | styleFetchInFlight = false; | |
| 281 | 308 | } | |
| 282 | 309 | ||
| 283 | - | // Apply user's saved style when they log in (initialTextStyle prop updated via invalidateAll) | |
| 284 | - | if (currentUser && !hasAppliedUserStyle && initialTextStyle) { | |
| 285 | - | hasAppliedUserStyle = true; | |
| 286 | - | currentTextStyle = { | |
| 287 | - | ...DEFAULT_TEXT_STYLE, | |
| 288 | - | ...initialTextStyle, | |
| 289 | - | color: initialTextStyle.color || '#000000' | |
| 290 | - | }; | |
| 291 | - | } | |
| 310 | + | // Fetch and apply user's saved style when they log in | |
| 311 | + | if (!currentUser || hasAppliedUserStyle || styleFetchInFlight || !browser) return; | |
| 312 | + | ||
| 313 | + | let isActive = true; | |
| 314 | + | const activeUserId = currentUser.id; | |
| 315 | + | const attempt = styleFetchAttempts + 1; | |
| 316 | + | styleFetchAttempts = attempt; | |
| 317 | + | styleFetchInFlight = true; | |
| 318 | + | // Fetch user preferences directly to avoid timing issues with invalidateAll | |
| 319 | + | api.textPreferences | |
| 320 | + | .get() | |
| 321 | + | .then((prefs) => { | |
| 322 | + | if (!isActive || currentUser?.id !== activeUserId) return; | |
| 323 | + | if (!prefs?.defaultStyle) return; | |
| 324 | + | currentTextStyle = { | |
| 325 | + | ...DEFAULT_TEXT_STYLE, | |
| 326 | + | ...prefs.defaultStyle, | |
| 327 | + | color: prefs.defaultStyle.color || '#000000' | |
| 328 | + | }; | |
| 329 | + | hasAppliedUserStyle = true; | |
| 330 | + | }) | |
| 331 | + | .finally(() => { | |
| 332 | + | if (!isActive || currentUser?.id !== activeUserId) return; | |
| 333 | + | if (!hasAppliedUserStyle && attempt < 3) { | |
| 334 | + | const retryDelay = Math.min(1500, 300 * attempt); | |
| 335 | + | window.setTimeout(() => { | |
| 336 | + | styleFetchInFlight = false; | |
| 337 | + | styleFetchNonce += 1; | |
| 338 | + | }, retryDelay); | |
| 339 | + | return; | |
| 340 | + | } | |
| 341 | + | styleFetchInFlight = false; | |
| 342 | + | }); | |
| 343 | + | ||
| 344 | + | return () => { | |
| 345 | + | isActive = false; | |
| 346 | + | }; | |
| 292 | 347 | }); | |
| 293 | 348 | ||
| 294 | - | // Auto-scroll to bottom when new messages arrive | |
| 349 | + | // Save text style to database when it changes (after initial load) | |
| 350 | + | let lastSavedStyleJson = $state<string | null>(null); | |
| 295 | 351 | $effect(() => { | |
| 296 | - | if (messages.length > lastMessageCount && messages.length > 0) { | |
| 297 | - | lastMessageCount = messages.length; | |
| 298 | - | if (browser && chatArea) { | |
| 299 | - | tick().then(() => { | |
| 300 | - | if (chatArea) { | |
| 301 | - | chatArea.scrollTop = chatArea.scrollHeight; | |
| 302 | - | } | |
| 303 | - | }); | |
| 304 | - | } | |
| 352 | + | // Only save if user is logged in and style has been initialized | |
| 353 | + | if (!currentUser || !hasAppliedUserStyle || !browser) return; | |
| 354 | + | ||
| 355 | + | const styleJson = JSON.stringify(currentTextStyle); | |
| 356 | + | ||
| 357 | + | // Skip if this is the initial load (set lastSavedStyleJson to current) | |
| 358 | + | if (lastSavedStyleJson === null) { | |
| 359 | + | lastSavedStyleJson = styleJson; | |
| 360 | + | return; | |
| 361 | + | } | |
| 362 | + | ||
| 363 | + | // Only save if style actually changed | |
| 364 | + | if (styleJson !== lastSavedStyleJson) { | |
| 365 | + | lastSavedStyleJson = styleJson; | |
| 366 | + | debouncedSaveTextStyle(currentTextStyle); | |
| 305 | 367 | } | |
| 306 | 368 | }); | |
| 307 | 369 | ||
| 308 | - | // Scroll to bottom on initial load (when chatArea becomes available and messages exist) | |
| 309 | - | $effect(() => { | |
| 310 | - | if (browser && chatArea && messages.length > 0 && !isInitialLoading) { | |
| 311 | - | // Only run once on initial load by checking if we haven't scrolled yet | |
| 312 | - | if (chatArea.scrollTop === 0 && chatArea.scrollHeight > chatArea.clientHeight) { | |
| 313 | - | tick().then(() => { | |
| 314 | - | if (chatArea) { | |
| 315 | - | chatArea.scrollTop = chatArea.scrollHeight; | |
| 316 | - | } | |
| 317 | - | }); | |
| 318 | - | } | |
| 370 | + | // Auto-scroll to bottom when new messages arrive (Svelte 5 recommended pattern) | |
| 371 | + | $effect.pre(() => { | |
| 372 | + | if (!browser || !chatArea) return; | |
| 373 | + | ||
| 374 | + | // Reference visibleMessages so effect re-runs when messages change | |
| 375 | + | const latestMessage = visibleMessages[visibleMessages.length - 1]; | |
| 376 | + | if (!latestMessage) return; | |
| 377 | + | const firstMessage = visibleMessages[0]; | |
| 378 | + | const messageSignature = `${firstMessage?.id ?? 'none'}:${latestMessage.id}:${visibleMessages.length}`; | |
| 379 | + | const hasMessageUpdate = messageSignature !== lastMessageSignature; | |
| 380 | + | lastMessageSignature = messageSignature; | |
| 381 | + | ||
| 382 | + | const isNearBottom = chatArea.scrollTop + chatArea.offsetHeight >= chatArea.scrollHeight - 40; | |
| 383 | + | ||
| 384 | + | // Scroll to bottom on initial load OR when messages update (if user was near bottom) | |
| 385 | + | if (!hasInitialScrolled || (hasMessageUpdate && isNearBottom)) { | |
| 386 | + | hasInitialScrolled = true; | |
| 387 | + | tick().then(() => { | |
| 388 | + | if (chatArea) { | |
| 389 | + | chatArea.scrollTo(0, chatArea.scrollHeight); | |
| 390 | + | } | |
| 391 | + | }); | |
| 319 | 392 | } | |
| 320 | 393 | }); | |
| 321 | 394 | ||
| @@ -559,18 +632,19 @@ | |||
| 559 | 632 | // Debounced function to update the server with last read mention timestamp | |
| 560 | 633 | const debouncedUpdateMentionTimestamp = debounce(async (timestamp: number) => { | |
| 561 | 634 | if (!currentUser || !browser) return; | |
| 562 | - | try { | |
| 563 | - | await fetch('/api/chat/mention-read', { | |
| 564 | - | method: 'POST', | |
| 565 | - | headers: { 'Content-Type': 'application/json' }, | |
| 566 | - | credentials: 'include', | |
| 567 | - | body: JSON.stringify({ timestamp }) | |
| 568 | - | }); | |
| 569 | - | } catch (error) { | |
| 570 | - | console.error('Failed to update mention read timestamp:', error); | |
| 571 | - | } | |
| 635 | + | await api.mentions.markRead(timestamp); | |
| 572 | 636 | }, 1000); | |
| 573 | 637 | ||
| 638 | + | // Debounced function to save text style preferences to the database | |
| 639 | + | const debouncedSaveTextStyle = debounce(async (style: TextStyle) => { | |
| 640 | + | if (!currentUser || !browser) return; | |
| 641 | + | await api.textPreferences.save({ | |
| 642 | + | defaultStyle: style, | |
| 643 | + | allowFormatting: true, | |
| 644 | + | maxMessageLength: MAX_MESSAGE_LENGTH | |
| 645 | + | }); | |
| 646 | + | }, 1500); | |
| 647 | + | ||
| 574 | 648 | function setsEqual(a: Set<string>, b: Set<string>) { | |
| 575 | 649 | if (a.size !== b.size) return false; | |
| 576 | 650 | for (const value of a) { | |
| @@ -800,26 +874,19 @@ | |||
| 800 | 874 | ||
| 801 | 875 | isSendingMessage = true; | |
| 802 | 876 | try { | |
| 803 | - | const response = await fetch('/api/chat/messages', { | |
| 804 | - | method: 'POST', | |
| 805 | - | headers: { 'Content-Type': 'application/json' }, | |
| 806 | - | credentials: 'include', | |
| 807 | - | body: JSON.stringify({ | |
| 808 | - | content: currentMessage, | |
| 809 | - | type: 'chat', | |
| 810 | - | userId: currentUser.id, | |
| 811 | - | chatRoomId: roomId, | |
| 812 | - | styleData: currentTextStyle ? JSON.stringify(currentTextStyle) : undefined | |
| 813 | - | }) | |
| 877 | + | const result = await api.chat.sendMessage({ | |
| 878 | + | content: currentMessage, | |
| 879 | + | type: 'chat', | |
| 880 | + | userId: currentUser.id, | |
| 881 | + | chatRoomId: roomId, | |
| 882 | + | styleData: currentTextStyle ? JSON.stringify(currentTextStyle) : undefined | |
| 814 | 883 | }); | |
| 815 | 884 | ||
| 816 | - | const data = await response.json(); | |
| 817 | - | ||
| 818 | - | if (!data.success && data.isRateLimited && data.retryAfter) { | |
| 819 | - | startCooldownTimer(data.retryAfter); | |
| 885 | + | if (!result.success && result.isRateLimited && result.retryAfter) { | |
| 886 | + | startCooldownTimer(result.retryAfter); | |
| 820 | 887 | rateLimitWarning = "Whoa there! You're sending messages too quickly. Take a breather..."; | |
| 821 | - | } else if (!data.success) { | |
| 822 | - | rateLimitWarning = data.error || 'Failed to send message. Please try again.'; | |
| 888 | + | } else if (!result.success) { | |
| 889 | + | rateLimitWarning = result.error || 'Failed to send message. Please try again.'; | |
| 823 | 890 | setTimeout(() => (rateLimitWarning = null), 3000); | |
| 824 | 891 | } else { | |
| 825 | 892 | currentMessage = ''; | |
| @@ -880,47 +947,40 @@ | |||
| 880 | 947 | } | |
| 881 | 948 | ||
| 882 | 949 | async function handleScroll(event: Event) { | |
| 883 | - | const chatArea = event.target as HTMLElement; | |
| 884 | - | const { scrollTop } = chatArea; | |
| 950 | + | const target = event.target as HTMLElement; | |
| 951 | + | const { scrollTop } = target; | |
| 885 | 952 | ||
| 886 | - | if (scrollTop < 100 && !isLoadingMore && hasMoreMessages && roomId) { | |
| 953 | + | if (scrollTop < 100 && !isLoadingMore && hasMoreMessages && roomId && chatArea && currentUser) { | |
| 887 | 954 | isLoadingMore = true; | |
| 888 | - | const scrollHeight = chatArea.scrollHeight; | |
| 889 | - | const currentScrollTop = chatArea.scrollTop; | |
| 955 | + | const prevScrollHeight = chatArea.scrollHeight; | |
| 956 | + | const prevScrollTop = chatArea.scrollTop; | |
| 890 | 957 | ||
| 891 | 958 | try { | |
| 892 | - | // Build query string manually to avoid URLSearchParams (svelte reactivity compliance) | |
| 893 | - | const queryParts = [ | |
| 894 | - | `before=${encodeURIComponent(oldestMessageTimestamp?.toString() || '')}`, | |
| 895 | - | `roomId=${encodeURIComponent(roomId)}` | |
| 896 | - | ]; | |
| 897 | - | if (!currentUser) queryParts.push('public=true'); | |
| 898 | - | const queryString = queryParts.join('&'); | |
| 899 | - | ||
| 900 | - | const response = await fetch(`/api/chat/messages?${queryString}`); | |
| 901 | - | ||
| 902 | - | if (response.ok) { | |
| 903 | - | const data = await response.json(); | |
| 904 | - | if (data.success) { | |
| 905 | - | if (data.messages.length === 0) { | |
| 906 | - | hasMoreMessages = false; | |
| 907 | - | } else { | |
| 908 | - | const fetchedMessages = data.messages as Message[]; | |
| 909 | - | mergePagedMessages(fetchedMessages); | |
| 910 | - | ||
| 911 | - | requestAnimationFrame(() => { | |
| 912 | - | const newScrollHeight = chatArea.scrollHeight; | |
| 913 | - | const heightDifference = newScrollHeight - scrollHeight; | |
| 914 | - | chatArea.scrollTop = currentScrollTop + heightDifference; | |
| 915 | - | }); | |
| 916 | - | ||
| 917 | - | const newOldest = Math.min(...fetchedMessages.map((m) => m.timestamp)); | |
| 918 | - | oldestMessageTimestamp = oldestMessageTimestamp | |
| 919 | - | ? Math.min(oldestMessageTimestamp, newOldest) | |
| 920 | - | : newOldest; | |
| 921 | - | hasMoreMessages = data.hasMore; | |
| 922 | - | } | |
| 923 | - | } | |
| 959 | + | const fetchLimit = currentUser ? 100 : 50; | |
| 960 | + | const data = await convexClient.query(convexApi.queries.getMessagesPublicPage, { | |
| 961 | + | roomId, | |
| 962 | + | limit: fetchLimit, | |
| 963 | + | beforeTimestamp: oldestMessageTimestamp ?? undefined | |
| 964 | + | }); | |
| 965 | + | ||
| 966 | + | if (data.messages.length === 0) { | |
| 967 | + | hasMoreMessages = false; | |
| 968 | + | } else { | |
| 969 | + | const fetchedMessages = data.messages as Message[]; | |
| 970 | + | mergePagedMessages(fetchedMessages); | |
| 971 | + | ||
| 972 | + | requestAnimationFrame(() => { | |
| 973 | + | if (!chatArea) return; | |
| 974 | + | const newScrollHeight = chatArea.scrollHeight; | |
| 975 | + | const heightDifference = newScrollHeight - prevScrollHeight; | |
| 976 | + | chatArea.scrollTop = prevScrollTop + heightDifference; | |
| 977 | + | }); | |
| 978 | + | ||
| 979 | + | const newOldest = Math.min(...fetchedMessages.map((m) => m.timestamp)); | |
| 980 | + | oldestMessageTimestamp = oldestMessageTimestamp | |
| 981 | + | ? Math.min(oldestMessageTimestamp, newOldest) | |
| 982 | + | : newOldest; | |
| 983 | + | hasMoreMessages = data.hasMore; | |
| 924 | 984 | } | |
| 925 | 985 | } catch (error) { | |
| 926 | 986 | console.error('Error loading more messages:', error); | |
| @@ -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 { useConvexClient, 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,24 +34,29 @@ | |||||
| 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(), initialTextStyle = DEFAULT_TEXT_STYLE } = $props<{ |
| 38 | + | showChatRoom?: boolean; | |||
| 39 | + | initialTextStyle?: TextStyle; | |||
| 40 | + | }>(); | |||
| 37 | 41 | ||||
| 38 | // ============ CONVEX REAL-TIME SUBSCRIPTIONS ============ | 42 | // ============ CONVEX REAL-TIME SUBSCRIPTIONS ============ | ||
| 39 | 43 | ||||
| 40 | // Subscribe to default room (to get room ID) | 44 | // Subscribe to default room (to get room ID) | ||
| 41 | - | const defaultRoomQuery = useQuery(api.queries.getDefaultRoomPublic, {}); | 45 | + | const defaultRoomQuery = useQuery(convexApi.queries.getDefaultRoomPublic, {}); |
| 42 | 46 | ||||
| 43 | // Subscribe to users (buddy list) - real-time updates | 47 | // Subscribe to users (buddy list) - real-time updates | ||
| 44 | - | const usersQuery = useQuery(api.queries.getUsersPublic, {}); | 48 | + | const usersQuery = useQuery(convexApi.queries.getUsersPublic, {}); |
| 45 | 49 | ||||
| 46 | // Room ID from Convex query | 50 | // Room ID from Convex query | ||
| 47 | const roomId = $derived(defaultRoomQuery.data?.id as Id<'chatRooms'> | undefined); | 51 | const roomId = $derived(defaultRoomQuery.data?.id as Id<'chatRooms'> | undefined); | ||
| 48 | 52 | ||||
| 49 | // Subscribe to messages - only when room ID is available | 53 | // Subscribe to messages - only when room ID is available | ||
| 50 | const messagesQuery = $derived( | 54 | const messagesQuery = $derived( | ||
| 51 | - | roomId ? useQuery(api.queries.getMessagesPublic, () => ({ roomId: roomId!, limit: 100 })) : null | 55 | + | roomId ? useQuery(convexApi.queries.getMessagesPublic, () => ({ roomId: roomId! })) : null |
| 52 | ); | 56 | ); | ||
| 53 | 57 | ||||
| 58 | + | const convexClient = useConvexClient(); | |||
| 59 | + | ||||
| 54 | // Transform Convex users to SafeUser format | 60 | // Transform Convex users to SafeUser format | ||
| 55 | const onlineUsers = $derived.by<SafeUser[]>(() => { | 61 | const onlineUsers = $derived.by<SafeUser[]>(() => { | ||
| 56 | if (!usersQuery.data) return []; | 62 | if (!usersQuery.data) return []; | ||
| @@ -153,14 +159,14 @@ | |||||
| 153 | 159 | ||||
| 154 | let currentMessage = $state(''); | 160 | let currentMessage = $state(''); | ||
| 155 | let inputScrollLeft = $state(0); | 161 | let inputScrollLeft = $state(0); | ||
| 156 | - | // Initialize text style with prop value | 162 | + | // Initialize text style with defaults - user-specific style applied after API fetch |
| 157 | - | let currentTextStyle = $state<TextStyle>({ | 163 | + | let currentTextStyle = $state<TextStyle>({ ...DEFAULT_TEXT_STYLE }); |
| 158 | - | ...DEFAULT_TEXT_STYLE, | 164 | + | // Track if we've applied the user's style from the API/DB (to avoid repeated fetch resets) |
| 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) | |||
| 163 | 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; | |||
| 164 | let lastUserId = $state<string | null>(null); | 170 | let lastUserId = $state<string | null>(null); | ||
| 165 | 171 | ||||
| 166 | // ============ MENTION STATE ============ | 172 | // ============ MENTION STATE ============ | ||
| @@ -202,7 +208,8 @@ | |||||
| 202 | let isLoadingMore = $state(false); | 208 | let isLoadingMore = $state(false); | ||
| 203 | let hasMoreMessages = $state(true); | 209 | let hasMoreMessages = $state(true); | ||
| 204 | let oldestMessageTimestamp = $state<number | null>(null); | 210 | let oldestMessageTimestamp = $state<number | null>(null); | ||
| 205 | - | let lastMessageCount = $state(0); | 211 | + | let lastMessageSignature = $state<string | null>(null); |
| 212 | + | let hasInitialScrolled = $state(false); | |||
| 206 | 213 | ||||
| 207 | // ============ DERIVED STATE ============ | 214 | // ============ DERIVED STATE ============ | ||
| 208 | 215 | ||||
| @@ -270,52 +277,118 @@ | |||||
| 270 | 277 | ||||
| 271 | // ============ EFFECTS ============ | 278 | // ============ EFFECTS ============ | ||
| 272 | 279 | ||||
| 273 | - | // Update text style when user logs in and their saved style is loaded from DB | 280 | + | // Update text style when user logs in - fetch preferences directly from API |
| 274 | $effect(() => { | 281 | $effect(() => { | ||
| 282 | + | const _styleFetchNonce = styleFetchNonce; | |||
| 283 | + | if (_styleFetchNonce < 0) return; | |||
| 275 | const userId = currentUser?.id ?? null; | 284 | const userId = currentUser?.id ?? null; | ||
| 285 | + | const defaultStyleJson = JSON.stringify(DEFAULT_TEXT_STYLE); | |||
| 286 | + | const initialStyleJson = JSON.stringify(initialTextStyle ?? DEFAULT_TEXT_STYLE); | |||
| 287 | + | ||||
| 288 | + | if (initialStyleJson !== lastInitialStyleJson) { | |||
| 289 | + | lastInitialStyleJson = initialStyleJson; | |||
| 290 | + | currentTextStyle = { | |||
| 291 | + | ...DEFAULT_TEXT_STYLE, | |||
| 292 | + | ...(initialTextStyle ?? DEFAULT_TEXT_STYLE), | |||
| 293 | + | color: (initialTextStyle?.color ?? DEFAULT_TEXT_STYLE.color) || '#000000' | |||
| 294 | + | }; | |||
| 295 | + | if (!currentUser) { | |||
| 296 | + | hasAppliedUserStyle = false; | |||
| 297 | + | } else if (initialStyleJson !== defaultStyleJson) { | |||
| 298 | + | hasAppliedUserStyle = true; | |||
| 299 | + | } | |||
| 300 | + | } | |||
| 276 | 301 | ||||
| 277 | // User changed (logged in or different user) | 302 | // User changed (logged in or different user) | ||
| 278 | if (userId !== lastUserId) { | 303 | if (userId !== lastUserId) { | ||
| 279 | lastUserId = userId; | 304 | lastUserId = userId; | ||
| 280 | hasAppliedUserStyle = false; | 305 | hasAppliedUserStyle = false; | ||
| 306 | + | styleFetchAttempts = 0; | |||
| 307 | + | styleFetchInFlight = false; | |||
| 281 | } | 308 | } | ||
| 282 | 309 | ||||
| 283 | - | // Apply user's saved style when they log in (initialTextStyle prop updated via invalidateAll) | 310 | + | // Fetch and apply user's saved style when they log in |
| 284 | - | if (currentUser && !hasAppliedUserStyle && initialTextStyle) { | 311 | + | if (!currentUser || hasAppliedUserStyle || styleFetchInFlight || !browser) return; |
| 285 | - | hasAppliedUserStyle = true; | 312 | + | |
| 286 | - | currentTextStyle = { | 313 | + | let isActive = true; |
| 287 | - | ...DEFAULT_TEXT_STYLE, | 314 | + | const activeUserId = currentUser.id; |
| 288 | - | ...initialTextStyle, | 315 | + | const attempt = styleFetchAttempts + 1; |
| 289 | - | color: initialTextStyle.color || '#000000' | 316 | + | styleFetchAttempts = attempt; |
| 290 | - | }; | 317 | + | styleFetchInFlight = true; |
| 291 | - | } | 318 | + | // Fetch user preferences directly to avoid timing issues with invalidateAll |
| 319 | + | api.textPreferences | |||
| 320 | + | .get() | |||
| 321 | + | .then((prefs) => { | |||
| 322 | + | if (!isActive || currentUser?.id !== activeUserId) return; | |||
| 323 | + | if (!prefs?.defaultStyle) return; | |||
| 324 | + | currentTextStyle = { | |||
| 325 | + | ...DEFAULT_TEXT_STYLE, | |||
| 326 | + | ...prefs.defaultStyle, | |||
| 327 | + | color: prefs.defaultStyle.color || '#000000' | |||
| 328 | + | }; | |||
| 329 | + | hasAppliedUserStyle = true; | |||
| 330 | + | }) | |||
| 331 | + | .finally(() => { | |||
| 332 | + | if (!isActive || currentUser?.id !== activeUserId) return; | |||
| 333 | + | if (!hasAppliedUserStyle && attempt < 3) { | |||
| 334 | + | const retryDelay = Math.min(1500, 300 * attempt); | |||
| 335 | + | window.setTimeout(() => { | |||
| 336 | + | styleFetchInFlight = false; | |||
| 337 | + | styleFetchNonce += 1; | |||
| 338 | + | }, retryDelay); | |||
| 339 | + | return; | |||
| 340 | + | } | |||
| 341 | + | styleFetchInFlight = false; | |||
| 342 | + | }); | |||
| 343 | + | ||||
| 344 | + | return () => { | |||
| 345 | + | isActive = false; | |||
| 346 | + | }; | |||
| 292 | }); | 347 | }); | ||
| 293 | 348 | ||||
| 294 | - | // Auto-scroll to bottom when new messages arrive | 349 | + | // Save text style to database when it changes (after initial load) |
| 350 | + | let lastSavedStyleJson = $state<string | null>(null); | |||
| 295 | $effect(() => { | 351 | $effect(() => { | ||
| 296 | - | if (messages.length > lastMessageCount && messages.length > 0) { | 352 | + | // Only save if user is logged in and style has been initialized |
| 297 | - | lastMessageCount = messages.length; | 353 | + | if (!currentUser || !hasAppliedUserStyle || !browser) return; |
| 298 | - | if (browser && chatArea) { | 354 | + | |
| 299 | - | tick().then(() => { | 355 | + | const styleJson = JSON.stringify(currentTextStyle); |
| 300 | - | if (chatArea) { | 356 | + | |
| 301 | - | chatArea.scrollTop = chatArea.scrollHeight; | 357 | + | // Skip if this is the initial load (set lastSavedStyleJson to current) |
| 302 | - | } | 358 | + | if (lastSavedStyleJson === null) { |
| 303 | - | }); | 359 | + | lastSavedStyleJson = styleJson; |
| 304 | - | } | 360 | + | return; |
| 361 | + | } | |||
| 362 | + | ||||
| 363 | + | // Only save if style actually changed | |||
| 364 | + | if (styleJson !== lastSavedStyleJson) { | |||
| 365 | + | lastSavedStyleJson = styleJson; | |||
| 366 | + | debouncedSaveTextStyle(currentTextStyle); | |||
| 305 | } | 367 | } | ||
| 306 | }); | 368 | }); | ||
| 307 | 369 | ||||
| 308 | - | // Scroll to bottom on initial load (when chatArea becomes available and messages exist) | 370 | + | // Auto-scroll to bottom when new messages arrive (Svelte 5 recommended pattern) |
| 309 | - | $effect(() => { | 371 | + | $effect.pre(() => { |
| 310 | - | if (browser && chatArea && messages.length > 0 && !isInitialLoading) { | 372 | + | if (!browser || !chatArea) return; |
| 311 | - | // Only run once on initial load by checking if we haven't scrolled yet | 373 | + | |
| 312 | - | if (chatArea.scrollTop === 0 && chatArea.scrollHeight > chatArea.clientHeight) { | 374 | + | // Reference visibleMessages so effect re-runs when messages change |
| 313 | - | tick().then(() => { | 375 | + | const latestMessage = visibleMessages[visibleMessages.length - 1]; |
| 314 | - | if (chatArea) { | 376 | + | if (!latestMessage) return; |
| 315 | - | chatArea.scrollTop = chatArea.scrollHeight; | 377 | + | const firstMessage = visibleMessages[0]; |
| 316 | - | } | 378 | + | const messageSignature = `${firstMessage?.id ?? 'none'}:${latestMessage.id}:${visibleMessages.length}`; |
| 317 | - | }); | 379 | + | const hasMessageUpdate = messageSignature !== lastMessageSignature; |
| 318 | - | } | 380 | + | lastMessageSignature = messageSignature; |
| 381 | + | ||||
| 382 | + | const isNearBottom = chatArea.scrollTop + chatArea.offsetHeight >= chatArea.scrollHeight - 40; | |||
| 383 | + | ||||
| 384 | + | // Scroll to bottom on initial load OR when messages update (if user was near bottom) | |||
| 385 | + | if (!hasInitialScrolled || (hasMessageUpdate && isNearBottom)) { | |||
| 386 | + | hasInitialScrolled = true; | |||
| 387 | + | tick().then(() => { | |||
| 388 | + | if (chatArea) { | |||
| 389 | + | chatArea.scrollTo(0, chatArea.scrollHeight); | |||
| 390 | + | } | |||
| 391 | + | }); | |||
| 319 | } | 392 | } | ||
| 320 | }); | 393 | }); | ||
| 321 | 394 | ||||
| @@ -559,18 +632,19 @@ | |||||
| 559 | // Debounced function to update the server with last read mention timestamp | 632 | // Debounced function to update the server with last read mention timestamp | ||
| 560 | const debouncedUpdateMentionTimestamp = debounce(async (timestamp: number) => { | 633 | const debouncedUpdateMentionTimestamp = debounce(async (timestamp: number) => { | ||
| 561 | if (!currentUser || !browser) return; | 634 | if (!currentUser || !browser) return; | ||
| 562 | - | try { | 635 | + | await api.mentions.markRead(timestamp); |
| 563 | - | await fetch('/api/chat/mention-read', { | |||
| 564 | - | method: 'POST', | |||
| 565 | - | headers: { 'Content-Type': 'application/json' }, | |||
| 566 | - | credentials: 'include', | |||
| 567 | - | body: JSON.stringify({ timestamp }) | |||
| 568 | - | }); | |||
| 569 | - | } catch (error) { | |||
| 570 | - | console.error('Failed to update mention read timestamp:', error); | |||
| 571 | - | } | |||
| 572 | }, 1000); | 636 | }, 1000); | ||
| 573 | 637 | ||||
| 638 | + | // Debounced function to save text style preferences to the database | |||
| 639 | + | const debouncedSaveTextStyle = debounce(async (style: TextStyle) => { | |||
| 640 | + | if (!currentUser || !browser) return; | |||
| 641 | + | await api.textPreferences.save({ | |||
| 642 | + | defaultStyle: style, | |||
| 643 | + | allowFormatting: true, | |||
| 644 | + | maxMessageLength: MAX_MESSAGE_LENGTH | |||
| 645 | + | }); | |||
| 646 | + | }, 1500); | |||
| 647 | + | ||||
| 574 | function setsEqual(a: Set<string>, b: Set<string>) { | 648 | function setsEqual(a: Set<string>, b: Set<string>) { | ||
| 575 | if (a.size !== b.size) return false; | 649 | if (a.size !== b.size) return false; | ||
| 576 | for (const value of a) { | 650 | for (const value of a) { | ||
| @@ -800,26 +874,19 @@ | |||||
| 800 | 874 | ||||
| 801 | isSendingMessage = true; | 875 | isSendingMessage = true; | ||
| 802 | try { | 876 | try { | ||
| 803 | - | const response = await fetch('/api/chat/messages', { | 877 | + | const result = await api.chat.sendMessage({ |
| 804 | - | method: 'POST', | 878 | + | content: currentMessage, |
| 805 | - | headers: { 'Content-Type': 'application/json' }, | 879 | + | type: 'chat', |
| 806 | - | credentials: 'include', | 880 | + | userId: currentUser.id, |
| 807 | - | body: JSON.stringify({ | 881 | + | chatRoomId: roomId, |
| 808 | - | content: currentMessage, | 882 | + | styleData: currentTextStyle ? JSON.stringify(currentTextStyle) : undefined |
| 809 | - | type: 'chat', | |||
| 810 | - | userId: currentUser.id, | |||
| 811 | - | chatRoomId: roomId, | |||
| 812 | - | styleData: currentTextStyle ? JSON.stringify(currentTextStyle) : undefined | |||
| 813 | - | }) | |||
| 814 | }); | 883 | }); | ||
| 815 | 884 | ||||
| 816 | - | const data = await response.json(); | 885 | + | if (!result.success && result.isRateLimited && result.retryAfter) { |
| 817 | - | 886 | + | startCooldownTimer(result.retryAfter); | |
| 818 | - | if (!data.success && data.isRateLimited && data.retryAfter) { | |||
| 819 | - | startCooldownTimer(data.retryAfter); | |||
| 820 | rateLimitWarning = "Whoa there! You're sending messages too quickly. Take a breather..."; | 887 | rateLimitWarning = "Whoa there! You're sending messages too quickly. Take a breather..."; | ||
| 821 | - | } else if (!data.success) { | 888 | + | } else if (!result.success) { |
| 822 | - | rateLimitWarning = data.error || 'Failed to send message. Please try again.'; | 889 | + | rateLimitWarning = result.error || 'Failed to send message. Please try again.'; |
| 823 | setTimeout(() => (rateLimitWarning = null), 3000); | 890 | setTimeout(() => (rateLimitWarning = null), 3000); | ||
| 824 | } else { | 891 | } else { | ||
| 825 | currentMessage = ''; | 892 | currentMessage = ''; | ||
| @@ -880,47 +947,40 @@ | |||||
| 880 | } | 947 | } | ||
| 881 | 948 | ||||
| 882 | async function handleScroll(event: Event) { | 949 | async function handleScroll(event: Event) { | ||
| 883 | - | const chatArea = event.target as HTMLElement; | 950 | + | const target = event.target as HTMLElement; |
| 884 | - | const { scrollTop } = chatArea; | 951 | + | const { scrollTop } = target; |
| 885 | 952 | ||||
| 886 | - | if (scrollTop < 100 && !isLoadingMore && hasMoreMessages && roomId) { | 953 | + | if (scrollTop < 100 && !isLoadingMore && hasMoreMessages && roomId && chatArea && currentUser) { |
| 887 | isLoadingMore = true; | 954 | isLoadingMore = true; | ||
| 888 | - | const scrollHeight = chatArea.scrollHeight; | 955 | + | const prevScrollHeight = chatArea.scrollHeight; |
| 889 | - | const currentScrollTop = chatArea.scrollTop; | 956 | + | const prevScrollTop = chatArea.scrollTop; |
| 890 | 957 | ||||
| 891 | try { | 958 | try { | ||
| 892 | - | // Build query string manually to avoid URLSearchParams (svelte reactivity compliance) | 959 | + | const fetchLimit = currentUser ? 100 : 50; |
| 893 | - | const queryParts = [ | 960 | + | const data = await convexClient.query(convexApi.queries.getMessagesPublicPage, { |
| 894 | - | `before=${encodeURIComponent(oldestMessageTimestamp?.toString() || '')}`, | 961 | + | roomId, |
| 895 | - | `roomId=${encodeURIComponent(roomId)}` | 962 | + | limit: fetchLimit, |
| 896 | - | ]; | 963 | + | beforeTimestamp: oldestMessageTimestamp ?? undefined |
| 897 | - | if (!currentUser) queryParts.push('public=true'); | 964 | + | }); |
| 898 | - | const queryString = queryParts.join('&'); | 965 | + | |
| 899 | - | 966 | + | if (data.messages.length === 0) { | |
| 900 | - | const response = await fetch(`/api/chat/messages?${queryString}`); | 967 | + | hasMoreMessages = false; |
| 901 | - | 968 | + | } else { | |
| 902 | - | if (response.ok) { | 969 | + | const fetchedMessages = data.messages as Message[]; |
| 903 | - | const data = await response.json(); | 970 | + | mergePagedMessages(fetchedMessages); |
| 904 | - | if (data.success) { | 971 | + | |
| 905 | - | if (data.messages.length === 0) { | 972 | + | requestAnimationFrame(() => { |
| 906 | - | hasMoreMessages = false; | 973 | + | if (!chatArea) return; |
| 907 | - | } else { | 974 | + | const newScrollHeight = chatArea.scrollHeight; |
| 908 | - | const fetchedMessages = data.messages as Message[]; | 975 | + | const heightDifference = newScrollHeight - prevScrollHeight; |
| 909 | - | mergePagedMessages(fetchedMessages); | 976 | + | chatArea.scrollTop = prevScrollTop + heightDifference; |
| 910 | - | 977 | + | }); | |
| 911 | - | requestAnimationFrame(() => { | 978 | + | |
| 912 | - | const newScrollHeight = chatArea.scrollHeight; | 979 | + | const newOldest = Math.min(...fetchedMessages.map((m) => m.timestamp)); |
| 913 | - | const heightDifference = newScrollHeight - scrollHeight; | 980 | + | oldestMessageTimestamp = oldestMessageTimestamp |
| 914 | - | chatArea.scrollTop = currentScrollTop + heightDifference; | 981 | + | ? Math.min(oldestMessageTimestamp, newOldest) |
| 915 | - | }); | 982 | + | : newOldest; |
| 916 | - | 983 | + | hasMoreMessages = data.hasMore; | |
| 917 | - | const newOldest = Math.min(...fetchedMessages.map((m) => m.timestamp)); | |||
| 918 | - | oldestMessageTimestamp = oldestMessageTimestamp | |||
| 919 | - | ? Math.min(oldestMessageTimestamp, newOldest) | |||
| 920 | - | : newOldest; | |||
| 921 | - | hasMoreMessages = data.hasMore; | |||
| 922 | - | } | |||
| 923 | - | } | |||
| 924 | } | 984 | } | ||
| 925 | } catch (error) { | 985 | } catch (error) { | ||
| 926 | console.error('Error loading more messages:', error); | 986 | 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 -4
| @@ -2,10 +2,9 @@ | |||
| 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 | ||
| 6 | + | const props = $props(); | |
| 7 | + | const pageData = $derived(props.data); | |
| 9 | 8 | let showChatRoom = $state(true); | |
| 10 | 9 | let showAuth = $state(false); | |
| 11 | 10 | ||
| @@ -18,7 +17,7 @@ | |||
| 18 | 17 | <DesktopIcons bind:showChatRoom bind:showAuth /> | |
| 19 | 18 | ||
| 20 | 19 | {#if showChatRoom} | |
| 21 | - | <ChatRoom bind:showChatRoom initialTextStyle={data.textStyle} /> | |
| 20 | + | <ChatRoom bind:showChatRoom initialTextStyle={pageData.textStyle} /> | |
| 22 | 21 | {/if} | |
| 23 | 22 | ||
| 24 | 23 | {#if showAuth} | |
| @@ -2,10 +2,9 @@ | |||||
| 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 | ||||
| 6 | + | const props = $props(); | |||
| 7 | + | const pageData = $derived(props.data); | |||
| 9 | let showChatRoom = $state(true); | 8 | let showChatRoom = $state(true); | ||
| 10 | let showAuth = $state(false); | 9 | let showAuth = $state(false); | ||
| 11 | 10 | ||||
| @@ -18,7 +17,7 @@ | |||||
| 18 | <DesktopIcons bind:showChatRoom bind:showAuth /> | 17 | <DesktopIcons bind:showChatRoom bind:showAuth /> | ||
| 19 | 18 | ||||
| 20 | {#if showChatRoom} | 19 | {#if showChatRoom} | ||
| 21 | - | <ChatRoom bind:showChatRoom initialTextStyle={data.textStyle} /> | 20 | + | <ChatRoom bind:showChatRoom initialTextStyle={pageData.textStyle} /> |
| 22 | {/if} | 21 | {/if} | ||
| 23 | 22 | ||||
| 24 | {#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, | ||