hazo_chat 2.0.16 → 2.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/README.md +60 -8
  2. package/SETUP_CHECKLIST.md +266 -10
  3. package/dist/api/index.d.ts +17 -2
  4. package/dist/api/index.d.ts.map +1 -1
  5. package/dist/api/index.js +16 -1
  6. package/dist/api/index.js.map +1 -1
  7. package/dist/api/messages.d.ts +52 -1
  8. package/dist/api/messages.d.ts.map +1 -1
  9. package/dist/api/messages.js +380 -22
  10. package/dist/api/messages.js.map +1 -1
  11. package/dist/api/types.d.ts +25 -0
  12. package/dist/api/types.d.ts.map +1 -1
  13. package/dist/components/hazo_chat/hazo_chat.d.ts.map +1 -1
  14. package/dist/components/hazo_chat/hazo_chat.js +18 -10
  15. package/dist/components/hazo_chat/hazo_chat.js.map +1 -1
  16. package/dist/components/hazo_chat/hazo_chat_header.d.ts.map +1 -1
  17. package/dist/components/hazo_chat/hazo_chat_header.js +22 -1
  18. package/dist/components/hazo_chat/hazo_chat_header.js.map +1 -1
  19. package/dist/components/hazo_chat/hazo_chat_messages.d.ts +5 -4
  20. package/dist/components/hazo_chat/hazo_chat_messages.d.ts.map +1 -1
  21. package/dist/components/hazo_chat/hazo_chat_messages.js +153 -7
  22. package/dist/components/hazo_chat/hazo_chat_messages.js.map +1 -1
  23. package/dist/components/ui/chat_bubble.d.ts.map +1 -1
  24. package/dist/components/ui/chat_bubble.js +28 -5
  25. package/dist/components/ui/chat_bubble.js.map +1 -1
  26. package/dist/hooks/use_chat_messages.d.ts +3 -3
  27. package/dist/hooks/use_chat_messages.d.ts.map +1 -1
  28. package/dist/hooks/use_chat_messages.js +259 -136
  29. package/dist/hooks/use_chat_messages.js.map +1 -1
  30. package/dist/types/index.d.ts +108 -2
  31. package/dist/types/index.d.ts.map +1 -1
  32. package/package.json +1 -1
