@tryappdata/react-native 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,78 @@
1
+ # @tryappdata/react-native
2
+
3
+ Drop-in **Live Users** SDK for React Native. Your real users light up the
4
+ appdata globe, live and worldwide — with **no location permission**, no
5
+ cookies, and no PII unless you explicitly pass an avatar.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm i @tryappdata/react-native
11
+ # recommended (persists the anon id across launches so appdata can tell
12
+ # new vs returning users):
13
+ npm i @react-native-async-storage/async-storage
14
+ ```
15
+
16
+ ## Use
17
+
18
+ ```ts
19
+ import { appdata } from '@tryappdata/react-native'
20
+
21
+ // once, at app root
22
+ appdata.init({ key: 'pk_…', appVersion: '2.3.1' })
23
+ ```
24
+
25
+ That's the whole required integration. The SDK creates an anonymous id,
26
+ then heartbeats every 60s **only while the app is foregrounded** (it
27
+ pauses in the background and resumes on return) — you write none of that.
28
+
29
+ Optional:
30
+
31
+ ```ts
32
+ // when the visible screen changes — founders see this verbatim
33
+ appdata.screen('Paywall')
34
+
35
+ // attach a logged-in user's avatar (pass only with consent)
36
+ appdata.identify({ id: user.id, avatarUrl: user.photoURL })
37
+
38
+ // back to anonymous, e.g. on logout
39
+ appdata.reset()
40
+ ```
41
+
42
+ ## What's sent
43
+
44
+ `{ key, anonId, platform, appVersion?, screen?, avatarUrl? }` to
45
+ `POST <host>/api/ingest/presence`. Country/city are derived **server-side
46
+ from the request IP and the IP is discarded** — the SDK never sends or
47
+ sees a location, and never asks for a location permission.
48
+
49
+ ## Privacy & consent
50
+
51
+ **You are the data controller for your end-users.** The SDK is anonymous
52
+ by default — no IP, location, cookies, or device id ever leave the
53
+ device, and it requests **no location permission** (country is derived
54
+ server-side from the request IP, which is then discarded).
55
+
56
+ If you call `identify`:
57
+
58
+ - pass `avatarUrl` **only with the user's consent**;
59
+ - **never** pass emails, names, or other PII in any field;
60
+ - gate `init` behind your own consent flow where your jurisdiction
61
+ requires it.
62
+
63
+ The `pk_…` key is **public by design** (like a Segment/PostHog write
64
+ key): write-only presence for one app, no read access, no secrets.
65
+
66
+ ## Config
67
+
68
+ | Field | Required | Notes |
69
+ |--------------|----------|--------------------------------------------------|
70
+ | `key` | yes | Public ingest key (`pk_…`) from the dashboard. |
71
+ | `appVersion` | no | Your app version; shown to you as exact data. |
72
+ | `host` | no | Override the appdata origin (defaults to prod). |
73
+ | `debug` | no | Log heartbeat failures to the console. |
74
+
75
+ Pings are fire-and-forget and never throw into your app. ~1 tiny request
76
+ per minute while foregrounded → negligible battery/data.
77
+
78
+ Full wire spec: [`../LIVE-USERS-CONTRACT.md`](../LIVE-USERS-CONTRACT.md).
@@ -0,0 +1,40 @@
1
+ export interface AppdataConfig {
2
+ /** Public per-app ingest key (`pk_…`) from the appdata dashboard. */
3
+ key: string;
4
+ /** Your app's version, e.g. "2.3.1". Shown to founders as exact data. */
5
+ appVersion?: string;
6
+ /** Override the appdata origin (defaults to production). */
7
+ host?: string;
8
+ /** Log heartbeat failures to the console. Off by default. */
9
+ debug?: boolean;
10
+ }
11
+ export interface AppdataIdentify {
12
+ /** Your own user id — reserved for future cross-reference; not stored yet. */
13
+ id?: string;
14
+ /** Avatar URL for this logged-in user. Pass only with user consent. */
15
+ avatarUrl?: string;
16
+ }
17
+ declare class Appdata {
18
+ private key;
19
+ private host;
20
+ private appVersion;
21
+ private debug;
22
+ private anonId;
23
+ private screenName;
24
+ private avatarUrl;
25
+ private timer;
26
+ private started;
27
+ init(config: AppdataConfig): void;
28
+ identify(traits: AppdataIdentify): void;
29
+ /** Call when the visible screen changes — shown to founders verbatim. */
30
+ screen(name: string): void;
31
+ /** Back to anonymous (e.g. on logout). */
32
+ reset(): void;
33
+ private onAppState;
34
+ private startTimer;
35
+ private stopTimer;
36
+ private resolveAnonId;
37
+ private beat;
38
+ }
39
+ export declare const appdata: Appdata;
40
+ export default appdata;
package/dist/index.js ADDED
@@ -0,0 +1,161 @@
1
+ var _a;
2
+ /**
3
+ * appdata Live Users SDK — React Native.
4
+ *
5
+ * One init call. The SDK then heartbeats the appdata ingest endpoint
6
+ * every 60s **while the app is foregrounded**, so the customer's real
7
+ * users light up the appdata globe live. No location permission (geo is
8
+ * derived server-side from IP and discarded), no cookies, no PII unless
9
+ * the customer explicitly passes an avatar via identify().
10
+ *
11
+ * import { appdata } from '@tryappdata/react-native'
12
+ * appdata.init({ key: 'pk_…', appVersion: '2.3.1' })
13
+ * appdata.screen('Paywall') // optional, when it changes
14
+ * appdata.identify({ avatarUrl }) // optional, logged-in users
15
+ * appdata.reset() // optional, on logout
16
+ */
17
+ import { Platform, AppState } from "react-native";
18
+ /** The appdata production origin. Override per-call via `init({ host })`. */
19
+ const DEFAULT_HOST = "https://tryappdata.com";
20
+ const HEARTBEAT_MS = 60000;
21
+ const STORAGE_KEY = "appdata_anon";
22
+ // AsyncStorage is optional. With it, the anon id (and thus accurate
23
+ // new-vs-returning) persists across launches; without it the id is
24
+ // per-process — the SDK still works, it just can't tell "returning".
25
+ let storage = null;
26
+ try {
27
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
28
+ const mod = require("@react-native-async-storage/async-storage");
29
+ storage = ((_a = mod === null || mod === void 0 ? void 0 : mod.default) !== null && _a !== void 0 ? _a : mod);
30
+ }
31
+ catch {
32
+ storage = null;
33
+ }
34
+ function uuid() {
35
+ return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
36
+ const r = (Math.random() * 16) | 0;
37
+ const v = c === "x" ? r : (r & 0x3) | 0x8;
38
+ return v.toString(16);
39
+ });
40
+ }
41
+ function platform() {
42
+ if (Platform.OS === "ios")
43
+ return "ios";
44
+ if (Platform.OS === "android")
45
+ return "android";
46
+ return "web";
47
+ }
48
+ class Appdata {
49
+ constructor() {
50
+ this.key = null;
51
+ this.host = DEFAULT_HOST;
52
+ this.debug = false;
53
+ this.anonId = null;
54
+ this.timer = null;
55
+ this.started = false;
56
+ this.onAppState = (state) => {
57
+ if (state === "active") {
58
+ this.beat();
59
+ this.startTimer();
60
+ }
61
+ else {
62
+ this.stopTimer();
63
+ }
64
+ };
65
+ }
66
+ init(config) {
67
+ var _a;
68
+ if (this.started)
69
+ return;
70
+ if (!(config === null || config === void 0 ? void 0 : config.key)) {
71
+ if (config === null || config === void 0 ? void 0 : config.debug)
72
+ console.warn("[appdata] init: missing `key`");
73
+ return;
74
+ }
75
+ this.key = config.key;
76
+ this.host = ((_a = config.host) !== null && _a !== void 0 ? _a : DEFAULT_HOST).replace(/\/+$/, "");
77
+ this.appVersion = config.appVersion;
78
+ this.debug = !!config.debug;
79
+ this.started = true;
80
+ void this.resolveAnonId().then(() => {
81
+ this.beat();
82
+ this.startTimer();
83
+ });
84
+ AppState.addEventListener("change", this.onAppState);
85
+ }
86
+ identify(traits) {
87
+ if (traits.avatarUrl && /^https:\/\//.test(traits.avatarUrl)) {
88
+ this.avatarUrl = traits.avatarUrl;
89
+ }
90
+ this.beat();
91
+ }
92
+ /** Call when the visible screen changes — shown to founders verbatim. */
93
+ screen(name) {
94
+ this.screenName = name;
95
+ this.beat();
96
+ }
97
+ /** Back to anonymous (e.g. on logout). */
98
+ reset() {
99
+ this.avatarUrl = undefined;
100
+ this.beat();
101
+ }
102
+ startTimer() {
103
+ if (this.timer)
104
+ return;
105
+ this.timer = setInterval(() => this.beat(), HEARTBEAT_MS);
106
+ }
107
+ stopTimer() {
108
+ if (this.timer) {
109
+ clearInterval(this.timer);
110
+ this.timer = null;
111
+ }
112
+ }
113
+ async resolveAnonId() {
114
+ if (this.anonId)
115
+ return;
116
+ try {
117
+ const existing = storage ? await storage.getItem(STORAGE_KEY) : null;
118
+ if (existing) {
119
+ this.anonId = existing;
120
+ return;
121
+ }
122
+ }
123
+ catch {
124
+ /* fall through to fresh id */
125
+ }
126
+ this.anonId = uuid();
127
+ try {
128
+ if (storage)
129
+ await storage.setItem(STORAGE_KEY, this.anonId);
130
+ }
131
+ catch {
132
+ /* in-memory only this process — acceptable */
133
+ }
134
+ }
135
+ beat() {
136
+ if (!this.key || !this.anonId)
137
+ return;
138
+ const body = {
139
+ key: this.key,
140
+ anonId: this.anonId,
141
+ platform: platform(),
142
+ };
143
+ if (this.appVersion)
144
+ body.appVersion = this.appVersion;
145
+ if (this.screenName)
146
+ body.screen = this.screenName;
147
+ if (this.avatarUrl)
148
+ body.avatarUrl = this.avatarUrl;
149
+ // Fire-and-forget; a presence ping must never affect the host app.
150
+ fetch(`${this.host}/api/ingest/presence`, {
151
+ method: "POST",
152
+ headers: { "content-type": "application/json" },
153
+ body: JSON.stringify(body),
154
+ }).catch((err) => {
155
+ if (this.debug)
156
+ console.warn("[appdata] heartbeat failed", err);
157
+ });
158
+ }
159
+ }
160
+ export const appdata = new Appdata();
161
+ export default appdata;
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@tryappdata/react-native",
3
+ "version": "0.1.0",
4
+ "description": "appdata Live Users SDK for React Native — drop-in real-user presence for the appdata globe. No location permission, no PII.",
5
+ "license": "MIT",
6
+ "main": "dist/index.js",
7
+ "module": "dist/index.js",
8
+ "types": "dist/index.d.ts",
9
+ "react-native": "src/index.ts",
10
+ "files": [
11
+ "src",
12
+ "dist",
13
+ "README.md"
14
+ ],
15
+ "scripts": {
16
+ "build": "tsc -p tsconfig.json",
17
+ "prepublishOnly": "npm run build"
18
+ },
19
+ "keywords": [
20
+ "appdata",
21
+ "react-native",
22
+ "presence",
23
+ "live-users",
24
+ "analytics"
25
+ ],
26
+ "peerDependencies": {
27
+ "react": ">=17",
28
+ "react-native": ">=0.64"
29
+ },
30
+ "devDependencies": {
31
+ "typescript": "^5.4.0"
32
+ }
33
+ }
package/src/index.ts ADDED
@@ -0,0 +1,185 @@
1
+ /**
2
+ * appdata Live Users SDK — React Native.
3
+ *
4
+ * One init call. The SDK then heartbeats the appdata ingest endpoint
5
+ * every 60s **while the app is foregrounded**, so the customer's real
6
+ * users light up the appdata globe live. No location permission (geo is
7
+ * derived server-side from IP and discarded), no cookies, no PII unless
8
+ * the customer explicitly passes an avatar via identify().
9
+ *
10
+ * import { appdata } from '@tryappdata/react-native'
11
+ * appdata.init({ key: 'pk_…', appVersion: '2.3.1' })
12
+ * appdata.screen('Paywall') // optional, when it changes
13
+ * appdata.identify({ avatarUrl }) // optional, logged-in users
14
+ * appdata.reset() // optional, on logout
15
+ */
16
+ import { Platform, AppState, type AppStateStatus } from "react-native"
17
+
18
+ /** The appdata production origin. Override per-call via `init({ host })`. */
19
+ const DEFAULT_HOST = "https://tryappdata.com"
20
+ const HEARTBEAT_MS = 60_000
21
+ const STORAGE_KEY = "appdata_anon"
22
+
23
+ export interface AppdataConfig {
24
+ /** Public per-app ingest key (`pk_…`) from the appdata dashboard. */
25
+ key: string
26
+ /** Your app's version, e.g. "2.3.1". Shown to founders as exact data. */
27
+ appVersion?: string
28
+ /** Override the appdata origin (defaults to production). */
29
+ host?: string
30
+ /** Log heartbeat failures to the console. Off by default. */
31
+ debug?: boolean
32
+ }
33
+
34
+ export interface AppdataIdentify {
35
+ /** Your own user id — reserved for future cross-reference; not stored yet. */
36
+ id?: string
37
+ /** Avatar URL for this logged-in user. Pass only with user consent. */
38
+ avatarUrl?: string
39
+ }
40
+
41
+ interface OptionalStorage {
42
+ getItem(k: string): Promise<string | null>
43
+ setItem(k: string, v: string): Promise<void>
44
+ }
45
+
46
+ // AsyncStorage is optional. With it, the anon id (and thus accurate
47
+ // new-vs-returning) persists across launches; without it the id is
48
+ // per-process — the SDK still works, it just can't tell "returning".
49
+ let storage: OptionalStorage | null = null
50
+ try {
51
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
52
+ const mod = require("@react-native-async-storage/async-storage")
53
+ storage = (mod?.default ?? mod) as OptionalStorage
54
+ } catch {
55
+ storage = null
56
+ }
57
+
58
+ function uuid(): string {
59
+ return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
60
+ const r = (Math.random() * 16) | 0
61
+ const v = c === "x" ? r : (r & 0x3) | 0x8
62
+ return v.toString(16)
63
+ })
64
+ }
65
+
66
+ function platform(): "ios" | "android" | "web" {
67
+ if (Platform.OS === "ios") return "ios"
68
+ if (Platform.OS === "android") return "android"
69
+ return "web"
70
+ }
71
+
72
+ class Appdata {
73
+ private key: string | null = null
74
+ private host = DEFAULT_HOST
75
+ private appVersion: string | undefined
76
+ private debug = false
77
+ private anonId: string | null = null
78
+ private screenName: string | undefined
79
+ private avatarUrl: string | undefined
80
+ private timer: ReturnType<typeof setInterval> | null = null
81
+ private started = false
82
+
83
+ init(config: AppdataConfig): void {
84
+ if (this.started) return
85
+ if (!config?.key) {
86
+ if (config?.debug) console.warn("[appdata] init: missing `key`")
87
+ return
88
+ }
89
+ this.key = config.key
90
+ this.host = (config.host ?? DEFAULT_HOST).replace(/\/+$/, "")
91
+ this.appVersion = config.appVersion
92
+ this.debug = !!config.debug
93
+ this.started = true
94
+
95
+ void this.resolveAnonId().then(() => {
96
+ this.beat()
97
+ this.startTimer()
98
+ })
99
+
100
+ AppState.addEventListener("change", this.onAppState)
101
+ }
102
+
103
+ identify(traits: AppdataIdentify): void {
104
+ if (traits.avatarUrl && /^https:\/\//.test(traits.avatarUrl)) {
105
+ this.avatarUrl = traits.avatarUrl
106
+ }
107
+ this.beat()
108
+ }
109
+
110
+ /** Call when the visible screen changes — shown to founders verbatim. */
111
+ screen(name: string): void {
112
+ this.screenName = name
113
+ this.beat()
114
+ }
115
+
116
+ /** Back to anonymous (e.g. on logout). */
117
+ reset(): void {
118
+ this.avatarUrl = undefined
119
+ this.beat()
120
+ }
121
+
122
+ private onAppState = (state: AppStateStatus): void => {
123
+ if (state === "active") {
124
+ this.beat()
125
+ this.startTimer()
126
+ } else {
127
+ this.stopTimer()
128
+ }
129
+ }
130
+
131
+ private startTimer(): void {
132
+ if (this.timer) return
133
+ this.timer = setInterval(() => this.beat(), HEARTBEAT_MS)
134
+ }
135
+
136
+ private stopTimer(): void {
137
+ if (this.timer) {
138
+ clearInterval(this.timer)
139
+ this.timer = null
140
+ }
141
+ }
142
+
143
+ private async resolveAnonId(): Promise<void> {
144
+ if (this.anonId) return
145
+ try {
146
+ const existing = storage ? await storage.getItem(STORAGE_KEY) : null
147
+ if (existing) {
148
+ this.anonId = existing
149
+ return
150
+ }
151
+ } catch {
152
+ /* fall through to fresh id */
153
+ }
154
+ this.anonId = uuid()
155
+ try {
156
+ if (storage) await storage.setItem(STORAGE_KEY, this.anonId)
157
+ } catch {
158
+ /* in-memory only this process — acceptable */
159
+ }
160
+ }
161
+
162
+ private beat(): void {
163
+ if (!this.key || !this.anonId) return
164
+ const body: Record<string, string> = {
165
+ key: this.key,
166
+ anonId: this.anonId,
167
+ platform: platform(),
168
+ }
169
+ if (this.appVersion) body.appVersion = this.appVersion
170
+ if (this.screenName) body.screen = this.screenName
171
+ if (this.avatarUrl) body.avatarUrl = this.avatarUrl
172
+
173
+ // Fire-and-forget; a presence ping must never affect the host app.
174
+ fetch(`${this.host}/api/ingest/presence`, {
175
+ method: "POST",
176
+ headers: { "content-type": "application/json" },
177
+ body: JSON.stringify(body),
178
+ }).catch((err) => {
179
+ if (this.debug) console.warn("[appdata] heartbeat failed", err)
180
+ })
181
+ }
182
+ }
183
+
184
+ export const appdata = new Appdata()
185
+ export default appdata