react-native-hubspot-wrapper 0.3.0 → 0.4.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 CHANGED
@@ -12,7 +12,7 @@ Early release (`0.x`). The public API may evolve before `1.0`.
12
12
  Currently supported:
13
13
 
14
14
  - `initialize()`
15
- - `openChat(chatflow)`
15
+ - `openChat(chatflow, options?)`
16
16
  - `setIdentity({ identityToken, email? })`
17
17
  - `setProperties(properties)`
18
18
  - `clearUserData()`
@@ -61,14 +61,39 @@ await HubspotWrapper.setProperties([{ name: 'plan', value: 'pro' }]);
61
61
  await HubspotWrapper.openChat('support');
62
62
  ```
63
63
 
64
+ By default, `openChat` hides HubSpot's "back to inbox" / conversations-list
65
+ button inside the chat widget. This wrapper is opinionated toward single-chat
66
+ support flows, but you can preserve HubSpot's stock UI per chat:
67
+
68
+ ```ts
69
+ await HubspotWrapper.openChat('support', {
70
+ hideBackToInboxButton: false,
71
+ });
72
+ ```
73
+
64
74
  ## API
65
75
 
66
76
  - `initialize(): Promise<void>`
67
- - `openChat(chatflow: string): Promise<void>`
77
+ - `openChat(chatflow: string, options?: { hideBackToInboxButton?: boolean }): Promise<void>`
68
78
  - `setIdentity({ identityToken, email? }): Promise<void>`
69
79
  - `setProperties(properties): Promise<void>`
70
80
  - `clearUserData(): Promise<void>`
71
81
 
82
+ ### `openChat(chatflow, options?)`
83
+
84
+ Opens the HubSpot chat UI for the provided chatflow.
85
+
86
+ - `hideBackToInboxButton` defaults to `true`.
87
+ - Set `hideBackToInboxButton: false` to keep HubSpot's default conversations
88
+ navigation visible.
89
+
90
+ ### `clearUserData()`
91
+
92
+ Clears the native SDK's current user/session state and waits for HubSpot chat
93
+ visitor cookies/data cleanup to finish before resolving. This is intended for
94
+ logout or account-switch flows so the next chat session does not reuse the
95
+ previous visitor identity.
96
+
72
97
  ## iOS SDK source strategy
73
98
 
74
99
  This package vendors HubSpot iOS SDK source files under `ios/HubspotMobileSDK`.
@@ -0,0 +1,279 @@
1
+ package com.marcinolek.reactnativehubspotwrapper
2
+
3
+ import android.app.Activity
4
+ import android.app.Application
5
+ import android.os.Bundle
6
+ import android.os.Handler
7
+ import android.os.Looper
8
+ import android.util.Log
9
+ import android.view.View
10
+ import android.view.ViewGroup
11
+ import android.webkit.WebView
12
+ import java.util.WeakHashMap
13
+
14
+ /**
15
+ * Hides the HubSpot chat widget's "back to inbox" / conversations-list navigation
16
+ * inside [HubspotWebActivity] (which lives in HubSpot's compiled `.aar` and which
17
+ * we therefore can't modify directly).
18
+ *
19
+ * Approach:
20
+ *
21
+ * 1. We register a single [Application.ActivityLifecycleCallbacks] from the wrapper
22
+ * module's init.
23
+ * 2. Each time `com.hubspot.mobilesdk.HubspotWebActivity` resumes, we depth-first
24
+ * walk its decor view to find the `WebView`.
25
+ * 3. We `evaluateJavascript` a heuristic-based hider that walks all same-origin
26
+ * sub-frames (the chat UI itself lives in an iframe on `app-na2.hubspot.com`)
27
+ * and hides any `<button>`/`<a>`/`role="button"` whose accessible label,
28
+ * `data-test-id`, title, id, class or short visible text normalizes (camelCase
29
+ * -> spaces, `_-` -> spaces, lowercase) to something matching
30
+ * `\b(back|inbox|previous|conversations)\b`. A `MutationObserver` is then
31
+ * attached so late-mounted buttons get hidden too.
32
+ * 4. We re-fire `evaluateJavascript` periodically for a few seconds so the iframe
33
+ * walk catches the chat iframe as soon as it mounts (it's loaded asynchronously
34
+ * by HubSpot's `project.js` bundle after the activity opens).
35
+ *
36
+ * The implementation is intentionally identical in spirit to the iOS hider in
37
+ * `HubspotWrapperImpl.swift` so the same JS works on both platforms.
38
+ */
39
+ internal object HubspotBackButtonHider {
40
+ private const val TAG = "HubspotWrapper"
41
+
42
+ const val EXTRA_HIDE_BACK_TO_INBOX_BUTTON =
43
+ "com.marcinolek.reactnativehubspotwrapper.HIDE_BACK_TO_INBOX_BUTTON"
44
+
45
+ /**
46
+ * The fully-qualified name of HubSpot's chat activity in the AAR. Matched by
47
+ * string so we don't have to import / depend on it for compile-time resolution.
48
+ */
49
+ private const val WEB_ACTIVITY_NAME = "com.hubspot.mobilesdk.HubspotWebActivity"
50
+
51
+ /** How often we re-fire `evaluateJavascript` to catch late-loaded iframes. */
52
+ private const val RESCAN_INTERVAL_MS = 250L
53
+
54
+ /**
55
+ * Total time we keep re-firing after the activity resumes. The chat iframe
56
+ * normally appears within ~2s; we give a generous margin and then stop to
57
+ * avoid burning CPU forever if the user leaves the chat open for a long time.
58
+ */
59
+ private const val MAX_SCAN_DURATION_MS = 10_000L
60
+
61
+ @Volatile
62
+ private var installed = false
63
+
64
+ private val handler = Handler(Looper.getMainLooper())
65
+ private val scheduledInjections = WeakHashMap<WebView, Runnable>()
66
+
67
+ @Synchronized
68
+ fun installOnce(app: Application) {
69
+ if (installed) return
70
+ installed = true
71
+
72
+ app.registerActivityLifecycleCallbacks(object : Application.ActivityLifecycleCallbacks {
73
+ override fun onActivityResumed(activity: Activity) {
74
+ if (activity.javaClass.name != WEB_ACTIVITY_NAME) return
75
+ if (!activity.intent.getBooleanExtra(EXTRA_HIDE_BACK_TO_INBOX_BUTTON, true)) {
76
+ cancelHiderInjection(findWebView(activity.window.decorView))
77
+ return
78
+ }
79
+ val webView = findWebView(activity.window.decorView)
80
+ if (webView == null) {
81
+ Log.w(TAG, "installBackButtonHider: WebView not found in $WEB_ACTIVITY_NAME view tree")
82
+ return
83
+ }
84
+ Log.i(TAG, "installBackButtonHider: scheduling JS injection on $webView")
85
+ scheduleHiderInjection(webView)
86
+ }
87
+
88
+ override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {}
89
+ override fun onActivityStarted(activity: Activity) {}
90
+ override fun onActivityPaused(activity: Activity) {
91
+ if (activity.javaClass.name == WEB_ACTIVITY_NAME) {
92
+ cancelHiderInjection(findWebView(activity.window.decorView))
93
+ }
94
+ }
95
+ override fun onActivityStopped(activity: Activity) {}
96
+ override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {}
97
+ override fun onActivityDestroyed(activity: Activity) {
98
+ if (activity.javaClass.name == WEB_ACTIVITY_NAME) {
99
+ cancelHiderInjection(findWebView(activity.window.decorView))
100
+ }
101
+ }
102
+ })
103
+ }
104
+
105
+ private fun findWebView(view: View?): WebView? {
106
+ if (view is WebView) return view
107
+ if (view is ViewGroup) {
108
+ for (i in 0 until view.childCount) {
109
+ val found = findWebView(view.getChildAt(i))
110
+ if (found != null) return found
111
+ }
112
+ }
113
+ return null
114
+ }
115
+
116
+ private fun scheduleHiderInjection(webView: WebView) {
117
+ cancelHiderInjection(webView)
118
+
119
+ val start = System.currentTimeMillis()
120
+ val runnable = object : Runnable {
121
+ override fun run() {
122
+ try {
123
+ webView.evaluateJavascript(BACK_BUTTON_HIDER_JS, null)
124
+ } catch (error: Exception) {
125
+ Log.w(TAG, "evaluateJavascript failed", error)
126
+ }
127
+ if (System.currentTimeMillis() - start < MAX_SCAN_DURATION_MS) {
128
+ handler.postDelayed(this, RESCAN_INTERVAL_MS)
129
+ } else {
130
+ scheduledInjections.remove(webView)
131
+ }
132
+ }
133
+ }
134
+ scheduledInjections[webView] = runnable
135
+ handler.post(runnable)
136
+ }
137
+
138
+ private fun cancelHiderInjection(webView: WebView?) {
139
+ if (webView == null) return
140
+ val runnable = scheduledInjections.remove(webView) ?: return
141
+ handler.removeCallbacks(runnable)
142
+ }
143
+
144
+ /**
145
+ * Heuristic-based hider for the chat widget's back/inbox button. Identical in
146
+ * shape to the iOS version in `HubspotWrapperImpl.swift`. Designed to be safe
147
+ * to invoke many times: a `__rnHubspotHiderInstalled` per-document flag short-
148
+ * circuits re-installation, while the periodic re-fire from
149
+ * [scheduleHiderInjection] still allows newly-mounted iframes to be picked up.
150
+ */
151
+ private const val BACK_BUTTON_HIDER_JS = """
152
+ (function() {
153
+ function bootstrap(win) {
154
+ var doc;
155
+ try { doc = win.document; } catch (_) { return; }
156
+ if (!doc || doc.__rnHubspotHiderInstalled) return;
157
+ doc.__rnHubspotHiderInstalled = true;
158
+
159
+ var BACK_PATTERN = /\b(back|inbox|previous|conversations)\b/i;
160
+
161
+ function normalize(s) {
162
+ if (!s) return '';
163
+ return String(s)
164
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
165
+ .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
166
+ .replace(/[_-]+/g, ' ')
167
+ .toLowerCase();
168
+ }
169
+
170
+ function looksLikeBackControl(el) {
171
+ if (!el || el.nodeType !== 1) return false;
172
+ if (el.getAttribute('data-rn-hubspot-wrapper-hidden') === '1') return false;
173
+ var tag = el.tagName;
174
+ if (tag !== 'BUTTON' && tag !== 'A' && el.getAttribute('role') !== 'button') return false;
175
+ var attrs = [
176
+ el.getAttribute('aria-label'),
177
+ el.getAttribute('data-test-id'),
178
+ el.getAttribute('data-test'),
179
+ el.getAttribute('title'),
180
+ el.getAttribute('id'),
181
+ el.getAttribute('class')
182
+ ];
183
+ for (var i = 0; i < attrs.length; i++) {
184
+ if (attrs[i] && BACK_PATTERN.test(normalize(attrs[i]))) return true;
185
+ }
186
+ var text = (el.textContent || '').trim();
187
+ if (text && text.length <= 32 && BACK_PATTERN.test(normalize(text))) return true;
188
+ return false;
189
+ }
190
+
191
+ function hide(el) {
192
+ try {
193
+ el.setAttribute('data-rn-hubspot-wrapper-hidden', '1');
194
+ el.style.setProperty('display', 'none', 'important');
195
+ el.style.setProperty('visibility', 'hidden', 'important');
196
+ el.style.setProperty('pointer-events', 'none', 'important');
197
+ } catch (_) {}
198
+ }
199
+
200
+ // Hide `el`, then walk up to a few ancestors and also hide any wrapper
201
+ // that becomes empty (no remaining unhidden element children) as a
202
+ // result. HubSpot's chat header has a back-button slot whose padding
203
+ // / min-width / flex-basis stays around even when the inner button is
204
+ // `display:none`, which shows up as the avatar shifting right after the
205
+ // first message is sent. Capping at depth 3 keeps us inside the header.
206
+ function hideUpwards(el) {
207
+ hide(el);
208
+ var current = el;
209
+ for (var depth = 0; depth < 3; depth++) {
210
+ var parent = current.parentElement;
211
+ if (!parent) break;
212
+ if (parent === doc.documentElement || parent === doc.body) break;
213
+ var visibleCount = 0;
214
+ var children = parent.children;
215
+ for (var i = 0; i < children.length; i++) {
216
+ if (children[i].getAttribute('data-rn-hubspot-wrapper-hidden') !== '1') {
217
+ visibleCount++;
218
+ }
219
+ }
220
+ if (visibleCount > 0) break;
221
+ hide(parent);
222
+ current = parent;
223
+ }
224
+ }
225
+
226
+ var observed = new WeakSet();
227
+ function scanRoot(root) {
228
+ if (!root) return;
229
+ try {
230
+ var nodes = root.querySelectorAll('button, a, [role="button"]');
231
+ for (var i = 0; i < nodes.length; i++) {
232
+ if (looksLikeBackControl(nodes[i])) hideUpwards(nodes[i]);
233
+ }
234
+ var all = root.querySelectorAll('*');
235
+ for (var j = 0; j < all.length; j++) {
236
+ var sr = all[j].shadowRoot;
237
+ if (sr) {
238
+ scanRoot(sr);
239
+ attachObserver(sr);
240
+ }
241
+ }
242
+ } catch (_) {}
243
+ }
244
+
245
+ function attachObserver(root) {
246
+ if (!root || observed.has(root)) return;
247
+ observed.add(root);
248
+ try {
249
+ var mo = new MutationObserver(function() { scanRoot(root); });
250
+ mo.observe(root, { childList: true, subtree: true, attributes: true, attributeFilter: ['aria-label', 'data-test-id', 'data-test', 'title', 'class'] });
251
+ } catch (_) {}
252
+ }
253
+
254
+ function go() {
255
+ scanRoot(doc);
256
+ attachObserver(doc.documentElement || doc);
257
+ }
258
+
259
+ if (doc.readyState === 'loading') {
260
+ doc.addEventListener('DOMContentLoaded', go, { once: true });
261
+ } else {
262
+ go();
263
+ }
264
+ }
265
+
266
+ function walkAllFrames(win) {
267
+ if (!win) return;
268
+ bootstrap(win);
269
+ try {
270
+ for (var i = 0; i < win.frames.length; i++) {
271
+ walkAllFrames(win.frames[i]);
272
+ }
273
+ } catch (_) {}
274
+ }
275
+
276
+ walkAllFrames(window);
277
+ })();
278
+ """
279
+ }
@@ -1,5 +1,6 @@
1
1
  package com.marcinolek.reactnativehubspotwrapper
2
2
 
3
+ import android.app.Application
3
4
  import android.content.Intent
4
5
  import android.util.Log
5
6
  import android.webkit.CookieManager
@@ -20,6 +21,14 @@ class HubspotWrapperModule(reactContext: ReactApplicationContext) :
20
21
  private val appContext = reactContext.applicationContext
21
22
  private lateinit var hubspotManager: HubspotManager
22
23
 
24
+ init {
25
+ // Install the chat back-button hider as soon as the wrapper module is created.
26
+ // The hider only does work when `HubspotWebActivity` actually resumes, so this
27
+ // is a cheap one-time `Application.ActivityLifecycleCallbacks` registration.
28
+ // See `HubspotBackButtonHider` for why this lives outside the activity itself.
29
+ (appContext as? Application)?.let { HubspotBackButtonHider.installOnce(it) }
30
+ }
31
+
23
32
  override fun getName(): String = NAME
24
33
 
25
34
  override fun initialize(promise: Promise) {
@@ -33,11 +42,12 @@ class HubspotWrapperModule(reactContext: ReactApplicationContext) :
33
42
  }
34
43
  }
35
44
 
36
- override fun openChat(chatflow: String, promise: Promise) {
45
+ override fun openChat(chatflow: String, hideBackToInboxButton: Boolean, promise: Promise) {
37
46
  try {
38
47
  val intent = Intent(appContext, HubspotWebActivity::class.java)
39
48
  intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
40
49
  intent.putExtra("chatflow", chatflow)
50
+ intent.putExtra(HubspotBackButtonHider.EXTRA_HIDE_BACK_TO_INBOX_BUTTON, hideBackToInboxButton)
41
51
  appContext.startActivity(intent)
42
52
  promise.resolve(null)
43
53
  } catch (error: Exception) {
@@ -349,6 +349,9 @@ struct HubspotChatWebView: UIViewRepresentable {
349
349
 
350
350
  contentController.addUserScript(WKUserScript(source: js, injectionTime: .atDocumentEnd, forMainFrameOnly: false))
351
351
 
352
+ // [react-native-hubspot-wrapper] hide chat "back to inbox" button - see HubspotWrapperImpl
353
+ HubspotWrapperImpl.installBackButtonHider(on: contentController)
354
+
352
355
  // create script that triggers on hubspot event, and calls our message handler
353
356
 
354
357
  let configCallbacksJS = """
@@ -1,9 +1,31 @@
1
1
  import Foundation
2
2
  import SwiftUI
3
3
  import UIKit
4
+ import WebKit
4
5
 
5
6
  @objcMembers
6
7
  public class HubspotWrapperImpl: NSObject {
8
+ private static var shouldHideBackToInboxButton = true
9
+
10
+ /// Substrings used to match `WKWebsiteDataRecord.displayName` (which is the host /
11
+ /// eTLD+1, e.g. `hubspot.com`, `hs-banner.com`, `hsadspixel.net`) for the various
12
+ /// domains the chat widget loads from. Anything matching is wiped on `clearUserData`.
13
+ ///
14
+ /// We deliberately use substrings rather than exact hosts because the widget pulls
15
+ /// resources from a long tail of HubSpot CDNs - `js.hs-banner.com`, `js.hubspot.com`,
16
+ /// `static.hsappstatic.net`, `app-na2.hubspot.com`, `*.hubspotqa.com`, etc. - and any
17
+ /// of those can hold cookies / localStorage / IndexedDB that re-attach a returning
18
+ /// visitor or surface the unsent draft message on the next open.
19
+ private static let chatDataDomainMatches: [String] = [
20
+ "hubspot",
21
+ "hsforms",
22
+ "hs-banner",
23
+ "hs-scripts",
24
+ "hsadspixel",
25
+ "hsappstatic",
26
+ "usemessages",
27
+ ]
28
+
7
29
  public func initialize(_ outError: NSErrorPointer) -> Bool {
8
30
  let semaphore = DispatchSemaphore(value: 0)
9
31
  var configureError: NSError?
@@ -26,7 +48,7 @@ public class HubspotWrapperImpl: NSObject {
26
48
  return true
27
49
  }
28
50
 
29
- public func openChat(_ chatflow: String, error outError: NSErrorPointer) -> Bool {
51
+ public func openChat(_ chatflow: String, hideBackToInboxButton: Bool, error outError: NSErrorPointer) -> Bool {
30
52
  var didSucceed = false
31
53
  let presentBlock = {
32
54
  guard let rootVC = Self.topViewController() else {
@@ -38,6 +60,7 @@ public class HubspotWrapperImpl: NSObject {
38
60
  return
39
61
  }
40
62
 
63
+ Self.shouldHideBackToInboxButton = hideBackToInboxButton
41
64
  let chatView = HubspotChatView(chatFlow: chatflow)
42
65
  let hostingController = UIHostingController(rootView: chatView)
43
66
  rootVC.present(hostingController, animated: true)
@@ -80,12 +103,218 @@ public class HubspotWrapperImpl: NSObject {
80
103
  }
81
104
  }
82
105
 
83
- public func clearUserData() {
84
- Task {
85
- await HubspotManager.shared.clearUserData()
106
+ /// Clear the SDK's in-memory identity/property state and synchronously wait for ALL
107
+ /// HubSpot website data (cookies, localStorage, sessionStorage, IndexedDB, service
108
+ /// workers, etc.) to be removed from the shared `WKWebsiteDataStore` before invoking
109
+ /// `completion`.
110
+ ///
111
+ /// Why we do our own clearing instead of relying on the SDK:
112
+ ///
113
+ /// 1. `HubspotManager.clearUserData()` is synchronous, but the cookie removal it
114
+ /// performs is dispatched into a fire-and-forget `Task { ... }` that we have no
115
+ /// handle to. JS code doing
116
+ /// `await HubspotWrapper.clearUserData(); await HubspotWrapper.openChat(...)`
117
+ /// races the still-in-flight cookie deletion, so the next chat session re-uses
118
+ /// the previous visitor identity.
119
+ ///
120
+ /// 2. Even when the SDK's cookie deletion *does* finish, it only removes the two
121
+ /// visitor-identity cookies (`hubspotutk`, `messagesUtk`). The chat widget also
122
+ /// persists the unsent draft message and other state in `localStorage` /
123
+ /// `sessionStorage` / `IndexedDB`. Cookie-only clearing leaves the draft visible
124
+ /// on the next open, which is exactly the bug we kept seeing.
125
+ ///
126
+ /// We therefore enumerate `WKWebsiteDataStore` records, filter to records whose
127
+ /// host matches a HubSpot domain (see `chatDataDomainMatches`) and remove ALL data
128
+ /// types for those records. We only resolve the JS promise once that's complete.
129
+ public func clearUserData(_ completion: @escaping () -> Void) {
130
+ Task { @MainActor in
131
+ HubspotManager.shared.clearUserData()
132
+ await Self.deleteAllChatWebsiteData()
133
+ completion()
134
+ }
135
+ }
136
+
137
+ private static func deleteAllChatWebsiteData() async {
138
+ let store = WKWebsiteDataStore.default()
139
+ let allTypes = WKWebsiteDataStore.allWebsiteDataTypes()
140
+ let records = await store.dataRecords(ofTypes: allTypes)
141
+ let matching = records.filter { record in
142
+ let host = record.displayName.lowercased()
143
+ return chatDataDomainMatches.contains(where: host.contains)
86
144
  }
145
+ guard !matching.isEmpty else { return }
146
+ await store.removeData(ofTypes: allTypes, for: matching)
147
+ }
148
+
149
+ /// Install a `WKUserScript` on `controller` that hides any "back to inbox" /
150
+ /// conversations-list navigation rendered by the HubSpot chat widget.
151
+ ///
152
+ /// Called from the patched `HubspotChatWebView.WebviewCoordinator.setupScripts()`
153
+ /// (see `update-hubspot-ios-sdk.sh` for the patch that re-applies that hook on
154
+ /// every SDK sync). The actual heuristic-based hide JS lives here in our own
155
+ /// non-vendored file so SDK updates don't churn the implementation.
156
+ ///
157
+ /// We can't rely on a single CSS selector because:
158
+ /// - HubSpot's widget uses minified/hashed class names that change across
159
+ /// releases.
160
+ /// - Parts of the widget render inside shadow roots, which a top-level
161
+ /// `<style>` doesn't pierce.
162
+ /// - The button can mount after first paint as the widget hydrates.
163
+ ///
164
+ /// The injected script installs a `MutationObserver` that walks the full tree
165
+ /// (recursing into open shadow roots) and hides anything that *behaves* like a
166
+ /// back button - matched on `aria-label`, `data-test-id`, `title`, `id`, `class`,
167
+ /// and visible text. Re-scans periodically for the first few seconds to cover
168
+ /// late-mounted shadow roots. Injected at document start in every frame.
169
+ @objc public static func installBackButtonHider(on controller: WKUserContentController) {
170
+ guard shouldHideBackToInboxButton else { return }
171
+
172
+ let script = WKUserScript(
173
+ source: backButtonHiderJS,
174
+ injectionTime: .atDocumentStart,
175
+ forMainFrameOnly: false
176
+ )
177
+ controller.addUserScript(script)
87
178
  }
88
179
 
180
+ /// Heuristic-based hider for the chat widget's back/inbox button. Designed to
181
+ /// work both as a `WKUserScript` (per-frame at `.atDocumentStart`) and as a
182
+ /// one-shot `evaluateJavascript` payload from the Android side - hence the
183
+ /// `walkAllFrames` / `bootstrap` split with the per-document install guard.
184
+ /// Keep this string in lockstep with `HubspotBackButtonHider.BACK_BUTTON_HIDER_JS`
185
+ /// in the Android wrapper. Insert spaces at camelCase boundaries and collapse
186
+ /// underscores/dashes to spaces so word-boundary matching catches class names
187
+ /// like `ConversationView__BackButton`. (In JS regex, `_` is a word char, so
188
+ /// without this `\\bback\\b` would fail against `..._back_...`.)
189
+ private static let backButtonHiderJS: String = """
190
+ (function() {
191
+ function bootstrap(win) {
192
+ var doc;
193
+ try { doc = win.document; } catch (_) { return; }
194
+ if (!doc || doc.__rnHubspotHiderInstalled) return;
195
+ doc.__rnHubspotHiderInstalled = true;
196
+
197
+ var BACK_PATTERN = /\\b(back|inbox|previous|conversations)\\b/i;
198
+
199
+ function normalize(s) {
200
+ if (!s) return '';
201
+ return String(s)
202
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
203
+ .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
204
+ .replace(/[_-]+/g, ' ')
205
+ .toLowerCase();
206
+ }
207
+
208
+ function looksLikeBackControl(el) {
209
+ if (!el || el.nodeType !== 1) return false;
210
+ if (el.getAttribute('data-rn-hubspot-wrapper-hidden') === '1') return false;
211
+ var tag = el.tagName;
212
+ if (tag !== 'BUTTON' && tag !== 'A' && el.getAttribute('role') !== 'button') return false;
213
+ var attrs = [
214
+ el.getAttribute('aria-label'),
215
+ el.getAttribute('data-test-id'),
216
+ el.getAttribute('data-test'),
217
+ el.getAttribute('title'),
218
+ el.getAttribute('id'),
219
+ el.getAttribute('class')
220
+ ];
221
+ for (var i = 0; i < attrs.length; i++) {
222
+ if (attrs[i] && BACK_PATTERN.test(normalize(attrs[i]))) return true;
223
+ }
224
+ var text = (el.textContent || '').trim();
225
+ if (text && text.length <= 32 && BACK_PATTERN.test(normalize(text))) return true;
226
+ return false;
227
+ }
228
+
229
+ function hide(el) {
230
+ try {
231
+ el.setAttribute('data-rn-hubspot-wrapper-hidden', '1');
232
+ el.style.setProperty('display', 'none', 'important');
233
+ el.style.setProperty('visibility', 'hidden', 'important');
234
+ el.style.setProperty('pointer-events', 'none', 'important');
235
+ } catch (_) {}
236
+ }
237
+
238
+ // Hide `el`, then walk up to a few ancestors and also hide any wrapper
239
+ // that becomes empty (no remaining unhidden element children) as a
240
+ // result. HubSpot's chat header has a back-button slot whose padding
241
+ // / min-width / flex-basis stays around even when the inner button is
242
+ // `display:none`, which shows up as the avatar shifting right after the
243
+ // first message is sent. Capping at depth 3 keeps us inside the header.
244
+ function hideUpwards(el) {
245
+ hide(el);
246
+ var current = el;
247
+ for (var depth = 0; depth < 3; depth++) {
248
+ var parent = current.parentElement;
249
+ if (!parent) break;
250
+ if (parent === doc.documentElement || parent === doc.body) break;
251
+ var visibleCount = 0;
252
+ var children = parent.children;
253
+ for (var i = 0; i < children.length; i++) {
254
+ if (children[i].getAttribute('data-rn-hubspot-wrapper-hidden') !== '1') {
255
+ visibleCount++;
256
+ }
257
+ }
258
+ if (visibleCount > 0) break;
259
+ hide(parent);
260
+ current = parent;
261
+ }
262
+ }
263
+
264
+ var observed = new WeakSet();
265
+ function scanRoot(root) {
266
+ if (!root) return;
267
+ try {
268
+ var nodes = root.querySelectorAll('button, a, [role="button"]');
269
+ for (var i = 0; i < nodes.length; i++) {
270
+ if (looksLikeBackControl(nodes[i])) hideUpwards(nodes[i]);
271
+ }
272
+ var all = root.querySelectorAll('*');
273
+ for (var j = 0; j < all.length; j++) {
274
+ var sr = all[j].shadowRoot;
275
+ if (sr) {
276
+ scanRoot(sr);
277
+ attachObserver(sr);
278
+ }
279
+ }
280
+ } catch (_) {}
281
+ }
282
+
283
+ function attachObserver(root) {
284
+ if (!root || observed.has(root)) return;
285
+ observed.add(root);
286
+ try {
287
+ var mo = new MutationObserver(function() { scanRoot(root); });
288
+ mo.observe(root, { childList: true, subtree: true, attributes: true, attributeFilter: ['aria-label', 'data-test-id', 'data-test', 'title', 'class'] });
289
+ } catch (_) {}
290
+ }
291
+
292
+ function go() {
293
+ scanRoot(doc);
294
+ attachObserver(doc.documentElement || doc);
295
+ }
296
+
297
+ if (doc.readyState === 'loading') {
298
+ doc.addEventListener('DOMContentLoaded', go, { once: true });
299
+ } else {
300
+ go();
301
+ }
302
+ }
303
+
304
+ function walkAllFrames(win) {
305
+ if (!win) return;
306
+ bootstrap(win);
307
+ try {
308
+ for (var i = 0; i < win.frames.length; i++) {
309
+ walkAllFrames(win.frames[i]);
310
+ }
311
+ } catch (_) {}
312
+ }
313
+
314
+ walkAllFrames(window);
315
+ })();
316
+ """
317
+
89
318
  private static func topViewController(
90
319
  base: UIViewController? = UIApplication.shared.connectedScenes
91
320
  .compactMap { $0 as? UIWindowScene }
@@ -34,10 +34,10 @@ RCT_EXPORT_MODULE(NativeHubspotWrapper)
34
34
  reject(@"INIT_ERROR", @"Failed to initialize HubSpot SDK", error);
35
35
  }
36
36
 
37
- - (void)openChat:(NSString *)chatflow resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject
37
+ - (void)openChat:(NSString *)chatflow hideBackToInboxButton:(BOOL)hideBackToInboxButton resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject
38
38
  {
39
39
  NSError *error = nil;
40
- BOOL didOpen = [_impl openChat:chatflow error:&error];
40
+ BOOL didOpen = [_impl openChat:chatflow hideBackToInboxButton:hideBackToInboxButton error:&error];
41
41
  if (didOpen) {
42
42
  resolve(nil);
43
43
  return;
@@ -59,8 +59,13 @@ RCT_EXPORT_MODULE(NativeHubspotWrapper)
59
59
 
60
60
  - (void)clearUserData:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject
61
61
  {
62
- [_impl clearUserData];
63
- resolve(nil);
62
+ // Important: do not resolve until the impl reports completion. The impl awaits the
63
+ // actual chat-identity cookie deletion before invoking this block. Resolving sooner
64
+ // creates a race where JS-level `await clearUserData()` returns before HubSpot's
65
+ // visitor cookies are gone, and the next `openChat` re-uses the previous identity.
66
+ [_impl clearUserData:^{
67
+ resolve(nil);
68
+ }];
64
69
  }
65
70
 
66
71
  @end
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "react-native-hubspot-wrapper",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "description": "TurboModule wrapper for HubSpot mobile chat SDK",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
7
7
  "files": [
8
8
  "src",
9
- "android",
9
+ "android/build.gradle",
10
+ "android/src",
10
11
  "ios",
11
12
  "ReactNativeHubspotWrapper.podspec",
12
13
  "react-native.config.js",
@@ -75,6 +75,31 @@ extension Bundle {
75
75
  if old_block not in content:
76
76
  raise RuntimeError("Expected Image extension block was not found in HubspotManager.swift")
77
77
  hubspot_manager.write_text(content.replace(old_block, new_block, 1))
78
+
79
+ # 4) Hook our back-button hider into HubspotChatWebView's WKUserContentController.
80
+ # The actual hider implementation lives in our own non-vendored
81
+ # HubspotWrapperImpl.swift; this patch just inserts a one-line call so the
82
+ # hider runs whenever the SDK builds its content controller. We open chat as
83
+ # a single fresh conversation per session and never want users navigating
84
+ # into prior threads.
85
+ chat_view = target_dir / "Views/ChatView/HubspotChatView.swift"
86
+ chat_content = chat_view.read_text()
87
+ anchor = (
88
+ 'contentController.addUserScript(WKUserScript(source: js, '
89
+ 'injectionTime: .atDocumentEnd, forMainFrameOnly: false))'
90
+ )
91
+ hook_block = (
92
+ "\n\n"
93
+ " // [react-native-hubspot-wrapper] hide chat \"back to inbox\" button - see HubspotWrapperImpl\n"
94
+ " HubspotWrapperImpl.installBackButtonHider(on: contentController)"
95
+ )
96
+ if anchor not in chat_content:
97
+ raise RuntimeError("Expected setupScripts anchor not found in HubspotChatView.swift")
98
+ if "HubspotWrapperImpl.installBackButtonHider" in chat_content:
99
+ # Already patched.
100
+ pass
101
+ else:
102
+ chat_view.write_text(chat_content.replace(anchor, anchor + hook_block, 1))
78
103
  PY
79
104
  }
80
105
 
package/src/index.ts CHANGED
@@ -7,6 +7,10 @@ export type SetIdentityParams = {
7
7
  email?: string | null;
8
8
  };
9
9
 
10
+ export type OpenChatOptions = {
11
+ hideBackToInboxButton?: boolean;
12
+ };
13
+
10
14
  function ensureNonEmpty(value: string, fieldName: string): void {
11
15
  if (!value || !value.trim()) {
12
16
  throw new Error(`\`${fieldName}\` must be a non-empty string.`);
