Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
- **`<LinkTrailPasteButton/>` 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`).
Expand Down
2 changes: 1 addition & 1 deletion LinktrailReactNative.podspec
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
28 changes: 23 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<LinkTrailPasteButton/>` counts as granting consent by default.

```ts
await LinkTrail.configure('lt_live_…', { requireConsent: true });
Expand All @@ -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
`<LinkTrailPasteButton/>` (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.

Expand Down Expand Up @@ -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
Expand Down
10 changes: 5 additions & 5 deletions example/ios/Podfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion example/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion ios/LinkTrailPasteButtonComponentView.mm
Original file line number Diff line number Diff line change
Expand Up @@ -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];
}
Expand Down
24 changes: 23 additions & 1 deletion ios/LinkTrailPasteButtonView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
Expand All @@ -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 &&
Expand All @@ -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()
Expand Down Expand Up @@ -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) {
Expand All @@ -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) }
}
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
9 changes: 8 additions & 1 deletion src/LinkTrailPasteButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand All @@ -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({
Expand Down
12 changes: 12 additions & 0 deletions src/LinkTrailPasteButtonNativeComponent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
import type {
BubblingEventHandler,
WithDefault,
} from 'react-native/Libraries/Types/CodegenTypes';

Check warning on line 5 in src/LinkTrailPasteButtonNativeComponent.ts

View workflow job for this annotation

GitHub Actions / Lint · Typecheck · Build · Codegen

'react-native/Libraries/Types/CodegenTypes' React Native deep imports are deprecated. Please use the top level import instead
import codegenNativeComponent from 'react-native/Libraries/Utilities/codegenNativeComponent';

Check warning on line 6 in src/LinkTrailPasteButtonNativeComponent.ts

View workflow job for this annotation

GitHub Actions / Lint · Typecheck · Build · Codegen

'react-native/Libraries/Utilities/codegenNativeComponent' React Native deep imports are deprecated. Please use the top level import instead

/**
* Codegen spec for the native paste-button Fabric component (iOS only).
Expand All @@ -14,6 +14,9 @@
* 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. */
Expand All @@ -36,6 +39,15 @@

/** 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<boolean, true>;
}

export default codegenNativeComponent<NativeProps>(
Expand Down
20 changes: 16 additions & 4 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
/**
Expand Down
Loading