@kavchat/react-native 0.0.2 → 0.0.3

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.
@@ -0,0 +1,196 @@
1
+ /**
2
+ * לקוח SSE קטן, בלי EventSource (ב-React Native אין כזה) ובלי חיבור מחדש אוטומטי:
3
+ * הכרטיס חד-פעמי, ולכן ה-session מבקש כרטיס חדש ומתחבר מחדש בעצמו (docs/live-chat-protocol.md 3.4).
4
+ * התעבורה: XMLHttpRequest עם קבלה הדרגתית (נתמך ב-React Native וב-Hermes), או fetch עם body.getReader
5
+ * כשאין XMLHttpRequest. אם אין אף אחד, ה-session עובר ל-long-poll
6
+ */
7
+
8
+ export interface SseMessage {
9
+ event: string;
10
+ data: string;
11
+ id?: string;
12
+ }
13
+
14
+ /** מפרק את הזרם לפי המפרט: שורות field: value, הודעה מסתיימת בשורה ריקה, שורה שמתחילה ב-: היא הערה */
15
+ export class SseParser {
16
+ private buffer = '';
17
+ private event = '';
18
+ private data: string[] = [];
19
+ private id: string | undefined;
20
+
21
+ constructor(private onMessage: (m: SseMessage) => void, private onComment?: () => void) {}
22
+
23
+ feed(chunk: string) {
24
+ this.buffer += chunk;
25
+ for (;;) {
26
+ const m = /\r\n|\r|\n/.exec(this.buffer);
27
+ if (!m) break;
28
+ // \r בסוף החתיכה עשוי להיות חצי של \r\n: מחכים לחתיכה הבאה
29
+ if (m[0] === '\r' && m.index === this.buffer.length - 1) break;
30
+ const line = this.buffer.slice(0, m.index);
31
+ this.buffer = this.buffer.slice(m.index + m[0].length);
32
+ this.line(line);
33
+ }
34
+ }
35
+
36
+ private line(line: string) {
37
+ if (line === '') {
38
+ if (this.data.length) this.onMessage({ event: this.event || 'message', data: this.data.join('\n'), ...(this.id !== undefined ? { id: this.id } : {}) });
39
+ this.event = '';
40
+ this.data = [];
41
+ this.id = undefined;
42
+ return;
43
+ }
44
+ if (line.startsWith(':')) {
45
+ this.onComment?.();
46
+ return;
47
+ }
48
+ const i = line.indexOf(':');
49
+ const field = i < 0 ? line : line.slice(0, i);
50
+ let value = i < 0 ? '' : line.slice(i + 1);
51
+ if (value.startsWith(' ')) value = value.slice(1);
52
+ if (field === 'event') this.event = value;
53
+ else if (field === 'data') this.data.push(value);
54
+ else if (field === 'id') this.id = value;
55
+ // retry ושדות אחרים: מתעלמים. החיבור מחדש בשליטה שלנו
56
+ }
57
+ }
58
+
59
+ export interface StreamHandlers {
60
+ /** כל חתיכת טקסט שהגיעה, כולל הערות keep-alive */
61
+ onChunk(text: string): void;
62
+ /** הזרם נכשל לפני שנפתח (status != 200), או נקטע. status 0 = רשת */
63
+ onError(status: number, body: string): void;
64
+ /** השרת סגר את הזרם בצורה מסודרת */
65
+ onEnd(): void;
66
+ }
67
+
68
+ export interface StreamHandle {
69
+ close(): void;
70
+ }
71
+
72
+ /** פותח GET ארוך ומעביר טקסט כפי שהוא מגיע. null כשאין תעבורה מתאימה, ואז עוברים ל-long-poll */
73
+ export type StreamOpener = (url: string, headers: Record<string, string>, handlers: StreamHandlers) => StreamHandle | null;
74
+
75
+ type XhrLike = {
76
+ readyState: number;
77
+ status: number;
78
+ responseText: string;
79
+ open(method: string, url: string, async: boolean): void;
80
+ setRequestHeader(name: string, value: string): void;
81
+ send(body?: unknown): void;
82
+ abort(): void;
83
+ onreadystatechange: (() => void) | null;
84
+ onprogress: (() => void) | null;
85
+ onerror: (() => void) | null;
86
+ ontimeout?: (() => void) | null;
87
+ };
88
+
89
+ /** XMLHttpRequest עם responseText שגדל (readyState 3). מספיק להגדיר onprogress כדי ש-React Native ישלח עדכונים הדרגתיים */
90
+ export const xhrStream: StreamOpener = (url, headers, h) => {
91
+ const Xhr = (globalThis as { XMLHttpRequest?: new () => XhrLike }).XMLHttpRequest;
92
+ if (!Xhr) return null;
93
+ const xhr = new Xhr();
94
+ let seen = 0;
95
+ let done = false;
96
+ const pump = () => {
97
+ if (done || xhr.status !== 200) return;
98
+ const text = xhr.responseText || '';
99
+ if (text.length > seen) {
100
+ const chunk = text.slice(seen);
101
+ seen = text.length;
102
+ h.onChunk(chunk);
103
+ }
104
+ };
105
+ xhr.onprogress = pump;
106
+ xhr.onreadystatechange = () => {
107
+ if (done) return;
108
+ if (xhr.readyState >= 3) pump();
109
+ if (xhr.readyState === 4) {
110
+ done = true;
111
+ if (xhr.status === 200) h.onEnd();
112
+ else h.onError(xhr.status, xhr.responseText || '');
113
+ }
114
+ };
115
+ xhr.onerror = () => {
116
+ if (done) return;
117
+ done = true;
118
+ h.onError(0, '');
119
+ };
120
+ xhr.ontimeout = xhr.onerror;
121
+ try {
122
+ xhr.open('GET', url, true);
123
+ for (const [k, v] of Object.entries(headers)) xhr.setRequestHeader(k, v);
124
+ xhr.setRequestHeader('Accept', 'text/event-stream');
125
+ xhr.send();
126
+ } catch {
127
+ done = true;
128
+ return null;
129
+ }
130
+ return {
131
+ close() {
132
+ if (done) return;
133
+ done = true;
134
+ try {
135
+ xhr.abort();
136
+ } catch {
137
+ // כבר נסגר
138
+ }
139
+ },
140
+ };
141
+ };
142
+
143
+ type StreamingResponse = {
144
+ status: number;
145
+ text(): Promise<string>;
146
+ body?: { getReader?: () => { read(): Promise<{ done: boolean; value?: Uint8Array }>; cancel(): Promise<void> } } | null;
147
+ };
148
+
149
+ /** fetch עם ReadableStream (Expo web, סביבות חדשות). בלי getReader אין זרימה, ומחזירים שגיאה כדי לעבור ל-long-poll */
150
+ export function fetchStream(fetchImpl?: (url: string, init: Record<string, unknown>) => Promise<StreamingResponse>): StreamOpener {
151
+ return (url, headers, h) => {
152
+ const f = fetchImpl ?? (globalThis.fetch as unknown as ((url: string, init: Record<string, unknown>) => Promise<StreamingResponse>) | undefined);
153
+ const Ctrl = (globalThis as { AbortController?: new () => { abort(): void; signal: unknown } }).AbortController;
154
+ const Decoder = (globalThis as { TextDecoder?: new () => { decode(v?: Uint8Array, o?: { stream: boolean }): string } }).TextDecoder;
155
+ if (!f || !Decoder) return null;
156
+ const ctrl = Ctrl ? new Ctrl() : null;
157
+ let done = false;
158
+ const finish = (fn: () => void) => {
159
+ if (done) return;
160
+ done = true;
161
+ fn();
162
+ };
163
+ void (async () => {
164
+ try {
165
+ const res = await f(url, { method: 'GET', headers: { ...headers, Accept: 'text/event-stream' }, cache: 'no-store', credentials: 'omit', ...(ctrl ? { signal: ctrl.signal } : {}) });
166
+ if (res.status !== 200) {
167
+ const body = await res.text().catch(() => '');
168
+ return finish(() => h.onError(res.status, body));
169
+ }
170
+ const reader = res.body?.getReader?.();
171
+ if (!reader) return finish(() => h.onError(-1, ''));
172
+ const decoder = new Decoder();
173
+ for (;;) {
174
+ const { done: end, value } = await reader.read();
175
+ if (done) {
176
+ void reader.cancel().catch(() => undefined);
177
+ return;
178
+ }
179
+ if (end) return finish(() => h.onEnd());
180
+ if (value) h.onChunk(decoder.decode(value, { stream: true }));
181
+ }
182
+ } catch {
183
+ finish(() => h.onError(0, ''));
184
+ }
185
+ })();
186
+ return {
187
+ close() {
188
+ done = true;
189
+ ctrl?.abort();
190
+ },
191
+ };
192
+ };
193
+ }
194
+
195
+ /** ברירת המחדל: XMLHttpRequest אם קיים, אחרת fetch עם זרימה */
196
+ export const defaultStreamOpener: StreamOpener = (url, headers, h) => xhrStream(url, headers, h) ?? fetchStream()(url, headers, h);
@@ -0,0 +1,25 @@
1
+ /**
2
+ * הטקסטים עצמם ב-@kavchat/core (i18n, המפתחות live.*), כמו בווידג'ט לאתר. כאן רק עיצוב של זמן
3
+ */
4
+
5
+ const pad = (n: number) => String(n).padStart(2, '0');
6
+
7
+ /**
8
+ * מתי הנציגים חוזרים, בשעון המכשיר ובשפת הממשק: רק שעה אם זה היום, אחרת יום ושעה (כמו בווידג'ט).
9
+ * בלי Intl (Hermes ישן): HH:MM או DD/MM HH:MM
10
+ */
11
+ export function formatOpenAt(iso: string, now: number, locale?: string): string {
12
+ const at = new Date(iso);
13
+ if (Number.isNaN(at.getTime())) return '';
14
+ const today = new Date(now);
15
+ const sameDay = at.getFullYear() === today.getFullYear() && at.getMonth() === today.getMonth() && at.getDate() === today.getDate();
16
+ if (locale) {
17
+ try {
18
+ return new Intl.DateTimeFormat(locale, { ...(sameDay ? {} : { weekday: 'short' }), hour: '2-digit', minute: '2-digit' }).format(at);
19
+ } catch {
20
+ // ממשיכים לפורמט הקבוע
21
+ }
22
+ }
23
+ const time = `${pad(at.getHours())}:${pad(at.getMinutes())}`;
24
+ return sameDay ? time : `${pad(at.getDate())}/${pad(at.getMonth() + 1)} ${time}`;
25
+ }
@@ -14,12 +14,14 @@ import {
14
14
  View,
15
15
  } from 'react-native';
