@pnlight/sdk-react-native 0.4.0 → 0.4.2

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.
@@ -0,0 +1,62 @@
1
+ import { StyleProp, ViewStyle } from "react-native";
2
+
3
+ export interface ActionEvent {
4
+ /**
5
+ * Full URL from the action (e.g., "myapp://button-clicked?id=primary")
6
+ * Note: Only custom URL schemes are passed to this callback.
7
+ * Standard http/https URLs are handled automatically by DivKit.
8
+ */
9
+ url: string;
10
+
11
+ /**
12
+ * URL scheme (e.g., "myapp", "app")
13
+ * Note: Will never be "http" or "https" as those are handled automatically.
14
+ */
15
+ scheme: string;
16
+
17
+ /**
18
+ * URL path (e.g., "/button-clicked")
19
+ */
20
+ path: string;
21
+
22
+ /**
23
+ * Parsed query parameters as key-value pairs
24
+ */
25
+ params: { [key: string]: string };
26
+
27
+ /**
28
+ * DivKit log ID (if available)
29
+ */
30
+ logId?: string;
31
+ }
32
+
33
+ export interface RemoteUiViewProps {
34
+ /**
35
+ * Placement ID passed to getUIConfig(placement)
36
+ */
37
+ placement: string;
38
+
39
+ /**
40
+ * Optional style for the container
41
+ */
42
+ style?: StyleProp<ViewStyle>;
43
+
44
+ /**
45
+ * Optional DivKit card ID (defaults to placement-based ID)
46
+ */
47
+ cardId?: string;
48
+
49
+ /**
50
+ * Callback for handling button clicks with custom URL schemes.
51
+ * Only called for custom schemes (myapp://, app://, etc.).
52
+ * Standard http/https URLs are handled automatically by DivKit.
53
+ * @param event - Action event containing URL, scheme, path, params, and logId
54
+ */
55
+ onAction?: (event: ActionEvent) => void;
56
+ }
57
+
58
+ /**
59
+ * Remote UI view: fetches UI config for the given placement via getUIConfig,
60
+ * shows nothing while loading, then renders the config in a native DivKit view.
61
+ */
62
+ export function RemoteUiView(props: RemoteUiViewProps): JSX.Element;
@@ -0,0 +1,58 @@
1
+ "use strict";
2
+
3
+ const React = require("react");
4
+ const { requireNativeComponent } = require("react-native");
5
+
6
+ const NativeRemoteUiView = requireNativeComponent("RemoteUiView");
7
+
8
+ async function getUIConfig(placement) {
9
+ return await NativeRemoteUiView.getUIConfig(placement);
10
+ }
11
+
12
+ /**
13
+ * Remote UI view: fetches UI config for the given placement via getUIConfig,
14
+ * shows nothing while loading, then renders the config in a native DivKit view.
15
+ *
16
+ * @param {object} props
17
+ * @param {string} props.placement - Placement id passed to getUIConfig(placement)
18
+ * @param {object} props.style - Optional style for the container
19
+ * @param {string} [props.cardId] - Optional DivKit card id (defaults to placement-based)
20
+ * @param {function} [props.onAction] - Callback for handling button clicks and custom actions
21
+ */
22
+ function RemoteUiView({ placement, style, cardId, onAction }) {
23
+ const [config, setConfig] = React.useState(null);
24
+ const cardIdToUse =
25
+ cardId ?? (placement ? `pnlight_${placement}` : "pnlight_card");
26
+
27
+ React.useEffect(() => {
28
+ if (!placement) return;
29
+ let cancelled = false;
30
+ getUIConfig(placement).then((uiConfig) => {
31
+ if (cancelled) return;
32
+ setConfig(uiConfig?.config ?? null);
33
+ });
34
+ return () => {
35
+ cancelled = true;
36
+ };
37
+ }, [placement]);
38
+
39
+ const handleAction = React.useCallback(
40
+ (event) => {
41
+ if (onAction) {
42
+ onAction(event.nativeEvent);
43
+ }
44
+ },
45
+ [onAction],
46
+ );
47
+
48
+ return (
49
+ <NativeRemoteUiView
50
+ style={[{ minHeight: 1, flex: 1 }, style]}
51
+ config={config}
52
+ cardId={cardIdToUse}
53
+ onAction={handleAction}
54
+ />
55
+ );
56
+ }
57
+
58
+ module.exports = { RemoteUiView };
package/app.plugin.js ADDED
@@ -0,0 +1,3 @@
1
+ "use strict";
2
+
3
+ module.exports = require("./plugin");
package/ios/PNLightSDK.m CHANGED
@@ -2,9 +2,12 @@
2
2
  @import PNLight;
