diff --git a/package.json b/package.json
index 8fc640eb07..13087f4009 100644
--- a/package.json
+++ b/package.json
@@ -36,7 +36,7 @@
"@babel/preset-typescript": "^7.26.0",
"@babel/runtime": "^7.26.7",
"@ecency/render-helper": "^2.4.24",
- "@ecency/sdk": "^2.0.30",
+ "@ecency/sdk": "^2.0.31",
"@esteemapp/dhive": "0.15.0",
"@esteemapp/react-native-autocomplete-input": "^4.2.1",
"@esteemapp/react-native-multi-slider": "^1.1.0",
diff --git a/src/components/postHtmlRenderer/postHtmlRenderer.tsx b/src/components/postHtmlRenderer/postHtmlRenderer.tsx
index 8b9e52069f..4771c85288 100644
--- a/src/components/postHtmlRenderer/postHtmlRenderer.tsx
+++ b/src/components/postHtmlRenderer/postHtmlRenderer.tsx
@@ -440,6 +440,15 @@ export const PostHtmlRenderer = memo(
[_minTableColWidth, contentWidth],
);
+ // Extract thumbnail for 3Speak video orientation detection
+ const _speakThumbnail = useMemo(() => {
+ if (metadata?.type === '3speak/video' || metadata?.video?.info?.platform === '3speak') {
+ const images = metadata?.image;
+ return Array.isArray(images) ? images[0] : images;
+ }
+ return undefined;
+ }, [metadata]);
+
// iframe renderer for rendering iframes in body
const _iframeRenderer = useCallback(
function IframeRenderer(props) {
@@ -454,12 +463,18 @@ export const PostHtmlRenderer = memo(
};
return ;
} else {
+ const isSpeakEmbed = /3speak\.tv/i.test(iframeProps.source.uri || '');
return (
-
+
);
}
},
- [isComment, handleVideoPress, contentWidth],
+ [isComment, handleVideoPress, contentWidth, _speakThumbnail],
);
const tagsStyles = useMemo(
diff --git a/src/components/postView/children/postReadingMetadata.tsx b/src/components/postView/children/postReadingMetadata.tsx
index f79c5f1cd3..c7a8adb3cd 100644
--- a/src/components/postView/children/postReadingMetadata.tsx
+++ b/src/components/postView/children/postReadingMetadata.tsx
@@ -5,6 +5,7 @@ import { useIntl } from 'react-intl';
import { SheetManager } from 'react-native-actions-sheet';
import { useAiAssist } from '@ecency/sdk';
import { Icon } from '../../icon';
+import { DropdownButton } from '../../dropdownButton';
import { TTSControls } from '../../textToSpeech/ttsControls';
import {
extractPlainTextForTTS,
@@ -12,7 +13,9 @@ import {
countWords,
estimateReadingMinutes,
} from '../../../utils/textToSpeech';
-import { useAuth } from '../../../hooks';
+import { getTranslation, fetchSupportedLangs } from '../../../providers/translation/translation';
+import { useAuth, useAppSelector } from '../../../hooks';
+import { selectLanguage } from '../../../redux/selectors';
import { SheetNames } from '../../../navigation/sheets';
interface PostReadingMetadataProps {
@@ -86,6 +89,68 @@ const PostReadingMetadataComponent = ({ post }: PostReadingMetadataProps) => {
}
}, [username, plainText, assistMutation, intl]);
+ // Translation state
+ const appLang = useAppSelector(selectLanguage);
+ const [showTranslate, setShowTranslate] = useState(false);
+ const [translatedSummary, setTranslatedSummary] = useState('');
+ const [isTranslating, setIsTranslating] = useState(false);
+ const [languages, setLanguages] = useState<{ name: string; code: string }[]>([]);
+ const [targetLang, setTargetLang] = useState<{ name: string; code: string } | null>(null);
+
+ // Load languages when translate is toggled
+ useEffect(() => {
+ if (showTranslate && languages.length === 0) {
+ fetchSupportedLangs()
+ .then((res) => {
+ if (res?.length) {
+ const langs = res.map((item: any) => ({ code: item.code, name: item.name }));
+ setLanguages(langs);
+
+ // Auto-select target language based on app language
+ const appCode = appLang?.split('-')[0];
+ const match = langs.find((l: any) => l.code === appCode);
+ if (!targetLang) {
+ setTargetLang(match || { name: 'English', code: 'en' });
+ }
+ }
+ })
+ .catch((err) => {
+ console.warn('Failed to fetch languages:', err);
+ });
+ }
+ }, [showTranslate]);
+
+ // Translate when target language changes
+ useEffect(() => {
+ if (!showTranslate || !summary || !targetLang) return;
+ let cancelled = false;
+ setIsTranslating(true);
+ setTranslatedSummary('');
+ getTranslation(summary, 'auto', targetLang.code)
+ .then((res) => {
+ if (!cancelled && res?.translatedText) {
+ setTranslatedSummary(res.translatedText);
+ }
+ })
+ .catch((err) => {
+ if (!cancelled) console.warn('Translation failed:', err);
+ })
+ .finally(() => {
+ if (!cancelled) setIsTranslating(false);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [showTranslate, summary, targetLang]);
+
+ // Reset translation when summary changes
+ useEffect(() => {
+ setShowTranslate(false);
+ setTranslatedSummary('');
+ }, [summary]);
+
+ const langOptions = useMemo(() => languages.map((l) => l.name), [languages]);
+
// Don't show if post has no readable content or less than 10 words
if (!hasReadableContent(post) || wordCount < 10) {
return null;
@@ -162,7 +227,32 @@ const PostReadingMetadataComponent = ({ post }: PostReadingMetadataProps) => {
{!!summary && (
- {summary}
+
+ {showTranslate && translatedSummary ? translatedSummary : summary}
+
+ {showTranslate && isTranslating && (
+
+ )}
+
+ setShowTranslate(!showTranslate)}>
+
+ {showTranslate
+ ? intl.formatMessage({ id: 'ai_assist.hide_translation' })
+ : intl.formatMessage({ id: 'post_dropdown.translate' })}
+
+
+ {showTranslate && languages.length > 0 && (
+ setTargetLang(languages[index])}
+ options={langOptions}
+ textStyle={styles.langDropdownText}
+ />
+ )}
+
)}
@@ -258,6 +348,34 @@ const styles = EStyleSheet.create({
color: '$primaryBlack',
lineHeight: 20,
},
+ translateRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ marginTop: 10,
+ paddingTop: 10,
+ borderTopWidth: 1,
+ borderTopColor: '$iconColor',
+ gap: 12,
+ },
+ translateLink: {
+ fontSize: 13,
+ fontWeight: '600',
+ color: '$primaryBlue',
+ },
+ translateLoader: {
+ marginTop: 8,
+ },
+ langDropdown: {
+ borderWidth: 1,
+ borderColor: '$iconColor',
+ borderRadius: 6,
+ paddingHorizontal: 8,
+ paddingVertical: 4,
+ },
+ langDropdownText: {
+ fontSize: 12,
+ color: '$primaryBlack',
+ },
});
// Memoize to prevent unnecessary re-renders that could cause infinite loops in FlashList
diff --git a/src/components/videoPlayer/videoPlayerView.tsx b/src/components/videoPlayer/videoPlayerView.tsx
index b83b974f15..7ca7aa20d3 100644
--- a/src/components/videoPlayer/videoPlayerView.tsx
+++ b/src/components/videoPlayer/videoPlayerView.tsx
@@ -1,5 +1,11 @@
import React, { useState, useRef, useEffect } from 'react';
-import { View, StyleSheet, ActivityIndicator, useWindowDimensions } from 'react-native';
+import {
+ View,
+ StyleSheet,
+ ActivityIndicator,
+ useWindowDimensions,
+ Image as RNImage,
+} from 'react-native';
import WebView from 'react-native-webview';
import YoutubeIframe, { InitialPlayerParams } from 'react-native-youtube-iframe';
@@ -17,6 +23,8 @@ interface VideoPlayerProps {
uri?: string;
// prop for youtube player
disableAutoplay?: boolean;
+ // thumbnail URL used to detect portrait video orientation
+ thumbnailUrl?: string;
}
const VideoPlayer = ({
@@ -26,6 +34,7 @@ const VideoPlayer = ({
contentWidth,
mode,
disableAutoplay,
+ thumbnailUrl,
}: VideoPlayerProps) => {
const dim = useWindowDimensions();
const videoPlayer = useRef(null);
@@ -40,15 +49,40 @@ const VideoPlayer = ({
const lockedOrientation = useAppSelector((state) => state.ui.lockedOrientation);
const playerWidth = contentWidth || dim.width;
- const defaultHeight = playerWidth * (9 / 16);
- const [playerHeight, setPlayerHeight] = useState(defaultHeight);
+ const [playerHeight, setPlayerHeight] = useState(playerWidth * (9 / 16));
const checkSrcRegex = /(.*?)\.(mp4|webm|ogg)$/gi;
const isExtensionType = mode === 'uri' ? uri.match(checkSrcRegex) : false;
- // Reset height when URI changes
+ // Reset height when URI changes; detect portrait from thumbnail if available
useEffect(() => {
+ let isActive = true;
setPlayerHeight(playerWidth * (9 / 16));
- }, [uri, playerWidth]);
+
+ if (thumbnailUrl) {
+ // Load the thumbnail to detect portrait/square orientation
+ RNImage.getSize(
+ thumbnailUrl,
+ (w: number, h: number) => {
+ if (!isActive) return;
+ if (w > 0 && h > 0) {
+ const ratio = h / w;
+ if (ratio > 1.05) {
+ // Portrait video — use 3:4 container (matching website)
+ // Cap at 4/3 so the embed player controls remain accessible
+ const cappedRatio = Math.min(ratio, 4 / 3);
+ setPlayerHeight(playerWidth * cappedRatio);
+ }
+ }
+ },
+ () => {
+ // Ignore thumbnail load errors
+ },
+ );
+ }
+ return () => {
+ isActive = false;
+ };
+ }, [uri, playerWidth, thumbnailUrl]);
useEffect(() => {
if (isFullScreen) {
@@ -189,36 +223,42 @@ const VideoPlayer = ({
);
};
- const htmlIframeVideoPlayer = (_uri) =>
+ // Escape URI for safe HTML attribute interpolation
+ const _sanitizeUri = (raw: string) =>
+ raw.replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>');
+
+ const htmlIframeVideoPlayer = (_uri: string) =>
`
-
+
-
-
-
+