From ee7c776bcefd8f6b5d794e9a1d044379e3078ccf Mon Sep 17 00:00:00 2001 From: William Chong Date: Fri, 27 Mar 2026 18:59:34 +0800 Subject: [PATCH 1/3] =?UTF-8?q?=F0=9F=8F=97=EF=B8=8F=20Add=20generalized?= =?UTF-8?q?=20WebView=20bridge=20with=20identity=20and=20URL=20handlers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace monolithic switch in index.tsx with a dispatcher/handler registry pattern. Add identity bridge (PostHog, Firebase Analytics, Sentry user sync) and URL bridge (in-app browser via expo-web-browser) as the first non-audio bridge handlers. Extract PostHog instance to shared module. --- app/_layout.tsx | 12 ++----- app/index.tsx | 57 ++++++++---------------------- services/audio-bridge.d.ts | 3 ++ services/audio-bridge.native.ts | 20 ++++++++++- services/audio-bridge.web.ts | 5 +++ services/bridge-dispatcher.ts | 33 +++++++++++++++++ services/identity-bridge.d.ts | 4 +++ services/identity-bridge.native.ts | 47 ++++++++++++++++++++++++ services/identity-bridge.web.ts | 5 +++ services/posthog.ts | 9 +++++ services/url-bridge.d.ts | 3 ++ services/url-bridge.native.ts | 26 ++++++++++++++ services/url-bridge.web.ts | 5 +++ 13 files changed, 176 insertions(+), 53 deletions(-) create mode 100644 services/bridge-dispatcher.ts create mode 100644 services/identity-bridge.d.ts create mode 100644 services/identity-bridge.native.ts create mode 100644 services/identity-bridge.web.ts create mode 100644 services/posthog.ts create mode 100644 services/url-bridge.d.ts create mode 100644 services/url-bridge.native.ts create mode 100644 services/url-bridge.web.ts 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..83fd8b0 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 } 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; @@ -41,44 +44,12 @@ export default function App() { 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, sendToWebView); } catch (e) { console.warn('[onMessage]', e); } }, - [] + [sendToWebView] ); return ( 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..6a1eb2d 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,24 @@ 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') 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..23d0f87 --- /dev/null +++ b/services/bridge-dispatcher.ts @@ -0,0 +1,33 @@ +export type SendToWebView = (data: object) => void; +export type BridgeHandler = ( + msg: Record +) => void | Promise; +export type BridgeHandlerMap = Record; + +type MessageHandler = ( + msg: Record, + sendToWebView: SendToWebView +) => void | Promise; + +const handlers = new Map(); + +export function registerHandlers( + map: Record +): void { + for (const [type, handler] of Object.entries(map)) { + handlers.set(type, handler); + } +} + +export async function dispatch( + raw: string, + sendToWebView: SendToWebView +): 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, sendToWebView); +} 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..5611a25 --- /dev/null +++ b/services/url-bridge.d.ts @@ -0,0 +1,3 @@ +import type { BridgeHandlerMap } from './bridge-dispatcher'; + +export function getURLHandlers(): BridgeHandlerMap; diff --git a/services/url-bridge.native.ts b/services/url-bridge.native.ts new file mode 100644 index 0000000..31b1751 --- /dev/null +++ b/services/url-bridge.native.ts @@ -0,0 +1,26 @@ +import * as WebBrowser from 'expo-web-browser'; +import * as Linking from 'expo-linking'; +import type { BridgeHandlerMap } from './bridge-dispatcher'; + +export function getURLHandlers(): BridgeHandlerMap { + return { + openExternalURL: async (msg) => { + const url = msg.url as string; + if (!url) return; + + try { + if (url.startsWith('mailto:') || url.startsWith('tel:')) { + await Linking.openURL(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..be9f809 --- /dev/null +++ b/services/url-bridge.web.ts @@ -0,0 +1,5 @@ +import type { BridgeHandlerMap } from './bridge-dispatcher'; + +export function getURLHandlers(): BridgeHandlerMap { + return {}; +} From fc5cfc9db8d86874ac0c1eeaae30aa74be093c0b Mon Sep 17 00:00:00 2001 From: William Chong Date: Fri, 27 Mar 2026 19:07:26 +0800 Subject: [PATCH 2/3] =?UTF-8?q?=E2=9C=A8=20Intercept=20wallet=20deep=20lin?= =?UTF-8?q?ks=20in=20WebView=20via=20onShouldStartLoadWithRequest?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WalletConnect, MetaMask, Coinbase and other wallet SDKs trigger navigation directly (wc:, metamask:, cbwallet: schemes + universal links) which the WebView silently drops. Intercept these via onShouldStartLoadWithRequest and route through expo-linking. --- app/index.tsx | 16 +++++++++++++++- services/url-bridge.d.ts | 2 ++ services/url-bridge.native.ts | 26 ++++++++++++++++++++++++-- services/url-bridge.web.ts | 6 ++++++ 4 files changed, 47 insertions(+), 3 deletions(-) diff --git a/app/index.tsx b/app/index.tsx index 83fd8b0..33cf608 100644 --- a/app/index.tsx +++ b/app/index.tsx @@ -11,7 +11,7 @@ import { registerEventListeners, } from '../services/audio-bridge'; import { getIdentityHandlers } from '../services/identity-bridge'; -import { getURLHandlers } from '../services/url-bridge'; +import { getURLHandlers, isDeepLink, openDeepLink } from '../services/url-bridge'; import { posthog } from '../services/posthog'; export default function App() { @@ -41,6 +41,19 @@ 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 { @@ -66,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/url-bridge.d.ts b/services/url-bridge.d.ts index 5611a25..dd58d88 100644 --- a/services/url-bridge.d.ts +++ b/services/url-bridge.d.ts @@ -1,3 +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 index 31b1751..c989ecd 100644 --- a/services/url-bridge.native.ts +++ b/services/url-bridge.native.ts @@ -2,14 +2,36 @@ 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 = msg.url as string; + const url = typeof msg.url === 'string' ? msg.url : undefined; if (!url) return; try { - if (url.startsWith('mailto:') || url.startsWith('tel:')) { + if (isDeepLink(url)) { await Linking.openURL(url); } else { await WebBrowser.openBrowserAsync(url, { diff --git a/services/url-bridge.web.ts b/services/url-bridge.web.ts index be9f809..8c261ab 100644 --- a/services/url-bridge.web.ts +++ b/services/url-bridge.web.ts @@ -1,5 +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 {}; } From 1376172dc8b739191d0333feb9fd22e6552f3ed6 Mon Sep 17 00:00:00 2001 From: William Chong Date: Mon, 30 Mar 2026 18:58:16 +0800 Subject: [PATCH 3/3] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Unify=20bridge=20handl?= =?UTF-8?q?er=20types=20and=20fix=20async=20seekTo=20dispatch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove unused sendToWebView parameter from dispatch() - Consolidate MessageHandler into BridgeHandler for consistent typing - Return handleSeekTo promise so dispatch properly awaits it - Use openDeepLink() in URL handler to centralize deep-link logic --- app/index.tsx | 4 ++-- services/audio-bridge.native.ts | 4 +++- services/bridge-dispatcher.ts | 14 ++++---------- services/url-bridge.native.ts | 2 +- 4 files changed, 10 insertions(+), 14 deletions(-) diff --git a/app/index.tsx b/app/index.tsx index 33cf608..36b96ca 100644 --- a/app/index.tsx +++ b/app/index.tsx @@ -57,12 +57,12 @@ export default function App() { const handleMessage = useCallback( async (event: WebViewMessageEvent) => { try { - await dispatch(event.nativeEvent.data, sendToWebView); + await dispatch(event.nativeEvent.data); } catch (e) { console.warn('[onMessage]', e); } }, - [sendToWebView] + [] ); return ( diff --git a/services/audio-bridge.native.ts b/services/audio-bridge.native.ts index 6a1eb2d..c8d8636 100644 --- a/services/audio-bridge.native.ts +++ b/services/audio-bridge.native.ts @@ -381,7 +381,9 @@ export function getAudioHandlers(): BridgeHandlerMap { if (typeof msg.rate === 'number') handleSetRate(msg.rate); }, seekTo: (msg) => { - if (typeof msg.position === 'number') handleSeekTo(msg.position); + if (typeof msg.position === 'number') { + return handleSeekTo(msg.position); + } }, }; } diff --git a/services/bridge-dispatcher.ts b/services/bridge-dispatcher.ts index 23d0f87..0c58dfe 100644 --- a/services/bridge-dispatcher.ts +++ b/services/bridge-dispatcher.ts @@ -4,15 +4,10 @@ export type BridgeHandler = ( ) => void | Promise; export type BridgeHandlerMap = Record; -type MessageHandler = ( - msg: Record, - sendToWebView: SendToWebView -) => void | Promise; - -const handlers = new Map(); +const handlers = new Map(); export function registerHandlers( - map: Record + map: BridgeHandlerMap ): void { for (const [type, handler] of Object.entries(map)) { handlers.set(type, handler); @@ -20,8 +15,7 @@ export function registerHandlers( } export async function dispatch( - raw: string, - sendToWebView: SendToWebView + raw: string ): Promise { const msg: { type: string; [key: string]: unknown } = JSON.parse(raw); const handler = handlers.get(msg.type); @@ -29,5 +23,5 @@ export async function dispatch( console.warn(`[bridge] unknown message type: ${msg.type}`); return; } - await handler(msg, sendToWebView); + await handler(msg); } diff --git a/services/url-bridge.native.ts b/services/url-bridge.native.ts index c989ecd..dd7d616 100644 --- a/services/url-bridge.native.ts +++ b/services/url-bridge.native.ts @@ -32,7 +32,7 @@ export function getURLHandlers(): BridgeHandlerMap { try { if (isDeepLink(url)) { - await Linking.openURL(url); + await openDeepLink(url); } else { await WebBrowser.openBrowserAsync(url, { dismissButtonStyle: 'close',