← back to Beverlyhillsvideos App

components/BHVWebView.tsx

199 lines

import React, { useRef, useState, useCallback } from 'react';
import {
  View,
  StyleSheet,
  ActivityIndicator,
  Text,
  TouchableOpacity,
  Share,
  Platform,
} from 'react-native';
import { WebView, WebViewNavigation } from 'react-native-webview';
import { Linking } from 'react-native';
import { Colors } from '@/constants/Colors';
import { useNetworkStatus } from '@/hooks/useNetworkStatus';

const BASE_DOMAIN = 'beverlyhillsvideos.com';
const BASE_URL = `https://${BASE_DOMAIN}`;

function isInternalUrl(url: string): boolean {
  try {
    const parsed = new URL(url);
    return (
      parsed.hostname === BASE_DOMAIN ||
      parsed.hostname === `www.${BASE_DOMAIN}`
    );
  } catch {
    return false;
  }
}

interface Props {
  initialUrl: string;
  shareTitle?: string;
}

export default function BHVWebView({ initialUrl, shareTitle }: Props) {
  const webViewRef = useRef<WebView>(null);
  const [loading, setLoading] = useState(true);
  const [canGoBack, setCanGoBack] = useState(false);
  const { isConnected } = useNetworkStatus();

  const handleNavigationChange = useCallback((nav: WebViewNavigation) => {
    setCanGoBack(nav.canGoBack);
    const url = nav.url;

    // External links: tel:, mailto:, off-domain — open in system browser
    if (
      url.startsWith('tel:') ||
      url.startsWith('mailto:') ||
      url.startsWith('sms:') ||
      (!isInternalUrl(url) && !url.startsWith('about:') && !url.startsWith('data:'))
    ) {
      // Prevent WebView navigation for external URLs
      // We use onShouldStartLoadWithRequest for this (see below)
    }
  }, []);

  const handleShouldStartLoad = useCallback(
    ({ url }: { url: string }): boolean => {
      if (
        url.startsWith('tel:') ||
        url.startsWith('mailto:') ||
        url.startsWith('sms:')
      ) {
        Linking.openURL(url).catch(() => {});
        return false;
      }
      if (!url.startsWith('about:') && !url.startsWith('data:') && !isInternalUrl(url)) {
        Linking.openURL(url).catch(() => {});
        return false;
      }
      return true;
    },
    []
  );

  const handleShare = useCallback(async () => {
    try {
      await Share.share({
        title: shareTitle ?? 'Beverly Hills Videos',
        message: `${shareTitle ?? 'Beverly Hills Videos'} — ${BASE_URL}`,
        url: BASE_URL,
      });
    } catch {
      // user dismissed
    }
  }, [shareTitle]);

  if (!isConnected) {
    return (
      <View style={styles.offlineContainer}>
        <Text style={styles.offlineIcon}>wifi_off</Text>
        <Text style={styles.offlineTitle}>No Connection</Text>
        <Text style={styles.offlineBody}>
          Connect to the internet and tap Retry to load Beverly Hills Videos.
        </Text>
        <TouchableOpacity
          style={styles.retryButton}
          onPress={() => webViewRef.current?.reload()}
          activeOpacity={0.8}
        >
          <Text style={styles.retryText}>Retry</Text>
        </TouchableOpacity>
      </View>
    );
  }

  return (
    <View style={styles.container}>
      {/* Share header button injected via parent layout, but we expose a method */}
      <WebView
        ref={webViewRef}
        source={{ uri: initialUrl }}
        style={styles.webview}
        onLoadStart={() => setLoading(true)}
        onLoadEnd={() => setLoading(false)}
        onNavigationStateChange={handleNavigationChange}
        onShouldStartLoadWithRequest={handleShouldStartLoad}
        pullToRefreshEnabled
        allowsBackForwardNavigationGestures={Platform.OS === 'ios'}
        allowsInlineMediaPlayback
        mediaPlaybackRequiresUserAction={false}
        javaScriptEnabled
        domStorageEnabled
        startInLoadingState={false}
        decelerationRate="normal"
        originWhitelist={['https://*', 'http://*', 'tel:*', 'mailto:*']}
        contentInsetAdjustmentBehavior="automatic"
        injectedJavaScriptBeforeContentLoaded={`(function(){
          var s=document.createElement('style');
          s.innerHTML='.adsbygoogle,ins.adsbygoogle,.ad-wrap{display:none!important}';
          (document.head||document.documentElement).appendChild(s);
          window.adsbygoogle=window.adsbygoogle||[]; window.adsbygoogle.push=function(){};
        })(); true;`}
      />
      {loading && (
        <View style={styles.loadingOverlay} pointerEvents="none">
          <ActivityIndicator size="large" color={Colors.green} />
        </View>
      )}
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: Colors.cream,
  },
  webview: {
    flex: 1,
    backgroundColor: Colors.cream,
  },
  loadingOverlay: {
    ...StyleSheet.absoluteFill,
    justifyContent: 'center',
    alignItems: 'center',
    backgroundColor: Colors.cream,
  },
  offlineContainer: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
    backgroundColor: Colors.cream,
    padding: 32,
  },
  offlineIcon: {
    fontSize: 48,
    marginBottom: 16,
    color: Colors.textSecondary,
  },
  offlineTitle: {
    fontSize: 22,
    fontWeight: '600',
    color: Colors.ink,
    marginBottom: 8,
    textAlign: 'center',
  },
  offlineBody: {
    fontSize: 15,
    color: Colors.textSecondary,
    textAlign: 'center',
    lineHeight: 22,
    marginBottom: 32,
  },
  retryButton: {
    backgroundColor: Colors.green,
    paddingVertical: 14,
    paddingHorizontal: 40,
    borderRadius: 8,
  },
  retryText: {
    color: '#fff',
    fontSize: 16,
    fontWeight: '600',
    letterSpacing: 0.5,
  },
});