@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 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/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,104 @@ 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
+ 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
- if (reconnectTimer) {
213
- clearTimeout(reconnectTimer);
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