feat(sdk): add offline mode to the react-native SDK
This commit is contained in:
@@ -1,4 +1,8 @@
|
|||||||
import type { OpenPanelOptions, TrackProperties } from '@openpanel/sdk';
|
import type {
|
||||||
|
OpenPanelOptions,
|
||||||
|
TrackHandlerPayload,
|
||||||
|
TrackProperties,
|
||||||
|
} from '@openpanel/sdk';
|
||||||
import { OpenPanel as OpenPanelBase } from '@openpanel/sdk';
|
import { OpenPanel as OpenPanelBase } from '@openpanel/sdk';
|
||||||
import * as Application from 'expo-application';
|
import * as Application from 'expo-application';
|
||||||
import Constants from 'expo-constants';
|
import Constants from 'expo-constants';
|
||||||
@@ -6,9 +10,49 @@ import { AppState, Platform } from 'react-native';
|
|||||||
|
|
||||||
export * from '@openpanel/sdk';
|
export * from '@openpanel/sdk';
|
||||||
|
|
||||||
|
const QUEUE_STORAGE_KEY = '@openpanel/offline_queue';
|
||||||
|
|
||||||
|
interface StorageLike {
|
||||||
|
getItem(key: string): Promise<string | null>;
|
||||||
|
setItem(key: string, value: string): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NetworkStateLike {
|
||||||
|
isConnected: boolean | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NetworkInfoLike {
|
||||||
|
addEventListener(callback: (state: NetworkStateLike) => void): () => void;
|
||||||
|
fetch(): Promise<NetworkStateLike>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReactNativeOpenPanelOptions extends OpenPanelOptions {
|
||||||
|
/**
|
||||||
|
* Provide an AsyncStorage-compatible adapter to persist the event queue
|
||||||
|
* across app restarts (enables full offline support).
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
* new OpenPanel({ clientId: '...', storage: AsyncStorage });
|
||||||
|
*/
|
||||||
|
storage?: StorageLike;
|
||||||
|
/**
|
||||||
|
* Provide a NetInfo-compatible adapter to detect connectivity changes and
|
||||||
|
* automatically flush the queue when the device comes back online.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* import NetInfo from '@react-native-community/netinfo';
|
||||||
|
* new OpenPanel({ clientId: '...', networkInfo: NetInfo });
|
||||||
|
*/
|
||||||
|
networkInfo?: NetworkInfoLike;
|
||||||
|
}
|
||||||
|
|
||||||
export class OpenPanel extends OpenPanelBase {
|
export class OpenPanel extends OpenPanelBase {
|
||||||
private lastPath = '';
|
private lastPath = '';
|
||||||
constructor(public options: OpenPanelOptions) {
|
private readonly storage?: StorageLike;
|
||||||
|
private isOnline = true;
|
||||||
|
|
||||||
|
constructor(public options: ReactNativeOpenPanelOptions) {
|
||||||
super({
|
super({
|
||||||
...options,
|
...options,
|
||||||
sdk: 'react-native',
|
sdk: 'react-native',
|
||||||
@@ -16,14 +60,30 @@ export class OpenPanel extends OpenPanelBase {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.api.addHeader('User-Agent', Constants.getWebViewUserAgentAsync());
|
this.api.addHeader('User-Agent', Constants.getWebViewUserAgentAsync());
|
||||||
|
this.storage = options.storage;
|
||||||
|
|
||||||
|
if (options.networkInfo) {
|
||||||
|
options.networkInfo.fetch().then(({ isConnected }) => {
|
||||||
|
this.isOnline = isConnected ?? true;
|
||||||
|
});
|
||||||
|
options.networkInfo.addEventListener(({ isConnected }) => {
|
||||||
|
const wasOffline = !this.isOnline;
|
||||||
|
this.isOnline = isConnected ?? true;
|
||||||
|
if (wasOffline && this.isOnline) {
|
||||||
|
this.flush();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
AppState.addEventListener('change', (state) => {
|
AppState.addEventListener('change', (state) => {
|
||||||
if (state === 'active') {
|
if (state === 'active') {
|
||||||
this.setDefaultProperties();
|
this.setDefaultProperties();
|
||||||
|
this.flush();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this.setDefaultProperties();
|
this.setDefaultProperties();
|
||||||
|
this.loadPersistedQueue();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async setDefaultProperties() {
|
private async setDefaultProperties() {
|
||||||
@@ -37,6 +97,59 @@ export class OpenPanel extends OpenPanelBase {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async loadPersistedQueue() {
|
||||||
|
if (!this.storage) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const stored = await this.storage.getItem(QUEUE_STORAGE_KEY);
|
||||||
|
if (stored) {
|
||||||
|
const items = JSON.parse(stored);
|
||||||
|
if (Array.isArray(items) && items.length > 0) {
|
||||||
|
this.queue = [...items, ...this.queue];
|
||||||
|
this.flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
this.log('Failed to load persisted queue');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private persistQueue() {
|
||||||
|
if (!this.storage) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.storage
|
||||||
|
.setItem(QUEUE_STORAGE_KEY, JSON.stringify(this.queue))
|
||||||
|
.catch(() => {
|
||||||
|
this.log('Failed to persist queue');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
addQueue(payload: TrackHandlerPayload) {
|
||||||
|
super.addQueue(payload);
|
||||||
|
this.persistQueue();
|
||||||
|
}
|
||||||
|
|
||||||
|
async send(payload: TrackHandlerPayload) {
|
||||||
|
if (this.options.filter && !this.options.filter(payload)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!this.isOnline) {
|
||||||
|
this.addQueue(payload);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return await super.send(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
flush() {
|
||||||
|
if (!this.isOnline) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
super.flush();
|
||||||
|
this.persistQueue();
|
||||||
|
}
|
||||||
|
|
||||||
track(name: string, properties?: TrackProperties) {
|
track(name: string, properties?: TrackProperties) {
|
||||||
return super.track(name, { ...properties, __path: this.lastPath });
|
return super.track(name, { ...properties, __path: this.lastPath });
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user