@usergist/feedback-react-native 0.1.2 → 0.2.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -4
- package/EXPO-ACCEPTANCE.md +57 -0
- package/EXPO.md +103 -0
- package/README.md +69 -11
- package/UserGistFeedback.podspec +4 -1
- package/UserGistFeedbackExtension.podspec +1 -1
- package/android/build.gradle +3 -1
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/java/studio/usergist/feedback/UserGistFirebaseMessagingService.kt +78 -12
- package/android/src/main/java/studio/usergist/feedback/UserGistNotificationDismissReceiver.kt +17 -0
- package/android/src/main/java/studio/usergist/feedback/UserGistPushModule.kt +46 -5
- package/android/src/main/java/studio/usergist/feedback/UserGistPushState.kt +69 -0
- package/app.plugin.js +2 -0
- package/dist/NativeUserGistPush.d.ts +10 -0
- package/dist/NativeUserGistPush.d.ts.map +1 -1
- package/dist/NativeUserGistPush.js.map +1 -1
- package/dist/Provider.d.ts.map +1 -1
- package/dist/Provider.js +28 -17
- package/dist/Provider.js.map +1 -1
- package/dist/Push.d.ts +2 -0
- package/dist/Push.d.ts.map +1 -1
- package/dist/Push.js +50 -2
- package/dist/Push.js.map +1 -1
- package/dist/UserGist.d.ts +9 -4
- package/dist/UserGist.d.ts.map +1 -1
- package/dist/UserGist.js +492 -130
- package/dist/UserGist.js.map +1 -1
- package/dist/expo/index.d.ts +68 -0
- package/dist/expo/index.d.ts.map +1 -0
- package/dist/expo/index.js +120 -0
- package/dist/expo/index.js.map +1 -0
- package/dist/internal/consent.d.ts.map +1 -1
- package/dist/internal/consent.js +4 -1
- package/dist/internal/consent.js.map +1 -1
- package/dist/internal/context.d.ts +1 -1
- package/dist/internal/context.d.ts.map +1 -1
- package/dist/internal/context.js +1 -1
- package/dist/internal/context.js.map +1 -1
- package/dist/internal/engine.d.ts +8 -2
- package/dist/internal/engine.d.ts.map +1 -1
- package/dist/internal/engine.js +189 -39
- package/dist/internal/engine.js.map +1 -1
- package/dist/internal/inapp-rules-cache.d.ts +1 -0
- package/dist/internal/inapp-rules-cache.d.ts.map +1 -1
- package/dist/internal/inapp-rules-cache.js +7 -0
- package/dist/internal/inapp-rules-cache.js.map +1 -1
- package/dist/internal/mutation-queue.d.ts +2 -2
- package/dist/internal/mutation-queue.d.ts.map +1 -1
- package/dist/internal/mutation-queue.js +32 -4
- package/dist/internal/mutation-queue.js.map +1 -1
- package/dist/internal/push-dedupe.d.ts +3 -0
- package/dist/internal/push-dedupe.d.ts.map +1 -0
- package/dist/internal/push-dedupe.js +33 -0
- package/dist/internal/push-dedupe.js.map +1 -0
- package/dist/internal/rules-cache.d.ts +1 -0
- package/dist/internal/rules-cache.d.ts.map +1 -1
- package/dist/internal/rules-cache.js +7 -0
- package/dist/internal/rules-cache.js.map +1 -1
- package/dist/internal/storage.d.ts +1 -0
- package/dist/internal/storage.d.ts.map +1 -1
- package/dist/internal/storage.js +1 -0
- package/dist/internal/storage.js.map +1 -1
- package/dist/internal/survey-invitations.d.ts +25 -0
- package/dist/internal/survey-invitations.d.ts.map +1 -0
- package/dist/internal/survey-invitations.js +54 -0
- package/dist/internal/survey-invitations.js.map +1 -0
- package/dist/internal/survey-matcher.d.ts +1 -0
- package/dist/internal/survey-matcher.d.ts.map +1 -1
- package/dist/internal/survey-matcher.js +3 -0
- package/dist/internal/survey-matcher.js.map +1 -1
- package/dist/internal/survey-rules-cache.d.ts +1 -0
- package/dist/internal/survey-rules-cache.d.ts.map +1 -1
- package/dist/internal/survey-rules-cache.js +7 -0
- package/dist/internal/survey-rules-cache.js.map +1 -1
- package/dist/internal/survey-store.d.ts +9 -2
- package/dist/internal/survey-store.d.ts.map +1 -1
- package/dist/internal/survey-store.js +37 -16
- package/dist/internal/survey-store.js.map +1 -1
- package/dist/internal/transport.d.ts +15 -3
- package/dist/internal/transport.d.ts.map +1 -1
- package/dist/internal/transport.js +16 -13
- package/dist/internal/transport.js.map +1 -1
- package/dist/internal/types.d.ts +2 -0
- package/dist/internal/types.d.ts.map +1 -1
- package/dist/native/events.d.ts +2 -0
- package/dist/native/events.d.ts.map +1 -1
- package/dist/native/events.js +2 -0
- package/dist/native/events.js.map +1 -1
- package/dist/native/push-adapter.d.ts +11 -0
- package/dist/native/push-adapter.d.ts.map +1 -0
- package/dist/native/push-adapter.js +10 -0
- package/dist/native/push-adapter.js.map +1 -0
- package/dist/native/push-bridge.d.ts +9 -0
- package/dist/native/push-bridge.d.ts.map +1 -1
- package/dist/native/push-bridge.js +39 -4
- package/dist/native/push-bridge.js.map +1 -1
- package/dist/ui/SurveyView.d.ts.map +1 -1
- package/dist/ui/SurveyView.js +5 -5
- package/dist/ui/SurveyView.js.map +1 -1
- package/dist/ui/requests/RequestsHost.d.ts.map +1 -1
- package/dist/ui/requests/RequestsHost.js +8 -0
- package/dist/ui/requests/RequestsHost.js.map +1 -1
- package/expo-module.config.json +8 -0
- package/expo-support/UserGistExpoAppDelegateSubscriber.swift +34 -0
- package/expo-support/UserGistFeedbackExpo.podspec +17 -0
- package/ios/Extension/UserGistNotificationService.swift +52 -5
- package/ios/Shared/UserGistNotificationActions.swift +27 -0
- package/ios/Shared/UserGistPushState.swift +110 -0
- package/ios/UserGistPush.h +1 -6
- package/ios/UserGistPush.mm +18 -0
- package/ios/UserGistPushImpl.swift +46 -4
- package/ios/UserGistPushInterop.h +5 -0
- package/package.json +33 -7
- package/plugin/index.cjs +269 -0
- package/plugin/index.d.cts +14 -0
- package/plugin/templates/UserGistExpoMessagingService.kt +20 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 0.1
|
|
3
|
+
## 0.2.0-beta.1
|
|
4
4
|
|
|
5
|
-
- Add `presentationPaused`,
|
|
6
|
-
-
|
|
7
|
-
-
|
|
5
|
+
- Add startup presentation readiness: initialize with `presentationPaused`, then call `resumePresentation()` after the loaded screen is ready.
|
|
6
|
+
- Keep analytics and networking running while campaign UI is paused; `pausePresentation()` can protect later host flows without dismissing active UI.
|
|
7
|
+
- Invalidate queued presentation work after consent revocation, reset, or identity changes, including requests prepared asynchronously.
|
|
8
8
|
|
|
9
|
+
## 0.2.0-beta.0
|
|
10
|
+
|
|
11
|
+
- Add Expo config plugin, iOS extension/EAS setup, and an optional notification adapter.
|
|
12
|
+
- Preserve existing Expo notification ownership and shared provider tokens.
|
|
13
|
+
- Add scoped native delivery-receipt retries, Expo onboarding, demo and consumer checks.
|
|
14
|
+
- Keep logout and consent changes from accepting stale request responses or disabling a newer push session.
|
|
15
|
+
- Expo support remains prerelease until signed-device acceptance is complete.
|
|
9
16
|
|
|
10
17
|
All notable changes to `@usergist/feedback-react-native` are documented here.
|
|
11
18
|
Releases use [Semantic Versioning](https://semver.org/).
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# Expo release evidence
|
|
2
|
+
|
|
3
|
+
Status: implementation under validation; stable release is blocked.
|
|
4
|
+
|
|
5
|
+
Record the exact npm archive SHA256, source commit, Expo/React Native versions,
|
|
6
|
+
build profile, and app identifiers alongside each result. Never record tokens
|
|
7
|
+
or provider private keys. A mocked provider or generated Firebase fixture does
|
|
8
|
+
not count as a live push test.
|
|
9
|
+
|
|
10
|
+
## Automated gates
|
|
11
|
+
|
|
12
|
+
- Packed archive resolves every existing export, `/expo`, the Node config
|
|
13
|
+
plugin, Expo subscriber, Kotlin dispatcher, and notification extension files.
|
|
14
|
+
- Expo 56.0.21 and 57.0.20: automatic and expo-notifications mode, iOS and
|
|
15
|
+
Android, with the New Architecture and repeated non-destructive prebuild.
|
|
16
|
+
- Existing React Native typecheck, unit tests, codegen/package check, and builds.
|
|
17
|
+
- Dashboard/API validation, Expo platform mapping, AI snippets, and onboarding.
|
|
18
|
+
|
|
19
|
+
## Signed-device gates (pending)
|
|
20
|
+
|
|
21
|
+
- Physical iPhone and Android: anonymous startup, identify, logout/account switch,
|
|
22
|
+
offline relaunch and eventual synchronization; no cross-user data or token binding.
|
|
23
|
+
- All reference demo feedback types, surveys, in-app messages, request board,
|
|
24
|
+
comments/votes/follows, targeting, branding, and analytics.
|
|
25
|
+
- UserGist push: foreground/background/terminated launch; token refresh, permission
|
|
26
|
+
denial and later recovery, channels, badge behavior, images, actions, JSON actions,
|
|
27
|
+
deep links, silent reachability and delivered/open evidence.
|
|
28
|
+
- Coexistence: interleave unrelated Expo and UserGist messages, including cold
|
|
29
|
+
starts; one presentation and handler invocation per event/action. Disable
|
|
30
|
+
UserGist and verify unrelated notifications still arrive.
|
|
31
|
+
- Turn off networking while a native receipt is recorded; restore it and verify
|
|
32
|
+
the original delivery timestamp is acknowledged once. Logout before retry and
|
|
33
|
+
verify that old work is never attributed to the new account.
|
|
34
|
+
- iOS development APNs signing and a distribution-signed build, including
|
|
35
|
+
extension embedding, provisioning, App Group persistence and rich delivery.
|
|
36
|
+
|
|
37
|
+
## Promotion
|
|
38
|
+
|
|
39
|
+
Publish a prerelease with npm tag `next` for consumer acceptance after package
|
|
40
|
+
and native build gates pass. Keep stable `latest` unchanged until all signed
|
|
41
|
+
device gates above have evidence. Do not describe Expo support as production
|
|
42
|
+
validated while this document remains pending.
|
|
43
|
+
|
|
44
|
+
The public release workflow enforces this through
|
|
45
|
+
`tools/expo/check-release-evidence.mjs`. Supply the repository variable
|
|
46
|
+
`USERGIST_EXPO_RELEASE_EVIDENCE` as JSON with `version`, `archiveSha256`,
|
|
47
|
+
`sourceCommit`, `nativeBuildRunId`, and arrays `nativeBuilds`, `regressions`, `physicalDevices`.
|
|
48
|
+
Each entry has `name`, `passed: true`, and a durable HTTPS `evidenceUrl` to
|
|
49
|
+
the corresponding build or device report. The script lists the exact required
|
|
50
|
+
names. Prereleases require package, prebuild, ordinary React Native regression,
|
|
51
|
+
and all eight Expo native combinations; stable additionally requires signed
|
|
52
|
+
physical-device evidence. A missing variable blocks publication. CI builds one archive and passes that file
|
|
53
|
+
to every consumer; the publish job downloads it from `nativeBuildRunId` instead
|
|
54
|
+
of rebuilding it. The release tag must point to that run’s source commit.
|
|
55
|
+
|
|
56
|
+
For stable promotion, validate the final stable archive too: changing a
|
|
57
|
+
prerelease version changes the package bytes and invalidates its earlier hash.
|
package/EXPO.md
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# Expo integration (0.2 prerelease)
|
|
2
|
+
|
|
3
|
+
Expo uses the same `UserGist`, `Push`, and `UserGistProvider` as React Native.
|
|
4
|
+
The supported build targets are Expo 56 and 57 on iOS and Android, with the New
|
|
5
|
+
Architecture enabled. Use a development client or production binary. Expo Go
|
|
6
|
+
cannot provide the native bridge or durable secure storage. Browser targets
|
|
7
|
+
use `@usergist/feedback-web` separately.
|
|
8
|
+
|
|
9
|
+
## Base setup
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
npx expo install @usergist/feedback-react-native \
|
|
13
|
+
@react-native-async-storage/async-storage react-native-safe-area-context
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Add `"@usergist/feedback-react-native"` to `expo.plugins` in app.json or
|
|
17
|
+
app.config.js. Mount `UserGistProvider` once around the existing application:
|
|
18
|
+
in Expo Router's `app/_layout.tsx` wrap `Slot`/`Stack`; otherwise wrap App's
|
|
19
|
+
existing navigation. Call `initAsync` once with the public write key.
|
|
20
|
+
|
|
21
|
+
Anonymous users work without login. For identified users, obtain a scoped
|
|
22
|
+
subject token from your authenticated backend and call `identifyAsync`.
|
|
23
|
+
Never put a server API token in an `EXPO_PUBLIC_` variable. Connect `setConsent`
|
|
24
|
+
to the host's consent decisions, and await `reset()` before changing accounts.
|
|
25
|
+
The consent screen in our demo is a test harness, not a customer requirement.
|
|
26
|
+
|
|
27
|
+
## Push configuration
|
|
28
|
+
|
|
29
|
+
```js
|
|
30
|
+
plugins: [
|
|
31
|
+
['@usergist/feedback-react-native', {
|
|
32
|
+
push: {
|
|
33
|
+
mode: 'automatic',
|
|
34
|
+
ios: { apsEnvironment: 'development' },
|
|
35
|
+
// android: { notificationIcon: './assets/notification-icon.png',
|
|
36
|
+
// notificationColor: '#3366FF' },
|
|
37
|
+
},
|
|
38
|
+
}],
|
|
39
|
+
]
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Set `ios.bundleIdentifier`, `android.package`, and `android.googleServicesFile`.
|
|
43
|
+
The Google JSON is Firebase's client configuration for that package ID, not
|
|
44
|
+
a service-account private key. Upload APNs/FCM sending credentials to UserGist.
|
|
45
|
+
Never include the .p8 or service-account key in an Expo config or application.
|
|
46
|
+
|
|
47
|
+
`apsEnvironment` must match signing: development provisioning uses
|
|
48
|
+
`development`; TestFlight/App Store uses `production`. This is independent of
|
|
49
|
+
the UserGist data environment. Runtime registration reads this plugin setting;
|
|
50
|
+
call `enablePush()` without an environment override to use it.
|
|
51
|
+
|
|
52
|
+
The plugin generates the notification service extension and App Group needed
|
|
53
|
+
for rich media and native iOS delivery receipts. Defaults are
|
|
54
|
+
`UserGistNotificationServiceExtension`, `<bundleId>.UserGistNotificationServiceExtension`,
|
|
55
|
+
and `group.<bundleId>.usergist`. Override these with `extensionTargetName`,
|
|
56
|
+
`extensionBundleIdentifier`, and `appGroupIdentifier` under `push.ios`.
|
|
57
|
+
EAS receives the extension metadata before provisioning. Verify the app and
|
|
58
|
+
extension profiles include the same App Group.
|
|
59
|
+
|
|
60
|
+
Omitting `push` keeps notification acquisition disabled. Initialization and
|
|
61
|
+
configuration never request permission. After the user opts in and consent
|
|
62
|
+
synchronizes, call `enablePush` from a user action. Check `result.granted` and
|
|
63
|
+
`result.error`; `native_module_unavailable` or `push_not_configured` describes
|
|
64
|
+
build configuration, not an OS denial. Restore opt-in only after checking
|
|
65
|
+
current OS permission. `disablePush()` disables UserGist without removing an
|
|
66
|
+
Expo app's shared APNs/FCM registration.
|
|
67
|
+
|
|
68
|
+
## Existing expo-notifications
|
|
69
|
+
|
|
70
|
+
Keep its existing plugin and handlers; place its plugin before UserGist's and
|
|
71
|
+
select `push.mode: 'expo-notifications'`. Configure the adapter before init:
|
|
72
|
+
|
|
73
|
+
```ts
|
|
74
|
+
import * as Notifications from 'expo-notifications'
|
|
75
|
+
import { configureExpoNotifications } from '@usergist/feedback-react-native/expo'
|
|
76
|
+
const dispose = configureExpoNotifications(Notifications)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Call once in the app bootstrap, not on every screen mount. Cleanup removes
|
|
80
|
+
the adapter listeners without modifying host notification handlers. The helper
|
|
81
|
+
does not request permissions, grant consent, or register a user on import.
|
|
82
|
+
UserGist uses native device tokens from `getDevicePushTokenAsync`, never
|
|
83
|
+
ExpoPushToken values. The Android dispatcher routes UserGist payloads to our
|
|
84
|
+
native renderer and other messages to Expo. On iOS, Expo retains foreground
|
|
85
|
+
presentation policy and response handling. Your existing Router/deep-link
|
|
86
|
+
handlers remain responsible for navigation.
|
|
87
|
+
|
|
88
|
+
## Rebuilds and conflicts
|
|
89
|
+
|
|
90
|
+
Native/plugin changes require rebuilding with `npx expo run:ios`,
|
|
91
|
+
`npx expo run:android`, or EAS Build. EAS Update cannot add a native module.
|
|
92
|
+
For manually running prebuild against existing directories, Expo 57 requires
|
|
93
|
+
`--no-clean` to preserve those directories; Expo 56 preserves them by default.
|
|
94
|
+
|
|
95
|
+
The plugin does not overwrite a foreign extension target or custom FCM service.
|
|
96
|
+
If one exists, preserve it and integrate the existing host-forwarded `Push`
|
|
97
|
+
API and extension base class following the React Native README. Do not enable
|
|
98
|
+
two FCM services. Automatic composition is supported for expo-notifications;
|
|
99
|
+
other SDKs require an explicit integration. Change ownership modes in a fresh
|
|
100
|
+
native fixture, or review the existing host configuration before migrating.
|
|
101
|
+
|
|
102
|
+
Use the Expo demo and `EXPO-ACCEPTANCE.md` for acceptance. Building or receiving
|
|
103
|
+
a JavaScript callback alone is not physical-device push validation.
|
package/README.md
CHANGED
|
@@ -2,8 +2,41 @@
|
|
|
2
2
|
|
|
3
3
|
Production userGist SDK for React Native and the behavioral reference for the
|
|
4
4
|
iOS, Android, and Flutter packages. Use the dashboard's SDK setup flow to
|
|
5
|
-
verify authentication and ingestion
|
|
6
|
-
|
|
5
|
+
verify authentication and ingestion. The userGist APNs and FCM paths have
|
|
6
|
+
passed end-to-end physical-device validation; each host app still needs its own
|
|
7
|
+
provider credentials, identifiers, native callback setup, and signed-device
|
|
8
|
+
acceptance test.
|
|
9
|
+
|
|
10
|
+
## Expo
|
|
11
|
+
|
|
12
|
+
Expo 56/57 development and production build integration is being validated for
|
|
13
|
+
0.2. Use the same package with its config plugin; see [EXPO.md](EXPO.md).
|
|
14
|
+
Expo Go cannot load the native bridge. See [release gates](EXPO-ACCEPTANCE.md).
|
|
15
|
+
|
|
16
|
+
## Startup presentation readiness
|
|
17
|
+
|
|
18
|
+
Initialize with `presentationPaused` enabled at app launch. Analytics, consent,
|
|
19
|
+
identity, and networking continue while campaign UI waits. After the existing
|
|
20
|
+
startup loading and navigation have finished and the loaded screen is visible,
|
|
21
|
+
call `resumePresentation()`. Mount any required UserGist UI provider before that
|
|
22
|
+
callback. Readiness must work for both anonymous and identified users.
|
|
23
|
+
|
|
24
|
+
Call `pausePresentation()` before another flow that must not be interrupted.
|
|
25
|
+
Pausing does not dismiss an already visible SDK surface. Queued feedback,
|
|
26
|
+
surveys, and in-app messages are discarded if their consent is withdrawn or
|
|
27
|
+
the user changes, even if consent is granted again before resuming. Repeated
|
|
28
|
+
initialization keeps the first readiness setting; repeated resume calls do not
|
|
29
|
+
show the same queued work twice. The option defaults to false for existing
|
|
30
|
+
integrations, so upgrading alone does not enable startup deferral.
|
|
31
|
+
|
|
32
|
+
Do not resume from a splash screen, an app-root mount that still shows loading,
|
|
33
|
+
a disappearing screen, or a fixed timer. Use the host's existing completion
|
|
34
|
+
callback; the SDK cannot infer when arbitrary startup navigation has finished.
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
// In the loaded screen’s existing startup/navigation completion callback:
|
|
38
|
+
UserGist.resumePresentation()
|
|
39
|
+
```
|
|
7
40
|
|
|
8
41
|
## Install
|
|
9
42
|
|
|
@@ -65,6 +98,7 @@ AppRegistry.registerComponent('app', () => App)
|
|
|
65
98
|
| `UserGist.init(config)` | Returns synchronously, then hydrates and establishes the anonymous session in the background. |
|
|
66
99
|
| `await UserGist.initAsync(config, initialIdentity?)` | Hydrates and optionally binds a server-proven identity before lifecycle events begin; returns `synced`, `queued`, or `rejected`. |
|
|
67
100
|
| `UserGist.identify(userId, props?, subjectToken)` | Links the anonymous installation using a customer-backend-minted subject token. |
|
|
101
|
+
| `await UserGist.setUserProperties(values, unsetKeys?)` | Saves flat properties for the current anonymous or identified user; returns `synced`, `queued`, or `rejected`. Requires analytics consent. |
|
|
68
102
|
| `await UserGist.identifyAsync(userId, props?, subjectToken)` | Backward-compatible async identity API returning `synced`, `queued`, or `rejected`. |
|
|
69
103
|
| `UserGist.track(name, props?)` | Enqueues a stable event id and immediately evaluates only server-authorized client-side campaigns; all other decisions remain server-authoritative. |
|
|
70
104
|
| `await UserGist.setConsent({ analytics?, feedback?, push?, survey? })` | Persists and synchronizes the transition, refreshes targeting rules, then resolves with `true`; returns `false` when synchronization fails. |
|
|
@@ -102,6 +136,39 @@ UserGist.init(config)
|
|
|
102
136
|
The adapter uses the same asynchronous string interface as AsyncStorage, so it
|
|
103
137
|
can wrap the host application's Keychain/Keystore-backed storage.
|
|
104
138
|
|
|
139
|
+
## Personalization and immediate engagement
|
|
140
|
+
|
|
141
|
+
Use `await UserGist.setUserProperties({ first_name: 'Ava', country: 'IL' })` for
|
|
142
|
+
current profile values and `UserGist.track('show_watched', { show_id: '00123',
|
|
143
|
+
show_title: 'Midnight Orbit', position_seconds: 1234 })` for activity. Unset a
|
|
144
|
+
property with `await UserGist.setUserProperties({}, ['first_name'])`. Sensitive
|
|
145
|
+
fields follow the app's privacy allow-list. Both anonymous and identified users
|
|
146
|
+
are supported; await reset before switching accounts.
|
|
147
|
+
|
|
148
|
+
The dashboard's field picker can use a saved property, one latest matching event,
|
|
149
|
+
or the triggering event. Reuse one activity source for related title/ID/position
|
|
150
|
+
fields. Whole-value JSON tokens preserve types; your JSON action handler owns
|
|
151
|
+
navigation. See the [personalization guide](../../apps/landing/src/app/docs/(content)/guides/personalize-messages/page.mdx)
|
|
152
|
+
for authoring, fallbacks, recipient preview and a complete movie example.
|
|
153
|
+
|
|
154
|
+
With the matching API release, known server-dependent engagement events flush
|
|
155
|
+
immediately and can receive authorized content in the ingest response. Cached
|
|
156
|
+
native feedback/in-app matching remains local. Selecting web plus mobile retains
|
|
157
|
+
server consent/frequency checks while avoiding deliberate batch and polling waits.
|
|
158
|
+
|
|
159
|
+
Coordinated surveys can carry a prepared attempt. Eligible repeatable, uncapped,
|
|
160
|
+
non-personalized surveys can start from a signed cached permission valid for ten
|
|
161
|
+
minutes; already-started sessions can upload for seven days. The SDK saves the
|
|
162
|
+
original content and answers before background synchronization. Persistent storage
|
|
163
|
+
is required for recovery after termination; completion retains its submission/retry
|
|
164
|
+
handling. With a custom `onInvite` handler, `openSurvey(surveyId)` retains the
|
|
165
|
+
invitation's authorized questions and triggering-event values. An invitation does
|
|
166
|
+
not start a session until opened; failed starts retry the same attempt ID. Pending
|
|
167
|
+
invitations are identity-bound and expire with the instruction (at most 24 hours);
|
|
168
|
+
reset or survey-consent withdrawal clears them. On-demand/link resume remains
|
|
169
|
+
available. New personalized surveys still need online resolution. See the
|
|
170
|
+
[delivery and rollout notes](../../infra/docs/immediate-engagement.md).
|
|
171
|
+
|
|
105
172
|
## Architecture
|
|
106
173
|
|
|
107
174
|
The SDK is a singleton that orchestrates:
|
|
@@ -230,12 +297,3 @@ silent-ping cycle for this user (saves provider quota + battery).
|
|
|
230
297
|
## License
|
|
231
298
|
|
|
232
299
|
MIT
|
|
233
|
-
|
|
234
|
-
## Startup readiness
|
|
235
|
-
|
|
236
|
-
Initialize with `presentationPaused: true`. Keep `UserGistProvider` mounted, then
|
|
237
|
-
call `UserGist.resumePresentation()` from the host's existing completion callback
|
|
238
|
-
after startup loading and navigation finish. Do not resume from a splash screen,
|
|
239
|
-
a root mount while loading, or a timer. Both anonymous and identified users work.
|
|
240
|
-
`pausePresentation()` protects later host flows without dismissing active UI or
|
|
241
|
-
stopping analytics. Revoked consent and user changes invalidate queued messages.
|
package/UserGistFeedback.podspec
CHANGED
|
@@ -23,7 +23,10 @@ Pod::Spec.new do |s|
|
|
|
23
23
|
# The RN bridge module: RCTBridgeModule, RCTEventEmitter, Swift impl.
|
|
24
24
|
# Pulls in React-Core because it imports <React/...> headers.
|
|
25
25
|
s.subspec 'Core' do |core|
|
|
26
|
-
core.source_files = 'ios/*.{h,m,mm,swift}'
|
|
26
|
+
core.source_files = 'ios/*.{h,m,mm,swift}', 'ios/Shared/*.swift'
|
|
27
|
+
# This shim is internal to the Obj-C implementation. Exporting its React
|
|
28
|
+
# imports through our Swift umbrella can hide React types in host modules.
|
|
29
|
+
core.private_header_files = 'ios/UserGistPush.h'
|
|
27
30
|
core.frameworks = 'UIKit', 'UserNotifications', 'Security'
|
|
28
31
|
core.dependency 'React-Core'
|
|
29
32
|
end
|
|
@@ -18,6 +18,6 @@ Pod::Spec.new do |s|
|
|
|
18
18
|
s.source = { :git => 'https://github.com/Future-Picnic/usergist-js.git', :tag => "v#{s.version}" }
|
|
19
19
|
s.requires_arc = true
|
|
20
20
|
s.swift_version = '5.7'
|
|
21
|
-
s.source_files = 'ios/Extension/*.swift'
|
|
21
|
+
s.source_files = 'ios/Extension/*.swift', 'ios/Shared/*.swift'
|
|
22
22
|
s.frameworks = 'UserNotifications'
|
|
23
23
|
end
|
package/android/build.gradle
CHANGED
|
@@ -85,7 +85,9 @@ dependencies {
|
|
|
85
85
|
// plugin in their app/build.gradle (Google's hard requirement for FCM).
|
|
86
86
|
// Use a floor version that's broadly compatible with current Firebase BoM
|
|
87
87
|
// ranges; consumer's BoM (if any) will pick the actual version.
|
|
88
|
-
|
|
88
|
+
// The public native dispatcher accepts RemoteMessage, so host adapters need
|
|
89
|
+
// Firebase on their compile classpath as well as at runtime.
|
|
90
|
+
api "com.google.firebase:firebase-messaging:23.4.1"
|
|
89
91
|
|
|
90
92
|
implementation "androidx.core:core-ktx:1.12.0"
|
|
91
93
|
implementation "androidx.security:security-crypto:1.1.0-alpha06"
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
|
10
10
|
|
|
11
11
|
<application>
|
|
12
|
+
<receiver android:name="studio.usergist.feedback.UserGistNotificationDismissReceiver" android:exported="false" />
|
|
12
13
|
<!-- Library-supplied FirebaseMessagingService. Hosts that already own
|
|
13
14
|
FCM delivery must set userGistFirebaseServiceEnabled=false and
|
|
14
15
|
forward tokens/messages through the public Push API. -->
|
|
@@ -31,15 +31,30 @@ import org.json.JSONArray
|
|
|
31
31
|
*/
|
|
32
32
|
class UserGistFirebaseMessagingService : FirebaseMessagingService() {
|
|
33
33
|
|
|
34
|
-
override fun onNewToken(token: String)
|
|
35
|
-
|
|
36
|
-
|
|
34
|
+
override fun onNewToken(token: String) = handleToken(token)
|
|
35
|
+
override fun onMessageReceived(message: RemoteMessage) = handleMessage(this, message)
|
|
36
|
+
companion object {
|
|
37
|
+
@JvmStatic fun handleToken(token: String) = UserGistPushEventBus.emitToken(token)
|
|
38
|
+
@JvmStatic fun handleMessage(context: Context, message: RemoteMessage) {
|
|
39
|
+
if (!UserGistPushState.enabled(context)) return
|
|
40
|
+
// FCM already invokes this on its worker thread. Keep the service alive
|
|
41
|
+
// until presentation completes instead of leaving detached work behind.
|
|
42
|
+
UserGistMessageHandler(context.applicationContext).receive(message)
|
|
43
|
+
}
|
|
37
44
|
}
|
|
45
|
+
}
|
|
38
46
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
47
|
+
private class UserGistMessageHandler(context: Context) : android.content.ContextWrapper(context) {
|
|
48
|
+
fun receive(remoteMessage: RemoteMessage) {
|
|
49
|
+
val recipient = remoteMessage.data["usergist_anonymous_id"] ?: UserGistPushState.anonymousId(this)
|
|
50
|
+
if (!UserGistPushState.accepts(this, recipient)) return
|
|
51
|
+
if (remoteMessage.data["usergist_silent"] == "1") {
|
|
52
|
+
UserGistPushState.receipt(this, "silent-ack", remoteMessage.data["usergist_ping_id"] ?: "")
|
|
53
|
+
return
|
|
54
|
+
}
|
|
55
|
+
if (!remoteMessage.data.containsKey("usergist_campaign_id")) return
|
|
56
|
+
UserGistPushState.receipt(this, "delivered", remoteMessage.data["usergist_delivery_id"] ?: "")
|
|
57
|
+
val payload = normalize(remoteMessage, recipient)
|
|
43
58
|
UserGistPushEventBus.emitNotificationReceived(payload)
|
|
44
59
|
|
|
45
60
|
// Surface a system notification when the user-visible content is set.
|
|
@@ -52,7 +67,7 @@ class UserGistFirebaseMessagingService : FirebaseMessagingService() {
|
|
|
52
67
|
postSystemNotification(
|
|
53
68
|
title = title,
|
|
54
69
|
body = body,
|
|
55
|
-
data = remoteMessage.data
|
|
70
|
+
data = remoteMessage.data + ("usergist_anonymous_id" to recipient)
|
|
56
71
|
)
|
|
57
72
|
}
|
|
58
73
|
}
|
|
@@ -74,17 +89,22 @@ class UserGistFirebaseMessagingService : FirebaseMessagingService() {
|
|
|
74
89
|
val pendingFlags = PendingIntent.FLAG_UPDATE_CURRENT or
|
|
75
90
|
(if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0)
|
|
76
91
|
val contentIntent = launchIntent?.let {
|
|
77
|
-
PendingIntent.getActivity(this,
|
|
92
|
+
PendingIntent.getActivity(this, (data["usergist_delivery_id"] ?: data.toString()).hashCode(), it, pendingFlags)
|
|
78
93
|
}
|
|
79
94
|
|
|
80
|
-
val notifId = (
|
|
95
|
+
val notifId = (data["usergist_delivery_id"] ?: data.toString()).hashCode()
|
|
81
96
|
val builder = NotificationCompat.Builder(this, channelId)
|
|
82
|
-
.setSmallIcon(
|
|
97
|
+
.setSmallIcon(packageManager.getApplicationInfo(packageName, android.content.pm.PackageManager.GET_META_DATA).metaData?.getInt("UserGistNotificationIcon", 0)?.takeIf { it != 0 } ?: android.R.drawable.ic_dialog_info)
|
|
83
98
|
.setContentTitle(title)
|
|
84
99
|
.setContentText(body)
|
|
85
100
|
.setAutoCancel(true)
|
|
86
101
|
.setContentIntent(contentIntent)
|
|
87
102
|
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
|
103
|
+
data["usergist_badge"]?.let { raw ->
|
|
104
|
+
val badges = getSharedPreferences("usergist_push_badges", Context.MODE_PRIVATE)
|
|
105
|
+
val count = if (raw == "+1") badges.getInt("count", 0) + 1 else raw.toIntOrNull()
|
|
106
|
+
if (count != null && count >= 0) { builder.setNumber(count); badges.edit().putInt("count", count).apply() }
|
|
107
|
+
}
|
|
88
108
|
|
|
89
109
|
val rawActions = data["usergist_actions"]
|
|
90
110
|
if (!rawActions.isNullOrBlank() && launchIntent != null) {
|
|
@@ -107,8 +127,53 @@ class UserGistFirebaseMessagingService : FirebaseMessagingService() {
|
|
|
107
127
|
}
|
|
108
128
|
}
|
|
109
129
|
|
|
130
|
+
val color = packageManager.getApplicationInfo(packageName, android.content.pm.PackageManager.GET_META_DATA).metaData?.get("UserGistNotificationColor")
|
|
131
|
+
when (color) {
|
|
132
|
+
is Int -> builder.setColor(color)
|
|
133
|
+
is String -> runCatching { builder.setColor(android.graphics.Color.parseColor(color)) }
|
|
134
|
+
}
|
|
135
|
+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O && data["usergist_sound"] != "none") {
|
|
136
|
+
val sound = data["usergist_sound"]
|
|
137
|
+
val custom = sound?.takeIf { it != "default" }?.let { resources.getIdentifier(it.substringBeforeLast('.'), "raw", packageName) } ?: 0
|
|
138
|
+
builder.setSound(if (custom != 0) android.net.Uri.parse("android.resource://$packageName/$custom") else android.media.RingtoneManager.getDefaultUri(android.media.RingtoneManager.TYPE_NOTIFICATION))
|
|
139
|
+
}
|
|
140
|
+
data["usergist_image_url"]?.let { raw ->
|
|
141
|
+
var connection: java.net.HttpURLConnection? = null
|
|
142
|
+
try {
|
|
143
|
+
val url = java.net.URL(raw)
|
|
144
|
+
if (url.protocol == "https") {
|
|
145
|
+
connection = url.openConnection() as java.net.HttpURLConnection
|
|
146
|
+
connection.connectTimeout = 4000; connection.readTimeout = 4000
|
|
147
|
+
connection.instanceFollowRedirects = false
|
|
148
|
+
if (connection.responseCode in 200..299) {
|
|
149
|
+
val bytes = connection.inputStream.use { stream ->
|
|
150
|
+
val out = java.io.ByteArrayOutputStream(); val buffer = ByteArray(8192)
|
|
151
|
+
while (out.size() <= 5 * 1024 * 1024) { val count = stream.read(buffer); if (count < 0) break; out.write(buffer, 0, count) }
|
|
152
|
+
out.toByteArray()
|
|
153
|
+
}
|
|
154
|
+
if (bytes.size <= 5 * 1024 * 1024) {
|
|
155
|
+
val bounds = android.graphics.BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
|
156
|
+
android.graphics.BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
|
|
157
|
+
val decode = android.graphics.BitmapFactory.Options().apply { inSampleSize = (maxOf(bounds.outWidth, bounds.outHeight) / 1024).coerceAtLeast(1) }
|
|
158
|
+
android.graphics.BitmapFactory.decodeByteArray(bytes, 0, bytes.size, decode)?.let { builder.setStyle(NotificationCompat.BigPictureStyle().bigPicture(it)) }
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
} catch (_: Exception) { /* Text notification still displays. */ }
|
|
163
|
+
finally { connection?.disconnect() }
|
|
164
|
+
}
|
|
165
|
+
val dismiss = Intent(this, UserGistNotificationDismissReceiver::class.java).apply {
|
|
166
|
+
data.forEach { (k, v) -> putExtra(k, v) }
|
|
167
|
+
}
|
|
168
|
+
builder.setDeleteIntent(PendingIntent.getBroadcast(this, notifId, dismiss, pendingFlags))
|
|
110
169
|
val nm = NotificationManagerCompat.from(this)
|
|
170
|
+
if (!UserGistPushState.accepts(this, data["usergist_anonymous_id"]) || !nm.areNotificationsEnabled()) return
|
|
171
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && nm.getNotificationChannel(channelId)?.importance == NotificationManager.IMPORTANCE_NONE) return
|
|
111
172
|
nm.notify(notifId, builder.build())
|
|
173
|
+
UserGistPushState.receipt(this, "displayed", data["usergist_delivery_id"] ?: "")
|
|
174
|
+
val displayed = Arguments.createMap()
|
|
175
|
+
displayed.putMap("data", Arguments.createMap().apply { data.forEach { (k, v) -> putString(k, v) } })
|
|
176
|
+
UserGistPushEventBus.emitNotificationDisplayed(displayed)
|
|
112
177
|
}
|
|
113
178
|
|
|
114
179
|
private fun ensureChannel(ctx: Context): String {
|
|
@@ -133,7 +198,7 @@ class UserGistFirebaseMessagingService : FirebaseMessagingService() {
|
|
|
133
198
|
return if (nm.getNotificationChannel(requested) != null) requested else ensureChannel(ctx)
|
|
134
199
|
}
|
|
135
200
|
|
|
136
|
-
private fun normalize(msg: RemoteMessage): WritableMap {
|
|
201
|
+
private fun normalize(msg: RemoteMessage, recipient: String): WritableMap {
|
|
137
202
|
val out = Arguments.createMap()
|
|
138
203
|
msg.notification?.title?.let { out.putString("title", it) }
|
|
139
204
|
msg.notification?.body?.let { out.putString("body", it) }
|
|
@@ -141,6 +206,7 @@ class UserGistFirebaseMessagingService : FirebaseMessagingService() {
|
|
|
141
206
|
for ((k, v) in msg.data) {
|
|
142
207
|
data.putString(k, v)
|
|
143
208
|
}
|
|
209
|
+
data.putString("usergist_anonymous_id", recipient)
|
|
144
210
|
out.putMap("data", data)
|
|
145
211
|
val deliveryId = msg.data["delivery_id"] ?: msg.data["usergist_delivery_id"]
|
|
146
212
|
if (deliveryId != null) out.putString("deliveryId", deliveryId)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
package studio.usergist.feedback
|
|
2
|
+
|
|
3
|
+
import android.content.BroadcastReceiver
|
|
4
|
+
import android.content.Context
|
|
5
|
+
import android.content.Intent
|
|
6
|
+
|
|
7
|
+
class UserGistNotificationDismissReceiver : BroadcastReceiver() {
|
|
8
|
+
override fun onReceive(context: Context, intent: Intent) {
|
|
9
|
+
if (!UserGistPushState.accepts(context, intent.getStringExtra("usergist_anonymous_id"))) return
|
|
10
|
+
UserGistPushState.receipt(context, "dismissed", intent.getStringExtra("usergist_delivery_id") ?: "")
|
|
11
|
+
val body = com.facebook.react.bridge.Arguments.createMap()
|
|
12
|
+
body.putMap("data", com.facebook.react.bridge.Arguments.createMap().apply {
|
|
13
|
+
intent.extras?.keySet()?.forEach { key -> intent.getStringExtra(key)?.let { putString(key, it) } }
|
|
14
|
+
})
|
|
15
|
+
UserGistPushEventBus.emitNotificationDismissed(body)
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -49,6 +49,12 @@ class UserGistPushModule(reactContext: ReactApplicationContext) :
|
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
override fun getName(): String = NAME
|
|
52
|
+
override fun invalidate() {
|
|
53
|
+
reactApplicationContext.removeLifecycleEventListener(this)
|
|
54
|
+
reactApplicationContext.removeActivityEventListener(this)
|
|
55
|
+
UserGistPushEventBus.detach(reactApplicationContext)
|
|
56
|
+
super.invalidate()
|
|
57
|
+
}
|
|
52
58
|
|
|
53
59
|
private val securePreferences by lazy {
|
|
54
60
|
val masterKey = MasterKey.Builder(reactApplicationContext)
|
|
@@ -64,7 +70,8 @@ class UserGistPushModule(reactContext: ReactApplicationContext) :
|
|
|
64
70
|
}
|
|
65
71
|
|
|
66
72
|
override fun onHostResume() {
|
|
67
|
-
|
|
73
|
+
UserGistPushState.retry(reactApplicationContext)
|
|
74
|
+
reactApplicationContext.currentActivity?.intent?.let { consumeNotificationIntent(it) }
|
|
68
75
|
}
|
|
69
76
|
|
|
70
77
|
override fun onHostPause() = Unit
|
|
@@ -77,6 +84,7 @@ class UserGistPushModule(reactContext: ReactApplicationContext) :
|
|
|
77
84
|
|
|
78
85
|
private fun consumeNotificationIntent(intent: Intent) {
|
|
79
86
|
val extras = intent.extras ?: return
|
|
87
|
+
if (!UserGistPushState.accepts(reactApplicationContext, extras.getString("usergist_anonymous_id"))) return
|
|
80
88
|
val keys = extras.keySet().filter { it.startsWith("usergist_") }
|
|
81
89
|
if (keys.isEmpty()) return
|
|
82
90
|
val body = Arguments.createMap()
|
|
@@ -99,8 +107,23 @@ class UserGistPushModule(reactContext: ReactApplicationContext) :
|
|
|
99
107
|
|
|
100
108
|
// MARK: - Exported methods
|
|
101
109
|
|
|
110
|
+
@ReactMethod
|
|
111
|
+
fun getPushConfiguration(promise: Promise) {
|
|
112
|
+
promise.resolve(Arguments.createMap().apply { putString("mode", UserGistPushState.mode(reactApplicationContext)) })
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
@ReactMethod
|
|
116
|
+
fun configurePushState(state: ReadableMap, promise: Promise) {
|
|
117
|
+
runCatching { UserGistPushState.configure(reactApplicationContext, org.json.JSONObject(state.toHashMap())) }
|
|
118
|
+
.onSuccess { promise.resolve(null) }.onFailure { promise.reject("push_state_failed", it) }
|
|
119
|
+
}
|
|
120
|
+
|
|
102
121
|
@ReactMethod
|
|
103
122
|
fun enablePush(_options: ReadableMap?, promise: Promise) {
|
|
123
|
+
if (UserGistPushState.mode(reactApplicationContext) == "disabled") {
|
|
124
|
+
promise.resolve(buildResult(false, "not_determined", null, "push_not_configured"))
|
|
125
|
+
return
|
|
126
|
+
}
|
|
104
127
|
// Android < 13: notification permission is install-time, no runtime
|
|
105
128
|
// prompt. We can short-circuit straight to the token fetch.
|
|
106
129
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
|
@@ -112,7 +135,7 @@ class UserGistPushModule(reactContext: ReactApplicationContext) :
|
|
|
112
135
|
fetchTokenAndResolve(promise, status = "authorized")
|
|
113
136
|
return
|
|
114
137
|
}
|
|
115
|
-
val activity = currentActivity
|
|
138
|
+
val activity = reactApplicationContext.currentActivity
|
|
116
139
|
if (activity == null) {
|
|
117
140
|
// No activity: can't ask for permission. Resolve with current state.
|
|
118
141
|
val granted = ActivityCompat.checkSelfPermission(
|
|
@@ -165,6 +188,8 @@ class UserGistPushModule(reactContext: ReactApplicationContext) :
|
|
|
165
188
|
|
|
166
189
|
@ReactMethod
|
|
167
190
|
fun disablePush(promise: Promise) {
|
|
191
|
+
UserGistPushState.disable(reactApplicationContext)
|
|
192
|
+
if (UserGistPushState.isExpo(reactApplicationContext)) { promise.resolve(null); return }
|
|
168
193
|
FirebaseMessaging.getInstance().deleteToken()
|
|
169
194
|
.addOnCompleteListener { promise.resolve(null) }
|
|
170
195
|
}
|
|
@@ -233,10 +258,10 @@ class UserGistPushModule(reactContext: ReactApplicationContext) :
|
|
|
233
258
|
// Required for RN's NativeEventEmitter API even though we emit from a
|
|
234
259
|
// separate static bus — RN will warn otherwise.
|
|
235
260
|
@ReactMethod
|
|
236
|
-
fun addListener(_eventName: String?) {
|
|
261
|
+
fun addListener(_eventName: String?) { UserGistPushEventBus.addListener() }
|
|
237
262
|
|
|
238
263
|
@ReactMethod
|
|
239
|
-
fun removeListeners(_count: Double) {
|
|
264
|
+
fun removeListeners(_count: Double) { UserGistPushEventBus.removeListeners(_count.toInt()) }
|
|
240
265
|
|
|
241
266
|
// MARK: - Internal
|
|
242
267
|
|
|
@@ -287,10 +312,12 @@ internal object UserGistPushEventBus {
|
|
|
287
312
|
@Volatile private var reactContext: ReactApplicationContext? = null
|
|
288
313
|
private val pending: MutableList<Pair<String, WritableMap>> = mutableListOf()
|
|
289
314
|
private var initialNotification: WritableMap? = null
|
|
315
|
+
private var listenerCount = 0
|
|
290
316
|
|
|
291
317
|
@Synchronized
|
|
292
318
|
fun attach(ctx: ReactApplicationContext) {
|
|
293
319
|
this.reactContext = ctx
|
|
320
|
+
if (listenerCount == 0) return
|
|
294
321
|
val drained = pending.toList()
|
|
295
322
|
pending.clear()
|
|
296
323
|
for ((name, body) in drained) {
|
|
@@ -298,6 +325,20 @@ internal object UserGistPushEventBus {
|
|
|
298
325
|
}
|
|
299
326
|
}
|
|
300
327
|
|
|
328
|
+
@Synchronized
|
|
329
|
+
fun addListener() {
|
|
330
|
+
listenerCount++
|
|
331
|
+
// Drain after all JS subscriptions in this tick have been registered.
|
|
332
|
+
android.os.Handler(android.os.Looper.getMainLooper()).post {
|
|
333
|
+
synchronized(this) { if (listenerCount > 0) reactContext?.let { attach(it) } }
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
@Synchronized fun removeListeners(count: Int) { listenerCount = (listenerCount - count).coerceAtLeast(0) }
|
|
337
|
+
@Synchronized fun detach(ctx: ReactApplicationContext) { if (reactContext === ctx) { reactContext = null; listenerCount = 0 } }
|
|
338
|
+
|
|
339
|
+
fun emitNotificationDisplayed(body: WritableMap) = emit("UserGistPush:notificationDisplayed", body)
|
|
340
|
+
fun emitNotificationDismissed(body: WritableMap) = emit("UserGistPush:notificationDismissed", body)
|
|
341
|
+
|
|
301
342
|
fun emitToken(token: String) {
|
|
302
343
|
val map = Arguments.createMap()
|
|
303
344
|
map.putString("token", token)
|
|
@@ -328,7 +369,7 @@ internal object UserGistPushEventBus {
|
|
|
328
369
|
@Synchronized
|
|
329
370
|
private fun emit(name: String, body: WritableMap) {
|
|
330
371
|
val ctx = reactContext
|
|
331
|
-
if (ctx == null || !ctx.hasActiveReactInstance()) {
|
|
372
|
+
if (ctx == null || !ctx.hasActiveReactInstance() || listenerCount == 0) {
|
|
332
373
|
if (pending.size >= 100) pending.removeAt(0)
|
|
333
374
|
pending.add(name to body)
|
|
334
375
|
return
|