@fluxy-chat/sdk 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.js ADDED
@@ -0,0 +1,843 @@
1
+ export { FluxyAuthError, FluxyConnectionError, FluxySendError, FluxyTimeoutError, FLUXY_WS_CLOSE_NORMAL, FLUXY_WS_CLOSE_POLICY, computeReconnectBackoffMs, mapWebSocketCloseToError, } from "./errors";
2
+ export { FluxyChatRoomConnection, } from "./room-connection";
3
+ export { FluxyMessageStream } from "./message-stream";
4
+ import { FluxyChatRoomConnection } from "./room-connection";
5
+ import { FluxyAuthError, FluxySendError } from "./errors";
6
+ function inferAttachmentKind(contentType, fileName) {
7
+ const ct = (contentType || "").toLowerCase();
8
+ if (ct.startsWith("image/"))
9
+ return "image";
10
+ if (ct.startsWith("audio/"))
11
+ return "audio";
12
+ if (/\.(webm|m4a|mp3|wav|ogg)$/i.test(fileName))
13
+ return "audio";
14
+ return "file";
15
+ }
16
+ export class FluxyChatClient {
17
+ constructor(options) {
18
+ this.baseUrl = options.baseUrl.replace(/\/+$/, "");
19
+ this.userId = options.userId;
20
+ this.apiKey = options.apiKey;
21
+ this.token = options.token;
22
+ }
23
+ isAuthenticated() {
24
+ return !!this.token;
25
+ }
26
+ authHeaders() {
27
+ if (this.token) {
28
+ return {
29
+ Authorization: `Bearer ${this.token}`,
30
+ };
31
+ }
32
+ if (this.apiKey) {
33
+ return {
34
+ "X-Fluxy-Api-Key": this.apiKey,
35
+ };
36
+ }
37
+ return undefined;
38
+ }
39
+ connect(roomId) {
40
+ const wsBase = this.baseUrl.replace(/^http/, "ws");
41
+ const url = new URL(`/ws/room/${encodeURIComponent(roomId)}`, wsBase.endsWith("/") ? wsBase : `${wsBase}/`);
42
+ if (this.apiKey) {
43
+ url.searchParams.set("apiKey", this.apiKey);
44
+ }
45
+ if (this.token) {
46
+ url.searchParams.set("token", this.token);
47
+ }
48
+ url.searchParams.set("userId", this.userId);
49
+ const ws = new WebSocket(url.toString());
50
+ return ws;
51
+ }
52
+ /**
53
+ * Resilient room WebSocket with typed errors, exponential backoff reconnect,
54
+ * and optional REST history replay after reconnect.
55
+ */
56
+ connectRoom(roomId, options) {
57
+ return new FluxyChatRoomConnection(this, roomId, options);
58
+ }
59
+ connectSSE(roomId) {
60
+ if (!this.token)
61
+ return null;
62
+ const url = new URL(`/rooms/${encodeURIComponent(roomId)}/stream`, this.baseUrl);
63
+ url.searchParams.set("token", this.token);
64
+ url.searchParams.set("userId", this.userId);
65
+ return new EventSource(url.toString());
66
+ }
67
+ async fetchMessages(roomId, limit = 50) {
68
+ const trimmedRoomId = roomId.trim();
69
+ if (!trimmedRoomId)
70
+ return [];
71
+ const url = new URL("/api/messages", this.baseUrl);
72
+ url.searchParams.set("roomId", trimmedRoomId);
73
+ url.searchParams.set("limit", String(limit));
74
+ const res = await fetch(url.toString(), {
75
+ headers: this.authHeaders(),
76
+ });
77
+ if (!res.ok)
78
+ throw new Error(`Failed to fetch messages: ${res.status}`);
79
+ const body = await res.json();
80
+ return body.messages ?? [];
81
+ }
82
+ async listRooms(type) {
83
+ const url = new URL("/rooms", this.baseUrl);
84
+ if (type)
85
+ url.searchParams.set("type", type);
86
+ const res = await fetch(url.toString(), {
87
+ headers: this.authHeaders(),
88
+ });
89
+ if (!res.ok)
90
+ throw new Error(`Failed to load rooms: ${res.status}`);
91
+ const body = await res.json();
92
+ return body.rooms ?? [];
93
+ }
94
+ // --- Authenticated REST helpers (used opportunistically by hooks) ---
95
+ async createMessage(roomId, content, replyTo, attachments) {
96
+ if (!this.token)
97
+ return null;
98
+ const res = await fetch(new URL("/messages", this.baseUrl).toString(), {
99
+ method: "POST",
100
+ headers: {
101
+ "Content-Type": "application/json",
102
+ ...this.authHeaders(),
103
+ },
104
+ body: JSON.stringify({
105
+ roomId,
106
+ content,
107
+ replyTo: replyTo ?? null,
108
+ ...(attachments?.length ? { attachments } : {}),
109
+ }),
110
+ });
111
+ if (!res.ok) {
112
+ throw new Error(`Failed to create message: ${res.status}`);
113
+ }
114
+ const body = await res.json();
115
+ return body.message ?? null;
116
+ }
117
+ /**
118
+ * Upload to Worker `POST /upload` (requires JWT). Returns attachment fields for composing a message.
119
+ */
120
+ async uploadFile(roomId, file) {
121
+ if (!this.token) {
122
+ throw new Error("JWT is required for uploads");
123
+ }
124
+ const contentType = file.type || "application/octet-stream";
125
+ const url = new URL("/upload", this.baseUrl).toString();
126
+ const res = await fetch(url, {
127
+ method: "POST",
128
+ headers: {
129
+ "Content-Type": contentType,
130
+ Authorization: `Bearer ${this.token}`,
131
+ "X-File-Name": file.name.slice(0, 255),
132
+ "X-Room-Id": roomId,
133
+ },
134
+ body: file,
135
+ });
136
+ if (!res.ok) {
137
+ throw new Error(`Upload failed: ${res.status}`);
138
+ }
139
+ const json = (await res.json());
140
+ const f = json.file;
141
+ if (!f?.url)
142
+ throw new Error("Invalid upload response");
143
+ return {
144
+ kind: inferAttachmentKind(contentType, file.name || f.name || ""),
145
+ url: f.url,
146
+ name: (f.name || file.name || "upload").slice(0, 255),
147
+ sizeBytes: typeof f.size === "number" ? f.size : file.size,
148
+ contentType,
149
+ };
150
+ }
151
+ async editMessageRest(messageId, content) {
152
+ if (!this.token)
153
+ return;
154
+ const url = new URL(`/messages/${messageId}`, this.baseUrl);
155
+ const res = await fetch(url.toString(), {
156
+ method: "PATCH",
157
+ headers: {
158
+ "Content-Type": "application/json",
159
+ ...this.authHeaders(),
160
+ },
161
+ body: JSON.stringify({ content }),
162
+ });
163
+ if (!res.ok) {
164
+ throw new Error(`Failed to edit message: ${res.status}`);
165
+ }
166
+ }
167
+ async deleteMessageRest(messageId) {
168
+ if (!this.token)
169
+ return;
170
+ const url = new URL(`/messages/${messageId}`, this.baseUrl);
171
+ const res = await fetch(url.toString(), {
172
+ method: "DELETE",
173
+ headers: this.authHeaders(),
174
+ });
175
+ if (!res.ok) {
176
+ throw new Error(`Failed to delete message: ${res.status}`);
177
+ }
178
+ }
179
+ async sendReactionRest(messageId, emoji, op = "add") {
180
+ if (!this.token)
181
+ return;
182
+ const url = new URL(`/messages/${messageId}/reactions`, this.baseUrl);
183
+ const res = await fetch(url.toString(), {
184
+ method: op === "remove" ? "DELETE" : "POST",
185
+ headers: {
186
+ "Content-Type": "application/json",
187
+ ...this.authHeaders(),
188
+ },
189
+ body: JSON.stringify({ emoji }),
190
+ });
191
+ if (!res.ok) {
192
+ throw new Error(`Failed to update reaction: ${res.status}`);
193
+ }
194
+ }
195
+ async markReadRest(roomId, messageId) {
196
+ if (!this.token)
197
+ return;
198
+ const url = new URL(`/rooms/${encodeURIComponent(roomId)}/read`, this.baseUrl);
199
+ const res = await fetch(url.toString(), {
200
+ method: "POST",
201
+ headers: {
202
+ "Content-Type": "application/json",
203
+ ...this.authHeaders(),
204
+ },
205
+ body: JSON.stringify({ messageId }),
206
+ });
207
+ if (!res.ok) {
208
+ throw new Error(`Failed to mark read: ${res.status}`);
209
+ }
210
+ }
211
+ async listAgents() {
212
+ if (!this.token)
213
+ return [];
214
+ const res = await fetch(new URL("/agents", this.baseUrl).toString(), {
215
+ headers: this.authHeaders(),
216
+ });
217
+ if (!res.ok)
218
+ throw new Error(`Failed to list agents: ${res.status}`);
219
+ const body = await res.json();
220
+ return body.agents ?? [];
221
+ }
222
+ async invokeAgentRest(agentId, roomId, content, options) {
223
+ if (!this.token)
224
+ throw new Error("invokeAgent requires JWT token");
225
+ const url = new URL(`/agents/${encodeURIComponent(agentId)}/invoke`, this.baseUrl);
226
+ const res = await fetch(url.toString(), {
227
+ method: "POST",
228
+ headers: {
229
+ "Content-Type": "application/json",
230
+ ...this.authHeaders(),
231
+ },
232
+ body: JSON.stringify({
233
+ roomId,
234
+ content,
235
+ replyTo: options?.replyTo ?? null,
236
+ stream: options?.stream !== false,
237
+ }),
238
+ });
239
+ if (!res.ok)
240
+ throw new Error(`Failed to invoke agent: ${res.status}`);
241
+ return res.json();
242
+ }
243
+ async getAgentRuns(agentId, limit = 50) {
244
+ if (!this.token)
245
+ return [];
246
+ const url = new URL(`/agents/${encodeURIComponent(agentId)}/runs`, this.baseUrl);
247
+ url.searchParams.set("limit", String(limit));
248
+ const res = await fetch(url.toString(), {
249
+ headers: this.authHeaders(),
250
+ });
251
+ if (!res.ok)
252
+ throw new Error(`Failed to fetch agent runs: ${res.status}`);
253
+ const body = await res.json();
254
+ return body.runs ?? [];
255
+ }
256
+ async getAgent(agentId) {
257
+ if (!this.token)
258
+ return null;
259
+ const url = new URL(`/agents/${encodeURIComponent(agentId)}`, this.baseUrl);
260
+ const res = await fetch(url.toString(), { headers: this.authHeaders() });
261
+ if (res.status === 404)
262
+ return null;
263
+ if (!res.ok)
264
+ throw new Error(`Failed to get agent: ${res.status}`);
265
+ const body = await res.json();
266
+ return body.agent ?? null;
267
+ }
268
+ async createAgent(body) {
269
+ if (!this.token)
270
+ throw new Error("createAgent requires JWT token");
271
+ const res = await fetch(new URL("/agents", this.baseUrl).toString(), {
272
+ method: "POST",
273
+ headers: { "Content-Type": "application/json", ...this.authHeaders() },
274
+ body: JSON.stringify(body),
275
+ });
276
+ if (!res.ok)
277
+ throw new Error(`Failed to create agent: ${res.status}`);
278
+ const data = await res.json();
279
+ return data.agent;
280
+ }
281
+ async updateAgent(agentId, body) {
282
+ if (!this.token)
283
+ throw new Error("updateAgent requires JWT token");
284
+ const url = new URL(`/agents/${encodeURIComponent(agentId)}`, this.baseUrl);
285
+ const res = await fetch(url.toString(), {
286
+ method: "PATCH",
287
+ headers: { "Content-Type": "application/json", ...this.authHeaders() },
288
+ body: JSON.stringify(body),
289
+ });
290
+ if (!res.ok)
291
+ throw new Error(`Failed to update agent: ${res.status}`);
292
+ const data = await res.json();
293
+ return data.agent;
294
+ }
295
+ async deleteAgent(agentId) {
296
+ if (!this.token)
297
+ throw new Error("deleteAgent requires JWT token");
298
+ const url = new URL(`/agents/${encodeURIComponent(agentId)}`, this.baseUrl);
299
+ const res = await fetch(url.toString(), {
300
+ method: "DELETE",
301
+ headers: this.authHeaders(),
302
+ });
303
+ if (!res.ok)
304
+ throw new Error(`Failed to delete agent: ${res.status}`);
305
+ }
306
+ async createRoom(body) {
307
+ if (!this.token)
308
+ throw new Error("createRoom requires JWT token");
309
+ const res = await fetch(new URL("/rooms", this.baseUrl).toString(), {
310
+ method: "POST",
311
+ headers: { "Content-Type": "application/json", ...this.authHeaders() },
312
+ body: JSON.stringify(body),
313
+ });
314
+ if (!res.ok)
315
+ throw new Error(`Failed to create room: ${res.status}`);
316
+ const data = await res.json();
317
+ return data.room;
318
+ }
319
+ async updateRoom(roomId, body) {
320
+ if (!this.token)
321
+ throw new Error("updateRoom requires JWT token");
322
+ const url = new URL(`/rooms/${encodeURIComponent(roomId)}`, this.baseUrl);
323
+ const res = await fetch(url.toString(), {
324
+ method: "PATCH",
325
+ headers: { "Content-Type": "application/json", ...this.authHeaders() },
326
+ body: JSON.stringify(body),
327
+ });
328
+ if (!res.ok)
329
+ throw new Error(`Failed to update room: ${res.status}`);
330
+ }
331
+ async deleteRoom(roomId) {
332
+ if (!this.token)
333
+ throw new Error("deleteRoom requires JWT token");
334
+ const url = new URL(`/rooms/${encodeURIComponent(roomId)}`, this.baseUrl);
335
+ const res = await fetch(url.toString(), {
336
+ method: "DELETE",
337
+ headers: this.authHeaders(),
338
+ });
339
+ if (!res.ok)
340
+ throw new Error(`Failed to delete room: ${res.status}`);
341
+ }
342
+ async addRoomMember(roomId, userId, role = "member") {
343
+ if (!this.token)
344
+ throw new Error("addRoomMember requires JWT token");
345
+ const url = new URL(`/rooms/${encodeURIComponent(roomId)}/members`, this.baseUrl);
346
+ const res = await fetch(url.toString(), {
347
+ method: "POST",
348
+ headers: { "Content-Type": "application/json", ...this.authHeaders() },
349
+ body: JSON.stringify({ userId, role }),
350
+ });
351
+ if (!res.ok)
352
+ throw new Error(`Failed to add room member: ${res.status}`);
353
+ }
354
+ async removeRoomMember(roomId, userId) {
355
+ if (!this.token)
356
+ throw new Error("removeRoomMember requires JWT token");
357
+ const url = new URL(`/rooms/${encodeURIComponent(roomId)}/members/${encodeURIComponent(userId)}`, this.baseUrl);
358
+ const res = await fetch(url.toString(), {
359
+ method: "DELETE",
360
+ headers: this.authHeaders(),
361
+ });
362
+ if (!res.ok)
363
+ throw new Error(`Failed to remove room member: ${res.status}`);
364
+ }
365
+ async registerWebhook(body) {
366
+ if (!this.token)
367
+ throw new Error("registerWebhook requires JWT token");
368
+ const res = await fetch(new URL("/webhooks/register", this.baseUrl).toString(), {
369
+ method: "POST",
370
+ headers: { "Content-Type": "application/json", ...this.authHeaders() },
371
+ body: JSON.stringify(body),
372
+ });
373
+ if (!res.ok)
374
+ throw new Error(`Failed to register webhook: ${res.status}`);
375
+ const data = await res.json();
376
+ return data.webhook;
377
+ }
378
+ async updateWebhook(webhookId, body) {
379
+ if (!this.token)
380
+ throw new Error("updateWebhook requires JWT token");
381
+ const url = new URL(`/webhooks/${encodeURIComponent(webhookId)}`, this.baseUrl);
382
+ const res = await fetch(url.toString(), {
383
+ method: "PATCH",
384
+ headers: { "Content-Type": "application/json", ...this.authHeaders() },
385
+ body: JSON.stringify(body),
386
+ });
387
+ if (!res.ok)
388
+ throw new Error(`Failed to update webhook: ${res.status}`);
389
+ }
390
+ async deleteWebhook(webhookId) {
391
+ if (!this.token)
392
+ throw new Error("deleteWebhook requires JWT token");
393
+ const url = new URL(`/webhooks/${encodeURIComponent(webhookId)}`, this.baseUrl);
394
+ const res = await fetch(url.toString(), {
395
+ method: "DELETE",
396
+ headers: this.authHeaders(),
397
+ });
398
+ if (!res.ok)
399
+ throw new Error(`Failed to delete webhook: ${res.status}`);
400
+ }
401
+ }
402
+ // React hook convenience API
403
+ import { useEffect, useRef, useState } from "react";
404
+ export function useChat({ roomId, client, agentId }) {
405
+ const [messages, setMessages] = useState([]);
406
+ const [online, setOnline] = useState(0);
407
+ const [typingUsers, setTypingUsers] = useState({});
408
+ const [seenBy, setSeenBy] = useState({});
409
+ const [onlineUsers, setOnlineUsers] = useState([]);
410
+ const [connected, setConnected] = useState(false);
411
+ const [connectionStatus, setConnectionStatus] = useState("connecting");
412
+ const [reconnectAttempt, setReconnectAttempt] = useState(0);
413
+ const [connectionError, setConnectionError] = useState(null);
414
+ const [agentTyping, setAgentTyping] = useState(false);
415
+ const [wsTypingAgentId, setWsTypingAgentId] = useState(null);
416
+ const [invokeTypingAgentId, setInvokeTypingAgentId] = useState(null);
417
+ const [reactions, setReactions] = useState({});
418
+ const connectionRef = useRef(null);
419
+ const sseRef = useRef(null);
420
+ const pollTimerRef = useRef(null);
421
+ useEffect(() => {
422
+ let active = true;
423
+ const trimmedRoomId = roomId.trim();
424
+ const MAX_WS_RECONNECT_ATTEMPTS = 6;
425
+ const POLL_INTERVAL_MS = 4000;
426
+ const stopPollingFallback = () => {
427
+ if (pollTimerRef.current) {
428
+ clearInterval(pollTimerRef.current);
429
+ pollTimerRef.current = null;
430
+ }
431
+ };
432
+ const stopSSEFallback = () => {
433
+ if (sseRef.current) {
434
+ sseRef.current.close();
435
+ sseRef.current = null;
436
+ }
437
+ };
438
+ const startPollingFallback = () => {
439
+ stopPollingFallback();
440
+ stopSSEFallback();
441
+ const tick = async () => {
442
+ if (!active)
443
+ return;
444
+ try {
445
+ const next = await client.fetchMessages(trimmedRoomId);
446
+ if (active)
447
+ setMessages(next);
448
+ }
449
+ catch {
450
+ /* ignore transient poll errors */
451
+ }
452
+ };
453
+ void tick();
454
+ pollTimerRef.current = setInterval(tick, POLL_INTERVAL_MS);
455
+ };
456
+ const startSSEFallback = () => {
457
+ stopPollingFallback();
458
+ stopSSEFallback();
459
+ const es = client.connectSSE(trimmedRoomId);
460
+ if (!es) {
461
+ startPollingFallback();
462
+ return;
463
+ }
464
+ sseRef.current = es;
465
+ setConnectionStatus("sse");
466
+ es.addEventListener("message", (event) => {
467
+ if (!active)
468
+ return;
469
+ try {
470
+ const data = JSON.parse(event.data);
471
+ handleEvent(data);
472
+ }
473
+ catch {
474
+ /* ignore malformed SSE events */
475
+ }
476
+ });
477
+ es.addEventListener("error", () => {
478
+ if (!active)
479
+ return;
480
+ stopSSEFallback();
481
+ startPollingFallback();
482
+ setConnectionStatus("polling");
483
+ });
484
+ };
485
+ const handleEvent = (data) => {
486
+ if (data.type === "history") {
487
+ setMessages(data.messages);
488
+ }
489
+ else if (data.type === "message") {
490
+ setMessages((prev) => {
491
+ const idx = prev.findIndex((m) => m.id === data.id);
492
+ if (idx >= 0) {
493
+ const next = [...prev];
494
+ next[idx] = { ...next[idx], ...data };
495
+ return next;
496
+ }
497
+ return [...prev, data];
498
+ });
499
+ }
500
+ else if (data.type === "presence") {
501
+ setOnline(data.online);
502
+ if (data.users)
503
+ setOnlineUsers(data.users);
504
+ }
505
+ else if (data.type === "typing") {
506
+ setTypingUsers((prev) => ({
507
+ ...prev,
508
+ [data.userId]: data.isTyping,
509
+ }));
510
+ }
511
+ else if (data.type === "agentTyping") {
512
+ setAgentTyping(data.isTyping);
513
+ setWsTypingAgentId(data.isTyping ? data.agentId : null);
514
+ }
515
+ else if (data.type === "edit") {
516
+ setMessages((prev) => prev.map((m) => m.id === data.id
517
+ ? {
518
+ ...m,
519
+ content: data.content,
520
+ editedAt: data.editedAt,
521
+ streaming: data.streaming ?? false,
522
+ }
523
+ : m));
524
+ }
525
+ else if (data.type === "reaction") {
526
+ setReactions((prev) => {
527
+ const byMessage = { ...prev };
528
+ const current = { ...(byMessage[data.messageId] || {}) };
529
+ const existingCount = current[data.emoji] || 0;
530
+ if (data.op === "remove") {
531
+ const nextCount = Math.max(existingCount - 1, 0);
532
+ if (nextCount === 0) {
533
+ delete current[data.emoji];
534
+ }
535
+ else {
536
+ current[data.emoji] = nextCount;
537
+ }
538
+ }
539
+ else {
540
+ current[data.emoji] = existingCount + 1;
541
+ }
542
+ if (Object.keys(current).length === 0) {
543
+ delete byMessage[data.messageId];
544
+ }
545
+ else {
546
+ byMessage[data.messageId] = current;
547
+ }
548
+ return byMessage;
549
+ });
550
+ }
551
+ else if (data.type === "read") {
552
+ setSeenBy((prev) => {
553
+ const existing = prev[data.messageId] || [];
554
+ if (existing.includes(data.userId))
555
+ return prev;
556
+ return {
557
+ ...prev,
558
+ [data.messageId]: [...existing, data.userId],
559
+ };
560
+ });
561
+ }
562
+ else if (data.type === "delete") {
563
+ if (data.hard) {
564
+ setMessages((prev) => prev.filter((m) => m.id !== data.id));
565
+ }
566
+ else {
567
+ setMessages((prev) => prev.map((m) => m.id === data.id
568
+ ? { ...m, content: "[deleted]", deletedAt: data.deletedAt }
569
+ : m));
570
+ }
571
+ }
572
+ };
573
+ if (!trimmedRoomId || !client.isAuthenticated()) {
574
+ setMessages([]);
575
+ setConnected(false);
576
+ setConnectionStatus("disconnected");
577
+ return () => {
578
+ active = false;
579
+ stopPollingFallback();
580
+ stopSSEFallback();
581
+ connectionRef.current?.close();
582
+ connectionRef.current = null;
583
+ };
584
+ }
585
+ client.fetchMessages(trimmedRoomId).then((initial) => {
586
+ if (!active)
587
+ return;
588
+ setMessages(initial);
589
+ }).catch(() => {
590
+ /* history load is best-effort until member JWT + room are ready */
591
+ });
592
+ const connection = client.connectRoom(trimmedRoomId, {
593
+ maxReconnectAttempts: MAX_WS_RECONNECT_ATTEMPTS,
594
+ onStatusChange: (status) => {
595
+ if (!active)
596
+ return;
597
+ if (status === "connected") {
598
+ setConnected(true);
599
+ setConnectionStatus("connected");
600
+ setReconnectAttempt(0);
601
+ setConnectionError(null);
602
+ stopPollingFallback();
603
+ stopSSEFallback();
604
+ }
605
+ else if (status === "connecting") {
606
+ setConnectionStatus("connecting");
607
+ setConnected(false);
608
+ }
609
+ else if (status === "reconnecting") {
610
+ setConnectionStatus("reconnecting");
611
+ setConnected(false);
612
+ setReconnectAttempt(connection.reconnectAttempts);
613
+ }
614
+ else if (status === "disconnected") {
615
+ setConnected(false);
616
+ setConnectionStatus("disconnected");
617
+ }
618
+ },
619
+ onAuthError: (err) => {
620
+ if (!active)
621
+ return;
622
+ setConnectionError(err);
623
+ setConnected(false);
624
+ setConnectionStatus("disconnected");
625
+ },
626
+ onConnectionError: (err) => {
627
+ if (!active)
628
+ return;
629
+ if (!(err instanceof FluxyAuthError)) {
630
+ setConnectionError(err);
631
+ }
632
+ },
633
+ onReconnectFailed: () => {
634
+ if (!active)
635
+ return;
636
+ setReconnectAttempt(connection.reconnectAttempts);
637
+ if (client.isAuthenticated()) {
638
+ startSSEFallback();
639
+ }
640
+ else {
641
+ startPollingFallback();
642
+ }
643
+ },
644
+ });
645
+ connection.addEventListener("message", (data) => {
646
+ if (!active)
647
+ return;
648
+ handleEvent(data);
649
+ });
650
+ connectionRef.current = connection;
651
+ connection.connect();
652
+ return () => {
653
+ active = false;
654
+ stopPollingFallback();
655
+ stopSSEFallback();
656
+ connection.close();
657
+ connectionRef.current = null;
658
+ setConnected(false);
659
+ setConnectionStatus("disconnected");
660
+ };
661
+ }, [roomId, client]);
662
+ const sendMessage = (content, replyTo, attachments) => {
663
+ if (client.isAuthenticated()) {
664
+ void client
665
+ .createMessage(roomId, content, replyTo, attachments)
666
+ .catch((err) =>
667
+ // eslint-disable-next-line no-console
668
+ console.error("[fluxychat] REST sendMessage failed, falling back to WS:", err));
669
+ return;
670
+ }
671
+ try {
672
+ connectionRef.current?.sendJson({
673
+ type: "message",
674
+ userId: client.userId,
675
+ content,
676
+ parentId: replyTo ?? null,
677
+ attachments: attachments ?? [],
678
+ });
679
+ }
680
+ catch (err) {
681
+ if (err instanceof FluxySendError)
682
+ return;
683
+ throw err;
684
+ }
685
+ };
686
+ const setTyping = (isTyping) => {
687
+ try {
688
+ connectionRef.current?.sendJson({
689
+ type: "typing",
690
+ userId: client.userId,
691
+ isTyping,
692
+ });
693
+ }
694
+ catch {
695
+ /* socket not open */
696
+ }
697
+ };
698
+ const editMessage = (messageId, content) => {
699
+ const tryWsEdit = () => {
700
+ try {
701
+ connectionRef.current?.sendJson({
702
+ type: "edit",
703
+ userId: client.userId,
704
+ messageId,
705
+ content,
706
+ });
707
+ }
708
+ catch {
709
+ /* socket not open */
710
+ }
711
+ };
712
+ if (client.isAuthenticated()) {
713
+ void client.editMessageRest(messageId, content).catch((err) => {
714
+ // eslint-disable-next-line no-console
715
+ console.error("[fluxychat] REST editMessage failed, falling back to WS:", err);
716
+ tryWsEdit();
717
+ });
718
+ return;
719
+ }
720
+ tryWsEdit();
721
+ };
722
+ const sendReaction = (messageId, emoji, op = "add") => {
723
+ if (client.isAuthenticated()) {
724
+ void client
725
+ .sendReactionRest(messageId, emoji, op)
726
+ .catch((err) =>
727
+ // eslint-disable-next-line no-console
728
+ console.error("[fluxychat] REST sendReaction failed, falling back to WS:", err));
729
+ return;
730
+ }
731
+ try {
732
+ connectionRef.current?.sendJson({
733
+ type: "reaction",
734
+ userId: client.userId,
735
+ messageId,
736
+ emoji,
737
+ op,
738
+ });
739
+ }
740
+ catch {
741
+ /* socket not open */
742
+ }
743
+ };
744
+ const sendReadReceipt = (messageId) => {
745
+ if (client.isAuthenticated()) {
746
+ void client
747
+ .markReadRest(roomId, messageId)
748
+ .catch((err) =>
749
+ // eslint-disable-next-line no-console
750
+ console.error("[fluxychat] REST sendReadReceipt failed, falling back to WS:", err));
751
+ return;
752
+ }
753
+ try {
754
+ connectionRef.current?.sendJson({
755
+ type: "read",
756
+ userId: client.userId,
757
+ messageId,
758
+ });
759
+ }
760
+ catch {
761
+ /* socket not open */
762
+ }
763
+ };
764
+ const deleteMessage = (messageId) => {
765
+ const tryWsDelete = () => {
766
+ try {
767
+ connectionRef.current?.sendJson({ type: "delete", messageId });
768
+ }
769
+ catch {
770
+ /* socket not open */
771
+ }
772
+ };
773
+ if (client.isAuthenticated()) {
774
+ void client.deleteMessageRest(messageId).catch((err) => {
775
+ // eslint-disable-next-line no-console
776
+ console.error("[fluxychat] REST deleteMessage failed, falling back to WS:", err);
777
+ tryWsDelete();
778
+ });
779
+ return;
780
+ }
781
+ tryWsDelete();
782
+ };
783
+ const invokeAgent = async (content, options) => {
784
+ const targetAgentId = options?.agentId || agentId;
785
+ if (!targetAgentId) {
786
+ throw new Error("invokeAgent requires an agentId in hook options or call options");
787
+ }
788
+ setAgentTyping(true);
789
+ try {
790
+ const result = await client.invokeAgentRest(targetAgentId, roomId, content, {
791
+ replyTo: options?.replyTo,
792
+ });
793
+ return result;
794
+ }
795
+ finally {
796
+ setAgentTyping(false);
797
+ }
798
+ };
799
+ return {
800
+ messages,
801
+ online,
802
+ typingUsers,
803
+ seenBy,
804
+ onlineUsers,
805
+ connected,
806
+ connectionStatus,
807
+ reconnectAttempt,
808
+ connectionError,
809
+ agentTyping,
810
+ typingAgentId: wsTypingAgentId ?? invokeTypingAgentId,
811
+ reactions,
812
+ sendMessage,
813
+ setTyping,
814
+ editMessage,
815
+ sendReaction,
816
+ sendReadReceipt,
817
+ deleteMessage,
818
+ invokeAgent,
819
+ };
820
+ }
821
+ export function useRooms(client) {
822
+ const [rooms, setRooms] = useState([]);
823
+ const [loading, setLoading] = useState(false);
824
+ const [error, setError] = useState(null);
825
+ const load = async () => {
826
+ setLoading(true);
827
+ setError(null);
828
+ try {
829
+ const next = await client.listRooms();
830
+ setRooms(next);
831
+ }
832
+ catch (e) {
833
+ setError(e instanceof Error ? e.message : String(e));
834
+ }
835
+ finally {
836
+ setLoading(false);
837
+ }
838
+ };
839
+ useEffect(() => {
840
+ void load();
841
+ }, [client]);
842
+ return { rooms, loading, error, reload: load };
843
+ }