@kavchat/react-native 0.0.0-stage → 0.0.1

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,512 @@
1
+ import {
2
+ aiQuestion,
3
+ answerWithAi,
4
+ configureEngine,
5
+ eventsPayload,
6
+ isRtl,
7
+ pendingQuestion,
8
+ pressChip,
9
+ sendText,
10
+ startConversation,
11
+ transcriptOf,
12
+ uiTexts,
13
+ } from '@kavchat/core';
14
+ import type { BotChip, BotEffect, BotResult, BotState, UiTexts } from '@kavchat/core';
15
+ import { AnyRelease, ApiConfig, EnabledRelease, FetchLike, TicketInput, fetchRelease, postAiAnswer, postEvents, postScreens, postTicket } from './api';
16
+ import { KavchatScreen, NormalizedScreen, SCREENS_MAX_AGE_MS, StoredScreens, normalizeScreens, screensFingerprint, screensKey } from './screens';
17
+ import {
18
+ CachedRelease,
19
+ StorageAdapter,
20
+ StoredSession,
21
+ memoryStorage,
22
+ randomId,
23
+ readJson,
24
+ releaseKey,
25
+ sessionKey,
26
+ userFingerprint,
27
+ writeJson,
28
+ } from './storage';
29
+ import { pickLocale, safeUrl, ticketError, validateTicket } from './text';
30
+
31
+ /**
32
+ * הבקר: טעינת הגרסה עם ETag ומטמון, שמירת השיחה, דיווח לשרת ופניות
33
+ * קוד TypeScript רגיל בלי React Native, כדי שאפשר יהיה לבדוק אותו ב-Node.
34
+ * לוגיקת ההתאמה ומכונת המצבים נמצאות ב-@kavchat/core ולא כאן
35
+ */
36
+
37
+ export const DEFAULT_API = 'https://api.kavchat.com';
38
+ export const BRANDING_URL = 'https://kavchat.com';
39
+
40
+ export type ContactEffect = Extract<BotEffect, { type: 'contact' }>;
41
+ export type KavchatStatus = 'loading' | 'ready' | 'unavailable';
42
+ export type KavchatView = 'chat' | 'form' | 'sent';
43
+
44
+ export type KavchatEvent =
45
+ | { type: 'ready'; version: number; offline: boolean }
46
+ | { type: 'unavailable'; reason?: string }
47
+ | { type: 'effect'; effect: BotEffect }
48
+ | { type: 'ticket'; id: string; number: number };
49
+
50
+ export interface KavchatSnapshot {
51
+ status: KavchatStatus;
52
+ release: EnabledRelease | null;
53
+ /** הוצגה גרסה שמורה כי אין רשת */
54
+ offline: boolean;
55
+ /** שפת הממשק (הטקסטים הקבועים), לא שפת התסריט */
56
+ locale: string;
57
+ rtl: boolean;
58
+ texts: UiTexts;
59
+ conversation: BotState | null;
60
+ /** מחכים לתשובת AI: מציגים סימן הקלדה ולא מקבלים קלט */
61
+ typing: boolean;
62
+ view: KavchatView;
63
+ contact: ContactEffect | null;
64
+ ticketNumber: number | null;
65
+ /** למה הבוט לא זמין, למשל quota */
66
+ reason?: string;
67
+ identified: boolean;
68
+ userName?: string;
69
+ }
70
+
71
+ export interface ControllerOptions {
72
+ publicKey: string;
73
+ apiBase?: string;
74
+ locale?: string;
75
+ userToken?: string;
76
+ userName?: string;
77
+ storage?: StorageAdapter;
78
+ fetch?: FetchLike;
79
+ /** שפת המכשיר, כשלא נקבעה locale */
80
+ deviceLocale?: string;
81
+ /** כפתור מסוג screen בתסריט. בלי callback הכפתור לא עושה דבר */
82
+ onScreen?: (screen: string, targetId?: string) => void;
83
+ /** פתיחת קישור https או tel. ב-React Native זה Linking.openURL */
84
+ onLink?: (url: string) => void;
85
+ onEvent?: (event: KavchatEvent) => void;
86
+ /** מסכי האפליקציה שכפתור screen יכול לפתוח. נרשמים בשרת כדי שהעורך יציע אותם ברשימה */
87
+ screens?: readonly KavchatScreen[];
88
+ reportDelayMs?: number;
89
+ sessionMaxAgeMs?: number;
90
+ now?: () => number;
91
+ }
92
+
93
+ export interface ContactFields {
94
+ name?: string;
95
+ email?: string;
96
+ phone?: string;
97
+ topicId?: string;
98
+ message: string;
99
+ }
100
+
101
+ export class KavchatController {
102
+ private opts: ControllerOptions;
103
+ private api: ApiConfig;
104
+ private store: StorageAdapter;
105
+ private snap: KavchatSnapshot;
106
+ private listeners = new Set<() => void>();
107
+ private session: StoredSession | null = null;
108
+ private loadSeq = 0;
109
+ private reportTimer: ReturnType<typeof setTimeout> | null = null;
110
+ private dirty = false;
111
+ private disposed = false;
112
+ private screens: NormalizedScreen[] = [];
113
+ /** הגרסה כבר נטענה פעם אחת מהשרת, ולכן אפשר לרשום מסכים */
114
+ private releaseFetched = false;
115
+ /** הטביעה שכבר נוסתה בהפעלה הזו, בהצלחה או בכישלון. אחרי כישלון מנסים רק בהפעלה הבאה */
116
+ private screensTried: string | null = null;
117
+ /** מתממש כשהטעינה האחרונה הסתיימה */
118
+ ready: Promise<void> = Promise.resolve();
119
+ /** מתממש כשניסיון רישום המסכים האחרון הסתיים. בשביל בדיקות */
120
+ screensSync: Promise<void> = Promise.resolve();
121
+
122
+ constructor(options: ControllerOptions) {
123
+ if (!options || !options.publicKey) throw new Error('Kavchat: publicKey is required');
124
+ this.opts = { ...options };
125
+ this.store = options.storage ?? memoryStorage();
126
+ this.api = {
127
+ apiBase: options.apiBase || DEFAULT_API,
128
+ publicKey: options.publicKey,
129
+ getToken: () => this.opts.userToken || undefined,
130
+ fetch: options.fetch,
131
+ };
132
+ this.screens = normalizeScreens(options.screens);
133
+ const locale = pickLocale(options.locale, options.deviceLocale, 'en');
134
+ this.snap = {
135
+ status: 'loading',
136
+ release: null,
137
+ offline: false,
138
+ locale,
139
+ rtl: isRtl(locale),
140
+ texts: uiTexts(locale),
141
+ conversation: null,
142
+ typing: false,
143
+ view: 'chat',
144
+ contact: null,
145
+ ticketNumber: null,
146
+ identified: !!options.userToken,
147
+ userName: options.userName,
148
+ };
149
+ }
150
+
151
+ // ---------- מנוי, בשביל useSyncExternalStore ----------
152
+
153
+ subscribe = (listener: () => void) => {
154
+ this.listeners.add(listener);
155
+ return () => {
156
+ this.listeners.delete(listener);
157
+ };
158
+ };
159
+
160
+ getSnapshot = (): KavchatSnapshot => this.snap;
161
+
162
+ private update(patch: Partial<KavchatSnapshot>) {
163
+ if (this.disposed) return;
164
+ this.snap = { ...this.snap, ...patch };
165
+ this.listeners.forEach((l) => {
166
+ try {
167
+ l();
168
+ } catch (e) {
169
+ console.error('Kavchat: listener failed', e);
170
+ }
171
+ });
172
+ }
173
+
174
+ /** callbacks שמשתנים בכל רינדור (onScreen וכו') מתעדכנים בלי לבנות בקר חדש */
175
+ setCallbacks(cb: Pick<ControllerOptions, 'onScreen' | 'onLink' | 'onEvent'>) {
176
+ this.opts.onScreen = cb.onScreen;
177
+ this.opts.onLink = cb.onLink;
178
+ this.opts.onEvent = cb.onEvent;
179
+ }
180
+
181
+ // ---------- טעינה ----------
182
+
183
+ load(): Promise<void> {
184
+ const seq = ++this.loadSeq;
185
+ this.ready = this.doLoad(seq);
186
+ return this.ready;
187
+ }
188
+
189
+ private async doLoad(seq: number) {
190
+ const pk = this.opts.publicKey;
191
+ const cached = await readJson<CachedRelease>(this.store, releaseKey(pk));
192
+ const result = await fetchRelease(this.api, cached?.etag || undefined);
193
+ // טעינה חדשה יותר (למשל אחרי setUser) כבר בדרך: מתעלמים מהתוצאה הישנה
194
+ if (seq !== this.loadSeq || this.disposed) return;
195
+ if (result.kind === 'ok' || result.kind === 'not-modified') {
196
+ this.releaseFetched = true;
197
+ this.syncScreens();
198
+ }
199
+ let release: AnyRelease | null = null;
200
+ let offline = false;
201
+ if (result.kind === 'ok') {
202
+ await writeJson(this.store, releaseKey(pk), { etag: result.etag, release: result.release });
203
+ release = result.release;
204
+ } else if (result.kind === 'not-modified') {
205
+ release = cached?.release ?? null;
206
+ } else if (result.kind === 'offline') {
207
+ release = cached?.release ?? null;
208
+ offline = !!release;
209
+ }
210
+ await this.apply(release, offline);
211
+ }
212
+
213
+ private async apply(release: AnyRelease | null, offline: boolean) {
214
+ if (!release || !release.enabled) {
215
+ // הבוט כבוי, מוגבל למשתמשי בדיקה או שנגמרה המכסה: לא מציגים כלום
216
+ this.session = null;
217
+ const reason = release && 'reason' in release ? release.reason : undefined;
218
+ this.update({ status: 'unavailable', release: null, conversation: null, offline: false, view: 'chat', contact: null, reason });
219
+ this.emit({ type: 'unavailable', reason });
220
+ return;
221
+ }
222
+ const prev = this.snap.release;
223
+ const user = userFingerprint(this.opts.userToken);
224
+ const changed = !prev || prev.version !== release.version || (this.session && this.session.user !== user);
225
+ const locale = pickLocale(this.opts.locale, this.opts.deviceLocale, release.locale);
226
+ let conversation = this.snap.conversation;
227
+ let view = this.snap.view;
228
+ if (changed || !conversation) {
229
+ conversation = await this.restoreSession(release, user);
230
+ view = 'chat';
231
+ }
232
+ this.update({
233
+ status: 'ready',
234
+ release,
235
+ offline,
236
+ locale,
237
+ rtl: isRtl(locale),
238
+ texts: uiTexts(locale),
239
+ conversation,
240
+ view,
241
+ contact: view === 'chat' ? null : this.snap.contact,
242
+ reason: undefined,
243
+ identified: !!this.opts.userToken,
244
+ userName: this.opts.userName,
245
+ });
246
+ this.emit({ type: 'ready', version: release.version, offline });
247
+ }
248
+
249
+ private async restoreSession(release: EnabledRelease, user: string): Promise<BotState | null> {
250
+ const saved = await readJson<StoredSession>(this.store, sessionKey(this.opts.publicKey));
251
+ const maxAge = this.opts.sessionMaxAgeMs ?? 24 * 60 * 60 * 1000;
252
+ const fresh = !saved?.savedAt || this.now() - saved.savedAt <= maxAge;
253
+ if (saved && saved.state && saved.version === release.version && (saved.user ?? '') === user && fresh) {
254
+ const scripts: BotState['scripts'] = {};
255
+ for (const sc of release.scripts) scripts[sc.id] = sc;
256
+ this.session = saved;
257
+ return { ...saved.state, scripts } as BotState;
258
+ }
259
+ this.session = null;
260
+ return null;
261
+ }
262
+
263
+ /** החלפת משתמש (התחברות או התנתקות). הגרסה נטענת מחדש, כי הקהל testers תלוי בזהות */
264
+ setUser(user: { token?: string | null; name?: string | null } | null): Promise<void> {
265
+ const token = user?.token || undefined;
266
+ const name = user?.name || undefined;
267
+ if (token === this.opts.userToken && name === this.opts.userName) return this.ready;
268
+ this.flush();
269
+ this.opts.userToken = token;
270
+ this.opts.userName = name;
271
+ return this.load();
272
+ }
273
+
274
+ /** הצהרה או עדכון של המסכים אחרי היצירה. נשלח רק אם הרשימה השתנתה */
275
+ registerScreens(screens: readonly KavchatScreen[] | null | undefined) {
276
+ this.screens = normalizeScreens(screens);
277
+ if (this.releaseFetched) this.syncScreens();
278
+ }
279
+
280
+ /** לא מחכים לזה בטעינה: רישום המסכים לא אמור לעכב או להפיל את הבוט */
281
+ private syncScreens() {
282
+ const screens = this.screens;
283
+ if (!screens.length || this.disposed) return;
284
+ const fingerprint = screensFingerprint(screens);
285
+ if (this.screensTried === fingerprint) return;
286
+ this.screensTried = fingerprint;
287
+ const key = screensKey(this.opts.publicKey);
288
+ this.screensSync = (async () => {
289
+ try {
290
+ const stored = await readJson<StoredScreens>(this.store, key);
291
+ if (stored && stored.fingerprint === fingerprint && this.now() - stored.savedAt < SCREENS_MAX_AGE_MS) return;
292
+ const ok = await postScreens(this.api, { platform: 'react-native', screens });
293
+ const next: StoredScreens = { fingerprint, savedAt: this.now() };
294
+ // שומרים רק אחרי 200, כדי שכישלון ינוסה שוב בהפעלה הבאה
295
+ if (ok) await writeJson(this.store, key, next);
296
+ } catch {
297
+ // לעולם לא מפילים את האפליקציה בגלל הרישום
298
+ }
299
+ })();
300
+ }
301
+
302
+ setLocale(locale: string | undefined) {
303
+ this.opts.locale = locale;
304
+ const releaseLocale = this.snap.release?.locale ?? 'en';
305
+ const next = pickLocale(locale, this.opts.deviceLocale, releaseLocale);
306
+ if (next !== this.snap.locale) this.update({ locale: next, rtl: isRtl(next), texts: uiTexts(next) });
307
+ }
308
+
309
+ // ---------- שיחה ----------
310
+
311
+ /** מתחיל שיחה אם עוד אין. קוראים לזה כשהצ'אט נפתח */
312
+ ensureConversation() {
313
+ if (this.snap.status === 'ready' && !this.snap.conversation) this.startFresh();
314
+ }
315
+
316
+ startFresh() {
317
+ const release = this.snap.release;
318
+ if (!release) return;
319
+ this.flush();
320
+ this.session = {
321
+ id: randomId(),
322
+ startedAt: new Date(this.now()).toISOString(),
323
+ version: release.version,
324
+ user: userFingerprint(this.opts.userToken),
325
+ state: null,
326
+ };
327
+ const state = this.run(() => ({
328
+ state: startConversation({
329
+ scripts: release.scripts,
330
+ openingScriptId: release.openingScriptId,
331
+ locale: release.locale,
332
+ userName: this.opts.userName,
333
+ }),
334
+ })).state;
335
+ this.setConversation(state, { view: 'chat', contact: null, ticketNumber: null });
336
+ }
337
+
338
+ pressChip(chip: BotChip) {
339
+ const state = this.snap.conversation;
340
+ if (!state || this.snap.typing) return;
341
+ this.afterAction(this.run(() => pressChip(state, chip)));
342
+ }
343
+
344
+ /** מתממש כשהתשובה הוצגה. עם תשובות AI זה אחרי הקריאה לשרת */
345
+ sendText(text: string): Promise<void> {
346
+ const state = this.snap.conversation;
347
+ if (!state || !String(text ?? '').trim() || this.snap.typing) return Promise.resolve();
348
+ // תשובות AI רק כשהן דלוקות בגרסה ואין תסריט שעונה על השאלה
349
+ const question = this.snap.release?.settings.aiAnswers === true && this.session ? aiQuestion(state, text) : null;
350
+ if (!question) {
351
+ this.afterAction(this.run(() => sendText(state, text)));
352
+ return Promise.resolve();
353
+ }
354
+ return this.askAi(state, text, question);
355
+ }
356
+
357
+ private async askAi(before: BotState, text: string, question: string) {
358
+ const session = this.session!;
359
+ const pending = this.run(() => ({ state: pendingQuestion(before, text) })).state;
360
+ this.update({ conversation: pending, typing: true });
361
+ let ai: Awaited<ReturnType<typeof postAiAnswer>> = null;
362
+ try {
363
+ ai = await postAiAnswer(this.api, session.id, question);
364
+ } catch {
365
+ ai = null;
366
+ }
367
+ if (this.disposed) return;
368
+ // השיחה התחלפה בזמן ההמתנה (שיחה חדשה, משתמש אחר, גרסה חדשה): לא נוגעים בה
369
+ if (this.session !== session || this.snap.conversation !== pending) {
370
+ this.update({ typing: false });
371
+ return;
372
+ }
373
+ // תשובה ריקה או כישלון: answerWithAi מחזיר בדיוק את "לא נמצא" הרגיל
374
+ this.update({ typing: false });
375
+ this.afterAction(this.run(() => answerWithAi(before, text, ai?.answer ?? null, ai?.scriptId ?? null)));
376
+ }
377
+
378
+ /**
379
+ * התסריט נבדק בשפת הבוט, והטקסטים הקבועים של הצ'אט מוצגים בשפת הממשק.
380
+ * ההגדרה של המנוע גלובלית, ולכן קובעים אותה לפני כל קריאה
381
+ */
382
+ private run(fn: () => BotResult): BotResult {
383
+ configureEngine({ texts: this.snap.texts });
384
+ return fn();
385
+ }
386
+
387
+ private setConversation(state: BotState, extra: Partial<KavchatSnapshot> = {}) {
388
+ if (this.session) {
389
+ const { scripts: _omit, ...rest } = state;
390
+ this.session.state = rest;
391
+ this.session.savedAt = this.now();
392
+ void writeJson(this.store, sessionKey(this.opts.publicKey), this.session);
393
+ }
394
+ this.update({ conversation: state, ...extra });
395
+ }
396
+
397
+ private afterAction(r: BotResult) {
398
+ this.setConversation(r.state);
399
+ this.scheduleReport();
400
+ if (r.effect) this.handleEffect(r.effect);
401
+ }
402
+
403
+ private handleEffect(effect: BotEffect) {
404
+ this.emit({ type: 'effect', effect });
405
+ if (effect.type === 'contact') {
406
+ this.update({ view: 'form', contact: effect, ticketNumber: null });
407
+ } else if (effect.type === 'link') {
408
+ // רק https ו-tel. כל פרוטוקול אחר מהתסריט נחסם
409
+ const href = safeUrl(effect.url, ['https', 'tel']);
410
+ if (href) this.safeCall(() => this.opts.onLink?.(href), 'onLink');
411
+ } else if (effect.type === 'screen') {
412
+ this.safeCall(() => this.opts.onScreen?.(effect.screen, effect.targetId), 'onScreen');
413
+ }
414
+ }
415
+
416
+ /** פתיחת הטופס מכפתור מחוץ לתסריט */
417
+ openContactForm(prefill = '') {
418
+ const state = this.snap.conversation;
419
+ this.update({
420
+ view: 'form',
421
+ ticketNumber: null,
422
+ contact: {
423
+ type: 'contact',
424
+ contactTopicId: null,
425
+ scriptId: state?.lastScriptId ?? null,
426
+ prefill,
427
+ transcript: state ? transcriptOf(state) : [],
428
+ },
429
+ });
430
+ }
431
+
432
+ backToChat() {
433
+ this.update({ view: 'chat', contact: null, ticketNumber: null });
434
+ }
435
+
436
+ /** שולח פנייה. מחזיר הודעת שגיאה בשפת הממשק, או null אם נשלחה */
437
+ async submitTicket(fields: ContactFields): Promise<string | null> {
438
+ const t = this.snap.texts;
439
+ const effect = this.snap.contact;
440
+ const trim = (v?: string) => (v ?? '').trim() || undefined;
441
+ const input: TicketInput = {
442
+ message: (fields.message ?? '').trim(),
443
+ name: trim(fields.name),
444
+ email: trim(fields.email),
445
+ phone: trim(fields.phone),
446
+ topicId: trim(fields.topicId),
447
+ scriptId: effect?.scriptId || undefined,
448
+ sessionId: this.session?.id,
449
+ transcript: (effect?.transcript ?? []).slice(-200).map((x) => ({ ...x, text: x.text.slice(0, 1000) })),
450
+ };
451
+ const problem = validateTicket(input, !!this.opts.userToken, t);
452
+ if (problem) return problem;
453
+ const res = await postTicket(this.api, input);
454
+ if (this.disposed) return null;
455
+ if (!res.ok) return ticketError(res.code, res.message, t);
456
+ this.flush();
457
+ this.emit({ type: 'ticket', id: res.id, number: res.number });
458
+ this.update({ view: 'sent', ticketNumber: res.number });
459
+ return null;
460
+ }
461
+
462
+ // ---------- דיווח ----------
463
+
464
+ private scheduleReport() {
465
+ this.dirty = true;
466
+ if (this.reportTimer) clearTimeout(this.reportTimer);
467
+ this.reportTimer = setTimeout(() => this.flush(), this.opts.reportDelayMs ?? 2000);
468
+ }
469
+
470
+ /** שולח את המסלול עכשיו. קוראים לזה גם כשהאפליקציה עוברת לרקע */
471
+ flush(): Promise<boolean> {
472
+ if (this.reportTimer) {
473
+ clearTimeout(this.reportTimer);
474
+ this.reportTimer = null;
475
+ }
476
+ const state = this.snap.conversation;
477
+ const release = this.snap.release;
478
+ const session = this.session;
479
+ if (!this.dirty || !state || !release || !session || this.disposed) return Promise.resolve(false);
480
+ this.dirty = false;
481
+ const payload = eventsPayload(state, session.id, release.version, session.startedAt);
482
+ return postEvents(this.api, payload).then((ok) => {
483
+ // כישלון: ינסה שוב בפעולה הבאה
484
+ if (!ok) this.dirty = true;
485
+ return ok;
486
+ });
487
+ }
488
+
489
+ dispose() {
490
+ if (this.disposed) return;
491
+ void this.flush();
492
+ this.disposed = true;
493
+ this.listeners.clear();
494
+ }
495
+
496
+ private now() {
497
+ return this.opts.now ? this.opts.now() : Date.now();
498
+ }
499
+
500
+ private emit(event: KavchatEvent) {
501
+ this.safeCall(() => this.opts.onEvent?.(event), 'onEvent');
502
+ }
503
+
504
+ private safeCall(fn: () => unknown, name: string) {
505
+ try {
506
+ const r = fn() as unknown;
507
+ if (r && typeof (r as Promise<unknown>).catch === 'function') (r as Promise<unknown>).catch((e) => console.error(`Kavchat: ${name} failed`, e));
508
+ } catch (e) {
509
+ console.error(`Kavchat: ${name} failed`, e);
510
+ }
511
+ }
512
+ }
package/src/index.ts ADDED
@@ -0,0 +1,13 @@
1
+ export { KavchatProvider, useKavchat } from './ui/KavchatProvider';
2
+ export type { KavchatProviderProps } from './ui/KavchatProvider';
3
+ export { KavchatChat } from './ui/KavchatChat';
4
+ export type { KavchatChatProps } from './ui/KavchatChat';
5
+ export { KavchatLauncher } from './ui/KavchatLauncher';
6
+ export type { KavchatLauncherProps } from './ui/KavchatLauncher';
7
+ export { KavchatController, DEFAULT_API, BRANDING_URL } from './controller';
8
+ export type { ControllerOptions, ContactFields, KavchatEvent, KavchatSnapshot, KavchatStatus, KavchatView } from './controller';
9
+ export { memoryStorage, defaultStorage } from './storage';
10
+ export type { StorageAdapter } from './storage';
11
+ export { themeFor } from './theme';
12
+ export type { KavchatTheme } from './theme';
13
+ export type { KavchatScreen } from './screens';
package/src/screens.ts ADDED
@@ -0,0 +1,141 @@
1
+ /**
2
+ * רישום מסכי האפליקציה בשרת, כדי שהעורך בלוח הניהול יציע רשימה במקום שם חופשי עם שגיאות כתיב.
3
+ * הכללים זהים לשרת, ולכן מסך לא תקין נזרק כבר כאן ולא מכשיל את כל הבקשה
4
+ */
5
+
6
+ export interface KavchatScreen {
7
+ /** השם שמגיע ל-onScreen. אותיות לטיניות, ספרות, _ . - עד 40 תווים */
8
+ id: string;
9
+ /** השם שמנהל הבוט רואה בעורך, עד 60 תווים */
10
+ label: string;
11
+ /** true כשהמסך צריך targetId, למשל מזהה מוצר */
12
+ needsTarget?: boolean;
13
+ }
14
+
15
+ export type NormalizedScreen = { id: string; label: string; needsTarget: boolean };
16
+
17
+ export const MAX_SCREENS = 100;
18
+ /** גם בלי שינוי שולחים פעם בשבוע, כדי שהשרת ידע שהאפליקציה עדיין משתמשת במסכים */
19
+ export const SCREENS_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
20
+ const ID_RE = /^[A-Za-z0-9_.-]{1,40}$/;
21
+
22
+ export const screensKey = (pk: string) => `kavchat:screens:${pk}`;
23
+
24
+ export interface StoredScreens {
25
+ fingerprint: string;
26
+ savedAt: number;
27
+ }
28
+
29
+ declare const __DEV__: boolean | undefined;
30
+ const isDev = () => typeof __DEV__ !== 'undefined' && !!__DEV__;
31
+
32
+ /** מסנן לפי כללי השרת: מזהה ותווית תקינים, בלי כפולים, 100 הראשונים */
33
+ export function normalizeScreens(list: readonly KavchatScreen[] | null | undefined, log: (msg: string) => void = defaultLog): NormalizedScreen[] {
34
+ const out: NormalizedScreen[] = [];
35
+ const seen = new Set<string>();
36
+ for (const s of Array.isArray(list) ? list : []) {
37
+ const id = typeof s?.id === 'string' ? s.id : '';
38
+ const label = typeof s?.label === 'string' ? s.label.trim() : '';
39
+ if (!ID_RE.test(id)) {
40
+ log(`Kavchat: screen dropped, invalid id ${JSON.stringify(s?.id)}`);
41
+ continue;
42
+ }
43
+ if (label.length < 1 || label.length > 60) {
44
+ log(`Kavchat: screen "${id}" dropped, label must be 1-60 characters`);
45
+ continue;
46
+ }
47
+ if (seen.has(id)) {
48
+ log(`Kavchat: screen "${id}" dropped, duplicate id`);
49
+ continue;
50
+ }
51
+ if (out.length >= MAX_SCREENS) {
52
+ log(`Kavchat: screen "${id}" dropped, more than ${MAX_SCREENS} screens`);
53
+ continue;
54
+ }
55
+ seen.add(id);
56
+ out.push({ id, label, needsTarget: s.needsTarget === true });
57
+ }
58
+ return out;
59
+ }
60
+
61
+ function defaultLog(msg: string) {
62
+ // רק בפיתוח: באפליקציה שפורסמה אין טעם להציף את הלוג
63
+ if (isDev()) console.warn(msg);
64
+ }
65
+
66
+ /** טביעה יציבה: שורות id|label|needsTarget ממוינות לפי id, ואז SHA-256. סדר ההצהרה לא משנה */
67
+ export function screensFingerprint(list: readonly NormalizedScreen[]): string {
68
+ const lines = [...list]
69
+ .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
70
+ .map((s) => `${s.id}|${s.label}|${s.needsTarget}`)
71
+ .join('\n');
72
+ return sha256Hex(lines);
73
+ }
74
+
75
+ // ---------- SHA-256 ב-JS טהור: ב-Hermes אין crypto.subtle, ולא רוצים להוסיף תלות נייטיב ----------
76
+
77
+ const K = new Uint32Array([
78
+ 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be,
79
+ 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa,
80
+ 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85,
81
+ 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3,
82
+ 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f,
83
+ 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
84
+ ]);
85
+
86
+ function utf8(text: string): number[] {
87
+ const out: number[] = [];
88
+ for (const ch of text) {
89
+ let c = ch.codePointAt(0)!;
90
+ if (c < 0x80) out.push(c);
91
+ else if (c < 0x800) out.push(0xc0 | (c >> 6), 0x80 | (c & 63));
92
+ else if (c < 0x10000) out.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 63), 0x80 | (c & 63));
93
+ else out.push(0xf0 | (c >> 18), 0x80 | ((c >> 12) & 63), 0x80 | ((c >> 6) & 63), 0x80 | (c & 63));
94
+ }
95
+ return out;
96
+ }
97
+
98
+ export function sha256Hex(text: string): string {
99
+ const bytes = utf8(text);
100
+ const bitLen = bytes.length * 8;
101
+ bytes.push(0x80);
102
+ while (bytes.length % 64 !== 56) bytes.push(0);
103
+ // אורך ב-64 ביט. מחרוזות כאן קטנות מ-2^32 ביט, ולכן החצי העליון אפס
104
+ bytes.push(0, 0, 0, 0, (bitLen >>> 24) & 255, (bitLen >>> 16) & 255, (bitLen >>> 8) & 255, bitLen & 255);
105
+ const h = new Uint32Array([0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19]);
106
+ const w = new Uint32Array(64);
107
+ const rotr = (x: number, n: number) => (x >>> n) | (x << (32 - n));
108
+ for (let off = 0; off < bytes.length; off += 64) {
109
+ for (let i = 0; i < 16; i++) {
110
+ const j = off + i * 4;
111
+ w[i] = (bytes[j] << 24) | (bytes[j + 1] << 16) | (bytes[j + 2] << 8) | bytes[j + 3];
112
+ }
113
+ for (let i = 16; i < 64; i++) {
114
+ const s0 = rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ (w[i - 15] >>> 3);
115
+ const s1 = rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ (w[i - 2] >>> 10);
116
+ w[i] = w[i - 16] + s0 + w[i - 7] + s1;
117
+ }
118
+ let [a, b, c, d, e, f, g, hh] = h;
119
+ for (let i = 0; i < 64; i++) {
120
+ const t1 = (hh + (rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25)) + ((e & f) ^ (~e & g)) + K[i] + w[i]) >>> 0;
121
+ const t2 = ((rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22)) + ((a & b) ^ (a & c) ^ (b & c))) >>> 0;
122
+ hh = g;
123
+ g = f;
124
+ f = e;
125
+ e = (d + t1) >>> 0;
126
+ d = c;
127
+ c = b;
128
+ b = a;
129
+ a = (t1 + t2) >>> 0;
130
+ }
131
+ h[0] += a;
132
+ h[1] += b;
133
+ h[2] += c;
134
+ h[3] += d;
135
+ h[4] += e;
136
+ h[5] += f;
137
+ h[6] += g;
138
+ h[7] += hh;
139
+ }
140
+ return Array.from(h, (x) => x.toString(16).padStart(8, '0')).join('');
141
+ }