@@ -18,9 +22,9 @@ const HubspotWrapper = {
18
22
  return NativeHubspotWrapper.initialize();
19
23
  },
20
24
 
21
- openChat(chatflow: string): Promise<void> {
25
+ openChat(chatflow: string, options: OpenChatOptions = {}): Promise<void> {
22
26
  ensureNonEmpty(chatflow, 'chatflow');
23
- return NativeHubspotWrapper.openChat(chatflow);
27
+ return NativeHubspotWrapper.openChat(chatflow, options.hideBackToInboxButton ?? true);
24
28
  },
25
29
 
26
30
  setIdentity(params: SetIdentityParams): Promise<void> {
@@ -8,7 +8,7 @@ export type HubspotProperty = {
8
8
 
9
9
  export interface Spec extends TurboModule {
10
10
  initialize(): Promise<void>;
11
- openChat(chatflow: string): Promise<void>;
11
+ openChat(chatflow: string, hideBackToInboxButton: boolean): Promise<void>;
12
12
  setIdentity(identityToken: string, email: string | null): Promise<void>;
13
13
  setProperties(properties: ReadonlyArray<HubspotProperty>): Promise<void>;
14
14
  clearUserData(): Promise<void>;
File without changes
@@ -1,2 +0,0 @@
1
- #Tue Apr 28 11:02:36 CEST 2026
2
- gradle.version=8.13
File without changes