@li-nk.me/react-native-sdk 0.1.1

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/src/index.ts ADDED
@@ -0,0 +1,580 @@
1
+ import { Linking, Platform } from 'react-native';
2
+
3
+ // Optional expo-clipboard for pasteboard support on iOS
4
+ let Clipboard: { getStringAsync?: () => Promise<string> } | null = null;
5
+ try {
6
+ // Dynamic import to make expo-clipboard optional
7
+ Clipboard = require('expo-clipboard');
8
+ } catch {
9
+ // expo-clipboard not installed - pasteboard will be skipped
10
+ }
11
+
12
+ export type LinkMePayload = {
13
+ linkId?: string;
14
+ path?: string;
15
+ params?: Record<string, string>;
16
+ utm?: Record<string, string>;
17
+ custom?: Record<string, string>;
18
+ };
19
+
20
+ export type LinkMeConfig = {
21
+ baseUrl?: string;
22
+ appId?: string;
23
+ appKey?: string;
24
+ /**
25
+ * @deprecated Pasteboard is now controlled from the Portal (App Settings → iOS).
26
+ * The SDK automatically checks pasteboard on iOS if expo-clipboard is installed.
27
+ * This parameter is ignored.
28
+ */
29
+ enablePasteboard?: boolean;
30
+ sendDeviceInfo?: boolean;
31
+ includeVendorId?: boolean;
32
+ includeAdvertisingId?: boolean;
33
+ };
34
+
35
+ type NormalizedConfig = LinkMeConfig & {
36
+ baseUrl: string;
37
+ apiBaseUrl: string;
38
+ };
39
+
40
+ type Listener = (payload: LinkMePayload) => void;
41
+
42
+ type LinkingLike = typeof Linking;
43
+
44
+ type FetchLike = typeof fetch;
45
+
46
+ type ControllerDeps = {
47
+ fetchImpl?: FetchLike;
48
+ linking?: LinkingLike;
49
+ };
50
+
51
+ class LinkMeController {
52
+ private config: NormalizedConfig | undefined;
53
+ private ready = false;
54
+ private advertisingConsent = false;
55
+ private userId: string | undefined;
56
+ private lastPayload: LinkMePayload | null = null;
57
+ private readonly listeners = new Set<Listener>();
58
+ private readonly pendingUrls: string[] = [];
59
+ private linkingSubscription: { remove: () => void } | null = null;
60
+ private initialUrlChecked = false;
61
+ private readonly fetchImpl: FetchLike;
62
+ private readonly linking: LinkingLike | undefined;
63
+
64
+ constructor(deps?: ControllerDeps) {
65
+ const impl = deps?.fetchImpl ?? (globalThis as any)?.fetch;
66
+ if (typeof impl !== 'function') {
67
+ throw new Error('fetch is not available; provide deps.fetchImpl');
68
+ }
69
+ this.fetchImpl = impl.bind(globalThis);
70
+ this.linking = deps?.linking ?? Linking;
71
+ }
72
+
73
+ async configure(config: LinkMeConfig): Promise<void> {
74
+ const normalized = normalizeConfig(config);
75
+ this.config = normalized;
76
+ this.advertisingConsent = !!config.includeAdvertisingId;
77
+ this.subscribeToLinking();
78
+ this.ready = true;
79
+ await this.drainPending();
80
+ }
81
+
82
+ async getInitialLink(): Promise<LinkMePayload | null> {
83
+ if (this.lastPayload) {
84
+ return this.lastPayload;
85
+ }
86
+ if (this.initialUrlChecked) {
87
+ return null;
88
+ }
89
+ this.initialUrlChecked = true;
90
+ try {
91
+ const url = await this.linking?.getInitialURL?.();
92
+ if (url) {
93
+ return await this.processUrl(url);
94
+ }
95
+ } catch {
96
+ /* noop */
97
+ }
98
+ return this.lastPayload;
99
+ }
100
+
101
+ async handleUrl(url: string): Promise<boolean> {
102
+ if (!url) {
103
+ return false;
104
+ }
105
+ if (!this.ready || !this.config) {
106
+ this.pendingUrls.push(url);
107
+ return false;
108
+ }
109
+ const payload = await this.processUrl(url);
110
+ return payload != null;
111
+ }
112
+
113
+ async claimDeferredIfAvailable(): Promise<LinkMePayload | null> {
114
+ const cfg = this.config;
115
+ if (!cfg) {
116
+ return null;
117
+ }
118
+
119
+ // 1. On iOS, try to read CID from pasteboard first (if expo-clipboard is available)
120
+ if (Platform.OS === 'ios' && Clipboard?.getStringAsync) {
121
+ const pasteboardPayload = await this.tryClaimFromPasteboard(cfg);
122
+ if (pasteboardPayload) {
123
+ return pasteboardPayload;
124
+ }
125
+ }
126
+
127
+ // 2. Fallback to probabilistic fingerprint matching
128
+ try {
129
+ const body: Record<string, any> = {
130
+ platform: Platform.OS,
131
+ };
132
+ const device = cfg.sendDeviceInfo === false ? undefined : this.buildDevicePayload();
133
+ if (device) {
134
+ body.device = device;
135
+ }
136
+ const res = await this.fetchImpl(`${cfg.apiBaseUrl}/deferred/claim`, {
137
+ method: 'POST',
138
+ headers: this.buildHeaders(true),
139
+ body: JSON.stringify(body),
140
+ });
141
+ if (!res.ok) {
142
+ return null;
143
+ }
144
+ const payload = await this.parsePayload(res);
145
+ if (payload) {
146
+ this.emit(payload);
147
+ }
148
+ return payload;
149
+ } catch {
150
+ return null;
151
+ }
152
+ }
153
+
154
+ private async tryClaimFromPasteboard(cfg: NormalizedConfig): Promise<LinkMePayload | null> {
155
+ try {
156
+ if (!Clipboard?.getStringAsync) {
157
+ return null;
158
+ }
159
+ const pasteStr = await Clipboard.getStringAsync();
160
+ if (!pasteStr) {
161
+ return null;
162
+ }
163
+ // Check if the clipboard contains a li-nk.me URL with a cid parameter
164
+ const cid = this.extractCidFromUrl(pasteStr, cfg.baseUrl);
165
+ if (!cid) {
166
+ return null;
167
+ }
168
+ // Resolve the CID to get the payload
169
+ const payload = await this.resolveCidWithConfig(cfg, cid);
170
+ if (payload) {
171
+ this.emit(payload);
172
+ // Track pasteboard claim
173
+ this.track('claim', { claim_type: 'pasteboard' });
174
+ }
175
+ return payload;
176
+ } catch {
177
+ return null;
178
+ }
179
+ }
180
+
181
+ private extractCidFromUrl(str: string, baseUrl: string): string | null {
182
+ try {
183
+ const url = new URL(str);
184
+ // Check if the URL is from our domain
185
+ const baseHost = new URL(baseUrl).host;
186
+ if (!url.host.endsWith(baseHost) && url.host !== baseHost.replace(/^www\./, '')) {
187
+ return null;
188
+ }
189
+ // Extract the cid parameter
190
+ const cid = url.searchParams.get('cid');
191
+ return cid || null;
192
+ } catch {
193
+ return null;
194
+ }
195
+ }
196
+
197
+ private async resolveCidWithConfig(cfg: NormalizedConfig, cid: string): Promise<LinkMePayload | null> {
198
+ try {
199
+ const res = await this.fetchImpl(`${cfg.apiBaseUrl}/deeplink?cid=${encodeURIComponent(cid)}`, {
200
+ method: 'GET',
201
+ headers: this.buildHeaders(false),
202
+ });
203
+ if (!res.ok) {
204
+ return null;
205
+ }
206
+ return this.parsePayload(res);
207
+ } catch {
208
+ return null;
209
+ }
210
+ }
211
+
212
+ setUserId(userId: string): void {
213
+ this.userId = userId;
214
+ }
215
+
216
+ setAdvertisingConsent(granted: boolean): void {
217
+ this.advertisingConsent = granted;
218
+ }
219
+
220
+ async setReady(): Promise<void> {
221
+ this.ready = true;
222
+ await this.drainPending();
223
+ }
224
+
225
+ async track(event: string, properties?: Record<string, any>): Promise<void> {
226
+ const cfg = this.config;
227
+ if (!cfg || !event) {
228
+ return;
229
+ }
230
+ try {
231
+ const body: Record<string, any> = {
232
+ event,
233
+ platform: Platform.OS,
234
+ timestamp: Math.floor(Date.now() / 1000),
235
+ };
236
+ if (this.userId) {
237
+ body.userId = this.userId;
238
+ }
239
+ if (properties) {
240
+ body.props = properties;
241
+ }
242
+ const res = await this.fetchImpl(`${cfg.apiBaseUrl}/app-events`, {
243
+ method: 'POST',
244
+ headers: this.buildHeaders(true),
245
+ body: JSON.stringify(body),
246
+ });
247
+ if (!res.ok) {
248
+ await res.text().catch(() => undefined);
249
+ }
250
+ } catch {
251
+ /* noop */
252
+ }
253
+ }
254
+
255
+ onLink(listener: Listener): { remove: () => void } {
256
+ this.listeners.add(listener);
257
+ return {
258
+ remove: () => {
259
+ this.listeners.delete(listener);
260
+ },
261
+ };
262
+ }
263
+
264
+ private subscribeToLinking(): void {
265
+ if (this.linkingSubscription || !this.linking || typeof this.linking.addEventListener !== 'function') {
266
+ return;
267
+ }
268
+ const handler = (event: { url: string } | string) => {
269
+ const incoming = typeof event === 'string' ? event : event?.url;
270
+ if (incoming) {
271
+ this.handleIncomingUrl(incoming);
272
+ }
273
+ };
274
+ const maybeSubscription = (this.linking as any).addEventListener('url', handler);
275
+ if (maybeSubscription && typeof maybeSubscription.remove === 'function') {
276
+ this.linkingSubscription = maybeSubscription;
277
+ } else if (typeof (this.linking as any).removeEventListener === 'function') {
278
+ this.linkingSubscription = { remove: () => (this.linking as any).removeEventListener('url', handler) };
279
+ } else {
280
+ this.linkingSubscription = { remove: () => { } };
281
+ }
282
+ }
283
+
284
+ private handleIncomingUrl(url: string): void {
285
+ if (!url) {
286
+ return;
287
+ }
288
+ if (!this.ready || !this.config) {
289
+ this.pendingUrls.push(url);
290
+ return;
291
+ }
292
+ void this.processUrl(url);
293
+ }
294
+
295
+ private async drainPending(): Promise<void> {
296
+ if (!this.ready || !this.config) {
297
+ return;
298
+ }
299
+ while (this.pendingUrls.length > 0) {
300
+ const url = this.pendingUrls.shift();
301
+ if (url) {
302
+ await this.processUrl(url);
303
+ }
304
+ }
305
+ }
306
+
307
+ private async processUrl(url: string): Promise<LinkMePayload | null> {
308
+ const cfg = this.config;
309
+ if (!cfg) {
310
+ return null;
311
+ }
312
+ const parsed = this.parseUrl(url);
313
+ if (!parsed) {
314
+ return null;
315
+ }
316
+ const cid = parsed.searchParams?.get('cid');
317
+ let payload: LinkMePayload | null = null;
318
+ if (cid) {
319
+ payload = await this.resolveCid(cid);
320
+ } else if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
321
+ payload = await this.resolveUniversalLink(url);
322
+ }
323
+ if (payload) {
324
+ this.emit(payload);
325
+ }
326
+ return payload;
327
+ }
328
+
329
+ private parseUrl(url: string): URL | null {
330
+ try {
331
+ return new URL(url);
332
+ } catch {
333
+ try {
334
+ return new URL(url, 'https://placeholder.local');
335
+ } catch {
336
+ return null;
337
+ }
338
+ }
339
+ }
340
+
341
+ private async resolveCid(cid: string): Promise<LinkMePayload | null> {
342
+ const cfg = this.config;
343
+ if (!cfg) {
344
+ return null;
345
+ }
346
+ try {
347
+ const headers = this.buildHeaders(false);
348
+ const device = cfg.sendDeviceInfo === false ? undefined : this.buildDevicePayload();
349
+ if (device) {
350
+ headers['x-linkme-device'] = JSON.stringify(device);
351
+ }
352
+ const res = await this.fetchImpl(`${cfg.apiBaseUrl}/deeplink?cid=${encodeURIComponent(cid)}`, {
353
+ method: 'GET',
354
+ headers,
355
+ });
356
+ if (!res.ok) {
357
+ return null;
358
+ }
359
+ return await this.parsePayload(res);
360
+ } catch {
361
+ return null;
362
+ }
363
+ }
364
+
365
+ private async resolveUniversalLink(url: string): Promise<LinkMePayload | null> {
366
+ const cfg = this.config;
367
+ if (!cfg) {
368
+ return null;
369
+ }
370
+ try {
371
+ const body: Record<string, any> = { url };
372
+ const device = cfg.sendDeviceInfo === false ? undefined : this.buildDevicePayload();
373
+ if (device) {
374
+ body.device = device;
375
+ }
376
+ const res = await this.fetchImpl(`${cfg.apiBaseUrl}/deeplink/resolve-url`, {
377
+ method: 'POST',
378
+ headers: this.buildHeaders(true),
379
+ body: JSON.stringify(body),
380
+ });
381
+ if (!res.ok) {
382
+ return null;
383
+ }
384
+ return await this.parsePayload(res);
385
+ } catch {
386
+ return null;
387
+ }
388
+ }
389
+
390
+ private buildHeaders(includeContentType: boolean): Record<string, string> {
391
+ const headers: Record<string, string> = { Accept: 'application/json' };
392
+ if (includeContentType) {
393
+ headers['Content-Type'] = 'application/json';
394
+ }
395
+ if (this.config?.appId) {
396
+ headers['x-app-id'] = this.config.appId;
397
+ }
398
+ if (this.config?.appKey) {
399
+ headers['x-api-key'] = this.config.appKey;
400
+ }
401
+ return headers;
402
+ }
403
+
404
+ private buildDevicePayload(): Record<string, any> | undefined {
405
+ const cfg = this.config;
406
+ if (!cfg) {
407
+ return undefined;
408
+ }
409
+ const device: Record<string, any> = {
410
+ platform: Platform.OS,
411
+ };
412
+ const version = Platform.Version as string | number | undefined;
413
+ if (version !== undefined) {
414
+ device.osVersion = typeof version === 'string' ? version : String(version);
415
+ }
416
+ const locale = getLocale();
417
+ if (locale) {
418
+ device.locale = locale;
419
+ }
420
+ const timezone = getTimezone();
421
+ if (timezone) {
422
+ device.timezone = timezone;
423
+ }
424
+ const consent: Record<string, any> = {};
425
+ if (cfg.includeVendorId) {
426
+ consent.vendor = true;
427
+ }
428
+ if (this.advertisingConsent) {
429
+ consent.advertising = true;
430
+ }
431
+ device.consent = consent;
432
+ return device;
433
+ }
434
+
435
+ private async parsePayload(res: Response): Promise<LinkMePayload | null> {
436
+ try {
437
+ const json = (await res.json()) as LinkMePayload;
438
+ return json ?? null;
439
+ } catch {
440
+ return null;
441
+ }
442
+ }
443
+
444
+ private emit(payload: LinkMePayload): void {
445
+ this.lastPayload = payload;
446
+ for (const listener of this.listeners) {
447
+ try {
448
+ listener(payload);
449
+ } catch {
450
+ /* noop */
451
+ }
452
+ }
453
+ }
454
+ }
455
+
456
+ function normalizeConfig(config: LinkMeConfig): NormalizedConfig {
457
+ const baseUrl = config?.baseUrl || 'https://li-nk.me';
458
+ const trimmed = baseUrl.replace(/\/$/, '');
459
+ return {
460
+ ...config,
461
+ baseUrl: trimmed,
462
+ apiBaseUrl: `${trimmed}/api`,
463
+ };
464
+ }
465
+
466
+ function getLocale(): string | undefined {
467
+ try {
468
+ const intl = (Intl as any)?.DateTimeFormat;
469
+ if (typeof intl === 'function') {
470
+ const resolved = new intl().resolvedOptions();
471
+ const locale = resolved?.locale ?? resolved?.localeMatcher;
472
+ return typeof locale === 'string' ? locale : undefined;
473
+ }
474
+ } catch {
475
+ /* noop */
476
+ }
477
+ return undefined;
478
+ }
479
+
480
+ function getTimezone(): string | undefined {
481
+ try {
482
+ const intl = (Intl as any)?.DateTimeFormat;
483
+ if (typeof intl === 'function') {
484
+ const resolved = new intl().resolvedOptions();
485
+ const tz = resolved?.timeZone ?? resolved?.timeZoneName;
486
+ return typeof tz === 'string' ? tz : undefined;
487
+ }
488
+ } catch {
489
+ /* noop */
490
+ }
491
+ return undefined;
492
+ }
493
+
494
+ const defaultController = new LinkMeController();
495
+
496
+ export async function configure(config: LinkMeConfig): Promise<void> {
497
+ await defaultController.configure(config);
498
+ }
499
+
500
+ export function getInitialLink(): Promise<LinkMePayload | null> {
501
+ return defaultController.getInitialLink();
502
+ }
503
+
504
+ export function handleUrl(url: string): Promise<boolean> {
505
+ return defaultController.handleUrl(url);
506
+ }
507
+
508
+ export function claimDeferredIfAvailable(): Promise<LinkMePayload | null> {
509
+ return defaultController.claimDeferredIfAvailable();
510
+ }
511
+
512
+ export function setUserId(userId: string): Promise<void> {
513
+ defaultController.setUserId(userId);
514
+ return Promise.resolve();
515
+ }
516
+
517
+ export function setAdvertisingConsent(granted: boolean): Promise<void> {
518
+ defaultController.setAdvertisingConsent(granted);
519
+ return Promise.resolve();
520
+ }
521
+
522
+ export function setReady(): Promise<void> {
523
+ return defaultController.setReady();
524
+ }
525
+
526
+ export function track(event: string, properties?: Record<string, any>): Promise<void> {
527
+ return defaultController.track(event, properties);
528
+ }
529
+
530
+ export function onLink(listener: Listener): { remove: () => void } {
531
+ return defaultController.onLink(listener);
532
+ }
533
+
534
+ export class LinkMeClient {
535
+ private readonly controller: LinkMeController;
536
+
537
+ constructor(deps?: ControllerDeps) {
538
+ this.controller = new LinkMeController(deps);
539
+ }
540
+
541
+ configure(config: LinkMeConfig): Promise<void> {
542
+ return this.controller.configure(config);
543
+ }
544
+
545
+ getInitialLink(): Promise<LinkMePayload | null> {
546
+ return this.controller.getInitialLink();
547
+ }
548
+
549
+ handleUrl(url: string): Promise<boolean> {
550
+ return this.controller.handleUrl(url);
551
+ }
552
+
553
+ claimDeferredIfAvailable(): Promise<LinkMePayload | null> {
554
+ return this.controller.claimDeferredIfAvailable();
555
+ }
556
+
557
+ setUserId(userId: string): Promise<void> {
558
+ this.controller.setUserId(userId);
559
+ return Promise.resolve();
560
+ }
561
+
562
+ setAdvertisingConsent(granted: boolean): Promise<void> {
563
+ this.controller.setAdvertisingConsent(granted);
564
+ return Promise.resolve();
565
+ }
566
+
567
+ setReady(): Promise<void> {
568
+ return this.controller.setReady();
569
+ }
570
+
571
+ track(event: string, properties?: Record<string, any>): Promise<void> {
572
+ return this.controller.track(event, properties);
573
+ }
574
+
575
+ onLink(listener: Listener): { remove: () => void } {
576
+ return this.controller.onLink(listener);
577
+ }
578
+ }
579
+
580
+ export default LinkMeClient;
@@ -0,0 +1,11 @@
1
+ declare module 'react-native' {
2
+ export const Linking: {
3
+ addEventListener(event: 'url', listener: (ev: { url: string } | string) => any): any;
4
+ removeEventListener?: (event: 'url', listener: (ev: { url: string } | string) => void) => void;
5
+ getInitialURL(): Promise<string | null>;
6
+ };
7
+ export const Platform: {
8
+ OS: string;
9
+ Version?: string | number;
10
+ };
11
+ }