Add Bible picker flow and chapter navigation UI

This commit is contained in:
Adolfo Reyna
2026-02-24 15:56:48 -05:00
parent ba28289783
commit ccfeed3c92
9 changed files with 865 additions and 12 deletions

155
Views/BibleChapterView.js Normal file
View File

@@ -0,0 +1,155 @@
import React from "react";
import { FlatList, Pressable, View } from "react-native";
import { ActivityIndicator, Text } from "react-native-paper";
import { SafeAreaView } from "react-native-safe-area-context";
import { useNavigation } from "@react-navigation/native";
import { parseBibleReference } from "../utils/bibleReferences.js";
import GlobalState from "../contexts/GlobalState.js";
const BibleChapterView = ({ route }) => {
const navigation = useNavigation();
const reference = route?.params?.reference || "";
const selectable = route?.params?.selectable === true;
const { chapterReference, verse: selectedVerse } = parseBibleReference(reference);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState("");
const [chapterData, setChapterData] = React.useState(null);
const listRef = React.useRef(null);
const autoScrolledRef = React.useRef(false);
React.useEffect(() => {
let mounted = true;
const loadChapter = async () => {
setLoading(true);
setError("");
try {
const response = await fetch(`https://bible-api.com/${encodeURIComponent(chapterReference)}`);
if (!response.ok) throw new Error("Failed chapter request");
const payload = await response.json();
if (!mounted) return;
setChapterData(payload);
} catch (_error) {
if (!mounted) return;
setError("Unable to load chapter.");
setChapterData(null);
} finally {
if (mounted) setLoading(false);
}
};
loadChapter();
return () => {
mounted = false;
};
}, [chapterReference]);
const verses = Array.isArray(chapterData?.verses) ? chapterData.verses : [];
const selectedVerseNumber = Number(selectedVerse || 1);
const selectedIndex = verses.findIndex((item) => Number(item?.verse || 0) === selectedVerseNumber);
React.useEffect(() => {
autoScrolledRef.current = false;
}, [chapterReference, selectedVerseNumber]);
const scrollToSelectedVerse = React.useCallback((animated = false) => {
if (autoScrolledRef.current) return;
if (!listRef.current || selectedIndex < 0 || !verses.length) return;
try {
listRef.current.scrollToIndex({
index: selectedIndex,
animated,
viewPosition: 0.35,
});
autoScrolledRef.current = true;
} catch (_error) {
// FlatList can throw before enough measurements are available.
}
}, [selectedIndex, verses.length]);
if (loading) {
return (
<SafeAreaView style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
<ActivityIndicator />
</SafeAreaView>
);
}
if (error) {
return (
<SafeAreaView style={{ flex: 1, justifyContent: "center", alignItems: "center", padding: 16 }}>
<Text style={{ color: "#b91c1c" }}>{error}</Text>
</SafeAreaView>
);
}
const handleVersePress = (verseNumber) => {
if (!selectable) return;
GlobalState.bibleChapterSelection = {
...parseBibleReference(`${chapterReference}:${verseNumber}`),
ts: Date.now(),
};
navigation.goBack();
};
return (
<SafeAreaView style={{ flex: 1 }}>
<View style={{ flex: 1, padding: 12 }}>
<Text style={{ fontSize: 22, fontWeight: "700", marginBottom: 4 }}>
{chapterData?.reference || chapterReference}
</Text>
<Text style={{ color: "#6b7280", marginBottom: 10 }}>
{chapterData?.translation_name || chapterData?.translation_id || "KJV"}
</Text>
{selectable ? (
<Text style={{ color: "#6b7280", marginBottom: 8 }}>Tap a verse to select it.</Text>
) : null}
<FlatList
ref={listRef}
data={verses}
keyExtractor={(item, idx) => `${item?.verse || idx}`}
initialNumToRender={24}
onLayout={() => {
setTimeout(() => scrollToSelectedVerse(false), 40);
}}
onContentSizeChange={() => {
setTimeout(() => scrollToSelectedVerse(false), 40);
}}
onScrollToIndexFailed={({ index, averageItemLength }) => {
if (!listRef.current) return;
listRef.current.scrollToOffset({
offset: Math.max(0, (averageItemLength || 36) * index),
animated: false,
});
setTimeout(() => {
scrollToSelectedVerse(false);
}, 120);
}}
renderItem={({ item }) => {
const verseNumber = Number(item?.verse || 0);
const isSelected = verseNumber === selectedVerseNumber;
return (
<Pressable
onPress={() => handleVersePress(verseNumber)}
style={{
paddingVertical: 8,
paddingHorizontal: 10,
borderRadius: 8,
marginBottom: 6,
backgroundColor: isSelected ? "#fff3cd" : "transparent",
borderWidth: isSelected ? 1 : 0,
borderColor: isSelected ? "#f59e0b" : "transparent",
}}
>
<Text style={{ fontWeight: "700", color: isSelected ? "#92400e" : "#374151" }}>
{verseNumber}
</Text>
<Text style={{ color: "#111827", lineHeight: 21 }}>{item?.text || ""}</Text>
</Pressable>
);
}}
/>
</View>
</SafeAreaView>
);
};
export default BibleChapterView;

