JustToThePoint English Website Version
JustToThePoint en español

Online Prayer Tab UI

He may look like an idiot and talk like an idiot but don’t let that fool you. He really is an idiot, Groucho Marx.

Online Prayer Tab UI

What we’re building

We’re adding an Online tab to a prayer app. This tab lets users browse a list of spiritual websites directly inside the app. The screen is split into two columns:

Test moments you should aim for:

  1. Tap “Sacred Space” → the website appears inside the app.
  2. Tap a site that blocks embedding → an error overlay appears with the “Open in browser” button.
  3. Tap “Open in browser” → the site opens in the phone’s system browser (Chrome, Safari, etc.).
  4. While inside a site, tap a link to another page → the WebView navigates to the new page, but the header still shows the original site’s name, not the page you navigated to.

WebView refresher: full websites vs. YouTube embeds

A WebView is like a miniature web browser living inside your app. You have already probably used something like:

<WebView source={{ uri: 'https://www.youtube.com/embed/VIDEO_ID' }} />

That’s a specific embed URL designed to be placed inside other pages. YouTube knows it’s being embedded and behaves nicely.

Now we try to load a full website (e.g. https://www.sacredspace.ie). The website doesn’t know it’s being shown inside an app — from its perspective it’s just another browser. But the website can instruct the browser (and therefore our WebView) to refuse to be displayed inside a frame or another app. This is a security feature.

Why some sites refuse to load: X-Frame-Options and friends

Imagine a malicious website puts your bank’s login page into an invisible frame. You think you’re typing your password on a normal page, but you’re actually typing it into the attacker’s fake overlay. This is called clickjacking.

To prevent this, servers can send extra HTTP headers that say “I don’t allow myself to be shown inside a frame/webview.” The most common ones are:

HTTP header Effect
X-Frame-Options: DENY Site refuses to load in ANY frame/webview
X-Frame-Options: SAMEORIGIN Site only loads in frames from its own domain
Content-Security-Policy: frame-ancestors 'none' Modern equivalent of X-Frame-Options: DENY

Important: If a site sends X-Frame-Options: DENY, there is no workaround. The WebView’s browser engine itself refuses to display the page, and we as developers cannot override it. The only correct behaviour is to detect the problem and offer the user an alternative way to view the site — like opening it in the real system browser.

Detecting problems: onError vs onHttpError

The WebView component has two callback props that help us understand what went wrong:

<WebView
  // onError: The device cannot reach the server at all.
  //   - DNS resolution failed
  //   - No Internet
  //   - SSL certificate errors
  onError={(e) => console.log("Network error:", e.nativeEvent)}

  // onHttpError: The server replies with an error status code (4xx, 5xx).
  //   - 403 Forbidden (often triggered by X-Frame-Options enforcement)
  //   - 404 Not Found
  //   - 500 Server Error
  onHttpError={(e) => console.log("HTTP error:", e.nativeEvent.statusCode)}
/>

The tricky case: Sometimes a site with X-Frame-Options: DENY actually returns an HTTP 200 OK — the page did load successfully — but the browser engine then refuses to display it. In that situation neither onError nor onHttpError fires, and the user sees a blank white rectangle where the website should be.

So we need an extra safety net: a timeout‑based fallback. After the WebView reports that the page has finished loading, we wait a few seconds. If the user still sees nothing (or if we detect the page is entirely blank), we show a message like “This site cannot be displayed inside the app” and the “Open in browser” button.

Even better, we always keep the “Open in browser” button visible, so the user has a manual escape route regardless of what happens.

Building the fallback: opening in the system browser with Linking

React Native’s built‑in Linking API can open any URL in the device’s default browser. This is our escape hatch.

import { Linking } from 'react-native';

const openInBrowser = (url: string) => {
  Linking.openURL(url).catch((err) =>
    console.error('Failed to open browser:', err)
  );
};

When the user taps the “Open in browser” button, we call openInBrowser with the URL of the site they wanted to visit. If the WebView is showing an error state, we use the site’s original URL; if the user just wants to switch, we can also pass whatever currentUrl the WebView is on.

Step 1 — Replace app/(tabs)/online.tsx

// app/(tabs)/online.tsx
//
// ONLINE PRAYER TAB
//
// app/(tabs)/online.tsx is a tab screen in a React Native app.
// When the user taps the “Online” tab, they see a two‑panel layout:
//   Left  (fixed width): A narrow, scrollable list of prayer websites.
//   Right (flex 1): A WebView (an embedded browser) that shows the currently selected website.
//
// It also handles three things that can go wrong when loading web content:
//   1. Some websites refuse to be shown inside another app (they block “iframes”).
// The header of the right panel always shows an “Open in browser” button so the user can view the site externally.
//   2. If a page fails to load (network down, server error), an error overlay appears.
//   3. If a page seems to hang, a loading hint is shown after 8 seconds.
//
// The list of prayer sites is read from a text file (prayeronline.txt) that
// contains lines like url:name:description.

// 1. Imports – The Toolbox
// React – the core library for building the UI.
// useEffect – runs a function after the component appears (for loading data).
// useState – lets the component remember things (like the list of sites, the selected site, loading state).
// useCallback – makes a function stable so it doesn’t get re‑created on every render (performance).
// useRef – creates a “box” that can hold a value that persists across renders.
import React, { useEffect, useState, useCallback, useRef } from "react";
// View – a container (like a DIV in HTML).
// Text – for displaying text.
// FlatList – a high‑performance scrollable list.
// TouchableHighlight – makes a view tappable; when pressed, it shows a subtle colour change.
// TouchableOpacity – another tappable view that reduces opacity when pressed.
// StyleSheet – for creating style objects (keeps styles organised).
// ActivityIndicator – a spinning loading indicator.
// useWindowDimensions – gives the screen width/height (used to size the left panel).
// Linking – opens external URLs (e.g., the device’s browser).
import {
  View, Text, FlatList, TouchableHighlight, TouchableOpacity,
  StyleSheet, ActivityIndicator, ListRenderItemInfo,
  useWindowDimensions, Linking,
} from "react-native";
// WebView – the component that shows a web page inside the app.
// WebViewNavigation – a TypeScript type describing a navigation event.
import { WebView, WebViewNavigation } from "react-native-webview";
// This function reads and parses the prayeronline.txt file, returning a list of PrayerSite objects.
import { loadPrayerSites } from "../../src/services/onlineService";
// Custom components:
// LoadingSpinner shows a spinner with a message
import { LoadingSpinner } from "../../src/components/LoadingSpinner";
// EmptyState shows an icon, title, and subtitle when there’s nothing to display.
import { EmptyState }     from "../../src/components/EmptyState";
// PrayerSite is a TypeScript type that looks like { url: string; name: string; description?: string }
import type { PrayerSite } from "../../src/db/types";

// 2. Colour Palette
// A simple object holding hex colour codes used throughout the screen
const C = {
  bg:     "#1a1f2e",
  panel:  "#1e2338",
  border: "#2d3550",
  accent: "#5b8fd4",
  text:   "#dde4f0",
  muted:  "#9ca3af",
  faint:  "#6b7280",
  gold:   "#c9a84c",
  dim:    "#4b5563",
  white:  "#ffffff",
};

// 3. The Main Component: OnlineScreen
// ══════════════════════════════════════════════════════════════════════════════
// MAIN SCREEN
// ══════════════════════════════════════════════════════════════════════════════
// This is the component that gets shown when the “Online” tab is active (default).
export default function OnlineScreen() {
  // sites – the array of prayer sites loaded from the file. Initially empty.
  const [sites,    setSites]    = useState<PrayerSite[]>([]);
  // loading – true while we’re fetching the list.
  const [loading,  setLoading]  = useState(true);
  // selected – the site that’s currently shown in the WebView (null if none).
  const [selected, setSelected] = useState<PrayerSite | null>(null);
  // error – if something goes wrong during loading, this holds a message.
  const [error,    setError]    = useState<string | null>(null);

  // Screen dimensions
  const { width: screenWidth } = useWindowDimensions();
  // The left panel will be at most 200 points wide, or 35% of the screen width, whichever is smaller.
  // This keeps it from looking too wide on a tablet.
  const sidebarWidth = Math.min(200, Math.floor(screenWidth * 0.35));

  // Loading the sites
  useEffect(() => {
    // B. loadPrayerSites() returns a promise that eventually resolves with the list of sites.
    loadPrayerSites()
      // C. If successful,...
      .then(list => {
        setSites(list); // ... we save the list and ...
        if (list.length > 0) setSelected(list[0]); // ... automatically select the first site.
      })
      // D. If it fails, we store the error message.
      .catch(e => setError(String(e)))
      // E. Whatever happens, we set loading to false.
      .finally(() => setLoading(false));
  // A. useEffect with an empty dependency array ([]) runs once,
  // right after the component first appears.
  }, []);

  // Conditional rendering
  // Now the component decides what to show, based on the current state:
  // **Loading**: shows a spinning indicator.
  if (loading) return <LoadingSpinner message="Loading prayer sites…" />;
  // **Error**: shows a warning triangle, the title, and the error details.
  if (error)   return <EmptyState icon="⚠️" title="Could not load sites" subtitle={error} />;
  // **No sites** (loaded successfully but list is empty): shows a friendly message explaining how to add sites.
  if (sites.length === 0) {
    return (
      <EmptyState
        icon="🌐"
        title="No prayer sites configured"
        subtitle={"Add sites to data/prayeronline.txt\nFormat:  url:name:description"}
      />
    );
  }

  // If none of those early returns happen, we have a valid list and a selected site.
  // The normal UI is shown:
  return (
    // s.root – the outermost container, styled to fill the screen and use a row layout.
    <View style={s.root}>
      // SiteList – the left panel.
      <SiteList
        sites={sites}
        selected={selected}
        sidebarWidth={sidebarWidth}
        onSelect={setSelected}
      />
      // A thin divider view between the two panels.
      <View style={s.divider} />
      // SiteViewer – the right panel
      <SiteViewer selected={selected} />
    </View>
  );
}
// ══════════════════════════════════════════════════════════════════════════════
// LEFT COLUMN — SITE LIST
// ══════════════════════════════════════════════════════════════════════════════
// A constant that stores the height of each item in the list to 64 pixels (logical pixels).
const ITEM_HEIGHT = 64;
// SiteList is a React component (a function that returns something to show on the screen).
// It is responsible for drawing the left column, showing the list of prayer sites.
// It receives four props (short for properties):
// sites – an array of PrayerSite objects (each representing a website with a name, url, maybe a description).
// selected – the PrayerSite that is currently selected, or null if none is selected.
// sidebarWidth – a number for the width of this whole panel (in logical pixels).
// onSelect – a function that will be called when the user taps a site; it receives the chosen PrayerSite.
function SiteList({
  sites, selected, sidebarWidth, onSelect,
}: {
  sites:        PrayerSite[];
  selected:     PrayerSite | null;
  sidebarWidth: number;
  onSelect:     (s: PrayerSite) => void;
}) {
  // useCallback for the render function
  // It is a React Hook that remembers a function and only re-creates it when certain values change.
  // The function inside it, ({ item }) => ..., is a render function that FlatList 🎌 will call for every item in the list.
  const renderItem = useCallback(
    // It destructures item from the argument; item is one PrayerSite object from the sites array.
    ({ item }: ListRenderItemInfo<PrayerSite>) => (
      // It returns a <SiteListItem> component with three props:
      <SiteListItem
        site={item} // the current site data.
        isSelected={item.url === selected?.url} // A boolean (true/false) indicating if this site is the currently selected one.
        // selected?.url uses optional chaining so that if selected is null it doesn’t crash;
        // it just returns undefined, which is not equal to item.url, so isSelected becomes false.
        onPress={() => onSelect(item)}
        // This is an arrow function that calls the onSelect prop with this site when the item is pressed.
      />
    ),
    // The dependency array [selected?.url, onSelect] tells React:
    // “recreate this function only when selected?.url or onSelect changes (rare).”
    [selected?.url, onSelect]
  );

  // The component returns a tree of React Native components wrapped in ( ... ).
  return (
    // A. Outer container. View is like a DIV in web; a container for layout.
    // style takes an array. First it applies s.sidebar (a style from an imported stylesheet),
    // then it adds a dynamic style object { width: sidebarWidth } to override just the width.
    // This makes the sidebar’s width adjustable via the prop.
    <View style={[s.sidebar, { width: sidebarWidth }]}>
      /* B. Another View for the header bar.
      Two Text elements: one showing the label “Prayer Sites”,
      the other showing the number of sites (from sites.length). This gives a title and a count */
      <View style={s.sidebarHeader}>
        <Text style={s.sidebarLabel}>Prayer Sites</Text>
        <Text style={s.sidebarCount}>{sites.length}</Text>
      </View>
      /* C. The list itself: FlatList.
      FlatList is a React Native component that efficiently renders a long, scrollable list of items. */
      <FlatList
        data={sites} // – the array of data to show.
        keyExtractor={item => item.url} // – a function that tells React how to identify each item uniquely.
        // It uses the url of each prayer site as the key.
        renderItem={renderItem}
        // 🎌 **the function we created with useCallback**. It tells FlatList how to draw each item.
        ItemSeparatorComponent={() => <View style={s.separator} />}
        // an optional prop that renders a thin separator between items.
        showsVerticalScrollIndicator={false} // – hides the vertical scrollbar.
      />
      /* D. Footer. A small footer at the bottom telling the user how to add more sites. */
      <View style={s.sidebarFooter}>
        <Text style={s.sidebarFooterText}>
          Edit data/prayeronline.txt{"\n"}to add more sites
        </Text>
      </View>
    </View>
  );
}

/* The SiteListItem component.
This component renders a single row in the list. Props: */
function SiteListItem({
  site, isSelected, onPress,
}: {
  site:       PrayerSite; // – the PrayerSite data for this row.
  isSelected: boolean; // – boolean, true if this site is currently selected.
  onPress:    () => void; //  – function to call when the user taps the row.
}) {
  return (
    // TouchableHighlight is a wrapper that makes its children touchable and shows a highlight color when pressed.
    // underlayColor="#252d45" – the color that appears underneath the content when the finger is pressing.
    // It’s a dark blue-grey.
    // onPress={onPress} – calls the supplied onPress function.
    // style={{ height: ITEM_HEIGHT }} – sets the row height to 64 pixels (the constant we have previously defined).
    <TouchableHighlight underlayColor="#252d45" onPress={onPress} style={{ height: ITEM_HEIGHT }}>
      // Inner container
      // A View that uses the base style s.listItem and, if isSelected is true, also adds s.listItemSelected.
      // The isSelected && s.listItemSelected is a short-circuit trick:
      // if isSelected is false, the expression evaluates to false, which React ignores in a style array;
      // if true, it evaluates to s.listItemSelected, so both styles are applied.
      <View style={[s.listItem, isSelected && s.listItemSelected]}>
        // Selection indicator bar
        // Another short-circuit: if isSelected is true, render a View with style s.listItemBorder.
        // This is a colored vertical bar on the left side of the row to indicate selection.
        {isSelected && <View style={s.listItemBorder} />}
        // Content area. A container for the text content.
        <View style={s.listItemContent}>
          // The site name
          // Applies s.listItemName always, and if selected, also s.listItemNameSelected
          // numberOfLines={1} makes the text cut off after one line with an ellipsis (...) if it’s too long.
          // Shows the site’s name.
          <Text style={[s.listItemName, isSelected && s.listItemNameSelected]} numberOfLines={1}>
            {site.name}
          </Text>
          // The site description or URL
          // This is a conditional rendering. It checks if site.description exists (a truthy value)
          {site.description ? (
            // If there is a description, it shows it with style s.listItemDesc, limited to 2 lines.
            <Text style={s.listItemDesc} numberOfLines={2}>
              {site.description}
            </Text>
          // If not, it shows a cleaned-up version of the URL.
          // The result is shown in one line *numberOfLines={1}) with style s.listItemUrl.
          ) : (
            <Text style={s.listItemUrl} numberOfLines={1}>
              {site.url.replace(/^https?:\/\//, "")}
            </Text>
          )}
        </View>
      </View>
    </Touchableighlight>
  );
}

This component is the “right column” of our app — it shows a live preview of a prayer website inside a small built-in browser (called a WebView). Because websites can be slow, broken, or block embedding, we need to handle all those situations gracefully.

// ══════════════════════════════════════════════════════════════════════════════
// RIGHT COLUMN — SITE VIEWER
// ══════════════════════════════════════════════════════════════════════════════

/** This sets the time we wait before showing the “taking a while?” message.
8000 means 8000 milliseconds = 8 seconds. After that, we assume something might be wrong. */
const SLOW_LOAD_TIMEOUT_MS = 8000;

/* SiteViewer is a React functional component.
It receives a selected prop that is either a prayer site object (with name, description, and url)
or null if nothing is selected. */
function SiteViewer({ selected }: { selected: PrayerSite | null }) {
  /* State variables — the component’s memory */
  // webLoading (boolean, starts true): Is the WebView still loading?
  const [webLoading, setWebLoading]  = useState(true);
  // If something goes wrong, we store an error message here; otherwise it’s null.
  const [webError,   setWebError]    = useState<string | null>(null);
  // currentUrl (string, starts ""): Tracks the URL the WebView is actually showing
  // (it might navigate away from the starting URL, so “open in browser” needs the current one.
  const [currentUrl, setCurrentUrl]  = useState<string>("");
  // Should we display the “taking a while?” hint? This becomes true after 8 seconds of loading.
  const [showSlowHint, setShowSlowHint] = useState(false);

  // The timer reference — something that survives re‑renders.
  // useRef gives us a container that holds a value without causing a re‑render when changed.
  // It’s perfect for storing the ID of a timer (setTimeout), because we need to be able to cancel the timer later.
  // We start it as null because no timer is running at first.
  const slowTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);

  /* The useEffect — running side effects when selected.url changes.
  This is the brain that resets everything when the user taps a different site. Let’s break it down. */
  useEffect(() => {
    // 2. If selected is null, do nothing.
    if (!selected) return;

    // 3. Reset all the relevant state:
    setWebLoading(true); // we are about to load.
    setWebError(null); // no error yet.
    setShowSlowHint(false); // hide the slow hint from any previous site.
    setCurrentUrl(selected.url); // start tracking from the site’s base URL.

    // 4. Manage the time
    // If a previous timer is still running, cancel it.
    if (slowTimerRef.current) clearTimeout(slowTimerRef.current);
    // Then start a new timer: after 8 seconds (SLOW_LOAD_TIMEOUT_MS), run setShowSlowHint(true).
    slowTimerRef.current = setTimeout(() => {
      setShowSlowHint(true);
    }, SLOW_LOAD_TIMEOUT_MS);

    // 5. Cleanup function (return () => {...}): This is incredibly important.
    // React runs this cleanup before the effect runs again (if dependencies change) or when the component unmounts.
    // It cancels the timer so we don’t show a stale slow hint when the user has already moved to a new site.
    return () => {
      if (slowTimerRef.current) clearTimeout(slowTimerRef.current);
    };
    // 1. deps. The effect will run whenever selected?.url changes.
    // If selected becomes null, the effect runs too (but we return early).
  }, [selected?.url]);

  // ── No selection ───────────────────────────────────────────────────────────
  // If no site is selected — the empty state
  // When selected is null, we don’t try to load anything. Instead we show a friendly message.
  if (!selected) {
    return (
      <View style={s.viewerContainer}>
        <EmptyState icon="🌐" title="Select a prayer site" subtitle="Tap a site in the list" />
      </View>
    );
  }

  /**
   * currentUrl tracks where the WebView actually navigated to. I
   * If the user has clicked links inside the WebView, currentUrl will be that new page, not the original site URL.
   * But if currentUrl is empty (just reset), we fall back to selected.url.
   * Linking.openURL(url) asks the phone’s operating system to open that URL
   * in the real browser app (Chrome, Safari, etc.), outside our app.
   */
  function openInBrowser() {
    const url = currentUrl || selected.url;
    Linking.openURL(url);
  }

  // The header — always visible
  return (
    <View style={s.viewerContainer}>

      {/* Header: it shows site's name (limited to one line with numberOfLines={1}),
      description (it also appears on one line),
      A button labeled “↗ Browser” that always calls openInBrowser // :rocket:. */}
      <View style={s.viewerHeader}>
        <View style={s.viewerHeaderText}>
          <Text style={s.viewerName} numberOfLines={1}>{selected.name}</Text>
          {selected.description ? (
            <Text style={s.viewerDesc} numberOfLines={1}>{selected.description}</Text>
          ) : null}
        </View>

        {/*
          "Why is this button always here?
          Some websites send a header called X‑Frame‑Options: DENY or Content‑Security‑Policy: frame‑ancestors 'none'.
          This tells browsers (and WebViews) “do not show my content inside another site’s frame”.
          The WebView obediently shows a blank page, but it doesn’t fire an error.
          The only way for the user to see the site is to open it in the real browser.
          By always showing the button, the user has a reliable escape hatch no matter what.
        */}
        <TouchableOpacity
          style={s.openBtn}
          onPress={openInBrowser} // :rocket:
          accessibilityLabel="Open in browser"
        >
          <Text style={s.openBtnText}>↗ Browser</Text>
        </TouchableOpacity>
      </View>

      {/* The WebView and its container */}
      <View style={s.webContainer}>

        <WebView
          key={selected.url}
          // In React, the key tells the framework to completely destroy and recreate a component when the key changes.
          // Here, every time selected.url changes, the WebView is unmounted and a fresh one is mounted.
          // This prevents flashes of the previous site’s content and ensures a clean load.

          // The web page to load, given as a URI object.
          source={{ uri: selected.url }}
          // Styling for the WebView (defined in our stylesheet - it is shown below).
          style={s.webView}
          // Allows JavaScript on the loaded page. Most modern sites need this.
          javaScriptEnabled={true}
          // Enables HTML5 local storage and session storage.
          // Many prayer sites save your reading progress or preferences there, so we need it.
          domStorageEnabled={true}
          // The WebView has its own built‑in loading spinner.
          // We set this to false because we will show our own custom loading overlay.
          startInLoadingState={false}

          // WebView event handlers
          // Fires when a new page starts loading.
          // We make sure the loading indicator shows and any previous error is cleared.
          onLoadStart={() => {
            setWebLoading(true);
            setWebError(null);
          }}

          // Fires when the page finishes loading (success or fail).
          // We turn off the loading spinner and cancel the slow‑load timer, because loading is done.
          onLoadEnd={() => {
            setWebLoading(false);
            // Clear the slow-load timer once loading finishes
            if (slowTimerRef.current) clearTimeout(slowTimerRef.current);
          }}

          // This fires for network‑level problems: no internet, DNS failure, connection refused, SSL errors.
          // We turn off loading, set an error message, and log the event for debugging.
          onError={(syntheticEvent) => {
            const { nativeEvent } = syntheticEvent;
            setWebLoading(false);
            setWebError(
              `Network error: ${nativeEvent.description ?? "Could not connect"}`
            );
            console.warn("[Online] WebView onError:", nativeEvent);
          }}

          // This fires when the server responds with an HTTP error status code (e.g., 403 Forbidden, 404 Not Found, 500 Internal Server Error). We handle it similarly.
          // NOTE: X-Frame-Options: DENY usually returns 200 OK with blank
          // content — onHttpError will NOT fire in that case.
          //  — another reason the “Open in browser” button must always be visible.
          onHttpError={(syntheticEvent) => {
            const { nativeEvent } = syntheticEvent;
            setWebLoading(false);
            setWebError(
              `Server returned HTTP ${nativeEvent.statusCode}`
            );
            console.warn("[Online] WebView onHttpError:", nativeEvent);
          }}

          // This fires any time the WebView navigates to a new URL:
          // initial load, user taps a link, a redirect happens, or the user goes back/forward.
          // We capture the new URL so that if the user presses “Open in browser”,
          // they open the current page, not just the starting site.
          onNavigationStateChange={(navState: WebViewNavigation) => {
            setCurrentUrl(navState.url);
          }}
        />

        {/* Loading overlay */}
        // When does it appear? Only if webLoading is true and there is no error (!webError).
        {webLoading && !webError && (
          <View style={s.loadingOverlay}>
            // A spinning ActivityIndicator (colored gold).
            <ActivityIndicator size="large" color={C.gold} />
            // A text “Loading site name…”.
            <Text style={s.loadingText}>Loading {selected.name}…</Text>

            /* If showSlowHint is true (after 8 seconds), an extra box appears telling the user
            that some sites can’t be shown inside the app and offering a direct “Open in browser instead” button. */
            {showSlowHint && (
              <View style={s.slowHint}>
                <Text style={s.slowHintText}>
                  This is taking a while.{"\n"}
                  Some sites cannot be displayed inside the app.
                </Text>
                <TouchableOpacity style={s.slowHintBtn} onPress={openInBrowser}>
                  <Text style={s.slowHintBtnText}>Open in browser instead</Text>
                </TouchableOpacity>
              </View>
            )}
          </View>
        )}

        {/* Error overlay. This appears whenever webError is not null. It shows: */}
        {webError && (
          <View style={s.errorOverlay}>
            // An icon, a title, the actual error message (network or HTTP),
            // and a hint explaining that some sites block embedding.
            <Text style={s.errorIcon}>🚫</Text>
            <Text style={s.errorTitle}>Could not display this site</Text>
            <Text style={s.errorText}>{webError}</Text>
            <Text style={s.errorHint}>
              Some prayer sites block embedding for security reasons{"\n"}
              (X-Frame-Options header). Open it in your browser instead.
            </Text>
            // A big “Open in Browser” button.
            <TouchableOpacity style={s.errorBtn} onPress={openInBrowser}>
              <Text style={s.errorBtnText}>Open in Browser</Text>
            </TouchableOpacity>
          </View>
        )}

      </View>
    </View>
  );
}


// ══════════════════════════════════════════════════════════════════════════════
// STYLES
// ══════════════════════════════════════════════════════════════════════════════

const s = StyleSheet.create({
  root:              { flex: 1, flexDirection: "row", backgroundColor: C.bg },
  divider:           { width: 1, backgroundColor: C.border },

  // Sidebar
  sidebar:           { backgroundColor: C.panel },
  sidebarHeader:     { flexDirection: "row", justifyContent: "space-between",
                       alignItems: "center", paddingHorizontal: 12,
                       paddingVertical: 10, borderBottomWidth: 1,
                       borderBottomColor: C.border },
  sidebarLabel:      { fontSize: 10, fontWeight: "700", color: C.faint,
                       textTransform: "uppercase", letterSpacing: 0.8 },
  sidebarCount:      { fontSize: 10, color: C.accent, backgroundColor: "#1a2a3a",
                       paddingHorizontal: 5, paddingVertical: 1,
                       borderRadius: 8, overflow: "hidden" },
  separator:         { height: 1, backgroundColor: "#1e2338", marginLeft: 12 },
  sidebarFooter:     { padding: 12, borderTopWidth: 1, borderTopColor: C.border },
  sidebarFooterText: { fontSize: 9, color: C.dim, textAlign: "center",
                       lineHeight: 14, fontFamily: "monospace" },

  // List item
  listItem:          { flexDirection: "row", alignItems: "center",
                       height: ITEM_HEIGHT, paddingHorizontal: 12, gap: 8 },
  listItemSelected:  { backgroundColor: "#222840" },
  listItemBorder:    { position: "absolute", left: 0, top: 0, bottom: 0,
                       width: 2, backgroundColor: C.gold },
  listItemContent:   { flex: 1 },
  listItemName:      { fontSize: 13, color: C.muted, fontWeight: "600",
                       marginBottom: 3 },
  listItemNameSelected: { color: C.gold },
  listItemDesc:      { fontSize: 11, color: C.faint, lineHeight: 15 },
  listItemUrl:       { fontSize: 10, color: C.dim, fontFamily: "monospace" },

  // Viewer
  viewerContainer:   { flex: 1, backgroundColor: C.bg },
  viewerHeader:      { flexDirection: "row", alignItems: "center",
                       paddingHorizontal: 14, paddingVertical: 10,
                       backgroundColor: C.panel, borderBottomWidth: 1,
                       borderBottomColor: C.border, gap: 10 },
  viewerHeaderText:  { flex: 1 },
  viewerName:        { fontSize: 14, fontWeight: "700", color: C.text },
  viewerDesc:        { fontSize: 11, color: C.faint, marginTop: 2 },
  openBtn:           { backgroundColor: C.accent, borderRadius: 6,
                       paddingHorizontal: 12, paddingVertical: 7,
                       flexShrink: 0 },
  openBtnText:       { fontSize: 12, color: C.white, fontWeight: "700" },

  webContainer:      { flex: 1, position: "relative" },
  webView:           { flex: 1, backgroundColor: C.white },
  // backgroundColor: white — most prayer sites have light backgrounds;
  // a dark background would flash visibly during load.

  // Loading overlay
  loadingOverlay:    { ...StyleSheet.absoluteFillObject, backgroundColor: C.bg,
                       alignItems: "center", justifyContent: "center", gap: 14,
                       padding: 32 },
  loadingText:       { color: C.muted, fontSize: 14 },
  slowHint:          { marginTop: 20, alignItems: "center", gap: 10,
                       padding: 16, backgroundColor: C.panel,
                       borderRadius: 10, borderWidth: 1, borderColor: C.border },
  slowHintText:      { fontSize: 12, color: C.faint, textAlign: "center",
                       lineHeight: 18 },
  slowHintBtn:       { backgroundColor: C.accent, borderRadius: 8,
                       paddingHorizontal: 16, paddingVertical: 8 },
  slowHintBtnText:   { color: C.white, fontSize: 12, fontWeight: "700" },

  // Error overlay
  errorOverlay:      { ...StyleSheet.absoluteFillObject, backgroundColor: C.bg,
                       alignItems: "center", justifyContent: "center",
                       padding: 32, gap: 10 },
  errorIcon:         { fontSize: 40 },
  errorTitle:        { fontSize: 16, fontWeight: "bold", color: C.text },
  errorText:         { fontSize: 12, color: "#f87171", textAlign: "center" },
  errorHint:         { fontSize: 12, color: C.faint, textAlign: "center",
                       lineHeight: 18 },
  errorBtn:          { backgroundColor: C.accent, borderRadius: 8,
                       paddingVertical: 10, paddingHorizontal: 24,
                       marginTop: 8 },
  errorBtnText:      { color: C.white, fontSize: 14, fontWeight: "700" },
});

Step 2 — Run and test

  1. Open your project and run: npx expo start --clear.
    This just means “start Expo and wipe old cached stuff so everything loads cleanly.”
  2. In your app, tap the 🌐 Online tab.
  3. You should see a list of eight websites. Each one shows a name and a short description. Tapping any item should load that site inside the app.
    When a site opens, the header at the top should always show the site’s name and description. That header stays the same even if you click around inside the page. You’ll also see a small ↗ button in the top‑right corner; tapping it opens the current site in your device’s normal browser.
    If a site loads slowly, after a few seconds you should see a friendly message saying it’s taking a while, along with a button that lets you open the site in your browser instead. Some sites don’t allow being displayed inside apps — those will appear blank, and you can use the ↗ button to view them normally.
    If you turn on airplane mode and try to open a site, you should see a clear network‑error message and an option to open the page in your browser. And if you switch quickly between different sites, each one should load cleanly without showing leftover content from the previous one.
Bitcoin donation

JustToThePoint Copyright © 2011 - 2026 Anawim. ALL RIGHTS RESERVED. Bilingual e-books, articles, and videos to help your child and your entire family succeed, develop a healthy lifestyle, and have a lot of fun. Social Issues, Join us.

This website uses cookies to improve your navigation experience.
By continuing, you are consenting to our use of cookies, in accordance with our Cookies Policy and Website Terms and Conditions of use.