@experiaapp/webchat-react-native 2.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.
Files changed (101) hide show
  1. package/README.md +254 -0
  2. package/app.plugin.js +6 -0
  3. package/lib/adapters/audio.d.ts +74 -0
  4. package/lib/adapters/audio.js +39 -0
  5. package/lib/adapters/audioRoute.d.ts +57 -0
  6. package/lib/adapters/audioRoute.js +77 -0
  7. package/lib/adapters/expoDefaults.d.ts +77 -0
  8. package/lib/adapters/expoDefaults.js +539 -0
  9. package/lib/adapters/picker.d.ts +67 -0
  10. package/lib/adapters/picker.js +37 -0
  11. package/lib/adapters/webrtc.d.ts +131 -0
  12. package/lib/adapters/webrtc.js +70 -0
  13. package/lib/core/VideoCallClient.d.ts +106 -0
  14. package/lib/core/VideoCallClient.js +302 -0
  15. package/lib/core/WebchatClient.d.ts +34 -0
  16. package/lib/core/WebchatClient.js +132 -0
  17. package/lib/core/configClient.d.ts +42 -0
  18. package/lib/core/configClient.js +302 -0
  19. package/lib/core/greet.d.ts +11 -0
  20. package/lib/core/greet.js +17 -0
  21. package/lib/core/ice.d.ts +31 -0
  22. package/lib/core/ice.js +48 -0
  23. package/lib/core/linkify.d.ts +11 -0
  24. package/lib/core/linkify.js +25 -0
  25. package/lib/core/logger.d.ts +17 -0
  26. package/lib/core/logger.js +53 -0
  27. package/lib/core/media.d.ts +52 -0
  28. package/lib/core/media.js +115 -0
  29. package/lib/core/mediaType.d.ts +21 -0
  30. package/lib/core/mediaType.js +66 -0
  31. package/lib/core/messagesReducer.d.ts +36 -0
  32. package/lib/core/messagesReducer.js +58 -0
  33. package/lib/core/persistence.d.ts +45 -0
  34. package/lib/core/persistence.js +63 -0
  35. package/lib/core/socketFactory.d.ts +16 -0
  36. package/lib/core/socketFactory.js +82 -0
  37. package/lib/core/types.d.ts +320 -0
  38. package/lib/core/types.js +30 -0
  39. package/lib/core/unread.d.ts +2 -0
  40. package/lib/core/unread.js +5 -0
  41. package/lib/i18n/ar.json +1 -0
  42. package/lib/i18n/en.json +1 -0
  43. package/lib/i18n/index.d.ts +7 -0
  44. package/lib/i18n/index.js +43 -0
  45. package/lib/index.d.ts +59 -0
  46. package/lib/index.js +142 -0
  47. package/lib/plugin/withWebchat.d.ts +53 -0
  48. package/lib/plugin/withWebchat.js +164 -0
  49. package/lib/state/WebchatProvider.d.ts +132 -0
  50. package/lib/state/WebchatProvider.js +906 -0
  51. package/lib/state/useWebchat.d.ts +1 -0
  52. package/lib/state/useWebchat.js +12 -0
  53. package/lib/theme/dir.d.ts +14 -0
  54. package/lib/theme/dir.js +20 -0
  55. package/lib/theme/themeFactory.d.ts +219 -0
  56. package/lib/theme/themeFactory.js +182 -0
  57. package/lib/ui/AttachButton.d.ts +35 -0
  58. package/lib/ui/AttachButton.js +26 -0
  59. package/lib/ui/AudioRecorder.d.ts +25 -0
  60. package/lib/ui/AudioRecorder.js +228 -0
  61. package/lib/ui/Bubble.d.ts +1 -0
  62. package/lib/ui/Bubble.js +265 -0
  63. package/lib/ui/CallControls.d.ts +27 -0
  64. package/lib/ui/CallControls.js +92 -0
  65. package/lib/ui/CallPlaceholder.d.ts +16 -0
  66. package/lib/ui/CallPlaceholder.js +73 -0
  67. package/lib/ui/Composer.d.ts +5 -0
  68. package/lib/ui/Composer.js +272 -0
  69. package/lib/ui/FileTile.d.ts +9 -0
  70. package/lib/ui/FileTile.js +31 -0
  71. package/lib/ui/Header.d.ts +52 -0
  72. package/lib/ui/Header.js +236 -0
  73. package/lib/ui/Icon.d.ts +21 -0
  74. package/lib/ui/Icon.js +110 -0
  75. package/lib/ui/ImageBubble.d.ts +11 -0
  76. package/lib/ui/ImageBubble.js +16 -0
  77. package/lib/ui/MediaUploadMenu.d.ts +23 -0
  78. package/lib/ui/MediaUploadMenu.js +68 -0
  79. package/lib/ui/MessageList.d.ts +1 -0
  80. package/lib/ui/MessageList.js +46 -0
  81. package/lib/ui/PoweredBy.d.ts +8 -0
  82. package/lib/ui/PoweredBy.js +14 -0
  83. package/lib/ui/PrechatForm.d.ts +1 -0
  84. package/lib/ui/PrechatForm.js +230 -0
  85. package/lib/ui/QuickReplies.d.ts +1 -0
  86. package/lib/ui/QuickReplies.js +24 -0
  87. package/lib/ui/TypingIndicator.d.ts +9 -0
  88. package/lib/ui/TypingIndicator.js +88 -0
  89. package/lib/ui/VideoBubble.d.ts +10 -0
  90. package/lib/ui/VideoBubble.js +130 -0
  91. package/lib/ui/VideoCall.d.ts +34 -0
  92. package/lib/ui/VideoCall.js +191 -0
  93. package/lib/ui/VideoTile.d.ts +25 -0
  94. package/lib/ui/VideoTile.js +13 -0
  95. package/lib/ui/VoiceMessage.d.ts +19 -0
  96. package/lib/ui/VoiceMessage.js +127 -0
  97. package/lib/ui/WebChat.d.ts +10 -0
  98. package/lib/ui/WebChat.js +386 -0
  99. package/lib/ui/openLink.d.ts +1 -0
  100. package/lib/ui/openLink.js +16 -0
  101. package/package.json +94 -0