3
3
 
4
4
  @interface RCT_EXTERN_MODULE(PNLightRN, NSObject)
5
- RCT_EXTERN_METHOD(initialize:(NSString *)apiKey resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject)
5
+ RCT_EXTERN_METHOD(initialize:(NSString *)apiKey baseDomain:(NSString *)baseDomain resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject)
6
6
  RCT_EXTERN_METHOD(validatePurchase:(BOOL)captcha resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject)
7
7
  RCT_EXTERN_METHOD(logEvent:(NSString *)eventName eventArgs:(NSDictionary *)eventArgs resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject)
8
+ RCT_EXTERN_METHOD(getUserId:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject)
9
+ RCT_EXTERN_METHOD(resetUserId:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject)
10
+ RCT_EXTERN_METHOD(addAttribution:(NSString *)providerString data:(NSDictionary *)data identifier:(NSString *)identifier resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject)
8
11
  RCT_EXTERN_METHOD(prefetchUIConfig:(NSString *)placement resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject)
9
12
  RCT_EXTERN_METHOD(getUIConfig:(NSString *)placement resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject)
10
13
  @end
@@ -26,12 +26,12 @@ class PNLightRNModule: NSObject {
26
26
  }
27
27
  }
28
28
 
29
- @objc func getUserId(_ resolver resolve: @escaping RCTPromiseResolveBlock, rejecter reject: @escaping RCTPromiseRejectBlock) {
29
+ @objc func getUserId(resolver resolve: @escaping RCTPromiseResolveBlock, rejecter reject: @escaping RCTPromiseRejectBlock) {
30
30
  let userId = PNLightSDK.shared.getOrCreateUserId().id
31
31
  resolve(userId)
32
32
  }
33
33
 
34
- @objc func resetUserId(_ resolver resolve: @escaping RCTPromiseResolveBlock, rejecter reject: @escaping RCTPromiseRejectBlock) {
34
+ @objc func resetUserId(resolver resolve: @escaping RCTPromiseResolveBlock, rejecter reject: @escaping RCTPromiseRejectBlock) {
35
35
  PNLightSDK.shared.resetUserId()
36
36
  resolve(nil)
37
37
  }
@@ -1,45 +1,237 @@
1
1
  import UIKit
2
2
  import DivKit
3
3
 
4
- /// Native UIView that renders PNLight UI config (DivKit JSON) inside a DivView.
5
- /// Shows nothing until config is set; then renders via DivKit per https://divkit.tech/docs/en/quickstart/ios
6
4
  @objc(RemoteUiView)
7
5
  final class RemoteUiView: UIView {
8
6
 
7
+ // MARK: - URL handler for DivKit
8
+ private final class RemoteUiUrlHandler: DivUrlHandler {
9
+ weak var owner: RemoteUiView?
10
+
11
+ func handle(_ url: URL, info: DivActionInfo, sender: AnyObject?) {
12
+ guard let owner else { return }
13
+
14
+ if owner.isCustomAction(url) {
15
+ owner.handleDivAction(info) // send to RN
16
+ return
17
+ }
18
+
19
+ // Default behavior for normal links
20
+ if url.scheme?.lowercased() == "http" || url.scheme?.lowercased() == "https" {
21
+ DispatchQueue.main.async {
22
+ UIApplication.shared.open(url, options: [:], completionHandler: nil)
23
+ }
24
+ } else {
25
+ // Other non-http(s) schemes: either treat as custom or try open
26
+ DispatchQueue.main.async {
27
+ UIApplication.shared.open(url, options: [:], completionHandler: nil)
28
+ }
29
+ }
30
+ }
31
+
32
+ // Backward compatible overload (DivKit may call this)
33
+ func handle(_ url: URL, sender: AnyObject?) {
34
+ // If DivKit calls the short version, we still try to open it
35
+ DispatchQueue.main.async {
36
+ UIApplication.shared.open(url, options: [:], completionHandler: nil)
37
+ }
38
+ }
39
+ }
40
+
9
41
  private let divView: DivView
10
- private static let divKitComponents = DivKitComponents()
42
+ private let divKitComponents: DivKitComponents
43
+ private let urlHandler: RemoteUiUrlHandler
44
+
11
45
  private var currentCardId: String = "pnlight_card"
12
46
 
47
+ private let loadingIndicator: UIActivityIndicatorView
48
+ private let errorLabel: UILabel
49
+ private var secureContainer: UIView?
50
+
51
+ @objc var onAction: (([String: Any]) -> Void)?
52
+
13
53
  override init(frame: CGRect) {
14
- self.divView = DivView(divKitComponents: Self.divKitComponents)
54
+ self.loadingIndicator = UIActivityIndicatorView(style: .large)
55
+ self.errorLabel = UILabel()
56
+
57
+ // Create url handler first (needed for DivKitComponents init)
58
+ self.urlHandler = RemoteUiUrlHandler()
59
+
60
+ // Pass urlHandler via initializer (actionHandler is a let constant inside DivKitComponents)
61
+ self.divKitComponents = DivKitComponents(urlHandler: urlHandler)
62
+ self.divView = DivView(divKitComponents: divKitComponents)
63
+
15
64
  super.init(frame: frame)
65
+
66
+ // bind back-reference
67
+ self.urlHandler.owner = self
68
+
69
+ backgroundColor = .clear
70
+
16
71
  divView.translatesAutoresizingMaskIntoConstraints = false
17
- addSubview(divView)
18
- NSLayoutConstraint.activate([
19
- divView.topAnchor.constraint(equalTo: topAnchor),
20
- divView.leadingAnchor.constraint(equalTo: leadingAnchor),
21
- divView.trailingAnchor.constraint(equalTo: trailingAnchor),
22
- divView.bottomAnchor.constraint(equalTo: bottomAnchor),
23
- ])
72
+ divView.alpha = 0
73
+ divView.backgroundColor = .clear
74
+
75
+ loadingIndicator.translatesAutoresizingMaskIntoConstraints = false
76
+ loadingIndicator.hidesWhenStopped = false
77
+
78
+ errorLabel.translatesAutoresizingMaskIntoConstraints = false
79
+ errorLabel.text = "Failed to load DivKit content"
80
+ errorLabel.textColor = .red
81
+ errorLabel.textAlignment = .center
82
+ errorLabel.numberOfLines = 0
83
+ errorLabel.alpha = 0
84
+
85
+ let isSecure = true
86
+ if isSecure, let secure = Self.makeSecureContainer() {
87
+ secure.translatesAutoresizingMaskIntoConstraints = false
88
+ self.secureContainer = secure
89
+
90
+ secure.addSubview(divView)
91
+ secure.addSubview(loadingIndicator)
92
+ secure.addSubview(errorLabel)
93
+ addSubview(secure)
94
+
95
+ NSLayoutConstraint.activate([
96
+ secure.topAnchor.constraint(equalTo: topAnchor),
97
+ secure.leadingAnchor.constraint(equalTo: leadingAnchor),
98
+ secure.trailingAnchor.constraint(equalTo: trailingAnchor),
99
+ secure.bottomAnchor.constraint(equalTo: bottomAnchor),
100
+
101
+ divView.topAnchor.constraint(equalTo: secure.topAnchor),
102
+ divView.leadingAnchor.constraint(equalTo: secure.leadingAnchor),
103
+ divView.trailingAnchor.constraint(equalTo: secure.trailingAnchor),
104
+ divView.bottomAnchor.constraint(equalTo: secure.bottomAnchor),
105
+
106
+ loadingIndicator.centerXAnchor.constraint(equalTo: secure.centerXAnchor),
107
+ loadingIndicator.centerYAnchor.constraint(equalTo: secure.centerYAnchor),
108
+
109
+ errorLabel.topAnchor.constraint(equalTo: secure.topAnchor),
110
+ errorLabel.leadingAnchor.constraint(equalTo: secure.leadingAnchor),
111
+ errorLabel.trailingAnchor.constraint(equalTo: secure.trailingAnchor),
112
+ errorLabel.bottomAnchor.constraint(equalTo: secure.bottomAnchor),
113
+ ])
114
+ } else {
115
+ addSubview(divView)
116
+ addSubview(loadingIndicator)
117
+ addSubview(errorLabel)
118
+
119
+ NSLayoutConstraint.activate([
120
+ divView.topAnchor.constraint(equalTo: topAnchor),
121
+ divView.leadingAnchor.constraint(equalTo: leadingAnchor),
122
+ divView.trailingAnchor.constraint(equalTo: trailingAnchor),
123
+ divView.bottomAnchor.constraint(equalTo: bottomAnchor),
124
+
125
+ loadingIndicator.centerXAnchor.constraint(equalTo: centerXAnchor),
126
+ loadingIndicator.centerYAnchor.constraint(equalTo: centerYAnchor),
127
+
128
+ errorLabel.topAnchor.constraint(equalTo: topAnchor),
129
+ errorLabel.leadingAnchor.constraint(equalTo: leadingAnchor),
130
+ errorLabel.trailingAnchor.constraint(equalTo: trailingAnchor),
131
+ errorLabel.bottomAnchor.constraint(equalTo: bottomAnchor),
132
+ ])
133
+ }
24
134
  }
25
135
 
26
136
  required init?(coder: NSCoder) {
27
137
  fatalError("init(coder:) has not been implemented")
28
138
  }
29
139
 
30
- /// Renders the given DivKit JSON string. Pass nil to show blank (e.g. while loading).
140
+ private static func makeSecureContainer() -> UIView? {
141
+ let textField = UITextField()
142
+ textField.isSecureTextEntry = true
143
+ textField.isUserInteractionEnabled = false
144
+
145
+ guard let secureLayer = textField.layer.sublayers?.first,
146
+ let secureView = secureLayer.delegate as? UIView else {
147
+ return nil
148
+ }
149
+
150
+ secureView.subviews.forEach { $0.removeFromSuperview() }
151
+ secureView.isUserInteractionEnabled = true
152
+ secureView.backgroundColor = .clear
153
+ return secureView
154
+ }
155
+
31
156
  private func applyConfig(configJson: String?, cardId: String) {
32
- guard let configJson = configJson, !configJson.isEmpty,
157
+ guard let configJson, !configJson.isEmpty,
33
158
  let jsonData = configJson.data(using: .utf8) else {
34
159
  return
35
160
  }
36
- divView.setSource(DivViewSource(kind: .data(jsonData), cardId: cardId))
161
+
162
+ errorLabel.alpha = 0
163
+ errorLabel.text = "Failed to load DivKit content"
164
+ loadingIndicator.startAnimating()
165
+ loadingIndicator.alpha = 1
166
+ divView.alpha = 0
167
+
168
+ let source = DivViewSource(
169
+ kind: .data(jsonData),
170
+ cardId: DivCardID(rawValue: cardId) ?? "divkit"
171
+ )
172
+
173
+ Task { @MainActor in
174
+ do {
175
+ try await self.divView.setSource(source)
176
+ self.showContent()
177
+ } catch {
178
+ self.showError(error: error)
179
+ }
180
+ }
181
+ }
182
+
183
+ private func showContent() {
184
+ loadingIndicator.stopAnimating()
185
+ UIView.animate(withDuration: 0.3) {
186
+ self.loadingIndicator.alpha = 0
187
+ self.divView.alpha = 1
188
+ }
189
+ }
190
+
191
+ private func showError(error: Error) {
192
+ errorLabel.text = "Failed to load DivKit content:\n\(error.localizedDescription)"
193
+ loadingIndicator.stopAnimating()
194
+ UIView.animate(withDuration: 0.3) {
195
+ self.loadingIndicator.alpha = 0
196
+ self.errorLabel.alpha = 1
197
+ }
198
+ }
199
+
200
+ private func isCustomAction(_ url: URL) -> Bool {
201
+ guard let scheme = url.scheme?.lowercased() else { return false }
202
+ return scheme != "http" && scheme != "https"
203
+ }
204
+
205
+ private func handleDivAction(_ action: DivActionInfo) {
206
+ guard let onAction else { return }
207
+
208
+ var payload: [String: Any] = [:]
209
+
210
+ if let url = action.url {
211
+ payload["url"] = url.absoluteString
212
+
213
+ if let components = URLComponents(url: url, resolvingAgainstBaseURL: false) {
214
+ payload["scheme"] = components.scheme ?? ""
215
+ payload["path"] = components.path
216
+
217
+ if let queryItems = components.queryItems {
218
+ var params: [String: String] = [:]
219
+ for item in queryItems {
220
+ params[item.name] = item.value ?? ""
221
+ }
222
+ payload["params"] = params
223
+ }
224
+ }
225
+ }
226
+
227
+ payload["logId"] = action.logId
228
+
229
+ onAction(payload)
37
230
  }
38
231
 
39
- // MARK: - React Native view props
232
+ // MARK: - React Native props
40
233
  @objc func setConfig(_ value: NSString?) {
41
- let json = value as String?
42
- applyConfig(configJson: json, cardId: currentCardId)
234
+ applyConfig(configJson: value as String?, cardId: currentCardId)
43
235
  }
44
236
 
45
237
  @objc func setCardId(_ value: NSString?) {
@@ -3,4 +3,5 @@
3
3
  @interface RCT_EXTERN_MODULE(RemoteUiViewManager, RCTViewManager)
4
4
  RCT_EXPORT_VIEW_PROPERTY(config, NSString)
5
5
  RCT_EXPORT_VIEW_PROPERTY(cardId, NSString)
6
+ RCT_EXPORT_VIEW_PROPERTY(onAction, RCTDirectEventBlock)
6
7
  @end
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pnlight/sdk-react-native",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "React Native wrapper for PNLight iOS binary SDK",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -10,16 +10,30 @@
10
10
  "index.d.ts",
11
11
  "ios",
12
12
  "PNLight.xcframework",
13
- "PNLightSDK-ReactNative.podspec"
13
+ "PNLightSDK-ReactNative.podspec",
14
+ "RemoteUiView.js",
15
+ "RemoteUiView.d.ts",
16
+ "app.plugin.js",
17
+ "plugin"
14
18
  ],
15
19
  "keywords": [
16
20
  "pnlight",
17
21
  "analytics",
18
22
  "in-app-purchase",
19
- "ios"
23
+ "ios",
24
+ "expo"
20
25
  ],
21
26
  "author": "PNLight",
22
27
  "license": "Commercial",
28
+ "peerDependencies": {
29
+ "react-native": "*",
30
+ "expo": "*"
31
+ },
32
+ "peerDependenciesMeta": {
33
+ "expo": {
34
+ "optional": true
35
+ }
36
+ },
23
37
  "scripts": {
24
38
  "prepare": "node ./scripts/copy-xcframework.js",
25
39
  "publish:dry": "npm pack",
@@ -0,0 +1,17 @@
1
+ "use strict";
2
+
3
+ const { withIosPodfileSource } = require("./withIos");
4
+
5
+ /**
6
+ * Expo config plugin for @pnlight/sdk-react-native.
7
+ * Configures the iOS Podfile with the DivKit source required by RemoteUiView.
8
+ *
9
+ * Usage in app.json / app.config.js:
10
+ * "plugins": ["@pnlight/sdk-react-native"]
11
+ */
12
+ function withPNLightSDK(config) {
13
+ config = withIosPodfileSource(config);
14
+ return config;
15
+ }
16
+
17
+ module.exports = withPNLightSDK;
@@ -0,0 +1,39 @@
1
+ "use strict";
2
+
3
+ const { withDangerousMod } = require("expo/config-plugins");
4
+ const fs = require("fs");
5
+ const path = require("path");
6
+
7
+ const DIVKIT_SOURCE = "source 'https://github.com/divkit/divkit-ios.git'";
8
+ const DIVKIT_MARKER = "divkit-ios";
9
+
10
+ /**
11
+ * Adds DivKit CocoaPods source to the Podfile (required by PNLightSDK-ReactNative for RemoteUiView).
12
+ * Idempotent: safe to run prebuild multiple times.
13
+ */
14
+ function withIosPodfileSource(config) {
15
+ return withDangerousMod(config, [
16
+ "ios",
17
+ async (config) => {
18
+ const podfilePath = path.join(
19
+ config.modRequest.platformProjectRoot,
20
+ "Podfile"
21
+ );
22
+ let contents = await fs.promises.readFile(podfilePath, "utf8");
23
+
24
+ if (contents.includes(DIVKIT_MARKER)) {
25
+ return config;
26
+ }
27
+
28
+ const lines = contents.split("\n");
29
+ const insertIndex =
30
+ lines.findIndex((line) => /^\s*source\s+['\"]/.test(line)) + 1;
31
+ const idx = insertIndex > 0 ? insertIndex : 1;
32
+ lines.splice(idx, 0, DIVKIT_SOURCE);
33
+ await fs.promises.writeFile(podfilePath, lines.join("\n"));
34
+ return config;
35
+ },
36
+ ]);
37
+ }
38
+
39
+ module.exports = withIosPodfileSource;