detached 2919a84

feat(api): add centralized API client and enforce public message limit

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

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