@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.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts","../src/use-chat.ts","../src/errors.ts","../src/session.ts","../src/resolve-url.ts"],"sourcesContent":["export { useChat } from './use-chat';\nexport { createChatSession } from './session';\nexport { resolveServerUrl, createManifest } from './resolve-url';\nexport { ChatDisconnectedError, ChannelClosedError } from './errors';\n\nexport type { ChatConfig, ChatStatus, UseChatOptions, UseChatReturn } from './types';\nexport type { ChatSession, SessionCallbacks } from './session';\n\nexport type {\n ChatDomain,\n DefaultDomain,\n Message,\n Participant,\n MessageAttributes,\n SendMessageResponse,\n ChatManifest,\n ChatBucket,\n PediChat,\n PediRole,\n PediVehicle,\n PediLocation,\n PediParticipantMeta,\n PediMessageType,\n PediMessageAttributes,\n} from '@pedi/chika-types';\n","import { useEffect, useRef, useState, useCallback } from 'react';\nimport { AppState, Platform, type AppStateStatus } from 'react-native';\nimport type {\n ChatDomain,\n DefaultDomain,\n Message,\n Participant,\n MessageAttributes,\n SendMessageResponse,\n} from '@pedi/chika-types';\nimport type { UseChatOptions, UseChatReturn, ChatStatus } from './types';\nimport { ChatDisconnectedError, ChannelClosedError } from './errors';\nimport { createChatSession, type ChatSession, type SessionCallbacks } from './session';\n\nconst DEFAULT_BACKGROUND_GRACE_MS = 2000;\n\n/**\n * React hook for real-time chat over SSE.\n * Manages connection lifecycle, AppState transitions, message deduplication, and reconnection.\n *\n * @template D - Chat domain type for role/message type narrowing. Defaults to DefaultDomain.\n */\nexport function useChat<D extends ChatDomain = DefaultDomain>(\n { config, channelId, profile, onMessage }: UseChatOptions<D>,\n): UseChatReturn<D> {\n const [messages, setMessages] = useState<Message<D>[]>([]);\n const [participants, setParticipants] = useState<Participant<D>[]>([]);\n const [status, setStatus] = useState<ChatStatus>('connecting');\n const [error, setError] = useState<Error | null>(null);\n\n const sessionRef = useRef<ChatSession<D> | null>(null);\n const disposedRef = useRef(false);\n const statusRef = useRef(status);\n statusRef.current = status;\n const appStateRef = useRef<AppStateStatus>(AppState.currentState);\n const backgroundTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n const profileRef = useRef(profile);\n profileRef.current = profile;\n const configRef = useRef(config);\n configRef.current = config;\n const onMessageRef = useRef(onMessage);\n onMessageRef.current = onMessage;\n const startingRef = useRef(false);\n\n const backgroundGraceMs =\n config.backgroundGraceMs ?? (Platform.OS === 'android' ? DEFAULT_BACKGROUND_GRACE_MS : 0);\n\n const callbacks: SessionCallbacks<D> = {\n onMessage: (message) => {\n if (disposedRef.current) return;\n setMessages((prev: Message<D>[]) => [...prev, message]);\n onMessageRef.current?.(message);\n },\n onStatusChange: (nextStatus) => {\n if (disposedRef.current) return;\n setStatus(nextStatus);\n if (nextStatus === 'connected') setError(null);\n },\n onError: (err) => {\n if (disposedRef.current) return;\n setError(err);\n },\n onResync: () => {\n if (disposedRef.current) return;\n startSession();\n },\n };\n\n async function startSession(): Promise<void> {\n if (startingRef.current) return;\n startingRef.current = true;\n\n const existing = sessionRef.current;\n if (existing) {\n existing.disconnect();\n sessionRef.current = null;\n }\n\n try {\n const session = await createChatSession<D>(configRef.current, channelId, profileRef.current, callbacks);\n\n if (disposedRef.current) {\n session.disconnect();\n return;\n }\n\n sessionRef.current = session;\n setParticipants(session.initialParticipants);\n setMessages(session.initialMessages);\n } catch (err) {\n if (disposedRef.current) return;\n\n if (err instanceof ChannelClosedError) {\n setStatus('closed');\n setError(err);\n return;\n }\n\n setStatus('error');\n setError(err instanceof Error ? err : new Error(String(err)));\n } finally {\n startingRef.current = false;\n }\n }\n\n useEffect(() => {\n disposedRef.current = false;\n startSession();\n\n return () => {\n disposedRef.current = true;\n if (backgroundTimerRef.current) {\n clearTimeout(backgroundTimerRef.current);\n backgroundTimerRef.current = null;\n }\n sessionRef.current?.disconnect();\n sessionRef.current = null;\n };\n }, [channelId]);\n\n useEffect(() => {\n function teardownSession(): void {\n sessionRef.current?.disconnect();\n sessionRef.current = null;\n setStatus('disconnected');\n }\n\n const subscription = AppState.addEventListener('change', (nextAppState) => {\n const prev = appStateRef.current;\n appStateRef.current = nextAppState;\n\n if (!sessionRef.current && nextAppState !== 'active') return;\n\n const shouldTeardown =\n nextAppState === 'background' ||\n (Platform.OS === 'ios' && nextAppState === 'inactive');\n\n if (nextAppState === 'active') {\n if (backgroundTimerRef.current) {\n clearTimeout(backgroundTimerRef.current);\n backgroundTimerRef.current = null;\n return;\n }\n\n if (prev.match(/inactive|background/) && !sessionRef.current) {\n startSession();\n }\n } else if (shouldTeardown) {\n if (backgroundTimerRef.current) return;\n\n if (backgroundGraceMs === 0) {\n teardownSession();\n } else {\n backgroundTimerRef.current = setTimeout(() => {\n backgroundTimerRef.current = null;\n teardownSession();\n }, backgroundGraceMs);\n }\n }\n });\n\n return () => {\n subscription.remove();\n if (backgroundTimerRef.current) {\n clearTimeout(backgroundTimerRef.current);\n backgroundTimerRef.current = null;\n }\n };\n }, [channelId, backgroundGraceMs]);\n\n const sendMessage = useCallback(\n async (type: D['messageType'], body: string, attributes?: MessageAttributes<D>): Promise<SendMessageResponse> => {\n const session = sessionRef.current;\n if (!session) throw new ChatDisconnectedError(statusRef.current);\n\n const optimistic = configRef.current.optimisticSend !== false;\n let optimisticId: string | null = null;\n\n if (optimistic) {\n optimisticId = `optimistic_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;\n const provisionalMsg: Message<D> = {\n id: optimisticId,\n channel_id: channelId,\n sender_id: profileRef.current.id,\n sender_role: profileRef.current.role as D['role'],\n type,\n body,\n attributes: (attributes ?? {}) as MessageAttributes<D>,\n created_at: new Date().toISOString(),\n };\n setMessages((prev) => [...prev, provisionalMsg]);\n }\n\n try {\n const response = await session.sendMessage(type, body, attributes);\n\n if (optimistic && optimisticId) {\n setMessages((prev) =>\n prev.map((m) =>\n m.id === optimisticId\n ? { ...m, id: response.id, created_at: response.created_at }\n : m,\n ),\n );\n }\n\n return response;\n } catch (err) {\n if (optimistic && optimisticId) {\n setMessages((prev) => prev.filter((m) => m.id !== optimisticId));\n }\n throw err;\n }\n },\n [channelId],\n );\n\n const disconnect = useCallback(() => {\n sessionRef.current?.disconnect();\n sessionRef.current = null;\n setStatus('disconnected');\n }, []);\n\n return { messages, participants, status, error, sendMessage, disconnect };\n}\n","import type { ChatStatus } from './types';\n\nexport class ChatDisconnectedError extends Error {\n constructor(public readonly status: ChatStatus) {\n super(`Cannot send message while ${status}`);\n this.name = 'ChatDisconnectedError';\n }\n}\n\nexport class ChannelClosedError extends Error {\n constructor(public readonly channelId: string) {\n super(`Channel ${channelId} is closed`);\n this.name = 'ChannelClosedError';\n }\n}\n","import EventSource from 'react-native-sse';\nimport type {\n ChatDomain,\n DefaultDomain,\n Participant,\n Message,\n JoinResponse,\n SendMessageRequest,\n SendMessageResponse,\n MessageAttributes,\n} from '@pedi/chika-types';\nimport type { ChatConfig, ChatStatus } from './types';\nimport { ChatDisconnectedError, ChannelClosedError } from './errors';\nimport { resolveServerUrl } from './resolve-url';\n\n// Custom SSE event types beyond built-in message/open/error/close.\n// 'heartbeat' is server keep-alive (no handler needed).\ntype ChatEvents = 'heartbeat' | 'resync';\n\nconst DEFAULT_RECONNECT_DELAY_MS = 3000;\nconst MAX_SEEN_IDS = 500;\n\nexport interface SessionCallbacks<D extends ChatDomain = DefaultDomain> {\n onMessage: (message: Message<D>) => void;\n onStatusChange: (status: ChatStatus) => void;\n onError: (error: Error) => void;\n onResync: () => void;\n}\n\nexport interface ChatSession<D extends ChatDomain = DefaultDomain> {\n serviceUrl: string;\n channelId: string;\n initialParticipants: Participant<D>[];\n initialMessages: Message<D>[];\n sendMessage: (type: D['messageType'], body: string, attributes?: MessageAttributes<D>) => Promise<SendMessageResponse>;\n disconnect: () => void;\n}\n\n/**\n * Creates an imperative chat session with SSE streaming and managed reconnection.\n * Lower-level API — prefer `useChat` hook for React Native components.\n *\n * @template D - Chat domain type. Defaults to DefaultDomain.\n */\nexport async function createChatSession<D extends ChatDomain = DefaultDomain>(\n config: ChatConfig,\n channelId: string,\n profile: Participant<D>,\n callbacks: SessionCallbacks<D>,\n): Promise<ChatSession<D>> {\n const serviceUrl = resolveServerUrl(config.manifest, channelId);\n const customHeaders = config.headers ?? {};\n const reconnectDelay = config.reconnectDelayMs ?? DEFAULT_RECONNECT_DELAY_MS;\n\n callbacks.onStatusChange('connecting');\n\n const joinRes = await fetch(`${serviceUrl}/channels/${channelId}/join`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...customHeaders },\n body: JSON.stringify(profile),\n });\n\n if (joinRes.status === 410) {\n throw new ChannelClosedError(channelId);\n }\n\n if (!joinRes.ok) {\n throw new Error(`Join failed: ${joinRes.status} ${await joinRes.text()}`);\n }\n\n const { messages, participants, joined_at }: JoinResponse<D> = await joinRes.json();\n\n let lastEventId =\n messages.length > 0 ? messages[messages.length - 1]!.id : undefined;\n\n const joinedAt = joined_at;\n\n const seenMessageIds = new Set<string>(messages.map((m) => m.id));\n\n let es: EventSource<ChatEvents> | null = null;\n let disposed = false;\n let reconnectTimer: ReturnType<typeof setTimeout> | null = null;\n\n function trimSeenIds(): void {\n if (seenMessageIds.size <= MAX_SEEN_IDS) return;\n const ids = [...seenMessageIds];\n seenMessageIds.clear();\n for (const id of ids.slice(-MAX_SEEN_IDS)) {\n seenMessageIds.add(id);\n }\n }\n\n function connect(): void {\n if (disposed) return;\n\n const streamUrl = lastEventId\n ? `${serviceUrl}/channels/${channelId}/stream`\n : `${serviceUrl}/channels/${channelId}/stream?since_time=${encodeURIComponent(joinedAt)}`;\n\n es = new EventSource<ChatEvents>(streamUrl, {\n headers: {\n ...customHeaders,\n ...(lastEventId && { 'Last-Event-ID': lastEventId }),\n },\n pollingInterval: 0,\n });\n\n es.addEventListener('open', () => {\n if (disposed) return;\n callbacks.onStatusChange('connected');\n });\n\n es.addEventListener('message', (event) => {\n if (disposed || !event.data) return;\n\n let message: Message<D>;\n try {\n message = JSON.parse(event.data);\n } catch {\n callbacks.onError(new Error('Failed to parse SSE message'));\n return;\n }\n\n if (event.lastEventId) {\n lastEventId = event.lastEventId;\n }\n\n if (seenMessageIds.has(message.id)) return;\n seenMessageIds.add(message.id);\n trimSeenIds();\n\n callbacks.onMessage(message);\n });\n\n es.addEventListener('resync', () => {\n if (disposed) return;\n cleanupEventSource();\n callbacks.onResync();\n });\n\n es.addEventListener('error', (event) => {\n if (disposed) return;\n\n const msg = 'message' in event ? String(event.message) : '';\n\n if (msg.includes('Channel is closed') || msg.includes('410')) {\n callbacks.onStatusChange('closed');\n cleanupEventSource();\n disposed = true;\n return;\n }\n\n if (msg) callbacks.onError(new Error(msg));\n\n scheduleReconnect();\n });\n\n es.addEventListener('close', () => {\n if (disposed) return;\n scheduleReconnect();\n });\n }\n\n function scheduleReconnect(): void {\n if (disposed || reconnectTimer) return;\n callbacks.onStatusChange('reconnecting');\n\n cleanupEventSource();\n\n reconnectTimer = setTimeout(() => {\n reconnectTimer = null;\n connect();\n }, reconnectDelay);\n }\n\n function cleanupEventSource(): void {\n if (es) {\n es.removeAllEventListeners();\n es.close();\n es = null;\n }\n }\n\n connect();\n\n return {\n serviceUrl,\n channelId,\n initialParticipants: participants,\n initialMessages: messages,\n\n sendMessage: async (type, body, attributes) => {\n if (disposed) throw new ChatDisconnectedError('disconnected');\n\n const payload: SendMessageRequest<D> = {\n sender_id: profile.id,\n type,\n body,\n attributes,\n };\n\n const res = await fetch(\n `${serviceUrl}/channels/${channelId}/messages`,\n {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...customHeaders },\n body: JSON.stringify(payload),\n },\n );\n\n if (!res.ok) {\n throw new Error(`Send failed: ${res.status} ${await res.text()}`);\n }\n\n const response: SendMessageResponse = await res.json();\n seenMessageIds.add(response.id);\n return response;\n },\n\n disconnect: () => {\n disposed = true;\n if (reconnectTimer) {\n clearTimeout(reconnectTimer);\n reconnectTimer = null;\n }\n cleanupEventSource();\n callbacks.onStatusChange('disconnected');\n },\n };\n}\n","import type { ChatManifest } from '@pedi/chika-types';\n\n/**\n * Creates a single-server manifest. Use this when all channels route to the same server.\n *\n * @example\n * ```ts\n * const config: ChatConfig = { manifest: createManifest('https://chat.example.com') };\n * ```\n */\nexport function createManifest(serverUrl: string): ChatManifest {\n return { buckets: [{ group: 'default', range: [0, 99], server_url: serverUrl }] };\n}\n\nexport function resolveServerUrl(manifest: ChatManifest, channelId: string): string {\n const hash = [...channelId].reduce((sum, c) => sum + c.charCodeAt(0), 0) % 100;\n const bucket = manifest.buckets.find(\n (b) => hash >= b.range[0] && hash <= b.range[1],\n );\n if (!bucket) throw new Error(`No chat bucket for hash ${hash}`);\n return bucket.server_url;\n}\n"],"mappings":"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","names":["EventSource"]}
1
+ {"version":3,"sources":["../src/index.ts","../src/use-chat.ts","../src/errors.ts","../src/resolve-url.ts","../src/sse-connection.ts","../src/session.ts","../src/use-unread.ts"],"sourcesContent":["export { useChat } from './use-chat';\nexport { useUnread } from './use-unread';\nexport { createChatSession } from './session';\nexport { resolveServerUrl, createManifest } from './resolve-url';\nexport { createSSEConnection } from './sse-connection';\nexport { ChatDisconnectedError, ChannelClosedError } from './errors';\n\nexport type { ChatConfig, ChatStatus, UseChatOptions, UseChatReturn } from './types';\nexport type { ChatSession, SessionCallbacks } from './session';\nexport type { UseUnreadOptions, UseUnreadReturn } from './use-unread';\nexport type { SSEConnection, SSEConnectionConfig, SSEConnectionCallbacks } from './sse-connection';\n\nexport type {\n ChatDomain,\n DefaultDomain,\n Message,\n Participant,\n MessageAttributes,\n SendMessageResponse,\n ChatManifest,\n ChatBucket,\n UnreadCountResponse,\n MarkReadRequest,\n SSEUnreadUpdateEvent,\n SSEUnreadClearEvent,\n SSEUnreadEvent,\n PediChat,\n PediRole,\n PediVehicle,\n PediLocation,\n PediParticipantMeta,\n PediMessageType,\n PediMessageAttributes,\n} from '@pedi/chika-types';\n","import { useEffect, useRef, useState, useCallback } from 'react';\nimport { AppState, Platform, type AppStateStatus } from 'react-native';\nimport type {\n ChatDomain,\n DefaultDomain,\n Message,\n Participant,\n MessageAttributes,\n SendMessageResponse,\n} from '@pedi/chika-types';\nimport type { UseChatOptions, UseChatReturn, ChatStatus } from './types';\nimport { ChatDisconnectedError, ChannelClosedError } from './errors';\nimport { createChatSession, type ChatSession, type SessionCallbacks } from './session';\n\nconst DEFAULT_BACKGROUND_GRACE_MS = 2000;\n\n/**\n * React hook for real-time chat over SSE.\n * Manages connection lifecycle, AppState transitions, message deduplication, and reconnection.\n *\n * @template D - Chat domain type for role/message type narrowing. Defaults to DefaultDomain.\n */\nexport function useChat<D extends ChatDomain = DefaultDomain>(\n { config, channelId, profile, onMessage }: UseChatOptions<D>,\n): UseChatReturn<D> {\n const [messages, setMessages] = useState<Message<D>[]>([]);\n const [participants, setParticipants] = useState<Participant<D>[]>([]);\n const [status, setStatus] = useState<ChatStatus>('connecting');\n const [error, setError] = useState<Error | null>(null);\n\n const sessionRef = useRef<ChatSession<D> | null>(null);\n const disposedRef = useRef(false);\n const messagesRef = useRef(messages);\n messagesRef.current = messages;\n const statusRef = useRef(status);\n statusRef.current = status;\n const appStateRef = useRef<AppStateStatus>(AppState.currentState);\n const backgroundTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n const profileRef = useRef(profile);\n profileRef.current = profile;\n const configRef = useRef(config);\n configRef.current = config;\n const onMessageRef = useRef(onMessage);\n onMessageRef.current = onMessage;\n const startingRef = useRef(false);\n const pendingOptimisticIds = useRef(new Set<string>());\n\n const backgroundGraceMs =\n config.backgroundGraceMs ?? (Platform.OS === 'android' ? DEFAULT_BACKGROUND_GRACE_MS : 0);\n\n const callbacks: SessionCallbacks<D> = {\n onMessage: (message) => {\n if (disposedRef.current) return;\n setMessages((prev: Message<D>[]) => {\n // Check if this SSE message reconciles a pending optimistic message.\n const optimisticIdx = prev.findIndex(\n (m) =>\n pendingOptimisticIds.current.has(m.id) &&\n m.sender_id === message.sender_id &&\n m.body === message.body &&\n m.type === message.type,\n );\n if (optimisticIdx !== -1) {\n const optimisticId = prev[optimisticIdx]!.id;\n pendingOptimisticIds.current.delete(optimisticId);\n const next = [...prev];\n next[optimisticIdx] = message;\n return next;\n }\n return [...prev, message];\n });\n onMessageRef.current?.(message);\n },\n onStatusChange: (nextStatus) => {\n if (disposedRef.current) return;\n setStatus(nextStatus);\n if (nextStatus === 'connected') setError(null);\n },\n onError: (err) => {\n if (disposedRef.current) return;\n setError(err);\n },\n onResync: () => {\n if (disposedRef.current) return;\n startSession();\n },\n };\n\n async function startSession(): Promise<void> {\n if (startingRef.current) return;\n startingRef.current = true;\n\n const existing = sessionRef.current;\n if (existing) {\n existing.disconnect();\n sessionRef.current = null;\n }\n\n try {\n const session = await createChatSession<D>(configRef.current, channelId, profileRef.current, callbacks);\n\n if (disposedRef.current) {\n session.disconnect();\n return;\n }\n\n sessionRef.current = session;\n setParticipants(session.initialParticipants);\n setMessages(session.initialMessages);\n } catch (err) {\n if (disposedRef.current) return;\n\n if (err instanceof ChannelClosedError) {\n setStatus('closed');\n setError(err);\n return;\n }\n\n setStatus('error');\n setError(err instanceof Error ? err : new Error(String(err)));\n } finally {\n startingRef.current = false;\n }\n }\n\n useEffect(() => {\n disposedRef.current = false;\n startSession();\n\n return () => {\n disposedRef.current = true;\n if (statusRef.current === 'connected' && sessionRef.current) {\n const lastMsg = messagesRef.current[messagesRef.current.length - 1];\n if (lastMsg) {\n sessionRef.current.markAsRead(lastMsg.id).catch(() => {});\n }\n }\n if (backgroundTimerRef.current) {\n clearTimeout(backgroundTimerRef.current);\n backgroundTimerRef.current = null;\n }\n sessionRef.current?.disconnect();\n sessionRef.current = null;\n };\n }, [channelId]);\n\n useEffect(() => {\n function teardownSession(): void {\n sessionRef.current?.disconnect();\n sessionRef.current = null;\n setStatus('disconnected');\n }\n\n const subscription = AppState.addEventListener('change', (nextAppState) => {\n const prev = appStateRef.current;\n appStateRef.current = nextAppState;\n\n if (!sessionRef.current && nextAppState !== 'active') return;\n\n const shouldTeardown =\n nextAppState === 'background' ||\n (Platform.OS === 'ios' && nextAppState === 'inactive');\n\n if (nextAppState === 'active') {\n if (backgroundTimerRef.current) {\n clearTimeout(backgroundTimerRef.current);\n backgroundTimerRef.current = null;\n return;\n }\n\n if (prev.match(/inactive|background/) && !sessionRef.current) {\n startSession();\n }\n } else if (shouldTeardown) {\n if (backgroundTimerRef.current) return;\n\n if (backgroundGraceMs === 0) {\n teardownSession();\n } else {\n backgroundTimerRef.current = setTimeout(() => {\n backgroundTimerRef.current = null;\n teardownSession();\n }, backgroundGraceMs);\n }\n }\n });\n\n return () => {\n subscription.remove();\n if (backgroundTimerRef.current) {\n clearTimeout(backgroundTimerRef.current);\n backgroundTimerRef.current = null;\n }\n };\n }, [channelId, backgroundGraceMs]);\n\n const sendMessage = useCallback(\n async (type: D['messageType'], body: string, attributes?: MessageAttributes<D>): Promise<SendMessageResponse> => {\n const session = sessionRef.current;\n if (!session) throw new ChatDisconnectedError(statusRef.current);\n\n const optimistic = configRef.current.optimisticSend !== false;\n let optimisticId: string | null = null;\n\n if (optimistic) {\n optimisticId = `optimistic_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;\n pendingOptimisticIds.current.add(optimisticId);\n const provisionalMsg: Message<D> = {\n id: optimisticId,\n channel_id: channelId,\n sender_id: profileRef.current.id,\n sender_role: profileRef.current.role as D['role'],\n type,\n body,\n attributes: (attributes ?? {}) as MessageAttributes<D>,\n created_at: new Date().toISOString(),\n };\n setMessages((prev) => [...prev, provisionalMsg]);\n }\n\n try {\n const response = await session.sendMessage(type, body, attributes);\n\n if (optimistic && optimisticId) {\n pendingOptimisticIds.current.delete(optimisticId);\n setMessages((prev) => {\n // If SSE already reconciled this message, the optimistic ID is gone.\n const stillPending = prev.some((m) => m.id === optimisticId);\n if (!stillPending) return prev;\n return prev.map((m) =>\n m.id === optimisticId\n ? { ...m, id: response.id, created_at: response.created_at }\n : m,\n );\n });\n }\n\n return response;\n } catch (err) {\n if (optimistic && optimisticId) {\n pendingOptimisticIds.current.delete(optimisticId);\n setMessages((prev) => prev.filter((m) => m.id !== optimisticId));\n }\n throw err;\n }\n },\n [channelId],\n );\n\n const disconnect = useCallback(() => {\n sessionRef.current?.disconnect();\n sessionRef.current = null;\n setStatus('disconnected');\n }, []);\n\n return { messages, participants, status, error, sendMessage, disconnect };\n}\n","import type { ChatStatus } from './types';\n\nexport class ChatDisconnectedError extends Error {\n constructor(public readonly status: ChatStatus) {\n super(`Cannot send message while ${status}`);\n this.name = 'ChatDisconnectedError';\n }\n}\n\nexport class ChannelClosedError extends Error {\n constructor(public readonly channelId: string) {\n super(`Channel ${channelId} is closed`);\n this.name = 'ChannelClosedError';\n }\n}\n","import type { ChatManifest } from '@pedi/chika-types';\n\n/**\n * Creates a single-server manifest. Use this when all channels route to the same server.\n *\n * @example\n * ```ts\n * const config: ChatConfig = { manifest: createManifest('https://chat.example.com') };\n * ```\n */\nexport function createManifest(serverUrl: string): ChatManifest {\n return { buckets: [{ group: 'default', range: [0, 99], server_url: serverUrl }] };\n}\n\nexport function resolveServerUrl(manifest: ChatManifest, channelId: string): string {\n const hash = [...channelId].reduce((sum, c) => sum + c.charCodeAt(0), 0) % 100;\n const bucket = manifest.buckets.find(\n (b) => hash >= b.range[0] && hash <= b.range[1],\n );\n if (!bucket) throw new Error(`No chat bucket for hash ${hash}`);\n return bucket.server_url;\n}\n","import EventSource from 'react-native-sse';\n\nconst DEFAULT_RECONNECT_DELAY_MS = 3000;\n\nexport interface SSEConnectionConfig {\n url: string;\n headers?: Record<string, string>;\n reconnectDelayMs?: number;\n lastEventId?: string;\n customEvents?: string[];\n}\n\nexport interface SSEConnectionCallbacks {\n onOpen?: () => void;\n onEvent: (eventType: string, data: string, lastEventId?: string) => void;\n onError?: (error: Error) => void;\n onClosed?: () => void;\n onReconnecting?: () => void;\n}\n\nexport interface SSEConnection {\n close: () => void;\n}\n\nexport function createSSEConnection(\n config: SSEConnectionConfig,\n callbacks: SSEConnectionCallbacks,\n): SSEConnection {\n const reconnectDelay = config.reconnectDelayMs ?? DEFAULT_RECONNECT_DELAY_MS;\n const customEvents = config.customEvents ?? [];\n\n let currentLastEventId = config.lastEventId;\n let es: EventSource<string> | null = null;\n let disposed = false;\n let reconnectTimer: ReturnType<typeof setTimeout> | null = null;\n\n function cleanup(): void {\n if (es) {\n es.removeAllEventListeners();\n es.close();\n es = null;\n }\n }\n\n function scheduleReconnect(): void {\n if (disposed || reconnectTimer) return;\n callbacks.onReconnecting?.();\n cleanup();\n\n reconnectTimer = setTimeout(() => {\n reconnectTimer = null;\n connect();\n }, reconnectDelay);\n }\n\n function connect(): void {\n if (disposed) return;\n\n es = new EventSource<string>(config.url, {\n headers: {\n ...config.headers,\n ...(currentLastEventId && { 'Last-Event-ID': currentLastEventId }),\n },\n pollingInterval: 0,\n });\n\n es.addEventListener('open', () => {\n if (disposed) return;\n callbacks.onOpen?.();\n });\n\n es.addEventListener('message', (event) => {\n if (disposed || !event.data) return;\n if (event.lastEventId) {\n currentLastEventId = event.lastEventId;\n }\n callbacks.onEvent('message', event.data, event.lastEventId ?? undefined);\n });\n\n for (const eventName of customEvents) {\n es.addEventListener(eventName, (event) => {\n if (disposed) return;\n callbacks.onEvent(eventName, event.data ?? '', undefined);\n });\n }\n\n es.addEventListener('error', (event) => {\n if (disposed) return;\n\n const msg = 'message' in event ? String(event.message) : '';\n\n if (msg.includes('Channel is closed') || msg.includes('410')) {\n callbacks.onClosed?.();\n cleanup();\n disposed = true;\n return;\n }\n\n if (msg) callbacks.onError?.(new Error(msg));\n\n scheduleReconnect();\n });\n\n es.addEventListener('close', () => {\n if (disposed) return;\n scheduleReconnect();\n });\n }\n\n connect();\n\n return {\n close: () => {\n disposed = true;\n if (reconnectTimer) {\n clearTimeout(reconnectTimer);\n reconnectTimer = null;\n }\n cleanup();\n },\n };\n}\n","import type {\n ChatDomain,\n DefaultDomain,\n Participant,\n Message,\n JoinResponse,\n SendMessageRequest,\n SendMessageResponse,\n MessageAttributes,\n} from '@pedi/chika-types';\nimport type { ChatConfig, ChatStatus } from './types';\nimport { ChatDisconnectedError, ChannelClosedError } from './errors';\nimport { resolveServerUrl } from './resolve-url';\nimport { createSSEConnection, type SSEConnection } from './sse-connection';\n\nconst DEFAULT_RECONNECT_DELAY_MS = 3000;\nconst MAX_SEEN_IDS = 500;\n\nexport interface SessionCallbacks<D extends ChatDomain = DefaultDomain> {\n onMessage: (message: Message<D>) => void;\n onStatusChange: (status: ChatStatus) => void;\n onError: (error: Error) => void;\n onResync: () => void;\n}\n\nexport interface ChatSession<D extends ChatDomain = DefaultDomain> {\n serviceUrl: string;\n channelId: string;\n initialParticipants: Participant<D>[];\n initialMessages: Message<D>[];\n sendMessage: (type: D['messageType'], body: string, attributes?: MessageAttributes<D>) => Promise<SendMessageResponse>;\n markAsRead: (messageId: string) => Promise<void>;\n disconnect: () => void;\n}\n\nexport async function createChatSession<D extends ChatDomain = DefaultDomain>(\n config: ChatConfig,\n channelId: string,\n profile: Participant<D>,\n callbacks: SessionCallbacks<D>,\n): Promise<ChatSession<D>> {\n const serviceUrl = resolveServerUrl(config.manifest, channelId);\n const customHeaders = config.headers ?? {};\n const reconnectDelay = config.reconnectDelayMs ?? DEFAULT_RECONNECT_DELAY_MS;\n\n callbacks.onStatusChange('connecting');\n\n const joinRes = await fetch(`${serviceUrl}/channels/${channelId}/join`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...customHeaders },\n body: JSON.stringify(profile),\n });\n\n if (joinRes.status === 410) {\n throw new ChannelClosedError(channelId);\n }\n\n if (!joinRes.ok) {\n throw new Error(`Join failed: ${joinRes.status} ${await joinRes.text()}`);\n }\n\n const { messages, participants, joined_at }: JoinResponse<D> = await joinRes.json();\n\n let lastEventId =\n messages.length > 0 ? messages[messages.length - 1]!.id : undefined;\n\n const joinedAt = joined_at;\n\n const seenMessageIds = new Set<string>(messages.map((m) => m.id));\n\n let sseConn: SSEConnection | null = null;\n let disposed = false;\n\n function trimSeenIds(): void {\n if (seenMessageIds.size <= MAX_SEEN_IDS) return;\n const ids = [...seenMessageIds];\n seenMessageIds.clear();\n for (const id of ids.slice(-MAX_SEEN_IDS)) {\n seenMessageIds.add(id);\n }\n }\n\n function connect(): void {\n if (disposed) return;\n\n const streamUrl = lastEventId\n ? `${serviceUrl}/channels/${channelId}/stream`\n : `${serviceUrl}/channels/${channelId}/stream?since_time=${encodeURIComponent(joinedAt)}`;\n\n sseConn = createSSEConnection(\n {\n url: streamUrl,\n headers: customHeaders,\n reconnectDelayMs: reconnectDelay,\n lastEventId,\n customEvents: ['resync'],\n },\n {\n onOpen: () => {\n if (!disposed) callbacks.onStatusChange('connected');\n },\n onEvent: (eventType, data, eventId) => {\n if (disposed) return;\n\n if (eventType === 'message') {\n let message: Message<D>;\n try {\n message = JSON.parse(data);\n } catch {\n callbacks.onError(new Error('Failed to parse SSE message'));\n return;\n }\n\n if (eventId) {\n lastEventId = eventId;\n }\n\n if (seenMessageIds.has(message.id)) return;\n seenMessageIds.add(message.id);\n trimSeenIds();\n\n callbacks.onMessage(message);\n } else if (eventType === 'resync') {\n sseConn?.close();\n sseConn = null;\n callbacks.onResync();\n }\n },\n onError: (err) => {\n if (!disposed) callbacks.onError(err);\n },\n onClosed: () => {\n callbacks.onStatusChange('closed');\n disposed = true;\n },\n onReconnecting: () => {\n if (!disposed) callbacks.onStatusChange('reconnecting');\n },\n },\n );\n }\n\n connect();\n\n return {\n serviceUrl,\n channelId,\n initialParticipants: participants,\n initialMessages: messages,\n\n sendMessage: async (type, body, attributes) => {\n if (disposed) throw new ChatDisconnectedError('disconnected');\n\n const payload: SendMessageRequest<D> = {\n sender_id: profile.id,\n type,\n body,\n attributes,\n };\n\n const res = await fetch(\n `${serviceUrl}/channels/${channelId}/messages`,\n {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...customHeaders },\n body: JSON.stringify(payload),\n },\n );\n\n if (!res.ok) {\n throw new Error(`Send failed: ${res.status} ${await res.text()}`);\n }\n\n const response: SendMessageResponse = await res.json();\n seenMessageIds.add(response.id);\n return response;\n },\n\n markAsRead: async (messageId: string) => {\n const res = await fetch(`${serviceUrl}/channels/${channelId}/read`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...customHeaders },\n body: JSON.stringify({\n participant_id: profile.id,\n message_id: messageId,\n }),\n });\n if (!res.ok) {\n throw new Error(`markAsRead failed: ${res.status}`);\n }\n },\n\n disconnect: () => {\n disposed = true;\n sseConn?.close();\n sseConn = null;\n callbacks.onStatusChange('disconnected');\n },\n };\n}\n","import { useEffect, useRef, useState, useCallback } from 'react';\nimport { AppState, Platform, type AppStateStatus } from 'react-native';\nimport type { UnreadCountResponse, SSEUnreadUpdateEvent } from '@pedi/chika-types';\nimport type { ChatConfig } from './types';\nimport { resolveServerUrl } from './resolve-url';\nimport { createSSEConnection, type SSEConnection } from './sse-connection';\n\nconst DEFAULT_BACKGROUND_GRACE_MS = 2000;\nconst UNREAD_CUSTOM_EVENTS = ['unread_snapshot', 'unread_update', 'unread_clear'];\n\nexport interface UseUnreadOptions {\n config: ChatConfig;\n channelId: string;\n participantId: string;\n enabled?: boolean;\n}\n\nexport interface UseUnreadReturn {\n unreadCount: number;\n hasUnread: boolean;\n lastMessageAt: string | null;\n error: Error | null;\n}\n\nexport function useUnread(options: UseUnreadOptions): UseUnreadReturn {\n const { config, channelId, participantId, enabled = true } = options;\n\n const [unreadCount, setUnreadCount] = useState(0);\n const [lastMessageAt, setLastMessageAt] = useState<string | null>(null);\n const [error, setError] = useState<Error | null>(null);\n\n const connRef = useRef<SSEConnection | null>(null);\n const configRef = useRef(config);\n configRef.current = config;\n const appStateRef = useRef<AppStateStatus>(AppState.currentState);\n const backgroundTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n\n const backgroundGraceMs =\n config.backgroundGraceMs ?? (Platform.OS === 'android' ? DEFAULT_BACKGROUND_GRACE_MS : 0);\n\n const connect = useCallback(() => {\n connRef.current?.close();\n connRef.current = null;\n\n const serviceUrl = resolveServerUrl(configRef.current.manifest, channelId);\n const customHeaders = configRef.current.headers ?? {};\n const url = `${serviceUrl}/channels/${channelId}/unread?participant_id=${encodeURIComponent(participantId)}`;\n\n connRef.current = createSSEConnection(\n {\n url,\n headers: customHeaders,\n reconnectDelayMs: configRef.current.reconnectDelayMs,\n customEvents: UNREAD_CUSTOM_EVENTS,\n },\n {\n onOpen: () => {\n setError(null);\n },\n onEvent: (eventType, data) => {\n try {\n if (eventType === 'unread_snapshot') {\n const snapshot: UnreadCountResponse = JSON.parse(data);\n setUnreadCount(snapshot.unread_count);\n setLastMessageAt(snapshot.last_message_at);\n } else if (eventType === 'unread_update') {\n const update: SSEUnreadUpdateEvent['data'] = JSON.parse(data);\n setUnreadCount((prev) => prev + 1);\n setLastMessageAt(update.created_at);\n } else if (eventType === 'unread_clear') {\n const clear: { channel_id: string; unread_count: number } = JSON.parse(data);\n setUnreadCount(clear.unread_count);\n }\n } catch {\n setError(new Error('Failed to parse unread SSE event'));\n }\n },\n onError: (err) => {\n setError(err);\n },\n onClosed: () => {\n connRef.current = null;\n },\n },\n );\n }, [channelId, participantId]);\n\n const disconnect = useCallback(() => {\n connRef.current?.close();\n connRef.current = null;\n }, []);\n\n useEffect(() => {\n setUnreadCount(0);\n setLastMessageAt(null);\n setError(null);\n\n if (!enabled) {\n disconnect();\n return;\n }\n\n connect();\n\n return () => {\n disconnect();\n if (backgroundTimerRef.current) {\n clearTimeout(backgroundTimerRef.current);\n backgroundTimerRef.current = null;\n }\n };\n }, [channelId, participantId, enabled, connect, disconnect]);\n\n useEffect(() => {\n if (!enabled) return;\n\n const subscription = AppState.addEventListener('change', (nextAppState) => {\n const prev = appStateRef.current;\n appStateRef.current = nextAppState;\n\n if (!connRef.current && nextAppState !== 'active') return;\n\n const shouldTeardown =\n nextAppState === 'background' ||\n (Platform.OS === 'ios' && nextAppState === 'inactive');\n\n if (nextAppState === 'active') {\n if (backgroundTimerRef.current) {\n clearTimeout(backgroundTimerRef.current);\n backgroundTimerRef.current = null;\n return;\n }\n\n if (prev.match(/inactive|background/) && !connRef.current) {\n connect();\n }\n } else if (shouldTeardown) {\n if (backgroundTimerRef.current) return;\n\n if (backgroundGraceMs === 0) {\n disconnect();\n } else {\n backgroundTimerRef.current = setTimeout(() => {\n backgroundTimerRef.current = null;\n disconnect();\n }, backgroundGraceMs);\n }\n }\n });\n\n return () => {\n subscription.remove();\n if (backgroundTimerRef.current) {\n clearTimeout(backgroundTimerRef.current);\n backgroundTimerRef.current = null;\n }\n };\n }, [enabled, backgroundGraceMs, connect, disconnect]);\n\n return { unreadCount, hasUnread: unreadCount > 0, lastMessageAt, error };\n}\n"],"mappings":"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","names":["EventSource","DEFAULT_RECONNECT_DELAY_MS","import_react","import_react_native","DEFAULT_BACKGROUND_GRACE_MS"]}
package/dist/index.mjs CHANGED
@@ -18,9 +18,6 @@ var ChannelClosedError = class extends Error {
18
18
  }
19
19
  };
20
20
 
21
- // src/session.ts
22
- import EventSource from "react-native-sse";
23
-
24
21
  // src/resolve-url.ts
25
22
  function createManifest(serverUrl) {
26
23
  return { buckets: [{ group: "default", range: [0, 99], server_url: serverUrl }] };
@@ -34,86 +31,68 @@ function resolveServerUrl(manifest, channelId) {
34
31
  return bucket.server_url;
35
32
  }
36
33
 
37
- // src/session.ts
34
+ // src/sse-connection.ts
35
+ import EventSource from "react-native-sse";
38
36
  var DEFAULT_RECONNECT_DELAY_MS = 3e3;
39
- var MAX_SEEN_IDS = 500;
40
- async function createChatSession(config, channelId, profile, callbacks) {
41
- const serviceUrl = resolveServerUrl(config.manifest, channelId);
42
- const customHeaders = config.headers ?? {};
37
+ function createSSEConnection(config, callbacks) {
43
38
  const reconnectDelay = config.reconnectDelayMs ?? DEFAULT_RECONNECT_DELAY_MS;
44
- callbacks.onStatusChange("connecting");
45
- const joinRes = await fetch(`${serviceUrl}/channels/${channelId}/join`, {
46
- method: "POST",
47
- headers: { "Content-Type": "application/json", ...customHeaders },
48
- body: JSON.stringify(profile)
49
- });
50
- if (joinRes.status === 410) {
51
- throw new ChannelClosedError(channelId);
52
- }
53
- if (!joinRes.ok) {
54
- throw new Error(`Join failed: ${joinRes.status} ${await joinRes.text()}`);
55
- }
56
- const { messages, participants, joined_at } = await joinRes.json();
57
- let lastEventId = messages.length > 0 ? messages[messages.length - 1].id : void 0;
58
- const joinedAt = joined_at;
59
- const seenMessageIds = new Set(messages.map((m) => m.id));
39
+ const customEvents = config.customEvents ?? [];
40
+ let currentLastEventId = config.lastEventId;
60
41
  let es = null;
61
42
  let disposed = false;
62
43
  let reconnectTimer = null;
63
- function trimSeenIds() {
64
- if (seenMessageIds.size <= MAX_SEEN_IDS) return;
65
- const ids = [...seenMessageIds];
66
- seenMessageIds.clear();
67
- for (const id of ids.slice(-MAX_SEEN_IDS)) {
68
- seenMessageIds.add(id);
44
+ function cleanup() {
45
+ if (es) {
46
+ es.removeAllEventListeners();
47
+ es.close();
48
+ es = null;
69
49
  }
70
50
  }
51
+ function scheduleReconnect() {
52
+ if (disposed || reconnectTimer) return;
53
+ callbacks.onReconnecting?.();
54
+ cleanup();
55
+ reconnectTimer = setTimeout(() => {
56
+ reconnectTimer = null;
57
+ connect();
58
+ }, reconnectDelay);
59
+ }
71
60
  function connect() {
72
61
  if (disposed) return;
73
- const streamUrl = lastEventId ? `${serviceUrl}/channels/${channelId}/stream` : `${serviceUrl}/channels/${channelId}/stream?since_time=${encodeURIComponent(joinedAt)}`;
74
- es = new EventSource(streamUrl, {
62
+ es = new EventSource(config.url, {
75
63
  headers: {
76
- ...customHeaders,
77
- ...lastEventId && { "Last-Event-ID": lastEventId }
64
+ ...config.headers,
65
+ ...currentLastEventId && { "Last-Event-ID": currentLastEventId }
78
66
  },
79
67
  pollingInterval: 0
80
68
  });
81
69
  es.addEventListener("open", () => {
82
70
  if (disposed) return;
83
- callbacks.onStatusChange("connected");
71
+ callbacks.onOpen?.();
84
72
  });
85
73
  es.addEventListener("message", (event) => {
86
74
  if (disposed || !event.data) return;
87
- let message;
88
- try {
89
- message = JSON.parse(event.data);
90
- } catch {
91
- callbacks.onError(new Error("Failed to parse SSE message"));
92
- return;
93
- }
94
75
  if (event.lastEventId) {
95
- lastEventId = event.lastEventId;
76
+ currentLastEventId = event.lastEventId;
96
77
  }
97
- if (seenMessageIds.has(message.id)) return;
98
- seenMessageIds.add(message.id);
99
- trimSeenIds();
100
- callbacks.onMessage(message);
101
- });
102
- es.addEventListener("resync", () => {
103
- if (disposed) return;
104
- cleanupEventSource();
105
- callbacks.onResync();
78
+ callbacks.onEvent("message", event.data, event.lastEventId ?? void 0);
106
79
  });
80
+ for (const eventName of customEvents) {
81
+ es.addEventListener(eventName, (event) => {
82
+ if (disposed) return;
83
+ callbacks.onEvent(eventName, event.data ?? "", void 0);
84
+ });
85
+ }
107
86
  es.addEventListener("error", (event) => {
108
87
  if (disposed) return;
109
88
  const msg = "message" in event ? String(event.message) : "";
110
89
  if (msg.includes("Channel is closed") || msg.includes("410")) {
111
- callbacks.onStatusChange("closed");
112
- cleanupEventSource();
90
+ callbacks.onClosed?.();
91
+ cleanup();
113
92
  disposed = true;
114
93
  return;
115
94
  }
116
- if (msg) callbacks.onError(new Error(msg));
95
+ if (msg) callbacks.onError?.(new Error(msg));
117
96
  scheduleReconnect();
118
97
  });
119
98
  es.addEventListener("close", () => {
@@ -121,22 +100,103 @@ async function createChatSession(config, channelId, profile, callbacks) {
121
100
  scheduleReconnect();
122
101
  });
123
102
  }
124
- function scheduleReconnect() {
125
- if (disposed || reconnectTimer) return;
126
- callbacks.onStatusChange("reconnecting");
127
- cleanupEventSource();
128
- reconnectTimer = setTimeout(() => {
129
- reconnectTimer = null;
130
- connect();
131
- }, reconnectDelay);
103
+ connect();
104
+ return {
105
+ close: () => {
106
+ disposed = true;
107
+ if (reconnectTimer) {
108
+ clearTimeout(reconnectTimer);
109
+ reconnectTimer = null;
110
+ }
111
+ cleanup();
112
+ }
113
+ };
114
+ }
115
+
116
+ // src/session.ts
117
+ var DEFAULT_RECONNECT_DELAY_MS2 = 3e3;
118
+ var MAX_SEEN_IDS = 500;
119
+ async function createChatSession(config, channelId, profile, callbacks) {
120
+ const serviceUrl = resolveServerUrl(config.manifest, channelId);
121
+ const customHeaders = config.headers ?? {};
122
+ const reconnectDelay = config.reconnectDelayMs ?? DEFAULT_RECONNECT_DELAY_MS2;
123
+ callbacks.onStatusChange("connecting");
124
+ const joinRes = await fetch(`${serviceUrl}/channels/${channelId}/join`, {
125
+ method: "POST",
126
+ headers: { "Content-Type": "application/json", ...customHeaders },
127
+ body: JSON.stringify(profile)
128
+ });
129
+ if (joinRes.status === 410) {
130
+ throw new ChannelClosedError(channelId);
132
131
  }
133
- function cleanupEventSource() {
134
- if (es) {
135
- es.removeAllEventListeners();
136
- es.close();
137
- es = null;
132
+ if (!joinRes.ok) {
133
+ throw new Error(`Join failed: ${joinRes.status} ${await joinRes.text()}`);
134
+ }
135
+ const { messages, participants, joined_at } = await joinRes.json();
136
+ let lastEventId = messages.length > 0 ? messages[messages.length - 1].id : void 0;
137
+ const joinedAt = joined_at;
138
+ const seenMessageIds = new Set(messages.map((m) => m.id));
139
+ let sseConn = null;
140
+ let disposed = false;
141
+ function trimSeenIds() {
142
+ if (seenMessageIds.size <= MAX_SEEN_IDS) return;
143
+ const ids = [...seenMessageIds];
144
+ seenMessageIds.clear();
145
+ for (const id of ids.slice(-MAX_SEEN_IDS)) {
146
+ seenMessageIds.add(id);
138
147
  }
139
148
  }
149
+ function connect() {
150
+ if (disposed) return;
151
+ const streamUrl = lastEventId ? `${serviceUrl}/channels/${channelId}/stream` : `${serviceUrl}/channels/${channelId}/stream?since_time=${encodeURIComponent(joinedAt)}`;
152
+ sseConn = createSSEConnection(
153
+ {
154
+ url: streamUrl,
155
+ headers: customHeaders,
156
+ reconnectDelayMs: reconnectDelay,
157
+ lastEventId,
158
+ customEvents: ["resync"]
159
+ },
160
+ {
161
+ onOpen: () => {
162
+ if (!disposed) callbacks.onStatusChange("connected");
163
+ },
164
+ onEvent: (eventType, data, eventId) => {
165
+ if (disposed) return;
166
+ if (eventType === "message") {
167
+ let message;
168
+ try {
169
+ message = JSON.parse(data);
170
+ } catch {
171
+ callbacks.onError(new Error("Failed to parse SSE message"));
172
+ return;
173
+ }
174
+ if (eventId) {
175
+ lastEventId = eventId;
176
+ }
177
+ if (seenMessageIds.has(message.id)) return;
178
+ seenMessageIds.add(message.id);
179
+ trimSeenIds();
180
+ callbacks.onMessage(message);
181
+ } else if (eventType === "resync") {
182
+ sseConn?.close();
183
+ sseConn = null;
184
+ callbacks.onResync();
185
+ }
186
+ },
187
+ onError: (err) => {
188
+ if (!disposed) callbacks.onError(err);
189
+ },
190
+ onClosed: () => {
191
+ callbacks.onStatusChange("closed");
192
+ disposed = true;
193
+ },
194
+ onReconnecting: () => {
195
+ if (!disposed) callbacks.onStatusChange("reconnecting");
196
+ }
197
+ }
198
+ );
199
+ }
140
200
  connect();
141
201
  return {
142
202
  serviceUrl,
@@ -166,13 +226,23 @@ async function createChatSession(config, channelId, profile, callbacks) {
166
226
  seenMessageIds.add(response.id);
167
227
  return response;
168
228
  },
229
+ markAsRead: async (messageId) => {
230
+ const res = await fetch(`${serviceUrl}/channels/${channelId}/read`, {
231
+ method: "POST",
232
+ headers: { "Content-Type": "application/json", ...customHeaders },
233
+ body: JSON.stringify({
234
+ participant_id: profile.id,
235
+ message_id: messageId
236
+ })
237
+ });
238
+ if (!res.ok) {
239
+ throw new Error(`markAsRead failed: ${res.status}`);
240
+ }
241
+ },
169
242
  disconnect: () => {
170
243
  disposed = true;
171
- if (reconnectTimer) {
172
- clearTimeout(reconnectTimer);
173
- reconnectTimer = null;
174
- }
175
- cleanupEventSource();
244
+ sseConn?.close();
245
+ sseConn = null;
176
246
  callbacks.onStatusChange("disconnected");
177
247
  }
178
248
  };
@@ -187,6 +257,8 @@ function useChat({ config, channelId, profile, onMessage }) {
187
257
  const [error, setError] = useState(null);
188
258
  const sessionRef = useRef(null);
189
259
  const disposedRef = useRef(false);
260
+ const messagesRef = useRef(messages);
261
+ messagesRef.current = messages;
190
262
  const statusRef = useRef(status);
191
263
  statusRef.current = status;
192
264
  const appStateRef = useRef(AppState.currentState);
@@ -198,11 +270,24 @@ function useChat({ config, channelId, profile, onMessage }) {
198
270
  const onMessageRef = useRef(onMessage);
199
271
  onMessageRef.current = onMessage;
200
272
  const startingRef = useRef(false);
273
+ const pendingOptimisticIds = useRef(/* @__PURE__ */ new Set());
201
274
  const backgroundGraceMs = config.backgroundGraceMs ?? (Platform.OS === "android" ? DEFAULT_BACKGROUND_GRACE_MS : 0);
202
275
  const callbacks = {
203
276
  onMessage: (message) => {
204
277
  if (disposedRef.current) return;
205
- setMessages((prev) => [...prev, message]);
278
+ setMessages((prev) => {
279
+ const optimisticIdx = prev.findIndex(
280
+ (m) => pendingOptimisticIds.current.has(m.id) && m.sender_id === message.sender_id && m.body === message.body && m.type === message.type
281
+ );
282
+ if (optimisticIdx !== -1) {
283
+ const optimisticId = prev[optimisticIdx].id;
284
+ pendingOptimisticIds.current.delete(optimisticId);
285
+ const next = [...prev];
286
+ next[optimisticIdx] = message;
287
+ return next;
288
+ }
289
+ return [...prev, message];
290
+ });
206
291
  onMessageRef.current?.(message);
207
292
  },
208
293
  onStatusChange: (nextStatus) => {
@@ -254,6 +339,13 @@ function useChat({ config, channelId, profile, onMessage }) {
254
339
  startSession();
255
340
  return () => {
256
341
  disposedRef.current = true;
342
+ if (statusRef.current === "connected" && sessionRef.current) {
343
+ const lastMsg = messagesRef.current[messagesRef.current.length - 1];
344
+ if (lastMsg) {
345
+ sessionRef.current.markAsRead(lastMsg.id).catch(() => {
346
+ });
347
+ }
348
+ }
257
349
  if (backgroundTimerRef.current) {
258
350
  clearTimeout(backgroundTimerRef.current);
259
351
  backgroundTimerRef.current = null;
@@ -310,6 +402,7 @@ function useChat({ config, channelId, profile, onMessage }) {
310
402
  let optimisticId = null;
311
403
  if (optimistic) {
312
404
  optimisticId = `optimistic_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
405
+ pendingOptimisticIds.current.add(optimisticId);
313
406
  const provisionalMsg = {
314
407
  id: optimisticId,
315
408
  channel_id: channelId,
@@ -325,15 +418,19 @@ function useChat({ config, channelId, profile, onMessage }) {
325
418
  try {
326
419
  const response = await session.sendMessage(type, body, attributes);
327
420
  if (optimistic && optimisticId) {
328
- setMessages(
329
- (prev) => prev.map(
421
+ pendingOptimisticIds.current.delete(optimisticId);
422
+ setMessages((prev) => {
423
+ const stillPending = prev.some((m) => m.id === optimisticId);
424
+ if (!stillPending) return prev;
425
+ return prev.map(
330
426
  (m) => m.id === optimisticId ? { ...m, id: response.id, created_at: response.created_at } : m
331
- )
332
- );
427
+ );
428
+ });
333
429
  }
334
430
  return response;
335
431
  } catch (err) {
336
432
  if (optimistic && optimisticId) {
433
+ pendingOptimisticIds.current.delete(optimisticId);
337
434
  setMessages((prev) => prev.filter((m) => m.id !== optimisticId));
338
435
  }
339
436
  throw err;
@@ -348,12 +445,134 @@ function useChat({ config, channelId, profile, onMessage }) {
348
445
  }, []);
349
446
  return { messages, participants, status, error, sendMessage, disconnect };
350
447
  }
448
+
449
+ // src/use-unread.ts
450
+ import { useEffect as useEffect2, useRef as useRef2, useState as useState2, useCallback as useCallback2 } from "react";
451
+ import { AppState as AppState2, Platform as Platform2 } from "react-native";
452
+ var DEFAULT_BACKGROUND_GRACE_MS2 = 2e3;
453
+ var UNREAD_CUSTOM_EVENTS = ["unread_snapshot", "unread_update", "unread_clear"];
454
+ function useUnread(options) {
455
+ const { config, channelId, participantId, enabled = true } = options;
456
+ const [unreadCount, setUnreadCount] = useState2(0);
457
+ const [lastMessageAt, setLastMessageAt] = useState2(null);
458
+ const [error, setError] = useState2(null);
459
+ const connRef = useRef2(null);
460
+ const configRef = useRef2(config);
461
+ configRef.current = config;
462
+ const appStateRef = useRef2(AppState2.currentState);
463
+ const backgroundTimerRef = useRef2(null);
464
+ const backgroundGraceMs = config.backgroundGraceMs ?? (Platform2.OS === "android" ? DEFAULT_BACKGROUND_GRACE_MS2 : 0);
465
+ const connect = useCallback2(() => {
466
+ connRef.current?.close();
467
+ connRef.current = null;
468
+ const serviceUrl = resolveServerUrl(configRef.current.manifest, channelId);
469
+ const customHeaders = configRef.current.headers ?? {};
470
+ const url = `${serviceUrl}/channels/${channelId}/unread?participant_id=${encodeURIComponent(participantId)}`;
471
+ connRef.current = createSSEConnection(
472
+ {
473
+ url,
474
+ headers: customHeaders,
475
+ reconnectDelayMs: configRef.current.reconnectDelayMs,
476
+ customEvents: UNREAD_CUSTOM_EVENTS
477
+ },
478
+ {
479
+ onOpen: () => {
480
+ setError(null);
481
+ },
482
+ onEvent: (eventType, data) => {
483
+ try {
484
+ if (eventType === "unread_snapshot") {
485
+ const snapshot = JSON.parse(data);
486
+ setUnreadCount(snapshot.unread_count);
487
+ setLastMessageAt(snapshot.last_message_at);
488
+ } else if (eventType === "unread_update") {
489
+ const update = JSON.parse(data);
490
+ setUnreadCount((prev) => prev + 1);
491
+ setLastMessageAt(update.created_at);
492
+ } else if (eventType === "unread_clear") {
493
+ const clear = JSON.parse(data);
494
+ setUnreadCount(clear.unread_count);
495
+ }
496
+ } catch {
497
+ setError(new Error("Failed to parse unread SSE event"));
498
+ }
499
+ },
500
+ onError: (err) => {
501
+ setError(err);
502
+ },
503
+ onClosed: () => {
504
+ connRef.current = null;
505
+ }
506
+ }
507
+ );
508
+ }, [channelId, participantId]);
509
+ const disconnect = useCallback2(() => {
510
+ connRef.current?.close();
511
+ connRef.current = null;
512
+ }, []);
513
+ useEffect2(() => {
514
+ setUnreadCount(0);
515
+ setLastMessageAt(null);
516
+ setError(null);
517
+ if (!enabled) {
518
+ disconnect();
519
+ return;
520
+ }
521
+ connect();
522
+ return () => {
523
+ disconnect();
524
+ if (backgroundTimerRef.current) {
525
+ clearTimeout(backgroundTimerRef.current);
526
+ backgroundTimerRef.current = null;
527
+ }
528
+ };
529
+ }, [channelId, participantId, enabled, connect, disconnect]);
530
+ useEffect2(() => {
531
+ if (!enabled) return;
532
+ const subscription = AppState2.addEventListener("change", (nextAppState) => {
533
+ const prev = appStateRef.current;
534
+ appStateRef.current = nextAppState;
535
+ if (!connRef.current && nextAppState !== "active") return;
536
+ const shouldTeardown = nextAppState === "background" || Platform2.OS === "ios" && nextAppState === "inactive";
537
+ if (nextAppState === "active") {
538
+ if (backgroundTimerRef.current) {
539
+ clearTimeout(backgroundTimerRef.current);
540
+ backgroundTimerRef.current = null;
541
+ return;
542
+ }
543
+ if (prev.match(/inactive|background/) && !connRef.current) {
544
+ connect();
545
+ }
546
+ } else if (shouldTeardown) {
547
+ if (backgroundTimerRef.current) return;
548
+ if (backgroundGraceMs === 0) {
549
+ disconnect();
550
+ } else {
551
+ backgroundTimerRef.current = setTimeout(() => {
552
+ backgroundTimerRef.current = null;
553
+ disconnect();
554
+ }, backgroundGraceMs);
555
+ }
556
+ }
557
+ });
558
+ return () => {
559
+ subscription.remove();
560
+ if (backgroundTimerRef.current) {
561
+ clearTimeout(backgroundTimerRef.current);
562
+ backgroundTimerRef.current = null;
563
+ }
564
+ };
565
+ }, [enabled, backgroundGraceMs, connect, disconnect]);
566
+ return { unreadCount, hasUnread: unreadCount > 0, lastMessageAt, error };
567
+ }
351
568
  export {
352
569
  ChannelClosedError,
353
570
  ChatDisconnectedError,
354
571
  createChatSession,
355
572
  createManifest,
573
+ createSSEConnection,
356
574
  resolveServerUrl,
357
- useChat
575
+ useChat,
576
+ useUnread
358
577
  };
359
578
  //# sourceMappingURL=index.mjs.map