detached ceeb452

fix(chat): hydrate prefs and align pagination

commit
ceeb452ff4330891b38f7e4b290063fcd04be16a
author
Kit · · 2026-01-24 07:29 UTC
parent
2919a84
signature
SSH UnverifiedSHA256:H91DFuEG4jpk5edzLA4aUKkpv4zv9Q339ipRmkph6Tk The key is unknown
9 files · +201 -132
M src/convex/queries.ts +46 -3
@@ -35,8 +35,9 @@ export const getMessagesPublic = query({
3535 limit: v.optional(v.number())
3636 },
3737 handler: async (ctx, args) => {
38- // Enforce max limit - client cannot request more than PUBLIC_MESSAGE_LIMIT
39- const limit = Math.min(args.limit ?? PUBLIC_MESSAGE_LIMIT, PUBLIC_MESSAGE_LIMIT);
38+ // Enforce max limit and clamp to a safe positive integer
39+ const rawLimit = args.limit ?? PUBLIC_MESSAGE_LIMIT;
40+ const limit = Math.max(1, Math.min(PUBLIC_MESSAGE_LIMIT, Math.floor(rawLimit)));
4041
4142 const messages = await ctx.db
4243 .query('messages')
@@ -74,6 +75,46 @@ export const getMessagesPublic = query({
7475 }
7576 });
7677
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+
77118 // Public query: Get all users (real-time subscription for buddy list)
78119 export const getUsersPublic = query({
79120 args: {},
@@ -131,12 +172,14 @@ export const getMessages = authQuery({
131172 limit: v.optional(v.number())
132173 },
133174 handler: async (ctx, args) => {
175+ const rawLimit = args.limit ?? 100;
176+ const limit = Math.max(1, Math.floor(rawLimit));
134177 const query = ctx.db
135178 .query('messages')
136179 .withIndex('by_chatRoom', (q) => q.eq('chatRoomId', args.roomId))
137180 .order('desc');
138181
139- const messages = args.limit ? await query.take(args.limit) : await query.collect();
182+ const messages = await query.take(limit);
140183
141184 // Enrich messages with sender info
142185 const enrichedMessages = await Promise.all(
@@ -35,8 +35,9 @@ export const getMessagesPublic = query({
35 limit: v.optional(v.number())35 limit: v.optional(v.number())
36 },36 },
37 handler: async (ctx, args) => {37 handler: async (ctx, args) => {
38- // Enforce max limit - client cannot request more than PUBLIC_MESSAGE_LIMIT38+ // Enforce max limit and clamp to a safe positive integer
39- const limit = Math.min(args.limit ?? PUBLIC_MESSAGE_LIMIT, PUBLIC_MESSAGE_LIMIT);39+ const rawLimit = args.limit ?? PUBLIC_MESSAGE_LIMIT;
40+ const limit = Math.max(1, Math.min(PUBLIC_MESSAGE_LIMIT, Math.floor(rawLimit)));
40 41
41 const messages = await ctx.db42 const messages = await ctx.db
42 .query('messages')43 .query('messages')
@@ -74,6 +75,46 @@ export const getMessagesPublic = query({
74 }75 }
75 });76 });
76 77
78+// Public query: Get paginated messages for a chat room (older history)
79+export const getMessagesPublicPage = query({
80+ args: {
81+ roomId: v.id('chatRooms'),
82+ limit: v.optional(v.number()),
83+ beforeTimestamp: v.optional(v.number())
84+ },
85+ handler: async (ctx, args) => {
86+ const rawLimit = args.limit ?? 50;
87+ const limit = Math.max(1, Math.min(PUBLIC_MESSAGE_LIMIT, Math.floor(rawLimit)));
88+
89+ let query = ctx.db
90+ .query('messages')
91+ .withIndex('by_chatRoom', (q) => q.eq('chatRoomId', args.roomId))
92+ .order('desc');
93+
94+ const beforeTimestamp = args.beforeTimestamp;
95+ if (beforeTimestamp !== undefined) {
96+ query = query.filter((q) => q.lt(q.field('timestamp'), beforeTimestamp));
97+ }
98+
99+ const messages = await query.take(limit + 1);
100+ const page = messages.slice(0, limit);
101+
102+ return {
103+ messages: page.map((msg) => ({
104+ id: msg._id,
105+ chatRoomId: msg.chatRoomId,
106+ senderId: msg.senderId,
107+ content: msg.content,
108+ type: msg.type,
109+ timestamp: msg.timestamp,
110+ styleData: msg.styleData,
111+ hasFormatting: msg.hasFormatting
112+ })),
113+ hasMore: messages.length > limit
114+ };
115+ }
116+});
117+
77 // Public query: Get all users (real-time subscription for buddy list)118 // Public query: Get all users (real-time subscription for buddy list)
78 export const getUsersPublic = query({119 export const getUsersPublic = query({
79 args: {},120 args: {},
@@ -131,12 +172,14 @@ export const getMessages = authQuery({
131 limit: v.optional(v.number())172 limit: v.optional(v.number())
132 },173 },
133 handler: async (ctx, args) => {174 handler: async (ctx, args) => {
175+ const rawLimit = args.limit ?? 100;
176+ const limit = Math.max(1, Math.floor(rawLimit));
134 const query = ctx.db177 const query = ctx.db
135 .query('messages')178 .query('messages')
136 .withIndex('by_chatRoom', (q) => q.eq('chatRoomId', args.roomId))179 .withIndex('by_chatRoom', (q) => q.eq('chatRoomId', args.roomId))
137 .order('desc');180 .order('desc');
138 181
139- const messages = args.limit ? await query.take(args.limit) : await query.collect();182+ const messages = await query.take(limit);
140 183
141 // Enrich messages with sender info184 // Enrich messages with sender info
142 const enrichedMessages = await Promise.all(185 const enrichedMessages = await Promise.all(
M src/lib/api/client.ts +26 -34
@@ -3,6 +3,7 @@
33 * Provides typed helper functions for all API calls
44 */
55
6+import type { SendMessageRequest, SendMessageResponse } from '$lib/types/payloads';
67 import type { TextStyle, UserTextPreferences } from '$lib/types/text-formatting';
78
89 // ============ TYPES ============
@@ -23,12 +24,12 @@ export interface TextPreferencesResponse {
2324 async function request<T>(endpoint: string, options: RequestInit = {}): Promise<ApiResponse<T>> {
2425 try {
2526 const response = await fetch(endpoint, {
27+ ...options,
2628 credentials: 'include',
2729 headers: {
2830 'Content-Type': 'application/json',
29- ...options.headers
30- },
31- ...options
31+ ...(options.headers ?? {})
32+ }
3233 });
3334
3435 const data = await response.json();
@@ -36,7 +37,8 @@ async function request<T>(endpoint: string, options: RequestInit = {}): Promise<
3637 if (!response.ok) {
3738 return {
3839 success: false,
39- error: data.error || `Request failed with status ${response.status}`
40+ error: data.error || `Request failed with status ${response.status}`,
41+ data
4042 };
4143 }
4244
@@ -64,12 +66,12 @@ export const textPreferences = {
6466 },
6567
6668 /**
67- * Save user's text preferences (partial update supported)
69+ * Save user's text preferences
6870 */
6971 async save(preferences: {
7072 defaultStyle: TextStyle;
71- allowFormatting?: boolean;
72- maxMessageLength?: number;
73+ allowFormatting: boolean;
74+ maxMessageLength: number;
7375 }): Promise<boolean> {
7476 const result = await request('/api/user/text-preferences', {
7577 method: 'POST',
@@ -124,14 +126,18 @@ export const session = {
124126 async register(
125127 username: string,
126128 password: string,
127- confirmPassword: string
129+ confirmPassword: string,
130+ captchaAnswer: string,
131+ turnstileToken?: string
128132 ): Promise<LoginResponse> {
129133 const result = await request<LoginResponse>('/api/register', {
130134 method: 'POST',
131135 body: JSON.stringify({
132136 suUsername: username,
133137 suPassword: password,
134- suConfirmPassword: confirmPassword
138+ suConfirmPassword: confirmPassword,
139+ captchaAnswer,
140+ ...(turnstileToken ? { turnstileToken } : {})
135141 })
136142 });
137143 return result.data ?? { success: false, error: result.error };
@@ -142,42 +148,28 @@ export const session = {
142148
143149 export interface SendMessageParams {
144150 content: string;
145- roomId: string;
151+ userId: string;
152+ chatRoomId?: string;
153+ type?: SendMessageRequest['type'];
146154 styleData?: string;
147- hasFormatting?: boolean;
148155 }
149156
150157 export const chat = {
151158 /**
152159 * Send a message to a chat room
153160 */
154- async sendMessage(params: SendMessageParams): Promise<boolean> {
155- const result = await request('/api/chat/messages', {
161+ async sendMessage(params: SendMessageParams): Promise<SendMessageResponse> {
162+ const result = await request<SendMessageResponse>('/api/chat/messages', {
156163 method: 'POST',
157164 body: JSON.stringify(params)
158165 });
159- return result.success;
160- },
161-
162- /**
163- * Load more messages (pagination)
164- */
165- async loadMessages(
166- roomId: string,
167- beforeTimestamp?: number,
168- isPublic = false
169- ): Promise<{ messages: unknown[]; hasMore: boolean } | null> {
170- const params = new URLSearchParams({ roomId });
171- if (beforeTimestamp) params.set('before', beforeTimestamp.toString());
172- if (isPublic) params.set('public', 'true');
173-
174- const result = await request<{ success: boolean; messages: unknown[]; hasMore: boolean }>(
175- `/api/chat/messages?${params.toString()}`
176- );
177- if (result.success && result.data) {
178- return { messages: result.data.messages, hasMore: result.data.hasMore };
166+ if (result.data) {
167+ return result.data;
179168 }
180- return null;
169+ return {
170+ success: false,
171+ error: result.error ?? 'Failed to send message'
172+ };
181173 }
182174 };
183175
@@ -3,6 +3,7 @@
3 * Provides typed helper functions for all API calls3 * Provides typed helper functions for all API calls
4 */4 */
5 5
6+import type { SendMessageRequest, SendMessageResponse } from '$lib/types/payloads';
6 import type { TextStyle, UserTextPreferences } from '$lib/types/text-formatting';7 import type { TextStyle, UserTextPreferences } from '$lib/types/text-formatting';
7 8
8 // ============ TYPES ============9 // ============ TYPES ============
@@ -23,12 +24,12 @@ export interface TextPreferencesResponse {
23 async function request<T>(endpoint: string, options: RequestInit = {}): Promise<ApiResponse<T>> {24 async function request<T>(endpoint: string, options: RequestInit = {}): Promise<ApiResponse<T>> {
24 try {25 try {
25 const response = await fetch(endpoint, {26 const response = await fetch(endpoint, {
27+ ...options,
26 credentials: 'include',28 credentials: 'include',
27 headers: {29 headers: {
28 'Content-Type': 'application/json',30 'Content-Type': 'application/json',
29- ...options.headers31+ ...(options.headers ?? {})
30- },32+ }
31- ...options
32 });33 });
33 34
34 const data = await response.json();35 const data = await response.json();
@@ -36,7 +37,8 @@ async function request<T>(endpoint: string, options: RequestInit = {}): Promise<
36 if (!response.ok) {37 if (!response.ok) {
37 return {38 return {
38 success: false,39 success: false,
39- error: data.error || `Request failed with status ${response.status}`40+ error: data.error || `Request failed with status ${response.status}`,
41+ data
40 };42 };
41 }43 }
42 44
@@ -64,12 +66,12 @@ export const textPreferences = {
64 },66 },
65 67
66 /**68 /**
67- * Save user's text preferences (partial update supported)69+ * Save user's text preferences
68 */70 */
69 async save(preferences: {71 async save(preferences: {
70 defaultStyle: TextStyle;72 defaultStyle: TextStyle;
71- allowFormatting?: boolean;73+ allowFormatting: boolean;
72- maxMessageLength?: number;74+ maxMessageLength: number;
73 }): Promise<boolean> {75 }): Promise<boolean> {
74 const result = await request('/api/user/text-preferences', {76 const result = await request('/api/user/text-preferences', {
75 method: 'POST',77 method: 'POST',
@@ -124,14 +126,18 @@ export const session = {
124 async register(126 async register(
125 username: string,127 username: string,
126 password: string,128 password: string,
127- confirmPassword: string129+ confirmPassword: string,
130+ captchaAnswer: string,
131+ turnstileToken?: string
128 ): Promise<LoginResponse> {132 ): Promise<LoginResponse> {
129 const result = await request<LoginResponse>('/api/register', {133 const result = await request<LoginResponse>('/api/register', {
130 method: 'POST',134 method: 'POST',
131 body: JSON.stringify({135 body: JSON.stringify({
132 suUsername: username,136 suUsername: username,
133 suPassword: password,137 suPassword: password,
134- suConfirmPassword: confirmPassword138+ suConfirmPassword: confirmPassword,
139+ captchaAnswer,
140+ ...(turnstileToken ? { turnstileToken } : {})
135 })141 })
136 });142 });
137 return result.data ?? { success: false, error: result.error };143 return result.data ?? { success: false, error: result.error };
@@ -142,42 +148,28 @@ export const session = {
142 148
143 export interface SendMessageParams {149 export interface SendMessageParams {
144 content: string;150 content: string;
145- roomId: string;151+ userId: string;
152+ chatRoomId?: string;
153+ type?: SendMessageRequest['type'];
146 styleData?: string;154 styleData?: string;
147- hasFormatting?: boolean;
148 }155 }
149 156
150 export const chat = {157 export const chat = {
151 /**158 /**
152 * Send a message to a chat room159 * Send a message to a chat room
153 */160 */
154- async sendMessage(params: SendMessageParams): Promise<boolean> {161+ async sendMessage(params: SendMessageParams): Promise<SendMessageResponse> {
155- const result = await request('/api/chat/messages', {162+ const result = await request<SendMessageResponse>('/api/chat/messages', {
156 method: 'POST',163 method: 'POST',
157 body: JSON.stringify(params)164 body: JSON.stringify(params)
158 });165 });
159- return result.success;166+ if (result.data) {
160- },167+ return result.data;
161-
162- /**
163- * Load more messages (pagination)
164- */
165- async loadMessages(
166- roomId: string,
167- beforeTimestamp?: number,
168- isPublic = false
169- ): Promise<{ messages: unknown[]; hasMore: boolean } | null> {
170- const params = new URLSearchParams({ roomId });
171- if (beforeTimestamp) params.set('before', beforeTimestamp.toString());
172- if (isPublic) params.set('public', 'true');
173-
174- const result = await request<{ success: boolean; messages: unknown[]; hasMore: boolean }>(
175- `/api/chat/messages?${params.toString()}`
176- );
177- if (result.success && result.data) {
178- return { messages: result.data.messages, hasMore: result.data.hasMore };
179 }168 }
180- return null;169+ return {
170+ success: false,
171+ error: result.error ?? 'Failed to send message'
172+ };
181 }173 }
182 };174 };
183 175
M src/lib/components/aim-login.svelte +7 -3
@@ -2,7 +2,7 @@
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 +102 -66
@@ -1,6 +1,6 @@
11 <script lang="ts">
22 import { chatState } from '../states/chat.svelte';
3- import { useQuery } from 'convex-svelte';
3+ import { useConvexClient, useQuery } from 'convex-svelte';
44 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';
@@ -34,7 +34,10 @@
3434 import { MAX_MESSAGE_LENGTH } from '$lib/validation/message';
3535
3636 // Props
37- let { showChatRoom = $bindable() } = $props();
37+ let { showChatRoom = $bindable(), initialTextStyle = DEFAULT_TEXT_STYLE } = $props<{
38+ showChatRoom?: boolean;
39+ initialTextStyle?: TextStyle;
40+ }>();
3841
3942 // ============ CONVEX REAL-TIME SUBSCRIPTIONS ============
4043
@@ -52,6 +55,8 @@
5255 roomId ? useQuery(convexApi.queries.getMessagesPublic, () => ({ roomId: roomId! })) : null
5356 );
5457
58+ const convexClient = useConvexClient();
59+
5560 // Transform Convex users to SafeUser format
5661 const onlineUsers = $derived.by<SafeUser[]>(() => {
5762 if (!usersQuery.data) return [];
@@ -154,10 +159,14 @@
154159
155160 let currentMessage = $state('');
156161 let inputScrollLeft = $state(0);
157- // Initialize text style with defaults - prop value applied via effect below
162+ // Initialize text style with defaults - user-specific style applied after API fetch
158163 let currentTextStyle = $state<TextStyle>({ ...DEFAULT_TEXT_STYLE });
159- // Track if we've applied user's style from DB (to avoid resetting on every prop change)
164+ // Track if we've applied the user's style from the API/DB (to avoid repeated fetch resets)
160165 let hasAppliedUserStyle = $state(false);
166+ let lastInitialStyleJson = $state<string | null>(null);
167+ let styleFetchNonce = $state(0);
168+ let styleFetchAttempts = 0;
169+ let styleFetchInFlight = false;
161170 let lastUserId = $state<string | null>(null);
162171
163172 // ============ MENTION STATE ============
@@ -270,28 +279,70 @@
270279
271280 // Update text style when user logs in - fetch preferences directly from API
272281 $effect(() => {
282+ styleFetchNonce;
273283 const userId = currentUser?.id ?? null;
284+ const defaultStyleJson = JSON.stringify(DEFAULT_TEXT_STYLE);
285+ const initialStyleJson = JSON.stringify(initialTextStyle ?? DEFAULT_TEXT_STYLE);
286+
287+ if (initialStyleJson !== lastInitialStyleJson) {
288+ lastInitialStyleJson = initialStyleJson;
289+ currentTextStyle = {
290+ ...DEFAULT_TEXT_STYLE,
291+ ...(initialTextStyle ?? DEFAULT_TEXT_STYLE),
292+ color: (initialTextStyle?.color ?? DEFAULT_TEXT_STYLE.color) || '#000000'
293+ };
294+ if (!currentUser) {
295+ hasAppliedUserStyle = false;
296+ } else if (initialStyleJson !== defaultStyleJson) {
297+ hasAppliedUserStyle = true;
298+ }
299+ }
274300
275301 // User changed (logged in or different user)
276302 if (userId !== lastUserId) {
277303 lastUserId = userId;
278304 hasAppliedUserStyle = false;
305+ styleFetchAttempts = 0;
306+ styleFetchInFlight = false;
279307 }
280308
281309 // Fetch and apply user's saved style when they log in
282- if (currentUser && !hasAppliedUserStyle && browser) {
283- hasAppliedUserStyle = true;
284- // Fetch user preferences directly to avoid timing issues with invalidateAll
285- api.textPreferences.get().then((prefs) => {
286- if (prefs?.defaultStyle) {
287- currentTextStyle = {
288- ...DEFAULT_TEXT_STYLE,
289- ...prefs.defaultStyle,
290- color: prefs.defaultStyle.color || '#000000'
291- };
310+ if (!currentUser || hasAppliedUserStyle || styleFetchInFlight || !browser) return;
311+
312+ let isActive = true;
313+ const activeUserId = currentUser.id;
314+ const attempt = styleFetchAttempts + 1;
315+ styleFetchAttempts = attempt;
316+ styleFetchInFlight = true;
317+ // Fetch user preferences directly to avoid timing issues with invalidateAll
318+ api.textPreferences
319+ .get()
320+ .then((prefs) => {
321+ if (!isActive || currentUser?.id !== activeUserId) return;
322+ if (!prefs?.defaultStyle) return;
323+ currentTextStyle = {
324+ ...DEFAULT_TEXT_STYLE,
325+ ...prefs.defaultStyle,
326+ color: prefs.defaultStyle.color || '#000000'
327+ };
328+ hasAppliedUserStyle = true;
329+ })
330+ .finally(() => {
331+ if (!isActive || currentUser?.id !== activeUserId) return;
332+ if (!hasAppliedUserStyle && attempt < 3) {
333+ const retryDelay = Math.min(1500, 300 * attempt);
334+ window.setTimeout(() => {
335+ styleFetchInFlight = false;
336+ styleFetchNonce += 1;
337+ }, retryDelay);
338+ return;
292339 }
340+ styleFetchInFlight = false;
293341 });
294- }
342+
343+ return () => {
344+ isActive = false;
345+ };
295346 });
296347
297348 // Save text style to database when it changes (after initial load)
@@ -822,26 +873,19 @@
822873
823874 isSendingMessage = true;
824875 try {
825- const response = await fetch('/api/chat/messages', {
826- method: 'POST',
827- headers: { 'Content-Type': 'application/json' },
828- credentials: 'include',
829- body: JSON.stringify({
830- content: currentMessage,
831- type: 'chat',
832- userId: currentUser.id,
833- chatRoomId: roomId,
834- styleData: currentTextStyle ? JSON.stringify(currentTextStyle) : undefined
835- })
876+ const result = await api.chat.sendMessage({
877+ content: currentMessage,
878+ type: 'chat',
879+ userId: currentUser.id,
880+ chatRoomId: roomId,
881+ styleData: currentTextStyle ? JSON.stringify(currentTextStyle) : undefined
836882 });
837883
838- const data = await response.json();
839-
840- if (!data.success && data.isRateLimited && data.retryAfter) {
841- startCooldownTimer(data.retryAfter);
884+ if (!result.success && result.isRateLimited && result.retryAfter) {
885+ startCooldownTimer(result.retryAfter);
842886 rateLimitWarning = "Whoa there! You're sending messages too quickly. Take a breather...";
843- } else if (!data.success) {
844- rateLimitWarning = data.error || 'Failed to send message. Please try again.';
887+ } else if (!result.success) {
888+ rateLimitWarning = result.error || 'Failed to send message. Please try again.';
845889 setTimeout(() => (rateLimitWarning = null), 3000);
846890 } else {
847891 currentMessage = '';
@@ -905,45 +949,37 @@
905949 const target = event.target as HTMLElement;
906950 const { scrollTop } = target;
907951
908- if (scrollTop < 100 && !isLoadingMore && hasMoreMessages && roomId && chatArea) {
952+ if (scrollTop < 100 && !isLoadingMore && hasMoreMessages && roomId && chatArea && currentUser) {
909953 isLoadingMore = true;
910954 const prevScrollHeight = chatArea.scrollHeight;
911955 const prevScrollTop = chatArea.scrollTop;
912956
913957 try {
914- // Build query string manually to avoid URLSearchParams (svelte reactivity compliance)
915- const queryParts = [
916- `before=${encodeURIComponent(oldestMessageTimestamp?.toString() || '')}`,
917- `roomId=${encodeURIComponent(roomId)}`
918- ];
919- if (!currentUser) queryParts.push('public=true');
920- const queryString = queryParts.join('&');
921-
922- const response = await fetch(`/api/chat/messages?${queryString}`);
923-
924- if (response.ok) {
925- const data = await response.json();
926- if (data.success) {
927- if (data.messages.length === 0) {
928- hasMoreMessages = false;
929- } else {
930- const fetchedMessages = data.messages as Message[];
931- mergePagedMessages(fetchedMessages);
932-
933- requestAnimationFrame(() => {
934- if (!chatArea) return;
935- const newScrollHeight = chatArea.scrollHeight;
936- const heightDifference = newScrollHeight - prevScrollHeight;
937- chatArea.scrollTop = prevScrollTop + heightDifference;
938- });
939-
940- const newOldest = Math.min(...fetchedMessages.map((m) => m.timestamp));
941- oldestMessageTimestamp = oldestMessageTimestamp
942- ? Math.min(oldestMessageTimestamp, newOldest)
943- : newOldest;
944- hasMoreMessages = data.hasMore;
945- }
946- }
958+ const fetchLimit = currentUser ? 100 : 50;
959+ const data = await convexClient.query(convexApi.queries.getMessagesPublicPage, {
960+ roomId,
961+ limit: fetchLimit,
962+ beforeTimestamp: oldestMessageTimestamp ?? undefined
963+ });
964+
965+ if (data.messages.length === 0) {
966+ hasMoreMessages = false;
967+ } else {
968+ const fetchedMessages = data.messages as Message[];
969+ mergePagedMessages(fetchedMessages);
970+
971+ requestAnimationFrame(() => {
972+ if (!chatArea) return;
973+ const newScrollHeight = chatArea.scrollHeight;
974+ const heightDifference = newScrollHeight - prevScrollHeight;
975+ chatArea.scrollTop = prevScrollTop + heightDifference;
976+ });
977+
978+ const newOldest = Math.min(...fetchedMessages.map((m) => m.timestamp));
979+ oldestMessageTimestamp = oldestMessageTimestamp
980+ ? Math.min(oldestMessageTimestamp, newOldest)
981+ : newOldest;
982+ hasMoreMessages = data.hasMore;
947983 }
948984 } catch (error) {
949985 console.error('Error loading more messages:', error);
@@ -1,6 +1,6 @@
1 <script lang="ts">1 <script lang="ts">
2 import { chatState } from '../states/chat.svelte';2 import { chatState } from '../states/chat.svelte';
3- import { useQuery } from 'convex-svelte';3+ import { useConvexClient, useQuery } from 'convex-svelte';
4 import { api as convexApi } from '../../convex/_generated/api';4 import { api as convexApi } from '../../convex/_generated/api';
5 import type { Id } from '../../convex/_generated/dataModel';5 import type { Id } from '../../convex/_generated/dataModel';
6 import type { Message, EnrichedMessage, SafeUser } from '../types/chat';6 import type { Message, EnrichedMessage, SafeUser } from '../types/chat';
@@ -34,7 +34,10 @@
34 import { MAX_MESSAGE_LENGTH } from '$lib/validation/message';34 import { MAX_MESSAGE_LENGTH } from '$lib/validation/message';
35 35
36 // Props36 // Props
37- let { showChatRoom = $bindable() } = $props();37+ let { showChatRoom = $bindable(), initialTextStyle = DEFAULT_TEXT_STYLE } = $props<{
38+ showChatRoom?: boolean;
39+ initialTextStyle?: TextStyle;
40+ }>();
38 41
39 // ============ CONVEX REAL-TIME SUBSCRIPTIONS ============42 // ============ CONVEX REAL-TIME SUBSCRIPTIONS ============
40 43
@@ -52,6 +55,8 @@
52 roomId ? useQuery(convexApi.queries.getMessagesPublic, () => ({ roomId: roomId! })) : null55 roomId ? useQuery(convexApi.queries.getMessagesPublic, () => ({ roomId: roomId! })) : null
53 );56 );
54 57
58+ const convexClient = useConvexClient();
59+
55 // Transform Convex users to SafeUser format60 // Transform Convex users to SafeUser format
56 const onlineUsers = $derived.by<SafeUser[]>(() => {61 const onlineUsers = $derived.by<SafeUser[]>(() => {
57 if (!usersQuery.data) return [];62 if (!usersQuery.data) return [];
@@ -154,10 +159,14 @@
154 159
155 let currentMessage = $state('');160 let currentMessage = $state('');
156 let inputScrollLeft = $state(0);161 let inputScrollLeft = $state(0);
157- // Initialize text style with defaults - prop value applied via effect below162+ // Initialize text style with defaults - user-specific style applied after API fetch
158 let currentTextStyle = $state<TextStyle>({ ...DEFAULT_TEXT_STYLE });163 let currentTextStyle = $state<TextStyle>({ ...DEFAULT_TEXT_STYLE });
159- // Track if we've applied user's style from DB (to avoid resetting on every prop change)164+ // Track if we've applied the user's style from the API/DB (to avoid repeated fetch resets)
160 let hasAppliedUserStyle = $state(false);165 let hasAppliedUserStyle = $state(false);
166+ let lastInitialStyleJson = $state<string | null>(null);
167+ let styleFetchNonce = $state(0);
168+ let styleFetchAttempts = 0;
169+ let styleFetchInFlight = false;
161 let lastUserId = $state<string | null>(null);170 let lastUserId = $state<string | null>(null);
162 171
163 // ============ MENTION STATE ============172 // ============ MENTION STATE ============
@@ -270,28 +279,70 @@
270 279
271 // Update text style when user logs in - fetch preferences directly from API280 // Update text style when user logs in - fetch preferences directly from API
272 $effect(() => {281 $effect(() => {
282+ styleFetchNonce;
273 const userId = currentUser?.id ?? null;283 const userId = currentUser?.id ?? null;
284+ const defaultStyleJson = JSON.stringify(DEFAULT_TEXT_STYLE);
285+ const initialStyleJson = JSON.stringify(initialTextStyle ?? DEFAULT_TEXT_STYLE);
286+
287+ if (initialStyleJson !== lastInitialStyleJson) {
288+ lastInitialStyleJson = initialStyleJson;
289+ currentTextStyle = {
290+ ...DEFAULT_TEXT_STYLE,
291+ ...(initialTextStyle ?? DEFAULT_TEXT_STYLE),
292+ color: (initialTextStyle?.color ?? DEFAULT_TEXT_STYLE.color) || '#000000'
293+ };
294+ if (!currentUser) {
295+ hasAppliedUserStyle = false;
296+ } else if (initialStyleJson !== defaultStyleJson) {
297+ hasAppliedUserStyle = true;
298+ }
299+ }
274 300
275 // User changed (logged in or different user)301 // User changed (logged in or different user)
276 if (userId !== lastUserId) {302 if (userId !== lastUserId) {
277 lastUserId = userId;303 lastUserId = userId;
278 hasAppliedUserStyle = false;304 hasAppliedUserStyle = false;
305+ styleFetchAttempts = 0;
306+ styleFetchInFlight = false;
279 }307 }
280 308
281 // Fetch and apply user's saved style when they log in309 // Fetch and apply user's saved style when they log in
282- if (currentUser && !hasAppliedUserStyle && browser) {310+ if (!currentUser || hasAppliedUserStyle || styleFetchInFlight || !browser) return;
283- hasAppliedUserStyle = true;311+
284- // Fetch user preferences directly to avoid timing issues with invalidateAll312+ let isActive = true;
285- api.textPreferences.get().then((prefs) => {313+ const activeUserId = currentUser.id;
286- if (prefs?.defaultStyle) {314+ const attempt = styleFetchAttempts + 1;
287- currentTextStyle = {315+ styleFetchAttempts = attempt;
288- ...DEFAULT_TEXT_STYLE,316+ styleFetchInFlight = true;
289- ...prefs.defaultStyle,317+ // Fetch user preferences directly to avoid timing issues with invalidateAll
290- color: prefs.defaultStyle.color || '#000000'318+ api.textPreferences
291- };319+ .get()
320+ .then((prefs) => {
321+ if (!isActive || currentUser?.id !== activeUserId) return;
322+ if (!prefs?.defaultStyle) return;
323+ currentTextStyle = {
324+ ...DEFAULT_TEXT_STYLE,
325+ ...prefs.defaultStyle,
326+ color: prefs.defaultStyle.color || '#000000'
327+ };
328+ hasAppliedUserStyle = true;
329+ })
330+ .finally(() => {
331+ if (!isActive || currentUser?.id !== activeUserId) return;
332+ if (!hasAppliedUserStyle && attempt < 3) {
333+ const retryDelay = Math.min(1500, 300 * attempt);
334+ window.setTimeout(() => {
335+ styleFetchInFlight = false;
336+ styleFetchNonce += 1;
337+ }, retryDelay);
338+ return;
292 }339 }
340+ styleFetchInFlight = false;
293 });341 });
294- }342+
343+ return () => {
344+ isActive = false;
345+ };
295 });346 });
296 347
297 // Save text style to database when it changes (after initial load)348 // Save text style to database when it changes (after initial load)
@@ -822,26 +873,19 @@
822 873
823 isSendingMessage = true;874 isSendingMessage = true;
824 try {875 try {
825- const response = await fetch('/api/chat/messages', {876+ const result = await api.chat.sendMessage({
826- method: 'POST',877+ content: currentMessage,
827- headers: { 'Content-Type': 'application/json' },878+ type: 'chat',
828- credentials: 'include',879+ userId: currentUser.id,
829- body: JSON.stringify({880+ chatRoomId: roomId,
830- content: currentMessage,881+ styleData: currentTextStyle ? JSON.stringify(currentTextStyle) : undefined
831- type: 'chat',
832- userId: currentUser.id,
833- chatRoomId: roomId,
834- styleData: currentTextStyle ? JSON.stringify(currentTextStyle) : undefined
835- })
836 });882 });
837 883
838- const data = await response.json();884+ if (!result.success && result.isRateLimited && result.retryAfter) {
839-885+ startCooldownTimer(result.retryAfter);
840- if (!data.success && data.isRateLimited && data.retryAfter) {
841- startCooldownTimer(data.retryAfter);
842 rateLimitWarning = "Whoa there! You're sending messages too quickly. Take a breather...";886 rateLimitWarning = "Whoa there! You're sending messages too quickly. Take a breather...";
843- } else if (!data.success) {887+ } else if (!result.success) {
844- rateLimitWarning = data.error || 'Failed to send message. Please try again.';888+ rateLimitWarning = result.error || 'Failed to send message. Please try again.';
845 setTimeout(() => (rateLimitWarning = null), 3000);889 setTimeout(() => (rateLimitWarning = null), 3000);
846 } else {890 } else {
847 currentMessage = '';891 currentMessage = '';
@@ -905,45 +949,37 @@
905 const target = event.target as HTMLElement;949 const target = event.target as HTMLElement;
906 const { scrollTop } = target;950 const { scrollTop } = target;
907 951
908- if (scrollTop < 100 && !isLoadingMore && hasMoreMessages && roomId && chatArea) {952+ if (scrollTop < 100 && !isLoadingMore && hasMoreMessages && roomId && chatArea && currentUser) {
909 isLoadingMore = true;953 isLoadingMore = true;
910 const prevScrollHeight = chatArea.scrollHeight;954 const prevScrollHeight = chatArea.scrollHeight;
911 const prevScrollTop = chatArea.scrollTop;955 const prevScrollTop = chatArea.scrollTop;
912 956
913 try {957 try {
914- // Build query string manually to avoid URLSearchParams (svelte reactivity compliance)958+ const fetchLimit = currentUser ? 100 : 50;
915- const queryParts = [959+ const data = await convexClient.query(convexApi.queries.getMessagesPublicPage, {
916- `before=${encodeURIComponent(oldestMessageTimestamp?.toString() || '')}`,960+ roomId,
917- `roomId=${encodeURIComponent(roomId)}`961+ limit: fetchLimit,
918- ];962+ beforeTimestamp: oldestMessageTimestamp ?? undefined
919- if (!currentUser) queryParts.push('public=true');963+ });
920- const queryString = queryParts.join('&');964+
921-965+ if (data.messages.length === 0) {
922- const response = await fetch(`/api/chat/messages?${queryString}`);966+ hasMoreMessages = false;
923-967+ } else {
924- if (response.ok) {968+ const fetchedMessages = data.messages as Message[];
925- const data = await response.json();969+ mergePagedMessages(fetchedMessages);
926- if (data.success) {970+
927- if (data.messages.length === 0) {971+ requestAnimationFrame(() => {
928- hasMoreMessages = false;972+ if (!chatArea) return;
929- } else {973+ const newScrollHeight = chatArea.scrollHeight;
930- const fetchedMessages = data.messages as Message[];974+ const heightDifference = newScrollHeight - prevScrollHeight;
931- mergePagedMessages(fetchedMessages);975+ chatArea.scrollTop = prevScrollTop + heightDifference;
932-976+ });
933- requestAnimationFrame(() => {977+
934- if (!chatArea) return;978+ const newOldest = Math.min(...fetchedMessages.map((m) => m.timestamp));
935- const newScrollHeight = chatArea.scrollHeight;979+ oldestMessageTimestamp = oldestMessageTimestamp
936- const heightDifference = newScrollHeight - prevScrollHeight;980+ ? Math.min(oldestMessageTimestamp, newOldest)
937- chatArea.scrollTop = prevScrollTop + heightDifference;981+ : newOldest;
938- });982+ hasMoreMessages = data.hasMore;
939-
940- const newOldest = Math.min(...fetchedMessages.map((m) => m.timestamp));
941- oldestMessageTimestamp = oldestMessageTimestamp
942- ? Math.min(oldestMessageTimestamp, newOldest)
943- : newOldest;
944- hasMoreMessages = data.hasMore;
945- }
946- }
947 }983 }
948 } catch (error) {984 } catch (error) {
949 console.error('Error loading more messages:', error);985 console.error('Error loading more messages:', error);
M src/routes/+layout.svelte +1 -3
@@ -55,8 +55,7 @@
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 -1
@@ -3,6 +3,8 @@
33 import DesktopIcons from '$lib/components/desktop-icons.svelte';
44 import AimLogin from '$lib/components/aim-login.svelte';
55
6+ const props = $props();
7+ const pageData = $derived(props.data);
68 let showChatRoom = $state(true);
79 let showAuth = $state(false);
810
@@ -15,7 +17,7 @@
1517 <DesktopIcons bind:showChatRoom bind:showAuth />
1618
1719 {#if showChatRoom}
18- <ChatRoom bind:showChatRoom />
20+ <ChatRoom bind:showChatRoom initialTextStyle={pageData.textStyle} />
1921 {/if}
2022
2123 {#if showAuth}
@@ -3,6 +3,8 @@
3 import DesktopIcons from '$lib/components/desktop-icons.svelte';3 import DesktopIcons from '$lib/components/desktop-icons.svelte';
4 import AimLogin from '$lib/components/aim-login.svelte';4 import AimLogin from '$lib/components/aim-login.svelte';
5 5
6+ const props = $props();
7+ const pageData = $derived(props.data);
6 let showChatRoom = $state(true);8 let showChatRoom = $state(true);
7 let showAuth = $state(false);9 let showAuth = $state(false);
8 10
@@ -15,7 +17,7 @@
15 <DesktopIcons bind:showChatRoom bind:showAuth />17 <DesktopIcons bind:showChatRoom bind:showAuth />
16 18
17 {#if showChatRoom}19 {#if showChatRoom}
18- <ChatRoom bind:showChatRoom />20+ <ChatRoom bind:showChatRoom initialTextStyle={pageData.textStyle} />
19 {/if}21 {/if}
20 22
21 {#if showAuth}23 {#if showAuth}
M src/routes/api/chat/messages/+server.ts +14 -2
@@ -49,7 +49,7 @@ function updateUserCooldown(userId: string): { canSend: boolean; retryAfter?: nu
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