@pedi/chika-sdk 1.0.2 → 1.0.5
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 +26 -0
- package/dist/index.d.mts +36 -8
- package/dist/index.d.ts +36 -8
- package/dist/index.js +282 -75
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +279 -74
- package/dist/index.mjs.map +1 -1
- package/package.json +7 -2
package/README.md
CHANGED
|
@@ -19,6 +19,7 @@ Provides a drop-in React hook (`useChat`) that connects your React Native app to
|
|
|
19
19
|
|
|
20
20
|
- `useChat<D>()` React hook with full TypeScript generics
|
|
21
21
|
- `createChatSession<D>()` imperative API for non-React usage
|
|
22
|
+
- **`useUnread()` hook** — Real-time unread count tracking via dedicated SSE stream with passive listening support
|
|
22
23
|
- Automatic SSE reconnection with configurable delay
|
|
23
24
|
- Platform-aware AppState handling (iOS vs Android)
|
|
24
25
|
- Optimistic message sending with deduplication
|
|
@@ -45,6 +46,31 @@ function ChatScreen({ bookingId, user }) {
|
|
|
45
46
|
}
|
|
46
47
|
```
|
|
47
48
|
|
|
49
|
+
### Unread Notifications
|
|
50
|
+
|
|
51
|
+
Monitor unread message counts in real-time — even for channels the user hasn't joined yet:
|
|
52
|
+
|
|
53
|
+
```typescript
|
|
54
|
+
import { useUnread } from '@pedi/chika-sdk';
|
|
55
|
+
|
|
56
|
+
function ChatListItem({ channelId, userId, config }) {
|
|
57
|
+
const { unreadCount, hasUnread, lastMessageAt } = useUnread({
|
|
58
|
+
config,
|
|
59
|
+
channelId,
|
|
60
|
+
participantId: userId,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<View>
|
|
65
|
+
<Text>{channelId}</Text>
|
|
66
|
+
{hasUnread && <Badge count={unreadCount} />}
|
|
67
|
+
</View>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
The hook handles SSE reconnection and AppState-aware lifecycle management automatically. Disable it with `enabled: false` when `useChat` is already active on the same channel.
|
|
73
|
+
|
|
48
74
|
**Peer dependencies:** `react >= 18`, `react-native >= 0.72`
|
|
49
75
|
|
|
50
76
|
## Documentation
|
package/dist/index.d.mts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { ChatManifest, ChatDomain, DefaultDomain, Participant, Message, MessageAttributes, SendMessageResponse } from '@pedi/chika-types';
|
|
2
|
-
export { ChatBucket, ChatDomain, ChatManifest, DefaultDomain, Message, MessageAttributes, Participant, PediChat, PediLocation, PediMessageAttributes, PediMessageType, PediParticipantMeta, PediRole, PediVehicle, SendMessageResponse } from '@pedi/chika-types';
|
|
2
|
+
export { ChatBucket, ChatDomain, ChatManifest, DefaultDomain, MarkReadRequest, Message, MessageAttributes, Participant, PediChat, PediLocation, PediMessageAttributes, PediMessageType, PediParticipantMeta, PediRole, PediVehicle, SSEUnreadClearEvent, SSEUnreadEvent, SSEUnreadUpdateEvent, SendMessageResponse, UnreadCountResponse } from '@pedi/chika-types';
|
|
3
3
|
|
|
4
4
|
type ChatStatus = 'connecting' | 'connected' | 'reconnecting' | 'disconnected' | 'closed' | 'error';
|
|
5
5
|
/**
|
|
@@ -49,6 +49,20 @@ interface UseChatReturn<D extends ChatDomain = DefaultDomain> {
|
|
|
49
49
|
*/
|
|
50
50
|
declare function useChat<D extends ChatDomain = DefaultDomain>({ config, channelId, profile, onMessage }: UseChatOptions<D>): UseChatReturn<D>;
|
|
51
51
|
|
|
52
|
+
interface UseUnreadOptions {
|
|
53
|
+
config: ChatConfig;
|
|
54
|
+
channelId: string;
|
|
55
|
+
participantId: string;
|
|
56
|
+
enabled?: boolean;
|
|
57
|
+
}
|
|
58
|
+
interface UseUnreadReturn {
|
|
59
|
+
unreadCount: number;
|
|
60
|
+
hasUnread: boolean;
|
|
61
|
+
lastMessageAt: string | null;
|
|
62
|
+
error: Error | null;
|
|
63
|
+
}
|
|
64
|
+
declare function useUnread(options: UseUnreadOptions): UseUnreadReturn;
|
|
65
|
+
|
|
52
66
|
interface SessionCallbacks<D extends ChatDomain = DefaultDomain> {
|
|
53
67
|
onMessage: (message: Message<D>) => void;
|
|
54
68
|
onStatusChange: (status: ChatStatus) => void;
|
|
@@ -61,14 +75,9 @@ interface ChatSession<D extends ChatDomain = DefaultDomain> {
|
|
|
61
75
|
initialParticipants: Participant<D>[];
|
|
62
76
|
initialMessages: Message<D>[];
|
|
63
77
|
sendMessage: (type: D['messageType'], body: string, attributes?: MessageAttributes<D>) => Promise<SendMessageResponse>;
|
|
78
|
+
markAsRead: (messageId: string) => Promise<void>;
|
|
64
79
|
disconnect: () => void;
|
|
65
80
|
}
|
|
66
|
-
/**
|
|
67
|
-
* Creates an imperative chat session with SSE streaming and managed reconnection.
|
|
68
|
-
* Lower-level API — prefer `useChat` hook for React Native components.
|
|
69
|
-
*
|
|
70
|
-
* @template D - Chat domain type. Defaults to DefaultDomain.
|
|
71
|
-
*/
|
|
72
81
|
declare function createChatSession<D extends ChatDomain = DefaultDomain>(config: ChatConfig, channelId: string, profile: Participant<D>, callbacks: SessionCallbacks<D>): Promise<ChatSession<D>>;
|
|
73
82
|
|
|
74
83
|
/**
|
|
@@ -82,6 +91,25 @@ declare function createChatSession<D extends ChatDomain = DefaultDomain>(config:
|
|
|
82
91
|
declare function createManifest(serverUrl: string): ChatManifest;
|
|
83
92
|
declare function resolveServerUrl(manifest: ChatManifest, channelId: string): string;
|
|
84
93
|
|
|
94
|
+
interface SSEConnectionConfig {
|
|
95
|
+
url: string;
|
|
96
|
+
headers?: Record<string, string>;
|
|
97
|
+
reconnectDelayMs?: number;
|
|
98
|
+
lastEventId?: string;
|
|
99
|
+
customEvents?: string[];
|
|
100
|
+
}
|
|
101
|
+
interface SSEConnectionCallbacks {
|
|
102
|
+
onOpen?: () => void;
|
|
103
|
+
onEvent: (eventType: string, data: string, lastEventId?: string) => void;
|
|
104
|
+
onError?: (error: Error) => void;
|
|
105
|
+
onClosed?: () => void;
|
|
106
|
+
onReconnecting?: () => void;
|
|
107
|
+
}
|
|
108
|
+
interface SSEConnection {
|
|
109
|
+
close: () => void;
|
|
110
|
+
}
|
|
111
|
+
declare function createSSEConnection(config: SSEConnectionConfig, callbacks: SSEConnectionCallbacks): SSEConnection;
|
|
112
|
+
|
|
85
113
|
declare class ChatDisconnectedError extends Error {
|
|
86
114
|
readonly status: ChatStatus;
|
|
87
115
|
constructor(status: ChatStatus);
|
|
@@ -91,4 +119,4 @@ declare class ChannelClosedError extends Error {
|
|
|
91
119
|
constructor(channelId: string);
|
|
92
120
|
}
|
|
93
121
|
|
|
94
|
-
export { ChannelClosedError, type ChatConfig, ChatDisconnectedError, type ChatSession, type ChatStatus, type SessionCallbacks, type UseChatOptions, type UseChatReturn, createChatSession, createManifest, resolveServerUrl, useChat };
|
|
122
|
+
export { ChannelClosedError, type ChatConfig, ChatDisconnectedError, type ChatSession, type ChatStatus, type SSEConnection, type SSEConnectionCallbacks, type SSEConnectionConfig, type SessionCallbacks, type UseChatOptions, type UseChatReturn, type UseUnreadOptions, type UseUnreadReturn, createChatSession, createManifest, createSSEConnection, resolveServerUrl, useChat, useUnread };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { ChatManifest, ChatDomain, DefaultDomain, Participant, Message, MessageAttributes, SendMessageResponse } from '@pedi/chika-types';
|
|
2
|
-
export { ChatBucket, ChatDomain, ChatManifest, DefaultDomain, Message, MessageAttributes, Participant, PediChat, PediLocation, PediMessageAttributes, PediMessageType, PediParticipantMeta, PediRole, PediVehicle, SendMessageResponse } from '@pedi/chika-types';
|
|
2
|
+
export { ChatBucket, ChatDomain, ChatManifest, DefaultDomain, MarkReadRequest, Message, MessageAttributes, Participant, PediChat, PediLocation, PediMessageAttributes, PediMessageType, PediParticipantMeta, PediRole, PediVehicle, SSEUnreadClearEvent, SSEUnreadEvent, SSEUnreadUpdateEvent, SendMessageResponse, UnreadCountResponse } from '@pedi/chika-types';
|
|
3
3
|
|
|
4
4
|
type ChatStatus = 'connecting' | 'connected' | 'reconnecting' | 'disconnected' | 'closed' | 'error';
|
|
5
5
|
/**
|
|
@@ -49,6 +49,20 @@ interface UseChatReturn<D extends ChatDomain = DefaultDomain> {
|
|
|
49
49
|
*/
|
|
50
50
|
declare function useChat<D extends ChatDomain = DefaultDomain>({ config, channelId, profile, onMessage }: UseChatOptions<D>): UseChatReturn<D>;
|
|
51
51
|
|
|
52
|
+
interface UseUnreadOptions {
|
|
53
|
+
config: ChatConfig;
|
|
54
|
+
channelId: string;
|
|
55
|
+
participantId: string;
|
|
56
|
+
enabled?: boolean;
|
|
57
|
+
}
|
|
58
|
+
interface UseUnreadReturn {
|
|
59
|
+
unreadCount: number;
|
|
60
|
+
hasUnread: boolean;
|
|
61
|
+
lastMessageAt: string | null;
|
|
62
|
+
error: Error | null;
|
|
63
|
+
}
|
|
64
|
+
declare function useUnread(options: UseUnreadOptions): UseUnreadReturn;
|
|
65
|
+
|
|
52
66
|
interface SessionCallbacks<D extends ChatDomain = DefaultDomain> {
|
|
53
67
|
onMessage: (message: Message<D>) => void;
|
|
54
68
|
onStatusChange: (status: ChatStatus) => void;
|
|
@@ -61,14 +75,9 @@ interface ChatSession<D extends ChatDomain = DefaultDomain> {
|
|
|
61
75
|
initialParticipants: Participant<D>[];
|
|
62
76
|
initialMessages: Message<D>[];
|
|
63
77
|
sendMessage: (type: D['messageType'], body: string, attributes?: MessageAttributes<D>) => Promise<SendMessageResponse>;
|
|
78
|
+
markAsRead: (messageId: string) => Promise<void>;
|
|
64
79
|
disconnect: () => void;
|
|
65
80
|
}
|
|
66
|
-
/**
|
|
67
|
-
* Creates an imperative chat session with SSE streaming and managed reconnection.
|
|
68
|
-
* Lower-level API — prefer `useChat` hook for React Native components.
|
|
69
|
-
*
|
|
70
|
-
* @template D - Chat domain type. Defaults to DefaultDomain.
|
|
71
|
-
*/
|
|
72
81
|
declare function createChatSession<D extends ChatDomain = DefaultDomain>(config: ChatConfig, channelId: string, profile: Participant<D>, callbacks: SessionCallbacks<D>): Promise<ChatSession<D>>;
|
|
73
82
|
|
|
74
83
|
/**
|
|
@@ -82,6 +91,25 @@ declare function createChatSession<D extends ChatDomain = DefaultDomain>(config:
|
|
|
82
91
|
declare function createManifest(serverUrl: string): ChatManifest;
|
|
83
92
|
declare function resolveServerUrl(manifest: ChatManifest, channelId: string): string;
|
|
84
93
|
|
|
94
|
+
interface SSEConnectionConfig {
|
|
95
|
+
url: string;
|
|
96
|
+
headers?: Record<string, string>;
|
|
97
|
+
reconnectDelayMs?: number;
|
|
98
|
+
lastEventId?: string;
|
|
99
|
+
customEvents?: string[];
|
|
100
|
+
}
|
|
101
|
+
interface SSEConnectionCallbacks {
|
|
102
|
+
onOpen?: () => void;
|
|
103
|
+
onEvent: (eventType: string, data: string, lastEventId?: string) => void;
|
|
104
|
+
onError?: (error: Error) => void;
|
|
105
|
+
onClosed?: () => void;
|
|
106
|
+
onReconnecting?: () => void;
|
|
107
|
+
}
|
|
108
|
+
interface SSEConnection {
|
|
109
|
+
close: () => void;
|
|
110
|
+
}
|
|
111
|
+
declare function createSSEConnection(config: SSEConnectionConfig, callbacks: SSEConnectionCallbacks): SSEConnection;
|
|
112
|
+
|
|
85
113
|
declare class ChatDisconnectedError extends Error {
|
|
86
114
|
readonly status: ChatStatus;
|
|
87
115
|
constructor(status: ChatStatus);
|
|
@@ -91,4 +119,4 @@ declare class ChannelClosedError extends Error {
|
|
|
91
119
|
constructor(channelId: string);
|
|
92
120
|
}
|
|
93
121
|
|
|
94
|
-
export { ChannelClosedError, type ChatConfig, ChatDisconnectedError, type ChatSession, type ChatStatus, type SessionCallbacks, type UseChatOptions, type UseChatReturn, createChatSession, createManifest, resolveServerUrl, useChat };
|
|
122
|
+
export { ChannelClosedError, type ChatConfig, ChatDisconnectedError, type ChatSession, type ChatStatus, type SSEConnection, type SSEConnectionCallbacks, type SSEConnectionConfig, type SessionCallbacks, type UseChatOptions, type UseChatReturn, type UseUnreadOptions, type UseUnreadReturn, createChatSession, createManifest, createSSEConnection, resolveServerUrl, useChat, useUnread };
|
package/dist/index.js
CHANGED
|
@@ -34,8 +34,10 @@ __export(index_exports, {
|
|
|
34
34
|
ChatDisconnectedError: () => ChatDisconnectedError,
|
|
35
35
|
createChatSession: () => createChatSession,
|
|
36
36
|
createManifest: () => createManifest,
|
|
37
|
+
createSSEConnection: () => createSSEConnection,
|
|
37
38
|
resolveServerUrl: () => resolveServerUrl,
|
|
38
|
-
useChat: () => useChat
|
|
39
|
+
useChat: () => useChat,
|
|
40
|
+
useUnread: () => useUnread
|
|
39
41
|
});
|
|
40
42
|
module.exports = __toCommonJS(index_exports);
|
|
41
43
|
|
|
@@ -59,9 +61,6 @@ var ChannelClosedError = class extends Error {
|
|
|
59
61
|
}
|
|
60
62
|
};
|
|
61
63
|
|
|
62
|
-
// src/session.ts
|
|
63
|
-
var import_react_native_sse = __toESM(require("react-native-sse"));
|
|
64
|
-
|
|
65
64
|
// src/resolve-url.ts
|
|
66
65
|
function createManifest(serverUrl) {
|
|
67
66
|
return { buckets: [{ group: "default", range: [0, 99], server_url: serverUrl }] };
|
|
@@ -75,86 +74,68 @@ function resolveServerUrl(manifest, channelId) {
|
|
|
75
74
|
return bucket.server_url;
|
|
76
75
|
}
|
|
77
76
|
|
|
78
|
-
// src/
|
|
77
|
+
// src/sse-connection.ts
|
|
78
|
+
var import_react_native_sse = __toESM(require("react-native-sse"));
|
|
79
79
|
var DEFAULT_RECONNECT_DELAY_MS = 3e3;
|
|
80
|
-
|
|
81
|
-
async function createChatSession(config, channelId, profile, callbacks) {
|
|
82
|
-
const serviceUrl = resolveServerUrl(config.manifest, channelId);
|
|
83
|
-
const customHeaders = config.headers ?? {};
|
|
80
|
+
function createSSEConnection(config, callbacks) {
|
|
84
81
|
const reconnectDelay = config.reconnectDelayMs ?? DEFAULT_RECONNECT_DELAY_MS;
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
method: "POST",
|
|
88
|
-
headers: { "Content-Type": "application/json", ...customHeaders },
|
|
89
|
-
body: JSON.stringify(profile)
|
|
90
|
-
});
|
|
91
|
-
if (joinRes.status === 410) {
|
|
92
|
-
throw new ChannelClosedError(channelId);
|
|
93
|
-
}
|
|
94
|
-
if (!joinRes.ok) {
|
|
95
|
-
throw new Error(`Join failed: ${joinRes.status} ${await joinRes.text()}`);
|
|
96
|
-
}
|
|
97
|
-
const { messages, participants, joined_at } = await joinRes.json();
|
|
98
|
-
let lastEventId = messages.length > 0 ? messages[messages.length - 1].id : void 0;
|
|
99
|
-
const joinedAt = joined_at;
|
|
100
|
-
const seenMessageIds = new Set(messages.map((m) => m.id));
|
|
82
|
+
const customEvents = config.customEvents ?? [];
|
|
83
|
+
let currentLastEventId = config.lastEventId;
|
|
101
84
|
let es = null;
|
|
102
85
|
let disposed = false;
|
|
103
86
|
let reconnectTimer = null;
|
|
104
|
-
function
|
|
105
|
-
if (
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
seenMessageIds.add(id);
|
|
87
|
+
function cleanup() {
|
|
88
|
+
if (es) {
|
|
89
|
+
es.removeAllEventListeners();
|
|
90
|
+
es.close();
|
|
91
|
+
es = null;
|
|
110
92
|
}
|
|
111
93
|
}
|
|
94
|
+
function scheduleReconnect() {
|
|
95
|
+
if (disposed || reconnectTimer) return;
|
|
96
|
+
callbacks.onReconnecting?.();
|
|
97
|
+
cleanup();
|
|
98
|
+
reconnectTimer = setTimeout(() => {
|
|
99
|
+
reconnectTimer = null;
|
|
100
|
+
connect();
|
|
101
|
+
}, reconnectDelay);
|
|
102
|
+
}
|
|
112
103
|
function connect() {
|
|
113
104
|
if (disposed) return;
|
|
114
|
-
|
|
115
|
-
es = new import_react_native_sse.default(streamUrl, {
|
|
105
|
+
es = new import_react_native_sse.default(config.url, {
|
|
116
106
|
headers: {
|
|
117
|
-
...
|
|
118
|
-
...
|
|
107
|
+
...config.headers,
|
|
108
|
+
...currentLastEventId && { "Last-Event-ID": currentLastEventId }
|
|
119
109
|
},
|
|
120
110
|
pollingInterval: 0
|
|
121
111
|
});
|
|
122
112
|
es.addEventListener("open", () => {
|
|
123
113
|
if (disposed) return;
|
|
124
|
-
callbacks.
|
|
114
|
+
callbacks.onOpen?.();
|
|
125
115
|
});
|
|
126
116
|
es.addEventListener("message", (event) => {
|
|
127
117
|
if (disposed || !event.data) return;
|
|
128
|
-
let message;
|
|
129
|
-
try {
|
|
130
|
-
message = JSON.parse(event.data);
|
|
131
|
-
} catch {
|
|
132
|
-
callbacks.onError(new Error("Failed to parse SSE message"));
|
|
133
|
-
return;
|
|
134
|
-
}
|
|
135
118
|
if (event.lastEventId) {
|
|
136
|
-
|
|
119
|
+
currentLastEventId = event.lastEventId;
|
|
137
120
|
}
|
|
138
|
-
|
|
139
|
-
seenMessageIds.add(message.id);
|
|
140
|
-
trimSeenIds();
|
|
141
|
-
callbacks.onMessage(message);
|
|
142
|
-
});
|
|
143
|
-
es.addEventListener("resync", () => {
|
|
144
|
-
if (disposed) return;
|
|
145
|
-
cleanupEventSource();
|
|
146
|
-
callbacks.onResync();
|
|
121
|
+
callbacks.onEvent("message", event.data, event.lastEventId ?? void 0);
|
|
147
122
|
});
|
|
123
|
+
for (const eventName of customEvents) {
|
|
124
|
+
es.addEventListener(eventName, (event) => {
|
|
125
|
+
if (disposed) return;
|
|
126
|
+
callbacks.onEvent(eventName, event.data ?? "", void 0);
|
|
127
|
+
});
|
|
128
|
+
}
|
|
148
129
|
es.addEventListener("error", (event) => {
|
|
149
130
|
if (disposed) return;
|
|
150
131
|
const msg = "message" in event ? String(event.message) : "";
|
|
151
132
|
if (msg.includes("Channel is closed") || msg.includes("410")) {
|
|
152
|
-
callbacks.
|
|
153
|
-
|
|
133
|
+
callbacks.onClosed?.();
|
|
134
|
+
cleanup();
|
|
154
135
|
disposed = true;
|
|
155
136
|
return;
|
|
156
137
|
}
|
|
157
|
-
if (msg) callbacks.onError(new Error(msg));
|
|
138
|
+
if (msg) callbacks.onError?.(new Error(msg));
|
|
158
139
|
scheduleReconnect();
|
|
159
140
|
});
|
|
160
141
|
es.addEventListener("close", () => {
|
|
@@ -162,22 +143,104 @@ async function createChatSession(config, channelId, profile, callbacks) {
|
|
|
162
143
|
scheduleReconnect();
|
|
163
144
|
});
|
|
164
145
|
}
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
146
|
+
connect();
|
|
147
|
+
return {
|
|
148
|
+
close: () => {
|
|
149
|
+
disposed = true;
|
|
150
|
+
if (reconnectTimer) {
|
|
151
|
+
clearTimeout(reconnectTimer);
|
|
152
|
+
reconnectTimer = null;
|
|
153
|
+
}
|
|
154
|
+
cleanup();
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// src/session.ts
|
|
160
|
+
var DEFAULT_RECONNECT_DELAY_MS2 = 3e3;
|
|
161
|
+
var MAX_SEEN_IDS = 500;
|
|
162
|
+
async function createChatSession(config, channelId, profile, callbacks) {
|
|
163
|
+
const serviceUrl = resolveServerUrl(config.manifest, channelId);
|
|
164
|
+
const customHeaders = config.headers ?? {};
|
|
165
|
+
const reconnectDelay = config.reconnectDelayMs ?? DEFAULT_RECONNECT_DELAY_MS2;
|
|
166
|
+
callbacks.onStatusChange("connecting");
|
|
167
|
+
const joinRes = await fetch(`${serviceUrl}/channels/${channelId}/join`, {
|
|
168
|
+
method: "POST",
|
|
169
|
+
headers: { "Content-Type": "application/json", ...customHeaders },
|
|
170
|
+
body: JSON.stringify(profile)
|
|
171
|
+
});
|
|
172
|
+
if (joinRes.status === 410) {
|
|
173
|
+
throw new ChannelClosedError(channelId);
|
|
173
174
|
}
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
175
|
+
if (!joinRes.ok) {
|
|
176
|
+
throw new Error(`Join failed: ${joinRes.status} ${await joinRes.text()}`);
|
|
177
|
+
}
|
|
178
|
+
const { messages, participants, joined_at } = await joinRes.json();
|
|
179
|
+
let lastEventId = messages.length > 0 ? messages[messages.length - 1].id : void 0;
|
|
180
|
+
const joinedAt = joined_at;
|
|
181
|
+
const seenMessageIds = new Set(messages.map((m) => m.id));
|
|
182
|
+
let sseConn = null;
|
|
183
|
+
let disposed = false;
|
|
184
|
+
const TRIM_THRESHOLD = MAX_SEEN_IDS * 1.5;
|
|
185
|
+
function trimSeenIds() {
|
|
186
|
+
if (seenMessageIds.size <= TRIM_THRESHOLD) return;
|
|
187
|
+
const ids = [...seenMessageIds];
|
|
188
|
+
seenMessageIds.clear();
|
|
189
|
+
for (const id of ids.slice(-MAX_SEEN_IDS)) {
|
|
190
|
+
seenMessageIds.add(id);
|
|
179
191
|
}
|
|
180
192
|
}
|
|
193
|
+
function connect() {
|
|
194
|
+
if (disposed) return;
|
|
195
|
+
const streamUrl = lastEventId ? `${serviceUrl}/channels/${channelId}/stream` : `${serviceUrl}/channels/${channelId}/stream?since_time=${encodeURIComponent(joinedAt)}`;
|
|
196
|
+
sseConn = createSSEConnection(
|
|
197
|
+
{
|
|
198
|
+
url: streamUrl,
|
|
199
|
+
headers: customHeaders,
|
|
200
|
+
reconnectDelayMs: reconnectDelay,
|
|
201
|
+
lastEventId,
|
|
202
|
+
customEvents: ["resync"]
|
|
203
|
+
},
|
|
204
|
+
{
|
|
205
|
+
onOpen: () => {
|
|
206
|
+
if (!disposed) callbacks.onStatusChange("connected");
|
|
207
|
+
},
|
|
208
|
+
onEvent: (eventType, data, eventId) => {
|
|
209
|
+
if (disposed) return;
|
|
210
|
+
if (eventType === "message") {
|
|
211
|
+
let message;
|
|
212
|
+
try {
|
|
213
|
+
message = JSON.parse(data);
|
|
214
|
+
} catch {
|
|
215
|
+
callbacks.onError(new Error("Failed to parse SSE message"));
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
if (eventId) {
|
|
219
|
+
lastEventId = eventId;
|
|
220
|
+
}
|
|
221
|
+
if (seenMessageIds.has(message.id)) return;
|
|
222
|
+
seenMessageIds.add(message.id);
|
|
223
|
+
trimSeenIds();
|
|
224
|
+
callbacks.onMessage(message);
|
|
225
|
+
} else if (eventType === "resync") {
|
|
226
|
+
sseConn?.close();
|
|
227
|
+
sseConn = null;
|
|
228
|
+
callbacks.onResync();
|
|
229
|
+
}
|
|
230
|
+
},
|
|
231
|
+
onError: (err) => {
|
|
232
|
+
if (!disposed) callbacks.onError(err);
|
|
233
|
+
},
|
|
234
|
+
onClosed: () => {
|
|
235
|
+
callbacks.onStatusChange("closed");
|
|
236
|
+
disposed = true;
|
|
237
|
+
},
|
|
238
|
+
onReconnecting: () => {
|
|
239
|
+
if (!disposed) callbacks.onStatusChange("reconnecting");
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
);
|
|
243
|
+
}
|
|
181
244
|
connect();
|
|
182
245
|
return {
|
|
183
246
|
serviceUrl,
|
|
@@ -207,13 +270,23 @@ async function createChatSession(config, channelId, profile, callbacks) {
|
|
|
207
270
|
seenMessageIds.add(response.id);
|
|
208
271
|
return response;
|
|
209
272
|
},
|
|
273
|
+
markAsRead: async (messageId) => {
|
|
274
|
+
const res = await fetch(`${serviceUrl}/channels/${channelId}/read`, {
|
|
275
|
+
method: "POST",
|
|
276
|
+
headers: { "Content-Type": "application/json", ...customHeaders },
|
|
277
|
+
body: JSON.stringify({
|
|
278
|
+
participant_id: profile.id,
|
|
279
|
+
message_id: messageId
|
|
280
|
+
})
|
|
281
|
+
});
|
|
282
|
+
if (!res.ok) {
|
|
283
|
+
throw new Error(`markAsRead failed: ${res.status}`);
|
|
284
|
+
}
|
|
285
|
+
},
|
|
210
286
|
disconnect: () => {
|
|
211
287
|
disposed = true;
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
reconnectTimer = null;
|
|
215
|
-
}
|
|
216
|
-
cleanupEventSource();
|
|
288
|
+
sseConn?.close();
|
|
289
|
+
sseConn = null;
|
|
217
290
|
callbacks.onStatusChange("disconnected");
|
|
218
291
|
}
|
|
219
292
|
};
|
|
@@ -228,6 +301,8 @@ function useChat({ config, channelId, profile, onMessage }) {
|
|
|
228
301
|
const [error, setError] = (0, import_react.useState)(null);
|
|
229
302
|
const sessionRef = (0, import_react.useRef)(null);
|
|
230
303
|
const disposedRef = (0, import_react.useRef)(false);
|
|
304
|
+
const messagesRef = (0, import_react.useRef)(messages);
|
|
305
|
+
messagesRef.current = messages;
|
|
231
306
|
const statusRef = (0, import_react.useRef)(status);
|
|
232
307
|
statusRef.current = status;
|
|
233
308
|
const appStateRef = (0, import_react.useRef)(import_react_native.AppState.currentState);
|
|
@@ -245,6 +320,9 @@ function useChat({ config, channelId, profile, onMessage }) {
|
|
|
245
320
|
onMessage: (message) => {
|
|
246
321
|
if (disposedRef.current) return;
|
|
247
322
|
setMessages((prev) => {
|
|
323
|
+
if (pendingOptimisticIds.current.size === 0) {
|
|
324
|
+
return [...prev, message];
|
|
325
|
+
}
|
|
248
326
|
const optimisticIdx = prev.findIndex(
|
|
249
327
|
(m) => pendingOptimisticIds.current.has(m.id) && m.sender_id === message.sender_id && m.body === message.body && m.type === message.type
|
|
250
328
|
);
|
|
@@ -308,6 +386,13 @@ function useChat({ config, channelId, profile, onMessage }) {
|
|
|
308
386
|
startSession();
|
|
309
387
|
return () => {
|
|
310
388
|
disposedRef.current = true;
|
|
389
|
+
if (statusRef.current === "connected" && sessionRef.current) {
|
|
390
|
+
const lastMsg = messagesRef.current[messagesRef.current.length - 1];
|
|
391
|
+
if (lastMsg) {
|
|
392
|
+
sessionRef.current.markAsRead(lastMsg.id).catch(() => {
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
}
|
|
311
396
|
if (backgroundTimerRef.current) {
|
|
312
397
|
clearTimeout(backgroundTimerRef.current);
|
|
313
398
|
backgroundTimerRef.current = null;
|
|
@@ -407,13 +492,135 @@ function useChat({ config, channelId, profile, onMessage }) {
|
|
|
407
492
|
}, []);
|
|
408
493
|
return { messages, participants, status, error, sendMessage, disconnect };
|
|
409
494
|
}
|
|
495
|
+
|
|
496
|
+
// src/use-unread.ts
|
|
497
|
+
var import_react2 = require("react");
|
|
498
|
+
var import_react_native2 = require("react-native");
|
|
499
|
+
var DEFAULT_BACKGROUND_GRACE_MS2 = 2e3;
|
|
500
|
+
var UNREAD_CUSTOM_EVENTS = ["unread_snapshot", "unread_update", "unread_clear"];
|
|
501
|
+
function useUnread(options) {
|
|
502
|
+
const { config, channelId, participantId, enabled = true } = options;
|
|
503
|
+
const [unreadCount, setUnreadCount] = (0, import_react2.useState)(0);
|
|
504
|
+
const [lastMessageAt, setLastMessageAt] = (0, import_react2.useState)(null);
|
|
505
|
+
const [error, setError] = (0, import_react2.useState)(null);
|
|
506
|
+
const connRef = (0, import_react2.useRef)(null);
|
|
507
|
+
const configRef = (0, import_react2.useRef)(config);
|
|
508
|
+
configRef.current = config;
|
|
509
|
+
const appStateRef = (0, import_react2.useRef)(import_react_native2.AppState.currentState);
|
|
510
|
+
const backgroundTimerRef = (0, import_react2.useRef)(null);
|
|
511
|
+
const backgroundGraceMs = config.backgroundGraceMs ?? (import_react_native2.Platform.OS === "android" ? DEFAULT_BACKGROUND_GRACE_MS2 : 0);
|
|
512
|
+
const connect = (0, import_react2.useCallback)(() => {
|
|
513
|
+
connRef.current?.close();
|
|
514
|
+
connRef.current = null;
|
|
515
|
+
const serviceUrl = resolveServerUrl(configRef.current.manifest, channelId);
|
|
516
|
+
const customHeaders = configRef.current.headers ?? {};
|
|
517
|
+
const url = `${serviceUrl}/channels/${channelId}/unread?participant_id=${encodeURIComponent(participantId)}`;
|
|
518
|
+
connRef.current = createSSEConnection(
|
|
519
|
+
{
|
|
520
|
+
url,
|
|
521
|
+
headers: customHeaders,
|
|
522
|
+
reconnectDelayMs: configRef.current.reconnectDelayMs,
|
|
523
|
+
customEvents: UNREAD_CUSTOM_EVENTS
|
|
524
|
+
},
|
|
525
|
+
{
|
|
526
|
+
onOpen: () => {
|
|
527
|
+
setError(null);
|
|
528
|
+
},
|
|
529
|
+
onEvent: (eventType, data) => {
|
|
530
|
+
try {
|
|
531
|
+
if (eventType === "unread_snapshot") {
|
|
532
|
+
const snapshot = JSON.parse(data);
|
|
533
|
+
setUnreadCount(snapshot.unread_count);
|
|
534
|
+
setLastMessageAt(snapshot.last_message_at);
|
|
535
|
+
} else if (eventType === "unread_update") {
|
|
536
|
+
const update = JSON.parse(data);
|
|
537
|
+
setUnreadCount((prev) => prev + 1);
|
|
538
|
+
setLastMessageAt(update.created_at);
|
|
539
|
+
} else if (eventType === "unread_clear") {
|
|
540
|
+
const clear = JSON.parse(data);
|
|
541
|
+
setUnreadCount(clear.unread_count);
|
|
542
|
+
}
|
|
543
|
+
} catch {
|
|
544
|
+
setError(new Error("Failed to parse unread SSE event"));
|
|
545
|
+
}
|
|
546
|
+
},
|
|
547
|
+
onError: (err) => {
|
|
548
|
+
setError(err);
|
|
549
|
+
},
|
|
550
|
+
onClosed: () => {
|
|
551
|
+
connRef.current = null;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
);
|
|
555
|
+
}, [channelId, participantId]);
|
|
556
|
+
const disconnect = (0, import_react2.useCallback)(() => {
|
|
557
|
+
connRef.current?.close();
|
|
558
|
+
connRef.current = null;
|
|
559
|
+
}, []);
|
|
560
|
+
(0, import_react2.useEffect)(() => {
|
|
561
|
+
setUnreadCount(0);
|
|
562
|
+
setLastMessageAt(null);
|
|
563
|
+
setError(null);
|
|
564
|
+
if (!enabled) {
|
|
565
|
+
disconnect();
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
connect();
|
|
569
|
+
return () => {
|
|
570
|
+
disconnect();
|
|
571
|
+
if (backgroundTimerRef.current) {
|
|
572
|
+
clearTimeout(backgroundTimerRef.current);
|
|
573
|
+
backgroundTimerRef.current = null;
|
|
574
|
+
}
|
|
575
|
+
};
|
|
576
|
+
}, [channelId, participantId, enabled, connect, disconnect]);
|
|
577
|
+
(0, import_react2.useEffect)(() => {
|
|
578
|
+
if (!enabled) return;
|
|
579
|
+
const subscription = import_react_native2.AppState.addEventListener("change", (nextAppState) => {
|
|
580
|
+
const prev = appStateRef.current;
|
|
581
|
+
appStateRef.current = nextAppState;
|
|
582
|
+
if (!connRef.current && nextAppState !== "active") return;
|
|
583
|
+
const shouldTeardown = nextAppState === "background" || import_react_native2.Platform.OS === "ios" && nextAppState === "inactive";
|
|
584
|
+
if (nextAppState === "active") {
|
|
585
|
+
if (backgroundTimerRef.current) {
|
|
586
|
+
clearTimeout(backgroundTimerRef.current);
|
|
587
|
+
backgroundTimerRef.current = null;
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
if (prev.match(/inactive|background/) && !connRef.current) {
|
|
591
|
+
connect();
|
|
592
|
+
}
|
|
593
|
+
} else if (shouldTeardown) {
|
|
594
|
+
if (backgroundTimerRef.current) return;
|
|
595
|
+
if (backgroundGraceMs === 0) {
|
|
596
|
+
disconnect();
|
|
597
|
+
} else {
|
|
598
|
+
backgroundTimerRef.current = setTimeout(() => {
|
|
599
|
+
backgroundTimerRef.current = null;
|
|
600
|
+
disconnect();
|
|
601
|
+
}, backgroundGraceMs);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
});
|
|
605
|
+
return () => {
|
|
606
|
+
subscription.remove();
|
|
607
|
+
if (backgroundTimerRef.current) {
|
|
608
|
+
clearTimeout(backgroundTimerRef.current);
|
|
609
|
+
backgroundTimerRef.current = null;
|
|
610
|
+
}
|
|
611
|
+
};
|
|
612
|
+
}, [enabled, backgroundGraceMs, connect, disconnect]);
|
|
613
|
+
return { unreadCount, hasUnread: unreadCount > 0, lastMessageAt, error };
|
|
614
|
+
}
|
|
410
615
|
// Annotate the CommonJS export names for ESM import in node:
|
|
411
616
|
0 && (module.exports = {
|
|
412
617
|
ChannelClosedError,
|
|
413
618
|
ChatDisconnectedError,
|
|
414
619
|
createChatSession,
|
|
415
620
|
createManifest,
|
|
621
|
+
createSSEConnection,
|
|
416
622
|
resolveServerUrl,
|
|
417
|
-
useChat
|
|
623
|
+
useChat,
|
|
624
|
+
useUnread
|
|
418
625
|
});
|
|
419
626
|
//# sourceMappingURL=index.js.map
|