From f92d0fec0b54a0ab5cecc9292b3a61111b14bbf5 Mon Sep 17 00:00:00 2001 From: Adolfo Reyna Date: Fri, 20 Feb 2026 23:16:05 -0500 Subject: [PATCH] feat: add global chat UI with i18n and translation indicators --- API.js | 26 ++++++- App.js | 7 +- Views/GlobalChat.js | 170 ++++++++++++++++++++++++++++++++++++++++++++ Views/Menu.js | 1 + i18nMessages.js | 28 ++++++++ 5 files changed, 228 insertions(+), 4 deletions(-) create mode 100644 Views/GlobalChat.js diff --git a/API.js b/API.js index 5ce7d16..a82f902 100644 --- a/API.js +++ b/API.js @@ -1,10 +1,11 @@ - -const baseUrl = "https://emiapi.reynafamily.com"; -//const baseUrl = "http://localhost:3000"; +import i18n from "./i18nMessages.js"; +const baseUrl = "http://localhost:3000"; +//const baseUrl = "https://emiapi.reynafamily.com"; 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"); @@ -47,6 +48,8 @@ let getCall = async (path = "", params = {}) => { credentials: 'include', headers: { 'Content-Type': 'application/json', + 'Accept-Language': getCurrentLanguage(), + 'x-app-language': getCurrentLanguage(), } }).then(async (response) => { const normalizedPath = normalizePath(path); @@ -75,6 +78,8 @@ let deleteCall = async (path = "", params = {}) => { credentials: 'include', headers: { 'Content-Type': 'application/json', + 'Accept-Language': getCurrentLanguage(), + 'x-app-language': getCurrentLanguage(), } }).then(async (response) => { const normalizedPath = normalizePath(path); @@ -100,6 +105,8 @@ let postCall = async (path, params) => { body: JSON.stringify(params), headers: { 'Content-Type': 'application/json', + 'Accept-Language': getCurrentLanguage(), + 'x-app-language': getCurrentLanguage(), } }).then(async (response) => { const normalizedPath = normalizePath(path); @@ -423,6 +430,19 @@ const API = { }, paymentRegister(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 : []); } } diff --git a/App.js b/App.js index a78b081..0c67a98 100644 --- a/App.js +++ b/App.js @@ -29,6 +29,7 @@ import GlobalState from './contexts/GlobalState.js'; import NewGroup from './Views/NewGroup.js'; import Slideshow from './Views/Slideshow.js'; import SongPlayer from './Views/SongPlayer.js'; +import GlobalChat from './Views/GlobalChat.js'; import { Platform } from 'react-native'; import { PostHogProvider } from 'posthog-react-native' import * as Updates from 'expo-updates'; @@ -296,7 +297,7 @@ export default function App() { }} /> : { props.navigation.navigate('Menu'); }} />} { - alert("Chats are comming soon."); + props.navigation.navigate("GlobalChat"); }} onLongPress={() => { props.navigation.navigate("SongPlayer"); }} /> @@ -373,6 +374,10 @@ export default function App() { name="SongPlayer" component={SongPlayer} /> + diff --git a/Views/GlobalChat.js b/Views/GlobalChat.js new file mode 100644 index 0000000..5508ab0 --- /dev/null +++ b/Views/GlobalChat.js @@ -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 ( + + + + {item?.senderName || "Anonymous"} + + + {messageText || ""} + + {isTranslated ? ( + + * {i18n.t("message.aiTranslated")} + + ) : <>} + + {createdAt && !Number.isNaN(createdAt.getTime()) ? createdAt.toLocaleTimeString() : ""} + + + + ); +}; + +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 ( + + {i18n.t("message.globalChat")} + * {i18n.t("message.chatRoomBeta")} + {i18n.t("message.onlineNow")} ({activeUsers.length}) + + {activeUsers.slice(0, 20).map((user) => ( + + {user?.displayName || "Anonymous"} + + ))} + + + {loading ? ( + {i18n.t("message.loadingChat")} + ) : ( + item?._id?.toString?.() || `${index}-${item?.createdAt || "msg"}`} + renderItem={({ item }) => { + const messageId = item?._id?.toString?.() || ""; + return ( + setPressedMessageId(messageId)} + onPressOut={() => setPressedMessageId("")} + /> + ); + }} + contentContainerStyle={{ paddingBottom: 10 }} + style={{ flex: 1 }} + /> + )} + + + + + + + ); +}; + +export default GlobalChat; diff --git a/Views/Menu.js b/Views/Menu.js index deb70e7..ed7bef3 100644 --- a/Views/Menu.js +++ b/Views/Menu.js @@ -79,6 +79,7 @@ let MenuView = ({ navigation }) => { { navigation.navigate("ProfileSettings") }} left={props => } /> + { navigation.navigate("GlobalChat") }} left={props => } /> } /> { navigation.navigate("Logout") }} left={props => } /> diff --git a/i18nMessages.js b/i18nMessages.js index 10b0eeb..aa28f84 100644 --- a/i18nMessages.js +++ b/i18nMessages.js @@ -117,6 +117,13 @@ const messages = { 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", }, }, es: { @@ -229,6 +236,13 @@ const messages = { 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", } }, fr: { @@ -341,6 +355,13 @@ const messages = { 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", } }, da: { @@ -453,6 +474,13 @@ const messages = { 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", } } }