@dubsdotapp/expo 0.3.9 → 0.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dubsdotapp/expo",
3
- "version": "0.3.9",
3
+ "version": "0.4.0",
4
4
  "description": "React Native SDK for the Dubs betting platform",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.mjs",
@@ -28,3 +28,5 @@ export { useEnterArcadePool } from './useEnterArcadePool';
28
28
  export type { EnterArcadePoolMutationResult } from './useEnterArcadePool';
29
29
  export { useArcadeCountdown } from './useArcadeCountdown';
30
30
  export type { ArcadeCountdown } from './useArcadeCountdown';
31
+ export { useArcadeBridge } from './useArcadeBridge';
32
+ export type { UseArcadeBridgeOptions, UseArcadeBridgeResult } from './useArcadeBridge';
@@ -0,0 +1,188 @@
1
+ /**
2
+ * useArcadeBridge
3
+ *
4
+ * Owns the full RN ↔ WebView bridge for Dubs Arcade pool integration.
5
+ * Replaces the manual sessionToken / gameStartTime / handleMessage / injectJavaScript
6
+ * wiring that previously lived inline in each game's index.tsx.
7
+ *
8
+ * PROTOCOL VERSION: dubsArcade 1.0
9
+ *
10
+ * Usage:
11
+ *
12
+ * const { webviewRef, handleMessage, triggerPlay, lastResult } = useArcadeBridge({
13
+ * poolId,
14
+ * startAttempt,
15
+ * submitScore,
16
+ * canPlay,
17
+ * onScoreSubmitted: (result) => { refetchPool(); },
18
+ * onError: (err) => { console.warn(err); },
19
+ * });
20
+ *
21
+ * <WebView ref={webviewRef} onMessage={handleMessage} ... />
22
+ * <TouchableOpacity onPress={triggerPlay}>Play</TouchableOpacity>
23
+ */
24
+
25
+ import { useRef, useState, useCallback } from 'react';
26
+ import type { StartAttemptResult, SubmitScoreResult } from '../types';
27
+
28
+ // react-native-webview is a peer dependency of apps using this SDK;
29
+ // we only need its ref type for the public interface.
30
+ // Using `any` here avoids forcing apps that don't use WebView to install it.
31
+ type WebView = any;
32
+
33
+ const PROTOCOL_VERSION = '1.0';
34
+
35
+ // ─── Types ──────────────────────────────────────────────────────────────────
36
+
37
+ export interface UseArcadeBridgeOptions {
38
+ /** Whether the player currently has an entry with lives remaining */
39
+ canPlay: boolean;
40
+
41
+ /** From useArcadeGame — starts a new life on the server */
42
+ startAttempt: () => Promise<StartAttemptResult>;
43
+
44
+ /** From useArcadeGame — submits a score for a completed life */
45
+ submitScore: (
46
+ sessionToken: string,
47
+ score: number,
48
+ durationMs?: number,
49
+ ) => Promise<SubmitScoreResult>;
50
+
51
+ /** Called after a score is successfully submitted */
52
+ onScoreSubmitted?: (result: SubmitScoreResult) => void;
53
+
54
+ /** Called on any bridge error (network, invalid message, etc.) */
55
+ onError?: (err: Error) => void;
56
+ }
57
+
58
+ export interface UseArcadeBridgeResult {
59
+ /** Attach to <WebView ref={webviewRef}> */
60
+ webviewRef: React.RefObject<WebView>;
61
+
62
+ /** Pass to <WebView onMessage={handleMessage}> */
63
+ handleMessage: (event: any) => void;
64
+
65
+ /**
66
+ * Call when the player taps PLAY in your UI.
67
+ * Calls startAttempt() then injects the session token + ARCADE_START into the WebView.
68
+ */
69
+ triggerPlay: () => Promise<void>;
70
+
71
+ /** The result of the most recent submitted score, or null */
72
+ lastResult: SubmitScoreResult | null;
73
+
74
+ /** True while a startAttempt or submitScore request is in flight */
75
+ bridgeLoading: boolean;
76
+ }
77
+
78
+ // ─── Hook ───────────────────────────────────────────────────────────────────
79
+
80
+ export function useArcadeBridge({
81
+ canPlay,
82
+ startAttempt,
83
+ submitScore,
84
+ onScoreSubmitted,
85
+ onError,
86
+ }: UseArcadeBridgeOptions): UseArcadeBridgeResult {
87
+ const webviewRef = useRef<WebView>(null);
88
+
89
+ // Active session state — kept in refs so handleMessage callbacks stay stable
90
+ const sessionTokenRef = useRef<string | null>(null);
91
+ const gameStartTimeRef = useRef<number>(0);
92
+
93
+ const [lastResult, setLastResult] = useState<SubmitScoreResult | null>(null);
94
+ const [bridgeLoading, setBridgeLoading] = useState(false);
95
+
96
+ // ── Inject session token + fire ARCADE_START ──────────────────────────────
97
+ const injectSession = useCallback((token: string, attemptNumber: number) => {
98
+ webviewRef.current?.injectJavaScript(`
99
+ window.ARCADE_SESSION_TOKEN = ${JSON.stringify(token)};
100
+ window.ARCADE_ATTEMPT_NUMBER = ${attemptNumber};
101
+ window._ARCADE_GAME_START_TIME = Date.now();
102
+ window.dispatchEvent(new Event('ARCADE_START'));
103
+ true;
104
+ `);
105
+ }, []);
106
+
107
+ // ── triggerPlay ───────────────────────────────────────────────────────────
108
+ const triggerPlay = useCallback(async () => {
109
+ if (!canPlay) return;
110
+ setBridgeLoading(true);
111
+ try {
112
+ const result = await startAttempt();
113
+ sessionTokenRef.current = result.sessionToken;
114
+ gameStartTimeRef.current = Date.now();
115
+ setLastResult(null);
116
+ injectSession(result.sessionToken, result.attemptNumber);
117
+ } catch (err) {
118
+ const e = err instanceof Error ? err : new Error(String(err));
119
+ onError?.(e);
120
+ } finally {
121
+ setBridgeLoading(false);
122
+ }
123
+ }, [canPlay, startAttempt, injectSession, onError]);
124
+
125
+ // ── handleMessage ─────────────────────────────────────────────────────────
126
+ const handleMessage = useCallback(
127
+ async (event: any) => {
128
+ let data: Record<string, any>;
129
+
130
+ // Parse + validate protocol envelope
131
+ try {
132
+ data = JSON.parse(event.nativeEvent.data);
133
+ } catch {
134
+ return; // non-JSON postMessages from the game's own code — ignore
135
+ }
136
+
137
+ // Only process messages from our bridge protocol
138
+ if (data.dubsArcade !== PROTOCOL_VERSION) return;
139
+
140
+ switch (data.type) {
141
+ case 'TAP_PLAY': {
142
+ if (canPlay) {
143
+ triggerPlay();
144
+ }
145
+ return;
146
+ }
147
+
148
+ case 'GAME_OVER': {
149
+ const token = sessionTokenRef.current;
150
+ if (!token) return; // no active session — stale message, ignore
151
+
152
+ const score: number = typeof data.score === 'number' ? data.score : 0;
153
+ // Use server-start time as ground truth; fall back to game's reported duration
154
+ const duration =
155
+ gameStartTimeRef.current > 0
156
+ ? Date.now() - gameStartTimeRef.current
157
+ : typeof data.durationMs === 'number'
158
+ ? data.durationMs
159
+ : undefined;
160
+
161
+ // Clear immediately to prevent double-submit on re-render
162
+ sessionTokenRef.current = null;
163
+ gameStartTimeRef.current = 0;
164
+
165
+ setBridgeLoading(true);
166
+ try {
167
+ const result = await submitScore(token, score, duration);
168
+ setLastResult(result);
169
+ onScoreSubmitted?.(result);
170
+ } catch (err) {
171
+ const e = err instanceof Error ? err : new Error(String(err));
172
+ onError?.(e);
173
+ } finally {
174
+ setBridgeLoading(false);
175
+ }
176
+ return;
177
+ }
178
+
179
+ default:
180
+ // Unknown message type from a future protocol version — ignore gracefully
181
+ return;
182
+ }
183
+ },
184
+ [canPlay, triggerPlay, submitScore, onScoreSubmitted, onError],
185
+ );
186
+
187
+ return { webviewRef, handleMessage, triggerPlay, lastResult, bridgeLoading };
188
+ }
package/src/index.ts CHANGED
@@ -109,6 +109,7 @@ export {
109
109
  useArcadeGame,
110
110
  useEnterArcadePool,
111
111
  useArcadeCountdown,
112
+ useArcadeBridge,
112
113
  } from './hooks';
113
114
  export type {
114
115
  CreateGameMutationResult,
@@ -123,6 +124,8 @@ export type {
123
124
  UseArcadeGameResult,
124
125
  EnterArcadePoolMutationResult,
125
126
  ArcadeCountdown,
127
+ UseArcadeBridgeOptions,
128
+ UseArcadeBridgeResult,
126
129
  } from './hooks';
127
130
 
128
131
  // UI