252
Views/BiblePicker.js Normal file
View File

@@ -0,0 +1,252 @@
import React from "react";
import { FlatList, Pressable, View } from "react-native";
import { ActivityIndicator, Button, Chip, Divider, Text, TextInput } from "react-native-paper";
import { useNavigation } from "@react-navigation/native";
import { useSnapshot } from "valtio";
import GlobalState from "../contexts/GlobalState.js";
import { BIBLE_BOOKS, createBibleToken, fetchBiblePassage, getBookChapterCount } from "../utils/bibleReferences.js";
const BiblePicker = ({ route }) => {
const navigation = useNavigation();
const gState = useSnapshot(GlobalState);
const target = route?.params?.target || "post";
const [query, setQuery] = React.useState(route?.params?.initialReference || "");
const [chapter, setChapter] = React.useState("1");
const [verse, setVerse] = React.useState("1");
const [selectedBook, setSelectedBook] = React.useState("");
const [activeStep, setActiveStep] = React.useState("book");
const [preview, setPreview] = React.useState(null);
const [loadingPreview, setLoadingPreview] = React.useState(false);
const [loadingVerses, setLoadingVerses] = React.useState(false);
const [error, setError] = React.useState("");
const [verseOptions, setVerseOptions] = React.useState(["1"]);
const chapterSelectionTs = gState?.bibleChapterSelection?.ts;
const computedReference = React.useMemo(() => {
if (query.trim()) return query.trim();
if (!selectedBook) return "";
return `${selectedBook} ${chapter || "1"}:${verse || "1"}`;
}, [chapter, query, selectedBook, verse]);
const filteredBooks = React.useMemo(() => {
const q = query.toLowerCase().trim();
if (!q) return BIBLE_BOOKS;
return BIBLE_BOOKS.filter((book) => book.toLowerCase().includes(q));
}, [query]);
const selectedBookChapterCount = React.useMemo(() => getBookChapterCount(selectedBook), [selectedBook]);
const chapterOptions = React.useMemo(
() => Array.from({ length: selectedBookChapterCount }, (_v, i) => String(i + 1)),
[selectedBookChapterCount]
);
const addReferenceToCaller = (reference) => {
const cleanReference = String(reference || "").trim();
if (!cleanReference) return;
GlobalState.biblePickerSelection = {
token: createBibleToken(cleanReference),
reference: cleanReference,
target,
ts: Date.now(),
};
navigation.goBack();
};
const loadPreviewForReference = async (reference) => {
if (!reference) return;
setError("");
setLoadingPreview(true);
try {
const passage = await fetchBiblePassage(reference);
setPreview(passage);
} catch (_err) {
setPreview(null);
setError("Unable to load this Bible passage.");
} finally {
setLoadingPreview(false);
}
};
const loadVerseOptions = async (book, chapterNumber) => {
if (!book || !chapterNumber) {
setVerseOptions(["1"]);
return;
}
setLoadingVerses(true);
try {
const response = await fetch(`https://bible-api.com/${encodeURIComponent(`${book} ${chapterNumber}`)}`);
if (!response.ok) throw new Error("Failed chapter fetch");
const payload = await response.json();
const options = Array.isArray(payload?.verses)
? payload.verses.map((v) => String(v?.verse || "")).filter(Boolean)
: [];
setVerseOptions(options.length ? options : ["1"]);
} catch (_error) {
setVerseOptions(["1"]);
} finally {
setLoadingVerses(false);
}
};
React.useEffect(() => {
const picked = gState?.bibleChapterSelection;
if (!picked || !picked.book) return;
setSelectedBook(picked.book);
setChapter(String(picked.chapter || 1));
setVerse(String(picked.verse || 1));
setQuery("");
setActiveStep("verse");
loadVerseOptions(picked.book, String(picked.chapter || 1));
loadPreviewForReference(picked.reference || `${picked.book} ${picked.chapter || 1}:${picked.verse || 1}`);
GlobalState.bibleChapterSelection = null;
}, [chapterSelectionTs]);
return (
<View style={{ flex: 1, paddingHorizontal: 12, paddingTop: 12 }}>
<Text style={{ fontSize: 22, fontWeight: "700", marginBottom: 6 }}>Bible Reference</Text>
<Text style={{ color: "#6b7280", marginBottom: 10 }}>
Pick a reference to insert into your {target === "chat" ? "chat message" : "post"}.
</Text>
<TextInput
mode="outlined"
value={query}
onChangeText={setQuery}
placeholder="John 3:16"
dense
/>
<View style={{ flexDirection: "row", marginTop: 8, justifyContent: "space-between" }}>
<Button mode="outlined" onPress={() => loadPreviewForReference(computedReference)} loading={loadingPreview}>
Preview
</Button>
<Button mode="contained" disabled={!computedReference} onPress={() => addReferenceToCaller(computedReference)}>
Use Reference
</Button>
</View>
{computedReference ? <Text style={{ marginTop: 8, color: "#374151" }}>Selected: {computedReference}</Text> : null}
{preview?.text ? (
<Pressable
onPress={() => navigation.navigate("BibleChapter", { reference: computedReference, selectable: true })}
style={{ marginTop: 10, padding: 10, backgroundColor: "#f8fafc", borderRadius: 10 }}
>
<Text style={{ color: "#111827" }}>{preview.text.slice(0, 420)}</Text>
<Text style={{ marginTop: 4, color: "#4b5563", fontSize: 12 }}>
{preview.reference} ({preview.translation})
</Text>
<Text style={{ marginTop: 4, color: "#6b7280", fontSize: 12 }}>Tap preview to pick verse from chapter</Text>
</Pressable>
) : null}
{error ? <Text style={{ marginTop: 8, color: "#b91c1c" }}>{error}</Text> : null}
<Divider style={{ marginVertical: 12 }} />
<View style={{ flexDirection: "row", marginBottom: 10 }}>
<Button mode={activeStep === "book" ? "contained-tonal" : "text"} onPress={() => setActiveStep("book")}>
Book
</Button>
<Button
mode={activeStep === "chapter" ? "contained-tonal" : "text"}
disabled={!selectedBook}
onPress={() => setActiveStep("chapter")}
>
Chapter
</Button>
<Button
mode={activeStep === "verse" ? "contained-tonal" : "text"}
disabled={!selectedBook || !chapter}
onPress={() => setActiveStep("verse")}
>
Verse
</Button>
</View>
{activeStep === "book" ? (
<>
<Text style={{ fontSize: 14, fontWeight: "600", marginBottom: 8 }}>Books</Text>
<FlatList
data={filteredBooks}
numColumns={2}
keyExtractor={(item) => item}
renderItem={({ item }) => (
<Chip
selected={selectedBook === item}
style={{ marginRight: 6, marginBottom: 6, width: "48%" }}
onPress={async () => {
setSelectedBook(item);
setQuery("");
setChapter("1");
setVerse("1");
setActiveStep("chapter");
await loadVerseOptions(item, "1");
await loadPreviewForReference(`${item} 1:1`);
}}
>
{item}
</Chip>
)}
/>
</>
) : null}
{activeStep === "chapter" ? (
<>
<Text style={{ fontSize: 14, fontWeight: "600", marginBottom: 8 }}>
Chapters {selectedBook ? `(${selectedBook})` : ""}
</Text>
<FlatList
data={chapterOptions}
numColumns={6}
keyExtractor={(item) => item}
renderItem={({ item }) => (
<Chip
selected={chapter === item}
style={{ marginRight: 6, marginBottom: 6, minWidth: 44 }}
onPress={async () => {
setChapter(item);
setVerse("1");
setActiveStep("verse");
await loadVerseOptions(selectedBook, item);
await loadPreviewForReference(`${selectedBook} ${item}:1`);
}}
>
{item}
</Chip>
)}
/>
</>
) : null}
{activeStep === "verse" ? (
<>
<Text style={{ fontSize: 14, fontWeight: "600", marginBottom: 8 }}>
Verses {selectedBook && chapter ? `(${selectedBook} ${chapter})` : ""}
</Text>
{loadingVerses ? (
<ActivityIndicator style={{ marginTop: 8 }} />
) : (
<FlatList
data={verseOptions}
numColumns={8}
keyExtractor={(item) => item}
renderItem={({ item }) => (
<Chip
selected={verse === item}
style={{ marginRight: 6, marginBottom: 6, minWidth: 40 }}
onPress={async () => {
setVerse(item);
await loadPreviewForReference(`${selectedBook} ${chapter || "1"}:${item}`);
}}
>
{item}
</Chip>
)}
/>
)}
</>
) : null}
</View>
);
};
export default BiblePicker;

