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.
@@ -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.1.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`. See `src/types.ts`
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
 
@@ -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.1.1"
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 name = user.getString("customerName") ?: return
87
- if (name.isBlank()) return
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
- val mimeType = opts.getString("mimeType")
130
- if (uri == null || name == null || mimeType == null) {
131
- promise.reject("E_ARGS", "uri, name, mimeType are required")
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 bytes = reactApplicationContext.contentResolver
136
- .openInputStream(android.net.Uri.parse(uri))?.use { it.readBytes() }
137
- ?: throw java.io.IOException("Could not open content URI: $uri")
138
- current.attachFile(bytes, name, mimeType, uri)
139
- promise.resolve(null)
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("E_READ", e.message, e)
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 {
@@ -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
- guard let name = userDict["customerName"] as? String, !name.isEmpty else { return }
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 uri = opts["uri"] as? String,
144
- let name = opts["name"] as? String,
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
- DispatchQueue.global(qos: .userInitiated).async {
151
- do {
152
- let data = try Data(contentsOf: url)
153
- DispatchQueue.main.async {
154
- sdk.attachFile(data: data, name: name, mimeType: mimeType, previewUri: uri)
155
- resolver(nil)
156
- }
157
- } catch {
158
- rejecter("E_READ", "Failed to read file: \(error.localizedDescription)", error)
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,