hazo_chat 2.1.0 → 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.
- package/SETUP_CHECKLIST.md +229 -10
- package/dist/api/index.d.ts +17 -2
- package/dist/api/index.d.ts.map +1 -1
- package/dist/api/index.js +16 -1
- package/dist/api/index.js.map +1 -1
- package/dist/api/messages.d.ts +34 -1
- package/dist/api/messages.d.ts.map +1 -1
- package/dist/api/messages.js +263 -21
- package/dist/api/messages.js.map +1 -1
- package/dist/api/types.d.ts +25 -0
- package/dist/api/types.d.ts.map +1 -1
- package/dist/components/hazo_chat/hazo_chat.d.ts.map +1 -1
- package/dist/components/hazo_chat/hazo_chat.js +17 -9
- package/dist/components/hazo_chat/hazo_chat.js.map +1 -1
- package/dist/components/hazo_chat/hazo_chat_header.d.ts.map +1 -1
- package/dist/components/hazo_chat/hazo_chat_header.js +17 -4
- package/dist/components/hazo_chat/hazo_chat_header.js.map +1 -1
- package/dist/components/hazo_chat/hazo_chat_messages.d.ts +5 -4
- package/dist/components/hazo_chat/hazo_chat_messages.d.ts.map +1 -1
- package/dist/components/hazo_chat/hazo_chat_messages.js +45 -5
- package/dist/components/hazo_chat/hazo_chat_messages.js.map +1 -1
- package/dist/hooks/use_chat_messages.d.ts +3 -3
- package/dist/hooks/use_chat_messages.d.ts.map +1 -1
- package/dist/hooks/use_chat_messages.js +247 -143
- package/dist/hooks/use_chat_messages.js.map +1 -1
- package/dist/types/index.d.ts +107 -2
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +1 -1
|
@@ -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,
|
|
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(
|
|
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
|
-
|
|
76
|
+
clearTimeout(polling_timer_ref.current);
|
|
77
|
+
polling_timer_ref.current = null;
|
|
48
78
|
}
|
|
49
79
|
};
|
|
50
80
|
}, []);
|
|
51
81
|
// -------------------------------------------------------------------------
|
|
52
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
179
|
+
// Calculate polling delay with exponential backoff
|
|
180
|
+
// -------------------------------------------------------------------------
|
|
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
|
|
103
191
|
// -------------------------------------------------------------------------
|
|
104
|
-
const fetch_messages_from_api = useCallback(async () => {
|
|
105
|
-
if (!receiver_user_id) {
|
|
106
|
-
return { messages: [], user_id: null };
|
|
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
|
-
|
|
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
|
-
|
|
115
|
-
|
|
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,31 +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
|
-
}, [
|
|
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
|
|
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(
|
|
154
|
-
:
|
|
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
|
+
}));
|
|
155
258
|
// Sort messages in ascending order (oldest first, newest last)
|
|
156
259
|
const sorted = transformed.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime());
|
|
157
260
|
set_messages(sorted);
|
|
158
|
-
set_has_more(
|
|
159
|
-
cursor_ref.current =
|
|
261
|
+
set_has_more(result.has_more);
|
|
262
|
+
cursor_ref.current = result.next_cursor;
|
|
160
263
|
retry_count_ref.current = 0;
|
|
161
264
|
set_polling_status('connected');
|
|
162
265
|
}
|
|
@@ -172,25 +275,32 @@ export function useChatMessages({ receiver_user_id, reference_id = '', reference
|
|
|
172
275
|
set_is_loading(false);
|
|
173
276
|
}
|
|
174
277
|
}
|
|
175
|
-
}, [receiver_user_id, fetch_messages_from_api, transform_messages
|
|
278
|
+
}, [config.receiver_user_id, fetch_messages_from_api, transform_messages]);
|
|
176
279
|
// -------------------------------------------------------------------------
|
|
177
|
-
// Load more (pagination)
|
|
280
|
+
// Load more (pagination)
|
|
178
281
|
// -------------------------------------------------------------------------
|
|
179
282
|
const load_more = useCallback(async () => {
|
|
180
|
-
if (!current_user_id || !has_more || is_loading_more) {
|
|
283
|
+
if (!current_user_id || !has_more || is_loading_more || !cursor_ref.current) {
|
|
181
284
|
return;
|
|
182
285
|
}
|
|
183
286
|
set_is_loading_more(true);
|
|
184
287
|
try {
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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);
|
|
188
293
|
if (is_mounted_ref.current) {
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
+
}
|
|
194
304
|
}
|
|
195
305
|
}
|
|
196
306
|
catch (err) {
|
|
@@ -203,88 +313,87 @@ export function useChatMessages({ receiver_user_id, reference_id = '', reference
|
|
|
203
313
|
}
|
|
204
314
|
}, [current_user_id, has_more, is_loading_more, fetch_messages_from_api, transform_messages]);
|
|
205
315
|
// -------------------------------------------------------------------------
|
|
206
|
-
// Poll for new messages
|
|
316
|
+
// Poll for new messages (uses setTimeout for proper backoff)
|
|
207
317
|
// -------------------------------------------------------------------------
|
|
208
|
-
const
|
|
209
|
-
if (!
|
|
318
|
+
const schedule_next_poll = useCallback(() => {
|
|
319
|
+
if (!is_mounted_ref.current || config.realtime_mode !== 'polling' || !config.receiver_user_id) {
|
|
210
320
|
return;
|
|
211
321
|
}
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
if (
|
|
215
|
-
|
|
216
|
-
set_messages((prev) => {
|
|
217
|
-
// Merge new messages, avoiding duplicates
|
|
218
|
-
const existing_ids = new Set(prev.map(m => m.id));
|
|
219
|
-
const new_messages = transformed.filter(m => !existing_ids.has(m.id));
|
|
220
|
-
if (new_messages.length > 0) {
|
|
221
|
-
// Combine and sort by created_at
|
|
222
|
-
const combined = [...prev, ...new_messages];
|
|
223
|
-
combined.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime());
|
|
224
|
-
return combined;
|
|
225
|
-
}
|
|
226
|
-
return prev;
|
|
227
|
-
});
|
|
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;
|
|
228
326
|
}
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
+
}
|
|
232
352
|
}
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
+
}
|
|
240
363
|
}
|
|
241
|
-
|
|
242
|
-
|
|
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();
|
|
243
370
|
}
|
|
244
371
|
}
|
|
245
|
-
}
|
|
246
|
-
}, [
|
|
372
|
+
}, delay);
|
|
373
|
+
}, [config.realtime_mode, config.receiver_user_id, get_poll_delay, fetch_messages_from_api, transform_messages, messages, current_user_id]);
|
|
247
374
|
// -------------------------------------------------------------------------
|
|
248
|
-
// Start polling
|
|
375
|
+
// Start/stop polling based on realtime_mode
|
|
249
376
|
// -------------------------------------------------------------------------
|
|
250
377
|
useEffect(() => {
|
|
251
|
-
// Only start polling if mode is 'polling'
|
|
252
|
-
if (realtime_mode !== 'polling' || !receiver_user_id) {
|
|
253
|
-
// Clear any existing timer if switching to manual mode
|
|
254
|
-
if (polling_timer_ref.current) {
|
|
255
|
-
clearInterval(polling_timer_ref.current);
|
|
256
|
-
polling_timer_ref.current = null;
|
|
257
|
-
}
|
|
258
|
-
// Set status to connected for manual mode (no polling needed)
|
|
259
|
-
if (realtime_mode === 'manual') {
|
|
260
|
-
set_polling_status('connected');
|
|
261
|
-
}
|
|
262
|
-
return;
|
|
263
|
-
}
|
|
264
378
|
// Clear any existing timer
|
|
265
379
|
if (polling_timer_ref.current) {
|
|
266
|
-
|
|
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');
|
|
267
389
|
}
|
|
268
|
-
// Calculate delay with exponential backoff
|
|
269
|
-
const get_poll_delay = () => {
|
|
270
|
-
if (retry_count_ref.current === 0) {
|
|
271
|
-
return polling_interval;
|
|
272
|
-
}
|
|
273
|
-
return Math.min(polling_interval * Math.pow(2, retry_count_ref.current), RETRY_BASE_DELAY * 30 // Cap at 30 seconds
|
|
274
|
-
);
|
|
275
|
-
};
|
|
276
|
-
const start_polling = () => {
|
|
277
|
-
polling_timer_ref.current = setInterval(() => {
|
|
278
|
-
poll_for_new_messages();
|
|
279
|
-
}, get_poll_delay());
|
|
280
|
-
};
|
|
281
|
-
start_polling();
|
|
282
390
|
return () => {
|
|
283
391
|
if (polling_timer_ref.current) {
|
|
284
|
-
|
|
392
|
+
clearTimeout(polling_timer_ref.current);
|
|
393
|
+
polling_timer_ref.current = null;
|
|
285
394
|
}
|
|
286
395
|
};
|
|
287
|
-
}, [
|
|
396
|
+
}, [config.realtime_mode, config.receiver_user_id, current_user_id, schedule_next_poll]);
|
|
288
397
|
// -------------------------------------------------------------------------
|
|
289
398
|
// Initial load effect
|
|
290
399
|
// -------------------------------------------------------------------------
|
|
@@ -299,8 +408,10 @@ export function useChatMessages({ receiver_user_id, reference_id = '', reference
|
|
|
299
408
|
set_error('Not authenticated');
|
|
300
409
|
return false;
|
|
301
410
|
}
|
|
302
|
-
// Create optimistic message
|
|
303
|
-
const optimistic_id =
|
|
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);
|
|
304
415
|
const optimistic_message = {
|
|
305
416
|
id: optimistic_id,
|
|
306
417
|
reference_id: payload.reference_id,
|
|
@@ -313,19 +424,18 @@ export function useChatMessages({ receiver_user_id, reference_id = '', reference
|
|
|
313
424
|
deleted_at: null,
|
|
314
425
|
created_at: new Date().toISOString(),
|
|
315
426
|
changed_at: new Date().toISOString(),
|
|
316
|
-
sender_profile:
|
|
317
|
-
receiver_profile:
|
|
427
|
+
sender_profile: sender_profile || undefined,
|
|
428
|
+
receiver_profile: receiver_profile || undefined,
|
|
318
429
|
is_sender: true,
|
|
319
|
-
send_status: 'sending'
|
|
430
|
+
send_status: 'sending',
|
|
320
431
|
};
|
|
321
|
-
// Add optimistic message to state
|
|
432
|
+
// Add optimistic message to state
|
|
322
433
|
set_messages((prev) => {
|
|
323
434
|
const updated = [...prev, optimistic_message];
|
|
324
|
-
// Sort to maintain chronological order
|
|
325
435
|
return updated.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime());
|
|
326
436
|
});
|
|
327
437
|
try {
|
|
328
|
-
const response = await fetch(`${api_base_url}/messages`, {
|
|
438
|
+
const response = await fetch(`${config.api_base_url}/messages`, {
|
|
329
439
|
method: 'POST',
|
|
330
440
|
headers: { 'Content-Type': 'application/json' },
|
|
331
441
|
credentials: 'include',
|
|
@@ -341,27 +451,25 @@ export function useChatMessages({ receiver_user_id, reference_id = '', reference
|
|
|
341
451
|
// Replace optimistic message with real one
|
|
342
452
|
const real_message = {
|
|
343
453
|
...data.message,
|
|
344
|
-
// Ensure all required fields are set with proper defaults
|
|
345
454
|
reference_list: data.message.reference_list ?? null,
|
|
346
455
|
read_at: data.message.read_at ?? null,
|
|
347
456
|
deleted_at: data.message.deleted_at ?? null,
|
|
348
457
|
changed_at: data.message.changed_at ?? data.message.created_at,
|
|
349
|
-
sender_profile:
|
|
350
|
-
receiver_profile:
|
|
458
|
+
sender_profile: sender_profile || undefined,
|
|
459
|
+
receiver_profile: receiver_profile || undefined,
|
|
351
460
|
is_sender: true,
|
|
352
|
-
send_status: 'sent'
|
|
461
|
+
send_status: 'sent',
|
|
353
462
|
};
|
|
354
463
|
set_messages((prev) => {
|
|
355
464
|
// Check if real message already exists (from polling)
|
|
356
|
-
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);
|
|
357
466
|
if (real_message_exists) {
|
|
358
467
|
// Real message already exists from polling, just remove optimistic one
|
|
359
|
-
return prev.filter(msg => msg.id !== optimistic_id);
|
|
468
|
+
return prev.filter((msg) => msg.id !== optimistic_id);
|
|
360
469
|
}
|
|
361
470
|
else {
|
|
362
471
|
// Replace optimistic message with real one
|
|
363
472
|
const replaced = prev.map((msg) => msg.id === optimistic_id ? real_message : msg);
|
|
364
|
-
// Sort to ensure correct chronological order
|
|
365
473
|
return replaced.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime());
|
|
366
474
|
}
|
|
367
475
|
});
|
|
@@ -375,13 +483,11 @@ export function useChatMessages({ receiver_user_id, reference_id = '', reference
|
|
|
375
483
|
console.error('[useChatMessages] Send error:', err);
|
|
376
484
|
// Mark optimistic message as failed
|
|
377
485
|
if (is_mounted_ref.current) {
|
|
378
|
-
set_messages((prev) => prev.map((msg) => msg.id === optimistic_id
|
|
379
|
-
? { ...msg, send_status: 'failed' }
|
|
380
|
-
: msg));
|
|
486
|
+
set_messages((prev) => prev.map((msg) => msg.id === optimistic_id ? { ...msg, send_status: 'failed' } : msg));
|
|
381
487
|
}
|
|
382
488
|
return false;
|
|
383
489
|
}
|
|
384
|
-
}, [current_user_id, api_base_url]);
|
|
490
|
+
}, [current_user_id, config.api_base_url, get_cached_profile]);
|
|
385
491
|
// -------------------------------------------------------------------------
|
|
386
492
|
// Delete message (soft delete) via API
|
|
387
493
|
// -------------------------------------------------------------------------
|
|
@@ -395,14 +501,17 @@ export function useChatMessages({ receiver_user_id, reference_id = '', reference
|
|
|
395
501
|
set_error('Cannot delete this message');
|
|
396
502
|
return false;
|
|
397
503
|
}
|
|
504
|
+
// Store original values for rollback
|
|
505
|
+
const original_deleted_at = message.deleted_at;
|
|
506
|
+
const original_message_text = message.message_text;
|
|
398
507
|
// Optimistic update
|
|
399
508
|
set_messages((prev) => prev.map((msg) => msg.id === message_id
|
|
400
509
|
? { ...msg, deleted_at: new Date().toISOString(), message_text: null }
|
|
401
510
|
: msg));
|
|
402
511
|
try {
|
|
403
|
-
const response = await fetch(`${api_base_url}/messages/${message_id}`, {
|
|
512
|
+
const response = await fetch(`${config.api_base_url}/messages/${message_id}`, {
|
|
404
513
|
method: 'DELETE',
|
|
405
|
-
credentials: 'include'
|
|
514
|
+
credentials: 'include',
|
|
406
515
|
});
|
|
407
516
|
if (!response.ok) {
|
|
408
517
|
throw new Error('Failed to delete message');
|
|
@@ -414,12 +523,12 @@ export function useChatMessages({ receiver_user_id, reference_id = '', reference
|
|
|
414
523
|
// Rollback on error
|
|
415
524
|
if (is_mounted_ref.current) {
|
|
416
525
|
set_messages((prev) => prev.map((msg) => msg.id === message_id
|
|
417
|
-
? { ...msg, deleted_at:
|
|
526
|
+
? { ...msg, deleted_at: original_deleted_at, message_text: original_message_text }
|
|
418
527
|
: msg));
|
|
419
528
|
}
|
|
420
529
|
return false;
|
|
421
530
|
}
|
|
422
|
-
}, [current_user_id, messages, api_base_url]);
|
|
531
|
+
}, [current_user_id, messages, config.api_base_url]);
|
|
423
532
|
// -------------------------------------------------------------------------
|
|
424
533
|
// Mark as read via API
|
|
425
534
|
// -------------------------------------------------------------------------
|
|
@@ -432,36 +541,31 @@ export function useChatMessages({ receiver_user_id, reference_id = '', reference
|
|
|
432
541
|
return;
|
|
433
542
|
}
|
|
434
543
|
try {
|
|
435
|
-
|
|
436
|
-
const response = await fetch(`${api_base_url}/messages/${message_id}/read`, {
|
|
544
|
+
const response = await fetch(`${config.api_base_url}/messages/${message_id}/read`, {
|
|
437
545
|
method: 'PATCH',
|
|
438
|
-
credentials: 'include'
|
|
546
|
+
credentials: 'include',
|
|
439
547
|
});
|
|
440
548
|
if (!response.ok) {
|
|
441
|
-
|
|
442
|
-
console.error('[useChatMessages] Mark as read failed:', response.status, error_text);
|
|
549
|
+
console.error('[useChatMessages] Mark as read failed:', response.status);
|
|
443
550
|
return;
|
|
444
551
|
}
|
|
445
552
|
const data = await response.json();
|
|
446
553
|
if (data.success && is_mounted_ref.current) {
|
|
447
|
-
console.log('[useChatMessages] Message marked as read successfully:', message_id, data.message?.read_at);
|
|
448
554
|
set_messages((prev) => prev.map((msg) => msg.id === message_id
|
|
449
555
|
? { ...msg, read_at: data.message?.read_at || new Date().toISOString() }
|
|
450
556
|
: msg));
|
|
451
557
|
}
|
|
452
|
-
else {
|
|
453
|
-
console.error('[useChatMessages] Mark as read response not successful:', data);
|
|
454
|
-
}
|
|
455
558
|
}
|
|
456
559
|
catch (err) {
|
|
457
560
|
console.error('[useChatMessages] Mark as read error:', err);
|
|
458
561
|
}
|
|
459
|
-
}, [current_user_id, messages, api_base_url]);
|
|
562
|
+
}, [current_user_id, messages, config.api_base_url]);
|
|
460
563
|
// -------------------------------------------------------------------------
|
|
461
564
|
// Refresh
|
|
462
565
|
// -------------------------------------------------------------------------
|
|
463
566
|
const refresh = useCallback(() => {
|
|
464
|
-
cursor_ref.current =
|
|
567
|
+
cursor_ref.current = null;
|
|
568
|
+
retry_count_ref.current = 0;
|
|
465
569
|
set_messages([]);
|
|
466
570
|
load_initial();
|
|
467
571
|
}, [load_initial]);
|