Add Bible picker flow and chapter navigation UI
This commit is contained in:
11
App.js
11
App.js
@@ -30,6 +30,8 @@ import NewGroup from './Views/NewGroup.js';
|
||||
import Slideshow from './Views/Slideshow.js';
|
||||
import SongPlayer from './Views/SongPlayer.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 { PostHogProvider } from 'posthog-react-native'
|
||||
import * as Updates from 'expo-updates';
|
||||
@@ -426,6 +428,15 @@ export default function App() {
|
||||
name="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="Login" component={Login} options={{ headerShown: false }} />
|
||||
<Tab.Screen name="Logout" component={Login} />
|
||||
|
||||
155
Views/BibleChapterView.js
Normal file
155
Views/BibleChapterView.js
Normal 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
252
Views/BiblePicker.js
Normal 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;
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
118
components/BibleEmbeddedView.js
Normal file
118
components/BibleEmbeddedView.js
Normal 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",
|
||||
},
|
||||
});
|
||||
@@ -14,6 +14,8 @@ import ProfilePhotoCircle from './ProfilePhotoCircle.js';
|
||||
import { posthog } from './../PostHog.js';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import ParsedText from 'react-native-parsed-text';
|
||||
import BibleEmbeddedView from './BibleEmbeddedView.js';
|
||||
import { stripBibleTokens } from '../utils/bibleReferences.js';
|
||||
|
||||
|
||||
let Post = (props) => {
|
||||
@@ -30,7 +32,7 @@ let Post = (props) => {
|
||||
const SWIPE_WIDTH = 86;
|
||||
let toProfileText = post.toProfile && post.toProfile !== post.profileid ?
|
||||
<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();
|
||||
//cleanContent = convertLinks(cleanContent);
|
||||
const newComentAdded = (commentData) => {
|
||||
@@ -172,6 +174,9 @@ let Post = (props) => {
|
||||
{cleanContent}
|
||||
</ParsedText>
|
||||
</Pressable>
|
||||
<View style={{ paddingLeft: 40, paddingRight: 8 }}>
|
||||
<BibleEmbeddedView content={post.content} openChapterOnPress />
|
||||
</View>
|
||||
|
||||
<View
|
||||
onStartShouldSetResponderCapture={() => {
|
||||
@@ -356,3 +361,10 @@ const styles = StyleSheet.create({
|
||||
textDecorationLine: 'underline',
|
||||
},
|
||||
});
|
||||
const stripInlineTags = (content = "") => {
|
||||
return String(content || "")
|
||||
.replace(/@[A-Za-z]+:[^\s]+/g, "")
|
||||
.replace(/[ \t]{2,}/g, " ")
|
||||
.replace(/[ \t]+\n/g, "\n")
|
||||
.trim();
|
||||
};
|
||||
|
||||
@@ -6,6 +6,8 @@ const GlobalState = proxy({
|
||||
profiles: {},
|
||||
currentMedia: '',
|
||||
mediaPost: {},
|
||||
biblePickerSelection: null,
|
||||
bibleChapterSelection: null,
|
||||
});
|
||||
|
||||
export default GlobalState;
|
||||
|
||||
229
utils/bibleReferences.js
Normal file
229
utils/bibleReferences.js
Normal 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;
|
||||
};
|
||||
Reference in New Issue
Block a user