react-native-hubspot-wrapper 0.2.0 → 0.4.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.
File without changes
@@ -0,0 +1,2 @@
1
+ #Tue Apr 28 11:02:36 CEST 2026
2
+ gradle.version=8.13
File without changes
@@ -0,0 +1,250 @@
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
+
13
+ /**
14
+ * Hides the HubSpot chat widget's "back to inbox" / conversations-list navigation
15
+ * inside [HubspotWebActivity] (which lives in HubSpot's compiled `.aar` and which
16
+ * we therefore can't modify directly).
17
+ *
18
+ * Approach:
19
+ *
20
+ * 1. We register a single [Application.ActivityLifecycleCallbacks] from the wrapper
21
+ * module's init.
22
+ * 2. Each time `com.hubspot.mobilesdk.HubspotWebActivity` resumes, we depth-first
23
+ * walk its decor view to find the `WebView`.
24
+ * 3. We `evaluateJavascript` a heuristic-based hider that walks all same-origin
25
+ * sub-frames (the chat UI itself lives in an iframe on `app-na2.hubspot.com`)
26
+ * and hides any `<button>`/`<a>`/`role="button"` whose accessible label,
27
+ * `data-test-id`, title, id, class or short visible text normalizes (camelCase
28
+ * -> spaces, `_-` -> spaces, lowercase) to something matching
29
+ * `\b(back|inbox|previous|conversations)\b`. A `MutationObserver` is then
30
+ * attached so late-mounted buttons get hidden too.
31
+ * 4. We re-fire `evaluateJavascript` periodically for a few seconds so the iframe
32
+ * walk catches the chat iframe as soon as it mounts (it's loaded asynchronously
33
+ * by HubSpot's `project.js` bundle after the activity opens).
34
+ *
35
+ * The implementation is intentionally identical in spirit to the iOS hider in
36
+ * `HubspotWrapperImpl.swift` so the same JS works on both platforms.
37
+ */
38
+ internal object HubspotBackButtonHider {
39
+ private const val TAG = "HubspotWrapper"
40
+
41
+ /**
42
+ * The fully-qualified name of HubSpot's chat activity in the AAR. Matched by
43
+ * string so we don't have to import / depend on it for compile-time resolution.
44
+ */
45
+ private const val WEB_ACTIVITY_NAME = "com.hubspot.mobilesdk.HubspotWebActivity"
46
+
47
+ /** How often we re-fire `evaluateJavascript` to catch late-loaded iframes. */
48
+ private const val RESCAN_INTERVAL_MS = 250L
49
+
50
+ /**
51
+ * Total time we keep re-firing after the activity resumes. The chat iframe
52
+ * normally appears within ~2s; we give a generous margin and then stop to
53
+ * avoid burning CPU forever if the user leaves the chat open for a long time.
54
+ */
55
+ private const val MAX_SCAN_DURATION_MS = 10_000L
56
+
57
+ @Volatile
58
+ private var installed = false
59
+
60
+ @Synchronized
61
+ fun installOnce(app: Application) {
62
+ if (installed) return
63
+ installed = true
64
+
65
+ app.registerActivityLifecycleCallbacks(object : Application.ActivityLifecycleCallbacks {
66
+ override fun onActivityResumed(activity: Activity) {
67
+ if (activity.javaClass.name != WEB_ACTIVITY_NAME) return
68
+ val webView = findWebView(activity.window.decorView)
69
+ if (webView == null) {
70
+ Log.w(TAG, "installBackButtonHider: WebView not found in $WEB_ACTIVITY_NAME view tree")
71
+ return
72
+ }
73
+ Log.i(TAG, "installBackButtonHider: scheduling JS injection on $webView")
74
+ scheduleHiderInjection(webView)
75
+ }
76
+
77
+ override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {}
78
+ override fun onActivityStarted(activity: Activity) {}
79
+ override fun onActivityPaused(activity: Activity) {}
80
+ override fun onActivityStopped(activity: Activity) {}
81
+ override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {}
82
+ override fun onActivityDestroyed(activity: Activity) {}
83
+ })
84
+ }
85
+
86
+ private fun findWebView(view: View?): WebView? {
87
+ if (view is WebView) return view
88
+ if (view is ViewGroup) {
89
+ for (i in 0 until view.childCount) {
90
+ val found = findWebView(view.getChildAt(i))
91
+ if (found != null) return found
92
+ }
93
+ }
94
+ return null
95
+ }
96
+
97
+ private fun scheduleHiderInjection(webView: WebView) {
98
+ val handler = Handler(Looper.getMainLooper())
99
+ val start = System.currentTimeMillis()
100
+ val runnable = object : Runnable {
101
+ override fun run() {
102
+ try {
103
+ webView.evaluateJavascript(BACK_BUTTON_HIDER_JS, null)
104
+ } catch (error: Exception) {
105
+ Log.w(TAG, "evaluateJavascript failed", error)
106
+ }
107
+ if (System.currentTimeMillis() - start < MAX_SCAN_DURATION_MS) {
108
+ handler.postDelayed(this, RESCAN_INTERVAL_MS)
109
+ }
110
+ }
111
+ }
112
+ handler.post(runnable)
113
+ }
114
+
115
+ /**
116
+ * Heuristic-based hider for the chat widget's back/inbox button. Identical in
117
+ * shape to the iOS version in `HubspotWrapperImpl.swift`. Designed to be safe
118
+ * to invoke many times: a `__rnHubspotHiderInstalled` per-document flag short-
119
+ * circuits re-installation, while the periodic re-fire from
120
+ * [scheduleHiderInjection] still allows newly-mounted iframes to be picked up.
121
+ */
122
+ private const val BACK_BUTTON_HIDER_JS = """
123
+ (function() {
124
+ function bootstrap(win) {
125
+ var doc;
126
+ try { doc = win.document; } catch (_) { return; }
127
+ if (!doc || doc.__rnHubspotHiderInstalled) return;
128
+ doc.__rnHubspotHiderInstalled = true;
129
+
130
+ var BACK_PATTERN = /\b(back|inbox|previous|conversations)\b/i;
131
+
132
+ function normalize(s) {
133
+ if (!s) return '';
134
+ return String(s)
135
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
136
+ .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
137
+ .replace(/[_-]+/g, ' ')
138
+ .toLowerCase();
139
+ }
140
+
141
+ function looksLikeBackControl(el) {
142
+ if (!el || el.nodeType !== 1) return false;
143
+ if (el.getAttribute('data-rn-hubspot-wrapper-hidden') === '1') return false;
144
+ var tag = el.tagName;
145
+ if (tag !== 'BUTTON' && tag !== 'A' && el.getAttribute('role') !== 'button') return false;
146
+ var attrs = [
147
+ el.getAttribute('aria-label'),
148
+ el.getAttribute('data-test-id'),
149
+ el.getAttribute('data-test'),
150
+ el.getAttribute('title'),
151
+ el.getAttribute('id'),
152
+ el.getAttribute('class')
153
+ ];
154
+ for (var i = 0; i < attrs.length; i++) {
155
+ if (attrs[i] && BACK_PATTERN.test(normalize(attrs[i]))) return true;
156
+ }
157
+ var text = (el.textContent || '').trim();
158
+ if (text && text.length <= 32 && BACK_PATTERN.test(normalize(text))) return true;
159
+ return false;
160
+ }
161
+
162
+ function hide(el) {
163
+ try {
164
+ el.setAttribute('data-rn-hubspot-wrapper-hidden', '1');
165
+ el.style.setProperty('display', 'none', 'important');
166
+ el.style.setProperty('visibility', 'hidden', 'important');
167
+ el.style.setProperty('pointer-events', 'none', 'important');
168
+ } catch (_) {}
169
+ }
170
+
171
+ // Hide `el`, then walk up to a few ancestors and also hide any wrapper
172
+ // that becomes empty (no remaining unhidden element children) as a
173
+ // result. HubSpot's chat header has a back-button slot whose padding
174
+ // / min-width / flex-basis stays around even when the inner button is
175
+ // `display:none`, which shows up as the avatar shifting right after the
176
+ // first message is sent. Capping at depth 3 keeps us inside the header.
177
+ function hideUpwards(el) {
178
+ hide(el);
179
+ var current = el;
180
+ for (var depth = 0; depth < 3; depth++) {
181
+ var parent = current.parentElement;
182
+ if (!parent) break;
183
+ if (parent === doc.documentElement || parent === doc.body) break;
184
+ var visibleCount = 0;
185
+ var children = parent.children;
186
+ for (var i = 0; i < children.length; i++) {
187
+ if (children[i].getAttribute('data-rn-hubspot-wrapper-hidden') !== '1') {
188
+ visibleCount++;
189
+ }
190
+ }
191
+ if (visibleCount > 0) break;
192
+ hide(parent);
193
+ current = parent;
194
+ }
195
+ }
196
+
197
+ var observed = new WeakSet();
198
+ function scanRoot(root) {
199
+ if (!root) return;
200
+ try {
201
+ var nodes = root.querySelectorAll('button, a, [role="button"]');
202
+ for (var i = 0; i < nodes.length; i++) {
203
+ if (looksLikeBackControl(nodes[i])) hideUpwards(nodes[i]);
204
+ }
205
+ var all = root.querySelectorAll('*');
206
+ for (var j = 0; j < all.length; j++) {
207
+ var sr = all[j].shadowRoot;
208
+ if (sr) {
209
+ scanRoot(sr);
210
+ attachObserver(sr);
211
+ }
212
+ }
213
+ } catch (_) {}
214
+ }
215
+
216
+ function attachObserver(root) {
217
+ if (!root || observed.has(root)) return;
218
+ observed.add(root);
219
+ try {
220
+ var mo = new MutationObserver(function() { scanRoot(root); });
221
+ mo.observe(root, { childList: true, subtree: true, attributes: true, attributeFilter: ['aria-label', 'data-test-id', 'data-test', 'title', 'class'] });
222
+ } catch (_) {}
223
+ }
224
+
225
+ function go() {
226
+ scanRoot(doc);
227
+ attachObserver(doc.documentElement || doc);
228
+ }
229
+
230
+ if (doc.readyState === 'loading') {
231
+ doc.addEventListener('DOMContentLoaded', go, { once: true });
232
+ } else {
233
+ go();
234
+ }
235
+ }
236
+
237
+ function walkAllFrames(win) {
238
+ if (!win) return;
239
+ bootstrap(win);
240
+ try {
241
+ for (var i = 0; i < win.frames.length; i++) {
242
+ walkAllFrames(win.frames[i]);
243
+ }
244
+ } catch (_) {}
245
+ }
246
+
247
+ walkAllFrames(window);
248
+ })();
249
+ """
250
+ }
@@ -1,6 +1,8 @@
1
1
  package com.marcinolek.reactnativehubspotwrapper
