Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
19 changes: 17 additions & 2 deletions src/components/postHtmlRenderer/postHtmlRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -454,12 +463,18 @@ export const PostHtmlRenderer = memo(
};
return <VideoThumb contentWidth={contentWidth} onPress={_onPress} />;
} else {
const isSpeakEmbed = /3speak\.tv/i.test(iframeProps.source.uri || '');
return (
<VideoPlayer mode="uri" uri={iframeProps.source.uri} contentWidth={contentWidth} />
<VideoPlayer
mode="uri"
uri={iframeProps.source.uri}
contentWidth={contentWidth}
thumbnailUrl={isSpeakEmbed ? _speakThumbnail : undefined}
/>
);
}
},
[isComment, handleVideoPress, contentWidth],
[isComment, handleVideoPress, contentWidth, _speakThumbnail],
);

const tagsStyles = useMemo(
Expand Down
122 changes: 120 additions & 2 deletions src/components/postView/children/postReadingMetadata.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,17 @@ 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,
hasReadableContent,
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 {
Expand Down Expand Up @@ -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]);
Comment on lines +100 to +121
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Missing dependencies in useEffect may cause stale closures.

The dependency array only includes showTranslate, but the effect references languages, appLang, and targetLang. While languages.length === 0 check prevents re-fetching, the auto-selection logic at lines 110-114 reads appLang and targetLang which could be stale.

Additionally, the empty .catch(() => {}) silently swallows errors. Consider at least logging the failure for debugging.

🛠️ Suggested fix
   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(() => {});
+        .catch((err) => console.warn('Failed to fetch supported languages:', err));
     }
-  }, [showTranslate]);
+  }, [showTranslate, languages.length, appLang, targetLang]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/postView/children/postReadingMetadata.tsx` around lines 100 -
119, The useEffect that loads languages (the block using useEffect,
showTranslate, languages, fetchSupportedLangs, setLanguages, appLang,
targetLang, setTargetLang) has missing dependencies and swallows errors; update
the dependency array to include languages (or languages.length), appLang, and
targetLang so the auto-selection logic sees current values and avoid stale
closures, and handle promise rejection in the .catch by logging the error (e.g.,
console.error or process logger) instead of an empty handler; ensure this change
still guards re-fetching by keeping the existing languages.length === 0 check to
prevent loops.


// 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;
Expand Down Expand Up @@ -162,7 +227,32 @@ const PostReadingMetadataComponent = ({ post }: PostReadingMetadataProps) => {

{!!summary && (
<View style={styles.summaryBox}>
<Text style={styles.summaryText}>{summary}</Text>
<Text style={styles.summaryText}>
{showTranslate && translatedSummary ? translatedSummary : summary}
</Text>
{showTranslate && isTranslating && (
<ActivityIndicator size="small" style={styles.translateLoader} />
)}
<View style={styles.translateRow}>
<TouchableOpacity onPress={() => setShowTranslate(!showTranslate)}>
<Text style={styles.translateLink}>
{showTranslate
? intl.formatMessage({ id: 'ai_assist.hide_translation' })
: intl.formatMessage({ id: 'post_dropdown.translate' })}
</Text>
</TouchableOpacity>
{showTranslate && languages.length > 0 && (
<DropdownButton
style={styles.langDropdown}
defaultText={targetLang?.name || 'English'}
isHasChildIcon
noHighlight
onSelect={(index) => setTargetLang(languages[index])}
options={langOptions}
textStyle={styles.langDropdownText}
/>
)}
</View>
</View>
)}
</View>
Expand Down Expand Up @@ -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
Expand Down
84 changes: 63 additions & 21 deletions src/components/videoPlayer/videoPlayerView.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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 = ({
Expand All @@ -26,6 +34,7 @@ const VideoPlayer = ({
contentWidth,
mode,
disableAutoplay,
thumbnailUrl,
}: VideoPlayerProps) => {
const dim = useWindowDimensions();
const videoPlayer = useRef(null);
Expand All @@ -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));
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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]);
Comment on lines +56 to +85
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's find and examine the videoPlayerView.tsx file
find . -name "videoPlayerView.tsx" -type f

Repository: ecency/ecency-mobile

Length of output: 112


🏁 Script executed:

# Check the file exists and read the relevant section
if [ -f "src/components/videoPlayer/videoPlayerView.tsx" ]; then
  wc -l src/components/videoPlayer/videoPlayerView.tsx
  echo "---"
  sed -n '50,85p' src/components/videoPlayer/videoPlayerView.tsx
fi

Repository: ecency/ecency-mobile

Length of output: 1312


🏁 Script executed:

# Let's also check the full context of this component to understand the structure
if [ -f "src/components/videoPlayer/videoPlayerView.tsx" ]; then
  head -100 src/components/videoPlayer/videoPlayerView.tsx
fi

Repository: ecency/ecency-mobile

Length of output: 3313


Prevent stale RNImage.getSize() callbacks from overwriting player height.

RNImage.getSize() is async, so if the component is recycled or dependencies change before the callback fires, the stale callback will overwrite playerHeight with incorrect data from the previous thumbnail. Use an isActive flag with a cleanup function to guard against this:

Fix
   useEffect(() => {
+    let isActive = true;
+
     setPlayerHeight(playerWidth * (9 / 16));

     if (thumbnailUrl) {
       // Load the thumbnail to detect portrait/square orientation
       RNImage.getSize(
         thumbnailUrl,
         (w: number, h: number) => {
-          if (w > 0 && h > 0) {
+          if (isActive && 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]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/videoPlayer/videoPlayerView.tsx` around lines 56 - 80, The
RNImage.getSize callback can be invoked after dependencies change causing stale
updates; inside the useEffect(for uri, playerWidth, thumbnailUrl) introduce an
isActive boolean flag (e.g., let isActive = true) and in the success callback
check isActive before calling setPlayerHeight, and set isActive = false in the
useEffect cleanup to prevent stale callbacks from overwriting playerHeight; keep
the existing logic using RNImage.getSize, but guard both success and error
callbacks with the isActive check so only the latest effect can update via
setPlayerHeight.


