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

11
App.js
View File

@@ -30,6 +30,8 @@ import NewGroup from './Views/NewGroup.js';
import Slideshow from './Views/Slideshow.js'; import Slideshow from './Views/Slideshow.js';
import SongPlayer from './Views/SongPlayer.js'; import SongPlayer from './Views/SongPlayer.js';
import GlobalChat from './Views/GlobalChat.js'; import GlobalChat from './Views/GlobalChat.js';
import BiblePicker from './Views/BiblePicker.js';
import BibleChapterView from './Views/BibleChapterView.js';
import { Platform } from 'react-native'; import { Platform } from 'react-native';
import { PostHogProvider } from 'posthog-react-native' import { PostHogProvider } from 'posthog-react-native'
import * as Updates from 'expo-updates'; import * as Updates from 'expo-updates';
@@ -426,6 +428,15 @@ export default function App() {
name="GlobalChat" name="GlobalChat"
component={GlobalChat} component={GlobalChat}
/> />
<Stack.Screen
name="BiblePicker"
component={BiblePicker}
/>
<Stack.Screen
name="BibleChapter"
component={BibleChapterView}
options={{ headerShown: false }}
/>
<Stack.Screen name="SinglePost" component={SinglePost} /> <Stack.Screen name="SinglePost" component={SinglePost} />
<Stack.Screen name="Login" component={Login} options={{ headerShown: false }} /> <Stack.Screen name="Login" component={Login} options={{ headerShown: false }} />
<Tab.Screen name="Logout" component={Login} /> <Tab.Screen name="Logout" component={Login} />

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

View File

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

View File

@@ -0,0 +1,118 @@
import React, { useMemo, useState } from "react";
import { StyleSheet, Text, View } from "react-native";
import { ActivityIndicator, Chip } from "react-native-paper";
import { useNavigation } from "@react-navigation/native";
import { extractBibleReferences, fetchBiblePassage } from "../utils/bibleReferences.js";
const BibleEmbeddedView = ({ content = "", compact = false, openChapterOnPress = false }) => {
const navigation = useNavigation();
const references = useMemo(() => extractBibleReferences(content), [content]);
const [selectedRef, setSelectedRef] = useState("");
const [byReference, setByReference] = useState({});
if (!references.length) return null;
const handleSelectReference = async (reference) => {
if (openChapterOnPress) {
navigation.navigate("BibleChapter", { reference });
return;
}
setSelectedRef(reference);
const current = byReference[reference];
if (current?.loading || current?.text || current?.error) return;
setByReference((prev) => ({ ...prev, [reference]: { loading: true } }));
try {
const data = await fetchBiblePassage(reference);
setByReference((prev) => ({ ...prev, [reference]: { loading: false, ...data } }));
} catch (_error) {
setByReference((prev) => ({ ...prev, [reference]: { loading: false, error: true } }));
}
};
const selectedData = selectedRef ? byReference[selectedRef] : null;
return (
<View style={[styles.container, compact ? styles.compactContainer : null]}>
<Text style={styles.label}>Bible</Text>
<View style={styles.chipsWrap}>
{references.map((reference) => (
<Chip
key={reference}
mode={selectedRef === reference ? "flat" : "outlined"}
selected={selectedRef === reference}
compact
style={styles.chip}
onPress={() => handleSelectReference(reference)}
>
{reference}
</Chip>
))}
</View>
{selectedData?.loading ? <ActivityIndicator size="small" style={styles.loader} /> : null}
{selectedData?.text ? (
<View style={styles.previewBox}>
<Text style={styles.previewText}>{selectedData.text.slice(0, compact ? 160 : 280)}</Text>
<Text style={styles.previewMeta}>
{selectedData.reference} ({selectedData.translation})
</Text>
</View>
) : null}
{selectedData?.error ? <Text style={styles.errorText}>Unable to load this passage.</Text> : null}
</View>
);
};
export default BibleEmbeddedView;
const styles = StyleSheet.create({
container: {
paddingTop: 6,
paddingHorizontal: 8,
},
compactContainer: {
paddingTop: 4,
paddingHorizontal: 0,
},
label: {
fontSize: 12,
color: "#4b5563",
fontWeight: "700",
marginBottom: 4,
},
chipsWrap: {
flexDirection: "row",
flexWrap: "wrap",
},
chip: {
marginRight: 6,
marginBottom: 6,
backgroundColor: "#f8fafc",
},
loader: {
alignSelf: "flex-start",
},
previewBox: {
marginTop: 2,
backgroundColor: "#f8fafc",
borderWidth: 1,
borderColor: "#e5e7eb",
borderRadius: 10,
padding: 8,
},
previewText: {
fontSize: 13,
color: "#111827",
},
previewMeta: {
marginTop: 4,
fontSize: 11,
color: "#6b7280",
fontWeight: "600",
},
errorText: {
marginTop: 2,
fontSize: 12,
color: "#b91c1c",
},
});

View File

@@ -14,6 +14,8 @@ import ProfilePhotoCircle from './ProfilePhotoCircle.js';
import { posthog } from './../PostHog.js'; import { posthog } from './../PostHog.js';
import { useNavigation } from '@react-navigation/native'; import { useNavigation } from '@react-navigation/native';
import ParsedText from 'react-native-parsed-text'; import ParsedText from 'react-native-parsed-text';
import BibleEmbeddedView from './BibleEmbeddedView.js';
import { stripBibleTokens } from '../utils/bibleReferences.js';
let Post = (props) => { let Post = (props) => {
@@ -30,7 +32,7 @@ let Post = (props) => {
const SWIPE_WIDTH = 86; const SWIPE_WIDTH = 86;
let toProfileText = post.toProfile && post.toProfile !== post.profileid ? let toProfileText = post.toProfile && post.toProfile !== post.profileid ?
<ProfilePhotoCircle profileid={post.toProfile} small={true} /> : undefined; <ProfilePhotoCircle profileid={post.toProfile} small={true} /> : undefined;
let cleanContent = post.content.replace(/@[A-z]+:.+\w/g, '').trim(); let cleanContent = stripInlineTags(stripBibleTokens(post.content));
const navigation = useNavigation(); const navigation = useNavigation();
//cleanContent = convertLinks(cleanContent); //cleanContent = convertLinks(cleanContent);
const newComentAdded = (commentData) => { const newComentAdded = (commentData) => {
@@ -172,6 +174,9 @@ let Post = (props) => {
{cleanContent} {cleanContent}
</ParsedText> </ParsedText>
</Pressable> </Pressable>
<View style={{ paddingLeft: 40, paddingRight: 8 }}>
<BibleEmbeddedView content={post.content} openChapterOnPress />
</View>
<View <View
onStartShouldSetResponderCapture={() => { onStartShouldSetResponderCapture={() => {
@@ -356,3 +361,10 @@ const styles = StyleSheet.create({
textDecorationLine: 'underline', textDecorationLine: 'underline',
}, },
}); });
const stripInlineTags = (content = "") => {
return String(content || "")
.replace(/@[A-Za-z]+:[^\s]+/g, "")
.replace(/[ \t]{2,}/g, " ")
.replace(/[ \t]+\n/g, "\n")
.trim();
};

View File

@@ -6,6 +6,8 @@ const GlobalState = proxy({
profiles: {}, profiles: {},
currentMedia: '', currentMedia: '',
mediaPost: {}, mediaPost: {},
biblePickerSelection: null,
bibleChapterSelection: null,
}); });
export default GlobalState; export default GlobalState;

229
utils/bibleReferences.js Normal file
View File

@@ -0,0 +1,229 @@
const BIBLE_TOKEN_REGEX = /@bible:([^\s]+)/gi;
const normalizeReference = (value = "") => {
try {
return decodeURIComponent(String(value || ""))
.replace(/_/g, " ")
.replace(/\s+/g, " ")
.trim();
} catch (_error) {
return String(value || "").replace(/_/g, " ").replace(/\s+/g, " ").trim();
}
};
export const encodeBibleReference = (reference = "") => {
return String(reference || "").replace(/\s+/g, "_").trim();
};
export const createBibleToken = (reference = "") => {
const encoded = encodeBibleReference(reference);
if (!encoded) return "";
return `@bible:${encoded}`;
};
export const extractBibleReferences = (content = "") => {
const seen = new Set();
const refs = [];
if (!content || typeof content !== "string") return refs;
let match;
while ((match = BIBLE_TOKEN_REGEX.exec(content)) !== null) {
const normalized = normalizeReference(match[1]);
if (!normalized || seen.has(normalized)) continue;
seen.add(normalized);
refs.push(normalized);
}
return refs;
};
export const stripBibleTokens = (content = "") => {
if (!content || typeof content !== "string") return "";
return content
.replace(BIBLE_TOKEN_REGEX, "")
.replace(/[ \t]{2,}/g, " ")
.replace(/[ \t]+\n/g, "\n")
.trim();
};
export const fetchBiblePassage = async (reference = "") => {
const safeReference = normalizeReference(reference);
if (!safeReference) {
throw new Error("Missing Bible reference");
}
const response = await fetch(`https://bible-api.com/${encodeURIComponent(safeReference)}`);
if (!response.ok) {
throw new Error("Failed to load Bible passage");
}
const payload = await response.json();
return {
reference: payload?.reference || safeReference,
text: (payload?.text || "").trim(),
translation: payload?.translation_name || payload?.translation_id || "KJV",
};
};
export const parseBibleReference = (reference = "") => {
const normalized = normalizeReference(reference);
const match = normalized.match(/^(.*)\s+(\d+)(?::(\d+)(?:-\d+)?)?$/);
if (!match) {
return {
reference: normalized,
book: normalized,
chapter: 1,
verse: 1,
chapterReference: normalized,
};
}
const book = (match[1] || "").trim();
const chapter = Number(match[2] || "1");
const verse = Number(match[3] || "1");
return {
reference: normalized,
book,
chapter,
verse,
chapterReference: `${book} ${chapter}`,
};
};
export const BIBLE_BOOKS = [
"Genesis",
"Exodus",
"Leviticus",
"Numbers",
"Deuteronomy",
"Joshua",
"Judges",
"Ruth",
"1 Samuel",
"2 Samuel",
"1 Kings",
"2 Kings",
"1 Chronicles",
"2 Chronicles",
"Ezra",
"Nehemiah",
"Esther",
"Job",
"Psalms",
"Proverbs",
"Ecclesiastes",
"Song of Solomon",
"Isaiah",
"Jeremiah",
"Lamentations",
"Ezekiel",
"Daniel",
"Hosea",
"Joel",
"Amos",
"Obadiah",
"Jonah",
"Micah",
"Nahum",
"Habakkuk",
"Zephaniah",
"Haggai",
"Zechariah",
"Malachi",
"Matthew",
"Mark",
"Luke",
"John",
"Acts",
"Romans",
"1 Corinthians",
"2 Corinthians",
"Galatians",
"Ephesians",
"Philippians",
"Colossians",
"1 Thessalonians",
"2 Thessalonians",
"1 Timothy",
"2 Timothy",
"Titus",
"Philemon",
"Hebrews",
"James",
"1 Peter",
"2 Peter",
"1 John",
"2 John",
"3 John",
"Jude",
"Revelation",
];
export const BIBLE_BOOK_CHAPTERS = {
Genesis: 50,
Exodus: 40,
Leviticus: 27,
Numbers: 36,
Deuteronomy: 34,
Joshua: 24,
Judges: 21,
Ruth: 4,
"1 Samuel": 31,
"2 Samuel": 24,
"1 Kings": 22,
"2 Kings": 25,
"1 Chronicles": 29,
"2 Chronicles": 36,
Ezra: 10,
Nehemiah: 13,
Esther: 10,
Job: 42,
Psalms: 150,
Proverbs: 31,
Ecclesiastes: 12,
"Song of Solomon": 8,
Isaiah: 66,
Jeremiah: 52,
Lamentations: 5,
Ezekiel: 48,
Daniel: 12,
Hosea: 14,
Joel: 3,
Amos: 9,
Obadiah: 1,
Jonah: 4,
Micah: 7,
Nahum: 3,
Habakkuk: 3,
Zephaniah: 3,
Haggai: 2,
Zechariah: 14,
Malachi: 4,
Matthew: 28,
Mark: 16,
Luke: 24,
John: 21,
Acts: 28,
Romans: 16,
"1 Corinthians": 16,
"2 Corinthians": 13,
Galatians: 6,
Ephesians: 6,
Philippians: 4,
Colossians: 4,
"1 Thessalonians": 5,
"2 Thessalonians": 3,
"1 Timothy": 6,
"2 Timothy": 4,
Titus: 3,
Philemon: 1,
Hebrews: 13,
James: 5,
"1 Peter": 5,
"2 Peter": 3,
"1 John": 5,
"2 John": 1,
"3 John": 1,
Jude: 1,
Revelation: 22,
};
export const getBookChapterCount = (book = "") => {
return BIBLE_BOOK_CHAPTERS[book] || 1;
};