View File

@@ -8,6 +8,8 @@ import { useSnapshot } from "valtio";
import API from "../API.js";
import GlobalState from "../contexts/GlobalState.js";
import i18n from "../i18nMessages.js";
import BibleEmbeddedView from "../components/BibleEmbeddedView.js";
import { stripBibleTokens } from "../utils/bibleReferences.js";
const PANEL_WIDTH = 260;
@@ -59,7 +61,8 @@ const CircleIconAction = ({ icon, onPress, color = "#6b7280", disabled = false }
const ChatMessage = ({ item, myProfileId, showOriginal, onPressIn, onPressOut }) => {
const isMine = item?.senderProfileId === myProfileId;
const isTranslated = !!(item?.textOriginal && item?.text && item.textOriginal !== item.text);
const messageText = isTranslated && showOriginal ? item?.textOriginal : item?.text;
const messageTextRaw = isTranslated && showOriginal ? item?.textOriginal : item?.text;
const messageText = stripBibleTokens(messageTextRaw || "");
const youtubeVideoId = getYouTubeVideoIdFromText(item?.textOriginal || "") || getYouTubeVideoIdFromText(item?.text || "");
const createdAt = item?.createdAt ? new Date(item.createdAt) : null;
return (
@@ -90,6 +93,9 @@ const ChatMessage = ({ item, myProfileId, showOriginal, onPressIn, onPressOut })
{createdAt && !Number.isNaN(createdAt.getTime()) ? createdAt.toLocaleTimeString() : ""}
</Text>
</Pressable>
<View style={{ maxWidth: "85%", paddingTop: 4 }}>
<BibleEmbeddedView content={item?.textOriginal || item?.text || ""} compact />
</View>
{youtubeVideoId ? (
<View
style={{
@@ -118,7 +124,7 @@ const ChatMessage = ({ item, myProfileId, showOriginal, onPressIn, onPressOut })
);
};
let GlobalChat = () => {
let GlobalChat = ({ navigation }) => {
const gState = useSnapshot(GlobalState);
const myProfileId = gState?.me?._id || "";
const [messages, setMessages] = React.useState([]);
@@ -133,6 +139,7 @@ let GlobalChat = () => {
const panelOpacity = React.useRef(new Animated.Value(0)).current;
const listRef = React.useRef(null);
const hasDoneInitialScrollRef = React.useRef(false);
const biblePickerSelectionTs = gState?.biblePickerSelection?.ts;
const scrollToBottom = React.useCallback((animated = true) => {
if (!listRef.current) return;
@@ -188,6 +195,16 @@ let GlobalChat = () => {
};
}, [loadMessages, refreshPresence]);
React.useEffect(() => {
const selection = gState?.biblePickerSelection;
if (!selection || selection.target !== "chat" || !selection.token) return;
setText((prev) => {
if ((prev || "").includes(selection.token)) return prev;
return `${(prev || "").trim()} ${selection.token}`.trim();
});
GlobalState.biblePickerSelection = null;
}, [biblePickerSelectionTs, gState?.biblePickerSelection]);
const sendMessage = async () => {
const trimmedText = (text || "").trim();
if (!trimmedText || sending) return;
@@ -343,7 +360,7 @@ let GlobalChat = () => {
elevation: 2,
}}
>
<CircleIconAction icon="add" onPress={() => { }} />
<CircleIconAction icon="add" onPress={() => navigation.navigate("BiblePicker", { target: "chat" })} />
<View style={{ flex: 1, paddingRight: 2 }}>
<TextInput
mode="flat"

View File

@@ -1,31 +1,36 @@
import React, { useEffect, useState } from 'react';
import { View, TextInput, Image, ScrollView } from "react-native";
import { Text, Button, Divider } from "react-native-paper";
import { SafeAreaView } from "react-native-safe-area-context";
import { Text, Button, Divider, Chip } from "react-native-paper";
import API from './../API.js';
import { useNavigation } from '@react-navigation/native';
import * as ImagePicker from 'expo-image-picker';
import i18n from "../i18nMessages";
import Media from '../components/Media';
import { Platform } from 'react-native';
import { useSnapshot } from 'valtio';
import GlobalState from '../contexts/GlobalState.js';
import { createBibleToken, extractBibleReferences, stripBibleTokens } from '../utils/bibleReferences.js';
const BATCH_SIZE = 1; // Constant for batch size
let NewPostView = (props) => {
const gState = useSnapshot(GlobalState);
let [postContent, setPostContent] = useState('');
let initialContent = props.route.params.intialContent;
let [extraContent, setExtraContent] = useState([initialContent]);
let initialContent = props.route?.params?.intialContent;
let [extraContent, setExtraContent] = useState(initialContent ? [initialContent] : []);
let [toProfile, setToProfile] = useState([]);
const [photo, setPhoto] = useState(null);
const [uploadProgress, setUploadProgress] = useState(0);
const [isUploading, setIsUploading] = useState(false); // Variable to prevent posting during upload
const [cancelUpload, setCancelUpload] = useState(false); // Variable to handle upload cancellation
const [bibleReferences, setBibleReferences] = useState([]);
const navigation = useNavigation();
const biblePickerSelectionTs = gState?.biblePickerSelection?.ts;
useEffect(() => {
let subscribed = true;
const getProfileData = async () => {
if (!props.route.params?.toProfile) return;
if (!props.route?.params?.toProfile) return;
try {
// Fetch profile data and update state if component is still subscribed
const profileObj = await API.getUserProfile(props.route.params.toProfile);
@@ -44,7 +49,31 @@ let NewPostView = (props) => {
// Cleanup subscription flag
subscribed = false;
}
}, [props.route.params?.sendNow]);
}, [props.route?.params?.sendNow]);
useEffect(() => {
const selection = gState?.biblePickerSelection;
if (!selection || selection.target !== "post" || !selection.token) return;
if (selection.reference) {
setBibleReferences((prev) => {
if (prev.includes(selection.reference)) return prev;
return prev.concat(selection.reference);
});
}
GlobalState.biblePickerSelection = null;
}, [biblePickerSelectionTs]);
const handlePostContentChange = (nextValue = "") => {
const detectedReferences = extractBibleReferences(nextValue);
if (detectedReferences.length) {
setBibleReferences((prev) => {
const seen = new Set(prev);
detectedReferences.forEach((reference) => seen.add(reference));
return Array.from(seen);
});
}
setPostContent(stripBibleTokens(nextValue));
};
const pickImage = async () => {
try {
@@ -142,10 +171,15 @@ let NewPostView = (props) => {
return;
}
try {
const bibleTokens = bibleReferences.map((reference) => createBibleToken(reference)).filter(Boolean);
// Create a new post with the combined content
await API.newPost(postContent + " " + extraContent.join(" "), props.route.params?.toProfile);
await API.newPost(
[postContent, extraContent.join(" "), bibleTokens.join(" ")].join(" ").trim(),
props.route?.params?.toProfile
);
setPostContent(''); // Clear post content after submission
setExtraContent([]); // Clear extra content after submission
setBibleReferences([]);
navigation.navigate('Feed', { reRender: Math.random() }); // Navigate back to the Feed and trigger re-render
} catch (error) {
console.error("Error creating new post", error);
@@ -177,7 +211,7 @@ let NewPostView = (props) => {
{/* Text input for post content */}
<TextInput
value={postContent}
onChangeText={setPostContent}
onChangeText={handlePostContentChange}
placeholder={i18n.t("message.whatIsOnYourMindToday")}
multiline={true}
numberOfLines={8}
@@ -190,9 +224,32 @@ let NewPostView = (props) => {
}}
autoFocus={true}
/>
{bibleReferences.length ? (
<View style={{ flexDirection: "row", flexWrap: "wrap", marginTop: 8, marginHorizontal: 8 }}>
{bibleReferences.map((reference) => (
<Chip
key={reference}
style={{ marginRight: 6, marginBottom: 6 }}
onPress={() => navigation.navigate("BibleChapter", { reference })}
onClose={() => {
setBibleReferences((prev) => prev.filter((item) => item !== reference));
}}
>
{reference}
</Chip>
))}
</View>
) : null}
<Divider bold={true} />
{/* Button to pick images from the gallery */}
<View style={{ flexDirection: "row", marginTop: 10, justifyContent: "space-around" }}>
<Button
icon="menu-book"
mode="outlined"
onPress={() => navigation.navigate("BiblePicker", { target: "post" })}
>
Bible
</Button>
<Button icon="add-a-photo" mode="outlined" onPress={pickImage}>
{i18n.t("message.addPhotos")}
</Button>