diff --git a/app/_layout.tsx b/app/_layout.tsx index 3407c19..21e247d 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -1,6 +1,8 @@ import * as Sentry from "@sentry/react-native"; import { Slot } from "expo-router"; -import PostHog, { PostHogProvider } from "posthog-react-native"; +import { PostHogProvider } from "posthog-react-native"; + +import { posthog } from "../services/posthog"; Sentry.init({ dsn: "https://316d95879bd0e47063df647af48ceb1f@o149940.ingest.us.sentry.io/4510799071608832", @@ -16,14 +18,6 @@ Sentry.init({ // spotlight: __DEV__, }); -const posthog = new PostHog( - "phc_VOXrU28p44Z0coehNjKThwVPK5dO0A6xwQTQqThWI1c", - { - host: "https://us.i.posthog.com", - captureAppLifecycleEvents: true, - } -); - export default function RootLayout() { return ( diff --git a/app/index.tsx b/app/index.tsx index 7136500..36b96ca 100644 --- a/app/index.tsx +++ b/app/index.tsx @@ -4,30 +4,33 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import WebView, { type WebViewMessageEvent } from 'react-native-webview'; import packageJson from '../package.json'; +import { registerHandlers, dispatch } from '../services/bridge-dispatcher'; import { setupPlayer, - handleLoad, - handlePause, - handleResume, - handleStop, - handleSkipTo, - handleSetRate, - handleSeekTo, + getAudioHandlers, registerEventListeners, - type LoadMessage, } from '../services/audio-bridge'; +import { getIdentityHandlers } from '../services/identity-bridge'; +import { getURLHandlers, isDeepLink, openDeepLink } from '../services/url-bridge'; +import { posthog } from '../services/posthog'; export default function App() { const insets = useSafeAreaInsets(); const webViewRef = useRef(null); const sendToWebView = useCallback((data: object) => { + const json = JSON.stringify(data); webViewRef.current?.injectJavaScript( - `window.dispatchEvent(new CustomEvent('nativeAudioEvent',{detail:${JSON.stringify(data)}}));true;` + `window.dispatchEvent(new CustomEvent('nativeAudioEvent',{detail:${json}}));` + + `window.dispatchEvent(new CustomEvent('nativeBridgeEvent',{detail:${json}}));true;` ); }, []); useEffect(() => { + registerHandlers(getAudioHandlers()); + registerHandlers(getIdentityHandlers(posthog)); + registerHandlers(getURLHandlers()); + setupPlayer(); const unsubscribe = registerEventListeners(sendToWebView); return unsubscribe; @@ -38,42 +41,23 @@ export default function App() { webViewRef.current?.reload(); }, []); + // Intercept wallet deep links (wc:, metamask:, etc.) that JS SDKs + // trigger via navigation rather than postMessage. + const handleNavigationRequest = useCallback( + (request: { url: string }) => { + if (isDeepLink(request.url)) { + openDeepLink(request.url).catch((e) => console.warn('[deep link]', e)); + return false; + } + return true; + }, + [] + ); + const handleMessage = useCallback( async (event: WebViewMessageEvent) => { try { - const msg: { type: string; [key: string]: unknown } = JSON.parse( - event.nativeEvent.data - ); - - switch (msg.type) { - case 'load': - await handleLoad(msg as unknown as LoadMessage); - break; - case 'pause': - await handlePause(); - break; - case 'resume': - await handleResume(); - break; - case 'stop': - await handleStop(); - break; - case 'skipTo': - if (typeof msg.index === 'number') { - await handleSkipTo(msg.index); - } - break; - case 'setRate': - if (typeof msg.rate === 'number') { - await handleSetRate(msg.rate); - } - break; - case 'seekTo': - if (typeof msg.position === 'number') { - await handleSeekTo(msg.position); - } - break; - } + await dispatch(event.nativeEvent.data); } catch (e) { console.warn('[onMessage]', e); } @@ -95,6 +79,7 @@ export default function App() { mediaPlaybackRequiresUserAction={false} allowsInlineMediaPlayback={true} pullToRefreshEnabled={true} + onShouldStartLoadWithRequest={handleNavigationRequest} onMessage={handleMessage} onContentProcessDidTerminate={handleContentProcessDidTerminate} onError={(e) => console.warn('[WebView error]', e.nativeEvent)} diff --git a/services/audio-bridge.d.ts b/services/audio-bridge.d.ts index 1b95015..9177745 100644 --- a/services/audio-bridge.d.ts +++ b/services/audio-bridge.d.ts @@ -17,4 +17,7 @@ export function handleStop(): void; export function handleSkipTo(index: number): void; export function handleSetRate(rate: number): void; export function handleSeekTo(position: number): Promise; +import type { BridgeHandlerMap } from './bridge-dispatcher'; + +export function getAudioHandlers(): BridgeHandlerMap; export function registerEventListeners(sendToWebView: (data: object) => void): () => void; diff --git a/services/audio-bridge.native.ts b/services/audio-bridge.native.ts index 9fa62b2..c8d8636 100644 --- a/services/audio-bridge.native.ts +++ b/services/audio-bridge.native.ts @@ -7,7 +7,7 @@ import { } from 'expo-audio'; import { Platform } from 'react-native'; -type SendToWebView = (data: object) => void; +import type { SendToWebView, BridgeHandlerMap } from './bridge-dispatcher'; interface TrackInfo { index: number; @@ -368,6 +368,26 @@ export async function handleSeekTo(position: number): Promise { await getActivePlayer()?.seekTo(position); } +export function getAudioHandlers(): BridgeHandlerMap { + return { + load: (msg) => handleLoad(msg as unknown as LoadMessage), + pause: () => handlePause(), + resume: () => handleResume(), + stop: () => handleStop(), + skipTo: (msg) => { + if (typeof msg.index === 'number') handleSkipTo(msg.index); + }, + setRate: (msg) => { + if (typeof msg.rate === 'number') handleSetRate(msg.rate); + }, + seekTo: (msg) => { + if (typeof msg.position === 'number') { + return handleSeekTo(msg.position); + } + }, + }; +} + export function registerEventListeners(sendToWebView: SendToWebView) { notifyWebView = sendToWebView; lastSentState = ''; diff --git a/services/audio-bridge.web.ts b/services/audio-bridge.web.ts index 99d01f9..046e49d 100644 --- a/services/audio-bridge.web.ts +++ b/services/audio-bridge.web.ts @@ -17,6 +17,11 @@ export function handleStop(): void {} export function handleSkipTo(_index: number): void {} export function handleSetRate(_rate: number): void {} export async function handleSeekTo(_position: number): Promise {} +import type { BridgeHandlerMap } from './bridge-dispatcher'; + +export function getAudioHandlers(): BridgeHandlerMap { + return {}; +} export function registerEventListeners(_sendToWebView: (data: object) => void) { return () => {}; } diff --git a/services/bridge-dispatcher.ts b/services/bridge-dispatcher.ts new file mode 100644 index 0000000..0c58dfe --- /dev/null +++ b/services/bridge-dispatcher.ts @@ -0,0 +1,27 @@ +export type SendToWebView = (data: object) => void; +export type BridgeHandler = ( + msg: Record +) => void | Promise; +export type BridgeHandlerMap = Record; + +const handlers = new Map(); + +export function registerHandlers( + map: BridgeHandlerMap +): void { + for (const [type, handler] of Object.entries(map)) { + handlers.set(type, handler); + } +} + +export async function dispatch( + raw: string +): Promise { + const msg: { type: string; [key: string]: unknown } = JSON.parse(raw); + const handler = handlers.get(msg.type); + if (!handler) { + console.warn(`[bridge] unknown message type: ${msg.type}`); + return; + } + await handler(msg); +} diff --git a/services/identity-bridge.d.ts b/services/identity-bridge.d.ts new file mode 100644 index 0000000..67d055c --- /dev/null +++ b/services/identity-bridge.d.ts @@ -0,0 +1,4 @@ +import type PostHog from 'posthog-react-native'; +import type { BridgeHandlerMap } from './bridge-dispatcher'; + +export function getIdentityHandlers(posthog: PostHog): BridgeHandlerMap; diff --git a/services/identity-bridge.native.ts b/services/identity-bridge.native.ts new file mode 100644 index 0000000..30ca6ea --- /dev/null +++ b/services/identity-bridge.native.ts @@ -0,0 +1,47 @@ +import * as Sentry from '@sentry/react-native'; +import analytics from '@react-native-firebase/analytics'; +import type PostHog from 'posthog-react-native'; +import type { BridgeHandlerMap } from './bridge-dispatcher'; + +export function getIdentityHandlers(posthog: PostHog): BridgeHandlerMap { + return { + identifyUser: async (msg) => { + const userId = typeof msg.userId === 'string' ? msg.userId : undefined; + if (!userId) return; + + const email = typeof msg.email === 'string' ? msg.email : undefined; + const displayName = + typeof msg.displayName === 'string' ? msg.displayName : undefined; + const isLikerPlus = !!msg.isLikerPlus; + const loginMethod = + typeof msg.loginMethod === 'string' ? msg.loginMethod : undefined; + + posthog.identify(userId, { + email: email ?? null, + name: displayName ?? null, + is_liker_plus: isLikerPlus, + login_method: loginMethod ?? null, + }); + + await Promise.all([ + analytics().setUserId(userId), + analytics().setUserProperties({ + is_liker_plus: String(isLikerPlus), + login_method: loginMethod || '', + }), + ]); + + Sentry.setUser({ + id: userId, + email, + username: displayName || userId, + }); + }, + + resetUser: async () => { + posthog.reset(); + await analytics().setUserId(null); + Sentry.setUser(null); + }, + }; +} diff --git a/services/identity-bridge.web.ts b/services/identity-bridge.web.ts new file mode 100644 index 0000000..6aa2d37 --- /dev/null +++ b/services/identity-bridge.web.ts @@ -0,0 +1,5 @@ +import type { BridgeHandlerMap } from './bridge-dispatcher'; + +export function getIdentityHandlers(): BridgeHandlerMap { + return {}; +} diff --git a/services/posthog.ts b/services/posthog.ts new file mode 100644 index 0000000..0b18a72 --- /dev/null +++ b/services/posthog.ts @@ -0,0 +1,9 @@ +import PostHog from 'posthog-react-native'; + +export const posthog = new PostHog( + 'phc_VOXrU28p44Z0coehNjKThwVPK5dO0A6xwQTQqThWI1c', + { + host: 'https://us.i.posthog.com', + captureAppLifecycleEvents: true, + } +); diff --git a/services/url-bridge.d.ts b/services/url-bridge.d.ts new file mode 100644 index 0000000..dd58d88 --- /dev/null +++ b/services/url-bridge.d.ts @@ -0,0 +1,5 @@ +import type { BridgeHandlerMap } from './bridge-dispatcher'; + +export function isDeepLink(url: string): boolean; +export function openDeepLink(url: string): Promise; +export function getURLHandlers(): BridgeHandlerMap; diff --git a/services/url-bridge.native.ts b/services/url-bridge.native.ts new file mode 100644 index 0000000..dd7d616 --- /dev/null +++ b/services/url-bridge.native.ts @@ -0,0 +1,48 @@ +import * as WebBrowser from 'expo-web-browser'; +import * as Linking from 'expo-linking'; +import type { BridgeHandlerMap } from './bridge-dispatcher'; + +const DEEP_LINK_SCHEME_RE = + /^(mailto:|tel:|wc:|metamask:|cbwallet:|rainbow:|trust:)/; + +const WALLET_UNIVERSAL_LINK_PREFIXES = [ + 'https://metamask.app.link/', + 'https://go.cb-w.com/', + 'https://link.trustwallet.com/', +]; + +/** Returns true for URLs that should be opened by the OS (wallet deep links, + * mailto, tel) rather than loaded inside the WebView or in-app browser. */ +export function isDeepLink(url: string): boolean { + return ( + DEEP_LINK_SCHEME_RE.test(url) || + WALLET_UNIVERSAL_LINK_PREFIXES.some((prefix) => url.startsWith(prefix)) + ); +} + +export async function openDeepLink(url: string): Promise { + await Linking.openURL(url); +} + +export function getURLHandlers(): BridgeHandlerMap { + return { + openExternalURL: async (msg) => { + const url = typeof msg.url === 'string' ? msg.url : undefined; + if (!url) return; + + try { + if (isDeepLink(url)) { + await openDeepLink(url); + } else { + await WebBrowser.openBrowserAsync(url, { + dismissButtonStyle: 'close', + presentationStyle: + WebBrowser.WebBrowserPresentationStyle.FULL_SCREEN, + }); + } + } catch (e) { + console.warn('[openExternalURL]', e); + } + }, + }; +} diff --git a/services/url-bridge.web.ts b/services/url-bridge.web.ts new file mode 100644 index 0000000..8c261ab --- /dev/null +++ b/services/url-bridge.web.ts @@ -0,0 +1,11 @@ +import type { BridgeHandlerMap } from './bridge-dispatcher'; + +export function isDeepLink(_url: string): boolean { + return false; +} + +export async function openDeepLink(_url: string): Promise {} + +export function getURLHandlers(): BridgeHandlerMap { + return {}; +}