16
16
  import type { StyleProp, ViewStyle } from 'react-native';
17
- import { format } from '@kavchat/core';
17
+ import { format, liveUnavailableText } from '@kavchat/core';
18
18
  import type { BotChip, BotMessage } from '@kavchat/core';
19
19
  import { BRANDING_URL } from '../controller';
20
20
  import { safeUrl, splitLinks } from '../text';
21
21
  import type { KavchatTheme } from '../theme';
22
22
  import { useKavchatContext } from './KavchatProvider';
23
+ import { LiveChatView, LiveRequestForm } from './LiveChatView';
24
+ import { formatOpenAt } from '../live/texts';
23
25
 
24
26
  /**
25
27
  * מסך הצ'אט. כיוון הפריסה נקבע לפי I18nManager של האפליקציה המארחת, ולא כופים RTL.
@@ -86,7 +88,17 @@ export function KavchatChat({ onClose, hideHeader, style }: KavchatChatProps) {
86
88
  <Text style={styles.bannerText}>{t['widget.offline']}</Text>
87
89
  </View>
88
90
  ) : null}
89
- {snapshot.view === 'chat' ? <ChatView /> : snapshot.view === 'form' ? <ContactForm /> : <SentView />}
91
+ {snapshot.view === 'chat' ? (
92
+ <ChatView />
93
+ ) : snapshot.view === 'form' ? (
94
+ <ContactForm />
95
+ ) : snapshot.view === 'live' ? (
96
+ <LiveChatView />
97
+ ) : snapshot.view === 'liveForm' ? (
98
+ <LiveRequestForm />
99
+ ) : (
100
+ <SentView />
101
+ )}
90
102
  {s.removeBranding ? null : (
91
103
  // בתוכנית החינמית הסמל חובה
92
104
  <Pressable onPress={() => Linking.openURL(BRANDING_URL).catch(() => undefined)} accessibilityRole="link" style={styles.badge}>
@@ -103,6 +115,8 @@ function ChatView() {
103
115
  const [text, setText] = useState('');
104
116
  const scroll = useRef<ComponentRef<typeof ScrollView>>(null);
105
117
  const conv = snapshot.conversation;
118
+ // כולל "שיחה עם נציג" לפני "פנייה לצוות" כשיש נציג זמין
119
+ const chips = conv ? controller.chips() : [];
106
120
 
107
121
  const typing = snapshot.typing;
108
122
  const send = () => {
@@ -129,9 +143,9 @@ function ChatView() {
129
143
  <ActivityIndicator size="small" color={theme.accent} />
130
144
  </View>
131
145
  ) : null}
132
- {conv && conv.chips.length > 0 ? (
146
+ {chips.length > 0 ? (
133
147
  <View style={styles.chips} accessibilityRole="menu">
134
- {conv.chips.map((chip) => (
148
+ {chips.map((chip) => (
135
149
  <ChipButton key={chip.id} label={chip.label} theme={theme} fixed={isFixed(chip)} onPress={() => controller.pressChip(chip)} />
136
150
  ))}
137
151
  </View>
@@ -216,11 +230,13 @@ function ContactForm() {
216
230
  const effect = snapshot.contact;
217
231
  const topics = snapshot.release?.settings.contactTopics ?? [];
218
232
  const initialTopic = effect?.contactTopicId && topics.some((x) => x.id === effect.contactTopicId) ? effect.contactTopicId : '';
219
- const [name, setName] = useState(snapshot.userName ?? '');
220
- const [email, setEmail] = useState('');
221
- const [phone, setPhone] = useState('');
233
+ // אחרי בקשה לנציג שלא נענתה: מה שכבר נכתב שם
234
+ const pre = snapshot.contactPrefill;
235
+ const [name, setName] = useState(pre?.name ?? snapshot.userName ?? '');
236
+ const [email, setEmail] = useState(pre?.email ?? '');
237
+ const [phone, setPhone] = useState(pre?.phone ?? '');
222
238
  const [topicId, setTopicId] = useState(initialTopic);
223
- const [message, setMessage] = useState(effect?.prefill ?? '');
239
+ const [message, setMessage] = useState(pre?.message ?? effect?.prefill ?? '');
224
240
  const [error, setError] = useState('');
225
241
  const [sending, setSending] = useState(false);
226
242
 
@@ -233,12 +249,24 @@ function ContactForm() {
233
249
  if (problem) setError(problem);
234
250
  };
235
251
 
252
+ // כשהצ'אט עם נציג דלוק ואין מי שיענה: שורת הסבר מעל הטופס (closed, no_agents). quota ו-disabled בלי הסבר
253
+ const offer = snapshot.liveEnabled ? snapshot.liveOffer : null;
254
+ const note =
255
+ offer && !offer.open && !offer.checking
256
+ ? liveUnavailableText(offer.reason, t, offer.reason === 'closed' && offer.nextOpenAt ? formatOpenAt(offer.nextOpenAt, Date.now(), snapshot.locale) || null : null)
257
+ : '';
258
+
236
259
  const inputStyle = [styles.field, { color: theme.text }];
237
260
  return (
238
261
  <ScrollView style={styles.flex} contentContainerStyle={styles.form} keyboardShouldPersistTaps="handled">
239
262
  <Text style={[styles.formTitle, { color: theme.text }]} accessibilityRole="header">
240
263
  {t['form.title']}
241
264
  </Text>
265
+ {note ? (
266
+ <Text style={[styles.reason, { color: theme.muted }]} accessibilityLiveRegion="polite">
267
+ {note}
268
+ </Text>
269
+ ) : null}
242
270
  <Label text={t['form.name']} theme={theme} />
243
271
  <TextInput value={name} onChangeText={setName} maxLength={100} autoComplete="name" textContentType="name" style={inputStyle} accessibilityLabel={t['form.name']} />
244
272
  <Label text={t['form.email']} theme={theme} />
@@ -373,6 +401,7 @@ const styles = StyleSheet.create({
373
401
  field: { borderWidth: 1, borderColor: '#d1d5db', borderRadius: 8, paddingHorizontal: 10, paddingVertical: Platform.OS === 'ios' ? 10 : 6, fontSize: 15, backgroundColor: '#ffffff' },
374
402
  message: { minHeight: 110, textAlignVertical: 'top' },
375
403
  error: { fontSize: 14, marginTop: 6 },
404
+ reason: { fontSize: 14, marginTop: 8 },
376
405
  actions: { flexDirection: 'row', gap: 10, marginTop: 12 },
377
406
  btn: { borderRadius: 8, paddingHorizontal: 18, paddingVertical: 10, alignItems: 'center' },
378
407
  btnGhost: { borderWidth: 1, backgroundColor: 'transparent' },
@@ -28,6 +28,13 @@ export interface KavchatProviderProps {
28
28
  onEvent?: (event: KavchatEvent) => void;
29
29
  /** ברירת מחדל: AsyncStorage אם מותקן, אחרת זיכרון בלבד */
