@usechatting/react-native 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,122 @@
1
+ # @usechatting/react-native
2
+
3
+ Expo-friendly React Native chat package for Chatting.
4
+
5
+ ## What it includes
6
+
7
+ - JavaScript client for Chatting public chat APIs
8
+ - pluggable async session storage
9
+ - React hook for conversation state
10
+ - simple React Native chat screen
11
+ - live conversation sync with automatic polling fallback
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ npm install @usechatting/react-native
17
+ ```
18
+
19
+ If you want session persistence across app restarts, also install an async storage adapter in your app, for example React Native Async Storage.
20
+
21
+ For Expo push notifications in your host app, also install:
22
+
23
+ ```bash
24
+ npx expo install expo-notifications
25
+ ```
26
+
27
+ ## Required config
28
+
29
+ - `baseURL`: use `https://usechatting.com`
30
+ - `siteId`: your site/workspace ID inside Chatting
31
+
32
+ ## Exact Expo integration snippet
33
+
34
+ ```tsx
35
+ import { useEffect, useMemo, useState } from "react";
36
+ import AsyncStorage from "@react-native-async-storage/async-storage";
37
+ import * as Notifications from "expo-notifications";
38
+ import { AppState, Platform } from "react-native";
39
+ import {
40
+ ChattingClient,
41
+ ChattingConversationScreen,
42
+ createKeyValueSessionStore
43
+ } from "@usechatting/react-native";
44
+
45
+ Notifications.setNotificationHandler({
46
+ handleNotification: async () => ({
47
+ shouldShowAlert: false,
48
+ shouldPlaySound: false,
49
+ shouldSetBadge: false
50
+ })
51
+ });
52
+
53
+ const client = new ChattingClient({
54
+ baseURL: "https://usechatting.com",
55
+ siteId: "your-site-id",
56
+ sessionStore: createKeyValueSessionStore(AsyncStorage, "your-site-id")
57
+ });
58
+
59
+ export function SupportScreen() {
60
+ const [refreshKey, setRefreshKey] = useState(0);
61
+ const context = useMemo(() => ({ pageUrl: "myapp://support" }), []);
62
+
63
+ useEffect(() => {
64
+ let mounted = true;
65
+ const refreshChat = async () => {
66
+ await client.syncPushToken();
67
+ if (mounted) {
68
+ setRefreshKey((current) => current + 1);
69
+ }
70
+ };
71
+
72
+ const registerPush = async () => {
73
+ const permission = await Notifications.requestPermissionsAsync();
74
+ if (!mounted || permission.status !== "granted") {
75
+ return;
76
+ }
77
+
78
+ const token = await Notifications.getExpoPushTokenAsync({
79
+ projectId: "your-expo-project-id"
80
+ });
81
+
82
+ await client.registerPushToken({
83
+ pushToken: token.data,
84
+ platform: Platform.OS
85
+ });
86
+ };
87
+
88
+ void registerPush();
89
+
90
+ const appStateSubscription = AppState.addEventListener("change", (nextState) => {
91
+ if (nextState === "active") {
92
+ void refreshChat();
93
+ }
94
+ });
95
+
96
+ const responseSubscription = Notifications.addNotificationResponseReceivedListener(() => {
97
+ void refreshChat();
98
+ });
99
+
100
+ return () => {
101
+ mounted = false;
102
+ appStateSubscription.remove();
103
+ responseSubscription.remove();
104
+ };
105
+ }, []);
106
+
107
+ return (
108
+ <ChattingConversationScreen
109
+ key={refreshKey}
110
+ client={client}
111
+ context={context}
112
+ draftVisitorEmail="customer@example.com"
113
+ />
114
+ );
115
+ }
116
+ ```
117
+
118
+ ## Realtime behavior
119
+
120
+ This package uses a dedicated React Native live transport for Chatting's SSE conversation stream, so new team messages and team typing updates show up immediately while the app is open. If that live connection drops, the hook falls back to foreground polling so the conversation stays usable while reconnecting.
121
+
122
+ Expo push delivery is now supported for background and suspended apps once the host app registers an Expo push token through `client.registerPushToken(...)`. When the app becomes active again or a notification is tapped, call `client.syncPushToken()` and remount or refresh your chat screen so the conversation refetches cleanly.
@@ -0,0 +1,13 @@
1
+ import { resolveContext } from "./chatting-utils";
2
+ import type { ChattingSessionState } from "./chatting-types";
3
+ export declare const DEFAULT_CHATTING_BASE_URL = "https://usechatting.com";
4
+ export declare function requireChattingText(value: string | null | undefined, message: string): string;
5
+ export declare function buildChattingSiteQuery(siteId: string, state: ChattingSessionState, context: ReturnType<typeof resolveContext>): {
6
+ siteId: string;
7
+ sessionId: string;
8
+ email: string | null;
9
+ pageUrl: string | null;
10
+ referrer: string | null;
11
+ timezone: string | null;
12
+ locale: string | null;
13
+ };
@@ -0,0 +1,20 @@
1
+ import { normalizeText } from "./chatting-utils";
2
+ export const DEFAULT_CHATTING_BASE_URL = "https://usechatting.com";
3
+ export function requireChattingText(value, message) {
4
+ const nextValue = normalizeText(value);
5
+ if (!nextValue) {
6
+ throw new Error(message);
7
+ }
8
+ return nextValue;
9
+ }
10
+ export function buildChattingSiteQuery(siteId, state, context) {
11
+ return {
12
+ siteId,
13
+ sessionId: state.sessionId,
14
+ email: state.email ?? null,
15
+ pageUrl: context.pageUrl,
16
+ referrer: context.referrer,
17
+ timezone: context.timezone,
18
+ locale: context.locale
19
+ };
20
+ }
@@ -0,0 +1,27 @@
1
+ import type { ChattingClientOptions, ChattingConversationState, ChattingLiveEvent, ChattingPushRegistrationInput, ChattingSendMessageResult, ChattingSessionState, ChattingSiteConfig, ChattingSiteStatus, ChattingVisitorContext, ChattingVisitorProfile } from "./chatting-types";
2
+ export declare class ChattingClient {
3
+ readonly siteId: string;
4
+ private readonly sessionStore;
5
+ private readonly transport;
6
+ constructor(options: ChattingClientOptions);
7
+ currentSessionState(): Promise<ChattingSessionState>;
8
+ clearConversation(): Promise<void>;
9
+ saveEmail(email: string): Promise<void>;
10
+ fetchSiteConfig(context?: ChattingVisitorContext): Promise<ChattingSiteConfig>;
11
+ fetchSiteStatus(context?: ChattingVisitorContext): Promise<ChattingSiteStatus>;
12
+ fetchConversationIfAvailable(): Promise<ChattingConversationState | null>;
13
+ fetchConversation(): Promise<ChattingConversationState>;
14
+ sendMessage(content: string, options?: {
15
+ context?: ChattingVisitorContext;
16
+ email?: string | null;
17
+ }): Promise<ChattingSendMessageResult>;
18
+ identify(profile: ChattingVisitorProfile, context?: ChattingVisitorContext): Promise<void>;
19
+ updateTyping(isTyping: boolean): Promise<void>;
20
+ registerPushToken(registration: ChattingPushRegistrationInput): Promise<void>;
21
+ unregisterPushToken(pushToken?: string | null): Promise<void>;
22
+ syncPushToken(): Promise<void>;
23
+ subscribeLiveEvents(input: {
24
+ onEvent(event: ChattingLiveEvent): void;
25
+ onDisconnect?(error?: unknown): void;
26
+ }): Promise<() => void>;
27
+ }
@@ -0,0 +1,147 @@
1
+ import { createMemorySessionStore } from "./chatting-session-store";
2
+ import { buildChattingSiteQuery, DEFAULT_CHATTING_BASE_URL, requireChattingText } from "./chatting-client-shared";
3
+ import { clearConversationPushState, registerChattingPushToken, syncChattingPushToken, unregisterChattingPushToken } from "./chatting-push-registration";
4
+ import { ChattingTransport } from "./chatting-transport";
5
+ import { createSessionId, normalizeText, resolveContext } from "./chatting-utils";
6
+ export class ChattingClient {
7
+ siteId;
8
+ sessionStore;
9
+ transport;
10
+ constructor(options) {
11
+ this.siteId = options.siteId;
12
+ this.sessionStore = options.sessionStore ?? createMemorySessionStore();
13
+ this.transport = new ChattingTransport(options.baseURL ?? DEFAULT_CHATTING_BASE_URL, options.fetchImpl ?? fetch);
14
+ }
15
+ async currentSessionState() {
16
+ const stored = await this.sessionStore.load();
17
+ if (stored) {
18
+ return stored;
19
+ }
20
+ const nextState = { sessionId: createSessionId() };
21
+ await this.sessionStore.save(nextState);
22
+ return nextState;
23
+ }
24
+ async clearConversation() {
25
+ const state = await this.currentSessionState();
26
+ await this.sessionStore.save(clearConversationPushState(state));
27
+ }
28
+ async saveEmail(email) {
29
+ const nextEmail = requireChattingText(email, "Email is required.");
30
+ const state = await this.currentSessionState();
31
+ if (!state.conversationId) {
32
+ await this.sessionStore.save({ ...state, email: nextEmail });
33
+ return;
34
+ }
35
+ await this.transport.post("/api/public/conversation-email", {
36
+ siteId: this.siteId,
37
+ sessionId: state.sessionId,
38
+ conversationId: state.conversationId,
39
+ email: nextEmail
40
+ });
41
+ await this.sessionStore.save({ ...state, email: nextEmail });
42
+ }
43
+ async fetchSiteConfig(context = {}) {
44
+ const state = await this.currentSessionState();
45
+ const response = await this.transport.get("/api/public/site-config", {
46
+ ...buildChattingSiteQuery(this.siteId, state, resolveContext(context)),
47
+ conversationId: state.conversationId ?? null
48
+ });
49
+ return response.site;
50
+ }
51
+ async fetchSiteStatus(context = {}) {
52
+ const state = await this.currentSessionState();
53
+ const response = await this.transport.get("/api/public/site-status", {
54
+ ...buildChattingSiteQuery(this.siteId, state, resolveContext(context)),
55
+ conversationId: state.conversationId ?? null
56
+ });
57
+ return response;
58
+ }
59
+ async fetchConversationIfAvailable() {
60
+ const state = await this.currentSessionState();
61
+ return state.conversationId ? this.fetchConversation() : null;
62
+ }
63
+ async fetchConversation() {
64
+ const state = await this.currentSessionState();
65
+ const conversationId = requireChattingText(state.conversationId, "No active Chatting conversation exists yet.");
66
+ return this.transport.get("/api/public/conversation", {
67
+ siteId: this.siteId,
68
+ sessionId: state.sessionId,
69
+ conversationId
70
+ });
71
+ }
72
+ async sendMessage(content, options = {}) {
73
+ const nextContent = requireChattingText(content, "Message content cannot be empty.");
74
+ const state = await this.currentSessionState();
75
+ const email = normalizeText(options.email) ?? state.email ?? null;
76
+ const result = await this.transport.post("/api/public/messages", {
77
+ siteId: this.siteId,
78
+ sessionId: state.sessionId,
79
+ conversationId: state.conversationId ?? null,
80
+ content: nextContent,
81
+ email,
82
+ ...resolveContext(options.context)
83
+ });
84
+ await this.sessionStore.save(await syncChattingPushToken({
85
+ siteId: this.siteId,
86
+ transport: this.transport,
87
+ state: { ...state, conversationId: result.conversationId, email }
88
+ }));
89
+ return result;
90
+ }
91
+ async identify(profile, context = {}) {
92
+ const state = await this.currentSessionState();
93
+ await this.transport.post("/api/public/identify", {
94
+ siteId: this.siteId,
95
+ sessionId: state.sessionId,
96
+ conversationId: state.conversationId ?? null,
97
+ ...resolveContext(context),
98
+ ...profile
99
+ });
100
+ await this.sessionStore.save({ ...state, email: profile.email });
101
+ }
102
+ async updateTyping(isTyping) {
103
+ const state = await this.currentSessionState();
104
+ const conversationId = requireChattingText(state.conversationId, "No active Chatting conversation exists yet.");
105
+ await this.transport.post("/api/public/typing", {
106
+ siteId: this.siteId,
107
+ sessionId: state.sessionId,
108
+ conversationId,
109
+ typing: isTyping
110
+ });
111
+ }
112
+ async registerPushToken(registration) {
113
+ const state = await this.currentSessionState();
114
+ await this.sessionStore.save(await registerChattingPushToken({
115
+ siteId: this.siteId,
116
+ state,
117
+ transport: this.transport,
118
+ registration
119
+ }));
120
+ }
121
+ async unregisterPushToken(pushToken) {
122
+ const state = await this.currentSessionState();
123
+ await this.sessionStore.save(await unregisterChattingPushToken({
124
+ siteId: this.siteId,
125
+ state,
126
+ transport: this.transport,
127
+ pushToken
128
+ }));
129
+ }
130
+ async syncPushToken() {
131
+ const state = await this.currentSessionState();
132
+ await this.sessionStore.save(await syncChattingPushToken({
133
+ siteId: this.siteId,
134
+ state,
135
+ transport: this.transport
136
+ }));
137
+ }
138
+ async subscribeLiveEvents(input) {
139
+ const state = await this.currentSessionState();
140
+ const conversationId = requireChattingText(state.conversationId, "No active Chatting conversation exists yet.");
141
+ return this.transport.connectLive("/api/public/conversation-live", {
142
+ siteId: this.siteId,
143
+ sessionId: state.sessionId,
144
+ conversationId
145
+ }, input);
146
+ }
147
+ }
@@ -0,0 +1,9 @@
1
+ import type { ChattingClient } from "./chatting-client";
2
+ import type { ChattingVisitorContext, ChattingVisitorProfile } from "./chatting-types";
3
+ export declare function ChattingConversationScreen(input: {
4
+ client: ChattingClient;
5
+ context?: ChattingVisitorContext;
6
+ profile?: ChattingVisitorProfile | null;
7
+ draftVisitorEmail?: string | null;
8
+ pollIntervalMs?: number;
9
+ }): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,21 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { ActivityIndicator, ScrollView, Text, TextInput, TouchableOpacity, View } from "react-native";
3
+ import { useChattingConversation } from "./use-chatting-conversation";
4
+ import { styles } from "./chatting-screen-styles";
5
+ export function ChattingConversationScreen(input) {
6
+ const conversation = useChattingConversation(input);
7
+ const title = conversation.siteConfig?.widgetTitle ?? "Chatting";
8
+ const subtitle = conversation.siteConfig?.showOnlineStatus !== false && conversation.siteStatus
9
+ ? conversation.siteStatus.online
10
+ ? "Team is online"
11
+ : "Team is away"
12
+ : conversation.siteConfig?.greetingText ?? "Start a conversation";
13
+ return (_jsxs(View, { style: styles.screen, children: [_jsxs(View, { style: styles.header, children: [_jsx(Text, { style: styles.title, children: title }), _jsx(Text, { style: styles.subtitle, children: subtitle })] }), _jsxs(ScrollView, { style: styles.scroll, contentContainerStyle: styles.content, children: [conversation.errorMessage ? (_jsx(View, { style: styles.banner, children: _jsx(Text, { style: [styles.bannerText, styles.error], children: conversation.errorMessage }) })) : null, conversation.isLoading ? _jsx(ActivityIndicator, { color: "#2563EB" }) : null, !conversation.isLoading && conversation.messages.length === 0 ? (_jsx(View, { style: styles.banner, children: _jsx(Text, { style: styles.bannerText, children: "Open support inside your app without rebuilding visitor chat from scratch." }) })) : null, conversation.messages.map((message) => (_jsx(View, { style: styles.row, children: message.sender === "team" ? (_jsxs(_Fragment, { children: [_jsx(Bubble, { message: message.content || "(attachment placeholder)", isUser: false }), _jsx(View, { style: styles.spacer })] })) : (_jsxs(_Fragment, { children: [_jsx(View, { style: styles.spacer }), _jsx(Bubble, { message: message.content || "(attachment placeholder)", isUser: true })] })) }, message.id))), conversation.teamTyping ? (_jsxs(View, { style: styles.row, children: [_jsx(Bubble, { message: "Team is typing...", isUser: false, subtle: true }), _jsx(View, { style: styles.spacer })] })) : null, conversation.faqSuggestions?.items.map((item) => (_jsxs(View, { style: styles.faqCard, children: [_jsx(Text, { style: styles.faqTitle, children: item.question }), _jsx(Text, { style: styles.faqText, children: item.answer })] }, item.id))), conversation.faqSuggestions?.fallbackMessage ? (_jsx(Text, { style: styles.meta, children: conversation.faqSuggestions.fallbackMessage })) : null] }), _jsxs(View, { style: styles.footer, children: [_jsx(TextInput, { value: conversation.emailAddress, onChangeText: conversation.setEmailAddress, placeholder: "Email for follow-up", autoCapitalize: "none", autoCorrect: false, style: styles.input }), _jsx(TextInput, { value: conversation.draftMessage, onChangeText: conversation.setDraftMessage, placeholder: "Type a message", multiline: true, style: [styles.input, styles.composer] }), _jsxs(View, { style: styles.actions, children: [_jsx(TouchableOpacity, { style: [styles.button, styles.secondaryButton], onPress: () => void conversation.saveEmail(), children: _jsx(Text, { style: styles.secondaryButtonText, children: conversation.isSavingEmail ? "Saving..." : "Save email" }) }), _jsx(TouchableOpacity, { style: styles.button, onPress: () => void conversation.sendMessage(), children: _jsx(Text, { style: styles.buttonText, children: conversation.isSending ? "Sending..." : "Send" }) })] })] })] }));
14
+ }
15
+ function Bubble(input) {
16
+ return (_jsx(View, { style: [
17
+ styles.bubble,
18
+ input.isUser ? styles.userBubble : styles.teamBubble,
19
+ input.subtle ? styles.typingBubble : null
20
+ ], children: _jsx(Text, { style: input.isUser ? styles.userText : styles.teamText, children: input.message }) }));
21
+ }
@@ -0,0 +1,20 @@
1
+ import type { MutableRefObject } from "react";
2
+ import { ChattingClient } from "./chatting-client";
3
+ import type { ChattingConversationState, ChattingFAQSuggestions, ChattingMessage } from "./chatting-types";
4
+ export declare function applyConversation(conversation: ChattingConversationState, setMessages: (messages: ChattingMessage[]) => void, setFaqSuggestions: (suggestions: ChattingFAQSuggestions | null) => void): void;
5
+ export declare function startConversationSync(input: {
6
+ client: ChattingClient;
7
+ pollIntervalMs?: number;
8
+ stopSyncRef: MutableRefObject<null | (() => void)>;
9
+ activeConversationIdRef: MutableRefObject<string | null>;
10
+ setMessages(messages: ChattingMessage[]): void;
11
+ setFaqSuggestions(suggestions: ChattingFAQSuggestions | null): void;
12
+ setTeamTyping(value: boolean): void;
13
+ setErrorMessage(message: string | null): void;
14
+ refreshConversation(): Promise<ChattingConversationState>;
15
+ }): Promise<void>;
16
+ export declare function syncTyping(input: {
17
+ client: ChattingClient;
18
+ value: string;
19
+ typingTimeoutRef: MutableRefObject<ReturnType<typeof setTimeout> | null>;
20
+ }): Promise<void>;
@@ -0,0 +1,83 @@
1
+ import { ChattingLiveStreamUnsupportedError } from "./chatting-live-stream";
2
+ import { startConversationPolling } from "./chatting-poller";
3
+ import { normalizeText, toErrorMessage } from "./chatting-utils";
4
+ export function applyConversation(conversation, setMessages, setFaqSuggestions) {
5
+ setMessages(conversation.messages);
6
+ setFaqSuggestions(conversation.faqSuggestions ?? null);
7
+ }
8
+ export async function startConversationSync(input) {
9
+ const session = await input.client.currentSessionState();
10
+ if (!session.conversationId) {
11
+ return;
12
+ }
13
+ if (input.activeConversationIdRef.current === session.conversationId && input.stopSyncRef.current) {
14
+ return;
15
+ }
16
+ input.stopSyncRef.current?.();
17
+ input.activeConversationIdRef.current = session.conversationId;
18
+ input.setTeamTyping(false);
19
+ const fallbackToPolling = () => {
20
+ input.stopSyncRef.current?.();
21
+ input.stopSyncRef.current = startConversationPolling({
22
+ client: input.client,
23
+ intervalMs: input.pollIntervalMs,
24
+ onConversation(conversation) {
25
+ applyConversation(conversation, input.setMessages, input.setFaqSuggestions);
26
+ input.setErrorMessage(null);
27
+ },
28
+ onError(error) {
29
+ input.setErrorMessage(toErrorMessage(error));
30
+ }
31
+ });
32
+ };
33
+ try {
34
+ input.stopSyncRef.current = await input.client.subscribeLiveEvents({
35
+ onEvent(event) {
36
+ void handleLiveEvent(event, input).catch((error) => {
37
+ input.setErrorMessage(toErrorMessage(error));
38
+ });
39
+ },
40
+ onDisconnect() {
41
+ fallbackToPolling();
42
+ }
43
+ });
44
+ }
45
+ catch (error) {
46
+ if (!(error instanceof ChattingLiveStreamUnsupportedError)) {
47
+ input.setErrorMessage(toErrorMessage(error));
48
+ }
49
+ fallbackToPolling();
50
+ }
51
+ }
52
+ async function handleLiveEvent(event, input) {
53
+ if (event.type === "typing.updated" && event.actor === "team") {
54
+ input.setTeamTyping(Boolean(event.typing));
55
+ input.setErrorMessage(null);
56
+ return;
57
+ }
58
+ if (event.type !== "message.created" && event.type !== "conversation.updated") {
59
+ return;
60
+ }
61
+ input.setTeamTyping(false);
62
+ const conversation = await input.refreshConversation();
63
+ applyConversation(conversation, input.setMessages, input.setFaqSuggestions);
64
+ input.setErrorMessage(null);
65
+ }
66
+ export async function syncTyping(input) {
67
+ if (input.typingTimeoutRef.current) {
68
+ clearTimeout(input.typingTimeoutRef.current);
69
+ }
70
+ const isTyping = Boolean(normalizeText(input.value));
71
+ try {
72
+ await input.client.updateTyping(isTyping);
73
+ }
74
+ catch {
75
+ return;
76
+ }
77
+ if (!isTyping) {
78
+ return;
79
+ }
80
+ input.typingTimeoutRef.current = setTimeout(() => {
81
+ void input.client.updateTyping(false).catch(() => undefined);
82
+ }, 1500);
83
+ }
@@ -0,0 +1,10 @@
1
+ import type { ChattingLiveEvent } from "./chatting-types";
2
+ export declare class ChattingLiveStreamUnsupportedError extends Error {
3
+ constructor();
4
+ }
5
+ export declare function connectChattingLiveStream(input: {
6
+ url: string;
7
+ fetchImpl?: typeof fetch;
8
+ onEvent(event: ChattingLiveEvent): void;
9
+ onDisconnect?(error?: unknown): void;
10
+ }): Promise<() => void>;
@@ -0,0 +1,136 @@
1
+ export class ChattingLiveStreamUnsupportedError extends Error {
2
+ constructor() {
3
+ super("This environment cannot open Chatting live events.");
4
+ this.name = "ChattingLiveStreamUnsupportedError";
5
+ }
6
+ }
7
+ export function connectChattingLiveStream(input) {
8
+ if (typeof globalThis.XMLHttpRequest === "function") {
9
+ return connectViaXHR(input);
10
+ }
11
+ const fetchImpl = input.fetchImpl ?? globalThis.fetch;
12
+ if (!fetchImpl) {
13
+ throw new ChattingLiveStreamUnsupportedError();
14
+ }
15
+ return connectViaFetch({ ...input, fetchImpl });
16
+ }
17
+ function connectViaXHR(input) {
18
+ return new Promise((resolve, reject) => {
19
+ const xhr = new XMLHttpRequest();
20
+ let settled = false;
21
+ let stopped = false;
22
+ let index = 0;
23
+ let buffer = "";
24
+ const stop = () => {
25
+ stopped = true;
26
+ xhr.abort();
27
+ };
28
+ const settle = (handler, value) => {
29
+ if (!settled) {
30
+ settled = true;
31
+ handler(value);
32
+ }
33
+ };
34
+ const consume = () => {
35
+ buffer = emitFrames(xhr.responseText.slice(index), buffer, input.onEvent);
36
+ index = xhr.responseText.length;
37
+ };
38
+ xhr.open("GET", input.url, true);
39
+ xhr.setRequestHeader("Accept", "text/event-stream");
40
+ xhr.onreadystatechange = () => {
41
+ if (xhr.readyState !== xhr.HEADERS_RECEIVED) {
42
+ return;
43
+ }
44
+ if (xhr.status >= 400) {
45
+ settle(reject, new Error(xhr.responseText || `Chatting live stream failed with ${xhr.status}.`));
46
+ stop();
47
+ return;
48
+ }
49
+ settle(resolve, stop);
50
+ };
51
+ xhr.onprogress = consume;
52
+ xhr.onerror = () => {
53
+ settle(reject, new Error("Chatting live stream failed."));
54
+ if (!stopped) {
55
+ input.onDisconnect?.(new Error("Chatting live stream failed."));
56
+ }
57
+ };
58
+ xhr.onload = () => {
59
+ consume();
60
+ if (!stopped) {
61
+ input.onDisconnect?.();
62
+ }
63
+ };
64
+ xhr.send();
65
+ });
66
+ }
67
+ async function connectViaFetch(input) {
68
+ const controller = new AbortController();
69
+ const response = await input.fetchImpl(input.url, {
70
+ method: "GET",
71
+ cache: "no-store",
72
+ headers: { Accept: "text/event-stream" },
73
+ signal: controller.signal
74
+ });
75
+ if (!response.ok) {
76
+ controller.abort();
77
+ throw new Error(await response.text() || `Chatting live stream failed with ${response.status}.`);
78
+ }
79
+ const reader = response.body?.getReader?.();
80
+ if (!reader) {
81
+ controller.abort();
82
+ throw new ChattingLiveStreamUnsupportedError();
83
+ }
84
+ let stopped = false;
85
+ let buffer = "";
86
+ void (async () => {
87
+ const decoder = new TextDecoder();
88
+ let disconnectError;
89
+ try {
90
+ while (true) {
91
+ const { done, value } = await reader.read();
92
+ if (done) {
93
+ break;
94
+ }
95
+ buffer = emitFrames(decoder.decode(value, { stream: true }), buffer, input.onEvent);
96
+ }
97
+ }
98
+ catch (error) {
99
+ disconnectError = error;
100
+ }
101
+ finally {
102
+ if (!stopped && !controller.signal.aborted) {
103
+ input.onDisconnect?.(disconnectError);
104
+ }
105
+ }
106
+ })();
107
+ return () => {
108
+ stopped = true;
109
+ controller.abort();
110
+ void reader.cancel().catch(() => undefined);
111
+ };
112
+ }
113
+ function emitFrames(chunk, existing, onEvent) {
114
+ const frames = `${existing}${chunk}`.split(/\r?\n\r?\n/);
115
+ const remainder = frames.pop() ?? "";
116
+ for (const frame of frames) {
117
+ const data = frame
118
+ .split(/\r?\n/)
119
+ .filter((line) => line.startsWith("data:"))
120
+ .map((line) => line.slice(5).trimStart())
121
+ .join("\n");
122
+ if (!data) {
123
+ continue;
124
+ }
125
+ try {
126
+ const payload = JSON.parse(data);
127
+ if (typeof payload.type === "string") {
128
+ onEvent(payload);
129
+ }
130
+ }
131
+ catch {
132
+ continue;
133
+ }
134
+ }
135
+ return remainder;
136
+ }
@@ -0,0 +1,11 @@
1
+ import type { ChattingConversationState } from "./chatting-types";
2
+ type PollingClient = {
3
+ fetchConversation(): Promise<ChattingConversationState>;
4
+ };
5
+ export declare function startConversationPolling(input: {
6
+ client: PollingClient;
7
+ intervalMs?: number;
8
+ onConversation(conversation: ChattingConversationState): void;
9
+ onError?(error: unknown): void;
10
+ }): () => void;
11
+ export {};
@@ -0,0 +1,41 @@
1
+ export function startConversationPolling(input) {
2
+ let lastKey = "";
3
+ let timeout = null;
4
+ let stopped = false;
5
+ const tick = async () => {
6
+ try {
7
+ const conversation = await input.client.fetchConversation();
8
+ const nextKey = snapshotKey(conversation);
9
+ if (nextKey !== lastKey) {
10
+ lastKey = nextKey;
11
+ input.onConversation(conversation);
12
+ }
13
+ }
14
+ catch (error) {
15
+ input.onError?.(error);
16
+ }
17
+ finally {
18
+ if (!stopped) {
19
+ timeout = setTimeout(() => void tick(), input.intervalMs ?? 3000);
20
+ }
21
+ }
22
+ };
23
+ void tick();
24
+ return () => {
25
+ stopped = true;
26
+ if (timeout) {
27
+ clearTimeout(timeout);
28
+ }
29
+ };
30
+ }
31
+ function snapshotKey(conversation) {
32
+ const lastMessage = conversation.messages.at(-1);
33
+ return JSON.stringify({
34
+ conversationId: conversation.conversationId,
35
+ count: conversation.messages.length,
36
+ lastMessageId: lastMessage?.id ?? null,
37
+ lastMessageAt: lastMessage?.createdAt ?? null,
38
+ faqCount: conversation.faqSuggestions?.items.length ?? 0,
39
+ fallback: conversation.faqSuggestions?.fallbackMessage ?? null
40
+ });
41
+ }