@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 +78 -0
- package/dist/index.d.ts +40 -0
- package/dist/index.js +161 -0
- package/package.json +33 -0
- package/src/index.ts +185 -0
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).
|
package/dist/index.d.ts
ADDED
|
@@ -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
|