@fluxy-chat/sdk 0.1.0 → 0.2.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 +39 -2
- package/dist/agent-outbound.d.ts +14 -0
- package/dist/agent-outbound.js +53 -0
- package/dist/index.d.ts +22 -51
- package/dist/index.js +59 -448
- package/dist/jwt-utils.d.ts +8 -0
- package/dist/jwt-utils.js +19 -0
- package/dist/message-history.d.ts +23 -0
- package/dist/message-history.js +21 -0
- package/dist/realtime-provider.d.ts +27 -0
- package/dist/realtime-provider.js +130 -0
- package/dist/room-rest.d.ts +4 -0
- package/dist/room-rest.js +24 -0
- package/dist/use-chat.d.ts +49 -0
- package/dist/use-chat.js +482 -0
- package/dist/use-fluxy-chat.d.ts +14 -0
- package/dist/use-fluxy-chat.js +14 -0
- package/dist/use-rooms.d.ts +9 -0
- package/dist/use-rooms.js +25 -0
- package/package.json +21 -2
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import React from "react";
|
|
4
|
+
import { FluxyChatClient } from "./index";
|
|
5
|
+
import { decodeFluxyJwtPayload, jwtRefreshDelayMs } from "./jwt-utils";
|
|
6
|
+
import { FluxyRealtimeContext } from "./use-fluxy-chat";
|
|
7
|
+
async function resolveAuthToken(provider) {
|
|
8
|
+
if (typeof provider === "string") {
|
|
9
|
+
return { token: provider };
|
|
10
|
+
}
|
|
11
|
+
const result = await provider();
|
|
12
|
+
if (typeof result === "string") {
|
|
13
|
+
return { token: result };
|
|
14
|
+
}
|
|
15
|
+
return result;
|
|
16
|
+
}
|
|
17
|
+
async function fetchConnectSession(connectUrl, init) {
|
|
18
|
+
const res = await fetch(connectUrl, {
|
|
19
|
+
method: "POST",
|
|
20
|
+
headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) },
|
|
21
|
+
body: JSON.stringify({}),
|
|
22
|
+
...init,
|
|
23
|
+
});
|
|
24
|
+
const data = (await res.json().catch(() => ({})));
|
|
25
|
+
if (!res.ok) {
|
|
26
|
+
throw new Error(data.error ?? `Connect failed (${res.status})`);
|
|
27
|
+
}
|
|
28
|
+
if (!data.memberJwt?.trim()) {
|
|
29
|
+
throw new Error("Connect response did not include memberJwt");
|
|
30
|
+
}
|
|
31
|
+
return {
|
|
32
|
+
token: data.memberJwt,
|
|
33
|
+
userId: data.memberUserId,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
export function FluxyRealtimeProvider({ children, workerUrl, authTokenProvider, connectUrl, connectRequestInit, userId: userIdProp, refreshBufferMs = 5 * 60 * 1000, onSessionError, }) {
|
|
37
|
+
const [token, setToken] = React.useState(null);
|
|
38
|
+
const [userId, setUserId] = React.useState(userIdProp ?? "");
|
|
39
|
+
const [refreshKey, setRefreshKey] = React.useState(0);
|
|
40
|
+
const isRefreshingRef = React.useRef(false);
|
|
41
|
+
const providerRef = React.useRef(authTokenProvider);
|
|
42
|
+
const connectUrlRef = React.useRef(connectUrl);
|
|
43
|
+
const connectInitRef = React.useRef(connectRequestInit);
|
|
44
|
+
React.useEffect(() => {
|
|
45
|
+
providerRef.current = authTokenProvider;
|
|
46
|
+
});
|
|
47
|
+
React.useEffect(() => {
|
|
48
|
+
connectUrlRef.current = connectUrl;
|
|
49
|
+
});
|
|
50
|
+
React.useEffect(() => {
|
|
51
|
+
connectInitRef.current = connectRequestInit;
|
|
52
|
+
});
|
|
53
|
+
React.useEffect(() => {
|
|
54
|
+
if (userIdProp)
|
|
55
|
+
setUserId(userIdProp);
|
|
56
|
+
}, [userIdProp]);
|
|
57
|
+
const refreshSession = React.useCallback(() => {
|
|
58
|
+
if (isRefreshingRef.current)
|
|
59
|
+
return;
|
|
60
|
+
setRefreshKey((k) => k + 1);
|
|
61
|
+
}, []);
|
|
62
|
+
React.useEffect(() => {
|
|
63
|
+
if (!authTokenProvider && !connectUrlRef.current)
|
|
64
|
+
return;
|
|
65
|
+
let cancelled = false;
|
|
66
|
+
let timer = null;
|
|
67
|
+
const run = async () => {
|
|
68
|
+
isRefreshingRef.current = true;
|
|
69
|
+
try {
|
|
70
|
+
let session;
|
|
71
|
+
if (connectUrlRef.current) {
|
|
72
|
+
session = await fetchConnectSession(connectUrlRef.current, connectInitRef.current);
|
|
73
|
+
}
|
|
74
|
+
else if (providerRef.current) {
|
|
75
|
+
session = await resolveAuthToken(providerRef.current);
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (cancelled)
|
|
81
|
+
return;
|
|
82
|
+
setToken(session.token);
|
|
83
|
+
const claims = decodeFluxyJwtPayload(session.token);
|
|
84
|
+
const resolvedUserId = session.userId ?? claims.sub ?? userIdProp ?? "";
|
|
85
|
+
if (resolvedUserId)
|
|
86
|
+
setUserId(resolvedUserId);
|
|
87
|
+
if (claims.exp && typeof providerRef.current !== "string") {
|
|
88
|
+
const delay = jwtRefreshDelayMs(claims.exp, refreshBufferMs);
|
|
89
|
+
timer = setTimeout(() => {
|
|
90
|
+
if (!cancelled)
|
|
91
|
+
run();
|
|
92
|
+
}, delay);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
catch (err) {
|
|
96
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
97
|
+
onSessionError?.(error);
|
|
98
|
+
}
|
|
99
|
+
finally {
|
|
100
|
+
if (!cancelled)
|
|
101
|
+
isRefreshingRef.current = false;
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
void run();
|
|
105
|
+
return () => {
|
|
106
|
+
cancelled = true;
|
|
107
|
+
isRefreshingRef.current = false;
|
|
108
|
+
if (timer)
|
|
109
|
+
clearTimeout(timer);
|
|
110
|
+
};
|
|
111
|
+
}, [authTokenProvider, connectUrl, connectRequestInit, refreshBufferMs, refreshKey, userIdProp, onSessionError]);
|
|
112
|
+
const client = React.useMemo(() => {
|
|
113
|
+
if (!token?.trim() || !userId.trim())
|
|
114
|
+
return null;
|
|
115
|
+
return new FluxyChatClient({
|
|
116
|
+
baseUrl: workerUrl.replace(/\/+$/, ""),
|
|
117
|
+
userId,
|
|
118
|
+
token,
|
|
119
|
+
});
|
|
120
|
+
}, [workerUrl, userId, token]);
|
|
121
|
+
const value = React.useMemo(() => ({
|
|
122
|
+
client,
|
|
123
|
+
userId,
|
|
124
|
+
token,
|
|
125
|
+
workerUrl,
|
|
126
|
+
ready: Boolean(client),
|
|
127
|
+
refreshSession,
|
|
128
|
+
}), [client, userId, token, workerUrl, refreshSession]);
|
|
129
|
+
return _jsx(FluxyRealtimeContext.Provider, { value: value, children: children });
|
|
130
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { FluxyRoomMember } from "./index";
|
|
2
|
+
export declare const FLUXY_MAX_MESSAGE_LENGTH = 4000;
|
|
3
|
+
export declare function normalizeRoomMember(raw: Record<string, unknown>): FluxyRoomMember | null;
|
|
4
|
+
export declare function normalizeRoomMembers(rows: unknown[]): FluxyRoomMember[];
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export const FLUXY_MAX_MESSAGE_LENGTH = 4000;
|
|
2
|
+
export function normalizeRoomMember(raw) {
|
|
3
|
+
const userId = String(raw.userId ?? raw.user_id ?? "").trim();
|
|
4
|
+
if (!userId)
|
|
5
|
+
return null;
|
|
6
|
+
const role = String(raw.role ?? "member").trim() || "member";
|
|
7
|
+
const joined_at = typeof raw.joined_at === "string"
|
|
8
|
+
? raw.joined_at
|
|
9
|
+
: typeof raw.joinedAt === "string"
|
|
10
|
+
? raw.joinedAt
|
|
11
|
+
: undefined;
|
|
12
|
+
return { userId, role, joined_at };
|
|
13
|
+
}
|
|
14
|
+
export function normalizeRoomMembers(rows) {
|
|
15
|
+
const out = [];
|
|
16
|
+
for (const row of rows) {
|
|
17
|
+
if (!row || typeof row !== "object")
|
|
18
|
+
continue;
|
|
19
|
+
const member = normalizeRoomMember(row);
|
|
20
|
+
if (member)
|
|
21
|
+
out.push(member);
|
|
22
|
+
}
|
|
23
|
+
return out;
|
|
24
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { FluxyChatAttachment, FluxyChatClient, FluxyChatMessage } from "./index";
|
|
2
|
+
export interface UseChatOptions {
|
|
3
|
+
roomId: string;
|
|
4
|
+
/** Omit when wrapped in `FluxyRealtimeProvider`. */
|
|
5
|
+
client?: FluxyChatClient;
|
|
6
|
+
agentId?: string;
|
|
7
|
+
/** Initial REST page size (default 50). */
|
|
8
|
+
historyLimit?: number;
|
|
9
|
+
}
|
|
10
|
+
export declare function useChat({ roomId, client: clientProp, agentId, historyLimit }: UseChatOptions): {
|
|
11
|
+
messages: FluxyChatMessage[];
|
|
12
|
+
hasMore: boolean;
|
|
13
|
+
isLoadingMore: boolean;
|
|
14
|
+
loadMore: () => Promise<void>;
|
|
15
|
+
online: number;
|
|
16
|
+
typingUsers: Record<string, boolean>;
|
|
17
|
+
seenBy: Record<number, string[]>;
|
|
18
|
+
onlineUsers: string[];
|
|
19
|
+
connected: boolean;
|
|
20
|
+
connectionStatus: "connecting" | "connected" | "reconnecting" | "disconnected" | "polling" | "sse";
|
|
21
|
+
reconnectAttempt: number;
|
|
22
|
+
connectionError: Error | null;
|
|
23
|
+
agentTyping: boolean;
|
|
24
|
+
typingAgentId: string | null;
|
|
25
|
+
reactions: Record<number, Record<string, number>>;
|
|
26
|
+
sendMessage: (content: string, replyTo?: number | null, attachments?: FluxyChatAttachment[]) => void;
|
|
27
|
+
setTyping: (isTyping: boolean) => void;
|
|
28
|
+
editMessage: (messageId: number, content: string) => void;
|
|
29
|
+
sendReaction: (messageId: number, emoji: string, op?: "add" | "remove") => void;
|
|
30
|
+
sendReadReceipt: (messageId: number) => void;
|
|
31
|
+
deleteMessage: (messageId: number) => void;
|
|
32
|
+
invokeAgent: (content: string, options?: {
|
|
33
|
+
agentId?: string;
|
|
34
|
+
replyTo?: number | null;
|
|
35
|
+
}) => Promise<{
|
|
36
|
+
run: {
|
|
37
|
+
id: string;
|
|
38
|
+
status: string;
|
|
39
|
+
latencyMs?: number;
|
|
40
|
+
inputTokens?: number;
|
|
41
|
+
outputTokens?: number;
|
|
42
|
+
estimatedCost?: number;
|
|
43
|
+
iterations?: number;
|
|
44
|
+
toolCalls?: import("./index").FluxyChatToolCall[];
|
|
45
|
+
createdAt: string;
|
|
46
|
+
};
|
|
47
|
+
message: FluxyChatMessage;
|
|
48
|
+
}>;
|
|
49
|
+
};
|
package/dist/use-chat.js
ADDED
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import React from "react";
|
|
3
|
+
import { FluxyAuthError, FluxySendError } from "./errors";
|
|
4
|
+
import { mergeMessagesChronological, sortMessagesChronological, } from "./message-history";
|
|
5
|
+
import { useFluxyChatOptional } from "./use-fluxy-chat";
|
|
6
|
+
export function useChat({ roomId, client: clientProp, agentId, historyLimit = 50 }) {
|
|
7
|
+
const realtime = useFluxyChatOptional();
|
|
8
|
+
const client = clientProp ?? realtime?.client ?? null;
|
|
9
|
+
const [messages, setMessages] = React.useState([]);
|
|
10
|
+
const [hasMore, setHasMore] = React.useState(false);
|
|
11
|
+
const [isLoadingMore, setIsLoadingMore] = React.useState(false);
|
|
12
|
+
const [online, setOnline] = React.useState(0);
|
|
13
|
+
const [typingUsers, setTypingUsers] = React.useState({});
|
|
14
|
+
const [seenBy, setSeenBy] = React.useState({});
|
|
15
|
+
const [onlineUsers, setOnlineUsers] = React.useState([]);
|
|
16
|
+
const [connected, setConnected] = React.useState(false);
|
|
17
|
+
const [connectionStatus, setConnectionStatus] = React.useState("connecting");
|
|
18
|
+
const [reconnectAttempt, setReconnectAttempt] = React.useState(0);
|
|
19
|
+
const [connectionError, setConnectionError] = React.useState(null);
|
|
20
|
+
const [agentTyping, setAgentTyping] = React.useState(false);
|
|
21
|
+
const [wsTypingAgentId, setWsTypingAgentId] = React.useState(null);
|
|
22
|
+
const [invokeTypingAgentId, setInvokeTypingAgentId] = React.useState(null);
|
|
23
|
+
const [reactions, setReactions] = React.useState({});
|
|
24
|
+
const connectionRef = React.useRef(null);
|
|
25
|
+
const sseRef = React.useRef(null);
|
|
26
|
+
const pollTimerRef = React.useRef(null);
|
|
27
|
+
const loadMore = React.useCallback(async () => {
|
|
28
|
+
if (!client || isLoadingMore || !hasMore)
|
|
29
|
+
return;
|
|
30
|
+
const trimmedRoomId = roomId.trim();
|
|
31
|
+
if (!trimmedRoomId)
|
|
32
|
+
return;
|
|
33
|
+
const chronological = sortMessagesChronological(messages);
|
|
34
|
+
const oldest = chronological[0];
|
|
35
|
+
if (!oldest?.createdAt)
|
|
36
|
+
return;
|
|
37
|
+
setIsLoadingMore(true);
|
|
38
|
+
try {
|
|
39
|
+
const older = await client.fetchMessages(trimmedRoomId, {
|
|
40
|
+
limit: historyLimit,
|
|
41
|
+
before: oldest.createdAt,
|
|
42
|
+
});
|
|
43
|
+
setMessages((prev) => mergeMessagesChronological(prev, older));
|
|
44
|
+
setHasMore(older.length >= historyLimit);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
/* keep existing list */
|
|
48
|
+
}
|
|
49
|
+
finally {
|
|
50
|
+
setIsLoadingMore(false);
|
|
51
|
+
}
|
|
52
|
+
}, [client, hasMore, historyLimit, isLoadingMore, messages, roomId]);
|
|
53
|
+
React.useEffect(() => {
|
|
54
|
+
let active = true;
|
|
55
|
+
const trimmedRoomId = roomId.trim();
|
|
56
|
+
const MAX_WS_RECONNECT_ATTEMPTS = 6;
|
|
57
|
+
const POLL_INTERVAL_MS = 4000;
|
|
58
|
+
const stopPollingFallback = () => {
|
|
59
|
+
if (pollTimerRef.current) {
|
|
60
|
+
clearInterval(pollTimerRef.current);
|
|
61
|
+
pollTimerRef.current = null;
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
const stopSSEFallback = () => {
|
|
65
|
+
if (sseRef.current) {
|
|
66
|
+
sseRef.current.close();
|
|
67
|
+
sseRef.current = null;
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
const startPollingFallback = () => {
|
|
71
|
+
if (!client)
|
|
72
|
+
return;
|
|
73
|
+
stopPollingFallback();
|
|
74
|
+
stopSSEFallback();
|
|
75
|
+
const tick = async () => {
|
|
76
|
+
if (!active || !client)
|
|
77
|
+
return;
|
|
78
|
+
try {
|
|
79
|
+
const next = await client.fetchMessages(trimmedRoomId, { limit: historyLimit });
|
|
80
|
+
if (active) {
|
|
81
|
+
setMessages(next);
|
|
82
|
+
setHasMore(next.length >= historyLimit);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
/* ignore transient poll errors */
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
void tick();
|
|
90
|
+
pollTimerRef.current = setInterval(tick, POLL_INTERVAL_MS);
|
|
91
|
+
};
|
|
92
|
+
const startSSEFallback = () => {
|
|
93
|
+
if (!client)
|
|
94
|
+
return;
|
|
95
|
+
stopPollingFallback();
|
|
96
|
+
stopSSEFallback();
|
|
97
|
+
const es = client.connectSSE(trimmedRoomId);
|
|
98
|
+
if (!es) {
|
|
99
|
+
startPollingFallback();
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
sseRef.current = es;
|
|
103
|
+
setConnectionStatus("sse");
|
|
104
|
+
es.addEventListener("message", (event) => {
|
|
105
|
+
if (!active)
|
|
106
|
+
return;
|
|
107
|
+
try {
|
|
108
|
+
const data = JSON.parse(event.data);
|
|
109
|
+
handleEvent(data);
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
/* ignore malformed SSE events */
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
es.addEventListener("error", () => {
|
|
116
|
+
if (!active)
|
|
117
|
+
return;
|
|
118
|
+
stopSSEFallback();
|
|
119
|
+
startPollingFallback();
|
|
120
|
+
setConnectionStatus("polling");
|
|
121
|
+
});
|
|
122
|
+
};
|
|
123
|
+
const handleEvent = (data) => {
|
|
124
|
+
if (data.type === "history") {
|
|
125
|
+
setMessages((prev) => mergeMessagesChronological(prev, sortMessagesChronological(data.messages)));
|
|
126
|
+
}
|
|
127
|
+
else if (data.type === "message") {
|
|
128
|
+
setMessages((prev) => {
|
|
129
|
+
const idx = prev.findIndex((m) => m.id === data.id);
|
|
130
|
+
if (idx >= 0) {
|
|
131
|
+
const next = [...prev];
|
|
132
|
+
next[idx] = { ...next[idx], ...data };
|
|
133
|
+
return sortMessagesChronological(next);
|
|
134
|
+
}
|
|
135
|
+
return sortMessagesChronological([...prev, data]);
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
else if (data.type === "presence") {
|
|
139
|
+
setOnline(data.online);
|
|
140
|
+
if (data.users)
|
|
141
|
+
setOnlineUsers(data.users);
|
|
142
|
+
}
|
|
143
|
+
else if (data.type === "typing") {
|
|
144
|
+
setTypingUsers((prev) => ({
|
|
145
|
+
...prev,
|
|
146
|
+
[data.userId]: data.isTyping,
|
|
147
|
+
}));
|
|
148
|
+
}
|
|
149
|
+
else if (data.type === "agentTyping") {
|
|
150
|
+
setAgentTyping(data.isTyping);
|
|
151
|
+
setWsTypingAgentId(data.isTyping ? data.agentId : null);
|
|
152
|
+
}
|
|
153
|
+
else if (data.type === "edit") {
|
|
154
|
+
setMessages((prev) => prev.map((m) => m.id === data.id
|
|
155
|
+
? {
|
|
156
|
+
...m,
|
|
157
|
+
content: data.content,
|
|
158
|
+
editedAt: data.editedAt,
|
|
159
|
+
streaming: data.streaming ?? false,
|
|
160
|
+
}
|
|
161
|
+
: m));
|
|
162
|
+
}
|
|
163
|
+
else if (data.type === "reaction") {
|
|
164
|
+
setReactions((prev) => {
|
|
165
|
+
const byMessage = { ...prev };
|
|
166
|
+
const current = { ...(byMessage[data.messageId] || {}) };
|
|
167
|
+
const existingCount = current[data.emoji] || 0;
|
|
168
|
+
if (data.op === "remove") {
|
|
169
|
+
const nextCount = Math.max(existingCount - 1, 0);
|
|
170
|
+
if (nextCount === 0) {
|
|
171
|
+
delete current[data.emoji];
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
current[data.emoji] = nextCount;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
else {
|
|
178
|
+
current[data.emoji] = existingCount + 1;
|
|
179
|
+
}
|
|
180
|
+
if (Object.keys(current).length === 0) {
|
|
181
|
+
delete byMessage[data.messageId];
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
byMessage[data.messageId] = current;
|
|
185
|
+
}
|
|
186
|
+
return byMessage;
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
else if (data.type === "read") {
|
|
190
|
+
setSeenBy((prev) => {
|
|
191
|
+
const existing = prev[data.messageId] || [];
|
|
192
|
+
if (existing.includes(data.userId))
|
|
193
|
+
return prev;
|
|
194
|
+
return {
|
|
195
|
+
...prev,
|
|
196
|
+
[data.messageId]: [...existing, data.userId],
|
|
197
|
+
};
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
else if (data.type === "delete") {
|
|
201
|
+
if (data.hard) {
|
|
202
|
+
setMessages((prev) => prev.filter((m) => m.id !== data.id));
|
|
203
|
+
}
|
|
204
|
+
else {
|
|
205
|
+
setMessages((prev) => prev.map((m) => m.id === data.id
|
|
206
|
+
? { ...m, content: "[deleted]", deletedAt: data.deletedAt }
|
|
207
|
+
: m));
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
if (!client || !trimmedRoomId || !client.isAuthenticated()) {
|
|
212
|
+
setMessages([]);
|
|
213
|
+
setHasMore(false);
|
|
214
|
+
setConnected(false);
|
|
215
|
+
setConnectionStatus("disconnected");
|
|
216
|
+
return () => {
|
|
217
|
+
active = false;
|
|
218
|
+
stopPollingFallback();
|
|
219
|
+
stopSSEFallback();
|
|
220
|
+
connectionRef.current?.close();
|
|
221
|
+
connectionRef.current = null;
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
client
|
|
225
|
+
.fetchMessages(trimmedRoomId, { limit: historyLimit })
|
|
226
|
+
.then((initial) => {
|
|
227
|
+
if (!active)
|
|
228
|
+
return;
|
|
229
|
+
setMessages(initial);
|
|
230
|
+
setHasMore(initial.length >= historyLimit);
|
|
231
|
+
})
|
|
232
|
+
.catch(() => {
|
|
233
|
+
/* history load is best-effort until member JWT + room are ready */
|
|
234
|
+
});
|
|
235
|
+
const connection = client.connectRoom(trimmedRoomId, {
|
|
236
|
+
maxReconnectAttempts: MAX_WS_RECONNECT_ATTEMPTS,
|
|
237
|
+
historyLimit,
|
|
238
|
+
onStatusChange: (status) => {
|
|
239
|
+
if (!active)
|
|
240
|
+
return;
|
|
241
|
+
if (status === "connected") {
|
|
242
|
+
setConnected(true);
|
|
243
|
+
setConnectionStatus("connected");
|
|
244
|
+
setReconnectAttempt(0);
|
|
245
|
+
setConnectionError(null);
|
|
246
|
+
stopPollingFallback();
|
|
247
|
+
stopSSEFallback();
|
|
248
|
+
}
|
|
249
|
+
else if (status === "connecting") {
|
|
250
|
+
setConnectionStatus("connecting");
|
|
251
|
+
setConnected(false);
|
|
252
|
+
}
|
|
253
|
+
else if (status === "reconnecting") {
|
|
254
|
+
setConnectionStatus("reconnecting");
|
|
255
|
+
setConnected(false);
|
|
256
|
+
setReconnectAttempt(connection.reconnectAttempts);
|
|
257
|
+
}
|
|
258
|
+
else if (status === "disconnected") {
|
|
259
|
+
setConnected(false);
|
|
260
|
+
setConnectionStatus("disconnected");
|
|
261
|
+
}
|
|
262
|
+
},
|
|
263
|
+
onAuthError: (err) => {
|
|
264
|
+
if (!active)
|
|
265
|
+
return;
|
|
266
|
+
setConnectionError(err);
|
|
267
|
+
setConnected(false);
|
|
268
|
+
setConnectionStatus("disconnected");
|
|
269
|
+
realtime?.refreshSession();
|
|
270
|
+
},
|
|
271
|
+
onConnectionError: (err) => {
|
|
272
|
+
if (!active)
|
|
273
|
+
return;
|
|
274
|
+
if (!(err instanceof FluxyAuthError)) {
|
|
275
|
+
setConnectionError(err);
|
|
276
|
+
}
|
|
277
|
+
},
|
|
278
|
+
onReconnectFailed: () => {
|
|
279
|
+
if (!active)
|
|
280
|
+
return;
|
|
281
|
+
setReconnectAttempt(connection.reconnectAttempts);
|
|
282
|
+
if (client.isAuthenticated()) {
|
|
283
|
+
startSSEFallback();
|
|
284
|
+
}
|
|
285
|
+
else {
|
|
286
|
+
startPollingFallback();
|
|
287
|
+
}
|
|
288
|
+
},
|
|
289
|
+
});
|
|
290
|
+
connection.addEventListener("message", (data) => {
|
|
291
|
+
if (!active)
|
|
292
|
+
return;
|
|
293
|
+
handleEvent(data);
|
|
294
|
+
});
|
|
295
|
+
connectionRef.current = connection;
|
|
296
|
+
connection.connect();
|
|
297
|
+
return () => {
|
|
298
|
+
active = false;
|
|
299
|
+
stopPollingFallback();
|
|
300
|
+
stopSSEFallback();
|
|
301
|
+
connection.close();
|
|
302
|
+
connectionRef.current = null;
|
|
303
|
+
setConnected(false);
|
|
304
|
+
setConnectionStatus("disconnected");
|
|
305
|
+
};
|
|
306
|
+
}, [roomId, client, historyLimit, realtime?.refreshSession]);
|
|
307
|
+
const sendMessage = (content, replyTo, attachments) => {
|
|
308
|
+
if (!client)
|
|
309
|
+
return;
|
|
310
|
+
if (client.isAuthenticated()) {
|
|
311
|
+
void client
|
|
312
|
+
.createMessage(roomId, content, replyTo, attachments)
|
|
313
|
+
.catch((err) =>
|
|
314
|
+
// eslint-disable-next-line no-console
|
|
315
|
+
console.error("[fluxychat] REST sendMessage failed, falling back to WS:", err));
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
try {
|
|
319
|
+
connectionRef.current?.sendJson({
|
|
320
|
+
type: "message",
|
|
321
|
+
userId: client.userId,
|
|
322
|
+
content,
|
|
323
|
+
parentId: replyTo ?? null,
|
|
324
|
+
attachments: attachments ?? [],
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
catch (err) {
|
|
328
|
+
if (err instanceof FluxySendError)
|
|
329
|
+
return;
|
|
330
|
+
throw err;
|
|
331
|
+
}
|
|
332
|
+
};
|
|
333
|
+
const setTyping = (isTyping) => {
|
|
334
|
+
if (!client)
|
|
335
|
+
return;
|
|
336
|
+
try {
|
|
337
|
+
connectionRef.current?.sendJson({
|
|
338
|
+
type: "typing",
|
|
339
|
+
userId: client.userId,
|
|
340
|
+
isTyping,
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
catch {
|
|
344
|
+
/* socket not open */
|
|
345
|
+
}
|
|
346
|
+
};
|
|
347
|
+
const editMessage = (messageId, content) => {
|
|
348
|
+
if (!client)
|
|
349
|
+
return;
|
|
350
|
+
const tryWsEdit = () => {
|
|
351
|
+
try {
|
|
352
|
+
connectionRef.current?.sendJson({
|
|
353
|
+
type: "edit",
|
|
354
|
+
userId: client.userId,
|
|
355
|
+
messageId,
|
|
356
|
+
content,
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
catch {
|
|
360
|
+
/* socket not open */
|
|
361
|
+
}
|
|
362
|
+
};
|
|
363
|
+
if (client.isAuthenticated()) {
|
|
364
|
+
void client.editMessageRest(messageId, content).catch((err) => {
|
|
365
|
+
// eslint-disable-next-line no-console
|
|
366
|
+
console.error("[fluxychat] REST editMessage failed, falling back to WS:", err);
|
|
367
|
+
tryWsEdit();
|
|
368
|
+
});
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
tryWsEdit();
|
|
372
|
+
};
|
|
373
|
+
const sendReaction = (messageId, emoji, op = "add") => {
|
|
374
|
+
if (!client)
|
|
375
|
+
return;
|
|
376
|
+
if (client.isAuthenticated()) {
|
|
377
|
+
void client
|
|
378
|
+
.sendReactionRest(messageId, emoji, op)
|
|
379
|
+
.catch((err) =>
|
|
380
|
+
// eslint-disable-next-line no-console
|
|
381
|
+
console.error("[fluxychat] REST sendReaction failed, falling back to WS:", err));
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
try {
|
|
385
|
+
connectionRef.current?.sendJson({
|
|
386
|
+
type: "reaction",
|
|
387
|
+
userId: client.userId,
|
|
388
|
+
messageId,
|
|
389
|
+
emoji,
|
|
390
|
+
op,
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
catch {
|
|
394
|
+
/* socket not open */
|
|
395
|
+
}
|
|
396
|
+
};
|
|
397
|
+
const sendReadReceipt = (messageId) => {
|
|
398
|
+
if (!client)
|
|
399
|
+
return;
|
|
400
|
+
if (client.isAuthenticated()) {
|
|
401
|
+
void client
|
|
402
|
+
.markReadRest(roomId, messageId)
|
|
403
|
+
.catch((err) =>
|
|
404
|
+
// eslint-disable-next-line no-console
|
|
405
|
+
console.error("[fluxychat] REST sendReadReceipt failed, falling back to WS:", err));
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
try {
|
|
409
|
+
connectionRef.current?.sendJson({
|
|
410
|
+
type: "read",
|
|
411
|
+
userId: client.userId,
|
|
412
|
+
messageId,
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
catch {
|
|
416
|
+
/* socket not open */
|
|
417
|
+
}
|
|
418
|
+
};
|
|
419
|
+
const deleteMessage = (messageId) => {
|
|
420
|
+
if (!client)
|
|
421
|
+
return;
|
|
422
|
+
const tryWsDelete = () => {
|
|
423
|
+
try {
|
|
424
|
+
connectionRef.current?.sendJson({ type: "delete", messageId });
|
|
425
|
+
}
|
|
426
|
+
catch {
|
|
427
|
+
/* socket not open */
|
|
428
|
+
}
|
|
429
|
+
};
|
|
430
|
+
if (client.isAuthenticated()) {
|
|
431
|
+
void client.deleteMessageRest(messageId).catch((err) => {
|
|
432
|
+
// eslint-disable-next-line no-console
|
|
433
|
+
console.error("[fluxychat] REST deleteMessage failed, falling back to WS:", err);
|
|
434
|
+
tryWsDelete();
|
|
435
|
+
});
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
tryWsDelete();
|
|
439
|
+
};
|
|
440
|
+
const invokeAgent = async (content, options) => {
|
|
441
|
+
if (!client) {
|
|
442
|
+
throw new Error("useChat requires a FluxyChatClient or FluxyRealtimeProvider");
|
|
443
|
+
}
|
|
444
|
+
const targetAgentId = options?.agentId || agentId;
|
|
445
|
+
if (!targetAgentId) {
|
|
446
|
+
throw new Error("invokeAgent requires an agentId in hook options or call options");
|
|
447
|
+
}
|
|
448
|
+
setAgentTyping(true);
|
|
449
|
+
try {
|
|
450
|
+
return await client.invokeAgentRest(targetAgentId, roomId, content, {
|
|
451
|
+
replyTo: options?.replyTo,
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
finally {
|
|
455
|
+
setAgentTyping(false);
|
|
456
|
+
}
|
|
457
|
+
};
|
|
458
|
+
return {
|
|
459
|
+
messages,
|
|
460
|
+
hasMore,
|
|
461
|
+
isLoadingMore,
|
|
462
|
+
loadMore,
|
|
463
|
+
online,
|
|
464
|
+
typingUsers,
|
|
465
|
+
seenBy,
|
|
466
|
+
onlineUsers,
|
|
467
|
+
connected,
|
|
468
|
+
connectionStatus,
|
|
469
|
+
reconnectAttempt,
|
|
470
|
+
connectionError,
|
|
471
|
+
agentTyping,
|
|
472
|
+
typingAgentId: wsTypingAgentId ?? invokeTypingAgentId,
|
|
473
|
+
reactions,
|
|
474
|
+
sendMessage,
|
|
475
|
+
setTyping,
|
|
476
|
+
editMessage,
|
|
477
|
+
sendReaction,
|
|
478
|
+
sendReadReceipt,
|
|
479
|
+
deleteMessage,
|
|
480
|
+
invokeAgent,
|
|
481
|
+
};
|
|
482
|
+
}
|