feat: add global chat UI with i18n and translation indicators
This commit is contained in:
26
API.js
26
API.js
@@ -1,10 +1,11 @@
|
|||||||
|
import i18n from "./i18nMessages.js";
|
||||||
const baseUrl = "https://emiapi.reynafamily.com";
|
const baseUrl = "http://localhost:3000";
|
||||||
//const baseUrl = "http://localhost:3000";
|
//const baseUrl = "https://emiapi.reynafamily.com";
|
||||||
const requestErrorCooldownMs = 30000;
|
const requestErrorCooldownMs = 30000;
|
||||||
const profileFailureCooldownMs = 60000;
|
const profileFailureCooldownMs = 60000;
|
||||||
const recentRequestErrors = {};
|
const recentRequestErrors = {};
|
||||||
const failedProfileCache = {};
|
const failedProfileCache = {};
|
||||||
|
const getCurrentLanguage = () => (i18n?.locale || "en").toLowerCase();
|
||||||
|
|
||||||
const normalizePath = (path = "") => {
|
const normalizePath = (path = "") => {
|
||||||
return path.replace(/[a-f0-9]{24}/gi, ":id");
|
return path.replace(/[a-f0-9]{24}/gi, ":id");
|
||||||
@@ -47,6 +48,8 @@ let getCall = async (path = "", params = {}) => {
|
|||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
'Accept-Language': getCurrentLanguage(),
|
||||||
|
'x-app-language': getCurrentLanguage(),
|
||||||
}
|
}
|
||||||
}).then(async (response) => {
|
}).then(async (response) => {
|
||||||
const normalizedPath = normalizePath(path);
|
const normalizedPath = normalizePath(path);
|
||||||
@@ -75,6 +78,8 @@ let deleteCall = async (path = "", params = {}) => {
|
|||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
'Accept-Language': getCurrentLanguage(),
|
||||||
|
'x-app-language': getCurrentLanguage(),
|
||||||
}
|
}
|
||||||
}).then(async (response) => {
|
}).then(async (response) => {
|
||||||
const normalizedPath = normalizePath(path);
|
const normalizedPath = normalizePath(path);
|
||||||
@@ -100,6 +105,8 @@ 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(async (response) => {
|
}).then(async (response) => {
|
||||||
const normalizedPath = normalizePath(path);
|
const normalizedPath = normalizePath(path);
|
||||||
@@ -423,6 +430,19 @@ 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 : []);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
7
App.js
7
App.js
@@ -29,6 +29,7 @@ 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 { 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';
|
||||||
@@ -296,7 +297,7 @@ 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");
|
||||||
}} />
|
}} />
|
||||||
@@ -373,6 +374,10 @@ export default function App() {
|
|||||||
name="SongPlayer"
|
name="SongPlayer"
|
||||||
component={SongPlayer}
|
component={SongPlayer}
|
||||||
/>
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="GlobalChat"
|
||||||
|
component={GlobalChat}
|
||||||
|
/>
|
||||||
<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} />
|
||||||
|
|||||||
170
Views/GlobalChat.js
Normal file
170
Views/GlobalChat.js
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { View, FlatList, Pressable } from "react-native";
|
||||||
|
import { Button, Chip, Text, TextInput } from "react-native-paper";
|
||||||
|
import { useSnapshot } from "valtio";
|
||||||
|
import API from "../API.js";
|
||||||
|
import GlobalState from "../contexts/GlobalState.js";
|
||||||
|
import i18n from "../i18nMessages.js";
|
||||||
|
|
||||||
|
const ChatMessage = ({ item, myProfileId, showOriginal, onPressIn, onPressOut }) => {
|
||||||
|
const isMine = item?.senderProfileId === myProfileId;
|
||||||
|
const isTranslated = !!(item?.textOriginal && item?.text && item.textOriginal !== item.text);
|
||||||
|
const messageText = isTranslated && showOriginal ? item?.textOriginal : item?.text;
|
||||||
|
const 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
let GlobalChat = () => {
|
||||||
|
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 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]);
|
||||||
|
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1, padding: 14 }}>
|
||||||
|
<Text style={{ fontSize: 22, fontWeight: "700", marginBottom: 10 }}>{i18n.t("message.globalChat")}</Text>
|
||||||
|
<Text style={{ color: "#9a3412", marginBottom: 8 }}>* {i18n.t("message.chatRoomBeta")}</Text>
|
||||||
|
<Text style={{ color: "#6b7280", marginBottom: 8 }}>{i18n.t("message.onlineNow")} ({activeUsers.length})</Text>
|
||||||
|
<View style={{ flexDirection: "row", flexWrap: "wrap", marginBottom: 12 }}>
|
||||||
|
{activeUsers.slice(0, 20).map((user) => (
|
||||||
|
<Chip key={user.profileId} style={{ marginRight: 8, marginBottom: 8 }}>
|
||||||
|
{user?.displayName || "Anonymous"}
|
||||||
|
</Chip>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<Text>{i18n.t("message.loadingChat")}</Text>
|
||||||
|
) : (
|
||||||
|
<FlatList
|
||||||
|
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("")}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
contentContainerStyle={{ paddingBottom: 10 }}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View style={{ marginTop: 10 }}>
|
||||||
|
<TextInput
|
||||||
|
mode="outlined"
|
||||||
|
placeholder={i18n.t("message.writeMessage")}
|
||||||
|
value={text}
|
||||||
|
onChangeText={setText}
|
||||||
|
multiline
|
||||||
|
maxLength={500}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
mode="contained"
|
||||||
|
onPress={sendMessage}
|
||||||
|
disabled={sending || !(text || "").trim()}
|
||||||
|
style={{ marginTop: 10 }}
|
||||||
|
>
|
||||||
|
{i18n.t("message.send")}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GlobalChat;
|
||||||
@@ -79,6 +79,7 @@ let MenuView = ({ navigation }) => {
|
|||||||
</List.Accordion>
|
</List.Accordion>
|
||||||
<List.Section title={i18n.t("message.userActions")}>
|
<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='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>
|
||||||
|
|||||||
@@ -117,6 +117,13 @@ const messages = {
|
|||||||
deleteGroup: "Delete Group",
|
deleteGroup: "Delete Group",
|
||||||
delete: "Delete",
|
delete: "Delete",
|
||||||
appName: "EMI Fellowship",
|
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",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
es: {
|
es: {
|
||||||
@@ -229,6 +236,13 @@ const messages = {
|
|||||||
deleteGroup: "Eliminar grupo",
|
deleteGroup: "Eliminar grupo",
|
||||||
delete: "Eliminar",
|
delete: "Eliminar",
|
||||||
appName: "EMI Fellowship",
|
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",
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
fr: {
|
fr: {
|
||||||
@@ -341,6 +355,13 @@ const messages = {
|
|||||||
deleteGroup: "Supprimer le groupe",
|
deleteGroup: "Supprimer le groupe",
|
||||||
delete: "Supprimer",
|
delete: "Supprimer",
|
||||||
appName: "EMI Fellowship",
|
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",
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
da: {
|
da: {
|
||||||
@@ -453,6 +474,13 @@ const messages = {
|
|||||||
deleteGroup: "Slet gruppe",
|
deleteGroup: "Slet gruppe",
|
||||||
delete: "Slet",
|
delete: "Slet",
|
||||||
appName: "EMI Fellowship",
|
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",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user