react-native-hubspot-wrapper 0.4.1 → 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
@@ -61,15 +61,9 @@ 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
- ```
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.
73
67
 
74
68
  ## API
75
69
 
@@ -83,9 +77,7 @@ await HubspotWrapper.openChat('support', {
83
77
 
84
78
  Opens the HubSpot chat UI for the provided chatflow.
85
79
 
86
- - `hideBackToInboxButton` defaults to `true`.
87
- - Set `hideBackToInboxButton: false` to keep HubSpot's default conversations
88
- navigation visible.
80
+ - `hideBackToInboxButton` is deprecated and ignored.
89
81
 
90
82
  ### `clearUserData()`
91
83
 
@@ -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,12 +42,12 @@ class HubspotWrapperModule(reactContext: ReactApplicationContext) :
42
42
  }
43
43
  }
44
44
 
45
+ @Suppress("UNUSED_PARAMETER")
45
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)
49
50
  intent.putExtra("chatflow", chatflow)
50
- intent.putExtra(HubspotBackButtonHider.EXTRA_HIDE_BACK_TO_INBOX_BUTTON, hideBackToInboxButton)
51
51
  appContext.startActivity(intent)
52
52
  promise.resolve(null)
53
53
  } catch (error: Exception) {
@@ -57,7 +57,7 @@ class HubspotWrapperModule(reactContext: ReactApplicationContext) :
57
57
 
58
58
  override fun setIdentity(identityToken: String, email: String?, promise: Promise) {
59
59
  try {
60
- hubspotManager.setUserIdentity(identityToken, email ?: "")
60
+ hubspotManager.setUserIdentity(email ?: "", identityToken)
61
61
  promise.resolve(null)
62
62
  } catch (error: Exception) {
63
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,27 +5,6 @@ import WebKit
5
5
 
6
6
  @objcMembers
7
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
-
29
8
  public func initialize(_ outError: NSErrorPointer) -> Bool {
30
9
  let semaphore = DispatchSemaphore(value: 0)
31
10
  var configureError: NSError?
@@ -48,7 +27,7 @@ public class HubspotWrapperImpl: NSObject {
48
27
  return true
49
28
  }
50
29
 
51
- public func openChat(_ chatflow: String, hideBackToInboxButton: Bool, error outError: NSErrorPointer) -> Bool {
30
+ public func openChat(_ chatflow: String, hideBackToInboxButton _: Bool, error outError: NSErrorPointer) -> Bool {
52
31
  var didSucceed = false
53
32
  let presentBlock = {
54
33
  guard let rootVC = Self.topViewController() else {
@@ -60,7 +39,6 @@ public class HubspotWrapperImpl: NSObject {
60
39
  return
61
40
  }
62
41
 
63
- Self.shouldHideBackToInboxButton = hideBackToInboxButton
64
42
  let chatView = HubspotChatView(chatFlow: chatflow)
65
43
  let hostingController = UIHostingController(rootView: chatView)
66
44
  rootVC.present(hostingController, animated: true)
@@ -123,9 +101,9 @@ public class HubspotWrapperImpl: NSObject {
123
101
  /// `sessionStorage` / `IndexedDB`. Cookie-only clearing leaves the draft visible
124
102
  /// on the next open, which is exactly the bug we kept seeing.
125
103
  ///
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.
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.
129
107
  public func clearUserData(_ completion: @escaping () -> Void) {
130
108
  Task { @MainActor in
131
109
  HubspotManager.shared.clearUserData()
@@ -137,184 +115,9 @@ public class HubspotWrapperImpl: NSObject {
137
115
  private static func deleteAllChatWebsiteData() async {
138
116
  let store = WKWebsiteDataStore.default()
139
117
  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)
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)
118
+ await store.removeData(ofTypes: allTypes, modifiedSince: .distantPast)
178
119
  }
179
120
 
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
-
318
121
  private static func topViewController(
319
122
  base: UIViewController? = UIApplication.shared.connectedScenes
320
123
  .compactMap { $0 as? UIWindowScene }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-hubspot-wrapper",
3
- "version": "0.4.1",
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",
@@ -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
@@ -8,6 +8,9 @@ export type SetIdentityParams = {
8
8
  };
9
9
 
10
10
  export type OpenChatOptions = {
11
+ /**
12
+ * @deprecated No-op. HubSpot's conversations navigation remains visible.
13
+ */
11
14
  hideBackToInboxButton?: boolean;
12
15
  };
13
16
 
@@ -24,7 +27,7 @@ const HubspotWrapper = {
24
27
 
25
28
  openChat(chatflow: string, options: OpenChatOptions = {}): Promise<void> {
26
29
  ensureNonEmpty(chatflow, 'chatflow');
27
- return NativeHubspotWrapper.openChat(chatflow, options.hideBackToInboxButton ?? true);
30
+ return NativeHubspotWrapper.openChat(chatflow, options.hideBackToInboxButton ?? false);
28
31
  },
29
32
 
30
33
  setIdentity(params: SetIdentityParams): Promise<void> {
@@ -1,279 +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
- 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
- }