diff --git a/Views/GlobalChat.js b/Views/GlobalChat.js
index 16cc12b..de3bd6c 100644
--- a/Views/GlobalChat.js
+++ b/Views/GlobalChat.js
@@ -3,6 +3,7 @@ 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";
@@ -10,6 +11,32 @@ import i18n from "../i18nMessages.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 }) => (
@@ -62,6 +90,30 @@ const ChatMessage = ({ item, myProfileId, showOriginal, onPressIn, onPressOut })
{createdAt && !Number.isNaN(createdAt.getTime()) ? createdAt.toLocaleTimeString() : ""}
+ {youtubeVideoId ? (
+
+
+
+ ) : <>>}
);
};
@@ -76,8 +128,24 @@ let GlobalChat = () => {
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 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);
@@ -193,6 +261,27 @@ let GlobalChat = () => {
},
}), [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 (
@@ -212,6 +301,7 @@ let GlobalChat = () => {
{i18n.t("message.loadingChat")}
) : (
item?._id?.toString?.() || `${index}-${item?.createdAt || "msg"}`}
renderItem={({ item }) => {
@@ -226,6 +316,10 @@ let GlobalChat = () => {
/>
);
}}
+ onScroll={handleChatScroll}
+ onLayout={runInitialScrollIfNeeded}
+ onContentSizeChange={runInitialScrollIfNeeded}
+ scrollEventThrottle={16}
contentContainerStyle={{ paddingBottom: 10 }}
style={{ flex: 1 }}
/>
diff --git a/components/Media.js b/components/Media.js
index eb41b7c..b31a06c 100644
--- a/components/Media.js
+++ b/components/Media.js
@@ -26,11 +26,10 @@ const videoIdF = (content) => {
// Extract YouTube video ID from content string
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 '';
- let tag = youtubeTag;
- tag = tag[0].substring(1);
- return tag.split(':')[1];
+ return youtubeTag[1];
};
// Extract HLS URL from content string
@@ -174,7 +173,13 @@ let Media = (props) => {
return (
);
}