Skip to content
80 changes: 66 additions & 14 deletions src/components/PostControls/PostMenu/PostMenuItems.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {memo, useMemo} from 'react'
import {memo, useCallback, useMemo, useState} from 'react'
import {
Platform,
type PressableProps,
Expand All @@ -8,7 +8,7 @@ import {
import * as Clipboard from 'expo-clipboard'
import {
type AppBskyFeedDefs,
AppBskyFeedPost,
type AppBskyFeedPost,
type AppBskyFeedThreadgate,
AtUri,
type RichText as RichTextAPI,
Expand Down Expand Up @@ -83,7 +83,6 @@ import {
} from '#/components/moderation/ReportDialog'
import * as Prompt from '#/components/Prompt'
import {IS_INTERNAL} from '#/env'
import * as bsky from '#/types/bsky'

let PostMenuItems = ({
post,
Expand Down Expand Up @@ -128,6 +127,8 @@ let PostMenuItems = ({
const postInteractionSettingsDialogControl = useDialogControl()
const quotePostDetachConfirmControl = useDialogControl()
const hideReplyConfirmControl = useDialogControl()
const translateControl = useDialogControl()
const [translation, setTranslation] = useState('')
const {mutateAsync: toggleReplyVisibility} =
useToggleReplyVisibilityMutation()

Expand Down Expand Up @@ -230,26 +231,69 @@ let PostMenuItems = ({
Toast.show(_(msg`Copied to clipboard`), 'clipboard-check')
}

const onPressTranslate = () => {
translate(record.text, langPrefs.primaryLanguage)
const onPressTranslate = useCallback(async () => {
const supportsTranslatorAPI = 'Translator' in self
const textToTranslate = record.text
const targetLanguage = langPrefs.primaryLanguage

if (
bsky.dangerousIsType<AppBskyFeedPost.Record>(
post.record,
AppBskyFeedPost.isRecord,
if (!supportsTranslatorAPI) {
translate(record.text, langPrefs.primaryLanguage)
logger.metric(
'translate',
{
sourceLanguages: record.langs ?? [],
targetLanguage: targetLanguage,
textLength: textToTranslate.length,
},
{statsig: false},
)
) {
return
}

try {
let sourceLanguage = record.langs?.[0]

if (!sourceLanguage && 'LanguageDetector' in self) {
const languageDetector = await self.LanguageDetector.create()
const detectionResult = await languageDetector.detect(textToTranslate)
sourceLanguage = detectionResult[0]?.detectedLanguage
}

const translator = await self.Translator.create({
sourceLanguage,
targetLanguage: targetLanguage,
})

const translations = []
const postParagraphs = textToTranslate.split(/\n/)
for (const postParagraph of postParagraphs) {
translations.push(await translator.translate(postParagraph))
}
const translatedText = translations.join('\n')
setTranslation(translatedText)
translateControl.open()

logger.metric(
'translate',
{
sourceLanguages: post.record.langs ?? [],
targetLanguage: langPrefs.primaryLanguage,
textLength: post.record.text.length,
sourceLanguages: record.langs ?? [],
targetLanguage: targetLanguage,
textLength: textToTranslate.length,
},
{statsig: false},
)
} catch (err) {
console.error(err)
Toast.show(_(msg`Could not translate`), 'xmark')
}
}
}, [
record.text,
record.langs,
langPrefs.primaryLanguage,
translate,
translateControl,
_,
])

const onHidePost = () => {
hidePost({uri: postUri})
Expand Down Expand Up @@ -760,6 +804,14 @@ let PostMenuItems = ({
confirmButtonCta={_(msg`Block`)}
confirmButtonColor="negative"
/>

<Prompt.Outer control={translateControl}>
<Prompt.TitleText>{_(msg`Translation`)}</Prompt.TitleText>
<Prompt.DescriptionText>{translation}</Prompt.DescriptionText>
<Prompt.Actions>
<Prompt.Action cta={_(msg`OK`)} onPress={() => {}} />
</Prompt.Actions>
</Prompt.Outer>
</>
)
}
Expand Down
82 changes: 69 additions & 13 deletions src/components/dms/MessageContextMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {memo, useCallback} from 'react'
import {memo, useCallback, useState} from 'react'
import {LayoutAnimation} from 'react-native'
import * as Clipboard from 'expo-clipboard'
import {type ChatBskyConvoDefs, RichText} from '@atproto/api'
Expand Down Expand Up @@ -37,6 +37,8 @@ export let MessageContextMenu = ({
const convo = useConvoActive()
const deleteControl = usePromptControl()
const reportControl = usePromptControl()
const translateControl = usePromptControl()
const [translation, setTranslation] = useState('')
const langPrefs = useLanguagePrefs()
const translate = useTranslate()

Expand All @@ -56,18 +58,64 @@ export let MessageContextMenu = ({
}, [_, message.text, message.facets])

const onPressTranslateMessage = useCallback(() => {
translate(message.text, langPrefs.primaryLanguage)

logger.metric(
'translate',
{
sourceLanguages: [],
targetLanguage: langPrefs.primaryLanguage,
textLength: message.text.length,
},
{statsig: false},
)
}, [langPrefs.primaryLanguage, message.text, translate])
const supportsTranslatorAPI = 'Translator' in self
const supportsLanguageDetectorAPI = 'LanguageDetector' in self
const textToTranslate = message.text
const targetLanguage = langPrefs.primaryLanguage

const run = async () => {
if (!supportsTranslatorAPI || !supportsLanguageDetectorAPI) {
translate(message.text, langPrefs.primaryLanguage)

logger.metric(
'translate',
{
sourceLanguages: [],
targetLanguage: targetLanguage,
textLength: textToTranslate.length,
},
{statsig: false},
)
return
}

try {
const languageDetector = await self.LanguageDetector.create()
const sourceLanguage = (
await languageDetector.detect(textToTranslate)
)[0].detectedLanguage

const translator = await self.Translator.create({
sourceLanguage,
targetLanguage: targetLanguage,
})

const translations = []
const postParagraphs = textToTranslate.split(/\n/)
for (const postParagraph of postParagraphs) {
translations.push(await translator.translate(postParagraph))
}
const translatedText = translations.join('\n')
setTranslation(translatedText)
translateControl.open()

logger.metric(
'translate',
{
sourceLanguages: [],
targetLanguage: targetLanguage,
textLength: textToTranslate.length,
},
{statsig: false},
)
} catch (err) {
console.error(err)
Toast.show(_(msg`Could not translate`), 'xmark')
}
}

run()
}, [message.text, langPrefs.primaryLanguage, translate, translateControl, _])

const onDelete = useCallback(() => {
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
Expand Down Expand Up @@ -186,6 +234,14 @@ export let MessageContextMenu = ({
confirmButtonColor="negative"
onConfirm={onDelete}
/>

<Prompt.Outer control={translateControl}>
<Prompt.TitleText>{_(msg`Translation`)}</Prompt.TitleText>
<Prompt.DescriptionText>{translation}</Prompt.DescriptionText>
<Prompt.Actions>
<Prompt.Action cta={_(msg`OK`)} onPress={() => {}} />
</Prompt.Actions>
</Prompt.Outer>
</>
)
}
Expand Down
92 changes: 74 additions & 18 deletions src/screens/PostThread/components/ThreadItemAnchor.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {memo, useCallback, useMemo} from 'react'
import {memo, useCallback, useMemo, useState} from 'react'
import {type GestureResponderEvent, Text as RNText, View} from 'react-native'
import {
AppBskyFeedDefs,
Expand All @@ -17,7 +17,12 @@ import {makeProfileLink} from '#/lib/routes/links'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
import {niceDate} from '#/lib/strings/time'
import {getTranslatorLink, isPostInLanguage} from '#/locale/helpers'
import {hasProp} from '#/lib/type-guards'
import {
getPostLanguage,
getTranslatorLink,
isPostInLanguage,
} from '#/locale/helpers'
import {logger} from '#/logger'
import {
POST_TOMBSTONE,
Expand Down Expand Up @@ -202,6 +207,7 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
const authorHref = makeProfileLink(post.author)
const isThreadAuthor = getThreadAuthor(post, record) === currentAccount?.did

const [translatedText, setTranslatedText] = useState<string | null>(null)
const likesHref = useMemo(() => {
const urip = new AtUri(post.uri)
return makeProfileLink(post.author, 'post', urip.rkey, 'liked-by')
Expand Down Expand Up @@ -394,7 +400,7 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
<RichText
enableTags
selectable
value={richText}
value={!translatedText ? richText : translatedText}
style={[a.flex_1, a.text_xl]}
authorHandle={post.author.handle}
shouldProxyLinks={true}
Expand All @@ -414,6 +420,8 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
<ExpandedPostDetails
post={item.value.post}
isThreadAuthor={isThreadAuthor}
translatedText={translatedText}
setTranslatedText={setTranslatedText}
/>
{post.repostCount !== 0 ||
post.likeCount !== 0 ||
Expand Down Expand Up @@ -525,15 +533,20 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
function ExpandedPostDetails({
post,
isThreadAuthor,
translatedText,
setTranslatedText,
}: {
post: Extract<ThreadItem, {type: 'threadPost'}>['value']['post']
isThreadAuthor: boolean
translatedText: string | null
setTranslatedText: (text: string) => void
}) {
const t = useTheme()
const {_, i18n} = useLingui()
const translate = useTranslate()
const isRootPost = !('reply' in post.record)
const langPrefs = useLanguagePrefs()
const supportsTranslatorAPI = 'Translator' in self

const needsTranslation = useMemo(
() =>
Expand All @@ -547,24 +560,67 @@ function ExpandedPostDetails({
const onTranslatePress = useCallback(
(e: GestureResponderEvent) => {
e.preventDefault()
translate(post.record.text || '', langPrefs.primaryLanguage)

if (
bsky.dangerousIsType<AppBskyFeedPost.Record>(
post.record,
AppBskyFeedPost.isRecord,
)
) {
logger.metric('translate', {
sourceLanguages: post.record.langs ?? [],
targetLanguage: langPrefs.primaryLanguage,
textLength: post.record.text.length,
})

if (translatedText) {
setTranslatedText(null)
return false
}

const run = async () => {
if (!supportsTranslatorAPI) {
translate(post.record.text || '', langPrefs.primaryLanguage)

if (
bsky.dangerousIsType<AppBskyFeedPost.Record>(
post.record,
AppBskyFeedPost.isRecord,
)
) {
logger.metric('translate', {
sourceLanguages: post.record.langs ?? [],
targetLanguage: langPrefs.primaryLanguage,
textLength: post.record.text.length,
})
}

return false
}

try {
const translator = await self.Translator.create({
sourceLanguage: getPostLanguage(post),
targetLanguage: langPrefs.primaryLanguage,
})

let postText = ''
if (
hasProp(post.record, 'text') &&
typeof post.record.text === 'string'
) {
postText = post.record.text
}
const translations = []
const postParagraphs = postText.split(/\n/)
for (const postParagraph of postParagraphs) {
translations.push(await translator.translate(postParagraph))
}
setTranslatedText(translations.join('\n'))
} catch (err) {
console.error(err)
}
}

run()
return false
},
[translate, langPrefs, post],
[
translatedText,
setTranslatedText,
supportsTranslatorAPI,
translate,
post,
langPrefs.primaryLanguage,
],
)

return (
Expand Down Expand Up @@ -593,7 +649,7 @@ function ExpandedPostDetails({
label={_(msg`Translate`)}
style={[a.text_sm]}
onPress={onTranslatePress}>
<Trans>Translate</Trans>
<Trans>{translatedText ? 'Show original' : 'Translate'}</Trans>
</InlineLinkText>
</>
)}
Expand Down