diff --git a/api/AI.ts b/api/AI.ts index bf211d39..4500a95e 100644 --- a/api/AI.ts +++ b/api/AI.ts @@ -88,3 +88,23 @@ export async function askQuestion( }); return await response.json(); } + +export async function translate( + customerId: string, + type: "post" | "comment", + sourceLanguage: string, + targetLanguage: string, + content: { postTitle?: string; postText?: string; comment?: string }, +): Promise { + const response = await fetch(`${HYDRA_SERVER_URL}/api/ai/translate`, { + method: "POST", + body: JSON.stringify({ + customerId, + type, + sourceLanguage, + targetLanguage, + ...content, + }), + }); + return await response.text(); +} diff --git a/components/Modals/TranslationModal.tsx b/components/Modals/TranslationModal.tsx new file mode 100644 index 00000000..566711da --- /dev/null +++ b/components/Modals/TranslationModal.tsx @@ -0,0 +1,114 @@ +import React, { useContext } from "react"; +import { StyleSheet, View, Text, ScrollView, useWindowDimensions, TouchableOpacity } from "react-native"; +import { MaterialCommunityIcons } from "@expo/vector-icons"; + +import { ModalContext } from "../../contexts/ModalContext"; +import { ThemeContext } from "../../contexts/SettingsContexts/ThemeContext"; + +type TranslationModalProps = { + originalText: string; + translatedText: string; +}; + +export default function TranslationModal({ originalText, translatedText }: TranslationModalProps) { + const { theme } = useContext(ThemeContext); + const { setModal } = useContext(ModalContext); + + const { width, height } = useWindowDimensions(); + + return ( + <> + + + Translation + setModal(null)} + > + + + + + + Original + + {originalText} + + + + + Translated + + {translatedText} + + + + { + setModal(null); + }} + /> + + ); +} + +const styles = StyleSheet.create({ + translationContainer: { + position: "absolute", + zIndex: 1, + borderRadius: 30, + paddingVertical: 20, + paddingHorizontal: 20, + borderWidth: 3, + }, + header: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + marginBottom: 20, + }, + title: { + fontSize: 20, + fontWeight: "bold", + }, + closeButton: { + padding: 5, + }, + section: { + flex: 1, + marginBottom: 15, + }, + sectionTitle: { + fontSize: 14, + fontWeight: "600", + marginBottom: 8, + }, + textScroll: { + flex: 1, + }, + text: { + fontSize: 16, + lineHeight: 22, + }, + background: { + position: "absolute", + width: "100%", + top: 0, + left: 0, + backgroundColor: "black", + opacity: 0.7, + }, +}); diff --git a/components/Navbar/SortAndContext.tsx b/components/Navbar/SortAndContext.tsx index 90fca560..3d81a98e 100644 --- a/components/Navbar/SortAndContext.tsx +++ b/components/Navbar/SortAndContext.tsx @@ -30,7 +30,11 @@ import { } from "../../constants/SettingsKeys"; import { ModalContext } from "../../contexts/ModalContext"; import { ThemeContext } from "../../contexts/SettingsContexts/ThemeContext"; +import { TranslationSettingsContext } from "../../contexts/SettingsContexts/TranslationSettingsContext"; +import { SubscriptionsContext } from "../../contexts/SubscriptionsContext"; import { SubredditContext } from "../../contexts/SubredditContext"; +import { translate } from "../../api/AI"; +import TranslationModal from "../Modals/TranslationModal"; import KeyStore from "../../utils/KeyStore"; import RedditURL, { PageType } from "../../utils/RedditURL"; import { useURLNavigation } from "../../utils/navigation"; @@ -72,7 +76,8 @@ export type ContextTypes = | "Hide Seen Posts" | "Sidebar" | "Wiki" - | "Open in Gallery Mode"; + | "Open in Gallery Mode" + | "Translate Text"; type SortAndContextProps = { route: RouteProp | string; @@ -94,6 +99,8 @@ export default function SortAndContext({ const { subscribe, unsubscribe, toggleFavorite, multis, addSubToMulti } = useContext(SubredditContext); const { toggleHideSeenURL } = useContext(FiltersContext); + const { sourceLanguage, targetLanguage } = useContext(TranslationSettingsContext); + const { isPro, customerId } = useContext(SubscriptionsContext); const { replaceURL, pushURL, setParams, openGallery } = useURLNavigation(); @@ -371,6 +378,24 @@ export default function SortAndContext({ pushURL(`https://www.reddit.com/r/${subreddit}/wiki/index`); } else if (result === "Open in Gallery Mode") { openGallery(currentPath); + } else if (result === "Translate Text" && isPro && customerId && pageData?.type === "postDetail") { + try { + const translation = await translate( + customerId, + "post", + sourceLanguage, + targetLanguage, + { postTitle: pageData.title, postText: pageData.text || "" }, + ); + setModal( + + ); + } catch { + alert("Failed to translate post. Please try again."); + } } }} > diff --git a/components/RedditDataRepresentations/Post/PostDetailsComponent.tsx b/components/RedditDataRepresentations/Post/PostDetailsComponent.tsx index 7285023e..d4bddc56 100644 --- a/components/RedditDataRepresentations/Post/PostDetailsComponent.tsx +++ b/components/RedditDataRepresentations/Post/PostDetailsComponent.tsx @@ -1,4 +1,10 @@ -import { AntDesign, Feather, FontAwesome, Octicons } from "@expo/vector-icons"; +import { + AntDesign, + Feather, + FontAwesome, + Octicons, + MaterialCommunityIcons, +} from "@expo/vector-icons"; import React, { Dispatch, SetStateAction, @@ -19,6 +25,8 @@ import { import PostMedia from "./PostParts/PostMedia"; import SubredditIcon from "./PostParts/SubredditIcon"; import { summarizePostDetails, summarizePostComments } from "../../../api/AI"; +import { translate } from "../../../api/AI"; +import TranslationModal from "../../Modals/TranslationModal"; import { PostDetail, vote } from "../../../api/PostDetail"; import { VoteOption } from "../../../api/Posts"; import { saveItem } from "../../../api/Save"; @@ -26,6 +34,7 @@ import { ModalContext } from "../../../contexts/ModalContext"; import { CommentSettingsContext } from "../../../contexts/SettingsContexts/CommentSettingsContext"; import { PostSettingsContext } from "../../../contexts/SettingsContexts/PostSettingsContext"; import { ThemeContext } from "../../../contexts/SettingsContexts/ThemeContext"; +import { TranslationSettingsContext } from "../../../contexts/SettingsContexts/TranslationSettingsContext"; import { SubscriptionsContext } from "../../../contexts/SubscriptionsContext"; import RedditURL from "../../../utils/RedditURL"; import { useRoute, useURLNavigation } from "../../../utils/navigation"; @@ -58,6 +67,7 @@ export default function PostDetailsComponent({ const { showPostSummary, tapToCollapsePost } = useContext(PostSettingsContext); const { showCommentSummary } = useContext(CommentSettingsContext); + const { sourceLanguage, targetLanguage } = useContext(TranslationSettingsContext); const [mediaCollapsed, setMediaCollapsed] = useState(false); const [commentSummaryCollapsed, setCommentSummaryCollapsed] = useState(false); @@ -74,6 +84,28 @@ export default function PostDetailsComponent({ }); }; + const handleTranslatePost = async () => { + if (!isPro || !customerId) return; + + try { + const translation = await translate( + customerId, + "post", + sourceLanguage, + targetLanguage, + { postTitle: postDetail.title, postText: postDetail.text || "" }, + ); + setModal( + + ); + } catch (error) { + alert("Failed to translate post. Please try again."); + } + }; + const getSummary = async () => { if (!isPro || !customerId) return; let postSummary = null; @@ -359,6 +391,14 @@ export default function PostDetailsComponent({ > + {isPro && ( + + + + )} {contextDepth > 0 && ( { + if (!isPro || !customerId) return; + + try { + const translation = await translate( + customerId, + "comment", + sourceLanguage, + targetLanguage, + { comment: comment.text }, + ); + setModal( + + ); + } catch (error) { + alert("Failed to translate comment. Please try again."); + } + }; + const showCommentOptions = async () => { const options = [ "Upvote", @@ -197,6 +228,7 @@ export function CommentComponent({ "Reply", ...(comment.saved ? ["Unsave"] : ["Save"]), ...(currentUser?.userName === comment.author ? ["Edit", "Delete"] : []), + ...(isPro ? ["Translate Text"] : []), "Share", ]; const result = await showContextMenu({ options }); @@ -219,6 +251,8 @@ export function CommentComponent({ confirmDeleteComment(); } else if (result === "Select Text") { setModal(); + } else if (result === "Translate Text" && isPro && customerId) { + handleTranslateComment(); } else if (result === "Share") { Share.share({ url: new RedditURL(comment.link).toString() }); } diff --git a/contexts/SettingsContexts/TranslationSettingsContext.tsx b/contexts/SettingsContexts/TranslationSettingsContext.tsx new file mode 100644 index 00000000..347147b0 --- /dev/null +++ b/contexts/SettingsContexts/TranslationSettingsContext.tsx @@ -0,0 +1,60 @@ +import { createContext } from "react"; +import { useMMKVString } from "react-native-mmkv"; + +export const SUPPORTED_LANGUAGES = [ + { code: "auto", name: "Auto Detect" }, + { code: "en", name: "English" }, + { code: "zh-Hant", name: "Chinese Traditional" }, + { code: "zh-Hans", name: "Chinese Simplified" }, + { code: "es", name: "Spanish" }, + { code: "fr", name: "French" }, + { code: "de", name: "German" }, + { code: "pt", name: "Portuguese" }, + { code: "ja", name: "Japanese" }, + { code: "ko", name: "Korean" }, + { code: "ru", name: "Russian" }, +] as const; + +type LanguageCode = typeof SUPPORTED_LANGUAGES[number]["code"]; + +const initialValues = { + sourceLanguage: "auto" as LanguageCode, + targetLanguage: "en" as LanguageCode, +}; + +const initialTranslationSettingsContext = { + ...initialValues, + setSourceLanguage: (_language: LanguageCode) => {}, + setTargetLanguage: (_language: LanguageCode) => {}, +}; + +export const TranslationSettingsContext = createContext( + initialTranslationSettingsContext, +); + +export function TranslationSettingsProvider({ + children, +}: React.PropsWithChildren) { + const [storedSourceLanguage, setSourceLanguage] = useMMKVString( + "translationSourceLanguage", + ); + const sourceLanguage = (storedSourceLanguage ?? initialValues.sourceLanguage) as LanguageCode; + + const [storedTargetLanguage, setTargetLanguage] = useMMKVString( + "translationTargetLanguage", + ); + const targetLanguage = (storedTargetLanguage ?? initialValues.targetLanguage) as LanguageCode; + + return ( + + {children} + + ); +} diff --git a/contexts/SettingsContexts/index.tsx b/contexts/SettingsContexts/index.tsx index 5fa421ea..dbde3448 100644 --- a/contexts/SettingsContexts/index.tsx +++ b/contexts/SettingsContexts/index.tsx @@ -6,6 +6,7 @@ import { NotificationsProvider } from "./NotificationsContext"; import { PostSettingsProvider } from "./PostSettingsContext"; import { TabSettingsProvider } from "./TabSettingsContext"; import { ThemeProvider } from "./ThemeContext"; +import { TranslationSettingsProvider } from "./TranslationSettingsContext"; export function SettingsProvider({ children }: React.PropsWithChildren) { return ( @@ -16,7 +17,9 @@ export function SettingsProvider({ children }: React.PropsWithChildren) { - {children} + + {children} + diff --git a/pages/PostDetails.tsx b/pages/PostDetails.tsx index 63a1d596..372185d7 100644 --- a/pages/PostDetails.tsx +++ b/pages/PostDetails.tsx @@ -32,12 +32,15 @@ import Comments from "../components/RedditDataRepresentations/Post/PostParts/Com import { AccountContext } from "../contexts/AccountContext"; import { ScrollerContext, ScrollerProvider } from "../contexts/ScrollerContext"; import { ThemeContext } from "../contexts/SettingsContexts/ThemeContext"; +import { SubscriptionsContext } from "../contexts/SubscriptionsContext"; import RedditURL from "../utils/RedditURL"; import { useURLNavigation } from "../utils/navigation"; import { TabScrollContext } from "../contexts/TabScrollContext"; import { modifyStat, Stat } from "../db/functions/Stats"; import ScrollToNextButtonProvider from "../contexts/ScrollToNextButtonProvider"; import { ScrollToNextButtonContext } from "../contexts/ScrollToNextButtonContext"; +import { summarizePostDetails, summarizePostComments } from "../api/AI"; +import TranslationModal from "../components/Modals/TranslationModal"; export type LoadMoreCommentsFunc = ( commentIds: string[], @@ -53,6 +56,7 @@ type PostDetailsProps = }; function PostDetails(props: PostDetailsProps) { + const { isPro } = useContext(SubscriptionsContext); const url = "route" in props ? props.route.params.url : props.splitViewURL; const isSplitView = "splitViewURL" in props && !!props.splitViewURL; @@ -73,6 +77,9 @@ function PostDetails(props: PostDetailsProps) { const [postDetail, setPostDetail] = useState(); const [refreshing, setRefreshing] = useState(false); + const [loadingMore, setLoadingMore] = useState(false); + const [showSummary, setShowSummary] = useState(false); + const [summary, setSummary] = useState(); const deferredPostDetail = useDeferredValue(postDetail); @@ -124,6 +131,7 @@ function PostDetails(props: PostDetailsProps) { "Report", "Select Text", "Share", + ...(isPro ? ["Translate Text" as ContextTypes] : []), ]; const contextSort: SortTypes[] = [ "Best", @@ -135,17 +143,19 @@ function PostDetails(props: PostDetailsProps) { ]; navigation.setOptions({ title: new RedditURL(url).getPageName(), - headerRight: () => { - return ( - - ); + headerRight: () => ( + + ), + headerStyle: { + backgroundColor: theme.background, }, + headerTintColor: theme.text, }); }; diff --git a/pages/SettingsPage/General/GeneralRoot.tsx b/pages/SettingsPage/General/GeneralRoot.tsx index 15eadb88..792358df 100644 --- a/pages/SettingsPage/General/GeneralRoot.tsx +++ b/pages/SettingsPage/General/GeneralRoot.tsx @@ -8,66 +8,79 @@ import React, { useContext } from "react"; import List from "../../../components/UI/List"; import { ThemeContext } from "../../../contexts/SettingsContexts/ThemeContext"; +import { SubscriptionsContext } from "../../../contexts/SubscriptionsContext"; import { useURLNavigation } from "../../../utils/navigation"; export default function GeneralRoot() { const { theme } = useContext(ThemeContext); + const { isPro } = useContext(SubscriptionsContext); const { pushURL } = useURLNavigation(); + const generalItems = [ + { + key: "gestures", + icon: , + text: "Gestures", + onPress: () => pushURL("hydra://settings/general/gestures"), + }, + { + key: "sorting", + icon: , + text: "Post & Comment Sorting", + onPress: () => pushURL("hydra://settings/general/sorting"), + }, + { + key: "filters", + icon: , + text: "Filters", + onPress: () => pushURL("hydra://settings/general/filters"), + }, + { + key: "openInHydra", + icon: , + text: "Open in Hydra", + onPress: () => pushURL("hydra://settings/general/openInHydra"), + }, + { + key: "startup", + icon: ( + + ), + text: "App Startup", + onPress: () => pushURL("hydra://settings/general/startup"), + }, + { + key: "legal", + icon: , + text: "Legal", + onPress: () => pushURL("hydra://settings/general/legal"), + }, + { + key: "externalLinks", + icon: , + text: "External Links", + onPress: () => pushURL("hydra://settings/general/externalLinks"), + }, + ]; + + if (isPro) { + generalItems.splice(5, 0, { + key: "translations", + icon: , + text: "Translations", + onPress: () => pushURL("hydra://settings/general/translations"), + }); + } + return ( <> , - text: "Gestures", - onPress: () => pushURL("hydra://settings/general/gestures"), - }, - { - key: "sorting", - icon: , - text: "Post & Comment Sorting", - onPress: () => pushURL("hydra://settings/general/sorting"), - }, - { - key: "filters", - icon: , - text: "Filters", - onPress: () => pushURL("hydra://settings/general/filters"), - }, - { - key: "openInHydra", - icon: , - text: "Open in Hydra", - onPress: () => pushURL("hydra://settings/general/openInHydra"), - }, - { - key: "startup", - icon: ( - - ), - text: "App Startup", - onPress: () => pushURL("hydra://settings/general/startup"), - }, - { - key: "legal", - icon: , - text: "Legal", - onPress: () => pushURL("hydra://settings/general/legal"), - }, - { - key: "externalLinks", - icon: , - text: "External Links", - onPress: () => pushURL("hydra://settings/general/externalLinks"), - }, - ]} + items={generalItems} /> ); diff --git a/pages/SettingsPage/General/Translations.tsx b/pages/SettingsPage/General/Translations.tsx new file mode 100644 index 00000000..12704184 --- /dev/null +++ b/pages/SettingsPage/General/Translations.tsx @@ -0,0 +1,107 @@ +import { MaterialCommunityIcons } from "@expo/vector-icons"; +import React, { useContext } from "react"; +import { View, StyleSheet } from "react-native"; + +import List from "../../../components/UI/List"; +import { ThemeContext } from "../../../contexts/SettingsContexts/ThemeContext"; +import { TranslationSettingsContext, SUPPORTED_LANGUAGES } from "../../../contexts/SettingsContexts/TranslationSettingsContext"; +import { SubscriptionsContext } from "../../../contexts/SubscriptionsContext"; +import { useSettingsPicker } from "../../../utils/useSettingsPicker"; + +export default function Translations() { + const { theme } = useContext(ThemeContext); + const { sourceLanguage, targetLanguage, setSourceLanguage, setTargetLanguage } = useContext(TranslationSettingsContext); + const { isPro } = useContext(SubscriptionsContext); + + const sourceLanguageOptions = SUPPORTED_LANGUAGES.map(lang => ({ + label: lang.name, + value: lang.code, + })); + + const targetLanguageOptions = SUPPORTED_LANGUAGES.filter(lang => lang.code !== "auto").map(lang => ({ + label: lang.name, + value: lang.code, + })); + + const { + openPicker: openSourceLanguagePicker, + rightIcon: rightIconSourceLanguage, + } = useSettingsPicker({ + items: sourceLanguageOptions, + value: sourceLanguage, + onChange: (newValue) => { + if (isPro) { + setSourceLanguage(newValue); + } + }, + }); + + const { + openPicker: openTargetLanguagePicker, + rightIcon: rightIconTargetLanguage, + } = useSettingsPicker({ + items: targetLanguageOptions, + value: targetLanguage, + onChange: (newValue) => { + if (isPro) { + setTargetLanguage(newValue); + } + }, + }); + + return ( + + , + text: "Source Language", + rightIcon: rightIconSourceLanguage, + onPress: () => { + if (isPro) { + openSourceLanguagePicker(); + } + }, + }, + { + key: "targetLanguage", + icon: , + text: "Target Language", + rightIcon: rightIconTargetLanguage, + onPress: () => { + if (isPro) { + openTargetLanguagePicker(); + } + }, + }, + ]} + /> + {!isPro && ( + + + , + }, + ]} + /> + + )} + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + proNotice: { + marginTop: 20, + paddingHorizontal: 15, + }, +}); diff --git a/pages/SettingsPage/index.tsx b/pages/SettingsPage/index.tsx index 01168210..e4746a17 100644 --- a/pages/SettingsPage/index.tsx +++ b/pages/SettingsPage/index.tsx @@ -24,6 +24,7 @@ import Gestures from "./General/Gestures"; import Stats from "./Stats"; import AppIcon from "./General/AppIcon/AppIcon"; import AppIconDetails from "./General/AppIcon/AppIconDetails"; +import Translations from "./General/Translations"; /** * Important to load this lazily since the guide loads the large documentation @@ -68,6 +69,7 @@ export default function SettingsPage({ {relativePath === "settings/general/startup" && } {relativePath === "settings/general/legal" && } {relativePath === "settings/general/externalLinks" && } + {relativePath === "settings/general/translations" && } {relativePath === "settings/theme" && } {relativePath === "settings/themeMaker" && }