Compare commits

...

43 Commits

Author SHA1 Message Date
Adolfo Reyna 3c4da84827 Refine live captions view to latest message translation flow 2026-02-26 22:56:48 -05:00
Adolfo Reyna 74d84470a9 Add live captions viewer and language selection 2026-02-26 22:31:04 -05:00
Adolfo Reyna c508ea8fea fix: sync post translation state and clear feed cache on language change 2026-02-25 18:49:50 -05:00
Adolfo Reyna c281878875 feat: integrate post AI translation with UI toggle and backend queue 2026-02-25 18:41:54 -05:00
Adolfo Reyna 9da5874977 feat: Add continuous Bible reading mode, backend-driven translation API integration, localized book names, and preference caching 2026-02-25 18:13:53 -05:00
Adolfo Reyna fc6f740fd2 Fix single post scrolling for long content and comments 2026-02-24 16:36:41 -05:00
Adolfo Reyna 53df0699a7 Improve comment composer and add Bible reference pills 2026-02-24 16:26:58 -05:00
Adolfo Reyna a2846423b9 Fix NewPost typing by preserving spaces while stripping bible tokens 2026-02-24 16:19:08 -05:00
Adolfo Reyna fc3159d3fb Internationalize Bible UI and add locale-aware translation fetch 2026-02-24 16:04:32 -05:00
Adolfo Reyna ccfeed3c92 Add Bible picker flow and chapter navigation UI 2026-02-24 15:56:48 -05:00
Adolfo Reyna ba28289783 Add localized feed profile-completion nudge 2026-02-24 00:51:58 -05:00
Adolfo Reyna 7f7fb8efb7 Run courses refresh only when Courses tab is focused 2026-02-24 00:41:50 -05:00
Adolfo Reyna 1f38b43d64 Fix YouTube embeds and improve global chat auto-scroll 2026-02-24 00:28:54 -05:00
Adolfo Reyna d5850bb91e Revamp global chat header, online panel, and composer UI 2026-02-24 00:07:36 -05:00
Adolfo Reyna 24515caf70 Handle chat push notifications and navigate to GlobalChat 2026-02-22 23:31:33 -05:00
Adolfo Reyna 5e05cba3ad Improve mobile photo quality for uploads and media rendering 2026-02-21 21:35:34 -05:00
Adolfo Reyna 270e4e1c2d Prevent post delete swipe from intercepting media gestures 2026-02-21 21:31:29 -05:00
Adolfo Reyna f9d4457b5a Add one-time token login flow and deep-link handling 2026-02-21 21:28:05 -05:00
Adolfo Reyna fe293828e6 Merge branch 'codex/universal-chat-room' 2026-02-20 23:41:38 -05:00
Adolfo Reyna e2bff88396 chore: restore production api base url 2026-02-20 23:22:31 -05:00
Adolfo Reyna f92d0fec0b feat: add global chat UI with i18n and translation indicators 2026-02-20 23:16:05 -05:00
Adolfo Reyna 6ff89c2e4b chore: save pending Menu.js changes 2026-02-20 22:39:54 -05:00
Adolfo Reyna 06e620dbf6 Internationalize remaining TODO-marked UI text in Expo app 2026-02-20 22:30:20 -05:00
Adolfo Reyna a8d21d31f8 Add owner swipe-delete and localized all-photos hint 2026-02-20 22:22:51 -05:00
Adolfo Reyna 0ba5d7bd0d Add notification viewed sync and header badge dot 2026-02-20 22:15:07 -05:00
Adolfo Reyna cc9ca1d075 Refresh profile from server and return after update 2026-02-20 22:10:44 -05:00
Adolfo Reyna ffb8f9bec5 Fix profile settings update flow and improve editor UX 2026-02-20 21:53:10 -05:00
Adolfo Reyna 01aeedf950 Complete new group flow and speed up groups loading 2026-02-20 21:35:54 -05:00
Adolfo Reyna 83604f5eaa Debug and fix courses watching progress resolution 2026-02-20 20:52:26 -05:00
Adolfo Reyna 058b060d03 Harden courses cache and viewer data iteration 2026-02-20 20:18:27 -05:00
Adolfo Reyna d2491800f2 Fix list rendering keys and nested list patterns 2026-02-20 20:13:10 -05:00
Adolfo Reyna 487edb4a62 Improve feed/profile cache logs and add Expo app agent notes 2026-02-20 20:10:07 -05:00
Adolfo Reyna 009f1ec792 Gracefully handle backend failures in Expo app 2026-02-20 19:25:38 -05:00
Adolfo Reyna fe9fc8e3e4 Display selected tag name in the Tags screen header 2025-02-28 00:35:22 -05:00
Adolfo Reyna 8cdcfefa0d Add tag functionality to posts and implement Tags screen 2025-02-28 00:21:00 -05:00
Adolfo Reyna f5c7ff38dd Add logic to handle notifications to open profile or posts 2025-02-27 23:04:56 -05:00
Adolfo Reyna 733c1fd793 Share post text to be able to copy 2025-02-22 00:20:51 -05:00
Adolfo Reyna 91e9740159 Add photo to comments 2025-02-22 00:07:35 -05:00
Adolfo Reyna a1b2143337 Provide access to change the API base url 2025-02-22 00:00:59 -05:00
Adolfo Reyna ad394a6f18 Open comments on singlepost view 2025-02-22 00:00:29 -05:00
Adolfo Reyna 0ae13c9ffe Enable profile photo touch 2025-02-22 00:00:07 -05:00
Adolfo Reyna 13406f2774 Show version on menu 2025-02-10 22:05:51 -05:00
Adolfo Reyna 8f62e4b953 Point to new server for test 2025-02-10 21:44:54 -05:00
44 changed files with 4306 additions and 452 deletions
+179 -18
View File
@@ -1,20 +1,67 @@
const baseUrl = "https://api.emmint.com"; import i18n from "./i18nMessages.js";
const baseUrl = "https://emiapi.reynafamily.com";
//const baseUrl = "http://localhost:3000"; //const baseUrl = "http://localhost:3000";
const requestErrorCooldownMs = 30000;
const profileFailureCooldownMs = 60000;
const recentRequestErrors = {};
const failedProfileCache = {};
const getCurrentLanguage = () => (i18n?.locale || "en").toLowerCase();
const normalizePath = (path = "") => {
return path.replace(/[a-f0-9]{24}/gi, ":id");
};
const logRequestErrorOnce = (key, message) => {
const now = Date.now();
if (recentRequestErrors[key] && now - recentRequestErrors[key] < requestErrorCooldownMs) return;
recentRequestErrors[key] = now;
console.error(message);
};
const getFallbackForPath = (path = "") => {
if (path.startsWith("/post/") || path === "/post/") return [];
return {};
};
const parseJsonResponse = async (response, path = "", fallback) => {
const text = await response.text();
if (!text) return fallback;
try {
return JSON.parse(text);
} catch (error) {
const normalizedPath = normalizePath(path);
logRequestErrorOnce(`invalid-json:${normalizedPath}`, `Invalid JSON from ${normalizedPath}: ${text.slice(0, 200)}`);
return fallback;
}
};
let getCall = async (path = "", params = {}) => { let getCall = async (path = "", params = {}) => {
let queryParams = "?"; let queryParams = "?";
Object.keys(params).forEach(p => { Object.keys(params).forEach(p => {
queryParams += p + "=" + params[p] + "&" queryParams += p + "=" + params[p] + "&"
}); });
return fetch(baseUrl + path + queryParams, { let localBaseUrl = global.baseUrl ?? baseUrl;
const fallback = getFallbackForPath(path);
return fetch(localBaseUrl + path + queryParams, {
method: 'GET', method: 'GET',
mode: 'cors', mode: 'cors',
credentials: 'include', credentials: 'include',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Accept-Language': getCurrentLanguage(),
'x-app-language': getCurrentLanguage(),
} }
}).then(response => response.json()).catch((error) => { }).then(async (response) => {
console.error(error); const normalizedPath = normalizePath(path);
if (!response.ok) {
logRequestErrorOnce(`GET:${normalizedPath}:${response.status}`, `GET ${normalizedPath} failed: ${response.status}`);
return fallback;
}
return parseJsonResponse(response, path, fallback);
}).catch((error) => {
const normalizedPath = normalizePath(path);
logRequestErrorOnce(`GET:${normalizedPath}:network`, `GET ${normalizedPath} network error: ${error?.message || error}`);
return fallback;
}) })
} }
@@ -23,29 +70,55 @@ let deleteCall = async (path = "", params = {}) => {
Object.keys(params).forEach(p => { Object.keys(params).forEach(p => {
queryParams += p + "=" + params[p] + "&" queryParams += p + "=" + params[p] + "&"
}); });
return fetch(baseUrl + path + queryParams, { let localBaseUrl = global.baseUrl ?? baseUrl;
const fallback = {};
return fetch(localBaseUrl + path + queryParams, {
method: 'DELETE', method: 'DELETE',
mode: 'cors', mode: 'cors',
credentials: 'include', credentials: 'include',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Accept-Language': getCurrentLanguage(),
'x-app-language': getCurrentLanguage(),
} }
}).then(response => response.json()).catch((error) => { }).then(async (response) => {
console.error(error); const normalizedPath = normalizePath(path);
if (!response.ok) {
logRequestErrorOnce(`DELETE:${normalizedPath}:${response.status}`, `DELETE ${normalizedPath} failed: ${response.status}`);
return fallback;
}
return parseJsonResponse(response, path, fallback);
}).catch((error) => {
const normalizedPath = normalizePath(path);
logRequestErrorOnce(`DELETE:${normalizedPath}:network`, `DELETE ${normalizedPath} network error: ${error?.message || error}`);
return fallback;
}) })
} }
let postCall = async (path, params) => { let postCall = async (path, params) => {
return fetch(baseUrl + path, { let localBaseUrl = global.baseUrl ?? baseUrl;
const fallback = {};
return fetch(localBaseUrl + path, {
method: 'POST', method: 'POST',
mode: 'cors', mode: 'cors',
credentials: 'include', credentials: 'include',
body: JSON.stringify(params), body: JSON.stringify(params),
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Accept-Language': getCurrentLanguage(),
'x-app-language': getCurrentLanguage(),
} }
}).then(response => response.json()).catch((error) => { }).then(async (response) => {
console.error(error); const normalizedPath = normalizePath(path);
if (!response.ok) {
logRequestErrorOnce(`POST:${normalizedPath}:${response.status}`, `POST ${normalizedPath} failed: ${response.status}`);
return fallback;
}
return parseJsonResponse(response, path, fallback);
}).catch((error) => {
const normalizedPath = normalizePath(path);
logRequestErrorOnce(`POST:${normalizedPath}:network`, `POST ${normalizedPath} network error: ${error?.message || error}`);
return fallback;
}) })
} }
@@ -54,14 +127,44 @@ let CurrentProfile;
let CurrentProfileData; let CurrentProfileData;
let userNameCache = {}; //save this on localstorage let userNameCache = {}; //save this on localstorage
let working_on = {}; //Promises let working_on = {}; //Promises
const emptyProfileData = (id = "") => ({
_id: id || "",
profile: {
firstName: "",
lastName: "",
photo: "",
description: "",
language: "en",
},
data: {},
following: [],
});
let getProfileFromCache = async (id, refresh=false) => { let getProfileFromCache = async (id, refresh=false) => {
if(!id) console.trace(); if(!id) console.trace();
const lastFailure = failedProfileCache[id] || 0;
if (!refresh && lastFailure && Date.now() - lastFailure < profileFailureCooldownMs) {
return emptyProfileData(id);
}
if (userNameCache[id] && !refresh) if (userNameCache[id] && !refresh)
return userNameCache[id]; return userNameCache[id];
if (working_on[id] && !refresh) if (working_on[id] && !refresh)
return working_on[id]; return working_on[id];
//console.log(id, "not in cache, getting...") working_on[id] = getCall("/user/" + id).then((profile) => {
working_on[id] = getCall("/user/" + id) const hasRealProfile = !!(profile && profile._id);
const safeProfile = hasRealProfile ? { ...emptyProfileData(id), ...profile, profile: { ...emptyProfileData(id).profile, ...(profile.profile || {}) } } : emptyProfileData(id);
if (hasRealProfile) {
userNameCache[id] = safeProfile;
delete failedProfileCache[id];
} else {
failedProfileCache[id] = Date.now();
}
delete working_on[id];
return safeProfile;
}).catch(() => {
failedProfileCache[id] = Date.now();
delete working_on[id];
return emptyProfileData(id);
});
return working_on[id]; return working_on[id];
} }
@@ -92,6 +195,15 @@ const API = {
return data; return data;
}) })
}, },
logInWithPasswordToken: async (token) => {
return postCall("/password/token-login", { token }).then((data) => {
if (data && data.status === "ok") {
CurrentUserId = data.user_sid;
CurrentProfile = data.profile_id;
}
return data;
});
},
async logout(){ async logout(){
console.log("Logging out...") console.log("Logging out...")
return getCall("/logout").then(()=>{ return getCall("/logout").then(()=>{
@@ -133,8 +245,11 @@ const API = {
}, },
//Posts //Posts
getPosts(userid) { getPosts(userid) {
if (userid) return getCall("/post/usr/" + userid); if (userid) return getCall("/post/usr/" + userid).then((data) => Array.isArray(data) ? data : []);
return getCall("/post/"); return getCall("/post/").then((data) => Array.isArray(data) ? data : []);
},
getPostsByTag(tag) {
return getCall("/post/tag/" + tag);
}, },
getPostsWithTag(userid, tag = "images") { getPostsWithTag(userid, tag = "images") {
if (userid) return getCall("/post/usr/" + userid + "/" + tag); if (userid) return getCall("/post/usr/" + userid + "/" + tag);
@@ -155,6 +270,9 @@ const API = {
updatePost(post){ updatePost(post){
return postCall("/post/" + post._id, {content: post.content}); return postCall("/post/" + post._id, {content: post.content});
}, },
translatePost(postid, targetLang) {
return postCall("/post/translate", { postid, targetLang });
},
newPost(content, toProfile, isNews) { newPost(content, toProfile, isNews) {
//Content is expected to be a string. //Content is expected to be a string.
let params = { content }; let params = { content };
@@ -205,8 +323,19 @@ const API = {
return getCall("/user/" + CurrentProfile); return getCall("/user/" + CurrentProfile);
}, },
updateMyProfile(profile, data) { updateMyProfile(profile, data) {
if (CurrentProfile) {
delete userNameCache[CurrentProfile];
delete failedProfileCache[CurrentProfile];
}
return postCall("/user/myProfile", {profile, data}); return postCall("/user/myProfile", {profile, data});
}, },
markNotificationsViewed() {
if (CurrentProfile) {
delete userNameCache[CurrentProfile];
delete failedProfileCache[CurrentProfile];
}
return postCall("/user/notifications/viewed", {});
},
searchProfiles(query){ searchProfiles(query){
return getCall("/user/search", query ? {query} : {}).then((data)=>{ return getCall("/user/search", query ? {query} : {}).then((data)=>{
if(data.status == "ok"){ if(data.status == "ok"){
@@ -225,7 +354,7 @@ const API = {
}); });
}, },
getUserProfile(profileid, refresh=false) { getUserProfile(profileid, refresh=false) {
return getProfileFromCache(profileid, refresh); return getProfileFromCache(profileid, refresh).then((profile) => profile || emptyProfileData(profileid));
}, },
deleteProfile(profileid){ deleteProfile(profileid){
return deleteCall("/user/" + profileid); return deleteCall("/user/" + profileid);
@@ -241,15 +370,24 @@ const API = {
return getCall("/user/" + profileid + "/unfollow"); return getCall("/user/" + profileid + "/unfollow");
}, },
setDataValue(key, value){ setDataValue(key, value){
if (CurrentProfile && userNameCache[CurrentProfile]) {
if (!userNameCache[CurrentProfile].data) userNameCache[CurrentProfile].data = {};
userNameCache[CurrentProfile].data[key] = value;
}
if (CurrentProfileData) {
if (!CurrentProfileData.data) CurrentProfileData.data = {};
CurrentProfileData.data[key] = value;
}
return postCall("/user/setData", {key, value}); return postCall("/user/setData", {key, value});
}, },
//Groups //Groups
newGroup(title, subtitle, description, isPrivate=false, isCourse=false) { newGroup(title, subtitle, description, isPrivate=false, isCourse=false, photo='') {
return postCall("/user/groups", { return postCall("/user/groups", {
profile: { profile: {
firstName: title, firstName: title,
lastName: subtitle, lastName: subtitle,
description description,
photo
}, },
isPrivate, isPrivate,
isCourse isCourse
@@ -312,7 +450,30 @@ const API = {
}, },
paymentRegister(userid, result){ paymentRegister(userid, result){
return postCall("/payments/register/", {userid, result}); return postCall("/payments/register/", {userid, result});
},
//Chat
getChatMessages(limit = 100) {
return getCall("/chat/messages", { limit, lang: getCurrentLanguage() }).then((data) => Array.isArray(data?.messages) ? data.messages : []);
},
sendChatMessage(text) {
return postCall("/chat/messages", { text, sourceLang: getCurrentLanguage() });
},
getChatActiveUsers() {
return getCall("/chat/active").then((data) => Array.isArray(data?.activeUsers) ? data.activeUsers : []);
},
pingChatPresence() {
return postCall("/chat/ping", {}).then((data) => Array.isArray(data?.activeUsers) ? data.activeUsers : []);
},
// Live captions
getLiveCaptions(sinceSequence, limit = 40) {
const params = { limit };
if (Number.isFinite(sinceSequence)) params.sinceSequence = sinceSequence;
return getCall("/live-captions/stream", params).then((data) => ({
latestSequence: Number.isFinite(data?.latestSequence) ? data.latestSequence : 0,
captions: Array.isArray(data?.captions) ? data.captions : [],
availableLanguages: Array.isArray(data?.availableLanguages) ? data.availableLanguages : [],
}));
} }
} }
export default API; export default API;
+113
View File
@@ -0,0 +1,113 @@
# EMI Expo App Agent Notes
This file summarizes the current `/Users/adolforeyna/Projects/EMI/expoApp` codebase so future work can start with context.
## What This App Is
- Expo React Native app for EMI Fellowship social network (iOS + Android + limited web support).
- Main features: auth, feed/posts/comments, profile pages, groups, courses, invite flow, notifications, media playback/upload.
- Backend is expected to be the EMI API and cookie-authenticated.
## Run + Build Basics
- Install: `npm install`
- Start dev server: `npm start`
- Device targets: `npm run ios`, `npm run android`
- Expo config: `/Users/adolforeyna/Projects/EMI/expoApp/app.json`
- EAS config: `/Users/adolforeyna/Projects/EMI/expoApp/eas.json`
## High-Level Architecture
- App entry + navigation: `/Users/adolforeyna/Projects/EMI/expoApp/App.js`
- API abstraction layer: `/Users/adolforeyna/Projects/EMI/expoApp/API.js`
- Global state (Valtio proxy): `/Users/adolforeyna/Projects/EMI/expoApp/contexts/GlobalState.js`
- Screen modules: `/Users/adolforeyna/Projects/EMI/expoApp/Views/*`
- Reusable UI pieces: `/Users/adolforeyna/Projects/EMI/expoApp/components/*`
### Navigation Shape
- Root stack (`createNativeStackNavigator`) contains:
- `MainNavigation` (bottom tabs)
- Details/screens like `Profile`, `SinglePost`, `Search`, `Menu`, `Notifications`, `ProfileSettings`, etc.
- Bottom tabs (`createBottomTabNavigator`) inside `MainNavigation`:
- `Feed`, `Groups`, `NewPost`, `Courses`, `MyProfile`
- Custom header in `App.js` provides menu/search/notifications shortcuts globally.
## State + Data Flow
- Server source of truth via `API.js`.
- `GlobalState.me` stores active viewer profile and is refreshed:
- on Feed load
- periodically in `MainNavigation` (30s interval)
- on profile switch in Menu
- Local caching with `AsyncStorage`:
- feed cache
- profile post cache
- courses cache
- profile/name cache in card/name components
- `API.js` has defensive networking:
- fallback responses on non-OK and invalid JSON
- request error cooldown logging
- profile failure cooldown cache to avoid repeated failing calls
## API Layer Notes
- Base URL default in `/Users/adolforeyna/Projects/EMI/expoApp/API.js`:
- `https://emiapi.reynafamily.com`
- can be overridden with `global.baseUrl`
- Uses `fetch(..., { credentials: 'include' })`, so backend cookie/CORS config must be correct.
- Covers endpoints for:
- auth/session
- posts/comments/reactions/bookmarks
- profiles/follow/change profile
- groups/courses
- invites
- payments
- notifications token registration
## Feature Map (Key Files)
- Login/register UI: `/Users/adolforeyna/Projects/EMI/expoApp/Views/Login.js`, `/Users/adolforeyna/Projects/EMI/expoApp/components/Login.js`, `/Users/adolforeyna/Projects/EMI/expoApp/components/Register.js`
- Feed: `/Users/adolforeyna/Projects/EMI/expoApp/Views/Feed.js`
- Post rendering/interactions: `/Users/adolforeyna/Projects/EMI/expoApp/components/Post.js`
- Media parser/player/slideshow handoff: `/Users/adolforeyna/Projects/EMI/expoApp/components/Media.js`
- Profile page + tagged profile posts: `/Users/adolforeyna/Projects/EMI/expoApp/Views/Profile.js`
- Create post + image uploads: `/Users/adolforeyna/Projects/EMI/expoApp/Views/NewPost.js`
- Groups + courses browsing: `/Users/adolforeyna/Projects/EMI/expoApp/Views/Groups.js`, `/Users/adolforeyna/Projects/EMI/expoApp/Views/Courses.js`
- Profile settings and invite flow: `/Users/adolforeyna/Projects/EMI/expoApp/Views/ProfileSettings.js`, `/Users/adolforeyna/Projects/EMI/expoApp/Views/Invite.js`
## Integrations In Use
- Expo Notifications (`expo-notifications`) including push token registration.
- Expo Updates (`expo-updates`) check/fetch/reload in Feed (disabled in `__DEV__`).
- PostHog analytics:
- setup in `/Users/adolforeyna/Projects/EMI/expoApp/PostHog.js`
- provider in `/Users/adolforeyna/Projects/EMI/expoApp/App.js`
- Media upload currently points to `https://social.emmint.com/upload.php` in multiple places.
## Known Risks / Footguns (Current Code)
- Missing imports likely to crash at runtime:
- `AsyncStorage` used but not imported in `/Users/adolforeyna/Projects/EMI/expoApp/Views/Courses.js`
- `Platform` used but not imported in `/Users/adolforeyna/Projects/EMI/expoApp/Views/ProfileSettings.js`
- `Platform` used but not imported in `/Users/adolforeyna/Projects/EMI/expoApp/components/NewPost.js`
- A few typos are intentionally present in route/prop names and work only because both sides share the typo:
- `intialContent`, `ceatedAt`, `skiptOnPress`, `skiptVideo`
- `Store.js` appears unused and stale (`easy-peasy` store not wired into app).
- `Views/NewGroup.js` is mostly scaffolded (UI shell, no creation flow yet).
## Debugging Checklist (Most Common Production Issues)
1. Confirm API base URL in `/Users/adolforeyna/Projects/EMI/expoApp/API.js` (or `global.baseUrl`) targets intended backend.
2. Validate backend session cookie/CORS behavior for current environment.
3. If login/feed fails, verify `API.isLoggedIn()` response shape and cookie persistence.
4. For phone testing against local backend, never use `localhost`; use machine LAN IP.
5. If feed/profile seems stale, clear AsyncStorage caches and re-open app.
## Recommended Working Style For This Repo
- Prefer small defensive changes over broad refactors.
- Preserve API response guards and null-safe rendering patterns.
- When touching profile/post rendering, test both cached and fresh-network paths.
- Keep changes isolated by feature screen/component; this code relies on many implicit shared assumptions.
+125 -6
View File
@@ -8,6 +8,7 @@ import MaterialIcons from 'react-native-vector-icons/MaterialIcons';
import Login from "./Views/Login.js" import Login from "./Views/Login.js"
import Feed from "./Views/Feed.js" import Feed from "./Views/Feed.js"
import Profile from "./Views/Profile.js" import Profile from "./Views/Profile.js"
import Tags from "./Views/Tags.js"
import Search from './Views/Search.js'; import Search from './Views/Search.js';
import Groups from './Views/Groups.js'; import Groups from './Views/Groups.js';
import Courses from './Views/Courses.js'; import Courses from './Views/Courses.js';
@@ -28,9 +29,16 @@ import GlobalState from './contexts/GlobalState.js';
import NewGroup from './Views/NewGroup.js'; 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 LiveCaptions from './Views/LiveCaptions.js';
import BiblePicker from './Views/BiblePicker.js';
import BibleChapterView from './Views/BibleChapterView.js';
import Bible from './Views/Bible.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';
import { useNavigation } from '@react-navigation/native';
import * as Linking from 'expo-linking';
const Tab = createBottomTabNavigator(); const Tab = createBottomTabNavigator();
@@ -57,6 +65,15 @@ Notifications.setNotificationHandler({
}, },
}); });
const parseTokenLoginUrl = (url = "") => {
const parsed = Linking.parse(url);
const token = typeof parsed?.queryParams?.token === "string" ? parsed.queryParams.token.trim() : "";
if (!token) return null;
const hostOrPath = `${parsed?.hostname || ""}/${parsed?.path || ""}`.toLowerCase();
if (!hostOrPath.includes("token-login")) return null;
return token;
};
async function registerForPushNotificationsAsync() { async function registerForPushNotificationsAsync() {
if (Platform.OS === 'android') { if (Platform.OS === 'android') {
@@ -95,24 +112,42 @@ const MainNavigation = ({ route }) => {
const [notification, setNotification] = useState(false); const [notification, setNotification] = useState(false);
const notificationListener = useRef(); const notificationListener = useRef();
const responseListener = useRef(); const responseListener = useRef();
const mainNavigation = useNavigation();
useEffect(() => { useEffect(() => {
registerForPushNotificationsAsync().then(async (token) => { registerForPushNotificationsAsync().then(async (token) => {
let isLoggedIn = await API.isLoggedIn(); let isLoggedIn = await API.isLoggedIn();
if (!isLoggedIn) return false; if (!isLoggedIn) return false;
if (!token) return false;
API.registerToken(token); API.registerToken(token);
return setExpoPushToken(token); return setExpoPushToken(token);
}); });
// This listener is fired whenever a notification is received while the app is foregrounded // This listener is fired whenever a notification is received while the app is foregrounded
notificationListener.current = Notifications.addNotificationReceivedListener(notification => { notificationListener.current = Notifications.addNotificationReceivedListener(notification => {
//console.log("got notif", notification) //console.log("got notif", notification);
setNotification(notification); setNotification(notification);
}); });
// This listener is fired whenever a user taps on or interacts with a notification (works when app is foregrounded, backgrounded, or killed) // This listener is fired whenever a user taps on or interacts with a notification (works when app is foregrounded, backgrounded, or killed)
responseListener.current = Notifications.addNotificationResponseReceivedListener(response => { responseListener.current = Notifications.addNotificationResponseReceivedListener(response => {
//console.log("got notif click", notification) const data = response.notification.request.content.data;
console.log(response); if (data && Object.keys(data).length > 0) {
try {
if (data.profile_id) {
mainNavigation.navigate("Profile", { profileid: data.profile_id });
}
if (data.post_id) {
mainNavigation.navigate("SinglePost", { postid: data.post_id });
}
if (data.type === "chat") {
mainNavigation.navigate("GlobalChat");
}
} catch (error) {
alert("Error: " + error);
}
} else {
//alert("Notification clicked but no data found.");
}
}); });
const interval = setInterval(async () => { const interval = setInterval(async () => {
@@ -256,12 +291,53 @@ const MainNavigation = ({ route }) => {
} }
export default function App() { export default function App() {
const appState = useSnapshot(GlobalState);
const viewer = appState.me || {};
const navigationRef = useRef(null);
const pendingTokenRef = useRef(null);
const hasUnviewedNotifications = Array.isArray(viewer?.notifications)
? viewer.notifications.some((n) => n && n.viewed !== true)
: false;
useEffect(() => {
const routeTokenLogin = (url) => {
const token = parseTokenLoginUrl(url);
if (!token) return;
if (!navigationRef.current) {
pendingTokenRef.current = token;
return;
}
navigationRef.current.navigate("Login", { token });
};
Linking.getInitialURL().then((url) => {
if (!url) return;
routeTokenLogin(url);
});
const sub = Linking.addEventListener("url", ({ url }) => {
routeTokenLogin(url);
});
return () => {
sub.remove();
};
}, []);
return ( return (
<PaperProvider settings={{ <PaperProvider settings={{
icon: props => <MaterialIcons {...props} />, icon: props => <MaterialIcons {...props} />,
}} theme={theme}> }} theme={theme}>
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : 'height'} style={{ flex: 1 }}> <KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : 'height'} style={{ flex: 1 }}>
<NavigationContainer> <NavigationContainer
ref={navigationRef}
onReady={() => {
if (!pendingTokenRef.current) return;
const token = pendingTokenRef.current;
pendingTokenRef.current = null;
navigationRef.current?.navigate("Login", { token });
}}
>
<PostHogProvider apiKey="phc_2zh7SoBDi83vaa7Rz4YWTXWCjV0bOLfiqRyUo2mkf0b" autocapture> <PostHogProvider apiKey="phc_2zh7SoBDi83vaa7Rz4YWTXWCjV0bOLfiqRyUo2mkf0b" autocapture>
<StatusBar style="dark" /> <StatusBar style="dark" />
<Stack.Navigator screenOptions={{ <Stack.Navigator screenOptions={{
@@ -273,12 +349,30 @@ export default function App() {
}} /> : <Appbar.Action icon="menu" style={{ padding: 0, margin: 0 }} onPress={() => { props.navigation.navigate('Menu'); }} />} }} /> : <Appbar.Action icon="menu" style={{ padding: 0, margin: 0 }} onPress={() => { props.navigation.navigate('Menu'); }} />}
<Appbar.Content title="EMI Fellowship" titleStyle={{}} /> <Appbar.Content title="EMI Fellowship" titleStyle={{}} />
<Appbar.Action icon="chat" onPress={() => { <Appbar.Action icon="chat" onPress={() => {
alert("Chats are comming soon."); props.navigation.navigate("GlobalChat");
}} onLongPress={() => { }} onLongPress={() => {
props.navigation.navigate("SongPlayer"); props.navigation.navigate("SongPlayer");
}} /> }} />
<Appbar.Action icon="search" onPress={() => { props.navigation.navigate("Search") }} /> <Appbar.Action icon="search" onPress={() => { props.navigation.navigate("Search") }} />
<Appbar.Action icon="notifications" onPress={() => { props.navigation.navigate("Notifications") }} /> <Appbar.Action
icon={({ size, color }) => (
<View style={{ width: size, height: size, justifyContent: "center", alignItems: "center" }}>
<MaterialIcons name="notifications" size={size} color={color} />
{hasUnviewedNotifications ? (
<View style={{
position: "absolute",
top: 2,
right: 0,
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: "#d93025",
}} />
) : <></>}
</View>
)}
onPress={() => { props.navigation.navigate("Notifications") }}
/>
</Appbar.Header> </Appbar.Header>
) )
}, },
@@ -291,6 +385,10 @@ export default function App() {
name="Profile" name="Profile"
component={Profile} component={Profile}
/> />
<Stack.Screen
name="Tags"
component={Tags}
/>
<Stack.Screen <Stack.Screen
name="NewPost" name="NewPost"
component={NewPostView} component={NewPostView}
@@ -328,6 +426,27 @@ export default function App() {
name="SongPlayer" name="SongPlayer"
component={SongPlayer} component={SongPlayer}
/> />
<Stack.Screen
name="GlobalChat"
component={GlobalChat}
/>
<Stack.Screen
name="LiveCaptions"
component={LiveCaptions}
/>
<Stack.Screen
name="BiblePicker"
component={BiblePicker}
/>
<Stack.Screen
name="BibleChapter"
component={BibleChapterView}
options={{ headerShown: false }}
/>
<Stack.Screen
name="Bible"
component={Bible}
/>
<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} />
+88
View File
@@ -0,0 +1,88 @@
import React from "react";
import { FlatList, View } from "react-native";
import { Button, Chip, Text } from "react-native-paper";
import { useNavigation } from "@react-navigation/native";
import { BIBLE_BOOKS, getBookChapterCount } from "../utils/bibleReferences.js";
import i18n from "../i18nMessages.js";
const Bible = () => {
const navigation = useNavigation();
const [selectedBook, setSelectedBook] = React.useState("");
const [activeStep, setActiveStep] = React.useState("book");
const selectedBookChapterCount = React.useMemo(() => getBookChapterCount(selectedBook), [selectedBook]);
const chapterOptions = React.useMemo(
() => Array.from({ length: selectedBookChapterCount }, (_v, i) => String(i + 1)),
[selectedBookChapterCount]
);
return (
<View style={{ flex: 1, paddingHorizontal: 12, paddingTop: 12 }}>
<Text style={{ fontSize: 22, fontWeight: "700", marginBottom: 6 }}>{i18n.t("message.bible") || "Read the Bible"}</Text>
<Text style={{ color: "#6b7280", marginBottom: 10 }}>
{i18n.t("message.biblePickerSubtitlePost") || "Select a book and chapter to start reading."}
</Text>
<View style={{ flexDirection: "row", marginBottom: 10 }}>
<Button mode={activeStep === "book" ? "contained-tonal" : "text"} onPress={() => setActiveStep("book")}>
{i18n.t("message.book") || "Book"}
</Button>
<Button
mode={activeStep === "chapter" ? "contained-tonal" : "text"}
disabled={!selectedBook}
onPress={() => setActiveStep("chapter")}
>
{i18n.t("message.chapter") || "Chapter"}
</Button>
</View>
{activeStep === "book" ? (
<>
<Text style={{ fontSize: 14, fontWeight: "600", marginBottom: 8 }}>{i18n.t("message.books") || "Books"}</Text>
<FlatList
data={BIBLE_BOOKS}
numColumns={2}
keyExtractor={(item) => item}
renderItem={({ item }) => (
<Chip
selected={selectedBook === item}
style={{ marginRight: 6, marginBottom: 6, width: "48%" }}
onPress={() => {
setSelectedBook(item);
setActiveStep("chapter");
}}
>
{item}
</Chip>
)}
/>
</>
) : null}
{activeStep === "chapter" ? (
<>
<Text style={{ fontSize: 14, fontWeight: "600", marginBottom: 8 }}>
{i18n.t("message.chapters") || "Chapters"} {selectedBook ? `(${selectedBook})` : ""}
</Text>
<FlatList
data={chapterOptions}
numColumns={6}
keyExtractor={(item) => item}
renderItem={({ item }) => (
<Chip
style={{ marginRight: 6, marginBottom: 6, minWidth: 44 }}
onPress={() => {
navigation.navigate("BibleChapter", { reference: `${selectedBook} ${item}`, selectable: false });
}}
>
{item}
</Chip>
)}
/>
</>
) : null}
</View>
);
};
export default Bible;
+243
View File
@@ -0,0 +1,243 @@
import React from "react";
import { FlatList, Pressable, View } from "react-native";
import { ActivityIndicator, IconButton, Menu, Text, Button } from "react-native-paper";
import { SafeAreaView } from "react-native-safe-area-context";
import { useNavigation } from "@react-navigation/native";
import { fetchBibleChapter, parseBibleReference, AVAILABLE_TRANSLATIONS, BIBLE_BOOKS, getBookChapterCount } from "../utils/bibleReferences.js";
import GlobalState from "../contexts/GlobalState.js";
import { useSnapshot } from "valtio";
import API from "../API.js";
import i18n from "../i18nMessages.js";
const BibleChapterView = ({ route }) => {
const navigation = useNavigation();
const gState = useSnapshot(GlobalState);
const reference = route?.params?.reference || "";
const selectable = route?.params?.selectable === true;
const { chapterReference, verse: selectedVerse, book, chapter } = parseBibleReference(reference);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState("");
const [chapterData, setChapterData] = React.useState(null);
const initialTranslation = gState.me?.data?.bibleTranslation || "";
const [selectedTranslation, setSelectedTranslation] = React.useState(initialTranslation);
const [translationMenuVisible, setTranslationMenuVisible] = React.useState(false);
const listRef = React.useRef(null);
const autoScrolledRef = React.useRef(false);
const bookIndex = BIBLE_BOOKS.indexOf(book);
const chapterCount = getBookChapterCount(book);
let prevReference = null;
if (chapter > 1) {
prevReference = `${book} ${chapter - 1}`;
} else if (bookIndex > 0) {
const prevBook = BIBLE_BOOKS[bookIndex - 1];
const prevBookChapters = getBookChapterCount(prevBook);
prevReference = `${prevBook} ${prevBookChapters}`;
}
let nextReference = null;
if (chapter < chapterCount) {
nextReference = `${book} ${chapter + 1}`;
} else if (bookIndex >= 0 && bookIndex < BIBLE_BOOKS.length - 1) {
const nextBook = BIBLE_BOOKS[bookIndex + 1];
nextReference = `${nextBook} 1`;
}
React.useEffect(() => {
let mounted = true;
const loadChapter = async () => {
setLoading(true);
setError("");
try {
const payload = await fetchBibleChapter(chapterReference, i18n.locale, selectedTranslation);
if (!mounted) return;
setChapterData(payload);
} catch (_error) {
if (!mounted) return;
setError(i18n.t("message.unableLoadChapter") || "Unable to load chapter");
setChapterData(null);
} finally {
if (mounted) setLoading(false);
}
};
loadChapter();
return () => {
mounted = false;
};
}, [chapterReference, selectedTranslation]);
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, selectedTranslation, 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 }}>
<View style={{ flexDirection: "row", justifyContent: "space-between", alignItems: "flex-start" }}>
<View style={{ flex: 1 }}>
<Text style={{ fontSize: 22, fontWeight: "700", marginBottom: 4 }}>
{chapterData?.reference || chapterReference}
</Text>
<Menu
visible={translationMenuVisible}
onDismiss={() => setTranslationMenuVisible(false)}
anchor={
<Pressable onPress={() => setTranslationMenuVisible(true)}>
<Text style={{ color: "#2563eb", marginBottom: 10, fontWeight: "600", textDecorationLine: "underline" }}>
{chapterData?.translation_name || chapterData?.translation_id || "KJV"}
</Text>
</Pressable>
}
>
{AVAILABLE_TRANSLATIONS.map((trans) => (
<Menu.Item
key={trans.id}
onPress={() => {
setSelectedTranslation(trans.id);
setTranslationMenuVisible(false);
if (GlobalState.me) {
if (!GlobalState.me.data) GlobalState.me.data = {};
GlobalState.me.data.bibleTranslation = trans.id;
}
API.setDataValue("bibleTranslation", trans.id);
}}
title={trans.name}
/>
))}
</Menu>
</View>
{!selectable ? (
<IconButton
icon="menu-book"
size={28}
onPress={() => navigation.navigate("Bible")}
style={{ margin: 0, marginTop: -4 }}
/>
) : null}
</View>
{selectable ? (
<Text style={{ color: "#6b7280", marginBottom: 8 }}>{i18n.t("message.tapVerseToSelect")}</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);
}}
ListFooterComponent={() => (
<View style={{ flexDirection: "row", justifyContent: "space-between", marginTop: 16, marginBottom: 24, paddingHorizontal: 4 }}>
<Button
mode="outlined"
disabled={!prevReference}
onPress={() => {
if (prevReference) {
navigation.replace("BibleChapter", { reference: prevReference, selectable });
}
}}
>
{i18n.t("message.previous") || "Previous"}
</Button>
<Button
mode="outlined"
disabled={!nextReference}
onPress={() => {
if (nextReference) {
navigation.replace("BibleChapter", { reference: nextReference, selectable });
}
}}
>
{i18n.t("message.next") || "Next"}
</Button>
</View>
)}
renderItem={({ item }) => {
const verseNumber = Number(item?.verse || 0);
const isSelected = verseNumber === selectedVerseNumber;
return (
<Pressable
onPress={() => handleVersePress(verseNumber)}
style={{
paddingVertical: 4,
paddingHorizontal: 8,
borderRadius: 8,
marginBottom: 2,
backgroundColor: isSelected ? "#fff3cd" : "transparent",
borderWidth: isSelected ? 1 : 0,
borderColor: isSelected ? "#f59e0b" : "transparent",
flexDirection: "row",
}}
>
<Text style={{ fontWeight: "700", color: isSelected ? "#92400e" : "#374151", marginRight: 8, marginTop: 2, fontSize: 12 }}>
{verseNumber}
</Text>
<Text style={{ color: "#111827", lineHeight: 22, flex: 1, fontSize: 16 }}>{(item?.text || "").replace(/\n/g, " ").trim()}</Text>
</Pressable>
);
}}
/>
</View>
</SafeAreaView>
);
};
export default BibleChapterView;
+251
View File
@@ -0,0 +1,251 @@
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, fetchBibleChapter, fetchBiblePassage, getBookChapterCount } from "../utils/bibleReferences.js";
import i18n from "../i18nMessages.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, i18n.locale);
setPreview(passage);
} catch (_err) {
setPreview(null);
setError(i18n.t("message.unableLoadPassage"));
} finally {
setLoadingPreview(false);
}
};
const loadVerseOptions = async (book, chapterNumber) => {
if (!book || !chapterNumber) {
setVerseOptions(["1"]);
return;
}
setLoadingVerses(true);
try {
const payload = await fetchBibleChapter(`${book} ${chapterNumber}`, i18n.locale);
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 }}>{i18n.t("message.bibleReferenceTitle")}</Text>
<Text style={{ color: "#6b7280", marginBottom: 10 }}>
{target === "chat" ? i18n.t("message.biblePickerSubtitleChat") : i18n.t("message.biblePickerSubtitlePost")}
</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}>
{i18n.t("message.preview")}
</Button>
<Button mode="contained" disabled={!computedReference} onPress={() => addReferenceToCaller(computedReference)}>
{i18n.t("message.useReference")}
</Button>
</View>
{computedReference ? <Text style={{ marginTop: 8, color: "#374151" }}>{i18n.t("message.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 }}>{i18n.t("message.tapPreviewPickVerse")}</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")}>
{i18n.t("message.book")}
</Button>
<Button
mode={activeStep === "chapter" ? "contained-tonal" : "text"}
disabled={!selectedBook}
onPress={() => setActiveStep("chapter")}
>
{i18n.t("message.chapter")}
</Button>
<Button
mode={activeStep === "verse" ? "contained-tonal" : "text"}
disabled={!selectedBook || !chapter}
onPress={() => setActiveStep("verse")}
>
{i18n.t("message.verse")}
</Button>
</View>
{activeStep === "book" ? (
<>
<Text style={{ fontSize: 14, fontWeight: "600", marginBottom: 8 }}>{i18n.t("message.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 }}>
{i18n.t("message.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 }}>
{i18n.t("message.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;
+161 -66
View File
@@ -1,36 +1,109 @@
import React, { useEffect } from "react"; import React, { useEffect } from "react";
import { Searchbar, Title } from 'react-native-paper'; import { Searchbar, Title } from 'react-native-paper';
import { ScrollView, ActivityIndicator, StyleSheet, SafeAreaView, FlatList, View } from 'react-native'; import { ScrollView, ActivityIndicator, StyleSheet, SafeAreaView, View } from 'react-native';
import { useIsFocused } from "@react-navigation/native";
import API from "../API"; import API from "../API";
import CourseCard from "../components/CourseCard"; import CourseCard from "../components/CourseCard";
import { useSnapshot } from 'valtio'; import { useSnapshot } from 'valtio';
import GlobalState from '../contexts/GlobalState.js'; import GlobalState from '../contexts/GlobalState.js';
import i18n from "../i18nMessages.js"; import i18n from "../i18nMessages.js";
import AsyncStorage from '@react-native-async-storage/async-storage';
const getCourses = async (profileObj) => { const COURSES_LOG_PREFIX = '[Courses]';
let courses; const logCourses = (...args) => {
let popular; if (__DEV__) console.log(COURSES_LOG_PREFIX, ...args);
};
const emptyCoursesData = {
courses: [],
popular: [],
watching: [],
};
const getCourses = async (profileObj = {}) => {
let courses = [];
let popular = [];
logCourses('getCourses:start', {
viewerId: profileObj?._id || null,
hasViewerData: !!profileObj?.data,
});
await API.getCourses().then((data) => { await API.getCourses().then((data) => {
courses = data.groups; const groups = Array.isArray(data?.groups) ? data.groups : [];
popular = [...data.groups].sort((a, b) => { courses = groups;
popular = [...groups].sort((a, b) => {
return Object.keys(b.subscribed).length - Object.keys(a.subscribed).length; return Object.keys(b.subscribed).length - Object.keys(a.subscribed).length;
}); });
logCourses('getCourses:groups', {
groups: groups.length,
popular: popular.length,
});
}); });
let watching = {}; let watching = {};
let watchingProms = []; let watchingProms = [];
Object.keys(profileObj.data).forEach((videoId) => { let watchingStats = {
if (profileObj.data[videoId].profileId) { rawEntries: 0,
let profileId = profileObj.data[videoId].profileId; explicitProfileId: 0,
watchingProms.push(API.getUserProfile(profileId).then(profile => { fallbackByPost: 0,
if (!profile.isCourse) skippedNoProgress: 0,
return 0; skippedNoProfileId: 0,
if (!watching[profileId]) courseProfilesResolved: 0,
watching[profileId] = { profile, progress: [], mostRecent: 0 }; };
if (watching[profileId].mostRecent < profileObj.data[videoId].ts) const pushWatchingProgress = (profile, progress, profileId) => {
watching[profileId].mostRecent = profileObj.data[videoId].ts; if (!profile?.isCourse) return;
watching[profileId].progress.push(profileObj.data[videoId]); if (!watching[profileId]) {
})); watching[profileId] = { profile, progress: [], mostRecent: 0 };
} }
if (watching[profileId].mostRecent < (progress?.ts || 0)) {
watching[profileId].mostRecent = progress.ts || 0;
}
watching[profileId].progress.push(progress);
watchingStats.courseProfilesResolved += 1;
};
let viewerData = {};
try {
// Detach potential proxy/non-extensible objects before iterating keys.
viewerData = JSON.parse(JSON.stringify(profileObj?.data || {}));
} catch (error) {
viewerData = {};
}
const viewerKeys = Object.keys(viewerData);
logCourses('watching:viewerData', {
entries: viewerKeys.length,
sampleKeys: viewerKeys.slice(0, 5),
});
Object.keys(viewerData).forEach((videoId) => {
watchingStats.rawEntries += 1;
const progress = viewerData[videoId];
if (!progress) {
watchingStats.skippedNoProgress += 1;
return;
}
const explicitProfileId = progress.profileId || progress.profileid;
if (explicitProfileId) {
watchingStats.explicitProfileId += 1;
watchingProms.push(API.getUserProfile(explicitProfileId).then((profile) => {
pushWatchingProgress(profile, progress, explicitProfileId);
}));
return;
}
// Backward compatibility: old progress entries may not include profileId.
const postId = progress.postId || videoId;
if (!postId) {
watchingStats.skippedNoProfileId += 1;
return;
}
watchingStats.fallbackByPost += 1;
watchingProms.push(
API.getPost(postId).then((post) => {
const fallbackProfileId = post?.profileid;
if (!fallbackProfileId) return 0;
return API.getUserProfile(fallbackProfileId).then((profile) => {
pushWatchingProgress(profile, progress, fallbackProfileId);
});
})
);
}); });
let watchingArray = []; let watchingArray = [];
await Promise.all(watchingProms).then(() => { await Promise.all(watchingProms).then(() => {
@@ -41,6 +114,10 @@ const getCourses = async (profileObj) => {
return b.mostRecent - a.mostRecent; return b.mostRecent - a.mostRecent;
}); });
}); });
logCourses('watching:resolved', {
...watchingStats,
uniqueCourses: watchingArray.length,
});
return { return {
courses: courses.slice(0, 10), courses: courses.slice(0, 10),
popular: popular.slice(0, 10), popular: popular.slice(0, 10),
@@ -62,14 +139,16 @@ const getCoursesCache = async () => {
if (value !== null) { if (value !== null) {
return JSON.parse(value); return JSON.parse(value);
} }
return emptyCoursesData;
} catch (e) { } catch (e) {
return [] return emptyCoursesData;
} }
} }
const Courses = () => { const Courses = () => {
const gState = useSnapshot(GlobalState); const gState = useSnapshot(GlobalState);
const viewer = gState.me; const viewer = gState.me;
const isFocused = useIsFocused();
const [searchQuery, setSearchQuery] = React.useState(''); const [searchQuery, setSearchQuery] = React.useState('');
const [groups, setGroups] = React.useState([]); const [groups, setGroups] = React.useState([]);
const [popular, setPopular] = React.useState([]); const [popular, setPopular] = React.useState([]);
@@ -77,16 +156,26 @@ const Courses = () => {
const [queryTimer, setQueryTimer] = React.useState(0); const [queryTimer, setQueryTimer] = React.useState(0);
useEffect(() => { useEffect(() => {
if (!isFocused) return;
let subscribed = true; let subscribed = true;
const getData = async () => { const getData = async () => {
await getCoursesCache().then((r) => { const cached = await getCoursesCache();
console.log("Courses Cache"); logCourses('cache:read', {
setGroups(r.courses || []); courses: Array.isArray(cached?.courses) ? cached.courses.length : 0,
setPopular(r.popular || []); popular: Array.isArray(cached?.popular) ? cached.popular.length : 0,
setWatching(r.watching || []); watching: Array.isArray(cached?.watching) ? cached.watching.length : 0,
}); });
if (subscribed) {
setGroups(Array.isArray(cached?.courses) ? cached.courses : []);
setPopular(Array.isArray(cached?.popular) ? cached.popular : []);
setWatching(Array.isArray(cached?.watching) ? cached.watching : []);
}
let r = await getCourses(viewer); let r = await getCourses(viewer);
console.log("Courses Live"); logCourses('live:resolved', {
courses: Array.isArray(r?.courses) ? r.courses.length : 0,
popular: Array.isArray(r?.popular) ? r.popular.length : 0,
watching: Array.isArray(r?.watching) ? r.watching.length : 0,
});
if(subscribed){ if(subscribed){
setGroups(r.courses || []); setGroups(r.courses || []);
setPopular(r.popular || []); setPopular(r.popular || []);
@@ -98,7 +187,7 @@ const Courses = () => {
return () => { return () => {
subscribed = false; subscribed = false;
} }
}, []) }, [isFocused, viewer?._id])
const onChangeSearch = query => { const onChangeSearch = query => {
setSearchQuery(query); setSearchQuery(query);
@@ -116,15 +205,34 @@ const Courses = () => {
}, 300); }, 300);
setQueryTimer(timerId); setQueryTimer(timerId);
}; };
const renderProfile = (({ item }) => { const renderCourseCards = (items = [], twoCols = false) => {
return (<CourseCard profileObj={item} />); return items.map((item, index) => (
}); <CourseCard
const watchingCourse = (({ item }) => { key={item?._id || item?.profile?._id || `course-${index}`}
return (<CourseCard profileObj={item.profile} />); profileObj={item}
}); twoCols={twoCols}
const renderCoursesQuery = (({ item }) => { />
return (<CourseCard profileObj={item} twoCols={true}/>); ));
}); };
const renderWatchingCards = (items = []) => {
return items.map((item, index) => (
<CourseCard
key={item?.profile?._id || item?._id || `watching-${index}`}
profileObj={item?.profile}
/>
));
};
useEffect(() => {
logCourses('render', {
searchQueryLength: searchQuery.length,
groups: groups.length,
popular: popular.length,
watching: watching.length,
showWatching: searchQuery.length === 0 && watching.length > 0,
});
}, [searchQuery, groups.length, popular.length, watching.length]);
return ( return (
<SafeAreaView style={styles.container}> <SafeAreaView style={styles.container}>
<Searchbar <Searchbar
@@ -138,53 +246,34 @@ const Courses = () => {
(searchQuery.length === 0 && watching.length) ? (searchQuery.length === 0 && watching.length) ?
<View> <View>
<Title style={styles.title} >{i18n.t("message.continueWatching")}:</Title> <Title style={styles.title} >{i18n.t("message.continueWatching")}:</Title>
<FlatList <ScrollView horizontal={true} showsHorizontalScrollIndicator={false}>
horizontal={true} {renderWatchingCards(watching)}
data={watching} </ScrollView>
renderItem={watchingCourse}
keyExtractor={item => item.profile._id}
initialNumToRender={2}
/>
</View> : <></> </View> : <></>
} }
{ {
(searchQuery.length === 0 && groups.length) ? (searchQuery.length === 0 && groups.length) ?
<> <>
<Title style={styles.title} >{i18n.t("message.recentlyAdded")}:</Title> <Title style={styles.title} >{i18n.t("message.recentlyAdded")}:</Title>
<FlatList <ScrollView horizontal={true} showsHorizontalScrollIndicator={false}>
horizontal={true} {renderCourseCards(groups)}
data={groups} </ScrollView>
renderItem={renderProfile}
keyExtractor={item => item._id}
initialNumToRender={2}
/>
</> : <></> </> : <></>
} }
{ {
(searchQuery.length === 0 && popular.length) ? (searchQuery.length === 0 && popular.length) ?
<> <>
<Title style={styles.title} >{i18n.t("message.popularCourses")}:</Title> <Title style={styles.title} >{i18n.t("message.popularCourses")}:</Title>
<FlatList <ScrollView horizontal={true} showsHorizontalScrollIndicator={false}>
horizontal={true} {renderCourseCards(popular)}
data={popular} </ScrollView>
renderItem={renderProfile}
keyExtractor={item => item._id}
initialNumToRender={2}
/>
</> : <></> </> : <></>
} }
{ {
(searchQuery.length !== 0 && groups.length) ? (searchQuery.length !== 0 && groups.length) ?
<> <View style={styles.searchGrid}>
<FlatList {renderCourseCards(groups, true)}
//horizontal={true} </View> : <></>
data={groups}
numColumns={2}
renderItem={renderCoursesQuery}
keyExtractor={item => item._id}
initialNumToRender={8}
/>
</> : <></>
} }
</ScrollView> </ScrollView>
</SafeAreaView> </SafeAreaView>
@@ -203,5 +292,11 @@ const styles = StyleSheet.create({
marginTop: 15, marginTop: 15,
fontWeight: "bold", fontWeight: "bold",
color: "#777" color: "#777"
},
searchGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
justifyContent: 'space-between',
paddingHorizontal: 4,
} }
}); });
+84 -11
View File
@@ -1,5 +1,6 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { StyleSheet, SafeAreaView, FlatList } from 'react-native'; import { StyleSheet, SafeAreaView, FlatList } from 'react-native';
import { Card, Text, Button } from 'react-native-paper';
import API from './../API.js'; import API from './../API.js';
import Post from './../components/Post.js'; import Post from './../components/Post.js';
import PostPopularUsers from '../components/PostPopularUsers.js'; import PostPopularUsers from '../components/PostPopularUsers.js';
@@ -7,9 +8,16 @@ import GlobalState from '../contexts/GlobalState.js';
import * as Linking from 'expo-linking'; import * as Linking from 'expo-linking';
import { posthog } from './../PostHog.js'; import { posthog } from './../PostHog.js';
import * as Updates from 'expo-updates'; import * as Updates from 'expo-updates';
import { useSnapshot } from 'valtio';
import i18n from "../i18nMessages.js";
import AsyncStorage from '@react-native-async-storage/async-storage'; import AsyncStorage from '@react-native-async-storage/async-storage';
const FEED_LOG_PREFIX = '[Feed]';
const logFeed = (...args) => {
if (__DEV__) console.log(FEED_LOG_PREFIX, ...args);
};
const storeFeed = async (value) => { const storeFeed = async (value) => {
try { try {
const jsonValue = JSON.stringify(value) const jsonValue = JSON.stringify(value)
@@ -49,6 +57,7 @@ const handleURL = (url, navigation) => {
} }
async function onFetchUpdateAsync() { async function onFetchUpdateAsync() {
if (__DEV__) return;
try { try {
const update = await Updates.checkForUpdateAsync(); const update = await Updates.checkForUpdateAsync();
@@ -63,38 +72,52 @@ async function onFetchUpdateAsync() {
} }
let Feed = ({ navigation, route }) => { let Feed = ({ navigation, route }) => {
const gState = useSnapshot(GlobalState);
const viewer = gState.me || {};
let [Posts, setPosts] = useState([]); let [Posts, setPosts] = useState([]);
const flatListRef = React.useRef() const flatListRef = React.useRef()
console.log("Render Feed"); logFeed('render', {
posts: Posts.length,
hasRouteParams: !!route?.params,
reRender: !!route?.params?.reRender,
targetProfile: route?.params?.profileid || null,
});
const url = Linking.useURL(); const url = Linking.useURL();
useEffect(() => { useEffect(() => {
if (prevLink === url || !url) return; if (prevLink === url || !url) return;
prevLink = url; prevLink = url;
logFeed('deep-link', url);
handleURL(url, navigation); handleURL(url, navigation);
}, [url]); }, [url]);
useEffect(() => { useEffect(() => {
let subscribed = true; let subscribed = true;
const getData = async () => { const getData = async () => {
logFeed('load:start', {
reRender: !!route?.params?.reRender,
targetProfile: route?.params?.profileid || null,
});
// TODO: Check for internet connection // TODO: Check for internet connection
const internet = true; const internet = true;
if (internet) { if (internet) {
//byPass and load //byPass and load
let loggedIn = await API.isLoggedIn(); let loggedIn = await API.isLoggedIn();
logFeed('session:checked', { loggedIn });
if (!loggedIn) return navigation.reset({ if (!loggedIn) return navigation.reset({
index: 0, index: 0,
routes: [{ name: 'Login' }], routes: [{ name: 'Login' }],
}); });
if (route.params && route.params.profileid) { if (route.params && route.params.profileid) {
logFeed('redirect:profile', { profileid: route.params.profileid });
return navigation.navigate('Profile', { profileid: route.params.profileid }); return navigation.navigate('Profile', { profileid: route.params.profileid });
} }
} }
if (!route.params?.reRender) { if (!route.params?.reRender) {
API.getMe().then((me) => { API.getMe().then((me) => {
if (subscribed) { if (subscribed && me && me._id) {
GlobalState.me = me; GlobalState.me = me;
posthog.identify(me.userid, posthog.identify(me.userid,
{ {
name: me.profile.firstName, name: me.profile?.firstName,
profileid: me._id, profileid: me._id,
is_superuser: me.superuser, is_superuser: me.superuser,
} }
@@ -102,23 +125,25 @@ let Feed = ({ navigation, route }) => {
posthog.capture('login'); posthog.capture('login');
} }
}); });
console.log("Feed from cache")
let cacheFeed = await getFeed() || []; let cacheFeed = await getFeed() || [];
logFeed('cache:read', { count: cacheFeed.length });
if (cacheFeed.length && subscribed) setPosts(cacheFeed); if (cacheFeed.length && subscribed) setPosts(cacheFeed);
console.log("Feed from server")
} }
await onFetchUpdateAsync(); await onFetchUpdateAsync();
flatListRef.current.scrollToOffset({ animated: true, offset: 0 }) flatListRef.current?.scrollToOffset({ animated: true, offset: 0 })
let posts = await API.getPosts(); let posts = await API.getPosts();
if (subscribed) { if (subscribed) {
setPosts(posts); const safePosts = Array.isArray(posts) ? posts : [];
storeFeed(posts); setPosts(safePosts);
storeFeed(safePosts);
logFeed('network:loaded', { count: safePosts.length, cached: true });
} }
console.log("Feed, end useEffect") logFeed('load:end');
} }
getData() getData()
return () => { return () => {
subscribed = false; subscribed = false;
logFeed('load:cleanup');
} }
}, [route.params]); }, [route.params]);
const renderPost = (({ item }) => { const renderPost = (({ item }) => {
@@ -131,17 +156,47 @@ let Feed = ({ navigation, route }) => {
return (<Post post={item} />); return (<Post post={item} />);
}); });
const missingProfilePhoto = !viewer?.profile?.photo;
const missingDescription = !String(viewer?.profile?.description || '').trim();
const shouldShowProfileNudge = missingProfilePhoto || missingDescription;
const renderProfileNudge = () => {
if (!shouldShowProfileNudge) return null;
return (
<Card style={styles.profileNudgeCard}>
<Card.Content>
<Text style={styles.profileNudgeTitle}>{i18n.t("message.completeProfileTitle")}</Text>
<Text style={styles.profileNudgeBody}>{i18n.t("message.completeProfileBody")}</Text>
<Text style={styles.profileNudgeHint}>
{i18n.t("message.completeProfileMissingHint")}
</Text>
</Card.Content>
<Card.Actions>
<Button mode="contained" onPress={() => navigation.navigate("ProfileSettings")}>
{i18n.t("message.updateProfile")}
</Button>
</Card.Actions>
</Card>
);
};
return ( return (
<SafeAreaView> <SafeAreaView>
<FlatList <FlatList
data={Posts} data={Posts}
renderItem={renderPost} renderItem={renderPost}
keyExtractor={item => item.lastUpdated || item._id || item.ceatedAt} //This may refresh the component ListHeaderComponent={renderProfileNudge}
keyExtractor={item => item.lastUpdated || item._id || item.createdAt} //This may refresh the component
//ListHeaderComponent={<NewPost newPostCB={(newPost) => setPosts([newPost, ...Posts])} />} //ListHeaderComponent={<NewPost newPostCB={(newPost) => setPosts([newPost, ...Posts])} />}
refreshing={Posts.length === 0} refreshing={Posts.length === 0}
onRefresh={() => { onRefresh={() => {
API.getPosts().then(setPosts); logFeed('refresh:start');
API.getPosts().then((data) => {
const safePosts = Array.isArray(data) ? data : [];
setPosts(safePosts);
storeFeed(safePosts);
logFeed('refresh:end', { count: safePosts.length, cached: true });
});
}} }}
initialNumToRender={3} initialNumToRender={3}
maxToRenderPerBatch={3} maxToRenderPerBatch={3}
@@ -159,4 +214,22 @@ const styles = StyleSheet.create({
container: { container: {
backgroundColor: "#edf2f7", backgroundColor: "#edf2f7",
}, },
profileNudgeCard: {
marginHorizontal: 10,
marginTop: 10,
marginBottom: 6,
},
profileNudgeTitle: {
fontSize: 18,
fontWeight: "700",
marginBottom: 4,
},
profileNudgeBody: {
color: "#4b5563",
},
profileNudgeHint: {
marginTop: 8,
color: "#6b7280",
fontSize: 12,
},
}); });
+436
View File
@@ -0,0 +1,436 @@
import React from "react";
import { View, FlatList, Pressable, Animated, PanResponder } from "react-native";
import { Text, TextInput } from "react-native-paper";
import { useFocusEffect } from "@react-navigation/native";
import MaterialIcons from "react-native-vector-icons/MaterialIcons";
import { WebView } from "react-native-webview";
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;
const getYouTubeVideoIdFromText = (text = "") => {
const matches = text.match(/https?:\/\/[^\s]+/gi) || [];
for (const match of matches) {
const rawUrl = match.replace(/[),.;!?]+$/, "");
try {
const parsed = new URL(rawUrl);
const host = parsed.hostname.replace(/^www\./, "");
if (host === "youtu.be") {
const shortId = (parsed.pathname || "").replace("/", "").split("/")[0] || "";
if (shortId) return shortId;
}
if (host === "youtube.com" || host === "m.youtube.com") {
const watchId = parsed.searchParams.get("v");
if (watchId) return watchId;
const pathParts = (parsed.pathname || "").split("/").filter(Boolean);
if (pathParts[0] === "shorts" || pathParts[0] === "embed") {
if (pathParts[1]) return pathParts[1];
}
}
} catch (error) {
continue;
}
}
return "";
};
const CircleIconAction = ({ icon, onPress, color = "#6b7280", disabled = false }) => (
<Pressable
onPress={onPress}
disabled={disabled}
style={({ pressed }) => ({
width: 36,
height: 36,
borderRadius: 18,
alignItems: "center",
justifyContent: "center",
backgroundColor: disabled ? "#f3f4f6" : (pressed ? "#e5e7eb" : "#f9fafb"),
marginHorizontal: 2,
transform: [{ scale: pressed ? 0.96 : 1 }],
})}
>
<MaterialIcons name={icon} size={20} color={disabled ? "#9ca3af" : color} />
</Pressable>
);
const ChatMessage = ({ item, myProfileId, showOriginal, onPressIn, onPressOut }) => {
const isMine = item?.senderProfileId === myProfileId;
const isTranslated = !!(item?.textOriginal && item?.text && 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 (
<View style={{ marginBottom: 12, alignItems: isMine ? "flex-end" : "flex-start" }}>
<Pressable
onPressIn={isTranslated ? onPressIn : undefined}
onPressOut={isTranslated ? onPressOut : undefined}
style={{
maxWidth: "85%",
backgroundColor: isMine ? "#0d6efd" : "#f1f3f5",
borderRadius: 12,
paddingHorizontal: 12,
paddingVertical: 8,
}}
>
<Text style={{ color: isMine ? "#fff" : "#3f3f46", fontSize: 12, marginBottom: 4 }}>
{item?.senderName || "Anonymous"}
</Text>
<Text style={{ color: isMine ? "#fff" : "#111827", fontSize: 15 }}>
{messageText || ""}
</Text>
{isTranslated ? (
<Text style={{ color: isMine ? "#dbeafe" : "#6b7280", fontSize: 11, marginTop: 4 }}>
* {i18n.t("message.aiTranslated")}
</Text>
) : <></>}
<Text style={{ color: isMine ? "#dbeafe" : "#6b7280", fontSize: 11, marginTop: 4 }}>
{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={{
marginTop: 6,
width: "85%",
minHeight: 210,
borderRadius: 12,
overflow: "hidden",
borderWidth: 1,
borderColor: "#e5e7eb",
}}
>
<WebView
style={{ flex: 1, minHeight: 210 }}
source={{
uri: `https://www.youtube.com/embed/${youtubeVideoId}?fs=0`,
headers: {
Referer: "https://social.emmint.com",
"Referrer-Policy": "strict-origin-when-cross-origin",
},
}}
/>
</View>
) : <></>}
</View>
);
};
let GlobalChat = ({ navigation }) => {
const gState = useSnapshot(GlobalState);
const myProfileId = gState?.me?._id || "";
const [messages, setMessages] = React.useState([]);
const [activeUsers, setActiveUsers] = React.useState([]);
const [text, setText] = React.useState("");
const [loading, setLoading] = React.useState(true);
const [sending, setSending] = React.useState(false);
const [pressedMessageId, setPressedMessageId] = React.useState("");
const [isOnlinePanelOpen, setIsOnlinePanelOpen] = React.useState(false);
const [isAtBottom, setIsAtBottom] = React.useState(true);
const panelX = React.useRef(new Animated.Value(-PANEL_WIDTH)).current;
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;
listRef.current.scrollToEnd({ animated });
}, []);
const runInitialScrollIfNeeded = React.useCallback(() => {
if (!messages.length || hasDoneInitialScrollRef.current) return;
hasDoneInitialScrollRef.current = true;
requestAnimationFrame(() => scrollToBottom(false));
setTimeout(() => scrollToBottom(false), 180);
setIsAtBottom(true);
}, [messages.length, scrollToBottom]);
const loadMessages = React.useCallback(async () => {
const data = await API.getChatMessages(100);
setMessages(Array.isArray(data) ? data : []);
}, []);
const refreshPresence = React.useCallback(async () => {
const users = await API.pingChatPresence();
setActiveUsers(Array.isArray(users) ? users : []);
}, []);
React.useEffect(() => {
let mounted = true;
const bootstrap = async () => {
setLoading(true);
const [initialMessages, users] = await Promise.all([
API.getChatMessages(100),
API.pingChatPresence(),
]);
if (!mounted) return;
setMessages(Array.isArray(initialMessages) ? initialMessages : []);
setActiveUsers(Array.isArray(users) ? users : []);
setLoading(false);
};
bootstrap();
const refreshInterval = setInterval(() => {
loadMessages();
refreshPresence();
}, 8000);
const pingInterval = setInterval(() => {
refreshPresence();
}, 25000);
return () => {
mounted = false;
clearInterval(refreshInterval);
clearInterval(pingInterval);
};
}, [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;
setSending(true);
const response = await API.sendChatMessage(trimmedText);
if (response?.status === "ok" && response?.message) {
setMessages((prev) => [...prev, response.message]);
if (Array.isArray(response.activeUsers)) {
setActiveUsers(response.activeUsers);
}
setText("");
}
setSending(false);
};
const openOnlinePanel = React.useCallback(() => {
setIsOnlinePanelOpen(true);
Animated.parallel([
Animated.timing(panelX, {
toValue: 0,
duration: 220,
useNativeDriver: true,
}),
Animated.timing(panelOpacity, {
toValue: 1,
duration: 220,
useNativeDriver: true,
}),
]).start();
}, [panelOpacity, panelX]);
const closeOnlinePanel = React.useCallback(() => {
Animated.parallel([
Animated.timing(panelX, {
toValue: -PANEL_WIDTH,
duration: 220,
useNativeDriver: true,
}),
Animated.timing(panelOpacity, {
toValue: 0,
duration: 220,
useNativeDriver: true,
}),
]).start(() => setIsOnlinePanelOpen(false));
}, [panelOpacity, panelX]);
useFocusEffect(
React.useCallback(() => () => {
setIsOnlinePanelOpen(false);
panelX.setValue(-PANEL_WIDTH);
panelOpacity.setValue(0);
}, [panelOpacity, panelX])
);
const panResponder = React.useMemo(() => PanResponder.create({
onMoveShouldSetPanResponder: (_, gesture) => {
const isHorizontal = Math.abs(gesture.dx) > 14 && Math.abs(gesture.dy) < 16;
if (!isHorizontal) return false;
if (!isOnlinePanelOpen && gesture.dx > 0) return true;
if (isOnlinePanelOpen && gesture.dx < 0) return true;
return false;
},
onPanResponderRelease: (_, gesture) => {
if (!isOnlinePanelOpen && gesture.dx > 45) {
openOnlinePanel();
return;
}
if (isOnlinePanelOpen && gesture.dx < -45) {
closeOnlinePanel();
}
},
}), [closeOnlinePanel, isOnlinePanelOpen, openOnlinePanel]);
React.useEffect(() => {
if (!messages.length) return;
if (!hasDoneInitialScrollRef.current) {
runInitialScrollIfNeeded();
return;
}
if (isAtBottom) {
requestAnimationFrame(() => scrollToBottom(true));
setTimeout(() => scrollToBottom(true), 120);
}
}, [isAtBottom, messages.length, runInitialScrollIfNeeded, scrollToBottom]);
const handleChatScroll = React.useCallback((event) => {
const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent || {};
const yOffset = contentOffset?.y || 0;
const contentHeight = contentSize?.height || 0;
const viewportHeight = layoutMeasurement?.height || 0;
const distanceFromBottom = contentHeight - (yOffset + viewportHeight);
setIsAtBottom(distanceFromBottom < 50);
}, []);
return (
<View style={{ flex: 1 }} {...panResponder.panHandlers}>
<View style={{ flex: 1, padding: 14 }}>
<View style={{ flexDirection: "row", justifyContent: "space-between", alignItems: "center", marginBottom: 12 }}>
<View style={{ flexDirection: "row", alignItems: "center" }}>
<Text style={{ color: "#dc2626", fontSize: 12, fontWeight: "700", marginRight: 6 }}>Beta</Text>
<Text style={{ fontSize: 22, fontWeight: "700" }}>{i18n.t("message.globalChat")}</Text>
</View>
<Pressable onPress={openOnlinePanel} hitSlop={8}>
<Text style={{ color: "#6b7280", fontSize: 14 }}>
{i18n.t("message.onlineNow")} ({activeUsers.length})
</Text>
</Pressable>
</View>
{loading ? (
<Text>{i18n.t("message.loadingChat")}</Text>
) : (
<FlatList
ref={listRef}
data={messages}
keyExtractor={(item, index) => item?._id?.toString?.() || `${index}-${item?.createdAt || "msg"}`}
renderItem={({ item }) => {
const messageId = item?._id?.toString?.() || "";
return (
<ChatMessage
item={item}
myProfileId={myProfileId}
showOriginal={pressedMessageId === messageId}
onPressIn={() => setPressedMessageId(messageId)}
onPressOut={() => setPressedMessageId("")}
/>
);
}}
onScroll={handleChatScroll}
onLayout={runInitialScrollIfNeeded}
onContentSizeChange={runInitialScrollIfNeeded}
scrollEventThrottle={16}
contentContainerStyle={{ paddingBottom: 10 }}
style={{ flex: 1 }}
/>
)}
<View
style={{
marginTop: 10,
borderWidth: 1,
borderColor: "#e5e7eb",
borderRadius: 28,
backgroundColor: "#ffffff",
paddingHorizontal: 4,
paddingVertical: 2,
flexDirection: "row",
alignItems: "flex-end",
shadowColor: "#111827",
shadowOpacity: 0.08,
shadowRadius: 10,
shadowOffset: { width: 0, height: 3 },
elevation: 2,
}}
>
<CircleIconAction icon="add" onPress={() => navigation.navigate("BiblePicker", { target: "chat" })} />
<View style={{ flex: 1, paddingRight: 2 }}>
<TextInput
mode="flat"
placeholder={i18n.t("message.writeMessage")}
value={text}
onChangeText={setText}
multiline
maxLength={500}
dense
underlineColor="transparent"
activeUnderlineColor="transparent"
style={{ backgroundColor: "transparent", maxHeight: 120 }}
contentStyle={{ paddingVertical: 10 }}
/>
</View>
<CircleIconAction icon="photo-camera" onPress={() => { }} />
<CircleIconAction
icon="send"
onPress={sendMessage}
disabled={sending || !(text || "").trim()}
color="#0d6efd"
/>
</View>
</View>
{isOnlinePanelOpen ? (
<Pressable
onPress={closeOnlinePanel}
style={{
position: "absolute",
top: 0,
right: 0,
bottom: 0,
left: 0,
backgroundColor: "rgba(17, 24, 39, 0.2)",
}}
/>
) : <></>}
<Animated.View
style={{
position: "absolute",
top: 0,
left: 0,
bottom: 0,
width: PANEL_WIDTH,
backgroundColor: "#ffffff",
borderRightWidth: 1,
borderRightColor: "#e5e7eb",
paddingHorizontal: 12,
paddingTop: 18,
opacity: panelOpacity,
transform: [{ translateX: panelX }],
}}
>
<Text style={{ fontSize: 16, fontWeight: "700", marginBottom: 12 }}>
{i18n.t("message.onlineNow")} ({activeUsers.length})
</Text>
<FlatList
data={activeUsers}
keyExtractor={(item, index) => item?.profileId || item?.displayName || `${index}`}
renderItem={({ item }) => (
<View style={{ paddingVertical: 8, borderBottomWidth: 1, borderBottomColor: "#f3f4f6" }}>
<Text style={{ color: "#111827" }}>{item?.displayName || "Anonymous"}</Text>
</View>
)}
ListEmptyComponent={<Text style={{ color: "#6b7280" }}>No users online</Text>}
/>
</Animated.View>
</View>
);
};
export default GlobalChat;
+59 -13
View File
@@ -1,24 +1,58 @@
import React, { useEffect, useRef } from "react"; import React, { useEffect, useRef } from "react";
import { Searchbar, Title, IconButton } from 'react-native-paper'; import { Searchbar, Title, IconButton } from 'react-native-paper';
import { StyleSheet, SafeAreaView, FlatList, View } from 'react-native'; import { StyleSheet, SafeAreaView, FlatList, View, ActivityIndicator } from 'react-native';
import API from "../API"; import API from "../API";
import GroupCard from "../components/GroupCard"; import GroupCard from "../components/GroupCard";
import AsyncStorage from '@react-native-async-storage/async-storage';
import i18n from "../i18nMessages.js";
const GROUPS_CACHE_KEY = 'groups_following';
const storeGroupsCache = async (value) => {
try {
const jsonValue = JSON.stringify(value || []);
await AsyncStorage.setItem(GROUPS_CACHE_KEY, jsonValue);
} catch (e) {
}
};
const getGroupsCache = async () => {
try {
const value = await AsyncStorage.getItem(GROUPS_CACHE_KEY);
if (value !== null) return JSON.parse(value);
return [];
} catch (e) {
return [];
}
};
const Groups = ({navigation}) => { const Groups = ({navigation}) => {
const [searchQuery, setSearchQuery] = React.useState(''); const [searchQuery, setSearchQuery] = React.useState('');
const [searchVisible, setSearchVisible] = React.useState(false); const [searchVisible, setSearchVisible] = React.useState(false);
const [groups, setGroups] = React.useState([]); const [groups, setGroups] = React.useState([]);
const [queryTimer, setQueryTimer] = React.useState(0); const [loading, setLoading] = React.useState(true);
const searchTextBox = useRef(null); const searchTextBox = useRef(null);
const queryTimer = useRef(null);
useEffect(() => { useEffect(() => {
let subscribed = true; let subscribed = true;
API.getFollowingGroups('').then((data) => { const getData = async () => {
if (subscribed) const cached = await getGroupsCache();
setGroups(data?.groups || []); if (subscribed) setGroups(Array.isArray(cached) ? cached : []);
}); API.getFollowingGroups('').then((data) => {
if (!subscribed) return;
const liveGroups = Array.isArray(data?.groups) ? data.groups : [];
setGroups(liveGroups);
storeGroupsCache(liveGroups);
setLoading(false);
}).catch(() => {
if (subscribed) setLoading(false);
});
};
getData();
return () => { return () => {
subscribed = false; subscribed = false;
if (queryTimer.current) clearTimeout(queryTimer.current);
} }
}, []) }, [])
@@ -26,19 +60,23 @@ const Groups = ({navigation}) => {
const onChangeSearch = query => { const onChangeSearch = query => {
setSearchQuery(query); setSearchQuery(query);
if (queryTimer) clearTimeout(queryTimer); if (queryTimer.current) clearTimeout(queryTimer.current);
let timerId = setTimeout(() => { setLoading(true);
queryTimer.current = setTimeout(() => {
if (!query) { if (!query) {
return API.getFollowingGroups('').then((data) => { return API.getFollowingGroups('').then((data) => {
setGroups(data.groups || []); const followingGroups = Array.isArray(data?.groups) ? data.groups : [];
setGroups(followingGroups);
storeGroupsCache(followingGroups);
setLoading(false);
}); });
} }
API.searchGroups(query).then((data) => { API.searchGroups(query).then((data) => {
setGroups(data.groups || []); setGroups(Array.isArray(data?.groups) ? data.groups : []);
setLoading(false);
}) })
}, 300); }, 300);
setQueryTimer(timerId);
}; };
const renderProfile = (({ item }) => { const renderProfile = (({ item }) => {
return (<GroupCard profileObj={item} />); return (<GroupCard profileObj={item} />);
@@ -76,7 +114,7 @@ const Groups = ({navigation}) => {
}} /> }} />
</View> : </View> :
<Searchbar <Searchbar
placeholder="Search Groups" placeholder={i18n.t("message.searchGroups")}
onChangeText={onChangeSearch} onChangeText={onChangeSearch}
value={searchQuery} value={searchQuery}
clearButtonMode="while-editing" clearButtonMode="while-editing"
@@ -88,11 +126,14 @@ const Groups = ({navigation}) => {
/> />
} }
</View> </View>
<Title style={styles.title} >{searchQuery ? "Results:" : "Your Groups:"}</Title> <Title style={styles.title} >{searchQuery ? i18n.t("message.results") : i18n.t("message.yourGroups")}</Title>
</> </>
} }
style={{backgroundColor: "#edf2f7",}} style={{backgroundColor: "#edf2f7",}}
/> />
{loading && !groups.length ? (
<ActivityIndicator style={styles.loader} />
) : <></>}
</SafeAreaView> </SafeAreaView>
) )
} }
@@ -110,5 +151,10 @@ const styles = StyleSheet.create({
marginTop: 15, marginTop: 15,
fontWeight: "bold", fontWeight: "bold",
color: "#777" color: "#777"
},
loader: {
position: "absolute",
alignSelf: "center",
top: 90,
} }
}); });
+4 -2
View File
@@ -72,10 +72,12 @@ let InviteView = ()=>{
onPress={()=>{setChecked(!checked)}} onPress={()=>{setChecked(!checked)}}
/> />
<Divider /> <Divider />
<Button mode="outlined" onPress={sendInvite}>Invite</Button> <Button mode="outlined" onPress={sendInvite}>
{i18n.t("message.invite")}
</Button>
</ImageBackground> </ImageBackground>
</View> </View>
) )
} }
export default InviteView; export default InviteView;
+208
View File
@@ -0,0 +1,208 @@
import React from "react";
import { View } from "react-native";
import { Text, Chip, Menu, Button } from "react-native-paper";
import API from "../API.js";
import i18n from "../i18nMessages.js";
const POLL_MS = 1800;
const CAPTION_META_KEYS = new Set(["sequence", "createdAt", "sourceLang", "original", "translations"]);
const toLangLabel = (lang = "") => {
const normalized = String(lang || "").toLowerCase();
if (!normalized || normalized === "original") return i18n.t("message.originalLanguage");
if (normalized === "en") return i18n.t("message.languageEnglish");
if (normalized === "es") return i18n.t("message.languageSpanish");
if (normalized === "fr") return i18n.t("message.languageFrench");
if (normalized === "da") return i18n.t("message.languageDanish");
return normalized.toUpperCase();
};
const inferSourceLang = (item) => {
if (!item || typeof item !== "object") return "";
if (item.sourceLang) return String(item.sourceLang).toLowerCase();
const original = typeof item.original === "string" ? item.original.trim() : "";
if (!original) return "";
const direct = Object.entries(item).find(([key, value]) => !CAPTION_META_KEYS.has(key) && typeof value === "string" && value.trim() === original);
if (direct?.[0]) return String(direct[0]).toLowerCase();
const nested = Object.entries(item.translations || {}).find(([_, value]) => typeof value === "string" && value.trim() === original);
return nested?.[0] ? String(nested[0]).toLowerCase() : "";
};
const getTextForLang = (item, lang) => {
if (!item || typeof item !== "object") return "";
const normalizedLang = String(lang || "").toLowerCase();
if (!normalizedLang || normalizedLang === "original") return item.original || "";
const direct = typeof item[normalizedLang] === "string" ? item[normalizedLang].trim() : "";
if (direct) return direct;
const nested = typeof item?.translations?.[normalizedLang] === "string" ? item.translations[normalizedLang].trim() : "";
if (nested) return nested;
return "";
};
const CaptionRow = ({ item, selectedLang }) => {
const translation = getTextForLang(item, selectedLang);
const sourceLang = inferSourceLang(item);
const displayTranslation = translation || item?.original || "";
const isFallback = !translation && selectedLang && selectedLang !== "original" && selectedLang !== sourceLang;
return (
<View style={{ flex: 1, backgroundColor: "#fff", borderRadius: 14, padding: 18, borderWidth: 1, borderColor: "#e5e7eb" }}>
<Text style={{ color: "#6b7280", fontSize: 11, marginBottom: 4 }}>
{sourceLang ? `${i18n.t("message.originalLanguage")}: ${toLangLabel(sourceLang)}` : i18n.t("message.originalLanguage")}
</Text>
<Text style={{ fontSize: 28, fontWeight: "700", color: "#111827", lineHeight: 36 }}>
{item?.original || ""}
</Text>
<Text style={{ color: "#6b7280", fontSize: 11, marginTop: 8, marginBottom: 4 }}>
{i18n.t("message.selectedTranslation")}: {toLangLabel(selectedLang)}
</Text>
<Text style={{ fontSize: 24, color: "#1f2937", lineHeight: 32 }}>
{displayTranslation}
</Text>
{isFallback ? (
<Text style={{ fontSize: 11, color: "#6b7280", marginTop: 6 }}>
{i18n.t("message.translationFallback")}
</Text>
) : <></>}
</View>
);
};
const LiveCaptions = () => {
const [captions, setCaptions] = React.useState([]);
const [availableLanguages, setAvailableLanguages] = React.useState([]);
const [selectedLang, setSelectedLang] = React.useState((i18n?.locale || "en").toLowerCase());
const [isLoading, setIsLoading] = React.useState(true);
const [langMenuVisible, setLangMenuVisible] = React.useState(false);
const latestSequenceRef = React.useRef(0);
const refresh = React.useCallback(async (initial = false) => {
const sinceSequence = initial ? undefined : latestSequenceRef.current;
const response = await API.getLiveCaptions(sinceSequence, 50);
const incoming = Array.isArray(response?.captions) ? response.captions : [];
const languages = Array.isArray(response?.availableLanguages) ? response.availableLanguages : [];
if (initial) {
setCaptions(incoming);
} else if (incoming.length > 0) {
setCaptions((prev) => {
const bySequence = new Map(prev.map((item) => [item.sequence, item]));
incoming.forEach((item) => {
if (!item || !Number.isFinite(item.sequence)) return;
bySequence.set(item.sequence, item);
});
return Array.from(bySequence.values()).sort((a, b) => (a.sequence || 0) - (b.sequence || 0)).slice(-150);
});
}
if (Number.isFinite(response?.latestSequence)) {
latestSequenceRef.current = response.latestSequence;
} else if (incoming.length) {
const maxSeq = incoming.reduce((acc, item) => Math.max(acc, Number(item?.sequence) || 0), latestSequenceRef.current);
latestSequenceRef.current = maxSeq;
}
setAvailableLanguages(languages);
setIsLoading(false);
}, []);
React.useEffect(() => {
refresh(true);
const interval = setInterval(() => {
refresh(false);
}, POLL_MS);
return () => clearInterval(interval);
}, [refresh]);
const latestCaption = captions.length ? captions[captions.length - 1] : null;
const extractLangsFromCaption = React.useCallback((caption) => {
const langs = new Set();
if (!caption || typeof caption !== "object") return langs;
if (caption.sourceLang) langs.add(String(caption.sourceLang).toLowerCase());
const map = caption.translations;
if (map && typeof map === "object" && !Array.isArray(map)) {
Object.keys(map).forEach((lang) => {
if (lang) langs.add(String(lang).toLowerCase());
});
}
// Backward/alternate payload support: languages at top-level (en, fr, es, etc.)
Object.entries(caption).forEach(([key, value]) => {
if (CAPTION_META_KEYS.has(key)) return;
if (typeof value !== "string" || !value.trim()) return;
const lang = String(key || "").toLowerCase().trim();
if (!lang) return;
langs.add(lang);
});
return langs;
}, []);
const languageOptions = React.useMemo(() => {
const unique = new Set(["original", ...availableLanguages]);
captions.forEach((caption) => {
const langs = extractLangsFromCaption(caption);
langs.forEach((lang) => unique.add(lang));
});
return Array.from(unique).filter((lang) => !!lang && lang !== "sourceLang" && lang !== "translations");
}, [availableLanguages, captions, extractLangsFromCaption]);
React.useEffect(() => {
if (languageOptions.includes(selectedLang)) return;
const appLang = (i18n?.locale || "en").toLowerCase();
if (languageOptions.includes(appLang)) {
setSelectedLang(appLang);
return;
}
if (languageOptions.includes("en")) {
setSelectedLang("en");
return;
}
if (languageOptions.length) setSelectedLang(languageOptions[0]);
}, [languageOptions, selectedLang]);
return (
<View style={{ flex: 1, padding: 14 }}>
<View style={{ flexDirection: "row", justifyContent: "space-between", alignItems: "center", marginBottom: 10 }}>
<View>
<Text style={{ fontSize: 24, fontWeight: "700" }}>{i18n.t("message.liveCaptions")}</Text>
<Text style={{ color: "#6b7280" }}>{i18n.t("message.liveCaptionsSubtitle")}</Text>
</View>
<Chip icon="circle" compact style={{ backgroundColor: "#ecfdf5" }} textStyle={{ color: "#047857" }}>
{i18n.t("message.liveNow")}
</Chip>
</View>
<View style={{ marginBottom: 12 }}>
<Text style={{ marginBottom: 6 }}>{i18n.t("message.translationLanguage")}</Text>
<Menu
visible={langMenuVisible}
onDismiss={() => setLangMenuVisible(false)}
anchor={
<Button mode="outlined" onPress={() => setLangMenuVisible(true)}>
{toLangLabel(selectedLang)}
</Button>
}
>
{languageOptions.map((lang) => (
<Menu.Item
key={lang}
title={toLangLabel(lang)}
onPress={() => {
setSelectedLang(lang);
setLangMenuVisible(false);
}}
/>
))}
</Menu>
</View>
{isLoading ? <Text>{i18n.t("message.loadingLiveCaptions")}</Text> : <></>}
{!isLoading && !latestCaption ? <Text>{i18n.t("message.awaitingLiveCaptions")}</Text> : <></>}
{!isLoading && latestCaption ? <CaptionRow item={latestCaption} selectedLang={selectedLang} /> : <></>}
</View>
);
};
export default LiveCaptions;
+4 -2
View File
@@ -12,6 +12,8 @@ export default function App({ navigation, route }) {
useEffect(() => { useEffect(() => {
getData = async () => { getData = async () => {
const tokenFromRoute = typeof route?.params?.token === "string" ? route.params.token.trim() : "";
if (tokenFromRoute) return;
let r = await API.isLoggedIn(); let r = await API.isLoggedIn();
if (r) { if (r) {
await API.logout(); await API.logout();
@@ -22,7 +24,7 @@ export default function App({ navigation, route }) {
return () => { return () => {
} }
}, []); }, [route?.params?.token]);
return ( return (
<SafeAreaView style={styles.container}> <SafeAreaView style={styles.container}>
@@ -30,7 +32,7 @@ export default function App({ navigation, route }) {
style={styles.logo} style={styles.logo}
source={require('./../assets/icon.png')} source={require('./../assets/icon.png')}
/> />
<Text style={styles.header}>EMI Fellowship</Text> <Text style={styles.header}>{i18n.t("message.appName")}</Text>
<View style={{ flexDirection: "row", justifyContent: "center", width: "100%" }}> <View style={{ flexDirection: "row", justifyContent: "center", width: "100%" }}>
<Button disabled={isLogin} onPress={() => setIsLogin(true)}> <Button disabled={isLogin} onPress={() => setIsLogin(true)}>
{i18n.t("message.login")} {i18n.t("message.login")}
+51 -20
View File
@@ -1,6 +1,6 @@
import React from "react"; import React from "react";
import { View, ImageBackground, ScrollView } from "react-native"; import { View, ImageBackground, ScrollView } from "react-native";
import { Text, List, RadioButton } from "react-native-paper"; import { Text, List, Menu, Button } from "react-native-paper";
import i18n from "../i18nMessages.js"; import i18n from "../i18nMessages.js";
import Moment from 'moment'; import Moment from 'moment';
import 'moment/min/locales'; import 'moment/min/locales';
@@ -9,11 +9,12 @@ import API from '../API.js';
import { useSnapshot } from 'valtio'; import { useSnapshot } from 'valtio';
import GlobalState from '../contexts/GlobalState.js'; import GlobalState from '../contexts/GlobalState.js';
import ProfileCardHorizontal from "../components/ProfileCardHorizontal.js"; import ProfileCardHorizontal from "../components/ProfileCardHorizontal.js";
import { reloadAppAsync } from "expo"; import * as Updates from 'expo-updates';
import AsyncStorage from '@react-native-async-storage/async-storage';
let MenuView = ({ navigation }) => { let MenuView = ({ navigation }) => {
const [value, setValue] = React.useState(i18n.locale); const [value, setValue] = React.useState(i18n.locale);
const [languageMenuVisible, setLanguageMenuVisible] = React.useState(false);
const [myProfiles, setMyProfiles] = React.useState([]); const [myProfiles, setMyProfiles] = React.useState([]);
const gState = useSnapshot(GlobalState); const gState = useSnapshot(GlobalState);
const viewer = gState.me; const viewer = gState.me;
@@ -23,7 +24,7 @@ let MenuView = ({ navigation }) => {
let getData = async () => { let getData = async () => {
const r = await API.getMyProfiles(); const r = await API.getMyProfiles();
if (!subscribed) return; if (!subscribed) return;
setMyProfiles(r.profiles); setMyProfiles(Array.isArray(r?.profiles) ? r.profiles : []);
} }
getData(); getData();
return () => { return () => {
@@ -31,24 +32,33 @@ let MenuView = ({ navigation }) => {
} }
}, []); }, []);
let changeLang = (locale) => { let changeLang = async (locale) => {
i18n.locale = locale; i18n.locale = locale;
Moment.locale(locale); Moment.locale(locale);
setValue(locale); setValue(locale);
await AsyncStorage.removeItem('feed');
//Change local on profile then reload //Change local on profile then reload
//reloadAppAsync();
} }
const languageOptions = [
{ value: "es", label: i18n.t("message.languageSpanish") },
{ value: "en", label: i18n.t("message.languageEnglish") },
{ value: "da", label: i18n.t("message.languageDanish") },
{ value: "fr", label: i18n.t("message.languageFrench") },
];
const currentLanguageLabel = languageOptions.find((opt) => opt.value === value)?.label || i18n.t("message.languageEnglish");
const profileLists = myProfiles.map((profile) => { const profileLists = myProfiles.map((profile) => {
const profileInfo = profile?.profile || {};
const DefaultPhoto = "https://social.emmint.com/uploads/e6f9be6d665dc43417701bf16a90122c.png"; const DefaultPhoto = "https://social.emmint.com/uploads/e6f9be6d665dc43417701bf16a90122c.png";
let photoUrl = profile.profile?.photo ? 'https://social.emmint.com/' + profile.profile.photo : DefaultPhoto; let photoUrl = profileInfo.photo ? 'https://social.emmint.com/' + profileInfo.photo : DefaultPhoto;
let icon = profile._id ? (!profile.isGroup ? "person-outline" : "group") : ''; let icon = profile._id ? (!profile.isGroup ? "person-outline" : "group") : '';
icon = icon === "person-outline" && profile.subscription && profile.subscription > (new Date() - 0) ? "assignment-ind" : icon; icon = icon === "person-outline" && profile.subscription && profile.subscription > (new Date() - 0) ? "assignment-ind" : icon;
icon = icon === "group" && profile.isCourse ? "subscriptions" : icon; icon = icon === "group" && profile.isCourse ? "subscriptions" : icon;
icon = icon === "group" && profile.isPrivate ? "screen-lock-portrait" : icon; icon = icon === "group" && profile.isPrivate ? "screen-lock-portrait" : icon;
return <> return <>
<List.Item <List.Item
title={profile.profile.firstName + " " + profile.profile.lastName} title={(profileInfo.firstName || "") + " " + (profileInfo.lastName || "")}
description={profile.profile.description} description={profileInfo.description || ""}
onPress={async () => { onPress={async () => {
await API.changeProfile(profile._id); await API.changeProfile(profile._id);
GlobalState.me = await API.getMe(); GlobalState.me = await API.getMe();
@@ -69,33 +79,54 @@ let MenuView = ({ navigation }) => {
> >
<ProfileCardHorizontal profileObj={viewer} skipFollow={true} skiptOnPress={true} key={viewer._id} /> <ProfileCardHorizontal profileObj={viewer} skipFollow={true} skiptOnPress={true} key={viewer._id} />
<List.Accordion <List.Accordion
title="Change Active Profile" title={i18n.t("message.changeActiveProfile")}
left={props => <List.Icon {...props} icon="published-with-changes" />} left={props => <List.Icon {...props} icon="published-with-changes" />}
> >
{profileLists} {profileLists}
</List.Accordion> </List.Accordion>
<List.Section title="User Actions"> <List.Section title={i18n.t("message.userActions")}>
<List.Item key='ProfileEditor' title={i18n.t('message.profile')} onPress={() => { navigation.navigate("ProfileSettings") }} left={props => <List.Icon {...props} icon="person" />} /> <List.Item key='ProfileEditor' title={i18n.t('message.profile')} onPress={() => { navigation.navigate("ProfileSettings") }} left={props => <List.Icon {...props} icon="person" />} />
<List.Item key='GlobalChat' title='Global Chat' onPress={() => { navigation.navigate("GlobalChat") }} left={props => <List.Icon {...props} icon="chat" />} />
<List.Item key='LiveCaptions' title={i18n.t("message.liveCaptions")} onPress={() => { navigation.navigate("LiveCaptions") }} left={props => <List.Icon {...props} icon="closed-caption" />} />
<List.Item key='Settings' title={i18n.t('message.settings')} left={props => <List.Icon {...props} icon="settings" />} /> <List.Item key='Settings' title={i18n.t('message.settings')} left={props => <List.Icon {...props} icon="settings" />} />
<List.Item key="Logout" title={i18n.t('message.logout')} onPress={() => { navigation.navigate("Logout") }} left={props => <List.Icon {...props} icon="logout" />} /> <List.Item key="Logout" title={i18n.t('message.logout')} onPress={() => { navigation.navigate("Logout") }} left={props => <List.Icon {...props} icon="logout" />} />
</List.Section> </List.Section>
<List.Section title="Fellowship App"> <List.Section title={i18n.t("message.fellowshipApp")}>
<List.Item key='Bible' title={i18n.t('message.bible') || 'Bible'} onPress={() => { navigation.navigate("Bible") }} left={props => <List.Icon {...props} icon="menu-book" />} />
<List.Item key='Invite' title={i18n.t('message.invite')} onPress={() => { navigation.navigate("Invite") }} left={props => <List.Icon {...props} icon="person-add" />} /> <List.Item key='Invite' title={i18n.t('message.invite')} onPress={() => { navigation.navigate("Invite") }} left={props => <List.Icon {...props} icon="person-add" />} />
<List.Item key='About' title={i18n.t('message.about')} left={props => <List.Icon {...props} icon="more" />} /> <List.Item key='About' title={i18n.t('message.about')} left={props => <List.Icon {...props} icon="more" />} />
</List.Section> </List.Section>
<View style={{ padding: 10 }}> <View style={{ padding: 10 }}>
<Text>App Language:</Text> <Text>{i18n.t("message.appLanguage")}:</Text>
<RadioButton.Group onValueChange={newValue => changeLang(newValue)} value={value}> <Menu
<RadioButton.Item value="es" label="Español" /> visible={languageMenuVisible}
<RadioButton.Item value="en" label="English" /> onDismiss={() => setLanguageMenuVisible(false)}
<RadioButton.Item value="da" label="Danish" /> anchor={
<RadioButton.Item value="fr" label="French" /> <Button mode="outlined" onPress={() => setLanguageMenuVisible(true)}>
</RadioButton.Group> {currentLanguageLabel}
</Button>
}
>
{languageOptions.map((option) => (
<Menu.Item
key={option.value}
title={option.label}
onPress={() => {
changeLang(option.value);
setLanguageMenuVisible(false);
}}
/>
))}
</Menu>
</View>
<View style={{ padding: 10, alignContent: "center", flex: 1 }}>
<Text>{i18n.t("message.version")}: {Updates.runtimeVersion}</Text>
<Text>{i18n.t("message.channel")}: {Updates.Channel}</Text>
</View> </View>
</ImageBackground> </ImageBackground>
</ScrollView> </ScrollView>
) )
} }
export default MenuView; export default MenuView;
+210 -21
View File
@@ -1,39 +1,228 @@
import React, { useEffect } from "react"; import React from "react";
import { Searchbar, Title, IconButton } from 'react-native-paper'; import { StyleSheet, SafeAreaView, ImageBackground, View, ScrollView, Alert, Image, Platform } from 'react-native';
import { StyleSheet, SafeAreaView, ImageBackground, View } from 'react-native'; import { Title, TextInput, Button, Text, Switch } from 'react-native-paper';
import API from "../API"; import API from "../API";
import GroupCard from "../components/GroupCard"; import * as ImagePicker from 'expo-image-picker';
import i18n from "../i18nMessages.js";
const NewGroup = () => { const DefaultPhoto = "https://social.emmint.com/uploads/e6f9be6d665dc43417701bf16a90122c.png";
useEffect(() => { const NewGroup = ({ navigation }) => {
}, []) const [title, setTitle] = React.useState('');
const [subtitle, setSubtitle] = React.useState('');
const [description, setDescription] = React.useState('');
const [isPrivate, setIsPrivate] = React.useState(false);
const [isSubmitting, setIsSubmitting] = React.useState(false);
const [selectedPhoto, setSelectedPhoto] = React.useState(null);
const pickImage = async () => {
if (isSubmitting) return;
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
allowsEditing: true,
aspect: [1, 1],
quality: 0.6,
});
if (!result.canceled && result.assets?.[0]) {
setSelectedPhoto(result.assets[0]);
}
};
const handleUploadPhoto = async (photo) => {
if (!photo) return '';
const uri = Platform.OS === "android" ? photo.uri : photo.uri.replace("file://", "");
const filename = photo.uri.split("/").pop() || "image.jpg";
const match = /\.(\w+)$/.exec(filename);
const ext = match?.[1] || "jpg";
const type = match ? `image/${match[1]}` : "image/jpeg";
const formData = new FormData();
formData.append("banner", {
uri,
name: `image.${ext}`,
type,
});
try {
const uploaded = await fetch("https://social.emmint.com/upload.php", {
method: "POST",
body: formData,
headers: { "Content-Type": "multipart/form-data" }
})
.then((res) => res.json())
.then((data) => data?.fileName || '');
return uploaded;
} catch (error) {
return '';
}
};
const createGroup = async () => {
const cleanTitle = title.trim();
const cleanSubtitle = subtitle.trim();
const cleanDescription = description.trim();
if (!cleanTitle) {
return Alert.alert("Missing name", "Please add a group name.");
}
if (!cleanDescription) {
return Alert.alert("Missing description", "Please add a short group description.");
}
setIsSubmitting(true);
try {
const photoPath = selectedPhoto ? await handleUploadPhoto(selectedPhoto) : '';
if (selectedPhoto && !photoPath) {
return Alert.alert("Could not upload image", "Please try again.");
}
const data = await API.newGroup(cleanTitle, cleanSubtitle, cleanDescription, isPrivate, false, photoPath);
if (data?.status !== "ok") {
return Alert.alert("Could not create group", data?.status || "Please try again.");
}
setTitle('');
setSubtitle('');
setDescription('');
setIsPrivate(false);
setSelectedPhoto(null);
if (data?._id) {
return navigation.replace("Profile", { profileid: data._id });
}
Alert.alert(
"Group created",
"Your new group is ready. You can switch to it from the menu.",
[{ text: "OK", onPress: () => navigation.goBack() }]
);
} catch (error) {
Alert.alert("Could not create group", "Please try again.");
} finally {
setIsSubmitting(false);
}
};
return ( return (
<SafeAreaView style={{ padding: 10, backgroundColor: "#edf2f7", flex:1 }}> <SafeAreaView style={styles.safeArea}>
<ImageBackground source={require("../assets/settings.png")} <ImageBackground
style={{flex:1}} source={require("../assets/settings.png")}
imageStyle={{resizeMode:"contain", opacity: 0.05}} style={styles.background}
imageStyle={styles.backgroundImage}
> >
<Title style={styles.title}>New Group:</Title> <ScrollView contentContainerStyle={styles.container}>
<Title style={styles.title}>{i18n.t("message.newGroup")}</Title>
<View style={styles.photoRow}>
<Image
source={{ uri: selectedPhoto?.uri || DefaultPhoto }}
style={styles.photoPreview}
/>
<Button mode="outlined" icon="photo" onPress={pickImage} disabled={isSubmitting}>
{selectedPhoto ? i18n.t("message.changeImage") : i18n.t("message.addGroupImage")}
</Button>
</View>
<TextInput
mode="outlined"
label={i18n.t("message.groupName")}
value={title}
onChangeText={setTitle}
style={styles.input}
/>
<TextInput
mode="outlined"
label={i18n.t("message.subtitleOptional")}
value={subtitle}
onChangeText={setSubtitle}
style={styles.input}
/>
<TextInput
mode="outlined"
label={i18n.t("message.description")}
value={description}
onChangeText={setDescription}
multiline
numberOfLines={4}
style={styles.input}
/>
<View style={styles.switchRow}>
<View style={styles.switchTextWrap}>
<Text style={styles.switchTitle}>{i18n.t("message.privateGroup")}</Text>
<Text style={styles.switchSubtitle}>{i18n.t("message.requireApprovalBeforeJoin")}</Text>
</View>
<Switch value={isPrivate} onValueChange={setIsPrivate} />
</View>
<Button
mode="contained"
onPress={createGroup}
loading={isSubmitting}
disabled={isSubmitting}
style={styles.button}
>
{i18n.t("message.createGroupAction")}
</Button>
</ScrollView>
</ImageBackground> </ImageBackground>
</SafeAreaView> </SafeAreaView>
) );
} };
export default NewGroup; export default NewGroup;
const styles = StyleSheet.create({ const styles = StyleSheet.create({
safeArea: {
backgroundColor: "#edf2f7",
flex: 1,
},
background: {
flex: 1,
},
backgroundImage: {
resizeMode: "contain",
opacity: 0.05,
},
container: { container: {
padding: 5, padding: 14,
paddingBottom: 24,
},
photoRow: {
alignItems: "center",
marginTop: 10,
marginBottom: 4,
},
photoPreview: {
width: 96,
height: 96,
borderRadius: 48,
marginBottom: 10,
backgroundColor: "#ddd",
}, },
title: { title: {
padding: 10, paddingBottom: 8,
paddingTop:5,
fontSize: 30, fontSize: 30,
marginTop: 15, marginTop: 8,
fontWeight: "bold", fontWeight: "bold",
color: "#777" color: "#777",
} },
input: {
marginTop: 10,
backgroundColor: "#fff",
},
switchRow: {
marginTop: 16,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
},
switchTextWrap: {
flex: 1,
},
switchTitle: {
fontSize: 16,
fontWeight: "600",
},
switchSubtitle: {
color: "#666",
marginTop: 2,
},
button: {
marginTop: 22,
},
}); });
+78 -16
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 } 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,14 +49,43 @@ 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 removeBibleTokensFromDraft = (value = "") => {
return String(value || "").replace(/@bible:[^\s]+/gi, "");
};
const handlePostContentChange = (nextValue = "") => {
const incomingValue = String(nextValue || "");
const detectedReferences = extractBibleReferences(incomingValue);
if (detectedReferences.length) {
setBibleReferences((prev) => {
const seen = new Set(prev);
detectedReferences.forEach((reference) => seen.add(reference));
return Array.from(seen);
});
}
setPostContent(removeBibleTokensFromDraft(incomingValue));
};
const pickImage = async () => { const pickImage = async () => {
try { try {
// Launch image picker to select images from gallery // Launch image picker to select images from gallery
const result = await ImagePicker.launchImageLibraryAsync({ const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images, mediaTypes: ImagePicker.MediaTypeOptions.Images,
quality: 0.2, // Set image quality quality: 0.85, // Avoid aggressive client-side compression before upload
allowsMultipleSelection: true, // Allow multiple images to be selected allowsMultipleSelection: true, // Allow multiple images to be selected
}); });
if (!result.canceled) { if (!result.canceled) {
@@ -142,10 +176,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);
@@ -170,15 +209,15 @@ let NewPostView = (props) => {
{ {
toProfile._id ? toProfile._id ?
<Text style={{ paddingLeft: 10, paddingBottom: 5 }}> <Text style={{ paddingLeft: 10, paddingBottom: 5 }}>
Posting on: {toProfile.profile?.firstName} {toProfile.profile?.lastName} {i18n.t("message.postingOn")}: {toProfile.profile?.firstName} {toProfile.profile?.lastName}
</Text> : null </Text> : null
} }
<Divider bold={true} /> <Divider bold={true} />
{/* Text input for post content */} {/* Text input for post content */}
<TextInput <TextInput
value={postContent} value={postContent}
onChangeText={setPostContent} onChangeText={handlePostContentChange}
placeholder="What is on your mind today?" placeholder={i18n.t("message.whatIsOnYourMindToday")}
multiline={true} multiline={true}
numberOfLines={8} numberOfLines={8}
style={{ style={{
@@ -190,22 +229,45 @@ 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" })}
>
{i18n.t("message.bible")}
</Button>
<Button icon="add-a-photo" mode="outlined" onPress={pickImage}> <Button icon="add-a-photo" mode="outlined" onPress={pickImage}>
Add Photos {i18n.t("message.addPhotos")}
</Button> </Button>
{isUploading && ( {isUploading && (
<Button icon="cancel" mode="outlined" onPress={handleCancelUpload}> <Button icon="cancel" mode="outlined" onPress={handleCancelUpload}>
Cancel Upload {i18n.t("message.cancelUpload")}
</Button> </Button>
)} )}
</View> </View>
{/* Display uploading state and selected image preview */} {/* Display uploading state and selected image preview */}
{photo && ( {photo && (
<View> <View>
<Text>Uploading...</Text> <Text>{i18n.t("message.uploading")}</Text>
<Image <Image
source={{ uri: photo.uri }} source={{ uri: photo.uri }}
style={{ width: 100, height: 100 }} style={{ width: 100, height: 100 }}
@@ -215,7 +277,7 @@ let NewPostView = (props) => {
{/* Display upload progress if in progress */} {/* Display upload progress if in progress */}
{ {
uploadProgress > 0 && uploadProgress < 100 && ( uploadProgress > 0 && uploadProgress < 100 && (
<Text>Upload Progress: {uploadProgress.toFixed(2)}%</Text> <Text>{i18n.t("message.uploadProgress")}: {uploadProgress.toFixed(2)}%</Text>
) )
} }
{/* Display media content if extra content is available */} {/* Display media content if extra content is available */}
@@ -229,4 +291,4 @@ let NewPostView = (props) => {
) )
} }
export default NewPostView; export default NewPostView;
+24 -2
View File
@@ -6,10 +6,32 @@ import SinglePost from '../components/SinglePostComponent';
import Moment from 'moment'; import Moment from 'moment';
import { useSnapshot } from 'valtio'; import { useSnapshot } from 'valtio';
import GlobalState from '../contexts/GlobalState.js'; import GlobalState from '../contexts/GlobalState.js';
import API from '../API.js';
import { useFocusEffect } from '@react-navigation/native';
let NotificationsView = ({ navigation, route }) => { let NotificationsView = ({ navigation, route }) => {
const gState = useSnapshot(GlobalState); const gState = useSnapshot(GlobalState);
const viewer = gState.me; const viewer = gState.me;
useFocusEffect(
React.useCallback(() => {
let active = true;
const markViewed = async () => {
const notifications = Array.isArray(viewer?.notifications) ? viewer.notifications : [];
const hasUnviewed = notifications.some((n) => n && n.viewed !== true);
if (!hasUnviewed) return;
const result = await API.markNotificationsViewed();
if (!active || result?.status !== "ok") return;
if (!GlobalState.me.notifications) GlobalState.me.notifications = [];
GlobalState.me.notifications = GlobalState.me.notifications.map((n) => ({ ...n, viewed: true }));
};
markViewed();
return () => {
active = false;
};
}, [viewer?.notifications?.length])
);
const renderNotification = (({ item }) => { const renderNotification = (({ item }) => {
const gotToPost = () => { const gotToPost = () => {
navigation.navigate('SinglePost', { postid: item.postid }); navigation.navigate('SinglePost', { postid: item.postid });
@@ -17,11 +39,11 @@ let NotificationsView = ({ navigation, route }) => {
return ( return (
<Card style={{ margin: 3 }} onPress={gotToPost}> <Card style={{ margin: 3 }} onPress={gotToPost}>
<Card.Content> <Card.Content>
<Text>{item.body}</Text> <Text style={{ fontWeight: item.viewed ? 'normal' : '700' }}>{item.body}</Text>
<Text style={{ fontWeight: 'normal', fontSize: 12 }}> <Text style={{ fontWeight: 'normal', fontSize: 12 }}>
{" " + Moment(item.ts).fromNow()} {" " + Moment(item.ts).fromNow()}
</Text> </Text>
<SinglePost postId={item.postid} /> <SinglePost postId={item.postid} hideComments={true} />
</Card.Content> </Card.Content>
</Card> </Card>
) )
+154 -22
View File
@@ -1,12 +1,20 @@
import { StatusBar } from 'expo-status-bar'; import { StatusBar } from 'expo-status-bar';
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { View, ActivityIndicator, StyleSheet, SafeAreaView, FlatList } from 'react-native'; import { View, ActivityIndicator, StyleSheet, SafeAreaView, FlatList, Alert } from 'react-native';
import { Button, IconButton } from 'react-native-paper'; import { Button, IconButton } from 'react-native-paper';
import API from './../API.js'; import API from './../API.js';
import Post from './../components/Post.js'; import Post from './../components/Post.js';
import NewPost from "./../components/NewPost.js"; import NewPost from "./../components/NewPost.js";
import ProfileHeader from '../components/ProfileHeader.js'; import ProfileHeader from '../components/ProfileHeader.js';
import AsyncStorage from '@react-native-async-storage/async-storage'; import AsyncStorage from '@react-native-async-storage/async-storage';
import { useSnapshot } from 'valtio';
import GlobalState from '../contexts/GlobalState.js';
import i18n from "../i18nMessages.js";
const PROFILE_LOG_PREFIX = '[Profile]';
const logProfile = (...args) => {
if (__DEV__) console.log(PROFILE_LOG_PREFIX, ...args);
};
const storeProfilePosts = async (profileid, value) => { const storeProfilePosts = async (profileid, value) => {
try { try {
@@ -30,49 +38,91 @@ const getProfilePosts = async (profileid) => {
} }
let Profile = ({ navigation, route }) => { let Profile = ({ navigation, route }) => {
const viewer = useSnapshot(GlobalState).me || {};
let [Posts, setPosts] = useState([]); let [Posts, setPosts] = useState([]);
let [profile, setProfile] = useState({}); let [profile, setProfile] = useState({});
const [showNewPost, setShowNewPost] = useState(false); const [showNewPost, setShowNewPost] = useState(false);
const [tag, setTag] = useState(''); const [tag, setTag] = useState('');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [isDeletingGroup, setIsDeletingGroup] = useState(false);
const isOwnedGroup = !!(
profile?._id &&
profile?.isGroup &&
String(profile?.userid || '') === String(viewer?.userid || '')
);
useEffect(() => { useEffect(() => {
let subscribed = true; let subscribed = true;
const getData = async () => { const getData = async () => {
logProfile('load:start', {
profileid: route?.params?.profileid || null,
tag,
});
setLoading(true);
setPosts([]); setPosts([]);
if (route.params && route.params.profileid && tag == '') { if (route.params && route.params.profileid && tag == '') {
console.log('Loading Cache Profile:' + route.params.profileid); logProfile('cache:read:start', { profileid: route.params.profileid });
await API.getUserProfile(route.params.profileid).then((profileObj) => { await API.getUserProfile(route.params.profileid).then((profileObj) => {
if(!subscribed) return 0; if(!subscribed) return 0;
let profile = profileObj.profile const nextProfile = profileObj && profileObj._id ? profileObj : {};
setProfile(profileObj); const profileData = nextProfile.profile || {};
navigation.setOptions({ title: profile.firstName + " " + profile.lastName }); setProfile(nextProfile);
navigation.setOptions({ title: (profileData.firstName || "") + " " + (profileData.lastName || "") });
logProfile('profile:loaded', {
profileid: nextProfile._id || route.params.profileid,
hasName: !!(profileData.firstName || profileData.lastName),
});
});
await getProfilePosts(route.params.profileid).then((cachedPosts) => {
const safeCachedPosts = Array.isArray(cachedPosts) ? cachedPosts : [];
setPosts(safeCachedPosts);
logProfile('cache:read:end', {
profileid: route.params.profileid,
count: safeCachedPosts.length,
});
}); });
await getProfilePosts(route.params.profileid).then(setPosts);
console.log('Loaded Cache Profile:' + route.params.profileid);
API.getPosts(route.params.profileid).then((data) => { API.getPosts(route.params.profileid).then((data) => {
setLoading(false);
if(!subscribed) return 0; if(!subscribed) return 0;
setPosts(data); const safePosts = Array.isArray(data) ? data : [];
storeProfilePosts(route.params.profileid, data); setPosts(safePosts);
console.log('Store Cache Profile:' + route.params.profileid); storeProfilePosts(route.params.profileid, safePosts);
setLoading(false);
logProfile('network:loaded', {
profileid: route.params.profileid,
count: safePosts.length,
cached: true,
});
}); });
} else { } else {
if(route.params && route.params.profileid){ if(route.params && route.params.profileid){
console.log('Getting posts with tag', tag) logProfile('tag:load:start', {
profileid: route.params.profileid,
tag,
});
API.getPostsWithTag(route.params.profileid, tag).then((data) => { API.getPostsWithTag(route.params.profileid, tag).then((data) => {
if(!subscribed) return 0; if(!subscribed) return 0;
setPosts(data.posts); const safeTagPosts = Array.isArray(data?.posts) ? data.posts : [];
setPosts(safeTagPosts);
setLoading(false);
logProfile('tag:load:end', {
profileid: route.params.profileid,
tag,
count: safeTagPosts.length,
});
}); });
} else { } else {
// if no profile information is pressent should load feed // if no profile information is pressent should load feed
logProfile('redirect:feed');
navigation.navigate('Feed') navigation.navigate('Feed')
} }
} }
logProfile('load:end');
} }
getData(); getData();
return ()=>{ return ()=>{
subscribed = false; subscribed = false;
logProfile('load:cleanup');
} }
}, [tag, route.params?.profileid]); }, [tag, route.params?.profileid]);
@@ -80,7 +130,7 @@ let Profile = ({ navigation, route }) => {
API.getPostsWithTag(tag).then((data) => { API.getPostsWithTag(tag).then((data) => {
//if(!subscribed) return 0; //if(!subscribed) return 0;
console.log(data.posts); console.log(data.posts);
setPosts(data.posts); setPosts(Array.isArray(data?.posts) ? data.posts : []);
}); });
} }
@@ -89,6 +139,46 @@ let Profile = ({ navigation, route }) => {
return (<></>); return (<></>);
return (<Post post={item} />); return (<Post post={item} />);
}); });
const handleDeleteGroup = () => {
if (!profile?._id || isDeletingGroup) return;
Alert.alert(
"Delete group?",
"This will permanently delete this group profile.",
[
{ text: "Cancel", style: "cancel" },
{
text: "Delete",
style: "destructive",
onPress: async () => {
setIsDeletingGroup(true);
try {
const result = await API.deleteProfile(profile._id);
if (result?.status !== "ok") {
return Alert.alert("Could not delete group", result?.status || "Please try again.");
}
const me = await API.getMe();
if (me && me._id) GlobalState.me = me;
if (navigation.canGoBack()) {
navigation.goBack();
} else {
navigation.reset({
index: 0,
routes: [{ name: 'MainNavigation' }],
});
}
} catch (error) {
Alert.alert("Could not delete group", "Please try again.");
} finally {
setIsDeletingGroup(false);
}
}
}
]
);
};
const header = ( const header = (
<View> <View>
<ProfileHeader profileObj={profile} key={profile._id} /> <ProfileHeader profileObj={profile} key={profile._id} />
@@ -114,19 +204,33 @@ let Profile = ({ navigation, route }) => {
navigation.navigate('NewPost', {toProfile: profile._id}) navigation.navigate('NewPost', {toProfile: profile._id})
}} /> }} />
</View> </View>
<Button style={{paddingLeft:12, backgroundColor:"#fff"}} title="Images" icon={tag == 'images' ? 'remove' : "image"} mode="outlined" onPress={()=>{ <Button style={{paddingLeft:12, backgroundColor:"#fff"}} title={i18n.t("message.images")} icon={tag == 'images' ? 'remove' : "image"} mode="outlined" onPress={()=>{
if(tag == 'images') return setTag(''); if(tag == 'images') return setTag('');
setTag('images'); setTag('images');
}}>{tag == 'images' ? "Images" : ''}</Button> }}>{tag == 'images' ? i18n.t("message.images") : ''}</Button>
<Button style={{paddingLeft:12, backgroundColor:"#fff"}} title="Media" icon={tag == 'media' ? 'remove' : "subscriptions"} mode="outlined" onPress={()=>{ <Button style={{paddingLeft:12, backgroundColor:"#fff"}} title={i18n.t("message.media")} icon={tag == 'media' ? 'remove' : "subscriptions"} mode="outlined" onPress={()=>{
if(tag == 'media') return setTag(''); if(tag == 'media') return setTag('');
setTag('media'); setTag('media');
}}>{tag == 'media' ? "Media" : ''}</Button> }}>{tag == 'media' ? i18n.t("message.media") : ''}</Button>
<Button style={{paddingLeft:12, backgroundColor:"#fff"}} title="Embedded" icon={tag == 'embedded' ? 'remove' : "folder"} mode="outlined" onPress={()=>{ <Button style={{paddingLeft:12, backgroundColor:"#fff"}} title={i18n.t("message.embedded")} icon={tag == 'embedded' ? 'remove' : "folder"} mode="outlined" onPress={()=>{
if(tag == 'embedded') return setTag(''); if(tag == 'embedded') return setTag('');
setTag('embedded'); setTag('embedded');
}}>{tag == 'embedded' ? "Files" : ''}</Button> }}>{tag == 'embedded' ? i18n.t("message.files") : ''}</Button>
</View> </View>
{isOwnedGroup ? (
<View style={styles.deleteGroupRow}>
<Button
mode="contained"
icon="delete"
buttonColor="#b3261e"
loading={isDeletingGroup}
disabled={isDeletingGroup}
onPress={handleDeleteGroup}
>
{i18n.t("message.deleteGroup")}
</Button>
</View>
) : <></>}
{ showNewPost ? { showNewPost ?
<NewPost newPostCB={(newPost) => setPosts([newPost, ...Posts])} /> <NewPost newPostCB={(newPost) => setPosts([newPost, ...Posts])} />
: <></> : <></>
@@ -141,14 +245,38 @@ let Profile = ({ navigation, route }) => {
<FlatList <FlatList
data={Posts} data={Posts}
renderItem={renderPost} renderItem={renderPost}
keyExtractor={item => item.lastUpdated || item._id || item.ceatedAt} keyExtractor={item => item.lastUpdated || item._id || item.createdAt}
ListHeaderComponent={header} ListHeaderComponent={header}
refreshing={loading} refreshing={loading}
initialNumToRender={3} initialNumToRender={3}
maxToRenderPerBatch={3} maxToRenderPerBatch={3}
removeClippedSubviews={true} removeClippedSubviews={true}
onRefresh={() => { onRefresh={() => {
API.getPosts(route.params.profileid).then(setPosts); logProfile('refresh:start', {
profileid: route?.params?.profileid || null,
tag,
});
setLoading(true);
Promise.all([
API.getUserProfile(route.params.profileid, true),
API.getPosts(route.params.profileid),
]).then(([profileObj, data]) => {
const nextProfile = profileObj && profileObj._id ? profileObj : {};
const profileData = nextProfile.profile || {};
const safePosts = Array.isArray(data) ? data : [];
setProfile(nextProfile);
navigation.setOptions({ title: (profileData.firstName || "") + " " + (profileData.lastName || "") });
setPosts(safePosts);
storeProfilePosts(route.params.profileid, safePosts);
setLoading(false);
logProfile('refresh:end', {
profileid: route.params.profileid,
count: safePosts.length,
cached: false,
});
}).catch(() => {
setLoading(false);
});
}} }}
/> : /> :
<></> //TODO: Add empty profile card here <></> //TODO: Add empty profile card here
@@ -166,4 +294,8 @@ const styles = StyleSheet.create({
flex: 1, flex: 1,
backgroundColor: "#edf2f7", backgroundColor: "#edf2f7",
}, },
deleteGroupRow: {
paddingHorizontal: 12,
paddingTop: 12,
},
}); });
+128 -43
View File
@@ -1,6 +1,6 @@
import React from "react"; import React from "react";
import { View, ImageBackground, ScrollView, Picker } from "react-native"; import { View, ImageBackground, ScrollView, Platform } from "react-native";
import { Text, TextInput, Button, Divider, Checkbox, RadioButton } from "react-native-paper"; import { Text, TextInput, Button, Divider, Checkbox, Menu } from "react-native-paper";
import i18n from "../i18nMessages.js"; import i18n from "../i18nMessages.js";
import Moment from 'moment'; import Moment from 'moment';
import 'moment/min/locales'; import 'moment/min/locales';
@@ -10,20 +10,44 @@ import { useSnapshot } from 'valtio';
import GlobalState from '../contexts/GlobalState.js'; import GlobalState from '../contexts/GlobalState.js';
import API from "../API.js"; import API from "../API.js";
import * as ImagePicker from 'expo-image-picker'; import * as ImagePicker from 'expo-image-picker';
import { useNavigation } from '@react-navigation/native';
import AsyncStorage from '@react-native-async-storage/async-storage';
let ProfileSettings = () => { let ProfileSettings = () => {
const navigation = useNavigation();
const gState = useSnapshot(GlobalState); const gState = useSnapshot(GlobalState);
const viewer = gState.me; const viewer = gState.me;
const viewerProfile = viewer?.profile || {};
const [photo, setPhoto] = React.useState(null); const [photo, setPhoto] = React.useState(null);
const [name, setName] = React.useState(viewer.profile.firstName); const [name, setName] = React.useState(viewerProfile.firstName || "");
const [lastName, setLastName] = React.useState(viewer.profile.lastName); const [lastName, setLastName] = React.useState(viewerProfile.lastName || "");
const [photoUrl, setphotoUrl] = React.useState(viewer.profile.photo); const [photoUrl, setphotoUrl] = React.useState(viewerProfile.photo || "");
const [language, setLanguage] = React.useState(viewer.profile.language); const [language, setLanguage] = React.useState(viewerProfile.language || "en");
const [updateKey, setUpdateKey] = React.useState(0); const [updateKey, setUpdateKey] = React.useState(0);
const [description, setDescription] = React.useState(viewer.profile.description); const [description, setDescription] = React.useState(viewerProfile.description || "");
const [uploading, setUploading] = React.useState(false); const [uploading, setUploading] = React.useState(false);
const [languageMenuVisible, setLanguageMenuVisible] = React.useState(false);
const previewProfile = {
...(GlobalState.me || {}),
profile: {
...(GlobalState.me?.profile || {}),
firstName: name,
lastName: lastName,
description: description,
language: language,
photo: photoUrl,
},
};
const languageOptions = [
{ value: "es", label: i18n.t("message.languageSpanish") },
{ value: "en", label: i18n.t("message.languageEnglish") },
{ value: "da", label: i18n.t("message.languageDanish") },
{ value: "fr", label: i18n.t("message.languageFrench") },
];
const currentLanguageLabel = languageOptions.find((opt) => opt.value === language)?.label || i18n.t("message.languageEnglish");
const pickImage = async () => { const pickImage = async () => {
if (uploading) return; if (uploading) return;
@@ -32,7 +56,7 @@ let ProfileSettings = () => {
mediaTypes: ImagePicker.MediaTypeOptions.Images, mediaTypes: ImagePicker.MediaTypeOptions.Images,
allowsEditing: true, allowsEditing: true,
aspect: [4, 3], aspect: [4, 3],
quality: 0.5, quality: 0.85,
//allowsMultipleSelection: true, //allowsMultipleSelection: true,
}); });
if (!result.canceled) { if (!result.canceled) {
@@ -41,9 +65,12 @@ let ProfileSettings = () => {
let newPhotoURL = await handleUploadPhoto(result.assets[0]); let newPhotoURL = await handleUploadPhoto(result.assets[0]);
if (newPhotoURL !== "") { if (newPhotoURL !== "") {
setphotoUrl(newPhotoURL); setphotoUrl(newPhotoURL);
if (!GlobalState.me.profile) GlobalState.me.profile = {};
GlobalState.me.profile.photo = newPhotoURL; GlobalState.me.profile.photo = newPhotoURL;
updateProfile() await updateProfile({ photo: newPhotoURL }, false);
setUpdateKey(updateKey + 1); setUpdateKey((k) => k + 1);
} else {
alert("Could not upload photo. Please try another image.");
} }
setPhoto(null); setPhoto(null);
setUploading(false); setUploading(false);
@@ -52,17 +79,17 @@ let ProfileSettings = () => {
const handleUploadPhoto = async (photo) => { const handleUploadPhoto = async (photo) => {
console.log(photo) console.log(photo)
if (!photo) return; if (!photo) return '';
const uri = const uri =
Platform.OS === "android" Platform.OS === "android"
? photo.uri ? photo.uri
: photo.uri.replace("file://", ""); : photo.uri.replace("file://", "");
console.log(uri); console.log(uri);
const filename = photo.uri.split("/").pop(); const filename = photo.uri.split("/").pop() || "image.jpg";
const match = /\.(\w+)$/.exec(filename); const match = /\.(\w+)$/.exec(filename);
const ext = match?.[1]; const ext = (match?.[1] || "jpg").toLowerCase();
const type = match ? `image/${match[1]}` : `image`; const type = ext === "heic" || ext === "heif" ? "image/heic" : `image/${ext}`;
const formData = new FormData(); const formData = new FormData();
formData.append("banner", { formData.append("banner", {
uri, uri,
@@ -79,7 +106,7 @@ let ProfileSettings = () => {
.then((res) => res.json()) .then((res) => res.json())
.then((data) => { .then((data) => {
console.log(data); console.log(data);
return data.fileName; return data?.fileName || '';
}); });
console.log(uploadedFile); console.log(uploadedFile);
} catch (err) { } catch (err) {
@@ -89,27 +116,59 @@ let ProfileSettings = () => {
return uploadedFile; return uploadedFile;
}; };
let updateProfile = async () => { let updateProfile = async (overrides = {}, goBackAfter = true) => {
let currentProfile = await API.getUserProfile(viewer._id) let currentProfile = {};
currentData = currentProfile.data; let currentData = {};
currentProfile = currentProfile.profile;
try { try {
//let currentProfile = JSON.parse(JSON.stringify(viewer.profile)); currentData = JSON.parse(JSON.stringify(viewer?.data || {}));
currentProfile.firstName = name; } catch (_) {
currentProfile.lastName = lastName; currentData = {};
currentProfile.description = description; }
currentProfile.language = language; try {
currentProfile.photo = photoUrl; currentProfile = {
firstName: name,
lastName: lastName,
description,
language,
photo: overrides.photo ?? photoUrl,
};
console.log("updating", currentProfile); console.log("updating", currentProfile);
} catch (error) { } catch (error) {
alert("Error updating profile, contact administrator."); alert("Error updating profile, contact administrator.");
return; return;
} }
let r = await API.updateMyProfile(currentProfile, currentData).catch((e) => { let r = await API.updateMyProfile(currentProfile, currentData).catch((e) => {
console.error("Profile update request failed", e);
alert("Error updating profile, contact administrator."); alert("Error updating profile, contact administrator.");
return null;
}); });
if (!r || r.status !== "ok") {
console.error("Profile update unexpected response", r);
return alert("Error updating profile, contact administrator.");
}
const refreshedProfile = await API.getUserProfile(viewer?._id, true).catch(() => null);
if (refreshedProfile && refreshedProfile._id) {
GlobalState.me = {
...GlobalState.me,
...refreshedProfile,
};
} else {
if (!GlobalState.me.profile) GlobalState.me.profile = {};
GlobalState.me.profile = {
...GlobalState.me.profile,
...currentProfile,
};
}
await AsyncStorage.removeItem('feed');
if (language && language !== i18n.locale) {
i18n.locale = language;
Moment.locale(language);
}
console.log(r); console.log(r);
setUpdateKey(updateKey + 1); setUpdateKey((k) => k + 1);
if (goBackAfter) {
navigation.goBack();
}
} }
return ( return (
@@ -120,7 +179,14 @@ let ProfileSettings = () => {
imageStyle={{ resizeMode: "contain", opacity: 0.05 }} imageStyle={{ resizeMode: "contain", opacity: 0.05 }}
style={{ paddingBottom: 50 }} style={{ paddingBottom: 50 }}
> >
<Text style={{ marginBottom: 10, fontSize: 20 }}>Profile Settings</Text> <View style={{ paddingTop: 10 }}>
<ProfileCardHorizontal profileObj={previewProfile} skipFollow={true} skiptOnPress={true} key={updateKey} />
</View>
<Text style={{ marginBottom: 10, marginTop: 10, fontSize: 20 }}>{i18n.t("message.profileSetting")}</Text>
<Button icon="photo" mode="outlined" onPress={pickImage}>
{!uploading ? i18n.t("message.updatePhoto") : i18n.t("message.uploading")}
</Button>
<Divider />
<View style={{ flexDirection: "row", justifyContent: "space-between" }}> <View style={{ flexDirection: "row", justifyContent: "space-between" }}>
<TextInput <TextInput
label={i18n.t("message.name")} label={i18n.t("message.name")}
@@ -143,22 +209,40 @@ let ProfileSettings = () => {
value={description} value={description}
onChangeText={text => setDescription(text)} onChangeText={text => setDescription(text)}
/> />
<Text>Language:</Text> <Text style={{ marginBottom: 4 }}>{i18n.t("message.language")}:</Text>
<View style={{ flexDirection: "row" }}> <View style={{ marginBottom: 10 }}>
<RadioButton.Group value={language} onValueChange={setLanguage} style={{ flexDirection: "row" }}> <Menu
<RadioButton.Item value="es" label="Español" /> visible={languageMenuVisible}
<RadioButton.Item value="en" label="English" /> onDismiss={() => setLanguageMenuVisible(false)}
<RadioButton.Item value="da" label="Danish" /> anchor={
<RadioButton.Item value="fr" label="French" /> <Button mode="outlined" onPress={() => setLanguageMenuVisible(true)}>
</RadioButton.Group> {currentLanguageLabel}
</Button>
}
>
{languageOptions.map((option) => (
<Menu.Item
key={option.value}
title={option.label}
onPress={() => {
setLanguage(option.value);
setLanguageMenuVisible(false);
}}
/>
))}
</Menu>
</View> </View>
<Button icon="photo" mode="outlined" onPress={pickImage}>{!uploading ? i18n.t("message.updatePhoto") : "uploading"}</Button> <Button
<Divider /> mode="contained"
<View style={{ paddingTop: 10 }}> onPress={() => updateProfile({}, true)}
<Text style={{ fontSize: 20, padding: 5, color: "#666" }}>Preview:</Text> buttonColor="#c44d56"
<ProfileCardHorizontal profileObj={GlobalState.me} skipFollow={true} skiptOnPress={true} key={updateKey} /> textColor="#ffffff"
</View> style={{ marginTop: 8 }}
<Button mode="outlined" onPress={updateProfile}>{i18n.t("message.update")}</Button> contentStyle={{ paddingVertical: 6 }}
>
{i18n.t("message.update")}
</Button>
{/*
<Divider /> <Divider />
<Text style={{ fontSize: 20, padding: 5, color: "#666" }}>{i18n.t("message.optional")}:</Text> <Text style={{ fontSize: 20, padding: 5, color: "#666" }}>{i18n.t("message.optional")}:</Text>
<View style={{ flexDirection: "row", justifyContent: "space-evenly" }}> <View style={{ flexDirection: "row", justifyContent: "space-evenly" }}>
@@ -199,9 +283,10 @@ let ProfileSettings = () => {
//onChangeText={text => setLastName(text)} //onChangeText={text => setLastName(text)}
/> />
<Button mode="outlined" onPress={updateProfile}>{i18n.t("message.update")}</Button> <Button mode="outlined" onPress={updateProfile}>{i18n.t("message.update")}</Button>
*/}
</ImageBackground> </ImageBackground>
</ScrollView> </ScrollView>
) )
} }
export default ProfileSettings; export default ProfileSettings;
+3 -2
View File
@@ -6,6 +6,7 @@ import ProfileCardHorizontal from "../components/ProfileCardHorizontal";
import { useSnapshot } from 'valtio'; import { useSnapshot } from 'valtio';
import GlobalState from '../contexts/GlobalState.js'; import GlobalState from '../contexts/GlobalState.js';
import ProfileHeader from "../components/ProfileHeader"; import ProfileHeader from "../components/ProfileHeader";
import i18n from "../i18nMessages.js";
const Search = () => { const Search = () => {
const viewer = useSnapshot(GlobalState).me; const viewer = useSnapshot(GlobalState).me;
@@ -47,7 +48,7 @@ const Search = () => {
return ( return (
<SafeAreaView style={{ flex: 1, backgroundColor: "#edf2f7", }}> <SafeAreaView style={{ flex: 1, backgroundColor: "#edf2f7", }}>
<Searchbar <Searchbar
placeholder="Search Users" placeholder={i18n.t("message.searchUsers")}
onChangeText={onChangeSearch} onChangeText={onChangeSearch}
value={searchQuery} value={searchQuery}
/> />
@@ -66,7 +67,7 @@ const Search = () => {
data={followers} data={followers}
renderItem={renderFollowing} renderItem={renderFollowing}
keyExtractor={item => item} keyExtractor={item => item}
ListHeaderComponent={<Text style={{fontSize:20, padding:10, alignSelf: "center"}}>Recently Following</Text>} ListHeaderComponent={<Text style={{fontSize:20, padding:10, alignSelf: "center"}}>{i18n.t("message.recentlyFollowing")}</Text>}
/> />
} }
</SafeAreaView> </SafeAreaView>
+7 -7
View File
@@ -43,23 +43,23 @@ let MenuView = ({ navigation }) => {
style={{ paddingTop: 10 }} style={{ paddingTop: 10 }}
imageStyle={{ resizeMode: "contain", opacity: 0.05 }} imageStyle={{ resizeMode: "contain", opacity: 0.05 }}
> >
<List.Section title="Current Profile"> <List.Section title={i18n.t("message.currentProfile")}>
<ProfileCardHorizontal profileObj={viewer} skipFollow={true} skiptOnPress={true} /> <ProfileCardHorizontal profileObj={viewer} skipFollow={true} skiptOnPress={true} />
</List.Section> </List.Section>
<List.Section title="User Actions"> <List.Section title={i18n.t("message.userActions")}>
<List.Item title={i18n.t('message.profile')} onPress={() => { navigation.navigate("ProfileSettings") }} left={props => <List.Icon {...props} icon="person" />} /> <List.Item title={i18n.t('message.profile')} onPress={() => { navigation.navigate("ProfileSettings") }} left={props => <List.Icon {...props} icon="person" />} />
<List.Item title={i18n.t('message.settings')} left={props => <List.Icon {...props} icon="settings" />} /> <List.Item title={i18n.t('message.settings')} left={props => <List.Icon {...props} icon="settings" />} />
<List.Item title={i18n.t('message.logout')} onPress={() => { navigation.navigate("Logout") }} left={props => <List.Icon {...props} icon="logout" />} /> <List.Item title={i18n.t('message.logout')} onPress={() => { navigation.navigate("Logout") }} left={props => <List.Icon {...props} icon="logout" />} />
</List.Section> </List.Section>
<List.Section title="Fellowship App"> <List.Section title={i18n.t("message.fellowshipApp")}>
<List.Item title={i18n.t('message.invite')} onPress={() => { navigation.navigate("Invite") }} left={props => <List.Icon {...props} icon="person-add" />} /> <List.Item title={i18n.t('message.invite')} onPress={() => { navigation.navigate("Invite") }} left={props => <List.Icon {...props} icon="person-add" />} />
<List.Item title={i18n.t('message.about')} left={props => <List.Icon {...props} icon="more" />} /> <List.Item title={i18n.t('message.about')} left={props => <List.Icon {...props} icon="more" />} />
</List.Section> </List.Section>
<View style={{ padding: 10 }}> <View style={{ padding: 10 }}>
<Text>Language:</Text> <Text>{i18n.t("message.language")}:</Text>
<RadioButton.Group onValueChange={newValue => changeLang(newValue)} value={value}> <RadioButton.Group onValueChange={newValue => changeLang(newValue)} value={value}>
<RadioButton.Item value="es" label="Español" /> <RadioButton.Item value="es" label={i18n.t("message.languageSpanish")} />
<RadioButton.Item value="en" label="English" /> <RadioButton.Item value="en" label={i18n.t("message.languageEnglish")} />
</RadioButton.Group> </RadioButton.Group>
</View> </View>
</ImageBackground> </ImageBackground>
@@ -67,4 +67,4 @@ let MenuView = ({ navigation }) => {
) )
} }
export default MenuView; export default MenuView;
+75
View File
@@ -0,0 +1,75 @@
import { StatusBar } from 'expo-status-bar';
import React, { useState, useEffect } from 'react';
import { View, Text, StyleSheet, SafeAreaView, FlatList } from 'react-native';
import API from './../API.js';
import Post from './../components/Post.js';
let Tags = ({ navigation, route }) => {
let [Posts, setPosts] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
let subscribed = true;
const getData = async () => {
setPosts([]);
console.log("Posts by tag", route.params.tag);
API.getPostsByTag(route.params.tag).then((data) => {
if(!subscribed) return 0;
console.log("Posts by tag", data);
setPosts(data);
setLoading(false);
});
}
getData();
return ()=>{
subscribed = false;
}
}, [route.params?.tag]);
const renderPost = (({ item }) => {
if (item.nonOrganicType)
return (<></>);
return (<Post post={item} />);
});
const header = (
<View>
<Text style={{ fontSize: 20, fontWeight: 'bold', padding: 10, alignContent: 'center', textAlign: 'center' }}>
#{route.params.tag}
</Text>
</View>
)
return (
<SafeAreaView style={styles.container}>
<View>
{!loading ?
<FlatList
data={Posts}
renderItem={renderPost}
keyExtractor={item => item.lastUpdated || item._id || item.createdAt}
ListHeaderComponent={header}
refreshing={loading}
initialNumToRender={3}
maxToRenderPerBatch={3}
removeClippedSubviews={true}
onRefresh={() => {
API.getPostsByTag(route.params.tag).then(setPosts);
}}
/> :
<></> //TODO: Add empty profile card here
}
</View>
<StatusBar style="auto" />
</SafeAreaView>
);
}
export default Tags;
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#edf2f7",
},
});
+123
View File
@@ -0,0 +1,123 @@
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, translateBibleReference } from "../utils/bibleReferences.js";
import GlobalState from "../contexts/GlobalState.js";
import { useSnapshot } from "valtio";
import i18n from "../i18nMessages.js";
const BibleEmbeddedView = ({ content = "", compact = false, openChapterOnPress = false }) => {
const navigation = useNavigation();
const gState = useSnapshot(GlobalState);
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 preferredTranslation = GlobalState.me?.data?.bibleTranslation || "";
const data = await fetchBiblePassage(reference, i18n.locale, preferredTranslation);
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}>{i18n.t("message.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)}
>
{translateBibleReference(reference, i18n.locale)}
</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}>
{translateBibleReference(selectedData.reference, i18n.locale)} ({selectedData.translation})
</Text>
</View>
) : null}
{selectedData?.error ? <Text style={styles.errorText}>{i18n.t("message.unableLoadPassage")}</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",
},
});
+20 -17
View File
@@ -4,22 +4,24 @@ import { FAB, Button, Card, Title, IconButton } from 'react-native-paper';
import API from './../API.js'; import API from './../API.js';
import UserName from './UserName.js'; import UserName from './UserName.js';
import Media from './Media.js'; import Media from './Media.js';
import BibleEmbeddedView from './BibleEmbeddedView.js';
import { useSnapshot } from 'valtio'; import { useSnapshot } from 'valtio';
import GlobalState from '../contexts/GlobalState.js'; import GlobalState from '../contexts/GlobalState.js';
import Moment from 'moment'; import Moment from 'moment';
import i18n from "../i18nMessages.js"; import i18n from "../i18nMessages.js";
import 'moment/min/locales'; import 'moment/min/locales';
Moment.locale(i18n.locale); Moment.locale(i18n.locale);
import ProfilePhotoCircle from './ProfilePhotoCircle.js';
let Comment = ({ comment, postid }) => { let Comment = ({ comment, postid }) => {
const gState = useSnapshot(GlobalState); const gState = useSnapshot(GlobalState);
const viewer = gState.me; const viewer = gState.me;
let [likes, changeLikes] = useState(Object.keys(comment.reactions).length); let [likes, changeLikes] = useState(Object.keys(comment.reactions).length);
let cleanContent = comment.content.replace(/@[A-z]+:.+\w/g, ''); let cleanContent = String(comment.content || '');
const newCommentReaction = () => { const newCommentReaction = () => {
if (!comment.reactions[viewer._id]) { if (!comment.reactions[viewer._id]) {
comment.reactions[viewer._id] = { type: "like" }; comment.reactions[viewer._id] = { type: "like" };
changeLikes(likes+1); changeLikes(likes + 1);
API.newCommentReaction(postid, comment.createdAt); API.newCommentReaction(postid, comment.createdAt);
} else { } else {
//API.removePostReaction(viewer._id).then(() => { //API.removePostReaction(viewer._id).then(() => {
@@ -31,14 +33,20 @@ let Comment = ({ comment, postid }) => {
return ( return (
<Card style={styles.comment}> <Card style={styles.comment}>
<Card.Content> <Card.Content>
<View style={{flexDirection: "row", alignItems: "center", justifyContent: "center"}}> <View style={{ flexDirection: "row", alignItems: "center", justifyContent: "center" }}>
<View style={{flex:8}}> <View style={{ flex: 8, marginBottom: 8 }}>
<Text style={styles.userName}> <ProfilePhotoCircle profileid={comment.profileid} />
<UserName profileid={comment.profileid} key={comment.profileid} /> <Text style={
<Text style={{fontSize: 12, fontWeight: "normal"}}> {Moment(comment.createdAt).fromNow()}</Text> {
</Text> fontSize: 12,
fontWeight: "normal",
position: "absolute",
top: 20,
left: 37,
}
}> {Moment(comment.createdAt).fromNow()}</Text>
</View> </View>
<View style={{flex:2}}> <View style={{ flex: 2 }}>
<Button <Button
icon={comment.reactions[viewer._id] ? "favorite" : "favorite-border"} icon={comment.reactions[viewer._id] ? "favorite" : "favorite-border"}
dense={true} dense={true}
@@ -46,8 +54,9 @@ let Comment = ({ comment, postid }) => {
>{likes ? likes : ''}</Button> >{likes ? likes : ''}</Button>
</View> </View>
</View> </View>
<Text style={{fontSize: 14}}>{cleanContent}</Text> <Text style={{ fontSize: 14 }}>{cleanContent}</Text>
<Media content={comment.content} postId={postid} skiptVideo={true}/> <BibleEmbeddedView content={comment.content} compact openChapterOnPress />
<Media content={comment.content} postId={postid} skiptVideo={true} />
</Card.Content> </Card.Content>
</Card> </Card>
); );
@@ -60,12 +69,6 @@ const styles = StyleSheet.create({
margin: 8, margin: 8,
marginTop: 0, marginTop: 0,
}, },
userName: {
fontSize: 14,
fontWeight: 'bold',
marginBottom: 5,
fontSize: 16
},
likeComment: { likeComment: {
position: 'absolute', position: 'absolute',
margin: 16, margin: 16,
+17 -13
View File
@@ -29,12 +29,16 @@ const getName = async (key) => {
let CourseCard = ({ profileid, hideIcon, profileObj, twoCols }) => { let CourseCard = ({ profileid, hideIcon, profileObj, twoCols }) => {
let [profile, setProfile] = useState(profileObj || {}); let [profile, setProfile] = useState(profileObj || {});
const safeProfile = profile || {};
const safeProfileInfo = safeProfile.profile || {};
const safeProfileObj = profileObj || {};
const safeProfileObjInfo = safeProfileObj.profile || {};
const navigation = useNavigation(); const navigation = useNavigation();
useEffect(() => { useEffect(() => {
let subscribed = true; let subscribed = true;
const getData = async () => { const getData = async () => {
if (profileObj._id) return 0; if (safeProfileObj._id) return 0;
let cacheProfile = await getName(profileid); let cacheProfile = await getName(profileid);
if (cacheProfile && cacheProfile.profile) setProfile(cacheProfile); if (cacheProfile && cacheProfile.profile) setProfile(cacheProfile);
let p = await API.getUserProfile(profileid).catch(() => { return {} }); let p = await API.getUserProfile(profileid).catch(() => { return {} });
@@ -47,13 +51,13 @@ let CourseCard = ({ profileid, hideIcon, profileObj, twoCols }) => {
} }
}, [profileid]); }, [profileid]);
let icon = profile._id ? (!profile.isGroup ? "person-outline" : "group") : ''; let icon = safeProfile._id ? (!safeProfile.isGroup ? "person-outline" : "group") : '';
icon = icon === "person-outline" && profile.subscription && profile.subscription > (new Date() - 0) ? "assignment-ind" : icon; icon = icon === "person-outline" && safeProfile.subscription && safeProfile.subscription > (new Date() - 0) ? "assignment-ind" : icon;
icon = icon === "group" && profile.isCourse ? "subscriptions" : icon; icon = icon === "group" && safeProfile.isCourse ? "subscriptions" : icon;
let photoUrl = profile.data && profile.data.profileImg ? profile.data.profileImg : DefaultPhoto; let photoUrl = safeProfile.data && safeProfile.data.profileImg ? safeProfile.data.profileImg : DefaultPhoto;
const onPress = () => { const onPress = () => {
return navigation.navigate('Profile', { profileid: profile._id }) return navigation.navigate('Profile', { profileid: safeProfile._id })
} }
return ( return (
@@ -64,28 +68,28 @@ let CourseCard = ({ profileid, hideIcon, profileObj, twoCols }) => {
{!hideIcon ? <Icon name={icon} size={18} /> : <></>} {!hideIcon ? <Icon name={icon} size={18} /> : <></>}
</Text> </Text>
<Text> <Text>
{profile.profile && profile.profile.firstName} {profile.profile && profile.profile.lastName} {safeProfileInfo.firstName} {safeProfileInfo.lastName}
</Text> </Text>
</Title> </Title>
<Paragraph numberOfLines={2}> <Paragraph numberOfLines={2}>
{profileObj.profile.description ? profileObj.profile.description : "We are working on this course description, soon to come!"} {safeProfileObjInfo.description ? safeProfileObjInfo.description : "We are working on this course description, soon to come!"}
</Paragraph> </Paragraph>
{profile.data ? {safeProfile.data ?
<View style={{flexDirection: "row", marginTop:5}}> <View style={{flexDirection: "row", marginTop:5}}>
<Avatar.Image size={64} source={{ uri: photoUrl }} /> <Avatar.Image size={64} source={{ uri: photoUrl }} />
<View> <View>
<Text>By: {profile.data.author ? profile.data.author : 'Working on this'}</Text> <Text>By: {safeProfile.data.author ? safeProfile.data.author : 'Working on this'}</Text>
<Text> <Text>
<Icon name="date-range" /> <Icon name="date-range" />
{profile.data.year ? profile.data.year : 'XXXX'} {safeProfile.data.year ? safeProfile.data.year : 'XXXX'}
</Text> </Text>
<Text> <Text>
<Icon name="timer" /> <Icon name="timer" />
{profile.data.duration ? profile.data.duration : '??'} {safeProfile.data.duration ? safeProfile.data.duration : '??'}
</Text> </Text>
<Text> <Text>
<Icon name="language" /> <Icon name="language" />
{profile.data.language} {safeProfile.data.language}
</Text> </Text>
</View> </View>
</View> </View>
+12 -8
View File
@@ -31,12 +31,16 @@ const getName = async (key) => {
let ProfileCard = ({ profileid, hideIcon, profileObj }) => { let ProfileCard = ({ profileid, hideIcon, profileObj }) => {
let [profile, setProfile] = useState(profileObj || {}); let [profile, setProfile] = useState(profileObj || {});
const safeProfile = profile || {};
const safeProfileInfo = safeProfile.profile || {};
const safeProfileObj = profileObj || {};
const safeProfileObjInfo = safeProfileObj.profile || {};
const navigation = useNavigation(); const navigation = useNavigation();
useEffect(() => { useEffect(() => {
let subscribed = true; let subscribed = true;
const getData = async () => { const getData = async () => {
if (profileObj._id) return 0; if (safeProfileObj._id) return 0;
let cacheProfile = await getName(profileid); let cacheProfile = await getName(profileid);
if (cacheProfile && cacheProfile.profile) setProfile(cacheProfile); if (cacheProfile && cacheProfile.profile) setProfile(cacheProfile);
let p = await API.getUserProfile(profileid).catch(() => { return {} }); let p = await API.getUserProfile(profileid).catch(() => { return {} });
@@ -49,14 +53,14 @@ let ProfileCard = ({ profileid, hideIcon, profileObj }) => {
} }
}, [profileid]); }, [profileid]);
let icon = profile._id ? (!profile.isGroup ? "person-outline" : "group") : ''; let icon = safeProfile._id ? (!safeProfile.isGroup ? "person-outline" : "group") : '';
icon = icon === "person-outline" && profile.subscription && profile.subscription > (new Date() - 0) ? "assignment-ind" : icon; icon = icon === "person-outline" && safeProfile.subscription && safeProfile.subscription > (new Date() - 0) ? "assignment-ind" : icon;
icon = icon === "group" && profile.isCourse ? "subscriptions" : icon; icon = icon === "group" && safeProfile.isCourse ? "subscriptions" : icon;
icon = icon === "group" && profile.isPrivate ? "screen-lock-portrait" : icon; icon = icon === "group" && safeProfile.isPrivate ? "screen-lock-portrait" : icon;
let photoUrl = profile.profile.photo ? 'https://social.emmint.com/' + profile.profile.photo : DefaultPhoto; let photoUrl = safeProfileInfo.photo ? 'https://social.emmint.com/' + safeProfileInfo.photo : DefaultPhoto;
const onPress = () => { const onPress = () => {
return navigation.navigate('Profile', { profileid: profile._id }) return navigation.navigate('Profile', { profileid: safeProfile._id })
} }
return ( return (
@@ -70,7 +74,7 @@ let ProfileCard = ({ profileid, hideIcon, profileObj }) => {
<List.Item <List.Item
title={<ProfilePhotoCircle profileid={profile._id} />} title={<ProfilePhotoCircle profileid={profile._id} />}
description={profileObj.profile.description} description={safeProfileObjInfo.description || ""}
//left={props => <List.Icon {...props} icon={icon} />} //left={props => <List.Icon {...props} icon={icon} />}
titleStyle={{fontWeight:"bold", fontSize:20}} titleStyle={{fontWeight:"bold", fontSize:20}}
descriptionStyle={{}} descriptionStyle={{}}
+61 -5
View File
@@ -1,8 +1,8 @@
import React, { useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import { Text, View, StyleSheet, Image } from 'react-native'; import { Text, View, StyleSheet, Image } from 'react-native';
import { TextInput, Button, HelperText } from 'react-native-paper'; import { TextInput, Button, HelperText } from 'react-native-paper';
import API from './../API.js'; import API from './../API.js';
import { useNavigation } from '@react-navigation/native'; import { useNavigation, useRoute } from '@react-navigation/native';
import i18n from "../i18nMessages.js"; import i18n from "../i18nMessages.js";
@@ -11,7 +11,12 @@ let LoginForm = () => {
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [error, setError] = useState(''); const [error, setError] = useState('');
const [tries, setTries] = useState(0); const [tries, setTries] = useState(0);
const [tokenLinkSending, setTokenLinkSending] = useState(false);
const [tokenLoginLoading, setTokenLoginLoading] = useState(false);
const [tokenLoginError, setTokenLoginError] = useState('');
const navigation = useNavigation(); const navigation = useNavigation();
const route = useRoute();
const handledTokenRef = useRef('');
const resetPasswordBol = tries > 2; const resetPasswordBol = tries > 2;
const resetPassword = async () => { const resetPassword = async () => {
@@ -40,13 +45,51 @@ let LoginForm = () => {
//alert(i18n.t('message.wrongInformation')) //alert(i18n.t('message.wrongInformation'))
}; };
const requestOneTimeLink = async () => {
if (!email.trim()) return;
setTokenLinkSending(true);
setTokenLoginError('');
try {
await API.resetPassword(email.trim());
alert("If this account exists, we sent a one-time sign-in link to your email.");
} finally {
setTokenLinkSending(false);
}
};
useEffect(() => {
const token = typeof route?.params?.token === "string" ? route.params.token.trim() : "";
if (!token) return;
if (handledTokenRef.current === token) return;
handledTokenRef.current = token;
let cancelled = false;
const consumeToken = async () => {
setTokenLoginLoading(true);
setTokenLoginError('');
const r = await API.logInWithPasswordToken(token);
if (cancelled) return;
setTokenLoginLoading(false);
if (r?.status === "ok") {
return navigation.reset({
index: 0,
routes: [{ name: 'MainNavigation' }],
});
}
setTokenLoginError(r?.status || "Invalid or expired token");
};
consumeToken();
return () => {
cancelled = true;
};
}, [route?.params?.token]);
return ( return (
<View style={styles.mainView}> <View style={styles.mainView}>
<TextInput <TextInput
style={styles.input} style={styles.input}
onChangeText={text => setEmail(text)} onChangeText={text => setEmail(text)}
defaultValue={email} defaultValue={email}
placeholder="email" placeholder={i18n.t("message.email")}
label={i18n.t("message.email")} label={i18n.t("message.email")}
autoCapitalize='none' autoCapitalize='none'
autoComplete='email' autoComplete='email'
@@ -61,7 +104,7 @@ let LoginForm = () => {
style={styles.input} style={styles.input}
onChangeText={text => setPassword(text)} onChangeText={text => setPassword(text)}
defaultValue={password} defaultValue={password}
placeholder="password" placeholder={i18n.t("message.password")}
textContentType="password" textContentType="password"
label={i18n.t("message.password")} label={i18n.t("message.password")}
secureTextEntry={true} secureTextEntry={true}
@@ -73,6 +116,12 @@ let LoginForm = () => {
{error === 'incorrect password' ? <HelperText type="error" visible={true}> {error === 'incorrect password' ? <HelperText type="error" visible={true}>
{i18n.t("message.reviewPassword")} {i18n.t("message.reviewPassword")}
</HelperText> : <></>} </HelperText> : <></>}
{tokenLoginLoading ? <HelperText type="info" visible={true}>
Signing you in from email link...
</HelperText> : <></>}
{tokenLoginError ? <HelperText type="error" visible={true}>
{tokenLoginError}
</HelperText> : <></>}
<View style={{ flexDirection: "row" }}> <View style={{ flexDirection: "row" }}>
<Button <Button
style={styles.button} style={styles.button}
@@ -93,6 +142,13 @@ let LoginForm = () => {
</Button> : <></> </Button> : <></>
} }
</View> </View>
<Button
style={styles.button}
disabled={!email.trim() || tokenLinkSending}
onPress={requestOneTimeLink}
>
{tokenLinkSending ? "Sending..." : "Email me a one-time sign-in link"}
</Button>
</View> </View>
); );
} }
@@ -113,4 +169,4 @@ const styles = StyleSheet.create({
marginTop: 10, marginTop: 10,
} }
}); });
+57 -24
View File
@@ -1,6 +1,6 @@
// Import necessary dependencies // Import necessary dependencies
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { View, TouchableHighlight, StyleSheet, FlatList, TouchableWithoutFeedback, Share } from 'react-native'; import { View, TouchableHighlight, StyleSheet, FlatList, TouchableWithoutFeedback, TouchableOpacity, Share, Dimensions, PixelRatio } from 'react-native';
import { Button, Text, ProgressBar } from 'react-native-paper'; import { Button, Text, ProgressBar } from 'react-native-paper';
import API from './../API.js'; import API from './../API.js';
import VideoPlayer from './VideoPlayer.js'; import VideoPlayer from './VideoPlayer.js';
@@ -13,6 +13,7 @@ import { useNavigation } from '@react-navigation/native';
import { Image } from 'expo-image'; // Import Image from expo-image import { Image } from 'expo-image'; // Import Image from expo-image
import * as FileSystem from 'expo-file-system'; import * as FileSystem from 'expo-file-system';
import * as Sharing from 'expo-sharing'; import * as Sharing from 'expo-sharing';
import i18n from "../i18nMessages.js";
// Extract Vimeo video ID from content string // Extract Vimeo video ID from content string
const videoIdF = (content) => { const videoIdF = (content) => {
@@ -25,11 +26,10 @@ const videoIdF = (content) => {
// Extract YouTube video ID from content string // Extract YouTube video ID from content string
const youtubeIdF = (content) => { const youtubeIdF = (content) => {
let youtubeTag = content.match(/@youtube:[0-z]+/); // Accept classic 11-char IDs and common ID-safe chars.
let youtubeTag = content.match(/@youtube:([A-Za-z0-9_-]+)/);
if (!youtubeTag) return ''; if (!youtubeTag) return '';
let tag = youtubeTag; return youtubeTag[1];
tag = tag[0].substring(1);
return tag.split(':')[1];
}; };
// Extract HLS URL from content string // Extract HLS URL from content string
@@ -70,8 +70,12 @@ let Media = (props) => {
const viewer = gState.me; const viewer = gState.me;
// Extracting tags from content // Extracting tags from content
const imagesTag = imagesTagF(props.content, props.imageWidth || 1000, props.imageHeight || 1000); const screenWidth = Dimensions.get('window').width;
const requestedImageWidth = props.imageWidth || Math.max(1200, Math.ceil(screenWidth * PixelRatio.get() * 1.2));
const requestedImageHeight = props.imageHeight || requestedImageWidth;
const imagesTag = imagesTagF(props.content, requestedImageWidth, requestedImageHeight);
const imagesTagLimited = imagesTag.slice(0, 10); const imagesTagLimited = imagesTag.slice(0, 10);
const isImagesCapped = imagesTag.length > imagesTagLimited.length;
const imageStyle = imagesTag.length === 1 ? styles.image : styles.multipleImage; const imageStyle = imagesTag.length === 1 ? styles.image : styles.multipleImage;
const videosId = videoIdF(props.content); const videosId = videoIdF(props.content);
const hlsUrl = hlsIdF(props.content); const hlsUrl = hlsIdF(props.content);
@@ -106,7 +110,13 @@ let Media = (props) => {
const renderVideo = () => { const renderVideo = () => {
if (videosFiles.length && !props.skiptVideo) { if (videosFiles.length && !props.skiptVideo) {
return loaded ? ( return loaded ? (
<VideoPlayer videosFiles={videosFiles} poster={poster} videoId={videosId[1]} /> <VideoPlayer
videosFiles={videosFiles}
poster={poster}
postId={props.postId}
profileId={props.post?.profileid}
videoId={videosId[1]}
/>
) : ( ) : (
<TouchableHighlight onPress={() => setLoaded(true)}> <TouchableHighlight onPress={() => setLoaded(true)}>
<Image source={poster ? { uri: poster } : {}} key={poster} style={styles.poster} cachePolicy="memory-disk" /> <Image source={poster ? { uri: poster } : {}} key={poster} style={styles.poster} cachePolicy="memory-disk" />
@@ -123,7 +133,7 @@ let Media = (props) => {
const renderHlsVideo = () => { const renderHlsVideo = () => {
if (hlsUrl && !props.skiptVideo) { if (hlsUrl && !props.skiptVideo) {
return loaded ? ( return loaded ? (
<VideoPlayer videoUrl={hlsUrl} postId={props.postId} /> <VideoPlayer videoUrl={hlsUrl} postId={props.postId} profileId={props.post?.profileid} />
) : ( ) : (
<TouchableHighlight onPress={() => { <TouchableHighlight onPress={() => {
GlobalState.currentMedia = hlsUrl; GlobalState.currentMedia = hlsUrl;
@@ -163,7 +173,13 @@ let Media = (props) => {
return ( return (
<WebView <WebView
style={styles.iframe} style={styles.iframe}
source={{ uri: "https://www.youtube.com/embed/" + youtubeId + "?fs=0" }} source={{
uri: "https://www.youtube.com/embed/" + youtubeId + "?fs=0",
headers: {
Referer: "https://social.emmint.com",
"Referrer-Policy": "strict-origin-when-cross-origin",
},
}}
/> />
); );
} }
@@ -223,19 +239,26 @@ let Media = (props) => {
return ( return (
<View style={{ paddingTop: 10, paddingBottom: 3 }}> <View style={{ paddingTop: 10, paddingBottom: 3 }}>
{imagesTag.length > 2 ? ( {imagesTag.length > 2 ? (
<FlatList <>
horizontal <FlatList
data={imagesTagLimited} horizontal
renderItem={renderImages} data={imagesTagLimited}
keyExtractor={(item) => item[1]} renderItem={renderImages}
initialNumToRender={2} keyExtractor={(item) => item[1]}
style={{ initialNumToRender={2}
transform: [{ scale: 1.1 }], style={{
paddingTop: 5, transform: [{ scale: 1.1 }],
paddingBottom: 10, paddingTop: 5,
}} paddingBottom: 10,
showsHorizontalScrollIndicator={false} }}
/> showsHorizontalScrollIndicator={false}
/>
{isImagesCapped ? (
<TouchableOpacity onPress={() => navigateToSlideshow(0)} style={styles.seeAllPhotosWrap}>
<Text style={styles.seeAllPhotosText}>{i18n.t("message.clickToSeeAllPhotos")}</Text>
</TouchableOpacity>
) : <></>}
</>
) : ( ) : (
<View style={{ flexDirection: "row" }}> <View style={{ flexDirection: "row" }}>
{imagesTag.map((image, i) => ( {imagesTag.map((image, i) => (
@@ -288,5 +311,15 @@ const styles = StyleSheet.create({
iframe: { iframe: {
width: "100%", width: "100%",
minHeight: 300, minHeight: 300,
} },
}); seeAllPhotosWrap: {
paddingTop: 2,
paddingBottom: 6,
paddingLeft: 6,
},
seeAllPhotosText: {
color: "#5f6368",
textDecorationLine: "underline",
fontSize: 13,
},
});
+136 -41
View File
@@ -1,48 +1,108 @@
import React, { useState } from 'react'; import React, { useEffect, useState } from 'react';
import { View, StyleSheet, Icon } from 'react-native'; import { View, StyleSheet, Pressable } from 'react-native';
import { TextInput, Button } from 'react-native-paper'; import { TextInput, Chip } from 'react-native-paper';
import MaterialIcons from 'react-native-vector-icons/MaterialIcons';
import API from './../API.js'; import API from './../API.js';
import i18n from "../i18nMessages.js";
import { useNavigation } from '@react-navigation/native'; import { useNavigation } from '@react-navigation/native';
import AwesomeIcon from 'react-native-vector-icons/FontAwesome'; import { useSnapshot } from 'valtio';
import GlobalState from '../contexts/GlobalState.js';
import { createBibleToken } from '../utils/bibleReferences.js';
const CircleIconAction = ({ icon, onPress, color = "#6b7280", disabled = false }) => (
<Pressable
onPress={onPress}
disabled={disabled}
style={({ pressed }) => ({
width: 36,
height: 36,
borderRadius: 18,
alignItems: "center",
justifyContent: "center",
backgroundColor: disabled ? "#f3f4f6" : (pressed ? "#e5e7eb" : "#f9fafb"),
marginHorizontal: 2,
transform: [{ scale: pressed ? 0.96 : 1 }],
})}
>
<MaterialIcons name={icon} size={20} color={disabled ? "#9ca3af" : color} />
</Pressable>
);
let NewComment = ({ postid, newComentAdded }) => { let NewComment = ({ postid, newComentAdded }) => {
let [commentContent, setCommentContent] = useState(''); const gState = useSnapshot(GlobalState);
const [commentContent, setCommentContent] = useState('');
const [bibleReferences, setBibleReferences] = useState([]);
const navigation = useNavigation(); const navigation = useNavigation();
const biblePickerSelectionTs = gState?.biblePickerSelection?.ts;
const hasContent = commentContent.trim().length > 0;
useEffect(() => {
const selection = gState?.biblePickerSelection;
if (!selection || selection.target !== "comment" || !selection.reference) return;
setBibleReferences((prev) => {
if (prev.includes(selection.reference)) return prev;
return prev.concat(selection.reference);
});
GlobalState.biblePickerSelection = null;
}, [biblePickerSelectionTs, gState?.biblePickerSelection]);
const handleSubmit = () => {
if (!hasContent && bibleReferences.length === 0) return;
const trimmedComment = commentContent.trim();
const bibleTokens = bibleReferences.map((reference) => createBibleToken(reference)).filter(Boolean);
setCommentContent('');
setBibleReferences([]);
API.newPostComment(postid, [trimmedComment, bibleTokens.join(" ")].join(" ").trim()).then((newPost) => {
if (newComentAdded) newComentAdded(newPost);
});
};
return ( return (
<View style={styles.NewComment}> <View style={styles.newComment}>
<TextInput <View style={styles.composerShell}>
label="New Comment" <CircleIconAction
value={commentContent} icon="menu-book"
onChangeText={setCommentContent} onPress={() => navigation.navigate("BiblePicker", { target: "comment" })}
mode="outlined" color="#6b7280"
multiline={true} />
dense={true} <View style={styles.inputWrap}>
style={{ <TextInput
flex: 8, mode="flat"
fontSize: 12, placeholder={i18n.t("message.newComment")}
backgroundColor: "white", value={commentContent}
}} onChangeText={setCommentContent}
/> multiline
<View style={{ maxLength={500}
flex: 2, dense
fontSize: 12, underlineColor="transparent"
flexDirection: "column", activeUnderlineColor="transparent"
alignItems: "center", // ignore this - we'll come back to it style={styles.input}
justifyContent: "center", contentStyle={styles.inputContent}
}}> />
<Button mode="outlined" onPress={() => { </View>
if (commentContent.trim() === "") return 0; <CircleIconAction
setCommentContent(''); icon="send"
API.newPostComment(postid, commentContent).then((newPost) => { onPress={handleSubmit}
setCommentContent(''); disabled={!hasContent && bibleReferences.length === 0}
if(newComentAdded) newComentAdded(newPost); color="#0d6efd"
}); />
}}>
<AwesomeIcon name="send" />
</Button>
</View> </View>
{bibleReferences.length ? (
<View style={styles.referencesWrap}>
{bibleReferences.map((reference) => (
<Chip
key={reference}
style={styles.referenceChip}
onPress={() => navigation.navigate("BibleChapter", { reference })}
onClose={() => {
setBibleReferences((prev) => prev.filter((item) => item !== reference));
}}
>
{reference}
</Chip>
))}
</View>
) : null}
</View> </View>
); );
} }
@@ -50,9 +110,44 @@ let NewComment = ({ postid, newComentAdded }) => {
export default NewComment; export default NewComment;
const styles = StyleSheet.create({ const styles = StyleSheet.create({
NewComment: { newComment: {
margin: 10, marginHorizontal: 10,
marginTop: 10,
marginBottom: 6,
},
composerShell: {
borderWidth: 1,
borderColor: "#e5e7eb",
borderRadius: 28,
backgroundColor: "#ffffff",
paddingHorizontal: 4,
paddingVertical: 2,
flexDirection: "row", flexDirection: "row",
flex: 6 alignItems: "flex-end",
} shadowColor: "#111827",
}); shadowOpacity: 0.08,
shadowRadius: 10,
shadowOffset: { width: 0, height: 3 },
elevation: 2,
},
inputWrap: {
flex: 1,
paddingRight: 2,
},
input: {
backgroundColor: "transparent",
maxHeight: 120,
},
inputContent: {
paddingVertical: 10,
},
referencesWrap: {
flexDirection: "row",
flexWrap: "wrap",
marginTop: 8,
},
referenceChip: {
marginRight: 6,
marginBottom: 6,
},
});
+3 -3
View File
@@ -21,7 +21,7 @@ let NewPost = ({ profileid, newPostCB }) => {
mediaTypes: ImagePicker.MediaTypeOptions.Images, mediaTypes: ImagePicker.MediaTypeOptions.Images,
//allowsEditing: true, //allowsEditing: true,
//aspect: [4, 3], //aspect: [4, 3],
quality: 0.2, quality: 0.85,
allowsMultipleSelection: true, allowsMultipleSelection: true,
}); });
if (!result.canceled) { if (!result.canceled) {
@@ -104,7 +104,7 @@ let NewPost = ({ profileid, newPostCB }) => {
</View> </View>
{photo && ( {photo && (
<View> <View>
<Text>Uploading...</Text> <Text>{i18n.t("message.uploading")}</Text>
<Image <Image
source={{ uri: photo.uri }} source={{ uri: photo.uri }}
style={{ width: 100, height: 100 }} style={{ width: 100, height: 100 }}
@@ -131,4 +131,4 @@ const styles = StyleSheet.create({
margin: 10, margin: 10,
padding: 10, padding: 10,
} }
}); });
+293 -32
View File
@@ -1,6 +1,5 @@
import React, { useState } from 'react'; import React, { useMemo, useRef, useState } from 'react';
import { Text, ScrollView, FlatList, StyleSheet, View, Share } from 'react-native'; import { Text, Pressable, StyleSheet, View, Share, Alert, Linking, Animated, PanResponder } from 'react-native';
import Hyperlink from 'react-native-hyperlink'
import { Button, Card, Chip } from 'react-native-paper'; import { Button, Card, Chip } from 'react-native-paper';
import API from './../API.js'; import API from './../API.js';
import UserName from './UserName.js'; import UserName from './UserName.js';
@@ -13,18 +12,58 @@ import GlobalState from '../contexts/GlobalState.js';
import i18n from "../i18nMessages.js"; import i18n from "../i18nMessages.js";
import ProfilePhotoCircle from './ProfilePhotoCircle.js'; import ProfilePhotoCircle from './ProfilePhotoCircle.js';
import { posthog } from './../PostHog.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) => { let Post = (props) => {
const gState = useSnapshot(GlobalState); const gState = useSnapshot(GlobalState);
const viewer = gState.me; const viewer = gState.me;
let [showCommentsB, changeshowCommentsB] = useState(false); let [showCommentsB, changeshowCommentsB] = useState(props.showComments || false);
let [post, changePost] = useState(props.post); let [post, changePost] = useState(props.post);
React.useEffect(() => {
changePost(props.post);
}, [props.post]);
const [deleted, setDeleted] = useState(false);
let [likes, changeLikes] = useState(Object.keys(post.reactions).length); let [likes, changeLikes] = useState(Object.keys(post.reactions).length);
let [bookmarked, changeBookmarked] = useState(post.bookmarks && post.bookmarks.includes(viewer._id)); let [bookmarked, changeBookmarked] = useState(post.bookmarks && post.bookmarks.includes(viewer._id));
const [translating, setTranslating] = useState(false);
const isOwner = String(post.profileid || '') === String(viewer?._id || '');
const swipeX = useRef(new Animated.Value(0)).current;
const mediaGestureActiveRef = useRef(false);
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 textWithoutUrls = cleanContent.replace(/(https?:\/\/[^\s]+)/g, '').trim();
const hasTextContent = textWithoutUrls && textWithoutUrls.length > 0 && cleanContent.length < 1000;
const currentLang = i18n.locale.substring(0, 2).toLowerCase();
const hasTranslation = post.translations && post.translations[currentLang];
const [showTranslation, setShowTranslation] = useState(!!hasTranslation);
const requestTranslation = async () => {
setTranslating(true);
try {
await API.translatePost(post._id, i18n.locale);
setTimeout(async () => {
const updatedPost = await API.getPost(post._id);
if (updatedPost && updatedPost.translations && updatedPost.translations[i18n.locale.substring(0, 2).toLowerCase()]) {
changePost(updatedPost);
setShowTranslation(true);
}
setTranslating(false);
}, 3000);
} catch (e) {
setTranslating(false);
}
};
//cleanContent = convertLinks(cleanContent); //cleanContent = convertLinks(cleanContent);
const newComentAdded = (commentData) => { const newComentAdded = (commentData) => {
let newPostObj = { ...post }; let newPostObj = { ...post };
@@ -62,37 +101,192 @@ let Post = (props) => {
API.removePostBookmark(post._id) API.removePostBookmark(post._id)
} }
} }
const renderComment = ({ item }) => ( const handleTagPress = (tag) => {
<Comment comment={item} postid={post._id} /> // Alert.alert("tag pressed", `You pressed the tag: ${tag}`);
); // You can navigate to another screen or perform any other action here
return ( //remove hastag from tag
tag = tag.replace("#", "");
navigation.navigate("Tags", { tag: tag });
};
const handleLinkPress = (url) => {
Linking.canOpenURL(url)
.then((supported) => {
if (supported) {
Linking.openURL(url);
} else {
Alert.alert('Error', 'Unable to open the link.');
}
})
.catch((err) => console.error('An error occurred', err));
};
const handleLinkLongPress = (url) => {
Share.share({
url: url
});
};
const closeSwipe = () => {
Animated.spring(swipeX, { toValue: 0, useNativeDriver: true }).start();
};
const deletePost = async () => {
const result = await API.deletePost(post._id);
if (result?.status !== "ok") {
Alert.alert("Could not delete post", result?.status || "Please try again.");
return closeSwipe();
}
setDeleted(true);
};
const confirmDelete = () => {
Alert.alert(
"Delete post?",
"This action cannot be undone.",
[
{ text: "Cancel", style: "cancel", onPress: closeSwipe },
{ text: "Delete", style: "destructive", onPress: deletePost },
]
);
};
const panResponder = useMemo(() => PanResponder.create({
onMoveShouldSetPanResponder: (_, gestureState) =>
isOwner &&
!mediaGestureActiveRef.current &&
Math.abs(gestureState.dx) > 8 &&
Math.abs(gestureState.dx) > Math.abs(gestureState.dy),
onPanResponderMove: (_, gestureState) => {
if (gestureState.dx > 0) {
swipeX.setValue(0);
return;
}
swipeX.setValue(Math.max(gestureState.dx, -SWIPE_WIDTH));
},
onPanResponderRelease: (_, gestureState) => {
const open = gestureState.dx < -SWIPE_WIDTH / 2;
Animated.spring(swipeX, {
toValue: open ? -SWIPE_WIDTH : 0,
useNativeDriver: true,
}).start();
},
onPanResponderTerminate: closeSwipe,
}), [isOwner, swipeX]);
if (deleted) return null;
const postCard = (
<Card style={styles.card}> <Card style={styles.card}>
<Card.Content style={{ <Card.Content style={{
padding: 0, padding: 0,
margin: 0, margin: 0,
marginBottom: 0 marginBottom: 0
}}> }}>
<Hyperlink linkDefault={true} linkStyle={{ color: '#2980b9' }}>
{!post.nonOrganicType ?
<View>
<ProfilePhotoCircle profileid={post.profileid} />
<View style={{ flexDirection: 'row', alignItems: 'center', margin: 0, marginLeft: 37, marginTop: -14, paddingBottom: 2 }}>
{toProfileText}
{!post.nonOrganicType ?
<View>
<ProfilePhotoCircle profileid={post.profileid} />
<View style={{ flexDirection: 'row', alignItems: 'center', margin: 0, marginLeft: 37, marginTop: -14, paddingBottom: 2 }}>
{toProfileText}
</View>
<Pressable onLongPress={() => {
if (cleanContent.length > 10) {
Share.share({
message: cleanContent
});
}
}}>
<ParsedText
style={styles.text}
parse={[
{ pattern: /#(\w+)/, style: styles.tag, onPress: handleTagPress },
{ pattern: /(https?:\/\/[^\s]+)/, style: styles.link, onPress: handleLinkPress, onLongPress: handleLinkLongPress },
]}
>
{showTranslation && hasTranslation ? stripInlineTags(stripBibleTokens(post.translations[currentLang])) : cleanContent}
</ParsedText>
</Pressable>
{hasTextContent && (
<View style={{ paddingLeft: 40, paddingRight: 8, marginTop: 4 }}>
{hasTranslation && showTranslation ? (
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingRight: 4 }}>
<Text style={{ fontSize: 11, color: '#16a34a', fontStyle: 'italic' }}>
{i18n.t("message.aiTranslated") || "AI Translated"}
</Text>
<Pressable onPress={() => setShowTranslation(false)}>
<Text style={{ color: '#6b7280', fontSize: 11, fontWeight: '500' }}>
{i18n.t("message.seeOriginal") || "See Original"}
</Text>
</Pressable>
</View>
) : (
<Pressable onPress={() => {
if (hasTranslation) {
setShowTranslation(true);
} else {
requestTranslation();
}
}}>
<Text style={{ color: '#2563eb', fontSize: 13, fontWeight: '600', marginBottom: 6 }}>
{translating ? (i18n.t("message.translating") || "Translating...") : (i18n.t("message.seeTranslation") || "See Translation")}
</Text>
</Pressable>
)}
</View> </View>
)}
<View style={{ paddingLeft: 40, paddingRight: 8 }}>
<BibleEmbeddedView content={post.content} openChapterOnPress />
</View>
<Text style={{ fontSize: 16, padding: 3, paddingLeft: 40 }}>{ <View
cleanContent onStartShouldSetResponderCapture={() => {
}</Text> mediaGestureActiveRef.current = true;
return false;
}}
onMoveShouldSetResponderCapture={() => {
mediaGestureActiveRef.current = true;
return false;
}}
onResponderRelease={() => {
mediaGestureActiveRef.current = false;
}}
onResponderTerminate={() => {
mediaGestureActiveRef.current = false;
}}
onTouchEnd={() => {
mediaGestureActiveRef.current = false;
}}
onTouchCancel={() => {
mediaGestureActiveRef.current = false;
}}
>
<Media content={post.content} postId={post._id} post={post} style={{ paddingTop: 2 }} /> <Media content={post.content} postId={post._id} post={post} style={{ paddingTop: 2 }} />
</View> : </View>
<View> </View> :
<Chip icon="new-releases" style={{ width: 100 }} >{i18n.t("message.news")}</Chip> <View>
<Text style={{ fontSize: 18 }}>{cleanContent}</Text> <Chip icon="new-releases" style={{ width: 100 }} >{i18n.t("message.news")}</Chip>
<Text style={{ fontSize: 18 }}>{cleanContent}</Text>
<View
onStartShouldSetResponderCapture={() => {
mediaGestureActiveRef.current = true;
return false;
}}
onMoveShouldSetResponderCapture={() => {
mediaGestureActiveRef.current = true;
return false;
}}
onResponderRelease={() => {
mediaGestureActiveRef.current = false;
}}
onResponderTerminate={() => {
mediaGestureActiveRef.current = false;
}}
onTouchEnd={() => {
mediaGestureActiveRef.current = false;
}}
onTouchCancel={() => {
mediaGestureActiveRef.current = false;
}}
>
<Media content={post.content} /> <Media content={post.content} />
</View> </View>
} </View>
</Hyperlink> }
</Card.Content> </Card.Content>
<Card.Actions style={{ flexDirection: "row", flow: 4, fontSize: 16, marginLeft: 36, marginTop: -10 }}> <Card.Actions style={{ flexDirection: "row", flow: 4, fontSize: 16, marginLeft: 36, marginTop: -10 }}>
<Button <Button
@@ -105,7 +299,13 @@ let Post = (props) => {
{likes} {likes}
</Button> </Button>
<Button icon="forum" labelStyle={{ fontSize: 17 }} style={{ flow: 1 }} <Button icon="forum" labelStyle={{ fontSize: 17 }} style={{ flow: 1 }}
onPress={() => { changeshowCommentsB(!showCommentsB) }} onPress={() => {
// changeshowCommentsB(!showCommentsB) // Show comments
// Change view to single post
navigation.navigate("SinglePost", {
postid: post._id,
});
}}
color="#555" color="#555"
> >
{post.comments.length} {post.comments.length}
@@ -133,15 +333,32 @@ let Post = (props) => {
{showCommentsB && <NewComment postid={post._id} newComentAdded={newComentAdded} />} {showCommentsB && <NewComment postid={post._id} newComentAdded={newComentAdded} />}
{ {
showCommentsB && showCommentsB &&
<ScrollView style={{ maxHeight: 300 }}> <View>
<FlatList {post.comments.map((comment, index) => (
data={post.comments} <Comment
renderItem={renderComment} key={`${comment?.createdAt || "comment"}-${index}`}
keyExtractor={item => item.createdAt} comment={comment}
/></ScrollView> postid={post._id}
/>
))}
</View>
} }
</Card> </Card>
); );
if (!isOwner) return postCard;
return (
<View style={styles.swipeWrap}>
<View style={styles.deleteActionWrap}>
<Pressable style={styles.deleteActionBtn} onPress={confirmDelete}>
<Text style={styles.deleteActionText}>{i18n.t("message.delete")}</Text>
</Pressable>
</View>
<Animated.View style={{ transform: [{ translateX: swipeX }] }} {...panResponder.panHandlers}>
{postCard}
</Animated.View>
</View>
);
} }
export default React.memo(Post); export default React.memo(Post);
@@ -160,9 +377,53 @@ const styles = StyleSheet.create({
marginBottom: 2, marginBottom: 2,
padding: 0 padding: 0
}, },
swipeWrap: {
position: "relative",
backgroundColor: "#edf2f7",
},
deleteActionWrap: {
position: "absolute",
right: 0,
top: 0,
bottom: 2,
width: 86,
justifyContent: "center",
alignItems: "center",
backgroundColor: "#b3261e",
},
deleteActionBtn: {
width: "100%",
height: "100%",
justifyContent: "center",
alignItems: "center",
},
deleteActionText: {
color: "#fff",
fontWeight: "700",
},
comment: { comment: {
margin: 8, margin: 8,
marginTop: 0, marginTop: 0,
padding: 8 padding: 8
} },
text: {
fontSize: 16,
padding: 3,
paddingLeft: 40
},
tag: {
color: '#77B5FE',
textDecorationLine: 'underline',
},
link: {
color: '#77B5FE',
textDecorationLine: 'underline',
},
}); });
const stripInlineTags = (content = "") => {
return String(content || "")
.replace(/@[A-Za-z]+:[^\s]+/g, "")
.replace(/[ \t]{2,}/g, " ")
.replace(/[ \t]+\n/g, "\n")
.trim();
};
+9 -12
View File
@@ -1,6 +1,5 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Text, ScrollView, FlatList, StyleSheet, View, Share } from 'react-native'; import { Text, ScrollView, FlatList, StyleSheet, View, Share } from 'react-native';
import Hyperlink from 'react-native-hyperlink'
import { Button, Card, Chip } from 'react-native-paper'; import { Button, Card, Chip } from 'react-native-paper';
import API from '../API.js'; import API from '../API.js';
import UserName from './UserName.js'; import UserName from './UserName.js';
@@ -44,17 +43,15 @@ let Post = (props) => {
margin: 0, margin: 0,
marginBottom: 0 marginBottom: 0
}}> }}>
<Hyperlink linkDefault={true} linkStyle={{ color: '#2980b9' }}> <View>
<View> <Text style={{ fontSize: 18 }}>{cleanContent}</Text>
<Text style={{ fontSize: 18 }}>{cleanContent}</Text> <Media content={post.content} />
<Media content={post.content} /> <FlatList data={userIds}
<FlatList data={userIds} horizontal={true}
horizontal={true} renderItem={renderPost}
renderItem={renderPost} keyExtractor={(item, index) => `${item}-${index}`}
//keyExtractor={item => item} />
/> </View>
</View>
</Hyperlink>
</Card.Content> </Card.Content>
</Card> </Card>
); );
+13 -9
View File
@@ -30,12 +30,16 @@ const getName = async (key) => {
let ProfileCard = ({ profileid, hideIcon, profileObj }) => { let ProfileCard = ({ profileid, hideIcon, profileObj }) => {
let [profile, setProfile] = useState(profileObj || {}); let [profile, setProfile] = useState(profileObj || {});
const safeProfile = profile || {};
const safeProfileInfo = safeProfile.profile || {};
const safeProfileObj = profileObj || {};
const safeProfileObjInfo = safeProfileObj.profile || {};
const navigation = useNavigation(); const navigation = useNavigation();
useEffect(() => { useEffect(() => {
let subscribed = true; let subscribed = true;
const getData = async () => { const getData = async () => {
if (profileObj._id) return 0; if (safeProfileObj._id) return 0;
let cacheProfile = await getName(profileid); let cacheProfile = await getName(profileid);
if (cacheProfile && cacheProfile.profile && subscribed) setProfile(cacheProfile); if (cacheProfile && cacheProfile.profile && subscribed) setProfile(cacheProfile);
let p = await API.getUserProfile(profileid).catch(() => { return {} }); let p = await API.getUserProfile(profileid).catch(() => { return {} });
@@ -48,13 +52,13 @@ let ProfileCard = ({ profileid, hideIcon, profileObj }) => {
} }
}, [profileid]); }, [profileid]);
let icon = profile._id ? (!profile.isGroup ? "person-outline" : "group") : ''; let icon = safeProfile._id ? (!safeProfile.isGroup ? "person-outline" : "group") : '';
icon = icon === "person-outline" && profile.subscription && profile.subscription > (new Date() - 0) ? "assignment-ind" : icon; icon = icon === "person-outline" && safeProfile.subscription && safeProfile.subscription > (new Date() - 0) ? "assignment-ind" : icon;
icon = icon === "group" && profile.isCourse ? "subscriptions" : icon; icon = icon === "group" && safeProfile.isCourse ? "subscriptions" : icon;
let photoUrl = profile.profile.photo ? 'https://social.emmint.com/' + profile.profile.photo : DefaultPhoto; let photoUrl = safeProfileInfo.photo ? 'https://social.emmint.com/' + safeProfileInfo.photo : DefaultPhoto;
const onPress = () => { const onPress = () => {
return navigation.navigate('Profile', { profileid: profile._id }) return navigation.navigate('Profile', { profileid: safeProfile._id })
} }
return ( return (
@@ -63,11 +67,11 @@ let ProfileCard = ({ profileid, hideIcon, profileObj }) => {
<Avatar.Image size={150} source={{ uri: photoUrl }} /> <Avatar.Image size={150} source={{ uri: photoUrl }} />
<Title onPress={onPress} numberOfLines={1}> <Title onPress={onPress} numberOfLines={1}>
<Text> <Text>
{profile.profile && profile.profile.firstName} {profile.profile && profile.profile.lastName} {safeProfileInfo.firstName} {safeProfileInfo.lastName}
</Text> </Text>
</Title> </Title>
<Paragraph numberOfLines={2}>{profileObj.profile.description}</Paragraph> <Paragraph numberOfLines={2}>{safeProfileObjInfo.description || ""}</Paragraph>
<FollowButton profile={profile} /> <FollowButton profile={safeProfile} />
</Card.Content> </Card.Content>
</Card> </Card>
+31 -11
View File
@@ -1,24 +1,44 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { View, Share } from 'react-native'; import { View, Share, TouchableOpacity } from 'react-native';
import { Avatar, Button, Card, Title, Paragraph } from 'react-native-paper'; import { Avatar, Button, Card, Title, Paragraph } from 'react-native-paper';
import UserName from './UserName'; import UserName from './UserName';
import FollowButton from './basics/FollowButton'; import FollowButton from './basics/FollowButton';
import { useNavigation } from '@react-navigation/native';
import { useSnapshot } from 'valtio';
import GlobalState from '../contexts/GlobalState.js';
const DefaultPhoto = "https://social.emmint.com/uploads/e6f9be6d665dc43417701bf16a90122c.png"; const DefaultPhoto = "https://social.emmint.com/uploads/e6f9be6d665dc43417701bf16a90122c.png";
const ProfileHeader = ({ profileObj }) => { const ProfileHeader = ({ profileObj }) => {
let photoUrl = profileObj.profile && profileObj.profile.photo ? 'https://social.emmint.com/' + profileObj.profile.photo + '?width=1000&height=1000' : DefaultPhoto; const navigation = useNavigation();
const viewer = useSnapshot(GlobalState).me || {};
const safeProfileObj = profileObj || {};
const safeProfile = safeProfileObj.profile || {};
let photoUrl = safeProfile.photo ? 'https://social.emmint.com/' + safeProfile.photo + '?width=1000&height=1000' : DefaultPhoto;
const canEditProfileImage = !!(
safeProfileObj?._id &&
!safeProfileObj?.isGroup &&
String(safeProfileObj._id) === String(viewer?._id || '')
);
const handlePressPhoto = () => {
if (!canEditProfileImage) return;
navigation.navigate("ProfileSettings");
};
return ( return (
<> <>
<Card elevation={3}> <Card elevation={3}>
<Card.Cover source={{ uri: photoUrl, cache: 'force-cache' }} style={{ <TouchableOpacity activeOpacity={canEditProfileImage ? 0.8 : 1} onPress={handlePressPhoto}>
height: 300 <Card.Cover source={{ uri: photoUrl, cache: 'force-cache' }} style={{
}} /> height: 300
}} />
</TouchableOpacity>
<Card.Content style={{}}> <Card.Content style={{}}>
<Title style={{ position: "absolute", top: -60, left: 10, backgroundColor: "rgba(255,255,255,0.4)", padding: 10 }}> <Title style={{ position: "absolute", top: -60, left: 10, backgroundColor: "rgba(255,255,255,0.4)", padding: 10 }}>
<UserName profileid={profileObj._id} /> <UserName profileid={safeProfileObj._id} />
</Title> </Title>
<Paragraph style={{ paddingTop: 10 }}>{profileObj.profile.description}</Paragraph> <Paragraph style={{ paddingTop: 10 }}>{safeProfile.description || ""}</Paragraph>
<View style={{ <View style={{
position: "absolute", position: "absolute",
top: -290, top: -290,
@@ -29,7 +49,7 @@ const ProfileHeader = ({ profileObj }) => {
borderRadius: 25, borderRadius: 25,
opacity: 0.7 opacity: 0.7
}}> }}>
<FollowButton profile={profileObj} /> <FollowButton profile={safeProfileObj} />
</View> </View>
<View style={{ <View style={{
position: "absolute", position: "absolute",
@@ -43,8 +63,8 @@ const ProfileHeader = ({ profileObj }) => {
}}> }}>
<Button icon="ios-share" labelStyle={{ fontSize: 24, paddingTop:10 }} onPress={() => { <Button icon="ios-share" labelStyle={{ fontSize: 24, paddingTop:10 }} onPress={() => {
Share.share({ Share.share({
message: "https://social.emmint.com/feed/" + profileObj._id, message: "https://social.emmint.com/feed/" + safeProfileObj._id,
title: profileObj.profile.firstName + " " + profileObj.profile.lastName title: (safeProfile.firstName || "") + " " + (safeProfile.lastName || "")
}); });
}}></Button> }}></Button>
</View> </View>
@@ -63,4 +83,4 @@ const ProfileHeader = ({ profileObj }) => {
} }
export default React.memo(ProfileHeader); export default React.memo(ProfileHeader);
+11 -7
View File
@@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Avatar } from 'react-native-paper'; import { Avatar } from 'react-native-paper';
import { View, StyleSheet, Text } from 'react-native'; import { View, StyleSheet, Text, TouchableOpacity } from 'react-native';
import API from './../API.js'; import API from './../API.js';
import { useNavigation } from '@react-navigation/native'; import { useNavigation } from '@react-navigation/native';
import { Image } from 'expo-image'; // Import Image from expo-image import { Image } from 'expo-image'; // Import Image from expo-image
@@ -29,13 +29,17 @@ const ProfileHeader = ({ profileid, withName = false, small = false }) => {
} }
return ( return (
<View style={styles.container}> <View style={styles.container}>
<TouchableOpacity onPress={onPress}>
<Image source={{ uri: photoUrl }} key={photoUrl} <Image source={{ uri: photoUrl }} key={photoUrl}
style={{ style={{
width: small ? 25 : 35, width: small ? 25 : 35,
height: small ? 25 : 35, height: small ? 25 : 35,
aspectRatio: 1, aspectRatio: 1,
borderRadius: 50, borderRadius: 50,
}} cachePolicy="memory-disk"/> }} cachePolicy="memory-disk"
/>
</TouchableOpacity>
<View style={styles.textContainer}> <View style={styles.textContainer}>
<Text style={small ? styles.smallProfileName : styles.profileName} onPress={onPress}>{fullName}</Text> <Text style={small ? styles.smallProfileName : styles.profileName} onPress={onPress}>{fullName}</Text>
</View> </View>
+6 -4
View File
@@ -6,12 +6,14 @@ import UserName from './UserName';
const DefaultPhoto = "https://social.emmint.com/uploads/e6f9be6d665dc43417701bf16a90122c.png"; const DefaultPhoto = "https://social.emmint.com/uploads/e6f9be6d665dc43417701bf16a90122c.png";
const ProfileSmallHeader = ({ profileObj }) => { const ProfileSmallHeader = ({ profileObj }) => {
let photoUrl = profileObj.profile.photo ? 'https://social.emmint.com/' + profileObj.profile.photo : DefaultPhoto; const safeProfileObj = profileObj || {};
const safeProfile = safeProfileObj.profile || {};
let photoUrl = safeProfile.photo ? 'https://social.emmint.com/' + safeProfile.photo : DefaultPhoto;
return ( return (
<> <>
<Card.Title <Card.Title
title={<UserName profileid={profileObj._id} hideIcon={true} />} title={<UserName profileid={safeProfileObj._id} hideIcon={true} />}
subtitle={profileObj.profile.description} subtitle={safeProfile.description || ""}
left={(props) => <Avatar.Image {...props} source={{ uri: photoUrl}} />} left={(props) => <Avatar.Image {...props} source={{ uri: photoUrl}} />}
right={(props) => <IconButton {...props} icon="more-vert" onPress={() => { }} />} right={(props) => <IconButton {...props} icon="more-vert" onPress={() => { }} />}
/> />
@@ -20,4 +22,4 @@ const ProfileSmallHeader = ({ profileObj }) => {
} }
export default React.memo(ProfileSmallHeader); export default React.memo(ProfileSmallHeader);
+7 -7
View File
@@ -57,7 +57,7 @@ let RegisterForm = () => {
style={styles.input} style={styles.input}
onChangeText={text => setPassword(text)} onChangeText={text => setPassword(text)}
defaultValue={password} defaultValue={password}
placeholder="password" placeholder={i18n.t("message.password")}
textContentType="password" textContentType="password"
label={i18n.t("message.password")} label={i18n.t("message.password")}
secureTextEntry={true} secureTextEntry={true}
@@ -70,7 +70,7 @@ let RegisterForm = () => {
style={styles.input} style={styles.input}
onChangeText={text => setPassword2(text)} onChangeText={text => setPassword2(text)}
defaultValue={password} defaultValue={password}
placeholder="same password" placeholder={i18n.t("message.rpassword")}
textContentType="same password" textContentType="same password"
label={i18n.t("message.rpassword")} label={i18n.t("message.rpassword")}
secureTextEntry={true} secureTextEntry={true}
@@ -83,7 +83,7 @@ let RegisterForm = () => {
style={styles.input} style={styles.input}
onChangeText={text => setFirstName(text)} onChangeText={text => setFirstName(text)}
defaultValue={firstName} defaultValue={firstName}
placeholder="First Name" placeholder={i18n.t("message.firstName")}
label={i18n.t("message.name")} label={i18n.t("message.name")}
autoCapitalize='words' autoCapitalize='words'
autoComplete='name' autoComplete='name'
@@ -94,7 +94,7 @@ let RegisterForm = () => {
style={styles.input} style={styles.input}
onChangeText={text => setLastName(text)} onChangeText={text => setLastName(text)}
defaultValue={lastName} defaultValue={lastName}
placeholder="Last Name" placeholder={i18n.t("message.lastName")}
label={i18n.t("message.lastName")} label={i18n.t("message.lastName")}
autoCapitalize='words' autoCapitalize='words'
autoComplete='name' autoComplete='name'
@@ -105,7 +105,7 @@ let RegisterForm = () => {
style={styles.input} style={styles.input}
onChangeText={text => setDescription(text)} onChangeText={text => setDescription(text)}
defaultValue={description} defaultValue={description}
placeholder="Description so others can know you" placeholder={i18n.t("message.describeYourself")}
label={i18n.t("message.describeYourself")} label={i18n.t("message.describeYourself")}
autoCapitalize='sentences' autoCapitalize='sentences'
autoComplete='description' autoComplete='description'
@@ -121,7 +121,7 @@ let RegisterForm = () => {
style={styles.input} style={styles.input}
onChangeText={text => setEmail(text)} onChangeText={text => setEmail(text)}
defaultValue={email} defaultValue={email}
placeholder="email" placeholder={i18n.t("message.email")}
label={i18n.t("message.email")} label={i18n.t("message.email")}
autoCapitalize='none' autoCapitalize='none'
autoComplete='email' autoComplete='email'
@@ -162,4 +162,4 @@ const styles = StyleSheet.create({
marginTop: 10, marginTop: 10,
} }
}); });
+7 -5
View File
@@ -1,9 +1,9 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { ScrollView } from 'react-native'; import { View, ScrollView } from 'react-native';
import API from './../API.js'; import API from './../API.js';
import Post from './Post.js'; import Post from './Post.js';
let SinglePostComponent = ({ postId }) => { let SinglePostComponent = ({ postId, hideComments }) => {
let [post, setPost] = useState({}); let [post, setPost] = useState({});
useEffect(() => { useEffect(() => {
let subscribed = true; let subscribed = true;
@@ -19,9 +19,11 @@ let SinglePostComponent = ({ postId }) => {
} }
}, [postId]); }, [postId]);
return (post._id ? ( return (post._id ? (
<ScrollView> <View style={{ flex: 1 }}>
<Post post={post}/> <ScrollView contentContainerStyle={{ paddingBottom: 18 }}>
</ScrollView> <Post post={post} showComments={hideComments ? false : true} />
</ScrollView>
</View>
) : null); ) : null);
}; };
+4 -2
View File
@@ -43,7 +43,9 @@ const VideoPlayer = ({ videosFiles, postId, profileId, poster, videoUrl, videoSt
postId: postId, postId: postId,
profileId: profileId, profileId: profileId,
}; };
//console.log(postId, newData); if (!GlobalState.me.data) GlobalState.me.data = {};
// Keep local viewer progress in sync so Courses can render immediately.
GlobalState.me.data[postId] = newData;
API.setDataValue(postId, newData); API.setDataValue(postId, newData);
} }
@@ -74,4 +76,4 @@ const styles = StyleSheet.create({
width: 320, width: 320,
height: 200, height: 200,
}, },
}); });
+2
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;
+329 -1
View File
@@ -78,6 +78,88 @@ const messages = {
localMinistry: "Local Ministry", localMinistry: "Local Ministry",
ocupation: "Ocupation", ocupation: "Ocupation",
country: 'Country', country: 'Country',
clickToSeeAllPhotos: "Click to see all photos",
recentlyFollowing: "Recently Following",
changeActiveProfile: "Change Active Profile",
userActions: "User Actions",
fellowshipApp: "Fellowship App",
appLanguage: "App Language",
language: "Language",
languageSpanish: "Spanish",
languageEnglish: "English",
languageDanish: "Danish",
languageFrench: "French",
version: "Version",
channel: "Channel",
postingOn: "Posting on",
whatIsOnYourMindToday: "What is on your mind today?",
addPhotos: "Add Photos",
cancelUpload: "Cancel Upload",
uploading: "Uploading...",
uploadProgress: "Upload Progress",
newGroup: "New Group",
changeImage: "Change image",
addGroupImage: "Add group image",
groupName: "Group name",
subtitleOptional: "Subtitle (optional)",
requireApprovalBeforeJoin: "Require approval before people can join.",
createGroupAction: "Create Group",
newComment: "New Comment",
currentProfile: "Current Profile",
profileSetting: "Profile Setting",
searchGroups: "Search Groups",
results: "Results:",
yourGroups: "Your Groups:",
images: "Images",
media: "Media",
embedded: "Embedded",
files: "Files",
deleteGroup: "Delete Group",
delete: "Delete",
appName: "EMI Fellowship",
globalChat: "Global Chat",
chatRoomBeta: "Chat room is in beta",
onlineNow: "Online now",
loadingChat: "Loading chat...",
writeMessage: "Write a message...",
send: "Send",
aiTranslated: "AI Translated",
liveCaptions: "Live Captions",
liveCaptionsSubtitle: "Realtime captions for current service",
liveNow: "Live",
translationLanguage: "Translation language",
selectedTranslation: "Selected translation",
originalLanguage: "Original",
loadingLiveCaptions: "Loading live captions...",
awaitingLiveCaptions: "Waiting for live captions...",
translationFallback: "Translation unavailable for this line. Showing original text.",
completeProfileTitle: "Complete your profile",
completeProfileBody: "Add a profile photo and short description so people can recognize you.",
completeProfileMissingHint: "Looks like your profile is still missing some details.",
completeProfileTestingHint: "This reminder is temporarily shown to everyone for testing.",
bible: "Bible",
bibleReferenceTitle: "Bible Reference",
biblePickerSubtitlePost: "Pick a reference to insert into your post.",
biblePickerSubtitleChat: "Pick a reference to insert into your chat message.",
preview: "Preview",
useReference: "Use Reference",
selected: "Selected",
tapPreviewPickVerse: "Tap preview to pick verse from chapter",
tapVerseToSelect: "Tap a verse to select it.",
unableLoadPassage: "Unable to load this Bible passage.",
unableLoadChapter: "Unable to load chapter.",
book: "Book",
chapter: "Chapter",
verse: "Verse",
books: "Books",
chapters: "Chapters",
verses: "Verses",
updateProfile: "Update Profile",
previous: "Previous",
next: "Next",
translating: "Translating...",
seeTranslation: "See Translation",
seeOriginal: "See Original",
}, },
}, },
es: { es: {
@@ -151,6 +233,88 @@ const messages = {
localMinistry: 'Ministerio local', localMinistry: 'Ministerio local',
ocupation: 'Ocupación', ocupation: 'Ocupación',
country: 'País', country: 'País',
clickToSeeAllPhotos: "Haz clic para ver todas las fotos",
recentlyFollowing: "Siguiendo recientemente",
changeActiveProfile: "Cambiar perfil activo",
userActions: "Acciones de usuario",
fellowshipApp: "App Fellowship",
appLanguage: "Idioma de la app",
language: "Idioma",
languageSpanish: "Español",
languageEnglish: "Inglés",
languageDanish: "Danés",
languageFrench: "Francés",
version: "Versión",
channel: "Canal",
postingOn: "Publicando en",
whatIsOnYourMindToday: "¿Qué tienes en mente hoy?",
addPhotos: "Agregar fotos",
cancelUpload: "Cancelar carga",
uploading: "Subiendo...",
uploadProgress: "Progreso de carga",
newGroup: "Nuevo grupo",
changeImage: "Cambiar imagen",
addGroupImage: "Agregar imagen del grupo",
groupName: "Nombre del grupo",
subtitleOptional: "Subtítulo (opcional)",
requireApprovalBeforeJoin: "Requiere aprobación antes de que las personas puedan unirse.",
createGroupAction: "Crear grupo",
newComment: "Nuevo comentario",
currentProfile: "Perfil actual",
profileSetting: "Configuración del perfil",
searchGroups: "Buscar grupos",
results: "Resultados:",
yourGroups: "Tus grupos:",
images: "Imágenes",
media: "Multimedia",
embedded: "Integrado",
files: "Archivos",
deleteGroup: "Eliminar grupo",
delete: "Eliminar",
appName: "EMI Fellowship",
globalChat: "Chat Global",
chatRoomBeta: "La sala de chat está en beta",
onlineNow: "En línea ahora",
loadingChat: "Cargando chat...",
writeMessage: "Escribe un mensaje...",
send: "Enviar",
aiTranslated: "Traducido por IA",
liveCaptions: "Subtítulos en vivo",
liveCaptionsSubtitle: "Subtítulos en tiempo real del servicio actual",
liveNow: "En vivo",
translationLanguage: "Idioma de traducción",
selectedTranslation: "Traducción seleccionada",
originalLanguage: "Original",
loadingLiveCaptions: "Cargando subtítulos en vivo...",
awaitingLiveCaptions: "Esperando subtítulos en vivo...",
translationFallback: "No hay traducción para esta línea. Mostrando texto original.",
completeProfileTitle: "Completa tu perfil",
completeProfileBody: "Agrega una foto de perfil y una breve descripción para que las personas puedan reconocerte.",
completeProfileMissingHint: "Parece que a tu perfil todavía le faltan algunos detalles.",
completeProfileTestingHint: "Este recordatorio se muestra temporalmente a todos para pruebas.",
bible: "Biblia",
bibleReferenceTitle: "Referencia bíblica",
biblePickerSubtitlePost: "Elige una referencia para insertarla en tu publicación.",
biblePickerSubtitleChat: "Elige una referencia para insertarla en tu mensaje de chat.",
preview: "Vista previa",
useReference: "Usar referencia",
selected: "Seleccionado",
tapPreviewPickVerse: "Toca la vista previa para elegir un versículo del capítulo",
tapVerseToSelect: "Toca un versículo para seleccionarlo.",
unableLoadPassage: "No se pudo cargar este pasaje bíblico.",
unableLoadChapter: "No se pudo cargar el capítulo.",
book: "Libro",
chapter: "Capítulo",
verse: "Versículo",
books: "Libros",
chapters: "Capítulos",
verses: "Versículos",
updateProfile: "Actualizar perfil",
previous: "Anterior",
next: "Siguiente",
translating: "Traduciendo...",
seeTranslation: "Ver traducción",
seeOriginal: "Ver original",
} }
}, },
fr: { fr: {
@@ -224,6 +388,88 @@ const messages = {
localMinistry: 'Ministère local', localMinistry: 'Ministère local',
ocupation: 'Occupation', ocupation: 'Occupation',
country: 'Pays', country: 'Pays',
clickToSeeAllPhotos: "Cliquez pour voir toutes les photos",
recentlyFollowing: "Abonnements récents",
changeActiveProfile: "Changer le profil actif",
userActions: "Actions utilisateur",
fellowshipApp: "Application Fellowship",
appLanguage: "Langue de l'application",
language: "Langue",
languageSpanish: "Espagnol",
languageEnglish: "Anglais",
languageDanish: "Danois",
languageFrench: "Français",
version: "Version",
channel: "Canal",
postingOn: "Publication sur",
whatIsOnYourMindToday: "Qu'avez-vous en tête aujourd'hui ?",
addPhotos: "Ajouter des photos",
cancelUpload: "Annuler le téléversement",
uploading: "Téléversement...",
uploadProgress: "Progression du téléversement",
newGroup: "Nouveau groupe",
changeImage: "Changer l'image",
addGroupImage: "Ajouter l'image du groupe",
groupName: "Nom du groupe",
subtitleOptional: "Sous-titre (facultatif)",
requireApprovalBeforeJoin: "Nécessite une approbation avant que les personnes puissent rejoindre.",
createGroupAction: "Créer le groupe",
newComment: "Nouveau commentaire",
currentProfile: "Profil actuel",
profileSetting: "Paramètres du profil",
searchGroups: "Rechercher des groupes",
results: "Résultats :",
yourGroups: "Vos groupes :",
images: "Images",
media: "Médias",
embedded: "Intégré",
files: "Fichiers",
deleteGroup: "Supprimer le groupe",
delete: "Supprimer",
appName: "EMI Fellowship",
globalChat: "Chat global",
chatRoomBeta: "La salle de chat est en version bêta",
onlineNow: "En ligne maintenant",
loadingChat: "Chargement du chat...",
writeMessage: "Écrire un message...",
send: "Envoyer",
aiTranslated: "Traduit par l'IA",
liveCaptions: "Sous-titres en direct",
liveCaptionsSubtitle: "Sous-titres en temps réel du service en cours",
liveNow: "En direct",
translationLanguage: "Langue de traduction",
selectedTranslation: "Traduction sélectionnée",
originalLanguage: "Original",
loadingLiveCaptions: "Chargement des sous-titres en direct...",
awaitingLiveCaptions: "En attente des sous-titres en direct...",
translationFallback: "Traduction indisponible pour cette ligne. Texte original affiché.",
completeProfileTitle: "Complétez votre profil",
completeProfileBody: "Ajoutez une photo de profil et une courte description pour que les autres puissent vous reconnaître.",
completeProfileMissingHint: "Il semble que votre profil manque encore de quelques informations.",
completeProfileTestingHint: "Ce rappel est temporairement affiché à tout le monde pour les tests.",
bible: "Bible",
bibleReferenceTitle: "Référence biblique",
biblePickerSubtitlePost: "Choisissez une référence à insérer dans votre publication.",
biblePickerSubtitleChat: "Choisissez une référence à insérer dans votre message de chat.",
preview: "Aperçu",
useReference: "Utiliser la référence",
selected: "Sélectionné",
tapPreviewPickVerse: "Touchez l'aperçu pour choisir un verset du chapitre",
tapVerseToSelect: "Touchez un verset pour le sélectionner.",
unableLoadPassage: "Impossible de charger ce passage biblique.",
unableLoadChapter: "Impossible de charger le chapitre.",
book: "Livre",
chapter: "Chapitre",
verse: "Verset",
books: "Livres",
chapters: "Chapitres",
verses: "Versets",
updateProfile: "Mettre à jour le profil",
previous: "Précédent",
next: "Suivant",
translating: "Traduction en cours...",
seeTranslation: "Voir la traduction",
seeOriginal: "Voir l'original",
} }
}, },
da: { da: {
@@ -297,6 +543,88 @@ const messages = {
localMinistry: "Lokalt ministerium", localMinistry: "Lokalt ministerium",
ocupation: "Beskæftigelse", ocupation: "Beskæftigelse",
country: 'Land', country: 'Land',
clickToSeeAllPhotos: "Klik for at se alle billeder",
recentlyFollowing: "For nylig fulgt",
changeActiveProfile: "Skift aktiv profil",
userActions: "Brugerhandlinger",
fellowshipApp: "Fellowship App",
appLanguage: "App-sprog",
language: "Sprog",
languageSpanish: "Spansk",
languageEnglish: "Engelsk",
languageDanish: "Dansk",
languageFrench: "Fransk",
version: "Version",
channel: "Kanal",
postingOn: "Udgiver på",
whatIsOnYourMindToday: "Hvad tænker du på i dag?",
addPhotos: "Tilføj billeder",
cancelUpload: "Annuller upload",
uploading: "Uploader...",
uploadProgress: "Uploadforløb",
newGroup: "Ny gruppe",
changeImage: "Skift billede",
addGroupImage: "Tilføj gruppebillede",
groupName: "Gruppenavn",
subtitleOptional: "Undertitel (valgfri)",
requireApprovalBeforeJoin: "Kræver godkendelse før folk kan deltage.",
createGroupAction: "Opret gruppe",
newComment: "Ny kommentar",
currentProfile: "Nuværende profil",
profileSetting: "Profilindstillinger",
searchGroups: "Søg grupper",
results: "Resultater:",
yourGroups: "Dine grupper:",
images: "Billeder",
media: "Medier",
embedded: "Indlejret",
files: "Filer",
deleteGroup: "Slet gruppe",
delete: "Slet",
appName: "EMI Fellowship",
globalChat: "Global chat",
chatRoomBeta: "Chatrummet er i beta",
onlineNow: "Online nu",
loadingChat: "Indlæser chat...",
writeMessage: "Skriv en besked...",
send: "Send",
aiTranslated: "AI-oversat",
liveCaptions: "Live undertekster",
liveCaptionsSubtitle: "Realtidsundertekster for nuværende gudstjeneste",
liveNow: "Live",
translationLanguage: "Oversættelsessprog",
selectedTranslation: "Valgt oversættelse",
originalLanguage: "Original",
loadingLiveCaptions: "Indlæser live undertekster...",
awaitingLiveCaptions: "Venter på live undertekster...",
translationFallback: "Oversættelse er ikke tilgængelig for denne linje. Viser original tekst.",
completeProfileTitle: "Udfyld din profil",
completeProfileBody: "Tilføj et profilbillede og en kort beskrivelse, så andre kan genkende dig.",
completeProfileMissingHint: "Det ser ud til, at din profil stadig mangler nogle detaljer.",
completeProfileTestingHint: "Denne påmindelse vises midlertidigt til alle for test.",
bible: "Bibelen",
bibleReferenceTitle: "Bibelreference",
biblePickerSubtitlePost: "Vælg en reference til at indsætte i dit opslag.",
biblePickerSubtitleChat: "Vælg en reference til at indsætte i din chatbesked.",
preview: "Forhåndsvis",
useReference: "Brug reference",
selected: "Valgt",
tapPreviewPickVerse: "Tryk på forhåndsvisning for at vælge et vers fra kapitlet",
tapVerseToSelect: "Tryk på et vers for at vælge det.",
unableLoadPassage: "Kunne ikke indlæse dette bibelafsnit.",
unableLoadChapter: "Kunne ikke indlæse kapitlet.",
book: "Bog",
chapter: "Kapitel",
verse: "Vers",
books: "Bøger",
chapters: "Kapitler",
verses: "Vers",
updateProfile: "Opdater profil",
previous: "Forrige",
next: "Næste",
translating: "Oversætter...",
seeTranslation: "Se oversættelse",
seeOriginal: "Se original",
} }
} }
} }
@@ -315,4 +643,4 @@ if (messages[devideLocale]) {
} }
moment.locale(i18n.locale); moment.locale(i18n.locale);
export default i18n; export default i18n;
+14
View File
@@ -40,6 +40,7 @@
"react-native-autoheight-webview": "^1.6.1", "react-native-autoheight-webview": "^1.6.1",
"react-native-hyperlink": "0.0.19", "react-native-hyperlink": "0.0.19",
"react-native-paper": "^4.11.2", "react-native-paper": "^4.11.2",
"react-native-parsed-text": "^0.0.22",
"react-native-safe-area-context": "4.10.5", "react-native-safe-area-context": "4.10.5",
"react-native-screens": "3.31.1", "react-native-screens": "3.31.1",
"react-native-vector-icons": "^9.1.0", "react-native-vector-icons": "^9.1.0",
@@ -13160,6 +13161,19 @@
"color-string": "^1.6.0" "color-string": "^1.6.0"
} }
}, },
"node_modules/react-native-parsed-text": {
"version": "0.0.22",
"resolved": "https://registry.npmjs.org/react-native-parsed-text/-/react-native-parsed-text-0.0.22.tgz",
"integrity": "sha512-hfD83RDXZf9Fvth3DowR7j65fMnlqM9PpxZBGWkzVcUTFtqe6/yPcIoIAgrJbKn6YmtzkivmhWE2MCE4JKBXrQ==",
"license": "MIT",
"dependencies": {
"prop-types": "^15.7.x"
},
"peerDependencies": {
"react": "*",
"react-native": "*"
}
},
"node_modules/react-native-safe-area-context": { "node_modules/react-native-safe-area-context": {
"version": "4.10.5", "version": "4.10.5",
"resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-4.10.5.tgz", "resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-4.10.5.tgz",
+1
View File
@@ -42,6 +42,7 @@
"react-native-autoheight-webview": "^1.6.1", "react-native-autoheight-webview": "^1.6.1",
"react-native-hyperlink": "0.0.19", "react-native-hyperlink": "0.0.19",
"react-native-paper": "^4.11.2", "react-native-paper": "^4.11.2",
"react-native-parsed-text": "^0.0.22",
"react-native-safe-area-context": "4.10.5", "react-native-safe-area-context": "4.10.5",
"react-native-screens": "3.31.1", "react-native-screens": "3.31.1",
"react-native-vector-icons": "^9.1.0", "react-native-vector-icons": "^9.1.0",
+465
View File
@@ -0,0 +1,465 @@
import API from '../API.js';
const BIBLE_TOKEN_REGEX = /@bible:([^\s]+)/gi;
const DEFAULT_TRANSLATION = "de4e12af7f28f599-01"; // KJV
export const BIBLE_BOOK_IDS = {
"Genesis": "GEN", "Exodus": "EXO", "Leviticus": "LEV", "Numbers": "NUM", "Deuteronomy": "DEU",
"Joshua": "JOS", "Judges": "JDG", "Ruth": "RUT", "1 Samuel": "1SA", "2 Samuel": "2SA",
"1 Kings": "1KI", "2 Kings": "2KI", "1 Chronicles": "1CH", "2 Chronicles": "2CH", "Ezra": "EZR",
"Nehemiah": "NEH", "Esther": "EST", "Job": "JOB", "Psalms": "PSA", "Proverbs": "PRO",
"Ecclesiastes": "ECC", "Song of Solomon": "SNG", "Isaiah": "ISA", "Jeremiah": "JER",
"Lamentations": "LAM", "Ezekiel": "EZK", "Daniel": "DAN", "Hosea": "HOS", "Joel": "JOL",
"Amos": "AMO", "Obadiah": "OBA", "Jonah": "JON", "Micah": "MIC", "Nahum": "NAM",
"Habakkuk": "HAB", "Zephaniah": "ZEP", "Haggai": "HAG", "Zechariah": "ZEC", "Malachi": "MAL",
"Matthew": "MAT", "Mark": "MRK", "Luke": "LUK", "John": "JHN", "Acts": "ACT",
"Romans": "ROM", "1 Corinthians": "1CO", "2 Corinthians": "2CO", "Galatians": "GAL",
"Ephesians": "EPH", "Philippians": "PHP", "Colossians": "COL", "1 Thessalonians": "1TH",
"2 Thessalonians": "2TH", "1 Timothy": "1TI", "2 Timothy": "2TI", "Titus": "TIT",
"Philemon": "PHM", "Hebrews": "HEB", "James": "JAS", "1 Peter": "1PE", "2 Peter": "2PE",
"1 John": "1JN", "2 John": "2JN", "3 John": "3JN", "Jude": "JUD", "Revelation": "REV"
};
const getNormalizedLocale = (locale = "") => {
return String(locale || "").toLowerCase().replace("_", "-").trim();
};
const getTranslationForLocale = (locale = "") => {
const normalized = getNormalizedLocale(locale);
if (!normalized) return "de4e12af7f28f599-01"; // KJV
if (normalized.startsWith("en")) return "de4e12af7f28f599-01"; // KJV
if (normalized.startsWith("es")) return "592420522e16049f-01"; // RVR1909
// Default to KJV
return DEFAULT_TRANSLATION;
};
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 AVAILABLE_TRANSLATIONS = [
{ id: "de4e12af7f28f599-01", name: "King James Version (English)" },
{ id: "9879dbb7cfe39e4d-01", name: "World English Bible (English)" },
{ id: "592420522e16049f-01", name: "Reina Valera 1909 (Spanish)" },
{ id: "48acedcf8595c754-01", name: "Palabla de Dios para ti (Spanish)" }
];
const parseChapterHtml = (htmlStr) => {
const parts = String(htmlStr || "").split(/<span[^>]*data-number=\"/);
const versesMap = {};
for (let i = 1; i < parts.length; i++) {
const part = parts[i];
const numMatch = part.match(/^(\d+)\"/);
if (!numMatch) continue;
const vNum = numMatch[1];
let text = part.replace(/^[^>]+>/, '');
text = text.replace(/<[^>]+>/g, '');
text = text.replace(new RegExp('^' + vNum + '\\s*'), '').trim();
text = text.replace(/&nbsp;/g, ' ').replace(/&#160;/g, ' ');
if (!versesMap[vNum]) versesMap[vNum] = '';
versesMap[vNum] += text + ' ';
}
return versesMap;
};
const parseChapterJson = (items) => {
const versesMap = {};
let currentVerse = null;
const extract = (nodes) => {
if (!nodes) return;
for (const item of nodes) {
if (item.name === 'verse') {
currentVerse = item.attrs?.number || currentVerse;
if (currentVerse && !versesMap[currentVerse]) versesMap[currentVerse] = "";
} else if (item.type === 'text') {
if (item.attrs?.verseId) {
const vNum = item.attrs.verseId.split('.').pop();
if (!versesMap[vNum]) versesMap[vNum] = "";
versesMap[vNum] += item.text;
} else if (currentVerse) {
// Skip if the text is exactly the verse number to prevent duplicates
if (item.text.trim() === String(currentVerse)) {
continue;
}
versesMap[currentVerse] += item.text;
}
}
if (item.items) {
extract(item.items);
}
}
};
extract(items);
return versesMap;
};
export const fetchBibleChapter = async (chapterReference = "", locale = "", customTranslation = "") => {
const { book, chapter } = parseBibleReference(chapterReference);
const bookId = BIBLE_BOOK_IDS[book];
if (!bookId) throw new Error("Missing or unknown Bible reference book");
const chapterId = `${bookId}.${chapter}`;
const preferredTranslation = customTranslation || getTranslationForLocale(locale);
// Call our backend API which has the correct key and handles auth
let response;
try {
const url = `/bible/chapters/${chapterId}`;
const queryParams = { bibleId: preferredTranslation, "content-type": "json" };
let queryParamsString = "?";
Object.keys(queryParams).forEach(p => {
queryParamsString += p + "=" + queryParams[p] + "&";
});
const localBaseUrl = global.baseUrl ?? "https://emiapi.reynafamily.com";
// To bypass getCall's strict JSON structure mapping (since our backend just passes the API response directly),
// we can use standard fetch with the same cookie inclusion strategy.
const fetchRes = await fetch(localBaseUrl + url + queryParamsString, {
method: 'GET',
mode: 'cors',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'Accept-Language': locale,
'x-app-language': locale,
}
});
if (!fetchRes.ok) throw new Error("Failed to load Bible passage");
response = await fetchRes.json();
} catch (error) {
throw new Error(error.message || "Failed to load Bible passage");
}
if (response?.error) throw new Error(response.error);
const payload = response.data;
if (!payload || !payload.content) throw new Error("Invalid Bible data");
let versesMap = {};
if (typeof payload.content === 'string') {
versesMap = parseChapterHtml(payload.content);
} else if (Array.isArray(payload.content)) {
versesMap = parseChapterJson(payload.content);
}
const verses = Object.keys(versesMap)
.sort((a, b) => Number(a) - Number(b))
.map(vNum => ({
verse: Number(vNum),
text: versesMap[vNum].replace(/\n/g, " ").trim()
}));
return {
reference: payload.reference || chapterReference,
verses: verses,
translation_id: preferredTranslation,
translation_name: AVAILABLE_TRANSLATIONS.find(t => t.id === preferredTranslation)?.name || "Translation",
};
};
export const fetchBiblePassage = async (reference = "", locale = "", customTranslation = "") => {
const { verse } = parseBibleReference(reference);
const chapterData = await fetchBibleChapter(reference, locale, customTranslation);
const verseData = chapterData.verses.find(v => v.verse === Number(verse));
if (!verseData) throw new Error("Verse not found");
return {
reference: `${chapterData.reference}:${verse}`,
text: verseData.text,
translation: chapterData.translation_name,
};
};
export const fetchBibleReference = async (reference = "", locale = "", customTranslation = "") => {
return fetchBibleChapter(reference, locale, customTranslation);
};
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;
};
const BIBLE_BOOK_TRANSLATIONS = {
"Genesis": { "es": "Génesis", "fr": "Genèse", "da": "1 Mosebog" },
"Exodus": { "es": "Éxodo", "fr": "Exode", "da": "2 Mosebog" },
"Leviticus": { "es": "Levítico", "fr": "Lévitique", "da": "3 Mosebog" },
"Numbers": { "es": "Números", "fr": "Nombres", "da": "4 Mosebog" },
"Deuteronomy": { "es": "Deuteronomio", "fr": "Deutéronome", "da": "5 Mosebog" },
"Joshua": { "es": "Josué", "fr": "Josué", "da": "Josva" },
"Judges": { "es": "Jueces", "fr": "Juges", "da": "Dommerne" },
"Ruth": { "es": "Rut", "fr": "Ruth", "da": "Ruth" },
"1 Samuel": { "es": "1 Samuel", "fr": "1 Samuel", "da": "1 Samuel" },
"2 Samuel": { "es": "2 Samuel", "fr": "2 Samuel", "da": "2 Samuel" },
"1 Kings": { "es": "1 Reyes", "fr": "1 Rois", "da": "1 Kongebog" },
"2 Kings": { "es": "2 Reyes", "fr": "2 Rois", "da": "2 Kongebog" },
"1 Chronicles": { "es": "1 Crónicas", "fr": "1 Chroniques", "da": "1 Krønikebog" },
"2 Chronicles": { "es": "2 Crónicas", "fr": "2 Chroniques", "da": "2 Krønikebog" },
"Ezra": { "es": "Esdras", "fr": "Esdras", "da": "Ezra" },
"Nehemiah": { "es": "Nehemías", "fr": "Néhémie", "da": "Nehemias" },
"Esther": { "es": "Ester", "fr": "Esther", "da": "Ester" },
"Job": { "es": "Job", "fr": "Job", "da": "Job" },
"Psalms": { "es": "Salmos", "fr": "Psaumes", "da": "Salmerne" },
"Proverbs": { "es": "Proverbios", "fr": "Proverbes", "da": "Ordsprogene" },
"Ecclesiastes": { "es": "Eclesiastés", "fr": "Ecclésiaste", "da": "Prædikeren" },
"Song of Solomon": { "es": "Cantares", "fr": "Cantique des Cantiques", "da": "Højsangen" },
"Isaiah": { "es": "Isaías", "fr": "Ésaïe", "da": "Esajas" },
"Jeremiah": { "es": "Jeremías", "fr": "Jérémie", "da": "Jeremias" },
"Lamentations": { "es": "Lamentaciones", "fr": "Lamentations", "da": "Klagesangene" },
"Ezekiel": { "es": "Ezequiel", "fr": "Ézéchiel", "da": "Ezekiel" },
"Daniel": { "es": "Daniel", "fr": "Daniel", "da": "Daniel" },
"Hosea": { "es": "Oseas", "fr": "Osée", "da": "Hoseas" },
"Joel": { "es": "Joel", "fr": "Joël", "da": "Joel" },
"Amos": { "es": "Amós", "fr": "Amos", "da": "Amos" },
"Obadiah": { "es": "Abdías", "fr": "Abdias", "da": "Obadias" },
"Jonah": { "es": "Jonás", "fr": "Jonas", "da": "Jonas" },
"Micah": { "es": "Miqueas", "fr": "Michée", "da": "Mika" },
"Nahum": { "es": "Nahúm", "fr": "Nahum", "da": "Nahum" },
"Habakkuk": { "es": "Habacuc", "fr": "Habacuc", "da": "Habakkuk" },
"Zephaniah": { "es": "Sofonías", "fr": "Sophonie", "da": "Sefanias" },
"Haggai": { "es": "Hageo", "fr": "Aggée", "da": "Haggaj" },
"Zechariah": { "es": "Zacarías", "fr": "Zacharie", "da": "Zakarias" },
"Malachi": { "es": "Malaquías", "fr": "Malachie", "da": "Malakias" },
"Matthew": { "es": "Mateo", "fr": "Matthieu", "da": "Matthæus" },
"Mark": { "es": "Marcos", "fr": "Marc", "da": "Markus" },
"Luke": { "es": "Lucas", "fr": "Luc", "da": "Lukas" },
"John": { "es": "Juan", "fr": "Jean", "da": "Johannes" },
"Acts": { "es": "Hechos", "fr": "Actes", "da": "Apostlenes Gerninger" },
"Romans": { "es": "Romanos", "fr": "Romains", "da": "Romerne" },
"1 Corinthians": { "es": "1 Corintios", "fr": "1 Corinthiens", "da": "1 Korinther" },
"2 Corinthians": { "es": "2 Corintios", "fr": "2 Corinthiens", "da": "2 Korinther" },
"Galatians": { "es": "Gálatas", "fr": "Galates", "da": "Galaterne" },
"Ephesians": { "es": "Efesios", "fr": "Éphésiens", "da": "Efeserne" },
"Philippians": { "es": "Filipenses", "fr": "Philippiens", "da": "Filipperne" },
"Colossians": { "es": "Colosenses", "fr": "Colossiens", "da": "Kolossenserne" },
"1 Thessalonians": { "es": "1 Tesalonicenses", "fr": "1 Thessaloniciens", "da": "1 Thessaloniker" },
"2 Thessalonians": { "es": "2 Tesalonicenses", "fr": "2 Thessaloniciens", "da": "2 Thessaloniker" },
"1 Timothy": { "es": "1 Timoteo", "fr": "1 Timothée", "da": "1 Timotheus" },
"2 Timothy": { "es": "2 Timoteo", "fr": "2 Timothée", "da": "2 Timotheus" },
"Titus": { "es": "Tito", "fr": "Tite", "da": "Titus" },
"Philemon": { "es": "Filemón", "fr": "Philémon", "da": "Filemon" },
"Hebrews": { "es": "Hebreos", "fr": "Hébreux", "da": "Hebræerne" },
"James": { "es": "Santiago", "fr": "Jacques", "da": "Jakob" },
"1 Peter": { "es": "1 Pedro", "fr": "1 Pierre", "da": "1 Peter" },
"2 Peter": { "es": "2 Pedro", "fr": "2 Pierre", "da": "2 Peter" },
"1 John": { "es": "1 Juan", "fr": "1 Jean", "da": "1 Johannes" },
"2 John": { "es": "2 Juan", "fr": "2 Jean", "da": "2 Johannes" },
"3 John": { "es": "3 Juan", "fr": "3 Jean", "da": "3 Johannes" },
"Jude": { "es": "Judas", "fr": "Jude", "da": "Judas" },
"Revelation": { "es": "Apocalipsis", "fr": "Apocalypse", "da": "Åbenbaringen" }
};
export const translateBibleReference = (reference = "", locale = "en") => {
const lang = String(locale || "en").substring(0, 2).toLowerCase();
if (lang === "en") return reference;
const parsed = parseBibleReference(reference);
if (!parsed || !parsed.book || !BIBLE_BOOK_TRANSLATIONS[parsed.book]) return reference;
const translatedBook = BIBLE_BOOK_TRANSLATIONS[parsed.book][lang] || parsed.book;
return reference.replace(parsed.book, translatedBook);
};