react-native-newinstance-chat 0.1.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NewinstanceChat.podspec +1 -1
- package/README.md +210 -3
- package/android/build.gradle +1 -1
- package/android/src/main/java/com/newinstancechat/NewinstanceChatModule.kt +145 -12
- package/ios/NewinstanceChat.mm +10 -0
- package/ios/NewinstanceChatImpl.swift +104 -18
- package/lib/module/NativeNewinstanceChat.js.map +1 -1
- package/lib/module/index.js +29 -0
- package/lib/module/index.js.map +1 -1
- package/lib/typescript/src/NativeNewinstanceChat.d.ts +3 -1
- package/lib/typescript/src/NativeNewinstanceChat.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +16 -2
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/types.d.ts +156 -3
- package/lib/typescript/src/types.d.ts.map +1 -1
- package/package.json +9 -3
- package/src/NativeNewinstanceChat.ts +3 -1
- package/src/index.tsx +31 -1
- package/src/types.ts +180 -3
package/NewinstanceChat.podspec
CHANGED
|
@@ -18,7 +18,7 @@ Pod::Spec.new do |s|
|
|
|
18
18
|
s.pod_target_xcconfig = { "DEFINES_MODULE" => "YES" }
|
|
19
19
|
|
|
20
20
|
# Native chat SDK we wrap, published on CocoaPods Trunk.
|
|
21
|
-
s.dependency "LiveAndAiChat", "~> 0.
|
|
21
|
+
s.dependency "LiveAndAiChat", "~> 0.3.0"
|
|
22
22
|
|
|
23
23
|
install_modules_dependencies(s)
|
|
24
24
|
end
|
package/README.md
CHANGED
|
@@ -75,6 +75,96 @@ sdk.openChat();
|
|
|
75
75
|
The chat screen renders natively — there is no React component to mount.
|
|
76
76
|
Your RN UI is whatever button / banner / FAB triggers `openChat()`.
|
|
77
77
|
|
|
78
|
+
### Authenticating the customer
|
|
79
|
+
|
|
80
|
+
`apiKey` is the publishable widget key **ID**, never the `keyId:secret`
|
|
81
|
+
form, which belongs on your server only.
|
|
82
|
+
|
|
83
|
+
There are two ways to say who the customer is.
|
|
84
|
+
|
|
85
|
+
**Anonymous.** Supply nothing. The chat collects whatever it needs.
|
|
86
|
+
|
|
87
|
+
**Verified.** Have your backend mint a chat identity token and hand it to
|
|
88
|
+
the app. The token is the identity, so no other customer fields are needed:
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
// Your backend, with the SECRET key, typically at sign-in:
|
|
92
|
+
// POST https://api.newinstance.cloud/api/v1/chat/sessions
|
|
93
|
+
// x-api-key: sk_live_abc123:YOUR_SECRET_KEY
|
|
94
|
+
// { "customerId": "usr_123", "customerName": "Ada Lovelace",
|
|
95
|
+
// "customerEmail": "ada@example.com", "customerPhone": "+44 20 7946 0958",
|
|
96
|
+
// "metadata": { "plan": "enterprise" }, "expiresInSeconds": 21600 }
|
|
97
|
+
// -> { token, expiresAt, session: { sessionId, ... } }
|
|
98
|
+
|
|
99
|
+
sdk.setUserToken(tokenFromYourBackend);
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Everything you put in that payload is signed into the token, so the chat
|
|
103
|
+
session already knows the customer before the app says anything:
|
|
104
|
+
|
|
105
|
+
```
|
|
106
|
+
customerId -> the verified customer id
|
|
107
|
+
customerName -> the name on the conversation and on every message
|
|
108
|
+
customerEmail -> also satisfies the merchant's "require email" setting
|
|
109
|
+
customerPhone -> shown to the agent
|
|
110
|
+
metadata -> any extra context your agents should see
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
The client then passes **one string**. It never repeats the name, never
|
|
114
|
+
repeats the email, and is never shown a pre-chat form.
|
|
115
|
+
|
|
116
|
+
The server verifies the signature and derives the name, email and customer
|
|
117
|
+
id from the token's claims, so a tampered app cannot claim to be someone
|
|
118
|
+
else. `customerId` passed *without* a token is recorded for agent context
|
|
119
|
+
only and is not treated as identity.
|
|
120
|
+
|
|
121
|
+
Tokens expire (one hour by default). A rejected token arrives on the error
|
|
122
|
+
event as `INVALID_IDENTITY_TOKEN`; mint a fresh one.
|
|
123
|
+
|
|
124
|
+
Sessions are managed over REST: `POST /api/v1/chat/sessions` to create,
|
|
125
|
+
`DELETE /api/v1/chat/sessions?customerId=...` on sign-out. You choose the
|
|
126
|
+
lifetime (60 seconds to 24 hours, default 1 hour) with `expiresInSeconds`.
|
|
127
|
+
|
|
128
|
+
A customer holds **one** live session at a time. Creating another while
|
|
129
|
+
theirs is valid returns `409` with the existing session; mint again once it
|
|
130
|
+
expires or after you revoke it. Revocation takes effect on the next request,
|
|
131
|
+
so an invalidated token stops working immediately even though its signature
|
|
132
|
+
is still valid.
|
|
133
|
+
|
|
134
|
+
The token is the session: the identity is signed into it, and the platform
|
|
135
|
+
stores no copy. Decode the token if you need the name, email, phone or
|
|
136
|
+
metadata back.
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
### Theming
|
|
140
|
+
|
|
141
|
+
The chat resolves colours from three layers:
|
|
142
|
+
|
|
143
|
+
```
|
|
144
|
+
dashboard theme > your local theme > the SDK's built-in theme
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
The merchant's dashboard configuration wins, your local theme fills in what
|
|
148
|
+
the dashboard did not set, and the built-in palette fills in the rest, so
|
|
149
|
+
every colour always has a value, even with no network. Merging is per
|
|
150
|
+
token, so setting one colour does not discard the others.
|
|
151
|
+
|
|
152
|
+
```ts
|
|
153
|
+
const sdk = new NewinstanceChat({
|
|
154
|
+
apiKey: 'sk_live_…',
|
|
155
|
+
theme: { mode: 'dark', sentBubble: '#16A34A' },
|
|
156
|
+
});
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Every `ChatThemeOverride` field is optional; colours are hex strings. An
|
|
160
|
+
unparseable value is ignored rather than throwing. `mode` is only a
|
|
161
|
+
preference: the dashboard's explicit light/dark wins, and its `auto` defers
|
|
162
|
+
to yours, then to the device.
|
|
163
|
+
|
|
164
|
+
If remote configuration cannot be fetched, your local theme still applies
|
|
165
|
+
and the chat stays fully usable; you get a `CONFIG_FETCH_FAILED` warning on
|
|
166
|
+
the error event.
|
|
167
|
+
|
|
78
168
|
### Subscribing to events
|
|
79
169
|
|
|
80
170
|
```ts
|
|
@@ -86,17 +176,134 @@ unsub();
|
|
|
86
176
|
```
|
|
87
177
|
|
|
88
178
|
Event names: `messageReceived`, `messageSent`, `agentTypingChanged`,
|
|
89
|
-
`connectionStateChanged`, `unreadCountChanged`, `error
|
|
90
|
-
for payload shapes.
|
|
179
|
+
`connectionStateChanged`, `unreadCountChanged`, `error`,
|
|
180
|
+
`attachmentUpdated`, `chatClosed`. See `src/types.ts` for payload shapes.
|
|
181
|
+
|
|
182
|
+
### The error event
|
|
183
|
+
|
|
184
|
+
`error` is the channel for "why is my chat not working". It never surfaces
|
|
185
|
+
in the chat interface the customer sees, and it never carries the API key,
|
|
186
|
+
the identity token, or customer data.
|
|
187
|
+
|
|
188
|
+
```ts
|
|
189
|
+
sdk.addListener('error', (e) => {
|
|
190
|
+
if (e.code === 'INVALID_IDENTITY_TOKEN') return refreshChatToken();
|
|
191
|
+
if (!e.recoverable) reportToYourMonitoring(e.code, e.message);
|
|
192
|
+
});
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
| `code` | What went wrong |
|
|
196
|
+
|---|---|
|
|
197
|
+
| `MISSING_WIDGET_KEY` | No API key was supplied |
|
|
198
|
+
| `INVALID_PUBLIC_KEY` | The key was rejected: wrong key, wrong environment, revoked, expired |
|
|
199
|
+
| `CHAT_CONFIG_UNAVAILABLE` | The key is fine, but the organization has no chat configuration |
|
|
200
|
+
| `CHAT_DISABLED` | Chat is switched off in the dashboard |
|
|
201
|
+
| `CONFIG_FETCH_FAILED` | Remote configuration could not be loaded; the local/built-in theme is in use |
|
|
202
|
+
| `INVALID_IDENTITY_TOKEN` | The token was malformed, expired, or signed for another organization |
|
|
203
|
+
| `NETWORK_ERROR` | The backend could not be reached |
|
|
204
|
+
| `TRANSPORT_ERROR` | The realtime connection dropped |
|
|
205
|
+
| `CHAT_INITIALIZATION_FAILED` | The conversation could not be started |
|
|
206
|
+
| `MESSAGE_SEND_FAILED` | A message could not be delivered |
|
|
207
|
+
| `ATTACHMENT_FAILED` | An attachment could not be uploaded |
|
|
208
|
+
| `SERVER_ERROR` | The backend returned something unusable |
|
|
209
|
+
|
|
210
|
+
The same codes are emitted by the web, Android, iOS and Flutter SDKs.
|
|
211
|
+
`type` (`network` / `validation` / `auth` / `system`) is still present for
|
|
212
|
+
handlers written against 0.2.x; `code` is `undefined` only when the
|
|
213
|
+
underlying native SDK predates coded errors.
|
|
214
|
+
|
|
215
|
+
### The chat-close event
|
|
216
|
+
|
|
217
|
+
`chatClosed` fires **exactly once per chat presentation**, however the
|
|
218
|
+
screen went away: the in-chat close button, Android back navigation
|
|
219
|
+
(button or gesture), an iOS dismissal gesture, `closeChat()`,
|
|
220
|
+
`destroy()` (reason `session_ended`), or the host app navigating the
|
|
221
|
+
screen away. Rerenders and lifecycle churn never produce duplicates.
|
|
222
|
+
|
|
223
|
+
```ts
|
|
224
|
+
useEffect(() => {
|
|
225
|
+
const unsub = sdk.addListener('chatClosed', (e) => {
|
|
226
|
+
// e.reason: 'close_button' | 'back_navigation' | 'gesture' |
|
|
227
|
+
// 'programmatic' | 'session_ended' | 'host_navigation'
|
|
228
|
+
// e.initiator: 'user' | 'host' | 'sdk'
|
|
229
|
+
// plus: timestamp, conversationId, assignmentId, channel,
|
|
230
|
+
// previousState, unreadCount, hasDraft,
|
|
231
|
+
// pendingAttachmentCount, metadata
|
|
232
|
+
analytics.track('chat_closed', { reason: e.reason });
|
|
233
|
+
});
|
|
234
|
+
return unsub; // always clean up on unmount
|
|
235
|
+
}, []);
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
Ordering: `chatClosed` is emitted after the SDK has left the open
|
|
239
|
+
state (`unreadCount` and `pendingAttachmentCount` reflect the moment of
|
|
240
|
+
dismissal). The event never contains message contents, credentials, or
|
|
241
|
+
attachment bytes.
|
|
91
242
|
|
|
92
243
|
### Sending messages programmatically
|
|
93
244
|
|
|
94
245
|
```ts
|
|
95
246
|
sdk.sendMessage('Hi, I have a question.');
|
|
96
|
-
sdk.attachFile({ uri: 'file:///…', name: 'screenshot.png', mimeType: 'image/png' });
|
|
97
247
|
sdk.retryMessage(failedMessageId);
|
|
98
248
|
```
|
|
99
249
|
|
|
250
|
+
### Programmatic attachments
|
|
251
|
+
|
|
252
|
+
`attachFile` accepts base64, data URIs, local file paths,
|
|
253
|
+
`content://` / `file://` URIs (picker outputs), and — when the native
|
|
254
|
+
SDK is configured with `allowRemoteAttachmentUrls` — remote URLs.
|
|
255
|
+
`sourceType: 'auto'` (the default) safely detects string inputs.
|
|
256
|
+
|
|
257
|
+
```ts
|
|
258
|
+
// Base64 (e.g. from a camera or canvas):
|
|
259
|
+
const id = await sdk.attachFile({
|
|
260
|
+
source: pngBase64,
|
|
261
|
+
sourceType: 'base64',
|
|
262
|
+
name: 'photo.png',
|
|
263
|
+
mimeType: 'image/png',
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
// React Native document picker result:
|
|
267
|
+
const [doc] = await pick(); // e.g. @react-native-documents/picker
|
|
268
|
+
await sdk.attachFile({
|
|
269
|
+
source: doc.uri, // content:// on Android, file:// on iOS
|
|
270
|
+
name: doc.name ?? 'document.pdf',
|
|
271
|
+
mimeType: doc.type ?? 'application/pdf',
|
|
272
|
+
size: doc.size ?? undefined,
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
// Data URI:
|
|
276
|
+
await sdk.attachFile({ source: dataUri, name: 'chart.png' });
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
The returned promise resolves with the attachment **id** as soon as the
|
|
280
|
+
request is accepted and queued. Validation and upload run in the
|
|
281
|
+
background and never block the UI thread — failures surface as
|
|
282
|
+
`attachmentUpdated` with `status: 'failed'` plus an `error` event — and
|
|
283
|
+
the message is never sent until the upload succeeds (send drains only
|
|
284
|
+
uploaded attachments). Track progress and results via
|
|
285
|
+
`attachmentUpdated`:
|
|
286
|
+
|
|
287
|
+
```ts
|
|
288
|
+
sdk.addListener('attachmentUpdated', (a) => {
|
|
289
|
+
// a.status: 'queued' | 'uploading' | 'uploaded' | 'failed' | 'cancelled'
|
|
290
|
+
// a.progress: 0..1 real byte progress
|
|
291
|
+
if (a.status === 'failed') console.warn(a.error);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
sdk.removeAttachment(id); // cancels an in-flight upload
|
|
295
|
+
sdk.clearAttachments();
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
Validation happens before anything is queued: base64 must decode, URIs
|
|
299
|
+
must be readable, files must be non-empty and at most 25 MB, the MIME
|
|
300
|
+
type must be one of png/jpeg/webp/gif/pdf, a mismatched `size` is
|
|
301
|
+
rejected as corruption, and the extension must agree with the MIME
|
|
302
|
+
type. Attachment contents are never written to logs.
|
|
303
|
+
|
|
304
|
+
Allowed MIME types and the 25 MB cap mirror the web widget; keys and
|
|
305
|
+
limits are enforced again server-side.
|
|
306
|
+
|
|
100
307
|
Handoff to a live agent and typing indicators are driven automatically
|
|
101
308
|
by the native chat screen — there's nothing to wire up from the host.
|
|
102
309
|
|
package/android/build.gradle
CHANGED
|
@@ -73,5 +73,5 @@ repositories {
|
|
|
73
73
|
dependencies {
|
|
74
74
|
implementation "com.facebook.react:react-android"
|
|
75
75
|
// Native chat SDK the bridge wraps. Published under cloud.newinstance.
|
|
76
|
-
implementation "cloud.newinstance:liveandaichat:0.
|
|
76
|
+
implementation "cloud.newinstance:liveandaichat:0.3.0"
|
|
77
77
|
}
|
|
@@ -4,13 +4,21 @@ import cloud.newinstance.liveandaichat.ConnectionState
|
|
|
4
4
|
import cloud.newinstance.liveandaichat.LiveAndAiChat
|
|
5
5
|
import cloud.newinstance.liveandaichat.LiveAndAiChatConfig
|
|
6
6
|
import cloud.newinstance.liveandaichat.LiveAndAiChatError
|
|
7
|
+
import cloud.newinstance.liveandaichat.ChatSdkError
|
|
8
|
+
import cloud.newinstance.liveandaichat.ChatThemeOverride
|
|
7
9
|
import cloud.newinstance.liveandaichat.ChatUser
|
|
10
|
+
import cloud.newinstance.liveandaichat.ErrorType
|
|
11
|
+
import cloud.newinstance.liveandaichat.model.AttachmentRequest
|
|
12
|
+
import cloud.newinstance.liveandaichat.model.AttachmentSource
|
|
13
|
+
import cloud.newinstance.liveandaichat.model.ChatCloseEvent
|
|
8
14
|
import cloud.newinstance.liveandaichat.model.ChatMessage
|
|
9
15
|
import cloud.newinstance.liveandaichat.model.TransportMode
|
|
16
|
+
import cloud.newinstance.liveandaichat.upload.QueuedAttachment
|
|
10
17
|
import com.facebook.react.bridge.Arguments
|
|
11
18
|
import com.facebook.react.bridge.Promise
|
|
12
19
|
import com.facebook.react.bridge.ReactApplicationContext
|
|
13
20
|
import com.facebook.react.bridge.ReadableMap
|
|
21
|
+
import com.facebook.react.bridge.ReadableType
|
|
14
22
|
import com.facebook.react.bridge.WritableMap
|
|
15
23
|
import com.facebook.react.modules.core.DeviceEventManagerModule
|
|
16
24
|
|
|
@@ -35,6 +43,13 @@ class NewinstanceChatModule(reactContext: ReactApplicationContext) :
|
|
|
35
43
|
private var sdk: LiveAndAiChat? = null
|
|
36
44
|
private var pendingUser: ChatUser? = null
|
|
37
45
|
|
|
46
|
+
/**
|
|
47
|
+
* Whether the host identified the customer with a chat identity token.
|
|
48
|
+
* Used only to tell an API-key rejection apart from a token rejection when
|
|
49
|
+
* reporting an auth failure.
|
|
50
|
+
*/
|
|
51
|
+
private var usingIdentityToken: Boolean = false
|
|
52
|
+
|
|
38
53
|
override fun getName(): String = NAME
|
|
39
54
|
|
|
40
55
|
private fun emit(event: String, body: Any?) {
|
|
@@ -62,12 +77,27 @@ class NewinstanceChatModule(reactContext: ReactApplicationContext) :
|
|
|
62
77
|
}
|
|
63
78
|
} else null
|
|
64
79
|
|
|
80
|
+
// Local theme arrives as a flat string map; unknown keys are ignored by
|
|
81
|
+
// ChatThemeOverride.fromMap so a newer JS SDK cannot break this bridge.
|
|
82
|
+
val themeOverride = if (config.hasKey("theme")) {
|
|
83
|
+
config.getMap("theme")?.let { map ->
|
|
84
|
+
val values = mutableMapOf<String, String?>()
|
|
85
|
+
val it = map.keySetIterator()
|
|
86
|
+
while (it.hasNextKey()) {
|
|
87
|
+
val key = it.nextKey()
|
|
88
|
+
if (map.getType(key) == ReadableType.String) values[key] = map.getString(key)
|
|
89
|
+
}
|
|
90
|
+
ChatThemeOverride.fromMap(values)
|
|
91
|
+
}
|
|
92
|
+
} else null
|
|
93
|
+
|
|
65
94
|
try {
|
|
66
95
|
val cfg = LiveAndAiChatConfig(
|
|
67
96
|
apiKey = apiKey,
|
|
68
97
|
baseUrl = baseUrl ?: LiveAndAiChatConfig.DEFAULT_BASE_URL,
|
|
69
98
|
transport = transport,
|
|
70
99
|
initialMessage = initialMessage,
|
|
100
|
+
theme = themeOverride,
|
|
71
101
|
)
|
|
72
102
|
sdk?.destroy()
|
|
73
103
|
val builder = LiveAndAiChat.Builder(reactApplicationContext).config(cfg)
|
|
@@ -83,13 +113,18 @@ class NewinstanceChatModule(reactContext: ReactApplicationContext) :
|
|
|
83
113
|
}
|
|
84
114
|
|
|
85
115
|
override fun setUser(user: ReadableMap) {
|
|
86
|
-
val
|
|
87
|
-
|
|
116
|
+
val token = if (user.hasKey("customerToken")) user.getString("customerToken") else null
|
|
117
|
+
// With a token the display name is irrelevant: the backend replaces the
|
|
118
|
+
// whole identity from the token's signed claims.
|
|
119
|
+
val name = user.getString("customerName")?.takeIf { it.isNotBlank() }
|
|
120
|
+
?: (if (!token.isNullOrBlank()) "Customer" else return)
|
|
88
121
|
val chatUser = ChatUser(
|
|
89
122
|
customerName = name,
|
|
90
123
|
customerId = if (user.hasKey("customerId")) user.getString("customerId") else null,
|
|
91
124
|
customerEmail = if (user.hasKey("customerEmail")) user.getString("customerEmail") else null,
|
|
125
|
+
customerToken = token,
|
|
92
126
|
)
|
|
127
|
+
usingIdentityToken = !token.isNullOrBlank()
|
|
93
128
|
val current = sdk
|
|
94
129
|
if (current != null) {
|
|
95
130
|
current.setUser(chatUser)
|
|
@@ -124,24 +159,57 @@ class NewinstanceChatModule(reactContext: ReactApplicationContext) :
|
|
|
124
159
|
promise.reject("E_NOT_CONFIGURED", "configure() must be called first")
|
|
125
160
|
return
|
|
126
161
|
}
|
|
127
|
-
val uri = opts.getString("uri")
|
|
128
162
|
val name = opts.getString("name")
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
163
|
+
if (name.isNullOrBlank()) {
|
|
164
|
+
promise.reject("E_ARGS", "name is required")
|
|
165
|
+
return
|
|
166
|
+
}
|
|
167
|
+
val sourceValue = opts.getString("source") ?: opts.getString("uri")
|
|
168
|
+
if (sourceValue.isNullOrBlank()) {
|
|
169
|
+
promise.reject("E_ARGS", "source (or legacy uri) is required")
|
|
132
170
|
return
|
|
133
171
|
}
|
|
172
|
+
val sourceType = opts.getString("sourceType") ?: "auto"
|
|
173
|
+
val source = AttachmentSource.fromTypeName(sourceType, sourceValue)
|
|
174
|
+
if (source == null) {
|
|
175
|
+
promise.reject(
|
|
176
|
+
"E_SOURCE",
|
|
177
|
+
if (sourceType == "auto") "Could not detect the attachment source type — pass sourceType explicitly"
|
|
178
|
+
else "Unknown sourceType: $sourceType",
|
|
179
|
+
)
|
|
180
|
+
return
|
|
181
|
+
}
|
|
182
|
+
val mimeType = opts.getString("mimeType")
|
|
183
|
+
val declaredSize = if (opts.hasKey("size") && !opts.isNull("size")) opts.getDouble("size").toLong() else null
|
|
184
|
+
val metadata = mutableMapOf<String, String>()
|
|
185
|
+
if (opts.hasKey("metadata") && !opts.isNull("metadata")) {
|
|
186
|
+
val map = opts.getMap("metadata")
|
|
187
|
+
map?.entryIterator?.forEach { (key, value) -> metadata[key] = value.toString() }
|
|
188
|
+
}
|
|
134
189
|
try {
|
|
135
|
-
val
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
190
|
+
val id = current.attach(
|
|
191
|
+
AttachmentRequest(
|
|
192
|
+
source = source,
|
|
193
|
+
name = name,
|
|
194
|
+
mimeType = mimeType,
|
|
195
|
+
declaredSize = declaredSize,
|
|
196
|
+
metadata = metadata,
|
|
197
|
+
),
|
|
198
|
+
)
|
|
199
|
+
promise.resolve(id)
|
|
140
200
|
} catch (e: Exception) {
|
|
141
|
-
promise.reject("
|
|
201
|
+
promise.reject("E_ATTACH", e.message, e)
|
|
142
202
|
}
|
|
143
203
|
}
|
|
144
204
|
|
|
205
|
+
override fun removeAttachment(id: String) {
|
|
206
|
+
sdk?.removeAttachment(id)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
override fun clearAttachments() {
|
|
210
|
+
sdk?.clearAttachments()
|
|
211
|
+
}
|
|
212
|
+
|
|
145
213
|
override fun destroy() {
|
|
146
214
|
sdk?.destroy()
|
|
147
215
|
sdk = null
|
|
@@ -170,14 +238,79 @@ class NewinstanceChatModule(reactContext: ReactApplicationContext) :
|
|
|
170
238
|
override fun onUnreadCountChange(count: Int) {
|
|
171
239
|
emit("unreadCountChanged", count)
|
|
172
240
|
}
|
|
241
|
+
override fun onAttachmentUpdate(attachment: QueuedAttachment) {
|
|
242
|
+
val map = Arguments.createMap()
|
|
243
|
+
map.putString("id", attachment.id)
|
|
244
|
+
map.putString("name", attachment.name)
|
|
245
|
+
map.putString("mimeType", attachment.mimeType)
|
|
246
|
+
map.putDouble("size", attachment.size.toDouble())
|
|
247
|
+
map.putString("status", attachment.status.name.lowercase())
|
|
248
|
+
map.putDouble("progress", attachment.progress.toDouble())
|
|
249
|
+
attachment.publicUrl?.let { map.putString("url", it) }
|
|
250
|
+
attachment.errorReason?.let { map.putString("error", it) }
|
|
251
|
+
emit("attachmentUpdated", map)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
override fun onChatClosed(event: ChatCloseEvent) {
|
|
255
|
+
val map = Arguments.createMap()
|
|
256
|
+
map.putString("reason", event.reason.wire)
|
|
257
|
+
map.putString("initiator", event.initiator.wire)
|
|
258
|
+
map.putDouble("timestamp", event.timestamp.toDouble())
|
|
259
|
+
event.conversationId?.let { map.putString("conversationId", it) }
|
|
260
|
+
event.assignmentId?.let { map.putString("assignmentId", it) }
|
|
261
|
+
map.putString("channel", event.channel)
|
|
262
|
+
map.putString("previousState", event.previousState)
|
|
263
|
+
map.putInt("unreadCount", event.unreadCount)
|
|
264
|
+
map.putBoolean("hasDraft", event.hasDraft)
|
|
265
|
+
map.putInt("pendingAttachmentCount", event.pendingAttachmentCount)
|
|
266
|
+
val meta = Arguments.createMap()
|
|
267
|
+
event.metadata.forEach { (k, v) -> meta.putString(k, v) }
|
|
268
|
+
map.putMap("metadata", meta)
|
|
269
|
+
emit("chatClosed", map)
|
|
270
|
+
}
|
|
271
|
+
|
|
173
272
|
override fun onError(error: LiveAndAiChatError) {
|
|
174
273
|
val map = Arguments.createMap()
|
|
274
|
+
map.putString("code", sdkErrorCode(error))
|
|
175
275
|
map.putString("type", error.type.name.lowercase())
|
|
176
276
|
map.putString("message", error.message)
|
|
177
277
|
map.putBoolean("recoverable", error.recoverable)
|
|
178
278
|
emit("error", map)
|
|
179
279
|
}
|
|
180
280
|
|
|
281
|
+
/**
|
|
282
|
+
* Normalise onto the cross-platform code vocabulary. `error.code` carries
|
|
283
|
+
* the raw GraphQL/transport code when the native SDK has one; otherwise the
|
|
284
|
+
* broad type decides. An auth failure while a chat identity token is in use
|
|
285
|
+
* is reported as a token problem, which is the difference between
|
|
286
|
+
* "rotate your key" and "mint a fresh token".
|
|
287
|
+
*/
|
|
288
|
+
private fun sdkErrorCode(error: LiveAndAiChatError): String {
|
|
289
|
+
val known = setOf(
|
|
290
|
+
ChatSdkError.CHAT_CONFIG_UNAVAILABLE,
|
|
291
|
+
ChatSdkError.CHAT_DISABLED,
|
|
292
|
+
ChatSdkError.INVALID_PUBLIC_KEY,
|
|
293
|
+
ChatSdkError.MISSING_WIDGET_KEY,
|
|
294
|
+
ChatSdkError.CONFIG_FETCH_FAILED,
|
|
295
|
+
ChatSdkError.CHAT_INITIALIZATION_FAILED,
|
|
296
|
+
ChatSdkError.NETWORK_ERROR,
|
|
297
|
+
ChatSdkError.SERVER_ERROR,
|
|
298
|
+
ChatSdkError.INVALID_IDENTITY_TOKEN,
|
|
299
|
+
ChatSdkError.TRANSPORT_ERROR,
|
|
300
|
+
ChatSdkError.MESSAGE_SEND_FAILED,
|
|
301
|
+
ChatSdkError.ATTACHMENT_FAILED,
|
|
302
|
+
)
|
|
303
|
+
error.code?.let { if (it in known) return it }
|
|
304
|
+
return when (error.type) {
|
|
305
|
+
ErrorType.AUTH ->
|
|
306
|
+
if (usingIdentityToken) ChatSdkError.INVALID_IDENTITY_TOKEN else ChatSdkError.INVALID_PUBLIC_KEY
|
|
307
|
+
ErrorType.UNAVAILABLE -> ChatSdkError.CHAT_CONFIG_UNAVAILABLE
|
|
308
|
+
ErrorType.NETWORK -> ChatSdkError.NETWORK_ERROR
|
|
309
|
+
ErrorType.VALIDATION -> ChatSdkError.SERVER_ERROR
|
|
310
|
+
ErrorType.SYSTEM -> ChatSdkError.CHAT_INITIALIZATION_FAILED
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
181
314
|
// MARK: - Helpers
|
|
182
315
|
|
|
183
316
|
private fun chatMessageToMap(m: ChatMessage): WritableMap {
|
package/ios/NewinstanceChat.mm
CHANGED
|
@@ -76,6 +76,16 @@ RCT_EXPORT_MODULE()
|
|
|
76
76
|
[_impl attachFile:opts resolver:resolve rejecter:reject];
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
- (void)removeAttachment:(NSString *)attachmentId
|
|
80
|
+
{
|
|
81
|
+
[_impl removeAttachment:attachmentId];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
- (void)clearAttachments
|
|
85
|
+
{
|
|
86
|
+
[_impl clearAttachments];
|
|
87
|
+
}
|
|
88
|
+
|
|
79
89
|
// NativeEventEmitter wires these so it can track subscriber counts. The
|
|
80
90
|
// actual event payloads flow through RCTDeviceEventEmitter, so these are
|
|
81
91
|
// intentional no-ops.
|
|
@@ -29,6 +29,11 @@ public final class NewinstanceChatImpl: NSObject, LiveAndAiChatDelegate {
|
|
|
29
29
|
private var sdk: LiveAndAiChat?
|
|
30
30
|
private var pendingUser: ChatUser?
|
|
31
31
|
|
|
32
|
+
/// Whether the host identified the customer with a chat identity token.
|
|
33
|
+
/// Used only to tell an API-key rejection apart from a token rejection when
|
|
34
|
+
/// reporting an auth failure.
|
|
35
|
+
private var usingIdentityToken: Bool = false
|
|
36
|
+
|
|
32
37
|
// MARK: - configure / setUser / initialize
|
|
33
38
|
|
|
34
39
|
@objc(configure:resolver:rejecter:)
|
|
@@ -49,6 +54,17 @@ public final class NewinstanceChatImpl: NSObject, LiveAndAiChatDelegate {
|
|
|
49
54
|
}
|
|
50
55
|
}()
|
|
51
56
|
|
|
57
|
+
// Local theme arrives as a flat string dictionary; unknown keys are
|
|
58
|
+
// ignored by fromDictionary so a newer JS SDK cannot break this bridge.
|
|
59
|
+
let themeOverride: ChatThemeOverride? = {
|
|
60
|
+
guard let raw = configDict["theme"] as? NSDictionary else { return nil }
|
|
61
|
+
var values: [String: String] = [:]
|
|
62
|
+
for (key, value) in raw {
|
|
63
|
+
if let k = key as? String, let v = value as? String { values[k] = v }
|
|
64
|
+
}
|
|
65
|
+
return values.isEmpty ? nil : ChatThemeOverride.fromDictionary(values)
|
|
66
|
+
}()
|
|
67
|
+
|
|
52
68
|
DispatchQueue.main.async { [weak self] in
|
|
53
69
|
guard let self = self else { return }
|
|
54
70
|
do {
|
|
@@ -56,7 +72,8 @@ public final class NewinstanceChatImpl: NSObject, LiveAndAiChatDelegate {
|
|
|
56
72
|
apiKey: apiKey,
|
|
57
73
|
baseUrl: baseUrl ?? LiveAndAiChatConfig.defaultBaseUrl,
|
|
58
74
|
transport: transport,
|
|
59
|
-
initialMessage: initialMessage
|
|
75
|
+
initialMessage: initialMessage,
|
|
76
|
+
theme: themeOverride
|
|
60
77
|
)
|
|
61
78
|
let builder = LiveAndAiChat.Builder().config(cfg)
|
|
62
79
|
if let user = self.pendingUser { _ = builder.user(user) }
|
|
@@ -76,14 +93,20 @@ public final class NewinstanceChatImpl: NSObject, LiveAndAiChatDelegate {
|
|
|
76
93
|
|
|
77
94
|
@objc(setUser:)
|
|
78
95
|
public func setUser(_ userDict: NSDictionary) {
|
|
79
|
-
|
|
96
|
+
let token = (userDict["customerToken"] as? String).flatMap { $0.isEmpty ? nil : $0 }
|
|
97
|
+
// With a token the display name is irrelevant: the backend replaces the
|
|
98
|
+
// whole identity from the token's signed claims.
|
|
99
|
+
let providedName = (userDict["customerName"] as? String).flatMap { $0.isEmpty ? nil : $0 }
|
|
100
|
+
guard let name = providedName ?? (token != nil ? "Customer" : nil) else { return }
|
|
80
101
|
let user = ChatUser(
|
|
81
102
|
customerName: name,
|
|
82
103
|
customerId: userDict["customerId"] as? String,
|
|
83
|
-
customerEmail: userDict["customerEmail"] as? String
|
|
104
|
+
customerEmail: userDict["customerEmail"] as? String,
|
|
105
|
+
customerToken: token
|
|
84
106
|
)
|
|
85
107
|
DispatchQueue.main.async { [weak self] in
|
|
86
108
|
guard let self = self else { return }
|
|
109
|
+
self.usingIdentityToken = token != nil
|
|
87
110
|
if let sdk = self.sdk {
|
|
88
111
|
sdk.setUser(user)
|
|
89
112
|
} else {
|
|
@@ -140,26 +163,53 @@ public final class NewinstanceChatImpl: NSObject, LiveAndAiChatDelegate {
|
|
|
140
163
|
rejecter("E_NOT_CONFIGURED", "configure() must be called first", nil)
|
|
141
164
|
return
|
|
142
165
|
}
|
|
143
|
-
guard let
|
|
144
|
-
|
|
145
|
-
let mimeType = opts["mimeType"] as? String,
|
|
146
|
-
let url = URL(string: uri) else {
|
|
147
|
-
rejecter("E_ARGS", "uri, name, mimeType are required", nil)
|
|
166
|
+
guard let name = opts["name"] as? String, !name.isEmpty else {
|
|
167
|
+
rejecter("E_ARGS", "name is required", nil)
|
|
148
168
|
return
|
|
149
169
|
}
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
170
|
+
guard let sourceValue = (opts["source"] as? String) ?? (opts["uri"] as? String), !sourceValue.isEmpty else {
|
|
171
|
+
rejecter("E_ARGS", "source (or legacy uri) is required", nil)
|
|
172
|
+
return
|
|
173
|
+
}
|
|
174
|
+
let sourceType = (opts["sourceType"] as? String) ?? "auto"
|
|
175
|
+
guard let source = AttachmentSource.fromTypeName(sourceType, value: sourceValue) else {
|
|
176
|
+
rejecter(
|
|
177
|
+
"E_SOURCE",
|
|
178
|
+
sourceType == "auto"
|
|
179
|
+
? "Could not detect the attachment source type — pass sourceType explicitly"
|
|
180
|
+
: "Unknown sourceType: \(sourceType)",
|
|
181
|
+
nil
|
|
182
|
+
)
|
|
183
|
+
return
|
|
184
|
+
}
|
|
185
|
+
let mimeType = opts["mimeType"] as? String
|
|
186
|
+
let declaredSize = (opts["size"] as? NSNumber)?.int64Value
|
|
187
|
+
var metadata: [String: String] = [:]
|
|
188
|
+
if let raw = opts["metadata"] as? [String: Any] {
|
|
189
|
+
for (key, value) in raw { metadata[key] = String(describing: value) }
|
|
190
|
+
}
|
|
191
|
+
let request = AttachmentRequest(
|
|
192
|
+
source: source,
|
|
193
|
+
name: name,
|
|
194
|
+
mimeType: mimeType,
|
|
195
|
+
declaredSize: declaredSize,
|
|
196
|
+
metadata: metadata
|
|
197
|
+
)
|
|
198
|
+
DispatchQueue.main.async {
|
|
199
|
+
let id = sdk.attach(request)
|
|
200
|
+
resolver(id)
|
|
160
201
|
}
|
|
161
202
|
}
|
|
162
203
|
|
|
204
|
+
@objc(removeAttachment:)
|
|
205
|
+
public func removeAttachment(_ id: String) {
|
|
206
|
+
DispatchQueue.main.async { [weak self] in self?.sdk?.removeAttachment(id: id) }
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
@objc public func clearAttachments() {
|
|
210
|
+
DispatchQueue.main.async { [weak self] in self?.sdk?.clearAttachments() }
|
|
211
|
+
}
|
|
212
|
+
|
|
163
213
|
@objc public func destroy() {
|
|
164
214
|
DispatchQueue.main.async { [weak self] in
|
|
165
215
|
guard let self = self else { return }
|
|
@@ -171,6 +221,41 @@ public final class NewinstanceChatImpl: NSObject, LiveAndAiChatDelegate {
|
|
|
171
221
|
|
|
172
222
|
// MARK: - LiveAndAiChatDelegate
|
|
173
223
|
|
|
224
|
+
public func unreadCountDidChange(_ count: Int) {
|
|
225
|
+
emitter?.emitEvent("unreadCountChanged", body: count)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
public func attachmentDidUpdate(_ attachment: QueuedAttachment) {
|
|
229
|
+
var body: [String: Any] = [
|
|
230
|
+
"id": attachment.id,
|
|
231
|
+
"name": attachment.name,
|
|
232
|
+
"mimeType": attachment.mimeType,
|
|
233
|
+
"size": attachment.size,
|
|
234
|
+
"status": attachment.status.rawValue,
|
|
235
|
+
"progress": attachment.progress,
|
|
236
|
+
]
|
|
237
|
+
if let url = attachment.publicUrl { body["url"] = url }
|
|
238
|
+
if let error = attachment.errorReason { body["error"] = error }
|
|
239
|
+
emitter?.emitEvent("attachmentUpdated", body: body)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
public func chatDidClose(_ event: ChatCloseEvent) {
|
|
243
|
+
var body: [String: Any] = [
|
|
244
|
+
"reason": event.reason.rawValue,
|
|
245
|
+
"initiator": event.initiator.rawValue,
|
|
246
|
+
"timestamp": event.timestamp,
|
|
247
|
+
"channel": event.channel,
|
|
248
|
+
"previousState": event.previousState,
|
|
249
|
+
"unreadCount": event.unreadCount,
|
|
250
|
+
"hasDraft": event.hasDraft,
|
|
251
|
+
"pendingAttachmentCount": event.pendingAttachmentCount,
|
|
252
|
+
"metadata": event.metadata,
|
|
253
|
+
]
|
|
254
|
+
if let id = event.conversationId { body["conversationId"] = id }
|
|
255
|
+
if let id = event.assignmentId { body["assignmentId"] = id }
|
|
256
|
+
emitter?.emitEvent("chatClosed", body: body)
|
|
257
|
+
}
|
|
258
|
+
|
|
174
259
|
public func didReceiveMessage(_ message: ChatMessage) {
|
|
175
260
|
emitter?.emitEvent("messageReceived", body: Self.chatMessageToDict(message))
|
|
176
261
|
}
|
|
@@ -185,6 +270,7 @@ public final class NewinstanceChatImpl: NSObject, LiveAndAiChatDelegate {
|
|
|
185
270
|
}
|
|
186
271
|
public func didEncounterError(_ error: LiveAndAiChatError) {
|
|
187
272
|
emitter?.emitEvent("error", body: [
|
|
273
|
+
"code": error.sdkCode(usingIdentityToken: usingIdentityToken),
|
|
188
274
|
"type": Self.errorTypeName(error.type),
|
|
189
275
|
"message": error.message,
|
|
190
276
|
"recoverable": error.recoverable,
|