@thrillee/aegischat 0.1.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/dist/index.d.mts +452 -0
- package/dist/index.d.ts +452 -0
- package/dist/index.js +1065 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +1024 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +60 -0
- package/src/hooks/index.ts +27 -0
- package/src/hooks/useAutoRead.ts +78 -0
- package/src/hooks/useChannels.ts +99 -0
- package/src/hooks/useChat.ts +731 -0
- package/src/hooks/useFileUpload.ts +25 -0
- package/src/hooks/useMentions.ts +36 -0
- package/src/hooks/useMessages.ts +37 -0
- package/src/hooks/useReactions.ts +28 -0
- package/src/hooks/useTypingIndicator.ts +28 -0
- package/src/index.ts +68 -0
- package/src/services/api.ts +370 -0
- package/src/types/index.ts +373 -0
|
@@ -0,0 +1,731 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// AegisChat React SDK - useChat Hook
|
|
3
|
+
// ============================================================================
|
|
4
|
+
|
|
5
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
6
|
+
import { chatApi, channelsApi, messagesApi, filesApi } from '../services/api';
|
|
7
|
+
import type {
|
|
8
|
+
AegisConfig,
|
|
9
|
+
ChatSession,
|
|
10
|
+
ChannelListItem,
|
|
11
|
+
Message,
|
|
12
|
+
MessagesResponse,
|
|
13
|
+
TypingUser,
|
|
14
|
+
UserSummary,
|
|
15
|
+
FileAttachment,
|
|
16
|
+
UploadProgress,
|
|
17
|
+
MessageSummary,
|
|
18
|
+
} from '../types';
|
|
19
|
+
|
|
20
|
+
const TYPING_TIMEOUT = 3000;
|
|
21
|
+
const RECONNECT_INTERVAL = 3000;
|
|
22
|
+
const MAX_RECONNECT_ATTEMPTS = 5;
|
|
23
|
+
const MAX_RECONNECT_DELAY = 30000;
|
|
24
|
+
const PING_INTERVAL = 30000;
|
|
25
|
+
const SESSION_STORAGE_KEY = '@aegischat/activeChannel';
|
|
26
|
+
|
|
27
|
+
export interface UseChatOptions {
|
|
28
|
+
config: AegisConfig;
|
|
29
|
+
role: 'lawyer' | 'client';
|
|
30
|
+
clientId?: string;
|
|
31
|
+
autoConnect?: boolean;
|
|
32
|
+
onMessage?: (message: Message) => void;
|
|
33
|
+
onTyping?: (channelId: string, user: TypingUser) => void;
|
|
34
|
+
onConnectionChange?: (connected: boolean) => void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface UseChatReturn {
|
|
38
|
+
session: ChatSession | null;
|
|
39
|
+
isConnected: boolean;
|
|
40
|
+
isConnecting: boolean;
|
|
41
|
+
channels: ChannelListItem[];
|
|
42
|
+
messages: Message[];
|
|
43
|
+
activeChannelId: string | null;
|
|
44
|
+
typingUsers: TypingUser[];
|
|
45
|
+
isLoadingChannels: boolean;
|
|
46
|
+
isLoadingMessages: boolean;
|
|
47
|
+
hasMoreMessages: boolean;
|
|
48
|
+
uploadProgress: UploadProgress[];
|
|
49
|
+
connect: () => Promise<void>;
|
|
50
|
+
disconnect: () => void;
|
|
51
|
+
selectChannel: (channelId: string) => void;
|
|
52
|
+
sendMessage: (content: string, options?: { type?: string; parent_id?: string; metadata?: Record<string, unknown> }) => Promise<void>;
|
|
53
|
+
sendMessageWithFiles: (content: string, files: File[], options?: { type?: string; parent_id?: string; metadata?: Record<string, unknown> }) => Promise<void>;
|
|
54
|
+
uploadFile: (file: File) => Promise<FileAttachment | null>;
|
|
55
|
+
loadMoreMessages: () => Promise<void>;
|
|
56
|
+
startTyping: () => void;
|
|
57
|
+
stopTyping: () => void;
|
|
58
|
+
refreshChannels: () => Promise<void>;
|
|
59
|
+
createDMWithUser: (userId: string) => Promise<string | null>;
|
|
60
|
+
retryMessage: (tempId: string) => Promise<void>;
|
|
61
|
+
deleteFailedMessage: (tempId: string) => void;
|
|
62
|
+
markAsRead: (channelId: string) => Promise<void>;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function useChat(options: UseChatOptions): UseChatReturn {
|
|
66
|
+
const { config, role, clientId, autoConnect = true, onMessage, onTyping, onConnectionChange } = options;
|
|
67
|
+
|
|
68
|
+
const [session, setSession] = useState<ChatSession | null>(null);
|
|
69
|
+
const [isConnected, setIsConnected] = useState(false);
|
|
70
|
+
const [isConnecting, setIsConnecting] = useState(false);
|
|
71
|
+
const [activeChannelId, setActiveChannelIdState] = useState<string | null>(null);
|
|
72
|
+
const [channels, setChannels] = useState<ChannelListItem[]>([]);
|
|
73
|
+
const [messages, setMessages] = useState<Message[]>([]);
|
|
74
|
+
const [typingUsers, setTypingUsers] = useState<Record<string, TypingUser[]>>({});
|
|
75
|
+
const [isLoadingChannels, setIsLoadingChannels] = useState(false);
|
|
76
|
+
const [isLoadingMessages, setIsLoadingMessages] = useState(false);
|
|
77
|
+
const [hasMoreMessages, setHasMoreMessages] = useState(true);
|
|
78
|
+
const [uploadProgress, setUploadProgress] = useState<UploadProgress[]>([]);
|
|
79
|
+
|
|
80
|
+
const wsRef = useRef<WebSocket | null>(null);
|
|
81
|
+
const reconnectAttempts = useRef(0);
|
|
82
|
+
const reconnectTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
83
|
+
const pingInterval = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
84
|
+
const typingTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
85
|
+
const isManualDisconnect = useRef(false);
|
|
86
|
+
const oldestMessageId = useRef<string | null>(null);
|
|
87
|
+
const activeChannelIdRef = useRef<string | null>(null);
|
|
88
|
+
const configRef = useRef(config);
|
|
89
|
+
const sessionRef = useRef<ChatSession | null>(null);
|
|
90
|
+
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
configRef.current = config;
|
|
93
|
+
}, [config]);
|
|
94
|
+
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
activeChannelIdRef.current = activeChannelId;
|
|
97
|
+
}, [activeChannelId]);
|
|
98
|
+
|
|
99
|
+
useEffect(() => {
|
|
100
|
+
sessionRef.current = session;
|
|
101
|
+
}, [session]);
|
|
102
|
+
|
|
103
|
+
const getActiveChannelId = useCallback((): string | null => {
|
|
104
|
+
if (typeof window === 'undefined') return null;
|
|
105
|
+
return sessionStorage.getItem(SESSION_STORAGE_KEY);
|
|
106
|
+
}, []);
|
|
107
|
+
|
|
108
|
+
const setActiveChannelId = useCallback((id: string | null) => {
|
|
109
|
+
setActiveChannelIdState(id);
|
|
110
|
+
if (typeof window !== 'undefined') {
|
|
111
|
+
if (id) {
|
|
112
|
+
sessionStorage.setItem(SESSION_STORAGE_KEY, id);
|
|
113
|
+
} else {
|
|
114
|
+
sessionStorage.removeItem(SESSION_STORAGE_KEY);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}, []);
|
|
118
|
+
|
|
119
|
+
const fetchFromComms = useCallback(async <T>(path: string, fetchOptions: RequestInit = {}): Promise<T> => {
|
|
120
|
+
const currentSession = sessionRef.current;
|
|
121
|
+
if (!currentSession) {
|
|
122
|
+
throw new Error('Chat session not initialized');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const response = await fetch(`${currentSession.api_url}${path}`, {
|
|
126
|
+
...fetchOptions,
|
|
127
|
+
headers: {
|
|
128
|
+
'Content-Type': 'application/json',
|
|
129
|
+
Authorization: `Bearer ${currentSession.access_token}`,
|
|
130
|
+
...fetchOptions.headers,
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
if (!response.ok) {
|
|
135
|
+
const error = await response.json().catch(() => ({}));
|
|
136
|
+
throw new Error(error.message || `HTTP ${response.status}`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const data = await response.json();
|
|
140
|
+
return data.data || data;
|
|
141
|
+
}, []);
|
|
142
|
+
|
|
143
|
+
const clearTimers = useCallback(() => {
|
|
144
|
+
if (reconnectTimeout.current) {
|
|
145
|
+
clearTimeout(reconnectTimeout.current);
|
|
146
|
+
reconnectTimeout.current = null;
|
|
147
|
+
}
|
|
148
|
+
if (pingInterval.current) {
|
|
149
|
+
clearInterval(pingInterval.current);
|
|
150
|
+
pingInterval.current = null;
|
|
151
|
+
}
|
|
152
|
+
}, []);
|
|
153
|
+
|
|
154
|
+
const handleWebSocketMessage = useCallback((data: { type: string; payload: unknown }) => {
|
|
155
|
+
const currentActiveChannelId = activeChannelIdRef.current;
|
|
156
|
+
console.log('[AegisChat] WebSocket message received:', data.type, data);
|
|
157
|
+
|
|
158
|
+
switch (data.type) {
|
|
159
|
+
case 'message.new': {
|
|
160
|
+
const newMessage = data.payload as Message;
|
|
161
|
+
if (newMessage.channel_id === currentActiveChannelId) {
|
|
162
|
+
setMessages((prev) => {
|
|
163
|
+
const existingIndex = prev.findIndex(
|
|
164
|
+
(m) => m.tempId && m.content === newMessage.content && m.status === 'sending'
|
|
165
|
+
);
|
|
166
|
+
if (existingIndex !== -1) {
|
|
167
|
+
const updated = [...prev];
|
|
168
|
+
updated[existingIndex] = { ...newMessage, status: 'sent' };
|
|
169
|
+
return updated;
|
|
170
|
+
}
|
|
171
|
+
if (prev.some((m) => m.id === newMessage.id)) return prev;
|
|
172
|
+
return [...prev, { ...newMessage, status: 'delivered' }];
|
|
173
|
+
});
|
|
174
|
+
onMessage?.(newMessage);
|
|
175
|
+
}
|
|
176
|
+
setChannels((prev) => {
|
|
177
|
+
const updated = prev.map((ch) =>
|
|
178
|
+
ch.id === newMessage.channel_id
|
|
179
|
+
? {
|
|
180
|
+
...ch,
|
|
181
|
+
last_message: {
|
|
182
|
+
id: newMessage.id,
|
|
183
|
+
content: newMessage.content,
|
|
184
|
+
created_at: newMessage.created_at,
|
|
185
|
+
sender: { id: newMessage.sender_id, display_name: 'Unknown', status: 'online' as const },
|
|
186
|
+
} as MessageSummary,
|
|
187
|
+
unread_count: ch.id === currentActiveChannelId ? 0 : ch.unread_count + 1,
|
|
188
|
+
}
|
|
189
|
+
: ch
|
|
190
|
+
);
|
|
191
|
+
return updated.sort((a, b) => {
|
|
192
|
+
const timeA = a.last_message?.created_at || '';
|
|
193
|
+
const timeB = b.last_message?.created_at || '';
|
|
194
|
+
return timeB.localeCompare(timeA);
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
break;
|
|
198
|
+
}
|
|
199
|
+
case 'message.updated': {
|
|
200
|
+
const updatedMessage = data.payload as Message;
|
|
201
|
+
setMessages((prev) => prev.map((m) => (m.id === updatedMessage.id ? updatedMessage : m)));
|
|
202
|
+
break;
|
|
203
|
+
}
|
|
204
|
+
case 'message.deleted': {
|
|
205
|
+
const { message_id } = data.payload as { message_id: string };
|
|
206
|
+
setMessages((prev) => prev.map((m) => (m.id === message_id ? { ...m, deleted: true } : m)));
|
|
207
|
+
break;
|
|
208
|
+
}
|
|
209
|
+
case 'message.delivered':
|
|
210
|
+
case 'message.read': {
|
|
211
|
+
const { message_id, channel_id, status } = data.payload as { message_id: string; channel_id: string; status: string };
|
|
212
|
+
if (channel_id === currentActiveChannelId) {
|
|
213
|
+
setMessages((prev) =>
|
|
214
|
+
prev.map((m) => (m.id === message_id ? { ...m, status: (status as Message['status']) || 'delivered' } : m))
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
break;
|
|
218
|
+
}
|
|
219
|
+
case 'message.delivered.batch':
|
|
220
|
+
case 'message.read.batch': {
|
|
221
|
+
const { channel_id } = data.payload as { channel_id: string };
|
|
222
|
+
if (channel_id === currentActiveChannelId) {
|
|
223
|
+
setMessages((prev) =>
|
|
224
|
+
prev.map((m) => (m.status === 'sent' || m.status === 'delivered' ? { ...m, status: data.type === 'message.delivered.batch' ? 'delivered' : 'read' } : m))
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
break;
|
|
228
|
+
}
|
|
229
|
+
case 'typing.start': {
|
|
230
|
+
const { channel_id, user } = data.payload as { channel_id: string; user: UserSummary };
|
|
231
|
+
const typingUser: TypingUser = {
|
|
232
|
+
id: user.id,
|
|
233
|
+
displayName: user.display_name,
|
|
234
|
+
avatarUrl: user.avatar_url,
|
|
235
|
+
startedAt: Date.now(),
|
|
236
|
+
};
|
|
237
|
+
setTypingUsers((prev) => ({
|
|
238
|
+
...prev,
|
|
239
|
+
[channel_id]: [...(prev[channel_id] || []).filter((u) => u.id !== user.id), typingUser],
|
|
240
|
+
}));
|
|
241
|
+
onTyping?.(channel_id, typingUser);
|
|
242
|
+
break;
|
|
243
|
+
}
|
|
244
|
+
case 'typing.stop': {
|
|
245
|
+
const { channel_id, user_id } = data.payload as { channel_id: string; user_id: string };
|
|
246
|
+
setTypingUsers((prev) => ({
|
|
247
|
+
...prev,
|
|
248
|
+
[channel_id]: (prev[channel_id] || []).filter((u) => u.id !== user_id),
|
|
249
|
+
}));
|
|
250
|
+
break;
|
|
251
|
+
}
|
|
252
|
+
case 'pong':
|
|
253
|
+
break;
|
|
254
|
+
default:
|
|
255
|
+
console.log('[AegisChat] Unhandled message type:', data.type);
|
|
256
|
+
}
|
|
257
|
+
}, [onMessage, onTyping]);
|
|
258
|
+
|
|
259
|
+
const connectWebSocket = useCallback(() => {
|
|
260
|
+
const currentSession = sessionRef.current;
|
|
261
|
+
if (!currentSession?.websocket_url || !currentSession?.access_token) {
|
|
262
|
+
console.warn('[AegisChat] Cannot connect WebSocket - missing session or token');
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
|
266
|
+
console.log('[AegisChat] WebSocket already open, skipping connection');
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
setIsConnecting(true);
|
|
271
|
+
isManualDisconnect.current = false;
|
|
272
|
+
|
|
273
|
+
const wsUrl = `${currentSession.websocket_url}?token=${currentSession.access_token}`;
|
|
274
|
+
console.log('[AegisChat] Creating WebSocket connection to:', wsUrl);
|
|
275
|
+
const ws = new WebSocket(wsUrl);
|
|
276
|
+
|
|
277
|
+
ws.onopen = () => {
|
|
278
|
+
console.log('[AegisChat] WebSocket connected');
|
|
279
|
+
setIsConnected(true);
|
|
280
|
+
reconnectAttempts.current = 0;
|
|
281
|
+
onConnectionChange?.(true);
|
|
282
|
+
|
|
283
|
+
pingInterval.current = setInterval(() => {
|
|
284
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
285
|
+
ws.send(JSON.stringify({ type: 'ping' }));
|
|
286
|
+
}
|
|
287
|
+
}, PING_INTERVAL);
|
|
288
|
+
|
|
289
|
+
if (activeChannelIdRef.current) {
|
|
290
|
+
ws.send(JSON.stringify({ type: 'channel.join', payload: { channel_id: activeChannelIdRef.current } }));
|
|
291
|
+
}
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
ws.onmessage = (event) => {
|
|
295
|
+
try {
|
|
296
|
+
const data = JSON.parse(event.data);
|
|
297
|
+
handleWebSocketMessage(data);
|
|
298
|
+
} catch (error) {
|
|
299
|
+
console.error('[AegisChat] Failed to parse WebSocket message:', error);
|
|
300
|
+
}
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
ws.onclose = () => {
|
|
304
|
+
console.log('[AegisChat] WebSocket disconnected');
|
|
305
|
+
setIsConnected(false);
|
|
306
|
+
clearTimers();
|
|
307
|
+
onConnectionChange?.(false);
|
|
308
|
+
|
|
309
|
+
if (!isManualDisconnect.current && reconnectAttempts.current < MAX_RECONNECT_ATTEMPTS) {
|
|
310
|
+
const delay = Math.min(RECONNECT_INTERVAL * Math.pow(2, reconnectAttempts.current), MAX_RECONNECT_DELAY);
|
|
311
|
+
console.log(`[AegisChat] Reconnecting in ${delay}ms...`);
|
|
312
|
+
reconnectTimeout.current = setTimeout(() => {
|
|
313
|
+
reconnectAttempts.current++;
|
|
314
|
+
connectWebSocket();
|
|
315
|
+
}, delay);
|
|
316
|
+
}
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
ws.onerror = (error) => {
|
|
320
|
+
console.error('[AegisChat] WebSocket error:', error);
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
wsRef.current = ws;
|
|
324
|
+
}, [clearTimers, handleWebSocketMessage, onConnectionChange]);
|
|
325
|
+
|
|
326
|
+
const connect = useCallback(async () => {
|
|
327
|
+
console.log('[AegisChat] connect() called');
|
|
328
|
+
if (sessionRef.current) {
|
|
329
|
+
console.log('[AegisChat] Session exists, calling connectWebSocket directly');
|
|
330
|
+
connectWebSocket();
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
try {
|
|
335
|
+
setIsConnecting(true);
|
|
336
|
+
console.log('[AegisChat] Fetching chat session...');
|
|
337
|
+
const result = await chatApi.connect({ role, client_id: clientId });
|
|
338
|
+
console.log('[AegisChat] Chat session received:', result);
|
|
339
|
+
setSession(result.data);
|
|
340
|
+
setIsConnecting(false);
|
|
341
|
+
} catch (error) {
|
|
342
|
+
console.error('[AegisChat] Failed to get chat session:', error);
|
|
343
|
+
setIsConnecting(false);
|
|
344
|
+
throw error;
|
|
345
|
+
}
|
|
346
|
+
}, [role, clientId, connectWebSocket]);
|
|
347
|
+
|
|
348
|
+
const disconnect = useCallback(() => {
|
|
349
|
+
isManualDisconnect.current = true;
|
|
350
|
+
clearTimers();
|
|
351
|
+
if (wsRef.current) {
|
|
352
|
+
wsRef.current.close();
|
|
353
|
+
wsRef.current = null;
|
|
354
|
+
}
|
|
355
|
+
setIsConnected(false);
|
|
356
|
+
setSession(null);
|
|
357
|
+
setChannels([]);
|
|
358
|
+
setMessages([]);
|
|
359
|
+
}, [clearTimers]);
|
|
360
|
+
|
|
361
|
+
const refreshChannels = useCallback(async () => {
|
|
362
|
+
const currentSession = sessionRef.current;
|
|
363
|
+
if (!currentSession) return;
|
|
364
|
+
|
|
365
|
+
setIsLoadingChannels(true);
|
|
366
|
+
try {
|
|
367
|
+
const response = await channelsApi.list({});
|
|
368
|
+
setChannels(response.data.channels || []);
|
|
369
|
+
} catch (error) {
|
|
370
|
+
console.error('[AegisChat] Failed to fetch channels:', error);
|
|
371
|
+
} finally {
|
|
372
|
+
setIsLoadingChannels(false);
|
|
373
|
+
}
|
|
374
|
+
}, []);
|
|
375
|
+
|
|
376
|
+
const selectChannel = useCallback(async (channelId: string) => {
|
|
377
|
+
const currentActiveChannelId = activeChannelIdRef.current;
|
|
378
|
+
setActiveChannelId(channelId);
|
|
379
|
+
setMessages([]);
|
|
380
|
+
setHasMoreMessages(true);
|
|
381
|
+
oldestMessageId.current = null;
|
|
382
|
+
|
|
383
|
+
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
|
384
|
+
if (currentActiveChannelId) {
|
|
385
|
+
wsRef.current.send(JSON.stringify({ type: 'channel.leave', payload: { channel_id: currentActiveChannelId } }));
|
|
386
|
+
}
|
|
387
|
+
wsRef.current.send(JSON.stringify({ type: 'channel.join', payload: { channel_id: channelId } }));
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
setIsLoadingMessages(true);
|
|
391
|
+
try {
|
|
392
|
+
const response = await fetchFromComms<MessagesResponse>(`/channels/${channelId}/messages?limit=50`);
|
|
393
|
+
setMessages(response.messages || []);
|
|
394
|
+
setHasMoreMessages(response.has_more);
|
|
395
|
+
if (response.oldest_id) {
|
|
396
|
+
oldestMessageId.current = response.oldest_id;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
await markAsRead(channelId);
|
|
400
|
+
|
|
401
|
+
setChannels((prev) => prev.map((ch) => (ch.id === channelId ? { ...ch, unread_count: 0 } : ch)));
|
|
402
|
+
} catch (error) {
|
|
403
|
+
console.error('[AegisChat] Failed to load messages:', error);
|
|
404
|
+
setMessages([]);
|
|
405
|
+
} finally {
|
|
406
|
+
setIsLoadingMessages(false);
|
|
407
|
+
}
|
|
408
|
+
}, [setActiveChannelId, fetchFromComms]);
|
|
409
|
+
|
|
410
|
+
const markAsRead = useCallback(async (channelId: string) => {
|
|
411
|
+
try {
|
|
412
|
+
await fetchFromComms(`/channels/${channelId}/read`, { method: 'POST' });
|
|
413
|
+
} catch (error) {
|
|
414
|
+
console.error('[AegisChat] Failed to mark as read:', error);
|
|
415
|
+
}
|
|
416
|
+
}, [fetchFromComms]);
|
|
417
|
+
|
|
418
|
+
const loadMoreMessages = useCallback(async () => {
|
|
419
|
+
if (!activeChannelId || !hasMoreMessages || isLoadingMessages) return;
|
|
420
|
+
|
|
421
|
+
setIsLoadingMessages(true);
|
|
422
|
+
try {
|
|
423
|
+
const params = oldestMessageId.current ? `?before=${oldestMessageId.current}&limit=50` : '?limit=50';
|
|
424
|
+
const response = await fetchFromComms<MessagesResponse>(`/channels/${activeChannelId}/messages${params}`);
|
|
425
|
+
setMessages((prev) => [...(response.messages || []), ...prev]);
|
|
426
|
+
setHasMoreMessages(response.has_more);
|
|
427
|
+
if (response.oldest_id) {
|
|
428
|
+
oldestMessageId.current = response.oldest_id;
|
|
429
|
+
}
|
|
430
|
+
} catch (error) {
|
|
431
|
+
console.error('[AegisChat] Failed to load more messages:', error);
|
|
432
|
+
} finally {
|
|
433
|
+
setIsLoadingMessages(false);
|
|
434
|
+
}
|
|
435
|
+
}, [activeChannelId, hasMoreMessages, isLoadingMessages, fetchFromComms]);
|
|
436
|
+
|
|
437
|
+
const sendMessage = useCallback(async (
|
|
438
|
+
content: string,
|
|
439
|
+
msgOptions: { type?: string; parent_id?: string; metadata?: Record<string, unknown> } = {}
|
|
440
|
+
) => {
|
|
441
|
+
const currentActiveChannelId = activeChannelIdRef.current;
|
|
442
|
+
const currentSession = sessionRef.current;
|
|
443
|
+
if (!currentActiveChannelId || !content.trim() || !currentSession) return;
|
|
444
|
+
|
|
445
|
+
const tempId = `temp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
446
|
+
const trimmedContent = content.trim();
|
|
447
|
+
|
|
448
|
+
const optimisticMessage: Message = {
|
|
449
|
+
id: tempId,
|
|
450
|
+
tempId,
|
|
451
|
+
channel_id: currentActiveChannelId,
|
|
452
|
+
sender_id: currentSession.comms_user_id,
|
|
453
|
+
content: trimmedContent,
|
|
454
|
+
type: (msgOptions.type as Message['type']) || 'text',
|
|
455
|
+
created_at: new Date().toISOString(),
|
|
456
|
+
updated_at: new Date().toISOString(),
|
|
457
|
+
status: 'sending',
|
|
458
|
+
metadata: msgOptions.metadata || {},
|
|
459
|
+
};
|
|
460
|
+
|
|
461
|
+
setMessages((prev) => [...prev, optimisticMessage]);
|
|
462
|
+
|
|
463
|
+
const now = new Date().toISOString();
|
|
464
|
+
setChannels((prev) => {
|
|
465
|
+
const updated = prev.map((ch) =>
|
|
466
|
+
ch.id === currentActiveChannelId
|
|
467
|
+
? {
|
|
468
|
+
...ch,
|
|
469
|
+
last_message: {
|
|
470
|
+
id: tempId,
|
|
471
|
+
content: trimmedContent,
|
|
472
|
+
created_at: now,
|
|
473
|
+
sender: { id: currentSession.comms_user_id, display_name: 'You', status: 'online' as const },
|
|
474
|
+
},
|
|
475
|
+
}
|
|
476
|
+
: ch
|
|
477
|
+
);
|
|
478
|
+
return updated.sort((a, b) => {
|
|
479
|
+
const timeA = a.last_message?.created_at || '';
|
|
480
|
+
const timeB = b.last_message?.created_at || '';
|
|
481
|
+
return timeB.localeCompare(timeA);
|
|
482
|
+
});
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
try {
|
|
486
|
+
await fetchFromComms<Message>(`/channels/${currentActiveChannelId}/messages`, {
|
|
487
|
+
method: 'POST',
|
|
488
|
+
body: JSON.stringify({ content: trimmedContent, type: msgOptions.type || 'text', parent_id: msgOptions.parent_id, metadata: msgOptions.metadata }),
|
|
489
|
+
});
|
|
490
|
+
} catch (error) {
|
|
491
|
+
console.error('[AegisChat] Failed to send message:', error);
|
|
492
|
+
setMessages((prev) =>
|
|
493
|
+
prev.map((m) =>
|
|
494
|
+
m.tempId === tempId ? { ...m, status: 'failed', errorMessage: error instanceof Error ? error.message : 'Failed to send' } : m
|
|
495
|
+
)
|
|
496
|
+
);
|
|
497
|
+
throw error;
|
|
498
|
+
}
|
|
499
|
+
}, [fetchFromComms]);
|
|
500
|
+
|
|
501
|
+
const uploadFile = useCallback(async (file: File): Promise<FileAttachment | null> => {
|
|
502
|
+
const currentSession = sessionRef.current;
|
|
503
|
+
if (!currentSession) return null;
|
|
504
|
+
|
|
505
|
+
const fileId = `temp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
506
|
+
|
|
507
|
+
setUploadProgress((prev) => [...prev, { fileId, fileName: file.name, progress: 0, status: 'pending' }]);
|
|
508
|
+
|
|
509
|
+
try {
|
|
510
|
+
setUploadProgress((prev) => prev.map((p) => p.fileId === fileId ? { ...p, status: 'uploading', progress: 10 } : p));
|
|
511
|
+
|
|
512
|
+
const uploadUrlResponse = await fetchFromComms<{ upload_url: string; file_id: string; expires_at: string }>('/files/upload-url', {
|
|
513
|
+
method: 'POST',
|
|
514
|
+
body: JSON.stringify({ file_name: file.name, file_type: file.type || 'application/octet-stream', file_size: file.size }),
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
setUploadProgress((prev) => prev.map((p) => p.fileId === fileId ? { ...p, fileId: uploadUrlResponse.file_id, progress: 30 } : p));
|
|
518
|
+
|
|
519
|
+
const uploadResponse = await fetch(uploadUrlResponse.upload_url, {
|
|
520
|
+
method: 'PUT',
|
|
521
|
+
body: file,
|
|
522
|
+
headers: { 'Content-Type': file.type || 'application/octet-stream' },
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
if (!uploadResponse.ok) throw new Error(`Upload failed: ${uploadResponse.statusText}`);
|
|
526
|
+
|
|
527
|
+
setUploadProgress((prev) => prev.map((p) => p.fileId === uploadUrlResponse.file_id ? { ...p, status: 'confirming', progress: 70 } : p));
|
|
528
|
+
|
|
529
|
+
const confirmResponse = await fetchFromComms<{ file: FileAttachment }>('/files', {
|
|
530
|
+
method: 'POST',
|
|
531
|
+
body: JSON.stringify({ file_id: uploadUrlResponse.file_id }),
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
setUploadProgress((prev) => prev.map((p) => p.fileId === uploadUrlResponse.file_id ? { ...p, status: 'complete', progress: 100 } : p));
|
|
535
|
+
setTimeout(() => setUploadProgress((prev) => prev.filter((p) => p.fileId !== uploadUrlResponse.file_id)), 2000);
|
|
536
|
+
|
|
537
|
+
return confirmResponse.file;
|
|
538
|
+
} catch (error) {
|
|
539
|
+
console.error('[AegisChat] Failed to upload file:', error);
|
|
540
|
+
setUploadProgress((prev) => prev.map((p) => p.fileId === fileId ? { ...p, status: 'error', error: error instanceof Error ? error.message : 'Upload failed' } : p));
|
|
541
|
+
setTimeout(() => setUploadProgress((prev) => prev.filter((p) => p.fileId !== fileId)), 5000);
|
|
542
|
+
return null;
|
|
543
|
+
}
|
|
544
|
+
}, [fetchFromComms]);
|
|
545
|
+
|
|
546
|
+
const sendMessageWithFiles = useCallback(async (
|
|
547
|
+
content: string,
|
|
548
|
+
files: File[],
|
|
549
|
+
msgOptions: { type?: string; parent_id?: string; metadata?: Record<string, unknown> } = {}
|
|
550
|
+
) => {
|
|
551
|
+
const currentActiveChannelId = activeChannelIdRef.current;
|
|
552
|
+
const currentSession = sessionRef.current;
|
|
553
|
+
if (!currentActiveChannelId || (!content.trim() && files.length === 0) || !currentSession) return;
|
|
554
|
+
|
|
555
|
+
const tempId = `temp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
556
|
+
const trimmedContent = content.trim();
|
|
557
|
+
|
|
558
|
+
const optimisticMessage: Message = {
|
|
559
|
+
id: tempId,
|
|
560
|
+
tempId,
|
|
561
|
+
channel_id: currentActiveChannelId,
|
|
562
|
+
sender_id: currentSession.comms_user_id,
|
|
563
|
+
content: trimmedContent || `Uploading ${files.length} file(s)...`,
|
|
564
|
+
type: 'file',
|
|
565
|
+
created_at: new Date().toISOString(),
|
|
566
|
+
updated_at: new Date().toISOString(),
|
|
567
|
+
status: 'sending',
|
|
568
|
+
metadata: { ...msgOptions.metadata, files: files.map((f) => ({ id: `temp-${f.name}`, filename: f.name, mime_type: f.type, size: f.size, url: '' })) },
|
|
569
|
+
};
|
|
570
|
+
|
|
571
|
+
setMessages((prev) => [...prev, optimisticMessage]);
|
|
572
|
+
|
|
573
|
+
try {
|
|
574
|
+
const uploadedFiles: FileAttachment[] = [];
|
|
575
|
+
for (const file of files) {
|
|
576
|
+
const attachment = await uploadFile(file);
|
|
577
|
+
if (attachment) uploadedFiles.push(attachment);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
const messageType = uploadedFiles.length > 0 && !trimmedContent ? 'file' : 'text';
|
|
581
|
+
|
|
582
|
+
await fetchFromComms<Message>(`/channels/${currentActiveChannelId}/messages`, {
|
|
583
|
+
method: 'POST',
|
|
584
|
+
body: JSON.stringify({
|
|
585
|
+
content: trimmedContent || (uploadedFiles.length > 0 ? `Shared ${uploadedFiles.length} file(s)` : ''),
|
|
586
|
+
type: msgOptions.type || messageType,
|
|
587
|
+
parent_id: msgOptions.parent_id,
|
|
588
|
+
metadata: { ...msgOptions.metadata, files: uploadedFiles },
|
|
589
|
+
file_ids: uploadedFiles.map((f) => f.id),
|
|
590
|
+
}),
|
|
591
|
+
});
|
|
592
|
+
} catch (error) {
|
|
593
|
+
console.error('[AegisChat] Failed to send message with files:', error);
|
|
594
|
+
setMessages((prev) => prev.map((m) => m.tempId === tempId ? { ...m, status: 'failed', errorMessage: error instanceof Error ? error.message : 'Failed to send' } : m));
|
|
595
|
+
throw error;
|
|
596
|
+
}
|
|
597
|
+
}, [fetchFromComms, uploadFile]);
|
|
598
|
+
|
|
599
|
+
const stopTyping = useCallback(() => {
|
|
600
|
+
const currentActiveChannelId = activeChannelIdRef.current;
|
|
601
|
+
if (!currentActiveChannelId || !wsRef.current) return;
|
|
602
|
+
wsRef.current.send(JSON.stringify({ type: 'typing.stop', payload: { channel_id: currentActiveChannelId } }));
|
|
603
|
+
if (typingTimeout.current) {
|
|
604
|
+
clearTimeout(typingTimeout.current);
|
|
605
|
+
typingTimeout.current = null;
|
|
606
|
+
}
|
|
607
|
+
}, []);
|
|
608
|
+
|
|
609
|
+
const startTyping = useCallback(() => {
|
|
610
|
+
const currentActiveChannelId = activeChannelIdRef.current;
|
|
611
|
+
if (!currentActiveChannelId || !wsRef.current) return;
|
|
612
|
+
wsRef.current.send(JSON.stringify({ type: 'typing.start', payload: { channel_id: currentActiveChannelId } }));
|
|
613
|
+
if (typingTimeout.current) clearTimeout(typingTimeout.current);
|
|
614
|
+
typingTimeout.current = setTimeout(stopTyping, TYPING_TIMEOUT);
|
|
615
|
+
}, [stopTyping]);
|
|
616
|
+
|
|
617
|
+
const createDMWithUser = useCallback(async (userId: string): Promise<string | null> => {
|
|
618
|
+
try {
|
|
619
|
+
const channel = await fetchFromComms<{ id: string }>('/channels/dm', {
|
|
620
|
+
method: 'POST',
|
|
621
|
+
body: JSON.stringify({ user_id: userId }),
|
|
622
|
+
});
|
|
623
|
+
await refreshChannels();
|
|
624
|
+
return channel.id;
|
|
625
|
+
} catch (error) {
|
|
626
|
+
console.error('[AegisChat] Failed to create DM:', error);
|
|
627
|
+
return null;
|
|
628
|
+
}
|
|
629
|
+
}, [fetchFromComms, refreshChannels]);
|
|
630
|
+
|
|
631
|
+
const retryMessage = useCallback(async (tempId: string) => {
|
|
632
|
+
const failedMessage = messages.find((m) => m.tempId === tempId && m.status === 'failed');
|
|
633
|
+
const currentActiveChannelId = activeChannelIdRef.current;
|
|
634
|
+
if (!failedMessage || !currentActiveChannelId) return;
|
|
635
|
+
|
|
636
|
+
setMessages((prev) => prev.map((m) => m.tempId === tempId ? { ...m, status: 'sending', errorMessage: undefined } : m));
|
|
637
|
+
|
|
638
|
+
try {
|
|
639
|
+
await fetchFromComms<Message>(`/channels/${currentActiveChannelId}/messages`, {
|
|
640
|
+
method: 'POST',
|
|
641
|
+
body: JSON.stringify({ content: failedMessage.content, type: failedMessage.type, metadata: failedMessage.metadata }),
|
|
642
|
+
});
|
|
643
|
+
} catch (error) {
|
|
644
|
+
console.error('[AegisChat] Failed to retry message:', error);
|
|
645
|
+
setMessages((prev) => prev.map((m) => m.tempId === tempId ? { ...m, status: 'failed', errorMessage: error instanceof Error ? error.message : 'Failed to send' } : m));
|
|
646
|
+
}
|
|
647
|
+
}, [messages, fetchFromComms]);
|
|
648
|
+
|
|
649
|
+
const deleteFailedMessage = useCallback((tempId: string) => {
|
|
650
|
+
setMessages((prev) => prev.filter((m) => m.tempId !== tempId));
|
|
651
|
+
}, []);
|
|
652
|
+
|
|
653
|
+
// Effects
|
|
654
|
+
useEffect(() => {
|
|
655
|
+
connect();
|
|
656
|
+
}, []);
|
|
657
|
+
|
|
658
|
+
useEffect(() => {
|
|
659
|
+
if (session && !isConnected && !isConnecting && autoConnect) {
|
|
660
|
+
connectWebSocket();
|
|
661
|
+
}
|
|
662
|
+
}, [session, isConnected, isConnecting, autoConnect, connectWebSocket]);
|
|
663
|
+
|
|
664
|
+
useEffect(() => {
|
|
665
|
+
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
|
666
|
+
wsRef.current.onmessage = (event) => {
|
|
667
|
+
try {
|
|
668
|
+
const data = JSON.parse(event.data);
|
|
669
|
+
handleWebSocketMessage(data);
|
|
670
|
+
} catch (error) {
|
|
671
|
+
console.error('[AegisChat] Failed to parse WebSocket message:', error);
|
|
672
|
+
}
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
}, [handleWebSocketMessage]);
|
|
676
|
+
|
|
677
|
+
useEffect(() => {
|
|
678
|
+
if (isConnected && channels.length === 0) {
|
|
679
|
+
refreshChannels();
|
|
680
|
+
}
|
|
681
|
+
}, [isConnected, channels.length, refreshChannels]);
|
|
682
|
+
|
|
683
|
+
useEffect(() => {
|
|
684
|
+
const storedActiveChannel = getActiveChannelId();
|
|
685
|
+
if (storedActiveChannel && !activeChannelId) {
|
|
686
|
+
selectChannel(storedActiveChannel);
|
|
687
|
+
}
|
|
688
|
+
}, [getActiveChannelId, activeChannelId, selectChannel]);
|
|
689
|
+
|
|
690
|
+
useEffect(() => {
|
|
691
|
+
return () => {
|
|
692
|
+
clearTimers();
|
|
693
|
+
if (typingTimeout.current) clearTimeout(typingTimeout.current);
|
|
694
|
+
if (wsRef.current) {
|
|
695
|
+
isManualDisconnect.current = true;
|
|
696
|
+
wsRef.current.close();
|
|
697
|
+
wsRef.current = null;
|
|
698
|
+
}
|
|
699
|
+
};
|
|
700
|
+
}, [clearTimers]);
|
|
701
|
+
|
|
702
|
+
return {
|
|
703
|
+
session,
|
|
704
|
+
isConnected,
|
|
705
|
+
isConnecting,
|
|
706
|
+
channels,
|
|
707
|
+
messages,
|
|
708
|
+
activeChannelId,
|
|
709
|
+
typingUsers: activeChannelId ? typingUsers[activeChannelId] || [] : [],
|
|
710
|
+
isLoadingChannels,
|
|
711
|
+
isLoadingMessages,
|
|
712
|
+
hasMoreMessages,
|
|
713
|
+
uploadProgress,
|
|
714
|
+
connect,
|
|
715
|
+
disconnect,
|
|
716
|
+
selectChannel,
|
|
717
|
+
sendMessage,
|
|
718
|
+
sendMessageWithFiles,
|
|
719
|
+
uploadFile,
|
|
720
|
+
loadMoreMessages,
|
|
721
|
+
startTyping,
|
|
722
|
+
stopTyping,
|
|
723
|
+
refreshChannels,
|
|
724
|
+
createDMWithUser,
|
|
725
|
+
retryMessage,
|
|
726
|
+
deleteFailedMessage,
|
|
727
|
+
markAsRead,
|
|
728
|
+
};
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
export default useChat;
|