@pedi/chika-sdk 1.0.0 → 1.0.4

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 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/session.ts
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
- var MAX_SEEN_IDS = 500;
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
- callbacks.onStatusChange("connecting");
86
- const joinRes = await fetch(`${serviceUrl}/channels/${channelId}/join`, {
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 trimSeenIds() {
105
- if (seenMessageIds.size <= MAX_SEEN_IDS) return;
106
- const ids = [...seenMessageIds];
107
- seenMessageIds.clear();
108
- for (const id of ids.slice(-MAX_SEEN_IDS)) {
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
- const streamUrl = lastEventId ? `${serviceUrl}/channels/${channelId}/stream` : `${serviceUrl}/channels/${channelId}/stream?since_time=${encodeURIComponent(joinedAt)}`;
115
- es = new import_react_native_sse.default(streamUrl, {
105
+ es = new import_react_native_sse.default(config.url, {
116
106
  headers: {
117
- ...customHeaders,
118
- ...lastEventId && { "Last-Event-ID": lastEventId }
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.onStatusChange("connected");
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
- lastEventId = event.lastEventId;
119
+ currentLastEventId = event.lastEventId;
137
120
  }
138
- if (seenMessageIds.has(message.id)) return;
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.onStatusChange("closed");
153
- cleanupEventSource();
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,103 @@ async function createChatSession(config, channelId, profile, callbacks) {
162
143
  scheduleReconnect();
163
144
  });
164
145
  }
165
- function scheduleReconnect() {
166
- if (disposed || reconnectTimer) return;
167
- callbacks.onStatusChange("reconnecting");
168
- cleanupEventSource();
169
- reconnectTimer = setTimeout(() => {
170
- reconnectTimer = null;
171
- connect();
172
- }, reconnectDelay);
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
- function cleanupEventSource() {
175
- if (es) {
176
- es.removeAllEventListeners();
177
- es.close();
178
- es = null;
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
+ function trimSeenIds() {
185
+ if (seenMessageIds.size <= MAX_SEEN_IDS) return;
186
+ const ids = [...seenMessageIds];
187
+ seenMessageIds.clear();
188
+ for (const id of ids.slice(-MAX_SEEN_IDS)) {
189
+ seenMessageIds.add(id);
179
190
  }
180
191
  }
192
+ function connect() {
193
+ if (disposed) return;
194
+ const streamUrl = lastEventId ? `${serviceUrl}/channels/${channelId}/stream` : `${serviceUrl}/channels/${channelId}/stream?since_time=${encodeURIComponent(joinedAt)}`;
195
+ sseConn = createSSEConnection(
196
+ {
197
+ url: streamUrl,
198
+ headers: customHeaders,
199
+ reconnectDelayMs: reconnectDelay,
200
+ lastEventId,
201
+ customEvents: ["resync"]
202
+ },
203
+ {
204
+ onOpen: () => {
205
+ if (!disposed) callbacks.onStatusChange("connected");
206
+ },
207
+ onEvent: (eventType, data, eventId) => {
208
+ if (disposed) return;
209
+ if (eventType === "message") {
210
+ let message;
211
+ try {
212
+ message = JSON.parse(data);
213
+ } catch {
214
+ callbacks.onError(new Error("Failed to parse SSE message"));
215
+ return;
216
+ }
217
+ if (eventId) {
218
+ lastEventId = eventId;
219
+ }
220
+ if (seenMessageIds.has(message.id)) return;
221
+ seenMessageIds.add(message.id);
222
+ trimSeenIds();
223
+ callbacks.onMessage(message);
224
+ } else if (eventType === "resync") {
225
+ sseConn?.close();
226
+ sseConn = null;
227
+ callbacks.onResync();
228
+ }
229
+ },
230
+ onError: (err) => {
231
+ if (!disposed) callbacks.onError(err);
232
+ },
233
+ onClosed: () => {
234
+ callbacks.onStatusChange("closed");
235
+ disposed = true;
236
+ },
237
+ onReconnecting: () => {
238
+ if (!disposed) callbacks.onStatusChange("reconnecting");
239
+ }
240
+ }
241
+ );
242
+ }
181
243
  connect();
182
244
  return {
183
245
  serviceUrl,
@@ -207,13 +269,23 @@ async function createChatSession(config, channelId, profile, callbacks) {
207
269
  seenMessageIds.add(response.id);
208
270
  return response;
209
271
  },
272
+ markAsRead: async (messageId) => {
273
+ const res = await fetch(`${serviceUrl}/channels/${channelId}/read`, {
274
+ method: "POST",
275
+ headers: { "Content-Type": "application/json", ...customHeaders },
276
+ body: JSON.stringify({
277
+ participant_id: profile.id,
278
+ message_id: messageId
279
+ })
280
+ });
281
+ if (!res.ok) {
282
+ throw new Error(`markAsRead failed: ${res.status}`);
283
+ }
284
+ },
210
285
  disconnect: () => {
211
286
  disposed = true;
212
- if (reconnectTimer) {
213
- clearTimeout(reconnectTimer);
214
- reconnectTimer = null;
215
- }
216
- cleanupEventSource();
287
+ sseConn?.close();
288
+ sseConn = null;
217
289
  callbacks.onStatusChange("disconnected");
218
290
  }
219
291
  };
@@ -228,6 +300,8 @@ function useChat({ config, channelId, profile, onMessage }) {
228
300
  const [error, setError] = (0, import_react.useState)(null);
229
301
  const sessionRef = (0, import_react.useRef)(null);
230
302
  const disposedRef = (0, import_react.useRef)(false);
303
+ const messagesRef = (0, import_react.useRef)(messages);
304
+ messagesRef.current = messages;
231
305
  const statusRef = (0, import_react.useRef)(status);
232
306
  statusRef.current = status;
233
307
  const appStateRef = (0, import_react.useRef)(import_react_native.AppState.currentState);
@@ -239,11 +313,24 @@ function useChat({ config, channelId, profile, onMessage }) {
239
313
  const onMessageRef = (0, import_react.useRef)(onMessage);
240
314
  onMessageRef.current = onMessage;
241
315
  const startingRef = (0, import_react.useRef)(false);
316
+ const pendingOptimisticIds = (0, import_react.useRef)(/* @__PURE__ */ new Set());
242
317
  const backgroundGraceMs = config.backgroundGraceMs ?? (import_react_native.Platform.OS === "android" ? DEFAULT_BACKGROUND_GRACE_MS : 0);
243
318
  const callbacks = {
244
319
  onMessage: (message) => {
245
320
  if (disposedRef.current) return;
246
- setMessages((prev) => [...prev, message]);
321
+ setMessages((prev) => {
322
+ const optimisticIdx = prev.findIndex(
323
+ (m) => pendingOptimisticIds.current.has(m.id) && m.sender_id === message.sender_id && m.body === message.body && m.type === message.type
324
+ );
325
+ if (optimisticIdx !== -1) {
326
+ const optimisticId = prev[optimisticIdx].id;
327
+ pendingOptimisticIds.current.delete(optimisticId);
328
+ const next = [...prev];
329
+ next[optimisticIdx] = message;
330
+ return next;
331
+ }
332
+ return [...prev, message];
333
+ });
247
334
  onMessageRef.current?.(message);
248
335
  },
249
336
  onStatusChange: (nextStatus) => {
@@ -295,6 +382,13 @@ function useChat({ config, channelId, profile, onMessage }) {
295
382
  startSession();
296
383
  return () => {
297
384
  disposedRef.current = true;
385
+ if (statusRef.current === "connected" && sessionRef.current) {
386
+ const lastMsg = messagesRef.current[messagesRef.current.length - 1];
387
+ if (lastMsg) {
388
+ sessionRef.current.markAsRead(lastMsg.id).catch(() => {
389
+ });
390
+ }
391
+ }
298
392
  if (backgroundTimerRef.current) {
299
393
  clearTimeout(backgroundTimerRef.current);
300
394
  backgroundTimerRef.current = null;
@@ -351,6 +445,7 @@ function useChat({ config, channelId, profile, onMessage }) {
351
445
  let optimisticId = null;
352
446
  if (optimistic) {
353
447
  optimisticId = `optimistic_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
448
+ pendingOptimisticIds.current.add(optimisticId);
354
449
  const provisionalMsg = {
355
450
  id: optimisticId,
356
451
  channel_id: channelId,
@@ -366,15 +461,19 @@ function useChat({ config, channelId, profile, onMessage }) {
366
461
  try {
367
462
  const response = await session.sendMessage(type, body, attributes);
368
463
  if (optimistic && optimisticId) {
369
- setMessages(
370
- (prev) => prev.map(
464
+ pendingOptimisticIds.current.delete(optimisticId);
465
+ setMessages((prev) => {
466
+ const stillPending = prev.some((m) => m.id === optimisticId);
467
+ if (!stillPending) return prev;
468
+ return prev.map(
371
469
  (m) => m.id === optimisticId ? { ...m, id: response.id, created_at: response.created_at } : m
372
- )
373
- );
470
+ );
471
+ });
374
472
  }
375
473
  return response;
376
474
  } catch (err) {
377
475
  if (optimistic && optimisticId) {
476
+ pendingOptimisticIds.current.delete(optimisticId);
378
477
  setMessages((prev) => prev.filter((m) => m.id !== optimisticId));
379
478
  }
380
479
  throw err;
@@ -389,13 +488,135 @@ function useChat({ config, channelId, profile, onMessage }) {
389
488
  }, []);
390
489
  return { messages, participants, status, error, sendMessage, disconnect };
391
490
  }
491
+
492
+ // src/use-unread.ts
493
+ var import_react2 = require("react");
494
+ var import_react_native2 = require("react-native");
495
+ var DEFAULT_BACKGROUND_GRACE_MS2 = 2e3;
496
+ var UNREAD_CUSTOM_EVENTS = ["unread_snapshot", "unread_update", "unread_clear"];
497
+ function useUnread(options) {
498
+ const { config, channelId, participantId, enabled = true } = options;
499
+ const [unreadCount, setUnreadCount] = (0, import_react2.useState)(0);
500
+ const [lastMessageAt, setLastMessageAt] = (0, import_react2.useState)(null);
501
+ const [error, setError] = (0, import_react2.useState)(null);
502
+ const connRef = (0, import_react2.useRef)(null);
503
+ const configRef = (0, import_react2.useRef)(config);
504
+ configRef.current = config;
505
+ const appStateRef = (0, import_react2.useRef)(import_react_native2.AppState.currentState);
506
+ const backgroundTimerRef = (0, import_react2.useRef)(null);
507
+ const backgroundGraceMs = config.backgroundGraceMs ?? (import_react_native2.Platform.OS === "android" ? DEFAULT_BACKGROUND_GRACE_MS2 : 0);
508
+ const connect = (0, import_react2.useCallback)(() => {
509
+ connRef.current?.close();
510
+ connRef.current = null;
511
+ const serviceUrl = resolveServerUrl(configRef.current.manifest, channelId);
512
+ const customHeaders = configRef.current.headers ?? {};
513
+ const url = `${serviceUrl}/channels/${channelId}/unread?participant_id=${encodeURIComponent(participantId)}`;
514
+ connRef.current = createSSEConnection(
515
+ {
516
+ url,
517
+ headers: customHeaders,
518
+ reconnectDelayMs: configRef.current.reconnectDelayMs,
519
+ customEvents: UNREAD_CUSTOM_EVENTS
520
+ },
521
+ {
522
+ onOpen: () => {
523
+ setError(null);
524
+ },
525
+ onEvent: (eventType, data) => {
526
+ try {
527
+ if (eventType === "unread_snapshot") {
528
+ const snapshot = JSON.parse(data);
529
+ setUnreadCount(snapshot.unread_count);
530
+ setLastMessageAt(snapshot.last_message_at);
531
+ } else if (eventType === "unread_update") {
532
+ const update = JSON.parse(data);
533
+ setUnreadCount((prev) => prev + 1);
534
+ setLastMessageAt(update.created_at);
535
+ } else if (eventType === "unread_clear") {
536
+ const clear = JSON.parse(data);
537
+ setUnreadCount(clear.unread_count);
538
+ }
539
+ } catch {
540
+ setError(new Error("Failed to parse unread SSE event"));
541
+ }
542
+ },
543
+ onError: (err) => {
544
+ setError(err);
545
+ },
546
+ onClosed: () => {
547
+ connRef.current = null;
548
+ }
549
+ }
550
+ );
551
+ }, [channelId, participantId]);
552
+ const disconnect = (0, import_react2.useCallback)(() => {
553
+ connRef.current?.close();
554
+ connRef.current = null;
555
+ }, []);
556
+ (0, import_react2.useEffect)(() => {
557
+ setUnreadCount(0);
558
+ setLastMessageAt(null);
559
+ setError(null);
560
+ if (!enabled) {
561
+ disconnect();
562
+ return;
563
+ }
564
+ connect();
565
+ return () => {
566
+ disconnect();
567
+ if (backgroundTimerRef.current) {
568
+ clearTimeout(backgroundTimerRef.current);
569
+ backgroundTimerRef.current = null;
570
+ }
571
+ };
572
+ }, [channelId, participantId, enabled, connect, disconnect]);
573
+ (0, import_react2.useEffect)(() => {
574
+ if (!enabled) return;
575
+ const subscription = import_react_native2.AppState.addEventListener("change", (nextAppState) => {
576
+ const prev = appStateRef.current;
577
+ appStateRef.current = nextAppState;
578
+ if (!connRef.current && nextAppState !== "active") return;
579
+ const shouldTeardown = nextAppState === "background" || import_react_native2.Platform.OS === "ios" && nextAppState === "inactive";
580
+ if (nextAppState === "active") {
581
+ if (backgroundTimerRef.current) {
582
+ clearTimeout(backgroundTimerRef.current);
583
+ backgroundTimerRef.current = null;
584
+ return;
585
+ }
586
+ if (prev.match(/inactive|background/) && !connRef.current) {
587
+ connect();
588
+ }
589
+ } else if (shouldTeardown) {
590
+ if (backgroundTimerRef.current) return;
591
+ if (backgroundGraceMs === 0) {
592
+ disconnect();
593
+ } else {
594
+ backgroundTimerRef.current = setTimeout(() => {
595
+ backgroundTimerRef.current = null;
596
+ disconnect();
597
+ }, backgroundGraceMs);
598
+ }
599
+ }
600
+ });
601
+ return () => {
602
+ subscription.remove();
603
+ if (backgroundTimerRef.current) {
604
+ clearTimeout(backgroundTimerRef.current);
605
+ backgroundTimerRef.current = null;
606
+ }
607
+ };
608
+ }, [enabled, backgroundGraceMs, connect, disconnect]);
609
+ return { unreadCount, hasUnread: unreadCount > 0, lastMessageAt, error };
610
+ }
392
611
  // Annotate the CommonJS export names for ESM import in node:
393
612
  0 && (module.exports = {
394
613
  ChannelClosedError,
395
614
  ChatDisconnectedError,
396
615
  createChatSession,
397
616
  createManifest,
617
+ createSSEConnection,
398
618
  resolveServerUrl,
399
- useChat
619
+ useChat,
620
+ useUnread
400
621
  });
401
622
  //# sourceMappingURL=index.js.map