react-native-hubspot-wrapper 0.4.0 → 0.5.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/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,31 @@ await HubspotWrapper.setProperties([{ name: 'plan', value: 'pro' }]);
61
61
  await HubspotWrapper.openChat('support');
62
62
  ```
63
63
 
64
+ `openChat` keeps HubSpot's stock conversations navigation visible. Older
65
+ versions accepted `hideBackToInboxButton`; the option is still accepted for
66
+ compatibility, but it no longer changes the HubSpot UI.
67
+
64
68
  ## API
65
69
 
66
70
  - `initialize(): Promise<void>`
67
- - `openChat(chatflow: string): Promise<void>`
71
+ - `openChat(chatflow: string, options?: { hideBackToInboxButton?: boolean }): Promise<void>`
68
72
  - `setIdentity({ identityToken, email? }): Promise<void>`
69
73
  - `setProperties(properties): Promise<void>`
70
74
  - `clearUserData(): Promise<void>`
71
75
 
76
+ ### `openChat(chatflow, options?)`
77
+
78
+ Opens the HubSpot chat UI for the provided chatflow.
79
+
80
+ - `hideBackToInboxButton` is deprecated and ignored.
81
+
82
+ ### `clearUserData()`
83
+
84
+ Clears the native SDK's current user/session state and waits for HubSpot chat
85
+ visitor cookies/data cleanup to finish before resolving. This is intended for
86
+ logout or account-switch flows so the next chat session does not reuse the
87
+ previous visitor identity.
88
+
72
89
  ## iOS SDK source strategy
73
90
 
74
91
  This package vendors HubSpot iOS SDK source files under `ios/HubspotMobileSDK`.
@@ -0,0 +1,232 @@
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.JavascriptInterface
12
+ import android.webkit.WebView
13
+ import java.lang.ref.WeakReference
14
+ import java.util.WeakHashMap
15
+ import org.json.JSONObject
16
+
17
+ /**
18
+ * Installs the small JavaScript bridge we need inside HubSpot's compiled
19
+ * [HubspotWebActivity], which cannot be modified directly from the wrapper.
20
+ *
21
+ * HubSpot Android SDK 1.0.8 uploads chat properties only after its native JS
22
+ * bridge receives a conversation id. The bundled script forwards
23
+ * `userSelectedThread`, but not `conversationStarted`, so newly-created threads
24
+ * never trigger the metadata POST. This installer forwards that one event
25
+ * without changing HubSpot's visible chat UI.
26
+ */
27
+ internal object HubspotChatScriptInstaller {
28
+ private const val TAG = "HubspotWrapper"
29
+ private const val JS_BRIDGE_NAME = "rnHubspotWrapper"
30
+
31
+ /**
32
+ * The fully-qualified name of HubSpot's chat activity in the AAR. Matched by
33
+ * string so we don't have to import / depend on it for compile-time resolution.
34
+ */
35
+ private const val WEB_ACTIVITY_NAME = "com.hubspot.mobilesdk.HubspotWebActivity"
36
+
37
+ /** How often we re-fire `evaluateJavascript` to catch late-loaded iframes. */
38
+ private const val RESCAN_INTERVAL_MS = 250L
39
+
40
+ /**
41
+ * Total time we keep re-firing after the activity resumes. The chat iframe
42
+ * normally appears within ~2s; we give a generous margin and then stop to
43
+ * avoid burning CPU forever if the user leaves the chat open for a long time.
44
+ */
45
+ private const val MAX_SCAN_DURATION_MS = 10_000L
46
+
47
+ @Volatile
48
+ private var installed = false
49
+
50
+ private val handler = Handler(Looper.getMainLooper())
51
+ private val scheduledInjections = WeakHashMap<WebView, Runnable>()
52
+ private val conversationBridges = WeakHashMap<WebView, ConversationBridge>()
53
+
54
+ @Synchronized
55
+ fun installOnce(app: Application) {
56
+ if (installed) return
57
+ installed = true
58
+
59
+ app.registerActivityLifecycleCallbacks(object : Application.ActivityLifecycleCallbacks {
60
+ override fun onActivityResumed(activity: Activity) {
61
+ if (activity.javaClass.name != WEB_ACTIVITY_NAME) return
62
+ val webView = findWebView(activity.window.decorView)
63
+ if (webView == null) {
64
+ Log.w(TAG, "installChatScripts: WebView not found in $WEB_ACTIVITY_NAME view tree")
65
+ return
66
+ }
67
+ Log.i(TAG, "installChatScripts: scheduling conversation bridge on $webView")
68
+ installNativeBridge(webView)
69
+ scheduleScriptInjection(webView)
70
+ }
71
+
72
+ override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {}
73
+ override fun onActivityStarted(activity: Activity) {}
74
+ override fun onActivityPaused(activity: Activity) {
75
+ if (activity.javaClass.name == WEB_ACTIVITY_NAME) {
76
+ cancelScriptInjection(findWebView(activity.window.decorView))
77
+ }
78
+ }
79
+ override fun onActivityStopped(activity: Activity) {}
80
+ override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {}
81
+ override fun onActivityDestroyed(activity: Activity) {
82
+ if (activity.javaClass.name == WEB_ACTIVITY_NAME) {
83
+ val webView = findWebView(activity.window.decorView)
84
+ cancelScriptInjection(webView)
85
+ if (webView != null) {
86
+ conversationBridges.remove(webView)
87
+ }
88
+ }
89
+ }
90
+ })
91
+ }
92
+
93
+ private fun findWebView(view: View?): WebView? {
94
+ if (view is WebView) return view
95
+ if (view is ViewGroup) {
96
+ for (i in 0 until view.childCount) {
97
+ val found = findWebView(view.getChildAt(i))
98
+ if (found != null) return found
99
+ }
100
+ }
101
+ return null
102
+ }
103
+
104
+ private fun scheduleScriptInjection(webView: WebView) {
105
+ cancelScriptInjection(webView)
106
+
107
+ val start = System.currentTimeMillis()
108
+ val runnable = object : Runnable {
109
+ override fun run() {
110
+ try {
111
+ webView.evaluateJavascript(CONVERSATION_STARTED_BRIDGE_JS, null)
112
+ } catch (error: Exception) {
113
+ Log.w(TAG, "evaluateJavascript failed", error)
114
+ }
115
+ if (System.currentTimeMillis() - start < MAX_SCAN_DURATION_MS) {
116
+ handler.postDelayed(this, RESCAN_INTERVAL_MS)
117
+ } else {
118
+ scheduledInjections.remove(webView)
119
+ }
120
+ }
121
+ }
122
+ scheduledInjections[webView] = runnable
123
+ handler.post(runnable)
124
+ }
125
+
126
+ private fun installNativeBridge(webView: WebView) {
127
+ if (conversationBridges.containsKey(webView)) return
128
+ val bridge = ConversationBridge(WeakReference(webView))
129
+ conversationBridges[webView] = bridge
130
+ webView.addJavascriptInterface(bridge, JS_BRIDGE_NAME)
131
+ }
132
+
133
+ private fun cancelScriptInjection(webView: WebView?) {
134
+ if (webView == null) return
135
+ val runnable = scheduledInjections.remove(webView) ?: return
136
+ handler.removeCallbacks(runnable)
137
+ }
138
+
139
+ private fun forwardConversationIdToNativeApp(webView: WebView, conversationId: String) {
140
+ val quotedId = JSONObject.quote(conversationId)
141
+ handler.post {
142
+ try {
143
+ webView.evaluateJavascript(
144
+ """
145
+ (function() {
146
+ if (window.nativeApp && typeof window.nativeApp.postConversationId === 'function') {
147
+ window.nativeApp.postConversationId($quotedId);
148
+ }
149
+ })();
150
+ """.trimIndent(),
151
+ null
152
+ )
153
+ } catch (error: Exception) {
154
+ Log.w(TAG, "postConversationId bridge failed", error)
155
+ }
156
+ }
157
+ }
158
+
159
+ private class ConversationBridge(private val webViewRef: WeakReference<WebView>) {
160
+ @JavascriptInterface
161
+ fun postConversationId(conversationId: String?) {
162
+ val id = conversationId?.takeIf { it.isNotBlank() } ?: return
163
+ val webView = webViewRef.get() ?: return
164
+ forwardConversationIdToNativeApp(webView, id)
165
+ }
166
+ }
167
+
168
+ private const val CONVERSATION_STARTED_BRIDGE_JS = """
169
+ (function() {
170
+ function bootstrap(win) {
171
+ var doc;
172
+ try { doc = win.document; } catch (_) { return; }
173
+ if (!doc) return;
174
+
175
+ if (doc.__rnHubspotConversationStartedBridgeInstalled) return;
176
+ doc.__rnHubspotConversationStartedBridgeInstalled = true;
177
+
178
+ function extractConversationId(payload) {
179
+ try {
180
+ var conversation = payload && payload.conversation;
181
+ var id = conversation && (
182
+ conversation.conversationId ||
183
+ conversation.threadId ||
184
+ conversation.id
185
+ );
186
+ if (id === undefined || id === null || id === '') return null;
187
+ return String(id);
188
+ } catch (_) {
189
+ return null;
190
+ }
191
+ }
192
+
193
+ function postConversationId(payload) {
194
+ var id = extractConversationId(payload);
195
+ if (!id) return;
196
+ try {
197
+ if (win.rnHubspotWrapper && typeof win.rnHubspotWrapper.postConversationId === 'function') {
198
+ win.rnHubspotWrapper.postConversationId(id);
199
+ }
200
+ } catch (_) {}
201
+ }
202
+
203
+ function configureHubspotConversations() {
204
+ if (!win.HubSpotConversations) return;
205
+ try {
206
+ win.HubSpotConversations.on('conversationStarted', postConversationId);
207
+ } catch (_) {}
208
+ }
209
+
210
+ if (win.HubSpotConversations) {
211
+ configureHubspotConversations();
212
+ } else if (Array.isArray(win.hsConversationsOnReady)) {
213
+ win.hsConversationsOnReady.push(configureHubspotConversations);
214
+ } else {
215
+ win.hsConversationsOnReady = [configureHubspotConversations];
216
+ }
217
+ }
218
+
219
+ function walkAllFrames(win) {
220
+ if (!win) return;
221
+ bootstrap(win);
222
+ try {
223
+ for (var i = 0; i < win.frames.length; i++) {
224
+ walkAllFrames(win.frames[i]);
225
+ }
226
+ } catch (_) {}
227
+ }
228
+
229
+ walkAllFrames(window);
230
+ })();
231
+ """
232
+ }
@@ -22,11 +22,11 @@ class HubspotWrapperModule(reactContext: ReactApplicationContext) :
22
22
  private lateinit var hubspotManager: HubspotManager
23
23
 
24
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
25
+ // Install the chat script bridge as soon as the wrapper module is created.
26
+ // The bridge only does work when `HubspotWebActivity` actually resumes, so this
27
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) }
28
+ // See `HubspotChatScriptInstaller` for why this lives outside the activity itself.
29
+ (appContext as? Application)?.let { HubspotChatScriptInstaller.installOnce(it) }
30
30
  }
31
31
 
32
32
  override fun getName(): String = NAME
@@ -42,7 +42,8 @@ class HubspotWrapperModule(reactContext: ReactApplicationContext) :
42
42
  }
43
43
  }
44
44
 
45
- override fun openChat(chatflow: String, promise: Promise) {
45
+ @Suppress("UNUSED_PARAMETER")
46
+ override fun openChat(chatflow: String, hideBackToInboxButton: Boolean, promise: Promise) {
46
47
  try {
47
48
  val intent = Intent(appContext, HubspotWebActivity::class.java)
48
49
  intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
@@ -56,7 +57,7 @@ class HubspotWrapperModule(reactContext: ReactApplicationContext) :
56
57
 
57
58
  override fun setIdentity(identityToken: String, email: String?, promise: Promise) {
58
59
  try {
59
- hubspotManager.setUserIdentity(identityToken, email ?: "")
60
+ hubspotManager.setUserIdentity(email ?: "", identityToken)
60
61
  promise.resolve(null)
61
62
  } catch (error: Exception) {
62
63
  promise.reject("IDENTITY_ERROR", "Failed to set HubSpot identity", error)
@@ -349,9 +349,6 @@ 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
-
355
352
  // create script that triggers on hubspot event, and calls our message handler
356
353
 
357
354
  let configCallbacksJS = """
@@ -5,25 +5,6 @@ import WebKit
5
5
 
6
6
  @objcMembers
7
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
-
27
8
  public func initialize(_ outError: NSErrorPointer) -> Bool {
28
9
  let semaphore = DispatchSemaphore(value: 0)
29
10
  var configureError: NSError?
@@ -46,7 +27,7 @@ public class HubspotWrapperImpl: NSObject {
46
27
  return true
47
28
  }
48
29
 
49
- public func openChat(_ chatflow: String, error outError: NSErrorPointer) -> Bool {
30
+ public func openChat(_ chatflow: String, hideBackToInboxButton _: Bool, error outError: NSErrorPointer) -> Bool {
50
31
  var didSucceed = false
51
32
  let presentBlock = {
52
33
  guard let rootVC = Self.topViewController() else {
@@ -120,9 +101,9 @@ public class HubspotWrapperImpl: NSObject {
120
101
  /// `sessionStorage` / `IndexedDB`. Cookie-only clearing leaves the draft visible
121
102
  /// on the next open, which is exactly the bug we kept seeing.
122
103
  ///
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.
104
+ /// We therefore remove all shared WebKit website data during logout. This is intentionally
105
+ /// broader than HubSpot-only cleanup: fetching individual `WKWebsiteDataRecord`s crashed
106
+ /// in production inside WebKit's `_fetchDataRecords...allDataStores` path.
126
107
  public func clearUserData(_ completion: @escaping () -> Void) {
127
108
  Task { @MainActor in
128
109
  HubspotManager.shared.clearUserData()
@@ -134,182 +115,9 @@ public class HubspotWrapperImpl: NSObject {
134
115
  private static func deleteAllChatWebsiteData() async {
135
116
  let store = WKWebsiteDataStore.default()
136
117
  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)
141
- }
142
- guard !matching.isEmpty else { return }
143
- await store.removeData(ofTypes: allTypes, for: matching)
118
+ await store.removeData(ofTypes: allTypes, modifiedSince: .distantPast)
144
119
  }
145
120
 
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
-
313
121
  private static func topViewController(
314
122
  base: UIViewController? = UIApplication.shared.connectedScenes
315
123
  .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;
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "react-native-hubspot-wrapper",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
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",
@@ -76,30 +76,6 @@ 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
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))
103
79
  PY
104
80
  }
105
81
 
package/src/index.ts CHANGED
@@ -7,6 +7,13 @@ export type SetIdentityParams = {
7
7
  email?: string | null;
8
8
  };
9
9
 
10
+ export type OpenChatOptions = {
11
+ /**
12
+ * @deprecated No-op. HubSpot's conversations navigation remains visible.
13
+ */
14
+ hideBackToInboxButton?: boolean;
15
+ };
16
+
10
17
  function ensureNonEmpty(value: string, fieldName: string): void {
11
18
  if (!value || !value.trim()) {
12
19
  throw new Error(`\`${fieldName}\` must be a non-empty string.`);
@@ -18,9 +25,9 @@ const HubspotWrapper = {
18
25
  return NativeHubspotWrapper.initialize();
19
26
  },
20
27
 
21
- openChat(chatflow: string): Promise<void> {
28
+ openChat(chatflow: string, options: OpenChatOptions = {}): Promise<void> {
22
29
  ensureNonEmpty(chatflow, 'chatflow');
23
- return NativeHubspotWrapper.openChat(chatflow);
30
+ return NativeHubspotWrapper.openChat(chatflow, options.hideBackToInboxButton ?? false);
24
31
  },
25
32
 
26
33
  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
@@ -1,250 +0,0 @@
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
- }