30
30
  storage?: StorageAdapter;
31
+ /**
32
+ * אחסון לקרדנשל של הצ'אט עם נציג. מומלץ אחסון מאובטח (expo-secure-store, react-native-keychain).
33
+ * ברירת מחדל: storage
34
+ */
35
+ secureStorage?: StorageAdapter;
36
+ /** שם המסך שנשלח עם הבקשה לנציג, למשל 'checkout' */
37
+ livePage?: string;
31
38
  /** עקיפה של הצבעים שמגיעים מההגדרות בלוח הניהול */
32
39
  theme?: Partial<KavchatTheme>;
33
40
  /** להחלפה בבדיקות */
@@ -48,7 +55,7 @@ export interface KavchatContextValue {
48
55
  const KavchatContext = createContext<KavchatContextValue | null>(null);
49
56
 
50
57
  export function KavchatProvider(props: KavchatProviderProps) {
51
- const { publicKey, apiBase, locale, userToken, userName, onScreen, screens, onEvent, storage, theme, fetch, children } = props;
58
+ const { publicKey, apiBase, locale, userToken, userName, onScreen, screens, onEvent, storage, secureStorage, livePage, theme, fetch, children } = props;
52
59
  const controllerRef = useRef<KavchatController | null>(null);
53
60
  const keyRef = useRef('');
54
61
  const key = `${publicKey}|${apiBase ?? ''}`;
@@ -62,6 +69,8 @@ export function KavchatProvider(props: KavchatProviderProps) {
62
69
  userToken: userToken || undefined,
63
70
  userName: userName || undefined,
64
71
  storage: storage ?? defaultStorage(),
72
+ secureStorage,
73
+ livePage,
65
74
  deviceLocale: deviceLocale(),
66
75
  screens,
67
76
  fetch,
@@ -107,10 +116,13 @@ export function KavchatProvider(props: KavchatProviderProps) {
107
116
  controller.setLocale(locale);
108
117
  }, [controller, locale]);
109
118
 
110
- // דיווח המסלול כשהאפליקציה עוברת לרקע, כי ייתכן שלא תחזור
119
+ // דיווח המסלול כשהאפליקציה עוברת לרקע, כי ייתכן שלא תחזור.
120
+ // הצ'אט עם הנציג: הזרם נסגר ברקע, ובחזרה מושלם לפי seq (אין התראות push בגרסה הזו)
111
121
  useEffect(() => {
112
122
  const sub = AppState.addEventListener('change', (s) => {
113
123
  if (s === 'background' || s === 'inactive') void controller.flush();
124
+ if (s === 'background') controller.setForeground(false);
125
+ if (s === 'active') controller.setForeground(true);
114
126
  });
115
127
  return () => sub.remove();
116
128
  }, [controller]);
@@ -161,6 +173,13 @@ export function useKavchat() {
161
173
  open();
162
174
  controller.openContactForm(prefill);
163
175
  },
176
+ /** "שיחה עם נציג" מכפתור באפליקציה: טופס הבקשה אם יש נציג זמין, אחרת טופס הפנייה עם הסבר */
177
+ openLive: () => {
178
+ open();
179
+ void controller.openLive();
180
+ },
181
+ /** שלב הצ'אט עם הנציג (queued, active...), או null */
182
+ livePhase: snapshot.live?.phase ?? null,
164
183
  controller,
165
184
  };
166
185
  }