diff --git a/CHANGELOG.md b/CHANGELOG.md
index f2690f8..bd56b19 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,22 @@
# Changelog
+## 0.0.5
+
+- Bump the iOS native SDK to **`LinkTrailSDK ~> 0.0.12`** (from `0.0.11`).
+- **Consent is now hold-until-decision** (iOS): until your app records a decision, nothing
+ leaves the device — no install, no clipboard read, no device identifier. The held install is
+ persisted, and each answer releases exactly one install (`setConsent(false)` sends an
+ uncounted one that still resolves the deferred link). Re-engagement links still route
+ without a decision.
+- **`` now grants consent on tap** (new `grantsConsent` prop, default
+ `true`), matching the native SDK — without it the install the tap triggers would be held by
+ the deny-by-default gate. Set `grantsConsent={false}` when a separate consent UI owns the
+ decision.
+
+⚠️ **Migration:** an app that leaves `requireConsent` on (the default) and never calls
+`setConsent` now sends nothing at all. Call `setConsent` when your consent UI resolves, or set
+`requireConsent: false` if consent is handled elsewhere.
+
## 0.0.4
- Bump the iOS native SDK to **`LinkTrailSDK ~> 0.0.11`** (from `0.0.10`).
diff --git a/LinktrailReactNative.podspec b/LinktrailReactNative.podspec
index 37ddda6..8bd7aae 100644
--- a/LinktrailReactNative.podspec
+++ b/LinktrailReactNative.podspec
@@ -18,7 +18,7 @@ Pod::Spec.new do |s|
# The binary LinkTrail iOS SDK, published to the CocoaPods trunk — CocoaPods
# resolves it automatically, so the consuming app needs no extra Podfile line.
- s.dependency "LinkTrailSDK", "~> 0.0.11"
+ s.dependency "LinkTrailSDK", "~> 0.0.12"
s.pod_target_xcconfig = {
"DEFINES_MODULE" => "YES",
diff --git a/README.md b/README.md
index 25b178f..c0bf42b 100644
--- a/README.md
+++ b/README.md
@@ -66,10 +66,26 @@ to forward URLs yourself via `LinkTrail.handleDeepLink(url)`.
### Consent gating
`requireConsent` (default `true`) gates attribution/tracking behind the user's decision —
-**deny-by-default** for GDPR / ePrivacy. While consent is unset or denied the SDK holds the install
-and drops events, but **deep links still route** (`onLink` fires) so the user reaches their
-destination. The SDK exposes **no consent getter** — your app is the source of truth: persist the
-choice and replay it with `setConsent` on every launch after `configure`.
+**deny-by-default and hold-until-decision** for GDPR / ePrivacy. Until your app records a decision,
+**nothing leaves the device**: no install, no clipboard read, no device identifier. The held install
+is persisted, so a user who decides on a later launch is still attributed.
+
+Each answer releases it as **exactly one** install:
+
+| Answer | What goes out |
+| --- | --- |
+| `setConsent(true)` | the counted, attributed install — replaying any click token captured before the decision |
+| `setConsent(false)` | one **uncounted** install with `consent: false` — the backend resolves the deferred link so the user still lands in the right place, but records nothing and sends no device id |
+
+**Re-engagement** links (app already installed) still route without a decision — they're resolved,
+not recorded, so `onLink` fires either way. The SDK exposes **no consent getter** — your app is the
+source of truth: persist the choice and replay it with `setConsent` on every launch after
+`configure`.
+
+> ⚠️ **Migration (SDK 0.0.12):** an app that leaves `requireConsent` on and **never calls
+> `setConsent`** now sends nothing at all — no install, no attribution, no deferred routing. Either
+> call `setConsent` once your consent UI resolves, or set `requireConsent: false` if consent is
+> handled elsewhere. Tapping `` counts as granting consent by default.
```ts
await LinkTrail.configure('lt_live_…', { requireConsent: true });
@@ -94,7 +110,8 @@ On iOS, deferred attribution recovers a **click token** the tapped link left on
system **"Allow Paste"** alert on first launch.
- **`'pasteButton'`** (default) — the token is read **only** when the user taps
`` (Apple's `UIPasteControl`), with **no "Allow Paste" alert**. You render
- the button and set **`autoTrackInstall: false`** so the install waits for the tap.
+ the button and set **`autoTrackInstall: false`** so the install waits for the tap. The tap is a
+ deliberate affirmative act, so it also **records consent** by default (`grantsConsent`).
- **`'none'`** — the SDK **never touches the clipboard** (no button, no alert); deferred matching
falls back to probabilistic IP matching only.
@@ -131,6 +148,7 @@ await LinkTrail.configure('lt_live_…', {
| `cornerStyle` | `'dynamic' \| 'fixed' \| 'capsule' \| 'large' \| 'medium' \| 'small'` | `'capsule'` | Corner rounding. |
| `foregroundColor` | `ColorValue` | system | Label/icon color. |
| `fillColor` | `ColorValue` | system | Button background. |
+| `grantsConsent` | `boolean` | `true` | Whether the tap also records tracking consent, so the install it triggers isn't held by the deny-by-default gate. Set `false` when a separate consent UI owns the decision. |
| `style` | `ViewStyle` | — | Size/layout (width, height, margins…). |
Requires **iOS 16+**. The on-screen label is the system **"Paste"** string — Apple's
diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock
index a535ef0..d607992 100644
--- a/example/ios/Podfile.lock
+++ b/example/ios/Podfile.lock
@@ -37,14 +37,14 @@ PODS:
- hermes-engine (0.80.3):
- hermes-engine/Pre-built (= 0.80.3)
- hermes-engine/Pre-built (0.80.3)
- - LinktrailReactNative (0.0.4):
+ - LinktrailReactNative (0.0.5):
- boost
- DoubleConversion
- fast_float
- fmt
- glog
- hermes-engine
- - LinkTrailSDK (~> 0.0.11)
+ - LinkTrailSDK (~> 0.0.12)
- RCT-Folly
- RCT-Folly/Fabric
- RCTRequired
@@ -67,7 +67,7 @@ PODS:
- ReactCommon/turbomodule/core
- SocketRocket
- Yoga
- - LinkTrailSDK (0.0.11)
+ - LinkTrailSDK (0.0.12)
- RCT-Folly (2024.11.18.00):
- boost
- DoubleConversion
@@ -2521,8 +2521,8 @@ SPEC CHECKSUMS:
fmt: a40bb5bd0294ea969aaaba240a927bd33d878cdd
glog: 5683914934d5b6e4240e497e0f4a3b42d1854183
hermes-engine: fff23161928673afcd95f63146e651726f68b384
- LinktrailReactNative: cdb6994035080e1edcb3697dcf8f2e287c5db30c
- LinkTrailSDK: 53a14d30eb2cd9cbde0d41c3468be80a7a414e5d
+ LinktrailReactNative: e295a8248651884d674ae4f76cb6804c92e691dd
+ LinkTrailSDK: fa3517b4076dd364c454ce692ddb95c0de8f8ef4
RCT-Folly: 846fda9475e61ec7bcbf8a3fe81edfcaeb090669
RCTDeprecation: 2ebc9cc0329ede460c5d52956ce5e1ab715c2c2c
RCTRequired: b83daef658afafdc68d85b97ed082f4497a8ffa4
diff --git a/example/package-lock.json b/example/package-lock.json
index 591cff1..13369da 100644
--- a/example/package-lock.json
+++ b/example/package-lock.json
@@ -40,7 +40,7 @@
},
"..": {
"name": "linktrail-react-native",
- "version": "0.0.4",
+ "version": "0.0.5",
"license": "MIT",
"devDependencies": {
"@react-native/eslint-config": "0.80.3",
diff --git a/ios/LinkTrailPasteButtonComponentView.mm b/ios/LinkTrailPasteButtonComponentView.mm
index 9f763cf..9f81b2f 100644
--- a/ios/LinkTrailPasteButtonComponentView.mm
+++ b/ios/LinkTrailPasteButtonComponentView.mm
@@ -64,7 +64,8 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared &
[_pasteView configureWithDisplayMode:RCTNSStringFromString(toString(newProps.displayMode))
cornerStyle:RCTNSStringFromString(toString(newProps.cornerStyle))
foregroundColor:RCTUIColorFromSharedColor(newProps.foregroundColor)
- fillColor:RCTUIColorFromSharedColor(newProps.fillColor)];
+ fillColor:RCTUIColorFromSharedColor(newProps.fillColor)
+ grantsConsent:newProps.grantsConsent];
}
[super updateProps:props oldProps:oldProps];
}
diff --git a/ios/LinkTrailPasteButtonView.swift b/ios/LinkTrailPasteButtonView.swift
index 023d673..c3ca2bc 100644
--- a/ios/LinkTrailPasteButtonView.swift
+++ b/ios/LinkTrailPasteButtonView.swift
@@ -6,6 +6,11 @@ import LinkTrailSDK
/// (no "Allow Paste" alert) and hands it to the SDK via `trackInstall(clickToken:)`.
/// Appearance is driven by the RN props (`displayMode`/`cornerStyle`/colors);
/// size comes from the RN `style`. iOS 16+.
+///
+/// The tap is a deliberate affirmative act, so by default it also records
+/// tracking consent (`grantsConsent`) — mirroring the SDK's own paste button.
+/// Without it the install would be held by the deny-by-default consent gate and
+/// the token would never be sent.
@available(iOS 16.0, *)
@objc(LinkTrailPasteButtonView)
public final class LinkTrailPasteButtonView: UIView {
@@ -21,6 +26,7 @@ public final class LinkTrailPasteButtonView: UIView {
private var cornerStyleName = "capsule"
private var foregroundColor: UIColor?
private var fillColor: UIColor?
+ private var grantsConsent = true
public override init(frame: CGRect) {
super.init(frame: frame)
@@ -35,8 +41,13 @@ public final class LinkTrailPasteButtonView: UIView {
displayMode: String,
cornerStyle: String,
foregroundColor: UIColor?,
- fillColor: UIColor?
+ fillColor: UIColor?,
+ grantsConsent: Bool
) {
+ // Applied before the appearance early-return: this prop can change on its own.
+ self.grantsConsent = grantsConsent
+ coordinator?.grantsConsent = grantsConsent
+
let unchanged =
displayMode == displayModeName &&
cornerStyle == cornerStyleName &&
@@ -55,6 +66,7 @@ public final class LinkTrailPasteButtonView: UIView {
control?.removeFromSuperview()
let coord = coordinator ?? PasteCoordinator { [weak self] token in self?.onToken?(token) }
+ coord.grantsConsent = grantsConsent
coordinator = coord
let configuration = UIPasteControl.Configuration()
@@ -97,6 +109,9 @@ public final class LinkTrailPasteButtonView: UIView {
/// The `UIPasteControl` target: accepts pasted text and forwards it to the SDK.
final class PasteCoordinator: UIResponder {
+ /// Whether the tap also records tracking consent. Mirrors the RN prop.
+ var grantsConsent = true
+
private let onToken: (String) -> Void
init(onToken: @escaping (String) -> Void) {
@@ -108,8 +123,15 @@ public final class LinkTrailPasteButtonView: UIView {
override func paste(itemProviders: [NSItemProvider]) {
guard let provider = itemProviders.first(where: { $0.canLoadObject(ofClass: NSString.self) }) else { return }
let onToken = self.onToken
+ let grantsConsent = self.grantsConsent
provider.loadObject(ofClass: NSString.self) { object, _ in
guard let string = object as? String else { return }
+ // Order matters: consent first, so the install below carries `consent: true`
+ // instead of being held by the deny-by-default gate. Safe because the paste
+ // flow requires `autoTrackInstall: false` — with auto-tracking on,
+ // `setConsent` would fire its own token-less install and consume the
+ // one-shot, losing the deterministic token the user just handed us.
+ if grantsConsent { LinkTrailSDK.LinkTrail.shared?.setConsent(true) }
LinkTrailSDK.LinkTrail.shared?.trackInstall(clickToken: string)
DispatchQueue.main.async { onToken(string) }
}
diff --git a/package.json b/package.json
index 41e3a33..14e6b00 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "linktrail-react-native",
- "version": "0.0.4",
+ "version": "0.0.5",
"description": "React Native SDK for LinkTrail — mobile attribution and deferred deep linking. A New Architecture TurboModule wrapping the native LinkTrail iOS and Android SDKs.",
"main": "./lib/module/index.js",
"types": "./lib/typescript/src/index.d.ts",
diff --git a/src/LinkTrailPasteButton.tsx b/src/LinkTrailPasteButton.tsx
index 096e58f..cf9dfb0 100644
--- a/src/LinkTrailPasteButton.tsx
+++ b/src/LinkTrailPasteButton.tsx
@@ -16,6 +16,12 @@ export interface LinkTrailPasteButtonProps extends ViewProps {
foregroundColor?: ColorValue;
/** Button fill color. */
fillColor?: ColorValue;
+ /**
+ * Whether the tap also records tracking consent. Default `true`, so the
+ * install the tap triggers isn't held by the deny-by-default consent gate.
+ * Set `false` when a separate consent UI owns the decision.
+ */
+ grantsConsent?: boolean;
}
/**
@@ -24,7 +30,8 @@ export interface LinkTrailPasteButtonProps extends ViewProps {
* it to the SDK. Style it to match your theme via `displayMode` / `cornerStyle` /
* `foregroundColor` / `fillColor`, and size it with `style` (width/height).
* Configure the SDK with `clickTokenSource: 'pasteButton'` and
- * `autoTrackInstall: false` so the install waits for the tap. Renders nothing on
+ * `autoTrackInstall: false` so the install waits for the tap. The tap also
+ * records consent by default (see `grantsConsent`). Renders nothing on
* Android (which uses the Play Install Referrer, so no paste button is needed).
*/
export function LinkTrailPasteButton({
diff --git a/src/LinkTrailPasteButtonNativeComponent.ts b/src/LinkTrailPasteButtonNativeComponent.ts
index 7ef6cbe..da969e6 100644
--- a/src/LinkTrailPasteButtonNativeComponent.ts
+++ b/src/LinkTrailPasteButtonNativeComponent.ts
@@ -14,6 +14,9 @@ import codegenNativeComponent from 'react-native/Libraries/Utilities/codegenNati
* theme via the props below (Apple restricts what's customizable on a paste
* control); size it with the standard `style` (width/height). Use with
* `clickTokenSource: 'pasteButton'` and `autoTrackInstall: false`.
+ *
+ * The tap also records tracking consent by default (`grantsConsent`), so the
+ * install it triggers isn't held by the deny-by-default consent gate.
*/
export interface NativeProps extends ViewProps {
/** Fired (main thread) with the raw pasted string after the SDK reads it. */
@@ -36,6 +39,15 @@ export interface NativeProps extends ViewProps {
/** Button fill color (maps to `baseBackgroundColor`). */
fillColor?: ColorValue;
+
+ /**
+ * Whether the tap also records tracking consent. Default `true` — the
+ * deliberate tap is the affirmative act, so the install it triggers carries
+ * consent and isn't held by the deny-by-default gate. Set `false` when a
+ * separate consent UI (CMP) owns the decision: the tap then only supplies the
+ * token, and the install waits for your `setConsent(true)`.
+ */
+ grantsConsent?: WithDefault;
}
export default codegenNativeComponent(
diff --git a/src/types.ts b/src/types.ts
index a290324..ddfd0be 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -91,10 +91,22 @@ export interface LinkTrailOptions {
autoTrackInstall?: boolean;
/**
* Gate attribution/tracking behind user consent (GDPR / ePrivacy). Default
- * `true` — **deny-by-default**. While consent is unset or denied the SDK holds
- * the install and drops events, but deep links are still **routed** (`onLink`
- * fires) so the user reaches their destination. Call `setConsent(true)` to
- * release tracking. Set `false` to attribute at init without a consent step.
+ * `true` — **deny-by-default and hold-until-decision**: until your app records
+ * a decision, **nothing leaves the device** (no install, no clipboard read, no
+ * device identifier). The held install is persisted, so a user who decides on
+ * a later launch is still attributed.
+ *
+ * Either answer releases it, as exactly one install: `setConsent(true)` sends
+ * the counted, attributed install (replaying any click token captured before
+ * the decision); `setConsent(false)` sends one **uncounted** install carrying
+ * `consent: false` — the backend resolves the deferred link so the user still
+ * lands in the right place, but records nothing and sends no device id.
+ *
+ * **Re-engagement** links (app already installed) still route without a
+ * decision — they're resolved, not recorded, so `onLink` fires either way.
+ *
+ * ⚠️ An app that leaves this on and **never calls `setConsent`** sends nothing
+ * at all. Set `false` to attribute at init when consent is handled elsewhere.
*/
requireConsent?: boolean;
/**