Fix YouTube embeds and improve global chat auto-scroll
This commit is contained in:
@@ -3,6 +3,7 @@ import { View, FlatList, Pressable, Animated, PanResponder } from "react-native"
|
|||||||
import { Text, TextInput } from "react-native-paper";
|
import { Text, TextInput } from "react-native-paper";
|
||||||
import { useFocusEffect } from "@react-navigation/native";
|
import { useFocusEffect } from "@react-navigation/native";
|
||||||
import MaterialIcons from "react-native-vector-icons/MaterialIcons";
|
import MaterialIcons from "react-native-vector-icons/MaterialIcons";
|
||||||
|
import { WebView } from "react-native-webview";
|
||||||
import { useSnapshot } from "valtio";
|
import { useSnapshot } from "valtio";
|
||||||
import API from "../API.js";
|
import API from "../API.js";
|
||||||
import GlobalState from "../contexts/GlobalState.js";
|
import GlobalState from "../contexts/GlobalState.js";
|
||||||
@@ -10,6 +11,32 @@ import i18n from "../i18nMessages.js";
|
|||||||
|
|
||||||
const PANEL_WIDTH = 260;
|
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 }) => (
|
const CircleIconAction = ({ icon, onPress, color = "#6b7280", disabled = false }) => (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
@@ -33,6 +60,7 @@ const ChatMessage = ({ item, myProfileId, showOriginal, onPressIn, onPressOut })
|
|||||||
const isMine = item?.senderProfileId === myProfileId;
|
const isMine = item?.senderProfileId === myProfileId;
|
||||||
const isTranslated = !!(item?.textOriginal && item?.text && item.textOriginal !== item.text);
|
const isTranslated = !!(item?.textOriginal && item?.text && item.textOriginal !== item.text);
|
||||||
const messageText = isTranslated && showOriginal ? item?.textOriginal : item?.text;
|
const messageText = isTranslated && showOriginal ? item?.textOriginal : item?.text;
|
||||||
|
const youtubeVideoId = getYouTubeVideoIdFromText(item?.textOriginal || "") || getYouTubeVideoIdFromText(item?.text || "");
|
||||||
const createdAt = item?.createdAt ? new Date(item.createdAt) : null;
|
const createdAt = item?.createdAt ? new Date(item.createdAt) : null;
|
||||||
return (
|
return (
|
||||||
<View style={{ marginBottom: 12, alignItems: isMine ? "flex-end" : "flex-start" }}>
|
<View style={{ marginBottom: 12, alignItems: isMine ? "flex-end" : "flex-start" }}>
|
||||||
@@ -62,6 +90,30 @@ const ChatMessage = ({ item, myProfileId, showOriginal, onPressIn, onPressOut })
|
|||||||
{createdAt && !Number.isNaN(createdAt.getTime()) ? createdAt.toLocaleTimeString() : ""}
|
{createdAt && !Number.isNaN(createdAt.getTime()) ? createdAt.toLocaleTimeString() : ""}
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
{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>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -76,8 +128,24 @@ let GlobalChat = () => {
|
|||||||
const [sending, setSending] = React.useState(false);
|
const [sending, setSending] = React.useState(false);
|
||||||
const [pressedMessageId, setPressedMessageId] = React.useState("");
|
const [pressedMessageId, setPressedMessageId] = React.useState("");
|
||||||
const [isOnlinePanelOpen, setIsOnlinePanelOpen] = React.useState(false);
|
const [isOnlinePanelOpen, setIsOnlinePanelOpen] = React.useState(false);
|
||||||
|
const [isAtBottom, setIsAtBottom] = React.useState(true);
|
||||||
const panelX = React.useRef(new Animated.Value(-PANEL_WIDTH)).current;
|
const panelX = React.useRef(new Animated.Value(-PANEL_WIDTH)).current;
|
||||||
const panelOpacity = React.useRef(new Animated.Value(0)).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 loadMessages = React.useCallback(async () => {
|
||||||
const data = await API.getChatMessages(100);
|
const data = await API.getChatMessages(100);
|
||||||
@@ -193,6 +261,27 @@ let GlobalChat = () => {
|
|||||||
},
|
},
|
||||||
}), [closeOnlinePanel, isOnlinePanelOpen, openOnlinePanel]);
|
}), [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 (
|
return (
|
||||||
<View style={{ flex: 1 }} {...panResponder.panHandlers}>
|
<View style={{ flex: 1 }} {...panResponder.panHandlers}>
|
||||||
<View style={{ flex: 1, padding: 14 }}>
|
<View style={{ flex: 1, padding: 14 }}>
|
||||||
@@ -212,6 +301,7 @@ let GlobalChat = () => {
|
|||||||
<Text>{i18n.t("message.loadingChat")}</Text>
|
<Text>{i18n.t("message.loadingChat")}</Text>
|
||||||
) : (
|
) : (
|
||||||
<FlatList
|
<FlatList
|
||||||
|
ref={listRef}
|
||||||
data={messages}
|
data={messages}
|
||||||
keyExtractor={(item, index) => item?._id?.toString?.() || `${index}-${item?.createdAt || "msg"}`}
|
keyExtractor={(item, index) => item?._id?.toString?.() || `${index}-${item?.createdAt || "msg"}`}
|
||||||
renderItem={({ item }) => {
|
renderItem={({ item }) => {
|
||||||
@@ -226,6 +316,10 @@ let GlobalChat = () => {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
|
onScroll={handleChatScroll}
|
||||||
|
onLayout={runInitialScrollIfNeeded}
|
||||||
|
onContentSizeChange={runInitialScrollIfNeeded}
|
||||||
|
scrollEventThrottle={16}
|
||||||
contentContainerStyle={{ paddingBottom: 10 }}
|
contentContainerStyle={{ paddingBottom: 10 }}
|
||||||
style={{ flex: 1 }}
|
style={{ flex: 1 }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -26,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
|
||||||
@@ -174,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",
|
||||||
|
},
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user