@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.
- package/README.md +254 -0
- package/app.plugin.js +6 -0
- package/lib/adapters/audio.d.ts +74 -0
- package/lib/adapters/audio.js +39 -0
- package/lib/adapters/audioRoute.d.ts +57 -0
- package/lib/adapters/audioRoute.js +77 -0
- package/lib/adapters/expoDefaults.d.ts +77 -0
- package/lib/adapters/expoDefaults.js +539 -0
- package/lib/adapters/picker.d.ts +67 -0
- package/lib/adapters/picker.js +37 -0
- package/lib/adapters/webrtc.d.ts +131 -0
- package/lib/adapters/webrtc.js +70 -0
- package/lib/core/VideoCallClient.d.ts +106 -0
- package/lib/core/VideoCallClient.js +302 -0
- package/lib/core/WebchatClient.d.ts +34 -0
- package/lib/core/WebchatClient.js +132 -0
- package/lib/core/configClient.d.ts +42 -0
- package/lib/core/configClient.js +302 -0
- package/lib/core/greet.d.ts +11 -0
- package/lib/core/greet.js +17 -0
- package/lib/core/ice.d.ts +31 -0
- package/lib/core/ice.js +48 -0
- package/lib/core/linkify.d.ts +11 -0
- package/lib/core/linkify.js +25 -0
- package/lib/core/logger.d.ts +17 -0
- package/lib/core/logger.js +53 -0
- package/lib/core/media.d.ts +52 -0
- package/lib/core/media.js +115 -0
- package/lib/core/mediaType.d.ts +21 -0
- package/lib/core/mediaType.js +66 -0
- package/lib/core/messagesReducer.d.ts +36 -0
- package/lib/core/messagesReducer.js +58 -0
- package/lib/core/persistence.d.ts +45 -0
- package/lib/core/persistence.js +63 -0
- package/lib/core/socketFactory.d.ts +16 -0
- package/lib/core/socketFactory.js +82 -0
- package/lib/core/types.d.ts +320 -0
- package/lib/core/types.js +30 -0
- package/lib/core/unread.d.ts +2 -0
- package/lib/core/unread.js +5 -0
- package/lib/i18n/ar.json +1 -0
- package/lib/i18n/en.json +1 -0
- package/lib/i18n/index.d.ts +7 -0
- package/lib/i18n/index.js +43 -0
- package/lib/index.d.ts +59 -0
- package/lib/index.js +142 -0
- package/lib/plugin/withWebchat.d.ts +53 -0
- package/lib/plugin/withWebchat.js +164 -0
- package/lib/state/WebchatProvider.d.ts +132 -0
- package/lib/state/WebchatProvider.js +906 -0
- package/lib/state/useWebchat.d.ts +1 -0
- package/lib/state/useWebchat.js +12 -0
- package/lib/theme/dir.d.ts +14 -0
- package/lib/theme/dir.js +20 -0
- package/lib/theme/themeFactory.d.ts +219 -0
- package/lib/theme/themeFactory.js +182 -0
- package/lib/ui/AttachButton.d.ts +35 -0
- package/lib/ui/AttachButton.js +26 -0
- package/lib/ui/AudioRecorder.d.ts +25 -0
- package/lib/ui/AudioRecorder.js +228 -0
- package/lib/ui/Bubble.d.ts +1 -0
- package/lib/ui/Bubble.js +265 -0
- package/lib/ui/CallControls.d.ts +27 -0
- package/lib/ui/CallControls.js +92 -0
- package/lib/ui/CallPlaceholder.d.ts +16 -0
- package/lib/ui/CallPlaceholder.js +73 -0
- package/lib/ui/Composer.d.ts +5 -0
- package/lib/ui/Composer.js +272 -0
- package/lib/ui/FileTile.d.ts +9 -0
- package/lib/ui/FileTile.js +31 -0
- package/lib/ui/Header.d.ts +52 -0
- package/lib/ui/Header.js +236 -0
- package/lib/ui/Icon.d.ts +21 -0
- package/lib/ui/Icon.js +110 -0
- package/lib/ui/ImageBubble.d.ts +11 -0
- package/lib/ui/ImageBubble.js +16 -0
- package/lib/ui/MediaUploadMenu.d.ts +23 -0
- package/lib/ui/MediaUploadMenu.js +68 -0
- package/lib/ui/MessageList.d.ts +1 -0
- package/lib/ui/MessageList.js +46 -0
- package/lib/ui/PoweredBy.d.ts +8 -0
- package/lib/ui/PoweredBy.js +14 -0
- package/lib/ui/PrechatForm.d.ts +1 -0
- package/lib/ui/PrechatForm.js +230 -0
- package/lib/ui/QuickReplies.d.ts +1 -0
- package/lib/ui/QuickReplies.js +24 -0
- package/lib/ui/TypingIndicator.d.ts +9 -0
- package/lib/ui/TypingIndicator.js +88 -0
- package/lib/ui/VideoBubble.d.ts +10 -0
- package/lib/ui/VideoBubble.js +130 -0
- package/lib/ui/VideoCall.d.ts +34 -0
- package/lib/ui/VideoCall.js +191 -0
- package/lib/ui/VideoTile.d.ts +25 -0
- package/lib/ui/VideoTile.js +13 -0
- package/lib/ui/VoiceMessage.d.ts +19 -0
- package/lib/ui/VoiceMessage.js +127 -0
- package/lib/ui/WebChat.d.ts +10 -0
- package/lib/ui/WebChat.js +386 -0
- package/lib/ui/openLink.d.ts +1 -0
- package/lib/ui/openLink.js +16 -0
- 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
|
+
}
|