437 lines
18 KiB
JavaScript
437 lines
18 KiB
JavaScript
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;
|