hazo_chat 2.1.0 → 3.0.0
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/README.md +182 -67
- package/SETUP_CHECKLIST.md +773 -67
- 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 +340 -47
- package/dist/api/messages.js.map +1 -1
- package/dist/api/types.d.ts +50 -2
- package/dist/api/types.d.ts.map +1 -1
- package/dist/api/unread_count.d.ts +19 -10
- package/dist/api/unread_count.d.ts.map +1 -1
- package/dist/api/unread_count.js +54 -30
- package/dist/api/unread_count.js.map +1 -1
- package/dist/components/hazo_chat/hazo_chat.d.ts.map +1 -1
- package/dist/components/hazo_chat/hazo_chat.js +23 -15
- 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 +48 -8
- package/dist/components/hazo_chat/hazo_chat_messages.js.map +1 -1
- package/dist/hooks/use_chat_messages.d.ts +5 -5
- package/dist/hooks/use_chat_messages.d.ts.map +1 -1
- package/dist/hooks/use_chat_messages.js +247 -148
- package/dist/hooks/use_chat_messages.js.map +1 -1
- package/dist/types/index.d.ts +162 -7
- 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({
|
|
36
|
+
export function useChatMessages({ chat_group_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
|
+
chat_group_id,
|
|
61
|
+
reference_id,
|
|
62
|
+
reference_type,
|
|
63
|
+
api_base_url,
|
|
64
|
+
realtime_mode,
|
|
65
|
+
polling_interval,
|
|
66
|
+
messages_per_page,
|
|
67
|
+
}), [chat_group_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,17 +152,16 @@ 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
|
// -------------------------------------------------------------------------
|
|
83
160
|
const transform_messages = useCallback(async (db_messages, user_id) => {
|
|
84
|
-
// Collect all unique
|
|
161
|
+
// Collect all unique sender IDs (no longer need receiver since it's group-based)
|
|
85
162
|
const user_ids = new Set();
|
|
86
163
|
db_messages.forEach((msg) => {
|
|
87
164
|
user_ids.add(msg.sender_user_id);
|
|
88
|
-
user_ids.add(msg.receiver_user_id);
|
|
89
165
|
});
|
|
90
166
|
// Fetch profiles
|
|
91
167
|
const profiles = await fetch_user_profiles(Array.from(user_ids));
|
|
@@ -93,26 +169,45 @@ export function useChatMessages({ receiver_user_id, reference_id = '', reference
|
|
|
93
169
|
return db_messages.map((msg) => ({
|
|
94
170
|
...msg,
|
|
95
171
|
sender_profile: profiles.get(msg.sender_user_id),
|
|
96
|
-
receiver_profile: profiles.get(msg.receiver_user_id),
|
|
97
172
|
is_sender: msg.sender_user_id === user_id,
|
|
98
|
-
send_status: 'sent'
|
|
173
|
+
send_status: 'sent',
|
|
99
174
|
}));
|
|
100
175
|
}, [fetch_user_profiles]);
|
|
101
176
|
// -------------------------------------------------------------------------
|
|
102
|
-
//
|
|
177
|
+
// Calculate polling delay with exponential backoff
|
|
178
|
+
// -------------------------------------------------------------------------
|
|
179
|
+
const get_poll_delay = useCallback(() => {
|
|
180
|
+
if (retry_count_ref.current === 0) {
|
|
181
|
+
return config.polling_interval;
|
|
182
|
+
}
|
|
183
|
+
// Exponential backoff: interval * 2^retryCount, capped at MAX_POLLING_DELAY
|
|
184
|
+
const delay = config.polling_interval * Math.pow(2, retry_count_ref.current);
|
|
185
|
+
return Math.min(delay, MAX_POLLING_DELAY);
|
|
186
|
+
}, [config.polling_interval]);
|
|
187
|
+
// -------------------------------------------------------------------------
|
|
188
|
+
// Fetch messages via API with optional pagination
|
|
103
189
|
// -------------------------------------------------------------------------
|
|
104
|
-
const fetch_messages_from_api = useCallback(async () => {
|
|
105
|
-
if (!
|
|
106
|
-
return { messages: [], user_id: null };
|
|
190
|
+
const fetch_messages_from_api = useCallback(async (options) => {
|
|
191
|
+
if (!config.chat_group_id) {
|
|
192
|
+
return { messages: [], user_id: null, has_more: false, next_cursor: null };
|
|
107
193
|
}
|
|
108
194
|
try {
|
|
109
195
|
const params = new URLSearchParams({
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
...(reference_type && { reference_type }),
|
|
196
|
+
chat_group_id: config.chat_group_id,
|
|
197
|
+
limit: String(options?.limit || config.messages_per_page),
|
|
113
198
|
});
|
|
114
|
-
|
|
115
|
-
|
|
199
|
+
if (config.reference_id) {
|
|
200
|
+
params.set('reference_id', config.reference_id);
|
|
201
|
+
}
|
|
202
|
+
if (config.reference_type) {
|
|
203
|
+
params.set('reference_type', config.reference_type);
|
|
204
|
+
}
|
|
205
|
+
if (options?.cursor) {
|
|
206
|
+
params.set('cursor', options.cursor);
|
|
207
|
+
params.set('direction', options.direction || 'older');
|
|
208
|
+
}
|
|
209
|
+
const response = await fetch(`${config.api_base_url}/messages?${params.toString()}`, {
|
|
210
|
+
credentials: 'include',
|
|
116
211
|
});
|
|
117
212
|
if (!response.ok) {
|
|
118
213
|
throw new Error(`HTTP ${response.status}`);
|
|
@@ -121,7 +216,9 @@ export function useChatMessages({ receiver_user_id, reference_id = '', reference
|
|
|
121
216
|
if (data.success) {
|
|
122
217
|
return {
|
|
123
218
|
messages: data.messages || [],
|
|
124
|
-
user_id: data.current_user_id || null
|
|
219
|
+
user_id: data.current_user_id || null,
|
|
220
|
+
has_more: data.pagination?.has_more ?? false,
|
|
221
|
+
next_cursor: data.pagination?.next_cursor ?? null,
|
|
125
222
|
};
|
|
126
223
|
}
|
|
127
224
|
else {
|
|
@@ -132,31 +229,35 @@ export function useChatMessages({ receiver_user_id, reference_id = '', reference
|
|
|
132
229
|
console.error('[useChatMessages] Fetch error:', err);
|
|
133
230
|
throw err;
|
|
134
231
|
}
|
|
135
|
-
}, [
|
|
232
|
+
}, [config]);
|
|
136
233
|
// -------------------------------------------------------------------------
|
|
137
234
|
// Initial load
|
|
138
235
|
// -------------------------------------------------------------------------
|
|
139
236
|
const load_initial = useCallback(async () => {
|
|
140
|
-
if (!
|
|
237
|
+
if (!config.chat_group_id) {
|
|
141
238
|
set_is_loading(false);
|
|
142
239
|
return;
|
|
143
240
|
}
|
|
144
241
|
set_is_loading(true);
|
|
145
242
|
set_error(null);
|
|
146
243
|
try {
|
|
147
|
-
const
|
|
148
|
-
if (user_id && is_mounted_ref.current) {
|
|
149
|
-
set_current_user_id(user_id);
|
|
244
|
+
const result = await fetch_messages_from_api();
|
|
245
|
+
if (result.user_id && is_mounted_ref.current) {
|
|
246
|
+
set_current_user_id(result.user_id);
|
|
150
247
|
}
|
|
151
248
|
if (is_mounted_ref.current) {
|
|
152
|
-
const transformed = user_id
|
|
153
|
-
? await transform_messages(
|
|
154
|
-
:
|
|
249
|
+
const transformed = result.user_id
|
|
250
|
+
? await transform_messages(result.messages, result.user_id)
|
|
251
|
+
: result.messages.map((msg) => ({
|
|
252
|
+
...msg,
|
|
253
|
+
is_sender: false,
|
|
254
|
+
send_status: 'sent',
|
|
255
|
+
}));
|
|
155
256
|
// Sort messages in ascending order (oldest first, newest last)
|
|
156
257
|
const sorted = transformed.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime());
|
|
157
258
|
set_messages(sorted);
|
|
158
|
-
set_has_more(
|
|
159
|
-
cursor_ref.current =
|
|
259
|
+
set_has_more(result.has_more);
|
|
260
|
+
cursor_ref.current = result.next_cursor;
|
|
160
261
|
retry_count_ref.current = 0;
|
|
161
262
|
set_polling_status('connected');
|
|
162
263
|
}
|
|
@@ -172,25 +273,32 @@ export function useChatMessages({ receiver_user_id, reference_id = '', reference
|
|
|
172
273
|
set_is_loading(false);
|
|
173
274
|
}
|
|
174
275
|
}
|
|
175
|
-
}, [
|
|
276
|
+
}, [config.chat_group_id, fetch_messages_from_api, transform_messages]);
|
|
176
277
|
// -------------------------------------------------------------------------
|
|
177
|
-
// Load more (pagination)
|
|
278
|
+
// Load more (pagination)
|
|
178
279
|
// -------------------------------------------------------------------------
|
|
179
280
|
const load_more = useCallback(async () => {
|
|
180
|
-
if (!current_user_id || !has_more || is_loading_more) {
|
|
281
|
+
if (!current_user_id || !has_more || is_loading_more || !cursor_ref.current) {
|
|
181
282
|
return;
|
|
182
283
|
}
|
|
183
284
|
set_is_loading_more(true);
|
|
184
285
|
try {
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
286
|
+
const result = await fetch_messages_from_api({
|
|
287
|
+
cursor: cursor_ref.current,
|
|
288
|
+
direction: 'older',
|
|
289
|
+
});
|
|
290
|
+
const transformed = await transform_messages(result.messages, current_user_id);
|
|
188
291
|
if (is_mounted_ref.current) {
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
292
|
+
set_messages((prev) => {
|
|
293
|
+
// Prepend older messages and sort
|
|
294
|
+
const combined = [...transformed, ...prev];
|
|
295
|
+
return combined.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime());
|
|
296
|
+
});
|
|
297
|
+
set_has_more(result.has_more);
|
|
298
|
+
// Update cursor to oldest message for next load_more
|
|
299
|
+
if (result.messages.length > 0) {
|
|
300
|
+
cursor_ref.current = result.messages[0].created_at;
|
|
301
|
+
}
|
|
194
302
|
}
|
|
195
303
|
}
|
|
196
304
|
catch (err) {
|
|
@@ -203,88 +311,87 @@ export function useChatMessages({ receiver_user_id, reference_id = '', reference
|
|
|
203
311
|
}
|
|
204
312
|
}, [current_user_id, has_more, is_loading_more, fetch_messages_from_api, transform_messages]);
|
|
205
313
|
// -------------------------------------------------------------------------
|
|
206
|
-
// Poll for new messages
|
|
314
|
+
// Poll for new messages (uses setTimeout for proper backoff)
|
|
207
315
|
// -------------------------------------------------------------------------
|
|
208
|
-
const
|
|
209
|
-
if (!
|
|
316
|
+
const schedule_next_poll = useCallback(() => {
|
|
317
|
+
if (!is_mounted_ref.current || config.realtime_mode !== 'polling' || !config.chat_group_id) {
|
|
210
318
|
return;
|
|
211
319
|
}
|
|
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
|
-
});
|
|
320
|
+
const delay = get_poll_delay();
|
|
321
|
+
polling_timer_ref.current = setTimeout(async () => {
|
|
322
|
+
if (!is_mounted_ref.current || is_polling_ref.current) {
|
|
323
|
+
return;
|
|
228
324
|
}
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
325
|
+
is_polling_ref.current = true;
|
|
326
|
+
try {
|
|
327
|
+
// Fetch only newer messages since our latest
|
|
328
|
+
const latest_message = messages[messages.length - 1];
|
|
329
|
+
const result = await fetch_messages_from_api(latest_message
|
|
330
|
+
? { cursor: latest_message.created_at, direction: 'newer', limit: 50 }
|
|
331
|
+
: undefined);
|
|
332
|
+
if (result.messages.length > 0 && is_mounted_ref.current && current_user_id) {
|
|
333
|
+
const transformed = await transform_messages(result.messages, current_user_id);
|
|
334
|
+
set_messages((prev) => {
|
|
335
|
+
// Merge new messages, avoiding duplicates
|
|
336
|
+
const existing_ids = new Set(prev.map((m) => m.id));
|
|
337
|
+
const new_messages = transformed.filter((m) => !existing_ids.has(m.id));
|
|
338
|
+
if (new_messages.length > 0) {
|
|
339
|
+
const combined = [...prev, ...new_messages];
|
|
340
|
+
return combined.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime());
|
|
341
|
+
}
|
|
342
|
+
return prev;
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
// Reset retry count on success
|
|
346
|
+
retry_count_ref.current = 0;
|
|
347
|
+
if (is_mounted_ref.current) {
|
|
348
|
+
set_polling_status('connected');
|
|
349
|
+
}
|
|
232
350
|
}
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
351
|
+
catch (err) {
|
|
352
|
+
console.error('[useChatMessages] Polling error:', err);
|
|
353
|
+
retry_count_ref.current += 1;
|
|
354
|
+
if (is_mounted_ref.current) {
|
|
355
|
+
if (retry_count_ref.current >= MAX_RETRY_ATTEMPTS) {
|
|
356
|
+
set_polling_status('error');
|
|
357
|
+
}
|
|
358
|
+
else {
|
|
359
|
+
set_polling_status('reconnecting');
|
|
360
|
+
}
|
|
240
361
|
}
|
|
241
|
-
|
|
242
|
-
|
|
362
|
+
}
|
|
363
|
+
finally {
|
|
364
|
+
is_polling_ref.current = false;
|
|
365
|
+
// Schedule next poll with updated delay (backoff applied via get_poll_delay)
|
|
366
|
+
if (is_mounted_ref.current && config.realtime_mode === 'polling') {
|
|
367
|
+
schedule_next_poll();
|
|
243
368
|
}
|
|
244
369
|
}
|
|
245
|
-
}
|
|
246
|
-
}, [
|
|
370
|
+
}, delay);
|
|
371
|
+
}, [config.realtime_mode, config.chat_group_id, get_poll_delay, fetch_messages_from_api, transform_messages, messages, current_user_id]);
|
|
247
372
|
// -------------------------------------------------------------------------
|
|
248
|
-
// Start polling
|
|
373
|
+
// Start/stop polling based on realtime_mode
|
|
249
374
|
// -------------------------------------------------------------------------
|
|
250
375
|
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
376
|
// Clear any existing timer
|
|
265
377
|
if (polling_timer_ref.current) {
|
|
266
|
-
|
|
378
|
+
clearTimeout(polling_timer_ref.current);
|
|
379
|
+
polling_timer_ref.current = null;
|
|
380
|
+
}
|
|
381
|
+
// Only start polling if mode is 'polling' and we have a chat group
|
|
382
|
+
if (config.realtime_mode === 'polling' && config.chat_group_id && current_user_id) {
|
|
383
|
+
schedule_next_poll();
|
|
384
|
+
}
|
|
385
|
+
else if (config.realtime_mode === 'manual') {
|
|
386
|
+
set_polling_status('connected');
|
|
267
387
|
}
|
|
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
388
|
return () => {
|
|
283
389
|
if (polling_timer_ref.current) {
|
|
284
|
-
|
|
390
|
+
clearTimeout(polling_timer_ref.current);
|
|
391
|
+
polling_timer_ref.current = null;
|
|
285
392
|
}
|
|
286
393
|
};
|
|
287
|
-
}, [
|
|
394
|
+
}, [config.realtime_mode, config.chat_group_id, current_user_id, schedule_next_poll]);
|
|
288
395
|
// -------------------------------------------------------------------------
|
|
289
396
|
// Initial load effect
|
|
290
397
|
// -------------------------------------------------------------------------
|
|
@@ -299,38 +406,37 @@ export function useChatMessages({ receiver_user_id, reference_id = '', reference
|
|
|
299
406
|
set_error('Not authenticated');
|
|
300
407
|
return false;
|
|
301
408
|
}
|
|
302
|
-
// Create optimistic message
|
|
303
|
-
const optimistic_id =
|
|
409
|
+
// Create optimistic message with unique ID
|
|
410
|
+
const optimistic_id = generateOptimisticId();
|
|
411
|
+
const sender_profile = get_cached_profile(current_user_id);
|
|
304
412
|
const optimistic_message = {
|
|
305
413
|
id: optimistic_id,
|
|
306
414
|
reference_id: payload.reference_id,
|
|
307
415
|
reference_type: payload.reference_type,
|
|
308
416
|
sender_user_id: current_user_id,
|
|
309
|
-
|
|
417
|
+
chat_group_id: payload.chat_group_id,
|
|
310
418
|
message_text: payload.message_text,
|
|
311
419
|
reference_list: payload.reference_list || null,
|
|
312
420
|
read_at: null,
|
|
313
421
|
deleted_at: null,
|
|
314
422
|
created_at: new Date().toISOString(),
|
|
315
423
|
changed_at: new Date().toISOString(),
|
|
316
|
-
sender_profile:
|
|
317
|
-
receiver_profile: user_profiles_cache_ref.current.get(payload.receiver_user_id),
|
|
424
|
+
sender_profile: sender_profile || undefined,
|
|
318
425
|
is_sender: true,
|
|
319
|
-
send_status: 'sending'
|
|
426
|
+
send_status: 'sending',
|
|
320
427
|
};
|
|
321
|
-
// Add optimistic message to state
|
|
428
|
+
// Add optimistic message to state
|
|
322
429
|
set_messages((prev) => {
|
|
323
430
|
const updated = [...prev, optimistic_message];
|
|
324
|
-
// Sort to maintain chronological order
|
|
325
431
|
return updated.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime());
|
|
326
432
|
});
|
|
327
433
|
try {
|
|
328
|
-
const response = await fetch(`${api_base_url}/messages`, {
|
|
434
|
+
const response = await fetch(`${config.api_base_url}/messages`, {
|
|
329
435
|
method: 'POST',
|
|
330
436
|
headers: { 'Content-Type': 'application/json' },
|
|
331
437
|
credentials: 'include',
|
|
332
438
|
body: JSON.stringify({
|
|
333
|
-
|
|
439
|
+
chat_group_id: payload.chat_group_id,
|
|
334
440
|
message_text: payload.message_text,
|
|
335
441
|
reference_id: payload.reference_id,
|
|
336
442
|
reference_type: payload.reference_type,
|
|
@@ -341,27 +447,24 @@ export function useChatMessages({ receiver_user_id, reference_id = '', reference
|
|
|
341
447
|
// Replace optimistic message with real one
|
|
342
448
|
const real_message = {
|
|
343
449
|
...data.message,
|
|
344
|
-
// Ensure all required fields are set with proper defaults
|
|
345
450
|
reference_list: data.message.reference_list ?? null,
|
|
346
451
|
read_at: data.message.read_at ?? null,
|
|
347
452
|
deleted_at: data.message.deleted_at ?? null,
|
|
348
453
|
changed_at: data.message.changed_at ?? data.message.created_at,
|
|
349
|
-
sender_profile:
|
|
350
|
-
receiver_profile: user_profiles_cache_ref.current.get(payload.receiver_user_id),
|
|
454
|
+
sender_profile: sender_profile || undefined,
|
|
351
455
|
is_sender: true,
|
|
352
|
-
send_status: 'sent'
|
|
456
|
+
send_status: 'sent',
|
|
353
457
|
};
|
|
354
458
|
set_messages((prev) => {
|
|
355
459
|
// Check if real message already exists (from polling)
|
|
356
|
-
const real_message_exists = prev.some(msg => msg.id === real_message.id);
|
|
460
|
+
const real_message_exists = prev.some((msg) => msg.id === real_message.id);
|
|
357
461
|
if (real_message_exists) {
|
|
358
462
|
// Real message already exists from polling, just remove optimistic one
|
|
359
|
-
return prev.filter(msg => msg.id !== optimistic_id);
|
|
463
|
+
return prev.filter((msg) => msg.id !== optimistic_id);
|
|
360
464
|
}
|
|
361
465
|
else {
|
|
362
466
|
// Replace optimistic message with real one
|
|
363
467
|
const replaced = prev.map((msg) => msg.id === optimistic_id ? real_message : msg);
|
|
364
|
-
// Sort to ensure correct chronological order
|
|
365
468
|
return replaced.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime());
|
|
366
469
|
}
|
|
367
470
|
});
|
|
@@ -375,13 +478,11 @@ export function useChatMessages({ receiver_user_id, reference_id = '', reference
|
|
|
375
478
|
console.error('[useChatMessages] Send error:', err);
|
|
376
479
|
// Mark optimistic message as failed
|
|
377
480
|
if (is_mounted_ref.current) {
|
|
378
|
-
set_messages((prev) => prev.map((msg) => msg.id === optimistic_id
|
|
379
|
-
? { ...msg, send_status: 'failed' }
|
|
380
|
-
: msg));
|
|
481
|
+
set_messages((prev) => prev.map((msg) => msg.id === optimistic_id ? { ...msg, send_status: 'failed' } : msg));
|
|
381
482
|
}
|
|
382
483
|
return false;
|
|
383
484
|
}
|
|
384
|
-
}, [current_user_id, api_base_url]);
|
|
485
|
+
}, [current_user_id, config.api_base_url, get_cached_profile]);
|
|
385
486
|
// -------------------------------------------------------------------------
|
|
386
487
|
// Delete message (soft delete) via API
|
|
387
488
|
// -------------------------------------------------------------------------
|
|
@@ -395,14 +496,17 @@ export function useChatMessages({ receiver_user_id, reference_id = '', reference
|
|
|
395
496
|
set_error('Cannot delete this message');
|
|
396
497
|
return false;
|
|
397
498
|
}
|
|
499
|
+
// Store original values for rollback
|
|
500
|
+
const original_deleted_at = message.deleted_at;
|
|
501
|
+
const original_message_text = message.message_text;
|
|
398
502
|
// Optimistic update
|
|
399
503
|
set_messages((prev) => prev.map((msg) => msg.id === message_id
|
|
400
504
|
? { ...msg, deleted_at: new Date().toISOString(), message_text: null }
|
|
401
505
|
: msg));
|
|
402
506
|
try {
|
|
403
|
-
const response = await fetch(`${api_base_url}/messages/${message_id}`, {
|
|
507
|
+
const response = await fetch(`${config.api_base_url}/messages/${message_id}`, {
|
|
404
508
|
method: 'DELETE',
|
|
405
|
-
credentials: 'include'
|
|
509
|
+
credentials: 'include',
|
|
406
510
|
});
|
|
407
511
|
if (!response.ok) {
|
|
408
512
|
throw new Error('Failed to delete message');
|
|
@@ -414,12 +518,12 @@ export function useChatMessages({ receiver_user_id, reference_id = '', reference
|
|
|
414
518
|
// Rollback on error
|
|
415
519
|
if (is_mounted_ref.current) {
|
|
416
520
|
set_messages((prev) => prev.map((msg) => msg.id === message_id
|
|
417
|
-
? { ...msg, deleted_at:
|
|
521
|
+
? { ...msg, deleted_at: original_deleted_at, message_text: original_message_text }
|
|
418
522
|
: msg));
|
|
419
523
|
}
|
|
420
524
|
return false;
|
|
421
525
|
}
|
|
422
|
-
}, [current_user_id, messages, api_base_url]);
|
|
526
|
+
}, [current_user_id, messages, config.api_base_url]);
|
|
423
527
|
// -------------------------------------------------------------------------
|
|
424
528
|
// Mark as read via API
|
|
425
529
|
// -------------------------------------------------------------------------
|
|
@@ -432,36 +536,31 @@ export function useChatMessages({ receiver_user_id, reference_id = '', reference
|
|
|
432
536
|
return;
|
|
433
537
|
}
|
|
434
538
|
try {
|
|
435
|
-
|
|
436
|
-
const response = await fetch(`${api_base_url}/messages/${message_id}/read`, {
|
|
539
|
+
const response = await fetch(`${config.api_base_url}/messages/${message_id}/read`, {
|
|
437
540
|
method: 'PATCH',
|
|
438
|
-
credentials: 'include'
|
|
541
|
+
credentials: 'include',
|
|
439
542
|
});
|
|
440
543
|
if (!response.ok) {
|
|
441
|
-
|
|
442
|
-
console.error('[useChatMessages] Mark as read failed:', response.status, error_text);
|
|
544
|
+
console.error('[useChatMessages] Mark as read failed:', response.status);
|
|
443
545
|
return;
|
|
444
546
|
}
|
|
445
547
|
const data = await response.json();
|
|
446
548
|
if (data.success && is_mounted_ref.current) {
|
|
447
|
-
console.log('[useChatMessages] Message marked as read successfully:', message_id, data.message?.read_at);
|
|
448
549
|
set_messages((prev) => prev.map((msg) => msg.id === message_id
|
|
449
550
|
? { ...msg, read_at: data.message?.read_at || new Date().toISOString() }
|
|
450
551
|
: msg));
|
|
451
552
|
}
|
|
452
|
-
else {
|
|
453
|
-
console.error('[useChatMessages] Mark as read response not successful:', data);
|
|
454
|
-
}
|
|
455
553
|
}
|
|
456
554
|
catch (err) {
|
|
457
555
|
console.error('[useChatMessages] Mark as read error:', err);
|
|
458
556
|
}
|
|
459
|
-
}, [current_user_id, messages, api_base_url]);
|
|
557
|
+
}, [current_user_id, messages, config.api_base_url]);
|
|
460
558
|
// -------------------------------------------------------------------------
|
|
461
559
|
// Refresh
|
|
462
560
|
// -------------------------------------------------------------------------
|
|
463
561
|
const refresh = useCallback(() => {
|
|
464
|
-
cursor_ref.current =
|
|
562
|
+
cursor_ref.current = null;
|
|
563
|
+
retry_count_ref.current = 0;
|
|
465
564
|
set_messages([]);
|
|
466
565
|
load_initial();
|
|
467
566
|
}, [load_initial]);
|