Compare commits
33 Commits
fe9fc8e3e4
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 3c4da84827 | |||
| 74d84470a9 | |||
| c508ea8fea | |||
| c281878875 | |||
| 9da5874977 | |||
| fc6f740fd2 | |||
| 53df0699a7 | |||
| a2846423b9 | |||
| fc3159d3fb | |||
| ccfeed3c92 | |||
| ba28289783 | |||
| 7f7fb8efb7 | |||
| 1f38b43d64 | |||
| d5850bb91e | |||
| 24515caf70 | |||
| 5e05cba3ad | |||
| 270e4e1c2d | |||
| f9d4457b5a | |||
| fe293828e6 | |||
| e2bff88396 | |||
| f92d0fec0b | |||
| 6ff89c2e4b | |||
| 06e620dbf6 | |||
| a8d21d31f8 | |||
| 0ba5d7bd0d | |||
| cc9ca1d075 | |||
| ffb8f9bec5 | |||
| 01aeedf950 | |||
| 83604f5eaa | |||
| 058b060d03 | |||
| d2491800f2 | |||
| 487edb4a62 | |||
| 009f1ec792 |
@@ -1,6 +1,39 @@
|
|||||||
|
import i18n from "./i18nMessages.js";
|
||||||
const baseUrl = "https://emiapi.reynafamily.com";
|
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 = "?";
|
||||||
@@ -8,16 +41,27 @@ let getCall = async (path = "", params = {}) => {
|
|||||||
queryParams += p + "=" + params[p] + "&"
|
queryParams += p + "=" + params[p] + "&"
|
||||||
});
|
});
|
||||||
let localBaseUrl = global.baseUrl ?? baseUrl;
|
let localBaseUrl = global.baseUrl ?? baseUrl;
|
||||||
|
const fallback = getFallbackForPath(path);
|
||||||
return fetch(localBaseUrl + path + queryParams, {
|
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);
|
||||||
console.trace();
|
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;
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,21 +71,33 @@ let deleteCall = async (path = "", params = {}) => {
|
|||||||
queryParams += p + "=" + params[p] + "&"
|
queryParams += p + "=" + params[p] + "&"
|
||||||
});
|
});
|
||||||
let localBaseUrl = global.baseUrl ?? baseUrl;
|
let localBaseUrl = global.baseUrl ?? baseUrl;
|
||||||
|
const fallback = {};
|
||||||
return fetch(localBaseUrl + path + queryParams, {
|
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);
|
||||||
console.trace();
|
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) => {
|
||||||
let localBaseUrl = global.baseUrl ?? baseUrl;
|
let localBaseUrl = global.baseUrl ?? baseUrl;
|
||||||
|
const fallback = {};
|
||||||
return fetch(localBaseUrl + path, {
|
return fetch(localBaseUrl + path, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
mode: 'cors',
|
mode: 'cors',
|
||||||
@@ -49,10 +105,20 @@ let postCall = async (path, params) => {
|
|||||||
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);
|
||||||
console.trace();
|
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;
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,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];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,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(()=>{
|
||||||
@@ -140,8 +245,8 @@ 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) {
|
getPostsByTag(tag) {
|
||||||
return getCall("/post/tag/" + tag);
|
return getCall("/post/tag/" + tag);
|
||||||
@@ -165,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 };
|
||||||
@@ -215,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"){
|
||||||
@@ -235,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);
|
||||||
@@ -251,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
|
||||||
@@ -322,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;
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
@@ -29,10 +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 { useNavigation } from '@react-navigation/native';
|
||||||
|
import * as Linking from 'expo-linking';
|
||||||
|
|
||||||
|
|
||||||
const Tab = createBottomTabNavigator();
|
const Tab = createBottomTabNavigator();
|
||||||
@@ -59,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') {
|
||||||
@@ -102,6 +117,7 @@ const MainNavigation = ({ route }) => {
|
|||||||
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);
|
||||||
});
|
});
|
||||||
@@ -123,6 +139,9 @@ const MainNavigation = ({ route }) => {
|
|||||||
if (data.post_id) {
|
if (data.post_id) {
|
||||||
mainNavigation.navigate("SinglePost", { postid: data.post_id });
|
mainNavigation.navigate("SinglePost", { postid: data.post_id });
|
||||||
}
|
}
|
||||||
|
if (data.type === "chat") {
|
||||||
|
mainNavigation.navigate("GlobalChat");
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
alert("Error: " + error);
|
alert("Error: " + error);
|
||||||
}
|
}
|
||||||
@@ -272,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={{
|
||||||
@@ -289,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>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -348,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} />
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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
@@ -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
@@ -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,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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")}
|
||||||
|
|||||||
+48
-22
@@ -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,12 +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 * 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;
|
||||||
@@ -24,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 () => {
|
||||||
@@ -32,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();
|
||||||
@@ -70,37 +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>
|
||||||
<View style={{ padding: 10, alignContent: "center", flex: 1 }}>
|
<View style={{ padding: 10, alignContent: "center", flex: 1 }}>
|
||||||
<Text>Version: {Updates.runtimeVersion}</Text>
|
<Text>{i18n.t("message.version")}: {Updates.runtimeVersion}</Text>
|
||||||
<Text>Channel: {Updates.Channel}</Text>
|
<Text>{i18n.t("message.channel")}: {Updates.Channel}</Text>
|
||||||
</View>
|
</View>
|
||||||
</ImageBackground>
|
</ImageBackground>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default MenuView;
|
export default MenuView;
|
||||||
|
|||||||
+210
-21
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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,7 +39,7 @@ 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>
|
||||||
|
|||||||
+154
-22
@@ -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
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
+1
-1
@@ -47,7 +47,7 @@ let Tags = ({ 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}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -4,6 +4,7 @@ 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';
|
||||||
@@ -16,7 +17,7 @@ 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" };
|
||||||
@@ -54,6 +55,7 @@ let Comment = ({ comment, postid }) => {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<Text style={{ fontSize: 14 }}>{cleanContent}</Text>
|
<Text style={{ fontSize: 14 }}>{cleanContent}</Text>
|
||||||
|
<BibleEmbeddedView content={comment.content} compact openChapterOnPress />
|
||||||
<Media content={comment.content} postId={postid} skiptVideo={true} />
|
<Media content={comment.content} postId={postid} skiptVideo={true} />
|
||||||
</Card.Content>
|
</Card.Content>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
+17
-13
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
+220
-16
@@ -1,6 +1,5 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useMemo, useRef, useState } from 'react';
|
||||||
import { Text, Pressable, FlatList, StyleSheet, View, Share, Alert, Linking } 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';
|
||||||
@@ -15,6 +14,8 @@ import ProfilePhotoCircle from './ProfilePhotoCircle.js';
|
|||||||
import { posthog } from './../PostHog.js';
|
import { posthog } from './../PostHog.js';
|
||||||
import { useNavigation } from '@react-navigation/native';
|
import { useNavigation } from '@react-navigation/native';
|
||||||
import ParsedText from 'react-native-parsed-text';
|
import ParsedText from 'react-native-parsed-text';
|
||||||
|
import BibleEmbeddedView from './BibleEmbeddedView.js';
|
||||||
|
import { stripBibleTokens } from '../utils/bibleReferences.js';
|
||||||
|
|
||||||
|
|
||||||
let Post = (props) => {
|
let Post = (props) => {
|
||||||
@@ -22,12 +23,47 @@ let Post = (props) => {
|
|||||||
const viewer = gState.me;
|
const viewer = gState.me;
|
||||||
let [showCommentsB, changeshowCommentsB] = useState(props.showComments || 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 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 };
|
||||||
@@ -65,9 +101,6 @@ let Post = (props) => {
|
|||||||
API.removePostBookmark(post._id)
|
API.removePostBookmark(post._id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const renderComment = ({ item }) => (
|
|
||||||
<Comment comment={item} postid={post._id} />
|
|
||||||
);
|
|
||||||
const handleTagPress = (tag) => {
|
const handleTagPress = (tag) => {
|
||||||
// Alert.alert("tag pressed", `You pressed the tag: ${tag}`);
|
// Alert.alert("tag pressed", `You pressed the tag: ${tag}`);
|
||||||
// You can navigate to another screen or perform any other action here
|
// You can navigate to another screen or perform any other action here
|
||||||
@@ -91,7 +124,52 @@ let Post = (props) => {
|
|||||||
url: url
|
url: url
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
return (
|
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,
|
||||||
@@ -120,16 +198,93 @@ let Post = (props) => {
|
|||||||
{ pattern: /(https?:\/\/[^\s]+)/, style: styles.link, onPress: handleLinkPress, onLongPress: handleLinkLongPress },
|
{ pattern: /(https?:\/\/[^\s]+)/, style: styles.link, onPress: handleLinkPress, onLongPress: handleLinkLongPress },
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{cleanContent}
|
{showTranslation && hasTranslation ? stripInlineTags(stripBibleTokens(post.translations[currentLang])) : cleanContent}
|
||||||
</ParsedText>
|
</ParsedText>
|
||||||
</Pressable>
|
</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 style={{ paddingLeft: 40, paddingRight: 8 }}>
|
||||||
|
<BibleEmbeddedView content={post.content} openChapterOnPress />
|
||||||
|
</View>
|
||||||
|
|
||||||
<Media content={post.content} postId={post._id} post={post} style={{ paddingTop: 2 }} />
|
<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} postId={post._id} post={post} style={{ paddingTop: 2 }} />
|
||||||
|
</View>
|
||||||
</View> :
|
</View> :
|
||||||
<View>
|
<View>
|
||||||
<Chip icon="new-releases" style={{ width: 100 }} >{i18n.t("message.news")}</Chip>
|
<Chip icon="new-releases" style={{ width: 100 }} >{i18n.t("message.news")}</Chip>
|
||||||
<Text style={{ fontSize: 18 }}>{cleanContent}</Text>
|
<Text style={{ fontSize: 18 }}>{cleanContent}</Text>
|
||||||
<Media content={post.content} />
|
<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} />
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
}
|
}
|
||||||
</Card.Content>
|
</Card.Content>
|
||||||
@@ -178,14 +333,32 @@ let Post = (props) => {
|
|||||||
{showCommentsB && <NewComment postid={post._id} newComentAdded={newComentAdded} />}
|
{showCommentsB && <NewComment postid={post._id} newComentAdded={newComentAdded} />}
|
||||||
{
|
{
|
||||||
showCommentsB &&
|
showCommentsB &&
|
||||||
<FlatList
|
<View>
|
||||||
data={post.comments}
|
{post.comments.map((comment, index) => (
|
||||||
renderItem={renderComment}
|
<Comment
|
||||||
keyExtractor={item => item.createdAt}
|
key={`${comment?.createdAt || "comment"}-${index}`}
|
||||||
/>
|
comment={comment}
|
||||||
|
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);
|
||||||
@@ -204,6 +377,30 @@ 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,
|
||||||
@@ -223,3 +420,10 @@ const styles = StyleSheet.create({
|
|||||||
textDecorationLine: 'underline',
|
textDecorationLine: 'underline',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
const stripInlineTags = (content = "") => {
|
||||||
|
return String(content || "")
|
||||||
|
.replace(/@[A-Za-z]+:[^\s]+/g, "")
|
||||||
|
.replace(/[ \t]{2,}/g, " ")
|
||||||
|
.replace(/[ \t]+\n/g, "\n")
|
||||||
|
.trim();
|
||||||
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { FlatList } 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';
|
||||||
|
|
||||||
@@ -19,12 +19,11 @@ let SinglePostComponent = ({ postId, hideComments }) => {
|
|||||||
}
|
}
|
||||||
}, [postId]);
|
}, [postId]);
|
||||||
return (post._id ? (
|
return (post._id ? (
|
||||||
<FlatList
|
<View style={{ flex: 1 }}>
|
||||||
data={[post]}
|
<ScrollView contentContainerStyle={{ paddingBottom: 18 }}>
|
||||||
renderItem={({ item }) => <Post post={item} showComments={hideComments ? false : true}/>}
|
<Post post={post} showComments={hideComments ? false : true} />
|
||||||
keyExtractor={item => item._id}
|
</ScrollView>
|
||||||
/>
|
</View>
|
||||||
|
|
||||||
) : null);
|
) : null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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(/ /g, ' ').replace(/ /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);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user