@@ -0,0 +1,53 @@
1
+ "use strict";
2
+ // src/core/logger.ts
3
+ //
4
+ // Logging policy (C18 / audit #28): the framework-agnostic core must NEVER leak
5
+ // PII to release logs. React Native has no Terser/console-stripping by default,
6
+ // so the logger is gated on `__DEV__` and no-ops entirely in release builds.
7
+ //
8
+ // HARD RULE: never pass a session_id or message content/text to this logger.
9
+ // Callers log structural facts only ("session_request retry 2/3", a typed error
10
+ // code, a status transition) — not the values that carry user data.
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.logger = void 0;
13
+ exports.createLogger = createLogger;
14
+ /**
15
+ * Resolve the ambient `__DEV__` flag without importing react-native (core stays
16
+ * framework-agnostic and unit-tests run under node where `__DEV__` is undefined).
17
+ * Treated as production (silent) unless explicitly `true`.
18
+ */
19
+ function isDev() {
20
+ // `__DEV__` is injected by the RN/Metro bundler onto the global scope. We read
21
+ // it off `globalThis` (never as a bare identifier) so this module is safe to
22
+ // load anywhere — node tests, web, release bundles — without a declared global.
23
+ const flag = globalThis.__DEV__;
24
+ return flag === true;
25
+ }
26
+ const PREFIX = "[webchat]";
27
+ /**
28
+ * Build a logger. In release (`__DEV__` !== true) every method is a no-op so no
29
+ * core diagnostics reach production logs. In dev they forward to the matching
30
+ * `console` method behind a stable prefix.
31
+ *
32
+ * @param sink injectable console-like sink (tests assert calls without spying on
33
+ * the global console).
34
+ */
35
+ function createLogger(sink = console) {
36
+ const dev = isDev();
37
+ const out = (method, args) => {
38
+ var _a;
39
+ if (!dev)
40
+ return;
41
+ const fn = (_a = sink[method]) !== null && _a !== void 0 ? _a : sink.log;
42
+ if (typeof fn === "function")
43
+ fn(PREFIX, ...args);
44
+ };
45
+ return {
46
+ debug: (...a) => out("debug", a),
47
+ info: (...a) => out("info", a),
48
+ warn: (...a) => out("warn", a),
49
+ error: (...a) => out("error", a),
50
+ };
51
+ }
52
+ /** Process-wide default logger (dev-gated). */
53
+ exports.logger = createLogger();
@@ -0,0 +1,52 @@
1
+ import type { Message } from "./types";
2
+ /** A file selected for upload, sized in raw (pre-base64) bytes. */
3
+ export interface MediaFile {
4
+ name: string;
5
+ mimeType: string;
6
+ /** raw byte size (NOT base64-inflated) */
7
+ size: number;
8
+ uri?: string;
9
+ base64?: string;
10
+ }
11
+ export interface MediaLimits {
12
+ /** images (and HEIC photos) cap, raw bytes */
13
+ imageBytes: number;
14
+ /** video cap, raw bytes (socket-frame limit) */
15
+ videoBytes: number;
16
+ /** documents/PDF cap, raw bytes */
17
+ documentBytes: number;
18
+ /** max files per send */
19
+ maxFiles: number;
20
+ }
21
+ /**
22
+ * RESOLVED caps (B4/B5): images ≤ 10MB, documents/PDF ≤ 10MB, video ≤ 16MB,
23
+ * max 10 files per send. Caps are on RAW bytes; base64 inflates ~33% on the wire
24
+ * (a 16MB file ≈ 21MB encoded) but the socket frame tolerates the resolved 16MB cap.
25
+ */
26
+ export declare const DEFAULT_LIMITS: MediaLimits;
27
+ export type RejectReason = "too-large" | "too-many" | "unsupported-type";
28
+ export interface RejectedFile {
29
+ file: MediaFile;
30
+ reason: RejectReason;
31
+ }
32
+ export interface ValidationResult {
33
+ accepted: MediaFile[];
34
+ rejected: RejectedFile[];
35
+ }
36
+ /**
37
+ * The accepted mime allow-list, mirroring the web widget. HEIC is present because the
38
+ * resolved policy accepts HEIC photos (the picker converts them to JPEG before encoding).
39
+ */
40
+ export declare function acceptedMimeTypes(): string[];
41
+ /**
42
+ * Enforce count, type, and per-kind size caps on a selection BEFORE encoding.
43
+ * Count is checked first (extras beyond maxFiles are `too-many`), then each kept file is
44
+ * checked for an allow-listed mime and against its per-kind raw-byte cap.
45
+ */
46
+ export declare function validateSelection(files: MediaFile[], limits?: MediaLimits): ValidationResult;
47
+ /**
48
+ * Once the server echoes a hosted `mediaUrl` (UPDATE_MSG), drop the heavy base64 payload
49
+ * so it is never persisted. Keeps `mediaUrl` and all non-base64 metadata. Pure: returns a
50
+ * new message and does not mutate the input.
51
+ */
52
+ export declare function stripBase64OnAck(message: Message): Message;
@@ -0,0 +1,115 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DEFAULT_LIMITS = void 0;
4
+ exports.acceptedMimeTypes = acceptedMimeTypes;
5
+ exports.validateSelection = validateSelection;
6
+ exports.stripBase64OnAck = stripBase64OnAck;
7
+ const MB = 1024 * 1024;
8
+ /**
9
+ * RESOLVED caps (B4/B5): images ≤ 10MB, documents/PDF ≤ 10MB, video ≤ 16MB,
10
+ * max 10 files per send. Caps are on RAW bytes; base64 inflates ~33% on the wire
11
+ * (a 16MB file ≈ 21MB encoded) but the socket frame tolerates the resolved 16MB cap.
12
+ */
13
+ exports.DEFAULT_LIMITS = {
14
+ imageBytes: 10 * MB,
15
+ videoBytes: 16 * MB,
16
+ documentBytes: 10 * MB,
17
+ maxFiles: 10,
18
+ };
19
+ /** Web allow-list (png/jpg/jpeg, mp4/m4v, pdf). HEIC included per resolved handling. */
20
+ const ALLOW_LIST = [
21
+ "image/png",
22
+ "image/jpeg",
23
+ "image/heic", // NOTE: converted to / accepted as JPEG per resolved HEIC policy
24
+ "video/mp4",
25
+ "video/x-m4v",
26
+ // iOS picks .mov clips and expo-image-picker reports them as "video/quicktime".
27
+ // Without this, every iOS video send was rejected as "unsupported-type" -> a
28
+ // WebChatError("send") before it ever encoded. ⚠️ Backend acceptance of
29
+ // video/quicktime MUST BE CONFIRMED (the web widget only accepted mp4/m4v); if the
30
+ // server rejects .mov, transcode to mp4 before send. kindOf() classifies any
31
+ // "video/*" as the video kind, so the 16MB video cap still applies.
32
+ "video/quicktime",
33
+ "application/pdf",
34
+ "audio/ogg", // recorded voice notes (canonical "audio/ogg;codecs=opus"; suffix tolerated)
35
+ // B6 codec flag: the default expo-audio recorder (the Phase 2 <AudioRecorder>
36
+ // component via useAudioRecorder) emits MPEG-4/AAC (.m4a) on iOS AND Android —
37
+ // it cannot produce Ogg/Opus. These mimes are allow-listed so the honest m4a/aac
38
+ // voice note round-trips instead of being rejected as unsupported-type. ⚠️
39
+ // Backend/infra acceptance of m4a/aac voice notes MUST BE CONFIRMED (the web
40
+ // widget only ever sent audio/ogg;codecs=opus); otherwise substitute an
41
+ // Opus-capable recorder behind the same AudioAdapter.
42
+ "audio/mp4",
43
+ "audio/aac",
44
+ "audio/m4a",
45
+ ];
46
+ /**
47
+ * The accepted mime allow-list, mirroring the web widget. HEIC is present because the
48
+ * resolved policy accepts HEIC photos (the picker converts them to JPEG before encoding).
49
+ */
50
+ function acceptedMimeTypes() {
51
+ return [...ALLOW_LIST];
52
+ }
53
+ function kindOf(mimeType) {
54
+ if (mimeType.startsWith("video/"))
55
+ return "video";
56
+ if (mimeType.startsWith("image/"))
57
+ return "image";
58
+ return "document";
59
+ }
60
+ function capFor(kind, limits) {
61
+ if (kind === "video")
62
+ return limits.videoBytes;
63
+ if (kind === "image")
64
+ return limits.imageBytes;
65
+ return limits.documentBytes;
66
+ }
67
+ /**
68
+ * Enforce count, type, and per-kind size caps on a selection BEFORE encoding.
69
+ * Count is checked first (extras beyond maxFiles are `too-many`), then each kept file is
70
+ * checked for an allow-listed mime and against its per-kind raw-byte cap.
71
+ */
72
+ function validateSelection(files, limits = exports.DEFAULT_LIMITS) {
73
+ const accepted = [];
74
+ const rejected = [];
75
+ files.forEach((file, i) => {
76
+ if (i >= limits.maxFiles) {
77
+ rejected.push({ file, reason: "too-many" });
78
+ return;
79
+ }
80
+ // Tolerate a `;codecs=…` suffix (e.g. "audio/ogg;codecs=opus") by comparing
81
+ // membership on the bare mime type only.
82
+ const baseMime = file.mimeType.split(";")[0].trim();
83
+ if (!ALLOW_LIST.includes(baseMime)) {
84
+ rejected.push({ file, reason: "unsupported-type" });
85
+ return;
86
+ }
87
+ if (file.size > capFor(kindOf(file.mimeType), limits)) {
88
+ rejected.push({ file, reason: "too-large" });
89
+ return;
90
+ }
91
+ accepted.push(file);
92
+ });
93
+ return { accepted, rejected };
94
+ }
95
+ /**
96
+ * Once the server echoes a hosted `mediaUrl` (UPDATE_MSG), drop the heavy base64 payload
97
+ * so it is never persisted. Keeps `mediaUrl` and all non-base64 metadata. Pure: returns a
98
+ * new message and does not mutate the input.
99
+ */
100
+ function stripBase64OnAck(message) {
101
+ const next = { ...message };
102
+ if (next.media)
103
+ delete next.media;
104
+ if (Array.isArray(next.files)) {
105
+ next.files = next.files.map((f) => {
106
+ if (f && typeof f === "object") {
107
+ const { base64, ...rest } = f;
108
+ void base64;
109
+ return rest;
110
+ }
111
+ return f;
112
+ });
113
+ }
114
+ return next;
115
+ }
@@ -0,0 +1,21 @@
1
+ export type MediaKind = "image" | "video" | "audio" | "document";
2
+ /** A minimal, transport-agnostic descriptor of an inbound file. */
3
+ export interface MediaTypeInput {
4
+ /** display name including extension, e.g. "photo.jpg" */
5
+ name?: string;
6
+ /** MIME type from a File/blob/picker asset, e.g. "image/jpeg" */
7
+ type?: string;
8
+ /** MIME type from the SDK Asset/MediaEntry shape */
9
+ mimeType?: string;
10
+ /** server-hosted URL once the message is acked */
11
+ mediaUrl?: string;
12
+ /** local file URI (file://… or content://…) */
13
+ uri?: string;
14
+ }
15
+ /**
16
+ * Classify an inbound file into the kind the renderer should use, mirroring the
17
+ * web widget's FileDisplay. MIME (`type`/`mimeType`) wins when it names a kind;
18
+ * otherwise the extension of `name` || `mediaUrl` || `uri` decides; unknown
19
+ * inputs fall back to "document".
20
+ */
21
+ export declare function detectMediaKind(file: MediaTypeInput): MediaKind;
@@ -0,0 +1,66 @@
1
+ "use strict";
2
+ // src/core/mediaType.ts
3
+ //
4
+ // PURE received-message media routing. Mirrors the web widget's FileDisplay
5
+ // (src/components/chatComponents/Body/FileDisplay.tsx): decide whether an
6
+ // inbound file should render as an image, video, audio player, or a generic
7
+ // document tile. No I/O, no React — just a deterministic classifier so the
8
+ // data layer and (Phase 2) the UI agree on a single source of truth.
9
+ //
10
+ // Routing precedence, matching FileDisplay.getFileType():
11
+ // 1. MIME wins. If `type`/`mimeType` contains "image" | "video" | "audio",
12
+ // that kind is returned immediately (mime-wins resolves the ambiguous
13
+ // `ogg` case — an audio/ogg mime classifies as audio even though the
14
+ // `.ogg` extension is listed under video first).
15
+ // 2. Otherwise fall back to the file extension taken from
16
+ // `name` || `mediaUrl` || `uri` (first non-empty), lower-cased,
17
+ // query-stripped. Extension order mirrors FileDisplay: image, then video,
18
+ // then audio — so `ogg` (in BOTH the video and audio lists, like the web
19
+ // reference) resolves to video on extension alone.
20
+ // 3. Anything unrecognized -> "document".
21
+ Object.defineProperty(exports, "__esModule", { value: true });
22
+ exports.detectMediaKind = detectMediaKind;
23
+ // Extension tables mirror FileDisplay.getFileTypeFromUrl(). `ogg` deliberately
24
+ // appears in BOTH video and audio (web parity); video is checked first below.
25
+ const IMAGE_EXTS = ["jpg", "jpeg", "png", "gif", "bmp", "webp", "svg"];
26
+ const VIDEO_EXTS = ["mp4", "webm", "mov", "avi", "mkv", "ogg"];
27
+ const AUDIO_EXTS = ["mp3", "wav", "ogg", "aac", "m4a", "flac"];
28
+ /** Lower-cased, query-stripped extension tail of a URL/name, "" when none. */
29
+ function extOf(s) {
30
+ const clean = s.split("?")[0];
31
+ const dot = clean.lastIndexOf(".");
32
+ return dot >= 0 ? clean.slice(dot + 1).toLowerCase() : "";
33
+ }
34
+ /**
35
+ * Classify an inbound file into the kind the renderer should use, mirroring the
36
+ * web widget's FileDisplay. MIME (`type`/`mimeType`) wins when it names a kind;
37
+ * otherwise the extension of `name` || `mediaUrl` || `uri` decides; unknown
38
+ * inputs fall back to "document".
39
+ */
40
+ function detectMediaKind(file) {
41
+ var _a, _b;
42
+ // 1. MIME wins (resolves the ambiguous ogg case in audio's favor when the
43
+ // mime explicitly says audio).
44
+ const mime = (_b = (_a = file.type) !== null && _a !== void 0 ? _a : file.mimeType) !== null && _b !== void 0 ? _b : "";
45
+ if (mime) {
46
+ if (mime.includes("image"))
47
+ return "image";
48
+ if (mime.includes("video"))
49
+ return "video";
50
+ if (mime.includes("audio"))
51
+ return "audio";
52
+ }
53
+ // 2. Extension fallback on the first non-empty source.
54
+ const source = file.name || file.mediaUrl || file.uri || "";
55
+ const ext = extOf(source);
56
+ if (ext) {
57
+ if (IMAGE_EXTS.includes(ext))
58
+ return "image";
59
+ if (VIDEO_EXTS.includes(ext))
60
+ return "video"; // video checked before audio (web parity)
61
+ if (AUDIO_EXTS.includes(ext))
62
+ return "audio";
63
+ }
64
+ // 3. Unknown -> document.
65
+ return "document";
66
+ }
@@ -0,0 +1,36 @@
1
+ import type { Message, MessageReaction } from "./types";
2
+ export interface MessagesState {
3
+ messages: Message[];
4
+ lastReadAt: number;
5
+ }
6
+ export declare const initialState: MessagesState;
7
+ export type MessagesAction = {
8
+ type: "RECEIVE";
9
+ message: Message;
10
+ } | {
11
+ type: "SEND";
12
+ message: Message;
13
+ } | {
14
+ type: "ACK";
15
+ key: string;
16
+ } | {
17
+ type: "FAIL";
18
+ key: string;
19
+ } | {
20
+ type: "UPDATE_MSG";
21
+ key: string;
22
+ patch: Partial<Message>;
23
+ } | {
24
+ type: "HYDRATE";
25
+ messages: Message[];
26
+ } | {
27
+ type: "MARK_READ";
28
+ at: number;
29
+ } | {
30
+ type: "REACT";
31
+ messageKey: string;
32
+ reaction: MessageReaction | null;
33
+ } | {
34
+ type: "CLEAR";
35
+ };
36
+ export declare function messagesReducer(state: MessagesState, action: MessagesAction): MessagesState;
@@ -0,0 +1,58 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.initialState = void 0;
4
+ exports.messagesReducer = messagesReducer;
5
+ const media_1 = require("./media");
6
+ exports.initialState = { messages: [], lastReadAt: 0 };
7
+ function messagesReducer(state, action) {
8
+ switch (action.type) {
9
+ case "RECEIVE":
10
+ return { ...state, messages: [...state.messages, action.message] };
11
+ case "SEND":
12
+ return { ...state, messages: [...state.messages, { ...action.message, status: "pending" }] };
13
+ case "ACK":
14
+ return { ...state, messages: state.messages.map((m) => m.key === action.key ? { ...m, status: "sent" } : m) };
15
+ case "FAIL":
16
+ return { ...state, messages: state.messages.map((m) => m.key === action.key ? { ...m, status: "failed" } : m) };
17
+ case "UPDATE_MSG":
18
+ // Reconcile by messageKey: apply the patch (e.g. server mediaUrl), default to 'sent',
19
+ // and strip base64 so the hosted URL is all that persists.
20
+ return {
21
+ ...state,
22
+ messages: state.messages.map((m) => m.key === action.key
23
+ ? (0, media_1.stripBase64OnAck)({ ...m, status: "sent", ...action.patch })
24
+ : m),
25
+ };
26
+ case "REACT":
27
+ // Match by the SERVER messageKey (distinct from the client `key`); set the
28
+ // reaction (or null to clear). No-op when no message carries that messageKey.
29
+ return {
30
+ ...state,
31
+ messages: state.messages.map((m) => m.messageKey === action.messageKey
32
+ ? { ...m, userReaction: action.reaction }
33
+ : m),
34
+ };
35
+ case "HYDRATE": {
36
+ // Legacy sessions persisted BEFORE received-message keys were made unique can
37
+ // hold duplicate client `key`s (same-millisecond bot bursts saved as `b{now}`).
38
+ // Re-key collisions on load so React keys — and key-based reconciliation
39
+ // lookups (ACK/UPDATE_MSG) — stay unique. Mirrors RECEIVE's unique-key fix.
40
+ const seen = new Set();
41
+ const deduped = action.messages.map((m, i) => {
42
+ var _a;
43
+ if (m.key && !seen.has(m.key)) {
44
+ seen.add(m.key);
45
+ return m;
46
+ }
47
+ return { ...m, key: `${(_a = m.key) !== null && _a !== void 0 ? _a : "m"}-${i}` };
48
+ });
49
+ return { ...state, messages: deduped };
50
+ }
51
+ case "MARK_READ":
52
+ return { ...state, lastReadAt: action.at };
53
+ case "CLEAR":
54
+ return { messages: [], lastReadAt: 0 };
55
+ default:
56
+ return state;
57
+ }
58
+ }
@@ -0,0 +1,45 @@
1
+ import type { Message } from "./types";
2
+ export interface KeyValueStore {
3
+ getItem(k: string): Promise<string | null>;
4
+ setItem(k: string, v: string): Promise<void>;
5
+ removeItem(k: string): Promise<void>;
6
+ }
7
+ export declare class MemoryStore implements KeyValueStore {
8
+ private m;
9
+ getItem(k: string): Promise<string | null>;
10
+ setItem(k: string, v: string): Promise<void>;
11
+ removeItem(k: string): Promise<void>;
12
+ }
13
+ /**
14
+ * Config-driven storage backend selection (Tier C #33, web `config.storage`).
15
+ *
16
+ * Precedence: a HOST-INJECTED store ALWAYS wins (the host explicitly chose a
17
+ * backend) — we return it untouched. Only when the host injects nothing do we
18
+ * honor `config.storage`:
19
+ * - "sessionStorage" | "memory" -> a fresh {@link MemoryStore} (EPHEMERAL —
20
+ * history lives only for the process lifetime, mirroring the web's non-durable
21
+ * sessionStorage/in-memory backends; nothing is written to AsyncStorage so a
22
+ * relaunch starts clean).
23
+ * - "localStorage" (default) / anything else -> the `persistent` store passed in
24
+ * (AsyncStorage in production), keeping the 24h sliding-TTL persistence path.
25
+ *
26
+ * The TTL logic in {@link loadSession}/{@link saveSession} is unchanged — it runs
27
+ * against whichever store this returns, so the persistent path keeps the sliding
28
+ * window and the ephemeral path simply never has prior data to expire.
29
+ *
30
+ * @param injected host-supplied store (or undefined when the host passed none).
31
+ * @param storage `config.storage` value.
32
+ * @param persistent the durable store to use for the "localStorage" path.
33
+ */
34
+ export declare function selectStore(injected: KeyValueStore | undefined | null, storage: string | undefined, persistent: KeyValueStore): KeyValueStore;
35
+ export interface PersistedSession {
36
+ sessionId: string;
37
+ messages: Message[];
38
+ lastActivity: number;
39
+ }
40
+ export declare function saveSession(store: KeyValueStore, s: PersistedSession, channelId: string): Promise<void>;
41
+ export declare function loadSession(store: KeyValueStore, channelId: string, opts: {
42
+ now: number;
43
+ ttlMs: number;
44
+ }): Promise<PersistedSession | null>;
45
+ export declare function clearSession(store: KeyValueStore, channelId: string): Promise<void>;
@@ -0,0 +1,63 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.MemoryStore = void 0;
4
+ exports.selectStore = selectStore;
5
+ exports.saveSession = saveSession;
6
+ exports.loadSession = loadSession;
7
+ exports.clearSession = clearSession;
8
+ class MemoryStore {
9
+ constructor() {
10
+ this.m = new Map();
11
+ }
12
+ async getItem(k) { var _a; return (_a = this.m.get(k)) !== null && _a !== void 0 ? _a : null; }
13
+ async setItem(k, v) { this.m.set(k, v); }
14
+ async removeItem(k) { this.m.delete(k); }
15
+ }
16
+ exports.MemoryStore = MemoryStore;
17
+ /**
18
+ * Config-driven storage backend selection (Tier C #33, web `config.storage`).
19
+ *
20
+ * Precedence: a HOST-INJECTED store ALWAYS wins (the host explicitly chose a
21
+ * backend) — we return it untouched. Only when the host injects nothing do we
22
+ * honor `config.storage`:
23
+ * - "sessionStorage" | "memory" -> a fresh {@link MemoryStore} (EPHEMERAL —
24
+ * history lives only for the process lifetime, mirroring the web's non-durable
25
+ * sessionStorage/in-memory backends; nothing is written to AsyncStorage so a
26
+ * relaunch starts clean).
27
+ * - "localStorage" (default) / anything else -> the `persistent` store passed in
28
+ * (AsyncStorage in production), keeping the 24h sliding-TTL persistence path.
29
+ *
30
+ * The TTL logic in {@link loadSession}/{@link saveSession} is unchanged — it runs
31
+ * against whichever store this returns, so the persistent path keeps the sliding
32
+ * window and the ephemeral path simply never has prior data to expire.
33
+ *
34
+ * @param injected host-supplied store (or undefined when the host passed none).
35
+ * @param storage `config.storage` value.
36
+ * @param persistent the durable store to use for the "localStorage" path.
37
+ */
38
+ function selectStore(injected, storage, persistent) {
39
+ if (injected)
40
+ return injected; // host choice is authoritative
41
+ if (storage === "sessionStorage" || storage === "memory")
42
+ return new MemoryStore();
43
+ return persistent;
44
+ }
45
+ const key = (channelId) => `webchat:${channelId}`;
46
+ async function saveSession(store, s, channelId) {
47
+ // never persist base64 media: callers strip it before saving
48
+ await store.setItem(key(channelId), JSON.stringify(s));
49
+ }
50
+ async function loadSession(store, channelId, opts) {
51
+ const raw = await store.getItem(key(channelId));
52
+ if (!raw)
53
+ return null;
54
+ const s = JSON.parse(raw);
55
+ if (opts.now - s.lastActivity > opts.ttlMs) {
56
+ await store.removeItem(key(channelId)); // active purge
57
+ return null;
58
+ }
59
+ return s;
60
+ }
61
+ async function clearSession(store, channelId) {
62
+ await store.removeItem(key(channelId));
63
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Minimal socket surface the framework-agnostic client depends on.
3
+ *
4
+ * In the plan this interface lives in `./WebchatClient` (Task 4). Until that
5
+ * module lands it is declared here so the real factory is self-contained and
6
+ * unit-testable offline. The shape is identical to the plan's `MinimalSocket`.
7
+ */
8
+ export interface MinimalSocket {
9
+ on(event: string, cb: Function): void;
10
+ off(event: string, cb?: Function): void;
11
+ emit(event: string, data?: unknown): void;
12
+ connect?(): void;
13
+ disconnect(): void;
14
+ connected?: boolean;
15
+ }
16
+ export declare function createSocket(connectionUrl: string, channelId: string, debug?: boolean): MinimalSocket;
@@ -0,0 +1,82 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.createSocket = createSocket;
7
+ // src/core/socketFactory.ts
8
+ const socket_io_client_1 = __importDefault(require("socket.io-client"));
9
+ /**
10
+ * Compact a list of log args for the debug tracer: strings pass through; objects
11
+ * are JSON-stringified. Any string longer than 500 chars (e.g. base64 media) is
12
+ * truncated so Metro logs stay readable and copy-pasteable.
13
+ */
14
+ function fmtArgs(args) {
15
+ const cap = (v) => (v.length > 500 ? `${v.slice(0, 200)}…(${v.length} chars)` : v);
16
+ return args
17
+ .map((a) => {
18
+ if (typeof a === "string")
19
+ return cap(a);
20
+ try {
21
+ return JSON.stringify(a, (_k, v) => (typeof v === "string" ? cap(v) : v));
22
+ }
23
+ catch {
24
+ return String(a);
25
+ }
26
+ })
27
+ .join(" ");
28
+ }
29
+ /**
30
+ * DEBUG ONLY (`config.debug`): trace every socket message to the console (Metro) —
31
+ * outbound emits, every inbound server event (via socket.io v2's `onevent` hook,
32
+ * so even events with no registered handler are seen), and connection lifecycle.
33
+ * Long strings (base64) are truncated. Never enable in production — full payloads
34
+ * (incl. session ids / content) reach the console. Fully guarded so a tracer
35
+ * failure never breaks the socket.
36
+ */
37
+ function attachSocketTrace(s) {
38
+ try {
39
+ // Outbound: wrap emit (preserves chaining + any ack callback in args).
40
+ const realEmit = s.emit.bind(s);
41
+ s.emit = function (event, ...args) {
42
+ // eslint-disable-next-line no-console
43
+ console.log("[webchat ▶ emit]", event, fmtArgs(args));
44
+ return realEmit(event, ...args);
45
+ };
46
+ // Inbound: the v2 Socket dispatches server events through `onevent(packet)`;
47
+ // wrapping it catches EVERY inbound event + payload.
48
+ if (typeof s.onevent === "function") {
49
+ const realOnevent = s.onevent.bind(s);
50
+ s.onevent = function (packet) {
51
+ const data = (packet && packet.data) || [];
52
+ // eslint-disable-next-line no-console
53
+ console.log("[webchat ◀ recv]", data[0], fmtArgs(data.slice(1)));
54
+ return realOnevent(packet);
55
+ };
56
+ }
57
+ // Lifecycle (not routed through onevent).
58
+ ["connect", "disconnect", "connect_error", "error", "reconnect"].forEach((ev) => s.on(ev, (payload) => {
59
+ // eslint-disable-next-line no-console
60
+ console.log(`[webchat ◇ ${ev}]`, payload === undefined ? "" : fmtArgs([payload]));
61
+ }));
62
+ // eslint-disable-next-line no-console
63
+ console.log("[webchat] socket trace ENABLED (config.debug) — disable for production");
64
+ }
65
+ catch (e) {
66
+ // eslint-disable-next-line no-console
67
+ console.log("[webchat] socket trace attach failed", e);
68
+ }
69
+ }
70
+ function createSocket(connectionUrl, channelId, debug = false) {
71
+ const s = (0, socket_io_client_1.default)(`wss://${connectionUrl}`, {
72
+ path: `/${channelId}/ws`,
73
+ transports: ["websocket"],
74
+ reconnectionDelay: 5000,
75
+ reconnection: true,
76
+ autoConnect: false,
77
+ });
78
+ // Opt-in full socket tracing for on-device debugging (config.debug). No-op otherwise.
79
+ if (debug)
80
+ attachSocketTrace(s);
81
+ return s;
82
+ }