Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 3 additions & 9 deletions app/_layout.tsx
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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 (
<PostHogProvider client={posthog}>
Expand Down
69 changes: 27 additions & 42 deletions app/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<WebView>(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;
Expand All @@ -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);
}
Expand All @@ -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)}
Expand Down
3 changes: 3 additions & 0 deletions services/audio-bridge.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>;
import type { BridgeHandlerMap } from './bridge-dispatcher';

export function getAudioHandlers(): BridgeHandlerMap;
export function registerEventListeners(sendToWebView: (data: object) => void): () => void;
22 changes: 21 additions & 1 deletion services/audio-bridge.native.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -368,6 +368,26 @@ export async function handleSeekTo(position: number): Promise<void> {
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 = '';
Expand Down
5 changes: 5 additions & 0 deletions services/audio-bridge.web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {}
import type { BridgeHandlerMap } from './bridge-dispatcher';

export function getAudioHandlers(): BridgeHandlerMap {
return {};
}
export function registerEventListeners(_sendToWebView: (data: object) => void) {
return () => {};
}
27 changes: 27 additions & 0 deletions services/bridge-dispatcher.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
export type SendToWebView = (data: object) => void;
export type BridgeHandler = (
msg: Record<string, unknown>
) => void | Promise<void>;
export type BridgeHandlerMap = Record<string, BridgeHandler>;

const handlers = new Map<string, BridgeHandler>();

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<void> {
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}`);
Comment on lines +20 to +23
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dispatch() assumes JSON.parse(raw) returns an object with a string type. If the WebView sends malformed JSON or null, this can throw or lead to confusing logs. Add a small runtime guard (and ideally catch JSON.parse errors here) so dispatch can safely ignore/diagnose invalid messages without relying on callers to wrap it.

Suggested change
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}`);
let msg: { type: string; [key: string]: unknown };
try {
msg = JSON.parse(raw);
} catch (error) {
console.warn('[bridge] failed to parse message from WebView', {
raw,
error,
});
return;
}
if (!msg || typeof msg !== 'object') {
console.warn('[bridge] received non-object message from WebView', {
raw,
parsed: msg,
});
return;
}
const type = (msg as { type?: unknown }).type;
if (typeof type !== 'string' || !type) {
console.warn('[bridge] received message without valid string "type"', {
raw,
parsed: msg,
});
return;
}
const handler = handlers.get(type);
if (!handler) {
console.warn(`[bridge] unknown message type: ${type}`);

Copilot uses AI. Check for mistakes.
return;
}
await handler(msg);
}
4 changes: 4 additions & 0 deletions services/identity-bridge.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import type PostHog from 'posthog-react-native';
import type { BridgeHandlerMap } from './bridge-dispatcher';

export function getIdentityHandlers(posthog: PostHog): BridgeHandlerMap;
47 changes: 47 additions & 0 deletions services/identity-bridge.native.ts
Original file line number Diff line number Diff line change
@@ -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);
},
};
}
5 changes: 5 additions & 0 deletions services/identity-bridge.web.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import type { BridgeHandlerMap } from './bridge-dispatcher';

export function getIdentityHandlers(): BridgeHandlerMap {
return {};
}
9 changes: 9 additions & 0 deletions services/posthog.ts
Original file line number Diff line number Diff line change
@@ -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,
}
);
5 changes: 5 additions & 0 deletions services/url-bridge.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import type { BridgeHandlerMap } from './bridge-dispatcher';

export function isDeepLink(url: string): boolean;
export function openDeepLink(url: string): Promise<void>;
export function getURLHandlers(): BridgeHandlerMap;
48 changes: 48 additions & 0 deletions services/url-bridge.native.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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);
}
},
};
}
11 changes: 11 additions & 0 deletions services/url-bridge.web.ts
Original file line number Diff line number Diff line change
@@ -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<void> {}

export function getURLHandlers(): BridgeHandlerMap {
return {};
}
Loading