@@ -1 +1 @@
1
- {"version":3,"file":"use_chat_messages.d.ts","sourceRoot":"","sources":["../../src/hooks/use_chat_messages.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAKH,OAAO,KAAK,EAKV,qBAAqB,EAEtB,MAAM,mBAAmB,CAAC;AAQ3B,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AAMtD,MAAM,WAAW,qBAAqB;IACpC,4CAA4C;IAC5C,gBAAgB,EAAE,MAAM,CAAC;IACzB,6CAA6C;IAC7C,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,uCAAuC;IACvC,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,6DAA6D;IAC7D,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,8EAA8E;IAC9E,aAAa,CAAC,EAAE,YAAY,CAAC;IAC7B,iGAAiG;IACjG,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,+DAA+D;IAC/D,iBAAiB,CAAC,EAAE,MAAM,CAAC;CAC5B;AA6BD,wBAAgB,eAAe,CAAC,EAC9B,gBAAgB,EAChB,YAAiB,EACjB,cAAuB,EACvB,YAA+B,EAC/B,aAAqC,EACrC,gBAA2C,EAC3C,iBAA6C,EAC9C,EAAE,qBAAqB,GAAG,qBAAqB,CA6hB/C"}
1
+ {"version":3,"file":"use_chat_messages.d.ts","sourceRoot":"","sources":["../../src/hooks/use_chat_messages.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAKH,OAAO,KAAK,EAKV,qBAAqB,EAErB,YAAY,EACb,MAAM,mBAAmB,CAAC;AA0B3B,MAAM,WAAW,qBAAqB;IACpC,4CAA4C;IAC5C,gBAAgB,EAAE,MAAM,CAAC;IACzB,6CAA6C;IAC7C,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,uCAAuC;IACvC,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,6DAA6D;IAC7D,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,8EAA8E;IAC9E,aAAa,CAAC,EAAE,YAAY,CAAC;IAC7B,iGAAiG;IACjG,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,+DAA+D;IAC/D,iBAAiB,CAAC,EAAE,MAAM,CAAC;CAC5B;AAoDD,wBAAgB,eAAe,CAAC,EAC9B,gBAAgB,EAChB,YAAiB,EACjB,cAAuB,EACvB,YAA+B,EAC/B,aAAqC,EACrC,gBAA2C,EAC3C,iBAA6C,GAC9C,EAAE,qBAAqB,GAAG,qBAAqB,CAgqB/C"}
@@ -7,17 +7,33 @@
7
7
  * - Optimistic updates for sent messages
8
8
  * - Soft delete functionality
9
9
  * - Exponential backoff on errors
10
+ * - Abstracted transport layer for future WebSocket/SSE support
10
11
  *
11
12
  * Uses fetch() to call API endpoints instead of direct database access.
12
13
  * This allows the hook to work in client components without Node.js dependencies.
13
14
  */
14
15
  'use client';
15
- import { useState, useEffect, useCallback, useRef } from 'react';
16
- import { DEFAULT_REALTIME_MODE, DEFAULT_POLLING_INTERVAL, DEFAULT_MESSAGES_PER_PAGE, MAX_RETRY_ATTEMPTS, RETRY_BASE_DELAY } from '../lib/constants.js';
16
+ import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
17
+ import { DEFAULT_REALTIME_MODE, DEFAULT_POLLING_INTERVAL, DEFAULT_MESSAGES_PER_PAGE, MAX_RETRY_ATTEMPTS, } from '../lib/constants.js';
18
+ // ============================================================================
19
+ // Constants
20
+ // ============================================================================
21
+ /** Maximum entries in the user profile cache */
22
+ const PROFILE_CACHE_MAX_SIZE = 200;
23
+ /** Cache TTL in milliseconds (30 minutes) */
24
+ const PROFILE_CACHE_TTL = 1000 * 60 * 30;
25
+ /** Maximum polling delay cap in milliseconds */
26
+ const MAX_POLLING_DELAY = 30000;
27
+ // ============================================================================
28
+ // Helper: Generate unique ID for optimistic messages
29
+ // ============================================================================
30
+ function generateOptimisticId() {
31
+ return `optimistic-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
32
+ }
17
33
  // ============================================================================
18
34
  // Hook Implementation
19
35
  // ============================================================================
20
- export function useChatMessages({ receiver_user_id, reference_id = '', reference_type = 'chat', api_base_url = '/api/hazo_chat', realtime_mode = DEFAULT_REALTIME_MODE, polling_interval = DEFAULT_POLLING_INTERVAL, messages_per_page = DEFAULT_MESSAGES_PER_PAGE }) {
36
+ export function useChatMessages({ receiver_user_id, reference_id = '', reference_type = 'chat', api_base_url = '/api/hazo_chat', realtime_mode = DEFAULT_REALTIME_MODE, polling_interval = DEFAULT_POLLING_INTERVAL, messages_per_page = DEFAULT_MESSAGES_PER_PAGE, }) {
21
37
  // -------------------------------------------------------------------------
22
38
  // State
23
39
  // -------------------------------------------------------------------------
@@ -29,13 +45,26 @@ export function useChatMessages({ receiver_user_id, reference_id = '', reference
29
45
  const [polling_status, set_polling_status] = useState('connected');
30
46
  const [current_user_id, set_current_user_id] = useState(null);
31
47
  // -------------------------------------------------------------------------
32
- // Refs
48
+ // Refs (stable across renders)
33
49
  // -------------------------------------------------------------------------
34
- const cursor_ref = useRef(0);
50
+ const cursor_ref = useRef(null);
35
51
  const retry_count_ref = useRef(0);
36
52
  const user_profiles_cache_ref = useRef(new Map());
37
53
  const polling_timer_ref = useRef(null);
38
54
  const is_mounted_ref = useRef(true);
55
+ const is_polling_ref = useRef(false);
56
+ // -------------------------------------------------------------------------
57
+ // Memoized config to prevent effect re-runs
58
+ // -------------------------------------------------------------------------
59
+ const config = useMemo(() => ({
60
+ receiver_user_id,
61
+ reference_id,
62
+ reference_type,
63
+ api_base_url,
64
+ realtime_mode,
65
+ polling_interval,
66
+ messages_per_page,
67
+ }), [receiver_user_id, reference_id, reference_type, api_base_url, realtime_mode, polling_interval, messages_per_page]);
39
68
  // -------------------------------------------------------------------------
40
69
  // Cleanup on unmount
41
70
  // -------------------------------------------------------------------------
@@ -44,29 +73,77 @@ export function useChatMessages({ receiver_user_id, reference_id = '', reference
44
73
  return () => {
45
74
  is_mounted_ref.current = false;
46
75
  if (polling_timer_ref.current) {
47
- clearInterval(polling_timer_ref.current);
76
+ clearTimeout(polling_timer_ref.current);
77
+ polling_timer_ref.current = null;
48
78
  }
49
79
  };
50
80
  }, []);
51
81
  // -------------------------------------------------------------------------
52
- // Fetch user profiles via API
82
+ // User profile cache with TTL and eviction
83
+ // -------------------------------------------------------------------------
84
+ const get_cached_profile = useCallback((user_id) => {
85
+ const entry = user_profiles_cache_ref.current.get(user_id);
86
+ if (!entry)
87
+ return null;
88
+ // Check TTL
89
+ if (Date.now() - entry.timestamp > PROFILE_CACHE_TTL) {
90
+ user_profiles_cache_ref.current.delete(user_id);
91
+ return null;
92
+ }
93
+ return entry.value;
94
+ }, []);
95
+ const set_cached_profile = useCallback((profile) => {
96
+ // Evict oldest entries if cache is too large
97
+ if (user_profiles_cache_ref.current.size >= PROFILE_CACHE_MAX_SIZE) {
98
+ // Find and remove oldest entry
99
+ let oldest_key = null;
100
+ let oldest_timestamp = Infinity;
101
+ user_profiles_cache_ref.current.forEach((entry, key) => {
102
+ if (entry.timestamp < oldest_timestamp) {
103
+ oldest_timestamp = entry.timestamp;
104
+ oldest_key = key;
105
+ }
106
+ });
107
+ if (oldest_key) {
108
+ user_profiles_cache_ref.current.delete(oldest_key);
109
+ }
110
+ }
111
+ user_profiles_cache_ref.current.set(profile.id, {
112
+ value: profile,
113
+ timestamp: Date.now(),
114
+ });
115
+ }, []);
116
+ // -------------------------------------------------------------------------
117
+ // Fetch user profiles via API (with cache)
53
118
  // -------------------------------------------------------------------------
54
119
  const fetch_user_profiles = useCallback(async (user_ids) => {
55
- const uncached_ids = user_ids.filter((id) => !user_profiles_cache_ref.current.has(id));
120
+ const result = new Map();
121
+ const uncached_ids = [];
122
+ // Check cache first
123
+ user_ids.forEach((id) => {
124
+ const cached = get_cached_profile(id);
125
+ if (cached) {
126
+ result.set(id, cached);
127
+ }
128
+ else {
129
+ uncached_ids.push(id);
130
+ }
131
+ });
132
+ // Fetch uncached profiles
56
133
  if (uncached_ids.length > 0) {
57
134
  try {
58
- // Use the hazo_auth profiles endpoint
59
135
  const response = await fetch('/api/hazo_auth/profiles', {
60
136
  method: 'POST',
61
137
  headers: { 'Content-Type': 'application/json' },
62
138
  body: JSON.stringify({ user_ids: uncached_ids }),
63
- credentials: 'include'
139
+ credentials: 'include',
64
140
  });
65
141
  if (response.ok) {
66
142
  const data = await response.json();
67
143
  if (data.success && data.profiles) {
68
144
  data.profiles.forEach((profile) => {
69
- user_profiles_cache_ref.current.set(profile.id, profile);
145
+ set_cached_profile(profile);
146
+ result.set(profile.id, profile);
70
147
  });
71
148
  }
72
149
  }
@@ -75,8 +152,8 @@ export function useChatMessages({ receiver_user_id, reference_id = '', reference
75
152
  console.error('[useChatMessages] Failed to fetch user profiles:', err);
76
153
  }
77
154
  }
78
- return user_profiles_cache_ref.current;
79
- }, []);
155
+ return result;
156
+ }, [get_cached_profile, set_cached_profile]);
80
157
  // -------------------------------------------------------------------------
81
158
  // Transform DB messages to ChatMessage
82
159
  // -------------------------------------------------------------------------
@@ -95,24 +172,44 @@ export function useChatMessages({ receiver_user_id, reference_id = '', reference
95
172
  sender_profile: profiles.get(msg.sender_user_id),
96
173
  receiver_profile: profiles.get(msg.receiver_user_id),
97
174
  is_sender: msg.sender_user_id === user_id,
98
- send_status: 'sent'
175
+ send_status: 'sent',
99
176
  }));
100
177
  }, [fetch_user_profiles]);
101
178
  // -------------------------------------------------------------------------
102
- // Fetch messages via API
179
+ // Calculate polling delay with exponential backoff
103
180
  // -------------------------------------------------------------------------
104
- const fetch_messages_from_api = useCallback(async () => {
105
- if (!receiver_user_id) {
106
- return { messages: [], user_id: null };
181
+ const get_poll_delay = useCallback(() => {
182
+ if (retry_count_ref.current === 0) {
183
+ return config.polling_interval;
184
+ }
185
+ // Exponential backoff: interval * 2^retryCount, capped at MAX_POLLING_DELAY
186
+ const delay = config.polling_interval * Math.pow(2, retry_count_ref.current);
187
+ return Math.min(delay, MAX_POLLING_DELAY);
188
+ }, [config.polling_interval]);
189
+ // -------------------------------------------------------------------------
190
+ // Fetch messages via API with optional pagination
191
+ // -------------------------------------------------------------------------
192
+ const fetch_messages_from_api = useCallback(async (options) => {
193
+ if (!config.receiver_user_id) {
194
+ return { messages: [], user_id: null, has_more: false, next_cursor: null };
107
195
  }
108
196
  try {
109
197
  const params = new URLSearchParams({
110
- receiver_user_id,
111
- ...(reference_id && { reference_id }),
112
- ...(reference_type && { reference_type }),
198
+ receiver_user_id: config.receiver_user_id,
199
+ limit: String(options?.limit || config.messages_per_page),
113
200
  });
114
- const response = await fetch(`${api_base_url}/messages?${params.toString()}`, {
115
- credentials: 'include'
201
+ if (config.reference_id) {
202
+ params.set('reference_id', config.reference_id);
203
+ }
204
+ if (config.reference_type) {
205
+ params.set('reference_type', config.reference_type);
206
+ }
207
+ if (options?.cursor) {
208
+ params.set('cursor', options.cursor);
209
+ params.set('direction', options.direction || 'older');
210
+ }
211
+ const response = await fetch(`${config.api_base_url}/messages?${params.toString()}`, {
212
+ credentials: 'include',
116
213
  });
117
214
  if (!response.ok) {
118
215
  throw new Error(`HTTP ${response.status}`);
@@ -121,7 +218,9 @@ export function useChatMessages({ receiver_user_id, reference_id = '', reference
121
218
  if (data.success) {
122
219
  return {
123
220
  messages: data.messages || [],
124
- user_id: data.current_user_id || null
221
+ user_id: data.current_user_id || null,
222
+ has_more: data.pagination?.has_more ?? false,
223
+ next_cursor: data.pagination?.next_cursor ?? null,
125
224
  };
126
225
  }
127
226
  else {
@@ -132,29 +231,35 @@ export function useChatMessages({ receiver_user_id, reference_id = '', reference
132
231
  console.error('[useChatMessages] Fetch error:', err);
133
232
  throw err;
134
233
  }
135
- }, [receiver_user_id, reference_id, reference_type, api_base_url]);
234
+ }, [config]);
136
235
  // -------------------------------------------------------------------------
137
236
  // Initial load
138
237
  // -------------------------------------------------------------------------
139
238
  const load_initial = useCallback(async () => {
140
- if (!receiver_user_id) {
239
+ if (!config.receiver_user_id) {
141
240
  set_is_loading(false);
142
241
  return;
143
242
  }
144
243
  set_is_loading(true);
145
244
  set_error(null);
146
245
  try {
147
- const { messages: db_messages, user_id } = await fetch_messages_from_api();
148
- if (user_id && is_mounted_ref.current) {
149
- set_current_user_id(user_id);
246
+ const result = await fetch_messages_from_api();
247
+ if (result.user_id && is_mounted_ref.current) {
248
+ set_current_user_id(result.user_id);
150
249
  }
151
250
  if (is_mounted_ref.current) {
152
- const transformed = user_id
153
- ? await transform_messages(db_messages, user_id)
154
- : db_messages.map(msg => ({ ...msg, is_sender: false, send_status: 'sent' }));
155
- set_messages(transformed);
156
- set_has_more(db_messages.length >= messages_per_page);
157
- cursor_ref.current = db_messages.length;
251
+ const transformed = result.user_id
252
+ ? await transform_messages(result.messages, result.user_id)
253
+ : result.messages.map((msg) => ({
254
+ ...msg,
255
+ is_sender: false,
256
+ send_status: 'sent',
257
+ }));
258
+ // Sort messages in ascending order (oldest first, newest last)
259
+ const sorted = transformed.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime());
260
+ set_messages(sorted);
261
+ set_has_more(result.has_more);
262
+ cursor_ref.current = result.next_cursor;
158
263
  retry_count_ref.current = 0;
159
264
  set_polling_status('connected');
160
265
  }
@@ -170,23 +275,32 @@ export function useChatMessages({ receiver_user_id, reference_id = '', reference
170
275
  set_is_loading(false);
171
276
  }
172
277
  }
173
- }, [receiver_user_id, fetch_messages_from_api, transform_messages, messages_per_page]);
278
+ }, [config.receiver_user_id, fetch_messages_from_api, transform_messages]);
174
279
  // -------------------------------------------------------------------------
175
- // Load more (pagination) - Note: simplified, API should support pagination params
280
+ // Load more (pagination)
176
281
  // -------------------------------------------------------------------------
177
282
  const load_more = useCallback(async () => {
178
- if (!current_user_id || !has_more || is_loading_more) {
283
+ if (!current_user_id || !has_more || is_loading_more || !cursor_ref.current) {
179
284
  return;
180
285
  }
181
286
  set_is_loading_more(true);
182
287
  try {
183
- // For now, reload all messages - pagination can be added to API later
184
- const { messages: db_messages } = await fetch_messages_from_api();
185
- const transformed = await transform_messages(db_messages, current_user_id);
288
+ const result = await fetch_messages_from_api({
289
+ cursor: cursor_ref.current,
290
+ direction: 'older',
291
+ });
292
+ const transformed = await transform_messages(result.messages, current_user_id);
186
293
  if (is_mounted_ref.current) {
187
- set_messages(transformed);
188
- set_has_more(false); // Simplified - loaded all
189
- cursor_ref.current = db_messages.length;
294
+ set_messages((prev) => {
295
+ // Prepend older messages and sort
296
+ const combined = [...transformed, ...prev];
297
+ return combined.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime());
298
+ });
299
+ set_has_more(result.has_more);
300
+ // Update cursor to oldest message for next load_more
301
+ if (result.messages.length > 0) {
302
+ cursor_ref.current = result.messages[0].created_at;
303
+ }
190
304
  }
191
305
  }
192
306
  catch (err) {
@@ -199,88 +313,87 @@ export function useChatMessages({ receiver_user_id, reference_id = '', reference
199
313
  }
200
314
  }, [current_user_id, has_more, is_loading_more, fetch_messages_from_api, transform_messages]);
201
315
  // -------------------------------------------------------------------------
202
- // Poll for new messages
316
+ // Poll for new messages (uses setTimeout for proper backoff)
203
317
  // -------------------------------------------------------------------------
204
- const poll_for_new_messages = useCallback(async () => {
205
- if (!current_user_id || !receiver_user_id) {
318
+ const schedule_next_poll = useCallback(() => {
319
+ if (!is_mounted_ref.current || config.realtime_mode !== 'polling' || !config.receiver_user_id) {
206
320
  return;
207
321
  }
208
- try {
209
- const { messages: db_messages } = await fetch_messages_from_api();
210
- if (db_messages.length > 0 && is_mounted_ref.current) {
211
- const transformed = await transform_messages(db_messages, current_user_id);
212
- set_messages((prev) => {
213
- // Merge new messages, avoiding duplicates
214
- const existing_ids = new Set(prev.map(m => m.id));
215
- const new_messages = transformed.filter(m => !existing_ids.has(m.id));
216
- if (new_messages.length > 0) {
217
- // Combine and sort by created_at
218
- const combined = [...prev, ...new_messages];
219
- combined.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime());
220
- return combined;
221
- }
222
- return prev;
223
- });
322
+ const delay = get_poll_delay();
323
+ polling_timer_ref.current = setTimeout(async () => {
324
+ if (!is_mounted_ref.current || is_polling_ref.current) {
325
+ return;
224
326
  }
225
- retry_count_ref.current = 0;
226
- if (is_mounted_ref.current) {
227
- set_polling_status('connected');
327
+ is_polling_ref.current = true;
328
+ try {
329
+ // Fetch only newer messages since our latest
330
+ const latest_message = messages[messages.length - 1];
331
+ const result = await fetch_messages_from_api(latest_message
332
+ ? { cursor: latest_message.created_at, direction: 'newer', limit: 50 }
333
+ : undefined);
334
+ if (result.messages.length > 0 && is_mounted_ref.current && current_user_id) {
335
+ const transformed = await transform_messages(result.messages, current_user_id);
336
+ set_messages((prev) => {
337
+ // Merge new messages, avoiding duplicates
338
+ const existing_ids = new Set(prev.map((m) => m.id));
339
+ const new_messages = transformed.filter((m) => !existing_ids.has(m.id));
340
+ if (new_messages.length > 0) {
341
+ const combined = [...prev, ...new_messages];
342
+ return combined.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime());
343
+ }
344
+ return prev;
345
+ });
346
+ }
347
+ // Reset retry count on success
348
+ retry_count_ref.current = 0;
349
+ if (is_mounted_ref.current) {
350
+ set_polling_status('connected');
351
+ }
228
352
  }
229
- }
230
- catch (err) {
231
- console.error('[useChatMessages] Polling error:', err);
232
- retry_count_ref.current += 1;
233
- if (is_mounted_ref.current) {
234
- if (retry_count_ref.current >= MAX_RETRY_ATTEMPTS) {
235
- set_polling_status('error');
353
+ catch (err) {
354
+ console.error('[useChatMessages] Polling error:', err);
355
+ retry_count_ref.current += 1;
356
+ if (is_mounted_ref.current) {
357
+ if (retry_count_ref.current >= MAX_RETRY_ATTEMPTS) {
358
+ set_polling_status('error');
359
+ }
360
+ else {
361
+ set_polling_status('reconnecting');
362
+ }
236
363
  }
237
- else {
238
- set_polling_status('reconnecting');
364
+ }
365
+ finally {
366
+ is_polling_ref.current = false;
367
+ // Schedule next poll with updated delay (backoff applied via get_poll_delay)
368
+ if (is_mounted_ref.current && config.realtime_mode === 'polling') {
369
+ schedule_next_poll();
239
370
  }
240
371
  }
241
- }
242
- }, [current_user_id, receiver_user_id, fetch_messages_from_api, transform_messages]);
372
+ }, delay);
373
+ }, [config.realtime_mode, config.receiver_user_id, get_poll_delay, fetch_messages_from_api, transform_messages, messages, current_user_id]);
243
374
  // -------------------------------------------------------------------------
244
- // Start polling (only if realtime_mode is 'polling')
375
+ // Start/stop polling based on realtime_mode
245
376
  // -------------------------------------------------------------------------
246
377
  useEffect(() => {
247
- // Only start polling if mode is 'polling'
248
- if (realtime_mode !== 'polling' || !receiver_user_id) {
249
- // Clear any existing timer if switching to manual mode
250
- if (polling_timer_ref.current) {
251
- clearInterval(polling_timer_ref.current);
252
- polling_timer_ref.current = null;
253
- }
254
- // Set status to connected for manual mode (no polling needed)
255
- if (realtime_mode === 'manual') {
256
- set_polling_status('connected');
257
- }
258
- return;
259
- }
260
378
  // Clear any existing timer
261
379
  if (polling_timer_ref.current) {
262
- clearInterval(polling_timer_ref.current);
380
+ clearTimeout(polling_timer_ref.current);
381
+ polling_timer_ref.current = null;
382
+ }
383
+ // Only start polling if mode is 'polling' and we have a receiver
384
+ if (config.realtime_mode === 'polling' && config.receiver_user_id && current_user_id) {
385
+ schedule_next_poll();
386
+ }
387
+ else if (config.realtime_mode === 'manual') {
388
+ set_polling_status('connected');
263
389
  }
264
- // Calculate delay with exponential backoff
265
- const get_poll_delay = () => {
266
- if (retry_count_ref.current === 0) {
267
- return polling_interval;
268
- }
269
- return Math.min(polling_interval * Math.pow(2, retry_count_ref.current), RETRY_BASE_DELAY * 30 // Cap at 30 seconds
270
- );
271
- };
272
- const start_polling = () => {
273
- polling_timer_ref.current = setInterval(() => {
274
- poll_for_new_messages();
275
- }, get_poll_delay());
276
- };
277
- start_polling();
278
390
  return () => {
279
391
  if (polling_timer_ref.current) {
280
- clearInterval(polling_timer_ref.current);
392
+ clearTimeout(polling_timer_ref.current);
393
+ polling_timer_ref.current = null;
281
394
  }
282
395
  };
283
- }, [receiver_user_id, realtime_mode, polling_interval, poll_for_new_messages]);
396
+ }, [config.realtime_mode, config.receiver_user_id, current_user_id, schedule_next_poll]);
284
397
  // -------------------------------------------------------------------------
285
398
  // Initial load effect
286
399
  // -------------------------------------------------------------------------
@@ -295,8 +408,10 @@ export function useChatMessages({ receiver_user_id, reference_id = '', reference
295
408
  set_error('Not authenticated');
296
409
  return false;
297
410
  }
298
- // Create optimistic message
299
- const optimistic_id = `optimistic-${Date.now()}`;
411
+ // Create optimistic message with unique ID
412
+ const optimistic_id = generateOptimisticId();
413
+ const sender_profile = get_cached_profile(current_user_id);
414
+ const receiver_profile = get_cached_profile(payload.receiver_user_id);
300
415
  const optimistic_message = {
301
416
  id: optimistic_id,
302
417
  reference_id: payload.reference_id,
@@ -309,15 +424,18 @@ export function useChatMessages({ receiver_user_id, reference_id = '', reference
309
424
  deleted_at: null,
310
425
  created_at: new Date().toISOString(),
311
426
  changed_at: new Date().toISOString(),
312
- sender_profile: user_profiles_cache_ref.current.get(current_user_id),
313
- receiver_profile: user_profiles_cache_ref.current.get(payload.receiver_user_id),
427
+ sender_profile: sender_profile || undefined,
428
+ receiver_profile: receiver_profile || undefined,
314
429
  is_sender: true,
315
- send_status: 'sending'
430
+ send_status: 'sending',
316
431
  };
317
432
  // Add optimistic message to state
318
- set_messages((prev) => [...prev, optimistic_message]);
433
+ set_messages((prev) => {
434
+ const updated = [...prev, optimistic_message];
435
+ return updated.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime());
436
+ });
319
437
  try {
320
- const response = await fetch(`${api_base_url}/messages`, {
438
+ const response = await fetch(`${config.api_base_url}/messages`, {
321
439
  method: 'POST',
322
440
  headers: { 'Content-Type': 'application/json' },
323
441
  credentials: 'include',
@@ -333,27 +451,25 @@ export function useChatMessages({ receiver_user_id, reference_id = '', reference
333
451
  // Replace optimistic message with real one
334
452
  const real_message = {
335
453
  ...data.message,
336
- // Ensure all required fields are set with proper defaults
337
454
  reference_list: data.message.reference_list ?? null,
338
455
  read_at: data.message.read_at ?? null,
339
456
  deleted_at: data.message.deleted_at ?? null,
340
457
  changed_at: data.message.changed_at ?? data.message.created_at,
341
- sender_profile: user_profiles_cache_ref.current.get(current_user_id),
342
- receiver_profile: user_profiles_cache_ref.current.get(payload.receiver_user_id),
458
+ sender_profile: sender_profile || undefined,
459
+ receiver_profile: receiver_profile || undefined,
343
460
  is_sender: true,
344
- send_status: 'sent'
461
+ send_status: 'sent',
345
462
  };
346
463
  set_messages((prev) => {
347
464
  // Check if real message already exists (from polling)
348
- const real_message_exists = prev.some(msg => msg.id === real_message.id);
465
+ const real_message_exists = prev.some((msg) => msg.id === real_message.id);
349
466
  if (real_message_exists) {
350
467
  // Real message already exists from polling, just remove optimistic one
351
- return prev.filter(msg => msg.id !== optimistic_id);
468
+ return prev.filter((msg) => msg.id !== optimistic_id);
352
469
  }
353
470
  else {
354
471
  // Replace optimistic message with real one
355
472
  const replaced = prev.map((msg) => msg.id === optimistic_id ? real_message : msg);
356
- // Sort to ensure correct chronological order
357
473
  return replaced.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime());
358
474
  }
359
475
  });
@@ -367,13 +483,11 @@ export function useChatMessages({ receiver_user_id, reference_id = '', reference
367
483
  console.error('[useChatMessages] Send error:', err);
368
484
  // Mark optimistic message as failed
369
485
  if (is_mounted_ref.current) {
370
- set_messages((prev) => prev.map((msg) => msg.id === optimistic_id
371
- ? { ...msg, send_status: 'failed' }
372
- : msg));
486
+ set_messages((prev) => prev.map((msg) => msg.id === optimistic_id ? { ...msg, send_status: 'failed' } : msg));
373
487
  }
374
488
  return false;
375
489
  }
376
- }, [current_user_id, api_base_url]);
490
+ }, [current_user_id, config.api_base_url, get_cached_profile]);
377
491
  // -------------------------------------------------------------------------
378
492
  // Delete message (soft delete) via API
379
493
  // -------------------------------------------------------------------------
@@ -387,14 +501,17 @@ export function useChatMessages({ receiver_user_id, reference_id = '', reference
387
501
  set_error('Cannot delete this message');
388
502
  return false;
389
503
  }
504
+ // Store original values for rollback
505
+ const original_deleted_at = message.deleted_at;
506
+ const original_message_text = message.message_text;
390
507
  // Optimistic update
391
508
  set_messages((prev) => prev.map((msg) => msg.id === message_id
392
509
  ? { ...msg, deleted_at: new Date().toISOString(), message_text: null }
393
510
  : msg));
394
511
  try {
395
- const response = await fetch(`${api_base_url}/messages/${message_id}`, {
512
+ const response = await fetch(`${config.api_base_url}/messages/${message_id}`, {
396
513
  method: 'DELETE',
397
- credentials: 'include'
514
+ credentials: 'include',
398
515
  });
399
516
  if (!response.ok) {
400
517
  throw new Error('Failed to delete message');
@@ -406,12 +523,12 @@ export function useChatMessages({ receiver_user_id, reference_id = '', reference
406
523
  // Rollback on error
407
524
  if (is_mounted_ref.current) {
408
525
  set_messages((prev) => prev.map((msg) => msg.id === message_id
409
- ? { ...msg, deleted_at: message.deleted_at, message_text: message.message_text }
526
+ ? { ...msg, deleted_at: original_deleted_at, message_text: original_message_text }
410
527
  : msg));
411
528
  }
412
529
  return false;
413
530
  }
414
- }, [current_user_id, messages, api_base_url]);
531
+ }, [current_user_id, messages, config.api_base_url]);
415
532
  // -------------------------------------------------------------------------
416
533
  // Mark as read via API
417
534
  // -------------------------------------------------------------------------
@@ -424,25 +541,31 @@ export function useChatMessages({ receiver_user_id, reference_id = '', reference
424
541
  return;
425
542
  }
426
543
  try {
427
- const response = await fetch(`${api_base_url}/messages/${message_id}/read`, {
544
+ const response = await fetch(`${config.api_base_url}/messages/${message_id}/read`, {
428
545
  method: 'PATCH',
429
- credentials: 'include'
546
+ credentials: 'include',
430
547
  });
431
- if (response.ok && is_mounted_ref.current) {
548
+ if (!response.ok) {
549
+ console.error('[useChatMessages] Mark as read failed:', response.status);
550
+ return;
551
+ }
552
+ const data = await response.json();
553
+ if (data.success && is_mounted_ref.current) {
432
554
  set_messages((prev) => prev.map((msg) => msg.id === message_id
433
- ? { ...msg, read_at: new Date().toISOString() }
555
+ ? { ...msg, read_at: data.message?.read_at || new Date().toISOString() }
434
556
  : msg));
435
557
  }
436
558
  }
437
559
  catch (err) {
438
560
  console.error('[useChatMessages] Mark as read error:', err);
439
561
  }
440
- }, [current_user_id, messages, api_base_url]);
562
+ }, [current_user_id, messages, config.api_base_url]);
441
563
  // -------------------------------------------------------------------------
442
564
  // Refresh
443
565
  // -------------------------------------------------------------------------
444
566
  const refresh = useCallback(() => {
445
- cursor_ref.current = 0;
567
+ cursor_ref.current = null;
568
+ retry_count_ref.current = 0;
446
569
  set_messages([]);
447
570
  load_initial();
448
571
  }, [load_initial]);