useEffect(() => {
if (isFullScreen) {
Expand Down Expand Up @@ -189,36 +223,42 @@ const VideoPlayer = ({
);
};

const htmlIframeVideoPlayer = (_uri) =>
// Escape URI for safe HTML attribute interpolation
const _sanitizeUri = (raw: string) =>
raw.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');

const htmlIframeVideoPlayer = (_uri: string) =>
`<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
<meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0" />
<style>
* { padding: 0; margin: 0; box-sizing: border-box; }
html, body { width: 100%; height: 100%; overflow: hidden; background: #000; }
#iframeWrapper {
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
html, body { width: 100%; height: 100%; background: #000; }
iframe {
border: 0;
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
}
iframe { border: 0; }
</style>
</head>
<body>
<div id="iframeWrapper">
<iframe width="100%" height="100%" src="${_uri}" allowfullscreen></iframe>
</div>
<iframe src="${_sanitizeUri(_uri)}"
allow="autoplay; accelerometer; encrypted-media; gyroscope; picture-in-picture; web-share; fullscreen"
allowfullscreen></iframe>
<script>
window.addEventListener('message', function(e) {
if (e.data && e.data.type === '3speak-player-ready') {
if (e.data && e.data.type === '3speak-player-ready' && window.ReactNativeWebView) {
var msg = { type: 'aspectRatio' };
if (e.data.isVertical) {
msg.ratio = 4 / 3;
} else if (e.data.aspectRatio && Math.abs(e.data.aspectRatio - 1) < 0.1) {
msg.ratio = 1;
}
if (msg.ratio && window.ReactNativeWebView) {
if (msg.ratio) {
window.ReactNativeWebView.postMessage(JSON.stringify(msg));
}
}
Expand All @@ -229,9 +269,9 @@ const VideoPlayer = ({
return (
<View style={styles.container}>
{mode === 'youtube' && youtubeVideoId && (
<View style={{ width: contentWidth, height: defaultHeight }}>
<View style={{ width: playerWidth, height: playerHeight }}>
<YoutubeIframe
height={defaultHeight}
height={playerHeight}
videoId={youtubeVideoId}
initialPlayerParams={initialParams}
onReady={_onReady}
Expand Down Expand Up @@ -269,7 +309,7 @@ const VideoPlayer = ({
setIsLoading(true);
}}
source={{ html: htmlIframeVideoPlayer(uri) }}
style={[styles.barkBackground, { width: contentWidth, height: playerHeight }]}
style={[styles.barkBackground, { width: playerWidth, height: playerHeight }]}
startInLoadingState={true}
onShouldStartLoadWithRequest={() => true}
mediaPlaybackRequiresUserAction={false}
Expand All @@ -283,7 +323,9 @@ const VideoPlayer = ({
try {
const msg = JSON.parse(event.nativeEvent.data);
if (msg.type === 'aspectRatio' && msg.ratio) {
setPlayerHeight(playerWidth * msg.ratio);
// Cap at 4/3 to keep player controls accessible
const cappedRatio = Math.min(msg.ratio, 4 / 3);
setPlayerHeight(playerWidth * cappedRatio);
}
} catch {
// ignore non-JSON messages
Expand Down
9 changes: 6 additions & 3 deletions src/config/locales/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -601,7 +601,8 @@
"error_insufficient_points": "Insufficient points. You need {required} but have {available}.",
"error_content_policy": "Content violates our content policy. Please modify your text.",
"error_rate_limit": "Too many requests. Please wait a moment and try again.",
"error_generic": "Something went wrong. Please try again."
"error_generic": "Something went wrong. Please try again.",
"hide_translation": "Hide translation"
},
"welcome": {
"line1_body": "Uncensored, immutable, rewarding, decentralized, that you own.",
Expand Down Expand Up @@ -871,8 +872,9 @@
"confirmTransaction": "Confirm transaction",
"invalid_amount_desc": "Enter valid amount in proper format",
"open": "Open URL",
"invalid_key_desc": "kindly login with {key} key or master key to perform this transaction"
},
"invalid_key_desc": "kindly login with {key} key or master key to perform this transaction",
"auth_transfer_confirm": "Confirm transfer to {username}?"
},
"voters_dropdown": {
"rewards": "REWARDS",
"percent": "PERCENT",
Expand Down Expand Up @@ -1207,6 +1209,7 @@
"prompt_placeholder": "A beautiful sunset over mountains...",
"chars_remaining": "{count} characters remaining",
"select_aspect_ratio": "Select aspect ratio",
"select_power": "Quality boost",
"cost_label": "Cost",
"balance_label": "Balance",
"points_unit": "POINTS",
Expand Down
Loading
Loading