detached 9900341

Merge pull request #83 from kitpdr/fix/chat-ux-improvements

feat(api): add centralized API client and enforce public message limit
commit
9900341f0c20e581a26675b631922c0292b2be20
author
KiT · · 2026-01-24 07:32 UTC
committer
GitHub · · 2026-01-24 07:32 UTC
merge of
18beec2 + 930acd6 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
9 files · +433 -147
M src/convex/queries.ts +52 -6
@@ -26,13 +26,18 @@ function computeEffectiveStatus(
2626 // ============ PUBLIC QUERIES (no auth required) ============
2727
2828 // 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+
2932 export const getMessagesPublic = query({
3033 args: {
3134 roomId: v.id('chatRooms'),
3235 limit: v.optional(v.number())
3336 },
3437 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)));
3641
3742 const messages = await ctx.db
3843 .query('messages')
@@ -70,6 +75,46 @@ export const getMessagesPublic = query({
7075 }
7176 });
7277
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+
73118 // Public query: Get all users (real-time subscription for buddy list)
74119 export const getUsersPublic = query({
75120 args: {},
@@ -127,13 +172,14 @@ export const getMessages = authQuery({
127172 limit: v.optional(v.number())
128173 },
129174 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
133178 .query('messages')
134179 .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);
137183
138184 // Enrich messages with sender info
139185 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.db42 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.db177+ 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 info184 // 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 @@
22 <script lang="ts">
33 import { draggable } from '$lib/actions/draggable';
44 import type { RegisterResponse, RegisterResponseError } from '$lib/types/payloads';
5- import { invalidateAll } from '$app/navigation';
5+ import { invalidate } from '$app/navigation';
66 import { chatState } from '$lib/states/chat.svelte';
77 import { createSafeUser } from '$lib/types/chat';
88 import { onMount } from 'svelte';
@@ -105,12 +105,15 @@
105105 await chatState.setCurrentUser(data.user ? createSafeUser(data.user) : null);
106106 loginStatus = 'success';
107107
108+ // Refresh session-dependent data
109+ await invalidate('app:session');
110+ await invalidate('app:chat');
111+
108112 // Call the callback prop if provided
109113 onLoginSuccess?.(data.user ? createSafeUser(data.user) : null);
110114 // Auto close login window after 3 seconds on successful login
111115 setTimeout(() => {
112116 console.debug('Auto closing AIM Login component after successful login');
113- invalidateAll();
114117 handleClose();
115118 }, 3000);
116119
@@ -222,10 +225,11 @@
222225 }
223226 console.debug('Auto login after registration succeeded:', data);
224227 await chatState.setCurrentUser(data.user ? createSafeUser(data.user) : null);
228+ await invalidate('app:session');
229+ await invalidate('app:chat');
225230 onLoginSuccess?.(data.user ? createSafeUser(data.user) : null);
226231 setTimeout(() => {
227232 console.debug('Auto closing AIM Login component after auto login on registration');
228- invalidateAll();
229233 handleClose();
230234 }, 3000);
231235 } 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 provided112 // 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 login114 // 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 @@
11 <script lang="ts">
22 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';
55 import type { Id } from '../../convex/_generated/dataModel';
66 import type { Message, EnrichedMessage, SafeUser } from '../types/chat';
77 import { onMount, tick } from 'svelte';
88 import { SvelteSet, SvelteMap } from 'svelte/reactivity';
99 import { browser } from '$app/environment';
10+ import { api } from '$lib/api/client';
1011 import { draggable } from '$lib/actions/draggable';
1112 import { resizable } from '$lib/actions/resizable';
1213 import { maximizable } from '$lib/actions/maximizable';
@@ -33,24 +34,29 @@
3334 import { MAX_MESSAGE_LENGTH } from '$lib/validation/message';
3435
3536 // 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+ }>();
3741
3842 // ============ CONVEX REAL-TIME SUBSCRIPTIONS ============
3943
4044 // Subscribe to default room (to get room ID)
41- const defaultRoomQuery = useQuery(api.queries.getDefaultRoomPublic, {});
45+ const defaultRoomQuery = useQuery(convexApi.queries.getDefaultRoomPublic, {});
4246
4347 // Subscribe to users (buddy list) - real-time updates
44- const usersQuery = useQuery(api.queries.getUsersPublic, {});
48+ const usersQuery = useQuery(convexApi.queries.getUsersPublic, {});
4549
4650 // Room ID from Convex query
4751 const roomId = $derived(defaultRoomQuery.data?.id as Id<'chatRooms'> | undefined);
4852
4953 // Subscribe to messages - only when room ID is available
5054 const messagesQuery = $derived(
51- roomId ? useQuery(api.queries.getMessagesPublic, () => ({ roomId: roomId!, limit: 100 })) : null
55+ roomId ? useQuery(convexApi.queries.getMessagesPublic, () => ({ roomId: roomId! })) : null
5256 );
5357
58+ const convexClient = useConvexClient();
59+
5460 // Transform Convex users to SafeUser format
5561 const onlineUsers = $derived.by<SafeUser[]>(() => {
5662 if (!usersQuery.data) return [];
@@ -153,14 +159,14 @@
153159
154160 let currentMessage = $state('');
155161 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)
163165 let hasAppliedUserStyle = $state(false);
166+ let lastInitialStyleJson = $state<string | null>(null);
167+ let styleFetchNonce = $state(0);
168+ let styleFetchAttempts = 0;
169+ let styleFetchInFlight = false;
164170 let lastUserId = $state<string | null>(null);
165171
166172 // ============ MENTION STATE ============
@@ -202,7 +208,8 @@
202208 let isLoadingMore = $state(false);
203209 let hasMoreMessages = $state(true);
204210 let oldestMessageTimestamp = $state<number | null>(null);
205- let lastMessageCount = $state(0);
211+ let lastMessageSignature = $state<string | null>(null);
212+ let hasInitialScrolled = $state(false);
206213
207214 // ============ DERIVED STATE ============
208215
@@ -270,52 +277,118 @@
270277
271278 // ============ EFFECTS ============
272279
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
274281 $effect(() => {
282+ const _styleFetchNonce = styleFetchNonce;
283+ if (_styleFetchNonce < 0) return;
275284 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+ }
276301
277302 // User changed (logged in or different user)
278303 if (userId !== lastUserId) {
279304 lastUserId = userId;
280305 hasAppliedUserStyle = false;
306+ styleFetchAttempts = 0;
307+ styleFetchInFlight = false;
281308 }
282309
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+ };
292347 });
293348
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);
295351 $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);
305367 }
306368 });
307369
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+ });
319392 }
320393 });
321394
@@ -559,18 +632,19 @@
559632 // Debounced function to update the server with last read mention timestamp
560633 const debouncedUpdateMentionTimestamp = debounce(async (timestamp: number) => {
561634 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);
572636 }, 1000);
573637
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+
574648 function setsEqual(a: Set<string>, b: Set<string>) {
575649 if (a.size !== b.size) return false;
576650 for (const value of a) {
@@ -800,26 +874,19 @@
800874
801875 isSendingMessage = true;
802876 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
814883 });
815884
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);
820887 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.';
823890 setTimeout(() => (rateLimitWarning = null), 3000);
824891 } else {
825892 currentMessage = '';
@@ -880,47 +947,40 @@
880947 }
881948
882949 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;
885952
886- if (scrollTop < 100 && !isLoadingMore && hasMoreMessages && roomId) {
953+ if (scrollTop < 100 && !isLoadingMore && hasMoreMessages && roomId && chatArea && currentUser) {
887954 isLoadingMore = true;
888- const scrollHeight = chatArea.scrollHeight;
889- const currentScrollTop = chatArea.scrollTop;
955+ const prevScrollHeight = chatArea.scrollHeight;
956+ const prevScrollTop = chatArea.scrollTop;
890957
891958 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;
924984 }
925985 } catch (error) {
926986 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 // Props36 // 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 updates47 // 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 query50 // 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 available53 // 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 })) : null55+ roomId ? useQuery(convexApi.queries.getMessagesPublic, () => ({ roomId: roomId! })) : null
52 );56 );
53 57
58+ const convexClient = useConvexClient();
59+
54 // Transform Convex users to SafeUser format60 // 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 value162+ // 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 DB280+ // 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 arrive349+ // 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 yet373+
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 timestamp632 // 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 @@
5555 reason: status !== lastSentStatus ? 'status_changed' : 'force_update',
5656 timeSinceLastUpdate,
5757 trigger: new Error().stack?.split('\n')[2]?.trim(),
58- hasSession: true,
59- cookiePresent: browser ? document.cookie.includes('session=') : false
58+ hasSession: true
6059 });
6160
6261 try {
@@ -220,7 +219,6 @@
220219 expiresAt: new Date(data.session.expiresAt)
221220 }
222221 : null,
223- cookiePresent: document.cookie.includes('session='),
224222 isVisible
225223 });
226224
@@ -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 isVisible222 isVisible
225 });223 });
226 224
M src/routes/+layout.ts +0 -19
@@ -4,25 +4,6 @@ export const load: LayoutLoad = async ({ data, depends }) => {
44 // Register client-side dependencies for invalidation
55 depends('app:session');
66 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-
267 // Return the data from the server load function
278 return {
289 user: data.user,
@@ -4,25 +4,6 @@ export const load: LayoutLoad = async ({ data, depends }) => {
4 // Register client-side dependencies for invalidation4 // 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 function7 // 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 {
77 } from '$lib/types/text-formatting';
88 import convex from '$lib/db/convex.server';
99
10-export const load: PageServerLoad = async ({ cookies }) => {
10+export const load: PageServerLoad = async ({ cookies, depends }) => {
11+ depends('app:session');
1112 const token = cookies.get('session');
1213
1314 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 @@
22 import ChatRoom from '$lib/components/chat-room.svelte';
33 import DesktopIcons from '$lib/components/desktop-icons.svelte';
44 import AimLogin from '$lib/components/aim-login.svelte';
5- import type { PageData } from './$types';
6-
7- let { data }: { data: PageData } = $props();
85
6+ const props = $props();
7+ const pageData = $derived(props.data);
98 let showChatRoom = $state(true);
109 let showAuth = $state(false);
1110
@@ -18,7 +17,7 @@
1817 <DesktopIcons bind:showChatRoom bind:showAuth />
1918
2019 {#if showChatRoom}
21- <ChatRoom bind:showChatRoom initialTextStyle={data.textStyle} />
20+ <ChatRoom bind:showChatRoom initialTextStyle={pageData.textStyle} />
2221 {/if}
2322
2423 {#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
4949 export const GET: RequestHandler = async ({ url, locals }) => {
5050 const beforeTimestamp = url.searchParams.get('before');
5151 const roomIdParam = url.searchParams.get('roomId');
52- const isPublic = url.searchParams.get('public') === 'true';
52+ const isPublic = url.searchParams.get('public') === 'true' || !locals.session;
5353
5454 // Use default room if not specified
5555 const roomId = roomIdParam || getDefaultChatRoomId();
@@ -67,7 +67,19 @@ export const GET: RequestHandler = async ({ url, locals }) => {
6767 isPublic
6868 });
6969
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;
7183
7284 const result = await messages.getByRoom(
7385 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 specified54 // 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 isPublic67 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,

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 3

Tags 8

↑↓ moveenter openesc close