2
2
 
3
+ import android.app.Application
3
4
  import android.content.Intent
5
+ import android.util.Log
4
6
  import android.webkit.CookieManager
5
7
  import com.facebook.react.bridge.Promise
6
8
  import com.facebook.react.bridge.ReactApplicationContext
@@ -19,6 +21,14 @@ class HubspotWrapperModule(reactContext: ReactApplicationContext) :
19
21
  private val appContext = reactContext.applicationContext
20
22
  private lateinit var hubspotManager: HubspotManager
21
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
+
22
32
  override fun getName(): String = NAME
23
33
 
24
34
  override fun initialize(promise: Promise) {
@@ -72,16 +82,94 @@ class HubspotWrapperModule(reactContext: ReactApplicationContext) :
72
82
  override fun clearUserData(promise: Promise) {
73
83
  CoroutineScope(Dispatchers.Main).launch {
74
84
  try {
85
+ Log.i(TAG, "clearUserData: invoked")
75
86
  hubspotManager.logout()
76
- clearWebViewCookies()
87
+ clearHubspotSessionCookies()
88
+ Log.i(TAG, "clearUserData: done")
77
89
  promise.resolve(null)
78
90
  } catch (error: Exception) {
91
+ Log.e(TAG, "clearUserData: failed", error)
79
92
  promise.reject("CLEAR_USER_DATA_ERROR", "Failed to clear HubSpot user data", error)
80
93
  }
81
94
  }
82
95
  }
83
96
 
84
- private suspend fun clearWebViewCookies() {
97
+ /**
98
+ * Mirror the iOS SDK's `cookiesToDeleteWhenClearingData` behavior: only drop the cookies that
99
+ * tie the embedded chat WebView to a previous HubSpot visitor identity (`hubspotutk`,
100
+ * `messagesUtk`). Anything else - Cloudflare bot-management cookies, future preference cookies,
101
+ * etc. - is intentionally preserved so the next chat session starts with a fresh identity but
102
+ * doesn't pay any unrelated cold-start tax.
103
+ *
104
+ * Implementation notes for Android:
105
+ *
106
+ * - `CookieManager.getCookie(url)` only returns `name=value` pairs - no `Domain`/`Path`
107
+ * metadata - so to delete a cookie reliably we have to overwrite it with an expired date
108
+ * under every plausible scope (host-only, exact host `Domain`, parent `Domain`). Writes
109
+ * whose `Domain` doesn't match an existing cookie are silently rejected by the cookie store,
110
+ * so issuing all three is safe.
111
+ *
112
+ * - The chat URL's host is computed from `hubspot-info.json` using the same rule as the SDK's
113
+ * internal `Hublet`/`Environment` value classes (which are package-private):
114
+ *
115
+ * `https://app[-<hublet>].hubspot[qa].com/`
116
+ *
117
+ * For example: hublet `na2` + env `prod` -> `https://app-na2.hubspot.com/`.
118
+ * Hublet `na1` is special-cased to `app.hubspot.com`. Env `qa` adds the `qa` suffix
119
+ * to the apex (`hubspotqa.com`).
120
+ *
121
+ * - If `HubspotManager` hasn't been configured yet, we fall back to `removeAllCookies()` so
122
+ * we never leak chat identity even in misconfigured states.
123
+ *
124
+ * Caveat (matches iOS): clearing `messagesUtk` resets the visitor identity, which causes
125
+ * HubSpot's chat embed to re-show its cookie consent banner on the next session. This is by
126
+ * design on HubSpot's side - their chat treats every new visitor id as a new visitor that
127
+ * must consent. There is no way to keep both "fresh chat" and "no consent reprompt" without
128
+ * also leaving the previous conversation thread visible to the user.
129
+ */
130
+ private suspend fun clearHubspotSessionCookies() {
131
+ val hubletId = runCatching { hubspotManager.getHublet() }.getOrNull()
132
+ val environment = runCatching { hubspotManager.getEnvironment() }.getOrNull()
133
+
134
+ if (hubletId.isNullOrEmpty() || environment.isNullOrEmpty()) {
135
+ Log.w(TAG, "clearUserData: missing hublet/environment, falling back to removeAllCookies()")
136
+ clearAllWebViewCookies()
137
+ return
138
+ }
139
+
140
+ val cookieManager = CookieManager.getInstance()
141
+ val urlSuffix = if (environment.equals("qa", ignoreCase = true)) "qa" else ""
142
+ val rootDomain = "hubspot$urlSuffix.com"
143
+ val appsSubDomain = if (hubletId.equals("na1", ignoreCase = true)) "app" else "app-$hubletId"
144
+ val chatHost = "$appsSubDomain.$rootDomain"
145
+ val chatUrl = "https://$chatHost/"
146
+
147
+ val existingCookieNames = cookieManager.getCookie(chatUrl).orEmpty()
148
+ .split(";")
149
+ .mapNotNull { it.substringBefore("=").trim().takeIf(String::isNotEmpty) }
150
+ .toSet()
151
+
152
+ val toDelete = COOKIES_TO_CLEAR.filter { it in existingCookieNames }
153
+ if (toDelete.isEmpty()) {
154
+ Log.i(TAG, "clearUserData: no chat-identity cookies present at $chatHost, nothing to delete")
155
+ return
156
+ }
157
+
158
+ Log.i(TAG, "clearUserData: deleting cookies=$toDelete on $chatHost")
159
+ val expiry = "Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT"
160
+ toDelete.forEach { name ->
161
+ cookieManager.setCookie(chatUrl, "$name=; $expiry")
162
+ cookieManager.setCookie(chatUrl, "$name=; Domain=$chatHost; $expiry")
163
+ cookieManager.setCookie(chatUrl, "$name=; Domain=.$rootDomain; $expiry")
164
+ }
165
+
166
+ suspendCancellableCoroutine<Unit> { continuation ->
167
+ cookieManager.flush()
168
+ continuation.resume(Unit)
169
+ }
170
+ }
171
+
172
+ private suspend fun clearAllWebViewCookies() {
85
173
  suspendCancellableCoroutine<Unit> { continuation ->
86
174
  val cookieManager = CookieManager.getInstance()
87
175
  cookieManager.removeAllCookies {
@@ -93,5 +181,14 @@ class HubspotWrapperModule(reactContext: ReactApplicationContext) :
93
181
 
94
182
  companion object {
95
183
  const val NAME = "NativeHubspotWrapper"
184
+
185
+ private const val TAG = "HubspotWrapper"
186
+
187
+ /**
188
+ * Cookies that bind the embedded chat WebView to a specific HubSpot visitor identity.
189
+ * Matches the iOS SDK's `cookiesToDeleteWhenClearingData`.
190
+ * See https://knowledge.hubspot.com/privacy-and-consent/what-cookies-does-hubspot-set-in-a-visitor-s-browser
191
+ */
192
+ private val COOKIES_TO_CLEAR = listOf("hubspotutk", "messagesUtk")
96
193
  }
97
194
  }
@@ -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,29 @@
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
+ /// Substrings used to match `WKWebsiteDataRecord.displayName` (which is the host /
9
+ /// eTLD+1, e.g. `hubspot.com`, `hs-banner.com`, `hsadspixel.net`) for the various
10
+ /// domains the chat widget loads from. Anything matching is wiped on `clearUserData`.
11
+ ///
12
+ /// We deliberately use substrings rather than exact hosts because the widget pulls
13
+ /// resources from a long tail of HubSpot CDNs - `js.hs-banner.com`, `js.hubspot.com`,
14
+ /// `static.hsappstatic.net`, `app-na2.hubspot.com`, `*.hubspotqa.com`, etc. - and any
15
+ /// of those can hold cookies / localStorage / IndexedDB that re-attach a returning
16
+ /// visitor or surface the unsent draft message on the next open.
17
+ private static let chatDataDomainMatches: [String] = [
18
+ "hubspot",
19
+ "hsforms",
20
+ "hs-banner",
21
+ "hs-scripts",
22
+ "hsadspixel",
23
+ "hsappstatic",
24
+ "usemessages",
25
+ ]
26
+
7
27
  public func initialize(_ outError: NSErrorPointer) -> Bool {
8
28
  let semaphore = DispatchSemaphore(value: 0)
9
29
  var configureError: NSError?
@@ -80,12 +100,216 @@ public class HubspotWrapperImpl: NSObject {
80
100
  }
81
101
  }
82
102
 
83
- public func clearUserData() {
84
- Task {
85
- await HubspotManager.shared.clearUserData()
103
+ /// Clear the SDK's in-memory identity/property state and synchronously wait for ALL
104
+ /// HubSpot website data (cookies, localStorage, sessionStorage, IndexedDB, service
105
+ /// workers, etc.) to be removed from the shared `WKWebsiteDataStore` before invoking
106
+ /// `completion`.
107
+ ///
108
+ /// Why we do our own clearing instead of relying on the SDK:
109
+ ///
110
+ /// 1. `HubspotManager.clearUserData()` is synchronous, but the cookie removal it
111
+ /// performs is dispatched into a fire-and-forget `Task { ... }` that we have no
112
+ /// handle to. JS code doing
113
+ /// `await HubspotWrapper.clearUserData(); await HubspotWrapper.openChat(...)`
114
+ /// races the still-in-flight cookie deletion, so the next chat session re-uses
115
+ /// the previous visitor identity.
116
+ ///
117
+ /// 2. Even when the SDK's cookie deletion *does* finish, it only removes the two
118
+ /// visitor-identity cookies (`hubspotutk`, `messagesUtk`). The chat widget also
119
+ /// persists the unsent draft message and other state in `localStorage` /
120
+ /// `sessionStorage` / `IndexedDB`. Cookie-only clearing leaves the draft visible
121
+ /// on the next open, which is exactly the bug we kept seeing.
122
+ ///
123
+ /// We therefore enumerate `WKWebsiteDataStore` records, filter to records whose
124
+ /// host matches a HubSpot domain (see `chatDataDomainMatches`) and remove ALL data
125
+ /// types for those records. We only resolve the JS promise once that's complete.
126
+ public func clearUserData(_ completion: @escaping () -> Void) {
127
+ Task { @MainActor in
128
+ HubspotManager.shared.clearUserData()
129
+ await Self.deleteAllChatWebsiteData()
130
+ completion()
131
+ }
132
+ }
133
+
134
+ private static func deleteAllChatWebsiteData() async {
135
+ let store = WKWebsiteDataStore.default()
136
+ let allTypes = WKWebsiteDataStore.allWebsiteDataTypes()
137
+ let records = await store.dataRecords(ofTypes: allTypes)
138
+ let matching = records.filter { record in
139
+ let host = record.displayName.lowercased()
140
+ return chatDataDomainMatches.contains(where: host.contains)
86
141
  }
142
+ guard !matching.isEmpty else { return }
143
+ await store.removeData(ofTypes: allTypes, for: matching)
87
144
  }
88
145
 
146
+ /// Install a `WKUserScript` on `controller` that hides any "back to inbox" /
147
+ /// conversations-list navigation rendered by the HubSpot chat widget.
148
+ ///
149
+ /// Called from the patched `HubspotChatWebView.WebviewCoordinator.setupScripts()`
150
+ /// (see `update-hubspot-ios-sdk.sh` for the patch that re-applies that hook on
151
+ /// every SDK sync). The actual heuristic-based hide JS lives here in our own
152
+ /// non-vendored file so SDK updates don't churn the implementation.
153
+ ///
154
+ /// We can't rely on a single CSS selector because:
155
+ /// - HubSpot's widget uses minified/hashed class names that change across
156
+ /// releases.
157
+ /// - Parts of the widget render inside shadow roots, which a top-level
158
+ /// `<style>` doesn't pierce.
159
+ /// - The button can mount after first paint as the widget hydrates.
160
+ ///
161
+ /// The injected script installs a `MutationObserver` that walks the full tree
162
+ /// (recursing into open shadow roots) and hides anything that *behaves* like a
163
+ /// back button - matched on `aria-label`, `data-test-id`, `title`, `id`, `class`,
164
+ /// and visible text. Re-scans periodically for the first few seconds to cover
165
+ /// late-mounted shadow roots. Injected at document start in every frame.
166
+ @objc public static func installBackButtonHider(on controller: WKUserContentController) {
167
+ let script = WKUserScript(
168
+ source: backButtonHiderJS,
169
+ injectionTime: .atDocumentStart,
170
+ forMainFrameOnly: false
171
+ )
172
+ controller.addUserScript(script)
173
+ }
174
+
175
+ /// Heuristic-based hider for the chat widget's back/inbox button. Designed to
176
+ /// work both as a `WKUserScript` (per-frame at `.atDocumentStart`) and as a
177
+ /// one-shot `evaluateJavascript` payload from the Android side - hence the
178
+ /// `walkAllFrames` / `bootstrap` split with the per-document install guard.
179
+ /// Keep this string in lockstep with `HubspotBackButtonHider.BACK_BUTTON_HIDER_JS`
180
+ /// in the Android wrapper. Insert spaces at camelCase boundaries and collapse
181
+ /// underscores/dashes to spaces so word-boundary matching catches class names
182
+ /// like `ConversationView__BackButton`. (In JS regex, `_` is a word char, so
183
+ /// without this `\\bback\\b` would fail against `..._back_...`.)
184
+ private static let backButtonHiderJS: String = """
185
+ (function() {
186
+ function bootstrap(win) {
187
+ var doc;
188
+ try { doc = win.document; } catch (_) { return; }
189
+ if (!doc || doc.__rnHubspotHiderInstalled) return;
190
+ doc.__rnHubspotHiderInstalled = true;
191
+
192
+ var BACK_PATTERN = /\\b(back|inbox|previous|conversations)\\b/i;
193
+
194
+ function normalize(s) {
195
+ if (!s) return '';
196
+ return String(s)
197
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
198
+ .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
199
+ .replace(/[_-]+/g, ' ')
200
+ .toLowerCase();
201
+ }
202
+
203
+ function looksLikeBackControl(el) {
204
+ if (!el || el.nodeType !== 1) return false;
205
+ if (el.getAttribute('data-rn-hubspot-wrapper-hidden') === '1') return false;
206
+ var tag = el.tagName;
207
+ if (tag !== 'BUTTON' && tag !== 'A' && el.getAttribute('role') !== 'button') return false;
208
+ var attrs = [
209
+ el.getAttribute('aria-label'),
210
+ el.getAttribute('data-test-id'),
211
+ el.getAttribute('data-test'),
212
+ el.getAttribute('title'),
213
+ el.getAttribute('id'),
214
+ el.getAttribute('class')
215
+ ];
216
+ for (var i = 0; i < attrs.length; i++) {
217
+ if (attrs[i] && BACK_PATTERN.test(normalize(attrs[i]))) return true;
218
+ }
219
+ var text = (el.textContent || '').trim();
220
+ if (text && text.length <= 32 && BACK_PATTERN.test(normalize(text))) return true;
221
+ return false;
222
+ }
223
+
224
+ function hide(el) {
225
+ try {
226
+ el.setAttribute('data-rn-hubspot-wrapper-hidden', '1');
227
+ el.style.setProperty('display', 'none', 'important');
228
+ el.style.setProperty('visibility', 'hidden', 'important');
229
+ el.style.setProperty('pointer-events', 'none', 'important');
230
+ } catch (_) {}
231
+ }
232
+
233
+ // Hide `el`, then walk up to a few ancestors and also hide any wrapper
234
+ // that becomes empty (no remaining unhidden element children) as a
235
+ // result. HubSpot's chat header has a back-button slot whose padding
236
+ // / min-width / flex-basis stays around even when the inner button is
237
+ // `display:none`, which shows up as the avatar shifting right after the
238
+ // first message is sent. Capping at depth 3 keeps us inside the header.
239
+ function hideUpwards(el) {
240
+ hide(el);
241
+ var current = el;
242
+ for (var depth = 0; depth < 3; depth++) {
243
+ var parent = current.parentElement;
244
+ if (!parent) break;
245
+ if (parent === doc.documentElement || parent === doc.body) break;
246
+ var visibleCount = 0;
247
+ var children = parent.children;
248
+ for (var i = 0; i < children.length; i++) {
249
+ if (children[i].getAttribute('data-rn-hubspot-wrapper-hidden') !== '1') {
250
+ visibleCount++;
251
+ }
252
+ }
253
+ if (visibleCount > 0) break;
254
+ hide(parent);
255
+ current = parent;
256
+ }
257
+ }
258
+
259
+ var observed = new WeakSet();
260
+ function scanRoot(root) {
261
+ if (!root) return;
262
+ try {
263
+ var nodes = root.querySelectorAll('button, a, [role="button"]');
264
+ for (var i = 0; i < nodes.length; i++) {
265
+ if (looksLikeBackControl(nodes[i])) hideUpwards(nodes[i]);
266
+ }
267
+ var all = root.querySelectorAll('*');
268
+ for (var j = 0; j < all.length; j++) {
269
+ var sr = all[j].shadowRoot;
270
+ if (sr) {
271
+ scanRoot(sr);
272
+ attachObserver(sr);
273
+ }
274
+ }
275
+ } catch (_) {}
276
+ }
277
+
278
+ function attachObserver(root) {
279
+ if (!root || observed.has(root)) return;
280
+ observed.add(root);
281
+ try {
282
+ var mo = new MutationObserver(function() { scanRoot(root); });
283
+ mo.observe(root, { childList: true, subtree: true, attributes: true, attributeFilter: ['aria-label', 'data-test-id', 'data-test', 'title', 'class'] });
284
+ } catch (_) {}
285
+ }
286
+
287
+ function go() {
288
+ scanRoot(doc);
289
+ attachObserver(doc.documentElement || doc);
290
+ }
291
+
292
+ if (doc.readyState === 'loading') {
293
+ doc.addEventListener('DOMContentLoaded', go, { once: true });
294
+ } else {
295
+ go();
296
+ }
297
+ }
298
+
299
+ function walkAllFrames(win) {
300
+ if (!win) return;
301
+ bootstrap(win);
302
+ try {
303
+ for (var i = 0; i < win.frames.length; i++) {
304
+ walkAllFrames(win.frames[i]);
305
+ }
306
+ } catch (_) {}
307
+ }
308
+
309
+ walkAllFrames(window);
310
+ })();
311
+ """
312
+
89
313
  private static func topViewController(
90
314
  base: UIViewController? = UIApplication.shared.connectedScenes
91
315
  .compactMap { $0 as? UIWindowScene }
@@ -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,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-hubspot-wrapper",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "TurboModule wrapper for HubSpot mobile chat SDK",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -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