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.
- package/android/.gradle/8.13/checksums/checksums.lock +0 -0
- package/android/.gradle/8.13/fileChanges/last-build.bin +0 -0
- package/android/.gradle/8.13/fileHashes/fileHashes.lock +0 -0
- package/android/.gradle/8.13/gc.properties +0 -0
- package/android/.gradle/buildOutputCleanup/buildOutputCleanup.lock +0 -0
- package/android/.gradle/buildOutputCleanup/cache.properties +2 -0
- package/android/.gradle/vcs-1/gc.properties +0 -0
- package/android/src/main/java/com/reactnativehubspotwrapper/HubspotBackButtonHider.kt +250 -0
- package/android/src/main/java/com/reactnativehubspotwrapper/HubspotWrapperModule.kt +99 -2
- package/ios/HubspotMobileSDK/Views/ChatView/HubspotChatView.swift +3 -0
- package/ios/HubspotWrapperImpl.swift +227 -3
- package/ios/RNHubspotWrapper.mm +7 -2
- package/package.json +1 -1
- package/scripts/update-hubspot-ios-sdk.sh +25 -0
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
File without changes
|
|
Binary file
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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 }
|
package/ios/RNHubspotWrapper.mm
CHANGED
|
@@ -59,8 +59,13 @@ RCT_EXPORT_MODULE(NativeHubspotWrapper)
|
|
|
59
59
|
|
|
60
60
|
- (void)clearUserData:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject
|
|
61
61
|
{
|
|
62
|
-
|
|
63
|
-
|
|
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
|
@@ -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
|
|