Refine UX for on-device translation for posts (#9987)
Co-authored-by: Samuel Newman <mozzius@protonmail.com> Co-authored-by: Eric Bailey <git@esb.lol>
This commit is contained in:
+1
-1
@@ -85,7 +85,7 @@
|
|||||||
"@braintree/sanitize-url": "^6.0.2",
|
"@braintree/sanitize-url": "^6.0.2",
|
||||||
"@bsky.app/alf": "^0.1.7",
|
"@bsky.app/alf": "^0.1.7",
|
||||||
"@bsky.app/expo-image-crop-tool": "^0.5.0",
|
"@bsky.app/expo-image-crop-tool": "^0.5.0",
|
||||||
"@bsky.app/expo-translate-text": "^0.2.4",
|
"@bsky.app/expo-translate-text": "^0.2.7",
|
||||||
"@bsky.app/react-native-mmkv": "2.12.5",
|
"@bsky.app/react-native-mmkv": "2.12.5",
|
||||||
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
|
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
|
||||||
"@emoji-mart/data": "^1.2.1",
|
"@emoji-mart/data": "^1.2.1",
|
||||||
|
|||||||
+6
-3
@@ -19,6 +19,7 @@ import {Provider as HideBottomBarBorderProvider} from '#/lib/hooks/useHideBottom
|
|||||||
import {QueryProvider} from '#/lib/react-query'
|
import {QueryProvider} from '#/lib/react-query'
|
||||||
import {s} from '#/lib/styles'
|
import {s} from '#/lib/styles'
|
||||||
import {ThemeProvider} from '#/lib/ThemeContext'
|
import {ThemeProvider} from '#/lib/ThemeContext'
|
||||||
|
import {Provider as TranslateOnDeviceProvider} from '#/lib/translation'
|
||||||
import I18nProvider from '#/locale/i18nProvider'
|
import I18nProvider from '#/locale/i18nProvider'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {Provider as A11yProvider} from '#/state/a11y'
|
import {Provider as A11yProvider} from '#/state/a11y'
|
||||||
@@ -179,9 +180,11 @@ function InnerApp() {
|
|||||||
style={s.h100pct}>
|
style={s.h100pct}>
|
||||||
<GlobalGestureEventsProvider>
|
<GlobalGestureEventsProvider>
|
||||||
<IntentDialogProvider>
|
<IntentDialogProvider>
|
||||||
<TestCtrls />
|
<TranslateOnDeviceProvider>
|
||||||
<Shell />
|
<TestCtrls />
|
||||||
<ToastOutlet />
|
<Shell />
|
||||||
|
<ToastOutlet />
|
||||||
|
</TranslateOnDeviceProvider>
|
||||||
</IntentDialogProvider>
|
</IntentDialogProvider>
|
||||||
</GlobalGestureEventsProvider>
|
</GlobalGestureEventsProvider>
|
||||||
</GestureHandlerRootView>
|
</GestureHandlerRootView>
|
||||||
|
|||||||
+5
-2
@@ -10,6 +10,7 @@ import * as Sentry from '@sentry/react-native'
|
|||||||
|
|
||||||
import {QueryProvider} from '#/lib/react-query'
|
import {QueryProvider} from '#/lib/react-query'
|
||||||
import {ThemeProvider} from '#/lib/ThemeContext'
|
import {ThemeProvider} from '#/lib/ThemeContext'
|
||||||
|
import {Provider as TranslateOnDeviceProvider} from '#/lib/translation'
|
||||||
import I18nProvider from '#/locale/i18nProvider'
|
import I18nProvider from '#/locale/i18nProvider'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {Provider as A11yProvider} from '#/state/a11y'
|
import {Provider as A11yProvider} from '#/state/a11y'
|
||||||
@@ -154,8 +155,10 @@ function InnerApp() {
|
|||||||
<EmailVerificationProvider>
|
<EmailVerificationProvider>
|
||||||
<HideBottomBarBorderProvider>
|
<HideBottomBarBorderProvider>
|
||||||
<IntentDialogProvider>
|
<IntentDialogProvider>
|
||||||
<Shell />
|
<TranslateOnDeviceProvider>
|
||||||
<ToastOutlet />
|
<Shell />
|
||||||
|
<ToastOutlet />
|
||||||
|
</TranslateOnDeviceProvider>
|
||||||
</IntentDialogProvider>
|
</IntentDialogProvider>
|
||||||
</HideBottomBarBorderProvider>
|
</HideBottomBarBorderProvider>
|
||||||
</EmailVerificationProvider>
|
</EmailVerificationProvider>
|
||||||
|
|||||||
@@ -1,113 +1,345 @@
|
|||||||
import {useMemo} from 'react'
|
import {useCallback, useMemo} from 'react'
|
||||||
import {Platform, View} from 'react-native'
|
import {Platform, View} from 'react-native'
|
||||||
import {msg} from '@lingui/core/macro'
|
import {type AppBskyFeedDefs} from '@atproto/api'
|
||||||
import {useLingui} from '@lingui/react'
|
import {Trans, useLingui} from '@lingui/react/macro'
|
||||||
import {Trans} from '@lingui/react/macro'
|
|
||||||
|
|
||||||
|
import {HITSLOP_30} from '#/lib/constants'
|
||||||
|
import {useGoogleTranslate} from '#/lib/hooks/useGoogleTranslate'
|
||||||
|
import {guessLanguage, useTranslate} from '#/lib/translation'
|
||||||
|
import {type TranslationFunction} from '#/lib/translation'
|
||||||
import {codeToLanguageName, languageName} from '#/locale/helpers'
|
import {codeToLanguageName, languageName} from '#/locale/helpers'
|
||||||
import {LANGUAGES} from '#/locale/languages'
|
import {LANGUAGES} from '#/locale/languages'
|
||||||
import {useLanguagePrefs} from '#/state/preferences'
|
import {useLanguagePrefs} from '#/state/preferences'
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a, native, useTheme, web} from '#/alf'
|
||||||
|
import {Button} from '#/components/Button'
|
||||||
|
import {ArrowRight_Stroke2_Corner0_Rounded as ArrowRightIcon} from '#/components/icons/Arrow'
|
||||||
|
import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
|
||||||
|
import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning'
|
||||||
|
import {createStaticClick, Link} from '#/components/Link'
|
||||||
import {Loader} from '#/components/Loader'
|
import {Loader} from '#/components/Loader'
|
||||||
import * as Select from '#/components/Select'
|
import * as Select from '#/components/Select'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
import {useAnalytics} from '#/analytics'
|
import {useAnalytics} from '#/analytics'
|
||||||
import {useTranslateOnDevice} from '#/translation'
|
import {IS_WEB} from '#/env'
|
||||||
|
|
||||||
export function TranslatedPost({
|
export function TranslatedPost({
|
||||||
|
hideTranslateLink = false,
|
||||||
|
post,
|
||||||
postText,
|
postText,
|
||||||
hideLoading = false,
|
|
||||||
}: {
|
}: {
|
||||||
|
hideTranslateLink?: boolean
|
||||||
|
post: AppBskyFeedDefs.PostView
|
||||||
postText: string
|
postText: string
|
||||||
hideLoading: boolean
|
|
||||||
}) {
|
}) {
|
||||||
const {translationState} = useTranslateOnDevice()
|
const langPrefs = useLanguagePrefs()
|
||||||
|
const {clearTranslation, translate, translationState} = useTranslate({
|
||||||
|
key: post.uri,
|
||||||
|
})
|
||||||
|
|
||||||
if (translationState.status === 'loading' && !hideLoading) {
|
const postLanguage = useMemo(() => guessLanguage(postText), [postText])
|
||||||
return <TranslationLoading />
|
const needsTranslation = postLanguage !== langPrefs.primaryLanguage
|
||||||
|
|
||||||
|
switch (translationState.status) {
|
||||||
|
case 'loading':
|
||||||
|
return <TranslationLoading />
|
||||||
|
case 'success':
|
||||||
|
return (
|
||||||
|
<TranslationResult
|
||||||
|
clearTranslation={clearTranslation}
|
||||||
|
translate={translate}
|
||||||
|
postText={postText}
|
||||||
|
sourceLanguage={
|
||||||
|
translationState.sourceLanguage ?? postLanguage ?? null // Fallback primarily for iOS
|
||||||
|
}
|
||||||
|
translatedText={translationState.translatedText}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
case 'error':
|
||||||
|
return (
|
||||||
|
<TranslationError
|
||||||
|
clearTranslation={clearTranslation}
|
||||||
|
message={translationState.message}
|
||||||
|
postText={postText}
|
||||||
|
primaryLanguage={langPrefs.primaryLanguage}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
!hideTranslateLink &&
|
||||||
|
needsTranslation && (
|
||||||
|
<TranslationLink
|
||||||
|
postText={postText}
|
||||||
|
primaryLanguage={langPrefs.primaryLanguage}
|
||||||
|
sourceLanguage={postLanguage}
|
||||||
|
translate={translate}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (translationState.status === 'success') {
|
|
||||||
return (
|
|
||||||
<TranslationResult
|
|
||||||
postText={postText}
|
|
||||||
sourceLanguage={translationState.sourceLanguage}
|
|
||||||
translatedText={translationState.translatedText}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function TranslationLoading() {
|
function TranslationLoading() {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[a.flex_row, a.align_center, a.gap_sm, a.py_xs]}>
|
<View style={[a.gap_md, a.pt_md, a.align_start]}>
|
||||||
<Loader size="sm" />
|
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
|
||||||
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
|
<Loader size="xs" />
|
||||||
<Trans>Translating…</Trans>
|
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
|
||||||
</Text>
|
<Trans>Translating…</Trans>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TranslationLink({
|
||||||
|
postText,
|
||||||
|
primaryLanguage,
|
||||||
|
sourceLanguage,
|
||||||
|
translate,
|
||||||
|
}: {
|
||||||
|
postText: string
|
||||||
|
primaryLanguage: string
|
||||||
|
sourceLanguage: string | null
|
||||||
|
translate: TranslationFunction
|
||||||
|
}) {
|
||||||
|
const t = useTheme()
|
||||||
|
const {t: l} = useLingui()
|
||||||
|
const ax = useAnalytics()
|
||||||
|
|
||||||
|
const handleTranslate = useCallback(() => {
|
||||||
|
void translate({
|
||||||
|
text: postText,
|
||||||
|
targetLangCode: primaryLanguage,
|
||||||
|
})
|
||||||
|
|
||||||
|
ax.metric('translate', {
|
||||||
|
sourceLanguages: sourceLanguage ? [sourceLanguage] : [],
|
||||||
|
targetLanguage: primaryLanguage,
|
||||||
|
textLength: postText.length,
|
||||||
|
})
|
||||||
|
}, [ax, postText, primaryLanguage, translate, sourceLanguage])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.gap_md,
|
||||||
|
a.pt_md,
|
||||||
|
a.align_start,
|
||||||
|
a.flex_row,
|
||||||
|
a.align_center,
|
||||||
|
a.gap_xs,
|
||||||
|
]}>
|
||||||
|
<Link
|
||||||
|
role={IS_WEB ? 'link' : 'button'}
|
||||||
|
{...createStaticClick(() => {
|
||||||
|
handleTranslate()
|
||||||
|
})}
|
||||||
|
label={l`Translate`}
|
||||||
|
hoverStyle={[
|
||||||
|
native({opacity: 0.5}),
|
||||||
|
web([a.underline, {textDecorationColor: t.palette.primary_500}]),
|
||||||
|
]}
|
||||||
|
hitSlop={HITSLOP_30}>
|
||||||
|
<Text style={[a.text_sm, {color: t.palette.primary_500}]}>
|
||||||
|
<Trans>Translate</Trans>
|
||||||
|
</Text>
|
||||||
|
</Link>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TranslationError({
|
||||||
|
clearTranslation,
|
||||||
|
message,
|
||||||
|
postText,
|
||||||
|
primaryLanguage,
|
||||||
|
}: {
|
||||||
|
clearTranslation: () => void
|
||||||
|
message: string
|
||||||
|
postText: string
|
||||||
|
primaryLanguage: string
|
||||||
|
}) {
|
||||||
|
const t = useTheme()
|
||||||
|
const {t: l} = useLingui()
|
||||||
|
const translate = useGoogleTranslate()
|
||||||
|
|
||||||
|
const handleFallback = () => {
|
||||||
|
void translate(postText, primaryLanguage)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.px_lg,
|
||||||
|
a.pt_sm,
|
||||||
|
a.pb_md,
|
||||||
|
a.mt_sm,
|
||||||
|
a.border,
|
||||||
|
a.rounded_lg,
|
||||||
|
t.atoms.border_contrast_high,
|
||||||
|
]}>
|
||||||
|
<View style={[a.flex_row, a.align_center, a.justify_between]}>
|
||||||
|
<View style={[a.flex_row, a.align_center, a.mb_sm, a.gap_xs]}>
|
||||||
|
<WarningIcon size="sm" fill={t.atoms.text_contrast_medium.color} />
|
||||||
|
<Text style={[a.text_xs, a.font_medium, t.atoms.text_contrast_high]}>
|
||||||
|
{message}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={[a.flex_row, a.align_center, a.mb_xs]}>
|
||||||
|
<Button
|
||||||
|
label={l`Hide translation`}
|
||||||
|
hitSlop={HITSLOP_30}
|
||||||
|
hoverStyle={{opacity: 0.5}}
|
||||||
|
onPress={clearTranslation}>
|
||||||
|
<XIcon size="sm" fill={t.atoms.text_contrast_medium.color} />
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View style={[a.flex_row, a.align_center]}>
|
||||||
|
<Link
|
||||||
|
{...createStaticClick(() => {
|
||||||
|
handleFallback()
|
||||||
|
})}
|
||||||
|
label={l`Try Google Translate`}
|
||||||
|
hoverStyle={[
|
||||||
|
native({opacity: 0.5}),
|
||||||
|
web([a.underline, {textDecorationColor: t.palette.primary_500}]),
|
||||||
|
]}
|
||||||
|
hitSlop={HITSLOP_30}>
|
||||||
|
<Text
|
||||||
|
style={[a.text_xs, a.font_medium, {color: t.palette.primary_500}]}>
|
||||||
|
<Trans>Try Google Translate</Trans>
|
||||||
|
</Text>
|
||||||
|
</Link>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function TranslationResult({
|
function TranslationResult({
|
||||||
|
clearTranslation,
|
||||||
|
translate,
|
||||||
postText,
|
postText,
|
||||||
sourceLanguage,
|
sourceLanguage,
|
||||||
translatedText,
|
translatedText,
|
||||||
}: {
|
}: {
|
||||||
|
clearTranslation: () => void
|
||||||
|
translate: TranslationFunction
|
||||||
postText: string
|
postText: string
|
||||||
sourceLanguage: string | null
|
sourceLanguage: string | null
|
||||||
translatedText: string
|
translatedText: string
|
||||||
}) {
|
}) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {i18n} = useLingui()
|
const langPrefs = useLanguagePrefs()
|
||||||
|
const {i18n, t: l} = useLingui()
|
||||||
|
|
||||||
const langName = sourceLanguage
|
const langName = sourceLanguage
|
||||||
? codeToLanguageName(sourceLanguage, i18n.locale)
|
? codeToLanguageName(sourceLanguage, i18n.locale)
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[a.py_xs, a.gap_xs, a.mt_sm]}>
|
<View>
|
||||||
<Text style={[a.text_xs, t.atoms.text_contrast_medium]}>
|
<View
|
||||||
{langName ? (
|
style={[
|
||||||
<Trans>Translated from {langName}</Trans>
|
a.px_lg,
|
||||||
) : (
|
a.pt_sm,
|
||||||
<Trans>Translated</Trans>
|
a.pb_md,
|
||||||
)}
|
a.mt_sm,
|
||||||
{sourceLanguage != null && (
|
a.border,
|
||||||
<>
|
a.rounded_lg,
|
||||||
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
|
t.atoms.border_contrast_high,
|
||||||
{' '}
|
]}>
|
||||||
·
|
<View style={[a.flex_row, a.align_center, a.mb_xs]}>
|
||||||
</Text>{' '}
|
{langName ? (
|
||||||
<TranslationLanguageSelect
|
<View style={[a.flex_row, a.align_center]}>
|
||||||
sourceLanguage={sourceLanguage}
|
<Text
|
||||||
postText={postText}
|
style={[
|
||||||
/>
|
a.text_xs,
|
||||||
</>
|
a.font_medium,
|
||||||
)}
|
t.atoms.text_contrast_medium,
|
||||||
</Text>
|
]}>
|
||||||
<Text emoji selectable style={[a.text_md, a.leading_snug]}>
|
{langName}{' '}
|
||||||
{translatedText}
|
</Text>
|
||||||
</Text>
|
<View style={[a.mt_2xs]}>
|
||||||
|
<ArrowRightIcon
|
||||||
|
size="xs"
|
||||||
|
fill={t.atoms.text_contrast_medium.color}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.text_xs,
|
||||||
|
a.font_medium,
|
||||||
|
t.atoms.text_contrast_medium,
|
||||||
|
]}>
|
||||||
|
{' '}
|
||||||
|
{codeToLanguageName(
|
||||||
|
langPrefs.primaryLanguage,
|
||||||
|
langPrefs.appLanguage,
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.text_xs,
|
||||||
|
a.font_medium,
|
||||||
|
t.atoms.text_contrast_medium,
|
||||||
|
a.mb_xs,
|
||||||
|
]}>
|
||||||
|
<Trans>Translated</Trans>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{sourceLanguage != null && (
|
||||||
|
<>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.text_xs,
|
||||||
|
a.font_medium,
|
||||||
|
t.atoms.text_contrast_medium,
|
||||||
|
]}>
|
||||||
|
{' '}
|
||||||
|
·{' '}
|
||||||
|
</Text>
|
||||||
|
<TranslationLanguageSelect
|
||||||
|
sourceLanguage={sourceLanguage}
|
||||||
|
translate={translate}
|
||||||
|
postText={postText}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<Text emoji selectable style={[a.text_md, a.leading_snug]}>
|
||||||
|
{translatedText}
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
label={l`Hide translation`}
|
||||||
|
hitSlop={HITSLOP_30}
|
||||||
|
hoverStyle={native({opacity: 0.5})}
|
||||||
|
style={[a.absolute, a.z_10, {top: 12, right: 14}]}
|
||||||
|
onPress={clearTranslation}>
|
||||||
|
<XIcon size="sm" fill={t.atoms.text_contrast_medium.color} />
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function TranslationLanguageSelect({
|
function TranslationLanguageSelect({
|
||||||
|
translate,
|
||||||
postText,
|
postText,
|
||||||
sourceLanguage,
|
sourceLanguage,
|
||||||
}: {
|
}: {
|
||||||
|
translate: TranslationFunction
|
||||||
postText: string
|
postText: string
|
||||||
sourceLanguage: string
|
sourceLanguage: string
|
||||||
}) {
|
}) {
|
||||||
|
const t = useTheme()
|
||||||
const ax = useAnalytics()
|
const ax = useAnalytics()
|
||||||
const {_} = useLingui()
|
const {t: l} = useLingui()
|
||||||
const langPrefs = useLanguagePrefs()
|
const langPrefs = useLanguagePrefs()
|
||||||
const {translate} = useTranslateOnDevice()
|
|
||||||
|
|
||||||
const items = useMemo(
|
const items = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -116,18 +348,21 @@ function TranslationLanguageSelect({
|
|||||||
!langPrefs.primaryLanguage.startsWith(lang.code2) && // Don't show the current language as it would be redundant
|
!langPrefs.primaryLanguage.startsWith(lang.code2) && // Don't show the current language as it would be redundant
|
||||||
index === self.findIndex(t => t.code2 === lang.code2), // Remove dupes (which will happen due to multiple code3 values mapping to the same code2)
|
index === self.findIndex(t => t.code2 === lang.code2), // Remove dupes (which will happen due to multiple code3 values mapping to the same code2)
|
||||||
)
|
)
|
||||||
.sort(
|
.sort((a, b) => {
|
||||||
(a, b) =>
|
// Prioritize sourceLanguage at the top
|
||||||
languageName(a, langPrefs.appLanguage).localeCompare(
|
if (a.code2 === sourceLanguage) return -1
|
||||||
languageName(b, langPrefs.appLanguage),
|
if (b.code2 === sourceLanguage) return 1
|
||||||
langPrefs.appLanguage,
|
// Localized sort
|
||||||
), // Localized sort
|
return languageName(a, langPrefs.appLanguage).localeCompare(
|
||||||
)
|
languageName(b, langPrefs.appLanguage),
|
||||||
|
langPrefs.appLanguage,
|
||||||
|
)
|
||||||
|
})
|
||||||
.map(l => ({
|
.map(l => ({
|
||||||
label: languageName(l, langPrefs.appLanguage), // The viewer may not be familiar with the source language, so localize the name
|
label: languageName(l, langPrefs.appLanguage), // The viewer may not be familiar with the source language, so localize the name
|
||||||
value: l.code2,
|
value: l.code2,
|
||||||
})),
|
})),
|
||||||
[langPrefs],
|
[langPrefs, sourceLanguage],
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleChangeTranslationLanguage = (sourceLangCode: string) => {
|
const handleChangeTranslationLanguage = (sourceLangCode: string) => {
|
||||||
@@ -136,24 +371,35 @@ function TranslationLanguageSelect({
|
|||||||
sourceLanguage: sourceLangCode,
|
sourceLanguage: sourceLangCode,
|
||||||
targetLanguage: langPrefs.primaryLanguage,
|
targetLanguage: langPrefs.primaryLanguage,
|
||||||
})
|
})
|
||||||
void translate(postText, langPrefs.primaryLanguage, sourceLangCode)
|
void translate({
|
||||||
|
text: postText,
|
||||||
|
targetLangCode: langPrefs.primaryLanguage,
|
||||||
|
sourceLangCode,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Select.Root
|
<Select.Root
|
||||||
value={sourceLanguage}
|
value={sourceLanguage}
|
||||||
onValueChange={handleChangeTranslationLanguage}>
|
onValueChange={handleChangeTranslationLanguage}>
|
||||||
<Select.Trigger hitSlop={10} label={_(msg`Change source language`)}>
|
<Select.Trigger label={l`Change the source language`}>
|
||||||
{({props}) => {
|
{({props}) => {
|
||||||
return (
|
return (
|
||||||
<Text {...props} style={[a.text_xs]}>
|
<Button
|
||||||
<Trans>Change</Trans>
|
label={props.accessibilityLabel}
|
||||||
</Text>
|
{...props}
|
||||||
|
hitSlop={HITSLOP_30}
|
||||||
|
hoverStyle={native({opacity: 0.5})}>
|
||||||
|
<Text
|
||||||
|
style={[a.text_xs, a.font_medium, t.atoms.text_contrast_high]}>
|
||||||
|
<Trans>Change</Trans>
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
</Select.Trigger>
|
</Select.Trigger>
|
||||||
<Select.Content
|
<Select.Content
|
||||||
label={_(msg`Select the source language`)}
|
label={l`Select the source language`}
|
||||||
renderItem={({label, value}) => (
|
renderItem={({label, value}) => (
|
||||||
<Select.Item value={value} label={label}>
|
<Select.Item value={value} label={label}>
|
||||||
<Select.ItemIndicator />
|
<Select.ItemIndicator />
|
||||||
|
|||||||
@@ -13,13 +13,12 @@ import {
|
|||||||
AtUri,
|
AtUri,
|
||||||
type RichText as RichTextAPI,
|
type RichText as RichTextAPI,
|
||||||
} from '@atproto/api'
|
} from '@atproto/api'
|
||||||
import {msg, plural} from '@lingui/core/macro'
|
import {plural} from '@lingui/core/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react/macro'
|
||||||
import {useNavigation} from '@react-navigation/native'
|
import {useNavigation} from '@react-navigation/native'
|
||||||
|
|
||||||
import {DISCOVER_DEBUG_DIDS} from '#/lib/constants'
|
import {DISCOVER_DEBUG_DIDS} from '#/lib/constants'
|
||||||
import {useOpenLink} from '#/lib/hooks/useOpenLink'
|
import {useOpenLink} from '#/lib/hooks/useOpenLink'
|
||||||
import {useTranslate} from '#/lib/hooks/useTranslate'
|
|
||||||
import {getCurrentRoute} from '#/lib/routes/helpers'
|
import {getCurrentRoute} from '#/lib/routes/helpers'
|
||||||
import {makeProfileLink} from '#/lib/routes/links'
|
import {makeProfileLink} from '#/lib/routes/links'
|
||||||
import {
|
import {
|
||||||
@@ -28,6 +27,7 @@ import {
|
|||||||
} from '#/lib/routes/types'
|
} from '#/lib/routes/types'
|
||||||
import {richTextToString} from '#/lib/strings/rich-text-helpers'
|
import {richTextToString} from '#/lib/strings/rich-text-helpers'
|
||||||
import {toShareUrl} from '#/lib/strings/url-helpers'
|
import {toShareUrl} from '#/lib/strings/url-helpers'
|
||||||
|
import {useTranslate} from '#/lib/translation'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {type Shadow} from '#/state/cache/post-shadow'
|
import {type Shadow} from '#/state/cache/post-shadow'
|
||||||
import {useProfileShadow} from '#/state/cache/profile-shadow'
|
import {useProfileShadow} from '#/state/cache/profile-shadow'
|
||||||
@@ -106,6 +106,7 @@ let PostMenuItems = ({
|
|||||||
threadgateRecord,
|
threadgateRecord,
|
||||||
onShowLess,
|
onShowLess,
|
||||||
logContext,
|
logContext,
|
||||||
|
forceGoogleTranslate,
|
||||||
}: {
|
}: {
|
||||||
testID: string
|
testID: string
|
||||||
post: Shadow<AppBskyFeedDefs.PostView>
|
post: Shadow<AppBskyFeedDefs.PostView>
|
||||||
@@ -120,9 +121,10 @@ let PostMenuItems = ({
|
|||||||
threadgateRecord?: AppBskyFeedThreadgate.Record
|
threadgateRecord?: AppBskyFeedThreadgate.Record
|
||||||
onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void
|
onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void
|
||||||
logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
|
logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
|
||||||
|
forceGoogleTranslate: boolean
|
||||||
}): React.ReactNode => {
|
}): React.ReactNode => {
|
||||||
const {hasSession, currentAccount} = useSession()
|
const {hasSession, currentAccount} = useSession()
|
||||||
const {_} = useLingui()
|
const {t: l} = useLingui()
|
||||||
const ax = useAnalytics()
|
const ax = useAnalytics()
|
||||||
const langPrefs = useLanguagePrefs()
|
const langPrefs = useLanguagePrefs()
|
||||||
const {mutateAsync: deletePostMutate} = usePostDeleteMutation()
|
const {mutateAsync: deletePostMutate} = usePostDeleteMutation()
|
||||||
@@ -133,7 +135,10 @@ let PostMenuItems = ({
|
|||||||
const {hidePost} = useHiddenPostsApi()
|
const {hidePost} = useHiddenPostsApi()
|
||||||
const feedFeedback = useFeedFeedbackContext()
|
const feedFeedback = useFeedFeedbackContext()
|
||||||
const openLink = useOpenLink()
|
const openLink = useOpenLink()
|
||||||
const translate = useTranslate()
|
const {clearTranslation, translate, translationState} = useTranslate({
|
||||||
|
key: post.uri,
|
||||||
|
forceGoogleTranslate,
|
||||||
|
})
|
||||||
const navigation = useNavigation<NavigationProp>()
|
const navigation = useNavigation<NavigationProp>()
|
||||||
const {mutedWordsDialogControl} = useGlobalDialogsControlContext()
|
const {mutedWordsDialogControl} = useGlobalDialogsControlContext()
|
||||||
const blockPromptControl = useDialogControl()
|
const blockPromptControl = useDialogControl()
|
||||||
@@ -191,7 +196,7 @@ let PostMenuItems = ({
|
|||||||
const onDeletePost = () => {
|
const onDeletePost = () => {
|
||||||
deletePostMutate({uri: postUri}).then(
|
deletePostMutate({uri: postUri}).then(
|
||||||
() => {
|
() => {
|
||||||
Toast.show(_(msg({message: 'Post deleted', context: 'toast'})))
|
Toast.show(l({message: 'Post deleted', context: 'toast'}))
|
||||||
|
|
||||||
const route = getCurrentRoute(navigation.getState())
|
const route = getCurrentRoute(navigation.getState())
|
||||||
if (route.name === 'PostThread') {
|
if (route.name === 'PostThread') {
|
||||||
@@ -211,7 +216,7 @@ let PostMenuItems = ({
|
|||||||
},
|
},
|
||||||
e => {
|
e => {
|
||||||
logger.error('Failed to delete post', {message: e})
|
logger.error('Failed to delete post', {message: e})
|
||||||
Toast.show(_(msg`Failed to delete post, please try again`), 'xmark')
|
Toast.show(l`Failed to delete post, please try again`, 'xmark')
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -226,7 +231,7 @@ let PostMenuItems = ({
|
|||||||
logContext,
|
logContext,
|
||||||
feedDescriptor: feedFeedback.feedDescriptor,
|
feedDescriptor: feedFeedback.feedDescriptor,
|
||||||
})
|
})
|
||||||
Toast.show(_(msg`You will now receive notifications for this thread`))
|
Toast.show(l`You will now receive notifications for this thread`)
|
||||||
} else {
|
} else {
|
||||||
void muteThread()
|
void muteThread()
|
||||||
ax.metric('post:mute', {
|
ax.metric('post:mute', {
|
||||||
@@ -235,18 +240,13 @@ let PostMenuItems = ({
|
|||||||
logContext,
|
logContext,
|
||||||
feedDescriptor: feedFeedback.feedDescriptor,
|
feedDescriptor: feedFeedback.feedDescriptor,
|
||||||
})
|
})
|
||||||
Toast.show(
|
Toast.show(l`You will no longer receive notifications for this thread`)
|
||||||
_(msg`You will no longer receive notifications for this thread`),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const e = err as Error
|
const e = err as Error
|
||||||
if (e?.name !== 'AbortError') {
|
if (e?.name !== 'AbortError') {
|
||||||
logger.error('Failed to toggle thread mute', {message: e})
|
logger.error('Failed to toggle thread mute', {message: e})
|
||||||
Toast.show(
|
Toast.show(l`Failed to toggle thread mute, please try again`, 'xmark')
|
||||||
_(msg`Failed to toggle thread mute, please try again`),
|
|
||||||
'xmark',
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -255,11 +255,14 @@ let PostMenuItems = ({
|
|||||||
const str = richTextToString(richText, true)
|
const str = richTextToString(richText, true)
|
||||||
|
|
||||||
void Clipboard.setStringAsync(str)
|
void Clipboard.setStringAsync(str)
|
||||||
Toast.show(_(msg`Copied to clipboard`), 'clipboard-check')
|
Toast.show(l`Copied to clipboard`, 'clipboard-check')
|
||||||
}
|
}
|
||||||
|
|
||||||
const onPressTranslate = () => {
|
const onPressTranslate = () => {
|
||||||
void translate(record.text, langPrefs.primaryLanguage)
|
void translate({
|
||||||
|
text: record.text,
|
||||||
|
targetLangCode: langPrefs.primaryLanguage,
|
||||||
|
})
|
||||||
|
|
||||||
if (
|
if (
|
||||||
bsky.dangerousIsType<AppBskyFeedPost.Record>(
|
bsky.dangerousIsType<AppBskyFeedPost.Record>(
|
||||||
@@ -297,9 +300,7 @@ let PostMenuItems = ({
|
|||||||
logContext,
|
logContext,
|
||||||
feedDescriptor: feedFeedback.feedDescriptor,
|
feedDescriptor: feedFeedback.feedDescriptor,
|
||||||
})
|
})
|
||||||
Toast.show(
|
Toast.show(l({message: 'Feedback sent to feed operator', context: 'toast'}))
|
||||||
_(msg({message: 'Feedback sent to feed operator', context: 'toast'})),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const onPressShowLess = () => {
|
const onPressShowLess = () => {
|
||||||
@@ -322,7 +323,7 @@ let PostMenuItems = ({
|
|||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
Toast.show(
|
Toast.show(
|
||||||
_(msg({message: 'Feedback sent to feed operator', context: 'toast'})),
|
l({message: 'Feedback sent to feed operator', context: 'toast'}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -341,13 +342,13 @@ let PostMenuItems = ({
|
|||||||
})
|
})
|
||||||
Toast.show(
|
Toast.show(
|
||||||
isDetach
|
isDetach
|
||||||
? _(msg`Quote post was successfully detached`)
|
? l`Quote post was successfully detached`
|
||||||
: _(msg`Quote post was re-attached`),
|
: l`Quote post was re-attached`,
|
||||||
)
|
)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const e = err as Error
|
const e = err as Error
|
||||||
Toast.show(
|
Toast.show(
|
||||||
_(msg({message: 'Updating quote attachment failed', context: 'toast'})),
|
l({message: 'Updating quote attachment failed', context: 'toast'}),
|
||||||
)
|
)
|
||||||
logger.error(`Failed to ${action} quote`, {safeMessage: e.message})
|
logger.error(`Failed to ${action} quote`, {safeMessage: e.message})
|
||||||
}
|
}
|
||||||
@@ -379,31 +380,27 @@ let PostMenuItems = ({
|
|||||||
|
|
||||||
Toast.show(
|
Toast.show(
|
||||||
isHide
|
isHide
|
||||||
? _(msg`Reply was successfully hidden`)
|
? l`Reply was successfully hidden`
|
||||||
: _(msg({message: 'Reply visibility updated', context: 'toast'})),
|
: l({message: 'Reply visibility updated', context: 'toast'}),
|
||||||
)
|
)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const e = err as Error
|
const e = err as Error
|
||||||
if (e instanceof MaxHiddenRepliesError) {
|
if (e instanceof MaxHiddenRepliesError) {
|
||||||
Toast.show(
|
Toast.show(
|
||||||
_(
|
plural(MAX_HIDDEN_REPLIES, {
|
||||||
plural(MAX_HIDDEN_REPLIES, {
|
other: 'You can hide a maximum of # replies.',
|
||||||
other: 'You can hide a maximum of # replies.',
|
}),
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
} else if (e instanceof InvalidInteractionSettingsError) {
|
} else if (e instanceof InvalidInteractionSettingsError) {
|
||||||
Toast.show(
|
Toast.show(
|
||||||
_(msg({message: 'Invalid interaction settings.', context: 'toast'})),
|
l({message: 'Invalid interaction settings.', context: 'toast'}),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
Toast.show(
|
Toast.show(
|
||||||
_(
|
l({
|
||||||
msg({
|
message: 'Updating reply visibility failed',
|
||||||
message: 'Updating reply visibility failed',
|
context: 'toast',
|
||||||
context: 'toast',
|
}),
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
logger.error(`Failed to ${action} reply`, {safeMessage: e.message})
|
logger.error(`Failed to ${action} reply`, {safeMessage: e.message})
|
||||||
}
|
}
|
||||||
@@ -422,12 +419,12 @@ let PostMenuItems = ({
|
|||||||
const onBlockAuthor = async () => {
|
const onBlockAuthor = async () => {
|
||||||
try {
|
try {
|
||||||
await queueBlock()
|
await queueBlock()
|
||||||
Toast.show(_(msg({message: 'Account blocked', context: 'toast'})))
|
Toast.show(l({message: 'Account blocked', context: 'toast'}))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const e = err as Error
|
const e = err as Error
|
||||||
if (e?.name !== 'AbortError') {
|
if (e?.name !== 'AbortError') {
|
||||||
logger.error('Failed to block account', {message: e})
|
logger.error('Failed to block account', {message: e})
|
||||||
Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark')
|
Toast.show(l`There was an issue! ${e.toString()}`, 'xmark')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -436,23 +433,23 @@ let PostMenuItems = ({
|
|||||||
if (postAuthor.viewer?.muted) {
|
if (postAuthor.viewer?.muted) {
|
||||||
try {
|
try {
|
||||||
await queueUnmute()
|
await queueUnmute()
|
||||||
Toast.show(_(msg({message: 'Account unmuted', context: 'toast'})))
|
Toast.show(l({message: 'Account unmuted', context: 'toast'}))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const e = err as Error
|
const e = err as Error
|
||||||
if (e?.name !== 'AbortError') {
|
if (e?.name !== 'AbortError') {
|
||||||
logger.error('Failed to unmute account', {message: e})
|
logger.error('Failed to unmute account', {message: e})
|
||||||
Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark')
|
Toast.show(l`There was an issue! ${e.toString()}`, 'xmark')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
await queueMute()
|
await queueMute()
|
||||||
Toast.show(_(msg({message: 'Account muted', context: 'toast'})))
|
Toast.show(l({message: 'Account muted', context: 'toast'}))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const e = err as Error
|
const e = err as Error
|
||||||
if (e?.name !== 'AbortError') {
|
if (e?.name !== 'AbortError') {
|
||||||
logger.error('Failed to mute account', {message: e})
|
logger.error('Failed to mute account', {message: e})
|
||||||
Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark')
|
Toast.show(l`There was an issue! ${e.toString()}`, 'xmark')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -467,6 +464,8 @@ let PostMenuItems = ({
|
|||||||
|
|
||||||
const onSignIn = () => requireSignIn(() => {})
|
const onSignIn = () => requireSignIn(() => {})
|
||||||
|
|
||||||
|
const onPressHideTranslation = () => clearTranslation()
|
||||||
|
|
||||||
const isDiscoverDebugUser =
|
const isDiscoverDebugUser =
|
||||||
IS_INTERNAL ||
|
IS_INTERNAL ||
|
||||||
DISCOVER_DEBUG_DIDS[currentAccount?.did || ''] ||
|
DISCOVER_DEBUG_DIDS[currentAccount?.did || ''] ||
|
||||||
@@ -481,16 +480,12 @@ let PostMenuItems = ({
|
|||||||
<Menu.Item
|
<Menu.Item
|
||||||
testID="pinPostBtn"
|
testID="pinPostBtn"
|
||||||
label={
|
label={
|
||||||
isPinned
|
isPinned ? l`Unpin from profile` : l`Pin to your profile`
|
||||||
? _(msg`Unpin from profile`)
|
|
||||||
: _(msg`Pin to your profile`)
|
|
||||||
}
|
}
|
||||||
disabled={isPinPending}
|
disabled={isPinPending}
|
||||||
onPress={onPressPin}>
|
onPress={onPressPin}>
|
||||||
<Menu.ItemText>
|
<Menu.ItemText>
|
||||||
{isPinned
|
{isPinned ? l`Unpin from profile` : l`Pin to your profile`}
|
||||||
? _(msg`Unpin from profile`)
|
|
||||||
: _(msg`Pin to your profile`)}
|
|
||||||
</Menu.ItemText>
|
</Menu.ItemText>
|
||||||
<Menu.ItemIcon
|
<Menu.ItemIcon
|
||||||
icon={isPinPending ? Loader : PinIcon}
|
icon={isPinPending ? Loader : PinIcon}
|
||||||
@@ -505,28 +500,46 @@ let PostMenuItems = ({
|
|||||||
<Menu.Group>
|
<Menu.Group>
|
||||||
{!hideInPWI || hasSession ? (
|
{!hideInPWI || hasSession ? (
|
||||||
<>
|
<>
|
||||||
<Menu.Item
|
{translationState.status === 'loading' ? (
|
||||||
testID="postDropdownTranslateBtn"
|
<Menu.Item
|
||||||
label={_(msg`Translate`)}
|
testID="postDropdownTranslateBtn"
|
||||||
onPress={onPressTranslate}>
|
label={l`Translating…`}
|
||||||
<Menu.ItemText>{_(msg`Translate`)}</Menu.ItemText>
|
onPress={() => {}}>
|
||||||
<Menu.ItemIcon icon={Translate} position="right" />
|
<Menu.ItemText>{l`Translating…`}</Menu.ItemText>
|
||||||
</Menu.Item>
|
<Menu.ItemIcon icon={Translate} position="right" />
|
||||||
|
</Menu.Item>
|
||||||
|
) : translationState.status === 'success' ? (
|
||||||
|
<Menu.Item
|
||||||
|
testID="postDropdownTranslateBtn"
|
||||||
|
label={l`Hide translation`}
|
||||||
|
onPress={onPressHideTranslation}>
|
||||||
|
<Menu.ItemText>{l`Hide translation`}</Menu.ItemText>
|
||||||
|
<Menu.ItemIcon icon={Translate} position="right" />
|
||||||
|
</Menu.Item>
|
||||||
|
) : (
|
||||||
|
<Menu.Item
|
||||||
|
testID="postDropdownTranslateBtn"
|
||||||
|
label={l`Translate`}
|
||||||
|
onPress={onPressTranslate}>
|
||||||
|
<Menu.ItemText>{l`Translate`}</Menu.ItemText>
|
||||||
|
<Menu.ItemIcon icon={Translate} position="right" />
|
||||||
|
</Menu.Item>
|
||||||
|
)}
|
||||||
|
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
testID="postDropdownCopyTextBtn"
|
testID="postDropdownCopyTextBtn"
|
||||||
label={_(msg`Copy post text`)}
|
label={l`Copy post text`}
|
||||||
onPress={onCopyPostText}>
|
onPress={onCopyPostText}>
|
||||||
<Menu.ItemText>{_(msg`Copy post text`)}</Menu.ItemText>
|
<Menu.ItemText>{l`Copy post text`}</Menu.ItemText>
|
||||||
<Menu.ItemIcon icon={ClipboardIcon} position="right" />
|
<Menu.ItemIcon icon={ClipboardIcon} position="right" />
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
testID="postDropdownSignInBtn"
|
testID="postDropdownSignInBtn"
|
||||||
label={_(msg`Sign in to view post`)}
|
label={l`Sign in to view post`}
|
||||||
onPress={onSignIn}>
|
onPress={onSignIn}>
|
||||||
<Menu.ItemText>{_(msg`Sign in to view post`)}</Menu.ItemText>
|
<Menu.ItemText>{l`Sign in to view post`}</Menu.ItemText>
|
||||||
<Menu.ItemIcon icon={Eye} position="right" />
|
<Menu.ItemIcon icon={Eye} position="right" />
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
)}
|
)}
|
||||||
@@ -538,17 +551,17 @@ let PostMenuItems = ({
|
|||||||
<Menu.Group>
|
<Menu.Group>
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
testID="postDropdownShowMoreBtn"
|
testID="postDropdownShowMoreBtn"
|
||||||
label={_(msg`Show more like this`)}
|
label={l`Show more like this`}
|
||||||
onPress={onPressShowMore}>
|
onPress={onPressShowMore}>
|
||||||
<Menu.ItemText>{_(msg`Show more like this`)}</Menu.ItemText>
|
<Menu.ItemText>{l`Show more like this`}</Menu.ItemText>
|
||||||
<Menu.ItemIcon icon={EmojiSmile} position="right" />
|
<Menu.ItemIcon icon={EmojiSmile} position="right" />
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
testID="postDropdownShowLessBtn"
|
testID="postDropdownShowLessBtn"
|
||||||
label={_(msg`Show less like this`)}
|
label={l`Show less like this`}
|
||||||
onPress={onPressShowLess}>
|
onPress={onPressShowLess}>
|
||||||
<Menu.ItemText>{_(msg`Show less like this`)}</Menu.ItemText>
|
<Menu.ItemText>{l`Show less like this`}</Menu.ItemText>
|
||||||
<Menu.ItemIcon icon={EmojiSad} position="right" />
|
<Menu.ItemIcon icon={EmojiSad} position="right" />
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
</Menu.Group>
|
</Menu.Group>
|
||||||
@@ -560,9 +573,9 @@ let PostMenuItems = ({
|
|||||||
<Menu.Divider />
|
<Menu.Divider />
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
testID="postDropdownReportMisclassificationBtn"
|
testID="postDropdownReportMisclassificationBtn"
|
||||||
label={_(msg`Assign topic for algo`)}
|
label={l`Assign topic for algo`}
|
||||||
onPress={onReportMisclassification}>
|
onPress={onReportMisclassification}>
|
||||||
<Menu.ItemText>{_(msg`Assign topic for algo`)}</Menu.ItemText>
|
<Menu.ItemText>{l`Assign topic for algo`}</Menu.ItemText>
|
||||||
<Menu.ItemIcon icon={AtomIcon} position="right" />
|
<Menu.ItemIcon icon={AtomIcon} position="right" />
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
</>
|
</>
|
||||||
@@ -574,12 +587,10 @@ let PostMenuItems = ({
|
|||||||
<Menu.Group>
|
<Menu.Group>
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
testID="postDropdownMuteThreadBtn"
|
testID="postDropdownMuteThreadBtn"
|
||||||
label={
|
label={isThreadMuted ? l`Unmute thread` : l`Mute thread`}
|
||||||
isThreadMuted ? _(msg`Unmute thread`) : _(msg`Mute thread`)
|
|
||||||
}
|
|
||||||
onPress={onToggleThreadMute}>
|
onPress={onToggleThreadMute}>
|
||||||
<Menu.ItemText>
|
<Menu.ItemText>
|
||||||
{isThreadMuted ? _(msg`Unmute thread`) : _(msg`Mute thread`)}
|
{isThreadMuted ? l`Unmute thread` : l`Mute thread`}
|
||||||
</Menu.ItemText>
|
</Menu.ItemText>
|
||||||
<Menu.ItemIcon
|
<Menu.ItemIcon
|
||||||
icon={isThreadMuted ? Unmute : Mute}
|
icon={isThreadMuted ? Unmute : Mute}
|
||||||
@@ -589,9 +600,9 @@ let PostMenuItems = ({
|
|||||||
|
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
testID="postDropdownMuteWordsBtn"
|
testID="postDropdownMuteWordsBtn"
|
||||||
label={_(msg`Mute words & tags`)}
|
label={l`Mute words & tags`}
|
||||||
onPress={() => mutedWordsDialogControl.open()}>
|
onPress={() => mutedWordsDialogControl.open()}>
|
||||||
<Menu.ItemText>{_(msg`Mute words & tags`)}</Menu.ItemText>
|
<Menu.ItemText>{l`Mute words & tags`}</Menu.ItemText>
|
||||||
<Menu.ItemIcon icon={Filter} position="right" />
|
<Menu.ItemIcon icon={Filter} position="right" />
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
</Menu.Group>
|
</Menu.Group>
|
||||||
@@ -606,16 +617,10 @@ let PostMenuItems = ({
|
|||||||
{canHidePostForMe && (
|
{canHidePostForMe && (
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
testID="postDropdownHideBtn"
|
testID="postDropdownHideBtn"
|
||||||
label={
|
label={isReply ? l`Hide reply for me` : l`Hide post for me`}
|
||||||
isReply
|
|
||||||
? _(msg`Hide reply for me`)
|
|
||||||
: _(msg`Hide post for me`)
|
|
||||||
}
|
|
||||||
onPress={() => hidePromptControl.open()}>
|
onPress={() => hidePromptControl.open()}>
|
||||||
<Menu.ItemText>
|
<Menu.ItemText>
|
||||||
{isReply
|
{isReply ? l`Hide reply for me` : l`Hide post for me`}
|
||||||
? _(msg`Hide reply for me`)
|
|
||||||
: _(msg`Hide post for me`)}
|
|
||||||
</Menu.ItemText>
|
</Menu.ItemText>
|
||||||
<Menu.ItemIcon icon={EyeSlash} position="right" />
|
<Menu.ItemIcon icon={EyeSlash} position="right" />
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
@@ -625,8 +630,8 @@ let PostMenuItems = ({
|
|||||||
testID="postDropdownHideBtn"
|
testID="postDropdownHideBtn"
|
||||||
label={
|
label={
|
||||||
isReplyHiddenByThreadgate
|
isReplyHiddenByThreadgate
|
||||||
? _(msg`Show reply for everyone`)
|
? l`Show reply for everyone`
|
||||||
: _(msg`Hide reply for everyone`)
|
: l`Hide reply for everyone`
|
||||||
}
|
}
|
||||||
onPress={
|
onPress={
|
||||||
isReplyHiddenByThreadgate
|
isReplyHiddenByThreadgate
|
||||||
@@ -635,8 +640,8 @@ let PostMenuItems = ({
|
|||||||
}>
|
}>
|
||||||
<Menu.ItemText>
|
<Menu.ItemText>
|
||||||
{isReplyHiddenByThreadgate
|
{isReplyHiddenByThreadgate
|
||||||
? _(msg`Show reply for everyone`)
|
? l`Show reply for everyone`
|
||||||
: _(msg`Hide reply for everyone`)}
|
: l`Hide reply for everyone`}
|
||||||
</Menu.ItemText>
|
</Menu.ItemText>
|
||||||
<Menu.ItemIcon
|
<Menu.ItemIcon
|
||||||
icon={isReplyHiddenByThreadgate ? Eye : EyeSlash}
|
icon={isReplyHiddenByThreadgate ? Eye : EyeSlash}
|
||||||
@@ -651,8 +656,8 @@ let PostMenuItems = ({
|
|||||||
testID="postDropdownHideBtn"
|
testID="postDropdownHideBtn"
|
||||||
label={
|
label={
|
||||||
quoteEmbed.isDetached
|
quoteEmbed.isDetached
|
||||||
? _(msg`Re-attach quote`)
|
? l`Re-attach quote`
|
||||||
: _(msg`Detach quote`)
|
: l`Detach quote`
|
||||||
}
|
}
|
||||||
onPress={
|
onPress={
|
||||||
quoteEmbed.isDetached
|
quoteEmbed.isDetached
|
||||||
@@ -661,8 +666,8 @@ let PostMenuItems = ({
|
|||||||
}>
|
}>
|
||||||
<Menu.ItemText>
|
<Menu.ItemText>
|
||||||
{quoteEmbed.isDetached
|
{quoteEmbed.isDetached
|
||||||
? _(msg`Re-attach quote`)
|
? l`Re-attach quote`
|
||||||
: _(msg`Detach quote`)}
|
: l`Detach quote`}
|
||||||
</Menu.ItemText>
|
</Menu.ItemText>
|
||||||
<Menu.ItemIcon
|
<Menu.ItemIcon
|
||||||
icon={
|
icon={
|
||||||
@@ -690,14 +695,14 @@ let PostMenuItems = ({
|
|||||||
testID="postDropdownMuteBtn"
|
testID="postDropdownMuteBtn"
|
||||||
label={
|
label={
|
||||||
postAuthor.viewer?.muted
|
postAuthor.viewer?.muted
|
||||||
? _(msg`Unmute account`)
|
? l`Unmute account`
|
||||||
: _(msg`Mute account`)
|
: l`Mute account`
|
||||||
}
|
}
|
||||||
onPress={() => void onMuteAuthor()}>
|
onPress={() => void onMuteAuthor()}>
|
||||||
<Menu.ItemText>
|
<Menu.ItemText>
|
||||||
{postAuthor.viewer?.muted
|
{postAuthor.viewer?.muted
|
||||||
? _(msg`Unmute account`)
|
? l`Unmute account`
|
||||||
: _(msg`Mute account`)}
|
: l`Mute account`}
|
||||||
</Menu.ItemText>
|
</Menu.ItemText>
|
||||||
<Menu.ItemIcon
|
<Menu.ItemIcon
|
||||||
icon={postAuthor.viewer?.muted ? UnmuteIcon : MuteIcon}
|
icon={postAuthor.viewer?.muted ? UnmuteIcon : MuteIcon}
|
||||||
@@ -708,18 +713,18 @@ let PostMenuItems = ({
|
|||||||
{!postAuthor.viewer?.blocking && (
|
{!postAuthor.viewer?.blocking && (
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
testID="postDropdownBlockBtn"
|
testID="postDropdownBlockBtn"
|
||||||
label={_(msg`Block account`)}
|
label={l`Block account`}
|
||||||
onPress={() => blockPromptControl.open()}>
|
onPress={() => blockPromptControl.open()}>
|
||||||
<Menu.ItemText>{_(msg`Block account`)}</Menu.ItemText>
|
<Menu.ItemText>{l`Block account`}</Menu.ItemText>
|
||||||
<Menu.ItemIcon icon={PersonX} position="right" />
|
<Menu.ItemIcon icon={PersonX} position="right" />
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
testID="postDropdownReportBtn"
|
testID="postDropdownReportBtn"
|
||||||
label={_(msg`Report post`)}
|
label={l`Report post`}
|
||||||
onPress={() => reportDialogControl.open()}>
|
onPress={() => reportDialogControl.open()}>
|
||||||
<Menu.ItemText>{_(msg`Report post`)}</Menu.ItemText>
|
<Menu.ItemText>{l`Report post`}</Menu.ItemText>
|
||||||
<Menu.ItemIcon icon={Warning} position="right" />
|
<Menu.ItemIcon icon={Warning} position="right" />
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
</>
|
</>
|
||||||
@@ -729,7 +734,7 @@ let PostMenuItems = ({
|
|||||||
<>
|
<>
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
testID="postDropdownEditPostInteractions"
|
testID="postDropdownEditPostInteractions"
|
||||||
label={_(msg`Edit interaction settings`)}
|
label={l`Edit interaction settings`}
|
||||||
onPress={() => postInteractionSettingsDialogControl.open()}
|
onPress={() => postInteractionSettingsDialogControl.open()}
|
||||||
{...(isAuthor
|
{...(isAuthor
|
||||||
? Platform.select({
|
? Platform.select({
|
||||||
@@ -742,15 +747,15 @@ let PostMenuItems = ({
|
|||||||
})
|
})
|
||||||
: {})}>
|
: {})}>
|
||||||
<Menu.ItemText>
|
<Menu.ItemText>
|
||||||
{_(msg`Edit interaction settings`)}
|
{l`Edit interaction settings`}
|
||||||
</Menu.ItemText>
|
</Menu.ItemText>
|
||||||
<Menu.ItemIcon icon={Gear} position="right" />
|
<Menu.ItemIcon icon={Gear} position="right" />
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
testID="postDropdownDeleteBtn"
|
testID="postDropdownDeleteBtn"
|
||||||
label={_(msg`Delete post`)}
|
label={l`Delete post`}
|
||||||
onPress={() => deletePromptControl.open()}>
|
onPress={() => deletePromptControl.open()}>
|
||||||
<Menu.ItemText>{_(msg`Delete post`)}</Menu.ItemText>
|
<Menu.ItemText>{l`Delete post`}</Menu.ItemText>
|
||||||
<Menu.ItemIcon icon={Trash} position="right" />
|
<Menu.ItemIcon icon={Trash} position="right" />
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
</>
|
</>
|
||||||
@@ -759,28 +764,21 @@ let PostMenuItems = ({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Menu.Outer>
|
</Menu.Outer>
|
||||||
|
|
||||||
<Prompt.Basic
|
<Prompt.Basic
|
||||||
control={deletePromptControl}
|
control={deletePromptControl}
|
||||||
title={_(msg`Delete this post?`)}
|
title={l`Delete this post?`}
|
||||||
description={_(
|
description={l`If you remove this post, you won't be able to recover it.`}
|
||||||
msg`If you remove this post, you won't be able to recover it.`,
|
|
||||||
)}
|
|
||||||
onConfirm={onDeletePost}
|
onConfirm={onDeletePost}
|
||||||
confirmButtonCta={_(msg`Delete`)}
|
confirmButtonCta={l`Delete`}
|
||||||
confirmButtonColor="negative"
|
confirmButtonColor="negative"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Prompt.Basic
|
<Prompt.Basic
|
||||||
control={hidePromptControl}
|
control={hidePromptControl}
|
||||||
title={isReply ? _(msg`Hide this reply?`) : _(msg`Hide this post?`)}
|
title={isReply ? l`Hide this reply?` : l`Hide this post?`}
|
||||||
description={_(
|
description={l`This post will be hidden from feeds and threads. This cannot be undone.`}
|
||||||
msg`This post will be hidden from feeds and threads. This cannot be undone.`,
|
|
||||||
)}
|
|
||||||
onConfirm={onHidePost}
|
onConfirm={onHidePost}
|
||||||
confirmButtonCta={_(msg`Hide`)}
|
confirmButtonCta={l`Hide`}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ReportDialog
|
<ReportDialog
|
||||||
control={reportDialogControl}
|
control={reportDialogControl}
|
||||||
subject={{
|
subject={{
|
||||||
@@ -788,42 +786,32 @@ let PostMenuItems = ({
|
|||||||
$type: 'app.bsky.feed.defs#postView',
|
$type: 'app.bsky.feed.defs#postView',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<PostInteractionSettingsDialog
|
<PostInteractionSettingsDialog
|
||||||
control={postInteractionSettingsDialogControl}
|
control={postInteractionSettingsDialogControl}
|
||||||
postUri={post.uri}
|
postUri={post.uri}
|
||||||
rootPostUri={rootUri}
|
rootPostUri={rootUri}
|
||||||
initialThreadgateView={post.threadgate}
|
initialThreadgateView={post.threadgate}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Prompt.Basic
|
<Prompt.Basic
|
||||||
control={quotePostDetachConfirmControl}
|
control={quotePostDetachConfirmControl}
|
||||||
title={_(msg`Detach quote post?`)}
|
title={l`Detach quote post?`}
|
||||||
description={_(
|
description={l`This will remove your post from this quote post for all users, and replace it with a placeholder.`}
|
||||||
msg`This will remove your post from this quote post for all users, and replace it with a placeholder.`,
|
|
||||||
)}
|
|
||||||
onConfirm={() => void onToggleQuotePostAttachment()}
|
onConfirm={() => void onToggleQuotePostAttachment()}
|
||||||
confirmButtonCta={_(msg`Yes, detach`)}
|
confirmButtonCta={l`Yes, detach`}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Prompt.Basic
|
<Prompt.Basic
|
||||||
control={hideReplyConfirmControl}
|
control={hideReplyConfirmControl}
|
||||||
title={_(msg`Hide this reply?`)}
|
title={l`Hide this reply?`}
|
||||||
description={_(
|
description={l`This reply will be sorted into a hidden section at the bottom of your thread and will mute notifications for subsequent replies - both for yourself and others.`}
|
||||||
msg`This reply will be sorted into a hidden section at the bottom of your thread and will mute notifications for subsequent replies - both for yourself and others.`,
|
|
||||||
)}
|
|
||||||
onConfirm={() => void onToggleReplyVisibility()}
|
onConfirm={() => void onToggleReplyVisibility()}
|
||||||
confirmButtonCta={_(msg`Yes, hide`)}
|
confirmButtonCta={l`Yes, hide`}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Prompt.Basic
|
<Prompt.Basic
|
||||||
control={blockPromptControl}
|
control={blockPromptControl}
|
||||||
title={_(msg`Block Account?`)}
|
title={l`Block Account?`}
|
||||||
description={_(
|
description={l`Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`}
|
||||||
msg`Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`,
|
|
||||||
)}
|
|
||||||
onConfirm={() => void onBlockAuthor()}
|
onConfirm={() => void onBlockAuthor()}
|
||||||
confirmButtonCta={_(msg`Block`)}
|
confirmButtonCta={l`Block`}
|
||||||
confirmButtonColor="negative"
|
confirmButtonColor="negative"
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -6,8 +6,7 @@ import {
|
|||||||
type AppBskyFeedThreadgate,
|
type AppBskyFeedThreadgate,
|
||||||
type RichText as RichTextAPI,
|
type RichText as RichTextAPI,
|
||||||
} from '@atproto/api'
|
} from '@atproto/api'
|
||||||
import {msg} from '@lingui/core/macro'
|
import {useLingui} from '@lingui/react/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
|
||||||
|
|
||||||
import {type Shadow} from '#/state/cache/post-shadow'
|
import {type Shadow} from '#/state/cache/post-shadow'
|
||||||
import {EventStopper} from '#/view/com/util/EventStopper'
|
import {EventStopper} from '#/view/com/util/EventStopper'
|
||||||
@@ -30,6 +29,7 @@ let PostMenuButton = ({
|
|||||||
onShowLess,
|
onShowLess,
|
||||||
hitSlop,
|
hitSlop,
|
||||||
logContext,
|
logContext,
|
||||||
|
forceGoogleTranslate,
|
||||||
}: {
|
}: {
|
||||||
testID: string
|
testID: string
|
||||||
post: Shadow<AppBskyFeedDefs.PostView>
|
post: Shadow<AppBskyFeedDefs.PostView>
|
||||||
@@ -43,8 +43,9 @@ let PostMenuButton = ({
|
|||||||
onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void
|
onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void
|
||||||
hitSlop?: Insets
|
hitSlop?: Insets
|
||||||
logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
|
logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
|
||||||
|
forceGoogleTranslate: boolean
|
||||||
}): React.ReactNode => {
|
}): React.ReactNode => {
|
||||||
const {_} = useLingui()
|
const {t: l} = useLingui()
|
||||||
|
|
||||||
const menuControl = useMenuControl()
|
const menuControl = useMenuControl()
|
||||||
const [hasBeenOpen, setHasBeenOpen] = useState(false)
|
const [hasBeenOpen, setHasBeenOpen] = useState(false)
|
||||||
@@ -63,7 +64,7 @@ let PostMenuButton = ({
|
|||||||
return (
|
return (
|
||||||
<EventStopper onKeyDown={false}>
|
<EventStopper onKeyDown={false}>
|
||||||
<Menu.Root control={lazyMenuControl}>
|
<Menu.Root control={lazyMenuControl}>
|
||||||
<Menu.Trigger label={_(msg`Open post options menu`)}>
|
<Menu.Trigger label={l`Open post options menu`}>
|
||||||
{({props}) => {
|
{({props}) => {
|
||||||
return (
|
return (
|
||||||
<PostControlButton
|
<PostControlButton
|
||||||
@@ -90,6 +91,7 @@ let PostMenuButton = ({
|
|||||||
threadgateRecord={threadgateRecord}
|
threadgateRecord={threadgateRecord}
|
||||||
onShowLess={onShowLess}
|
onShowLess={onShowLess}
|
||||||
logContext={logContext}
|
logContext={logContext}
|
||||||
|
forceGoogleTranslate={forceGoogleTranslate}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Menu.Root>
|
</Menu.Root>
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import {
|
|||||||
type AppBskyFeedThreadgate,
|
type AppBskyFeedThreadgate,
|
||||||
type RichText as RichTextAPI,
|
type RichText as RichTextAPI,
|
||||||
} from '@atproto/api'
|
} from '@atproto/api'
|
||||||
import {msg, plural} from '@lingui/core/macro'
|
import {plural} from '@lingui/core/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react/macro'
|
||||||
|
|
||||||
import {CountWheel} from '#/lib/custom-animations/CountWheel'
|
import {CountWheel} from '#/lib/custom-animations/CountWheel'
|
||||||
import {AnimatedLikeIcon} from '#/lib/custom-animations/LikeIcon'
|
import {AnimatedLikeIcon} from '#/lib/custom-animations/LikeIcon'
|
||||||
@@ -55,6 +55,7 @@ let PostControls = ({
|
|||||||
onShowLess,
|
onShowLess,
|
||||||
viaRepost,
|
viaRepost,
|
||||||
variant,
|
variant,
|
||||||
|
forceGoogleTranslate = false,
|
||||||
}: {
|
}: {
|
||||||
big?: boolean
|
big?: boolean
|
||||||
post: Shadow<AppBskyFeedDefs.PostView>
|
post: Shadow<AppBskyFeedDefs.PostView>
|
||||||
@@ -70,9 +71,10 @@ let PostControls = ({
|
|||||||
onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void
|
onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void
|
||||||
viaRepost?: {uri: string; cid: string}
|
viaRepost?: {uri: string; cid: string}
|
||||||
variant?: 'compact' | 'normal' | 'large'
|
variant?: 'compact' | 'normal' | 'large'
|
||||||
|
forceGoogleTranslate?: boolean
|
||||||
}): React.ReactNode => {
|
}): React.ReactNode => {
|
||||||
const ax = useAnalytics()
|
const ax = useAnalytics()
|
||||||
const {_} = useLingui()
|
const {t: l} = useLingui()
|
||||||
const {openComposer} = useOpenComposer()
|
const {openComposer} = useOpenComposer()
|
||||||
const {feedDescriptor} = useFeedFeedbackContext()
|
const {feedDescriptor} = useFeedFeedbackContext()
|
||||||
const [queueLike, queueUnlike] = usePostLikeMutationQueue(
|
const [queueLike, queueUnlike] = usePostLikeMutationQueue(
|
||||||
@@ -104,10 +106,7 @@ let PostControls = ({
|
|||||||
|
|
||||||
const onPressToggleLike = async () => {
|
const onPressToggleLike = async () => {
|
||||||
if (isBlocked) {
|
if (isBlocked) {
|
||||||
Toast.show(
|
Toast.show(l`Cannot interact with a blocked user`, 'exclamation-circle')
|
||||||
_(msg`Cannot interact with a blocked user`),
|
|
||||||
'exclamation-circle',
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,7 +125,8 @@ let PostControls = ({
|
|||||||
} else {
|
} else {
|
||||||
await queueUnlike()
|
await queueUnlike()
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (err) {
|
||||||
|
const e = err as Error
|
||||||
if (e?.name !== 'AbortError') {
|
if (e?.name !== 'AbortError') {
|
||||||
throw e
|
throw e
|
||||||
}
|
}
|
||||||
@@ -135,10 +135,7 @@ let PostControls = ({
|
|||||||
|
|
||||||
const onRepost = async () => {
|
const onRepost = async () => {
|
||||||
if (isBlocked) {
|
if (isBlocked) {
|
||||||
Toast.show(
|
Toast.show(l`Cannot interact with a blocked user`, 'exclamation-circle')
|
||||||
_(msg`Cannot interact with a blocked user`),
|
|
||||||
'exclamation-circle',
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,7 +151,8 @@ let PostControls = ({
|
|||||||
} else {
|
} else {
|
||||||
await queueUnrepost()
|
await queueUnrepost()
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (err) {
|
||||||
|
const e = err as Error
|
||||||
if (e?.name !== 'AbortError') {
|
if (e?.name !== 'AbortError') {
|
||||||
throw e
|
throw e
|
||||||
}
|
}
|
||||||
@@ -163,10 +161,7 @@ let PostControls = ({
|
|||||||
|
|
||||||
const onQuote = () => {
|
const onQuote = () => {
|
||||||
if (isBlocked) {
|
if (isBlocked) {
|
||||||
Toast.show(
|
Toast.show(l`Cannot interact with a blocked user`, 'exclamation-circle')
|
||||||
_(msg`Cannot interact with a blocked user`),
|
|
||||||
'exclamation-circle',
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,16 +233,14 @@ let PostControls = ({
|
|||||||
})
|
})
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
label={_(
|
label={l({
|
||||||
msg({
|
message: `Reply (${plural(post.replyCount || 0, {
|
||||||
message: `Reply (${plural(post.replyCount || 0, {
|
one: '# reply',
|
||||||
one: '# reply',
|
other: '# replies',
|
||||||
other: '# replies',
|
})})`,
|
||||||
})})`,
|
comment:
|
||||||
comment:
|
'Accessibility label for the reply button, verb form followed by number of replies and noun form',
|
||||||
'Accessibility label for the reply button, verb form followed by number of replies and noun form',
|
})}
|
||||||
}),
|
|
||||||
)}
|
|
||||||
big={big}>
|
big={big}>
|
||||||
<PostControlButtonIcon icon={Bubble} />
|
<PostControlButtonIcon icon={Bubble} />
|
||||||
{typeof post.replyCount !== 'undefined' && post.replyCount > 0 && (
|
{typeof post.replyCount !== 'undefined' && post.replyCount > 0 && (
|
||||||
@@ -261,7 +254,7 @@ let PostControls = ({
|
|||||||
<RepostButton
|
<RepostButton
|
||||||
isReposted={!!post.viewer?.repost}
|
isReposted={!!post.viewer?.repost}
|
||||||
repostCount={(post.repostCount ?? 0) + (post.quoteCount ?? 0)}
|
repostCount={(post.repostCount ?? 0) + (post.quoteCount ?? 0)}
|
||||||
onRepost={onRepost}
|
onRepost={() => void onRepost()}
|
||||||
onQuote={onQuote}
|
onQuote={onQuote}
|
||||||
big={big}
|
big={big}
|
||||||
embeddingDisabled={Boolean(post.viewer?.embeddingDisabled)}
|
embeddingDisabled={Boolean(post.viewer?.embeddingDisabled)}
|
||||||
@@ -274,26 +267,22 @@ let PostControls = ({
|
|||||||
onPress={() => requireAuth(() => onPressToggleLike())}
|
onPress={() => requireAuth(() => onPressToggleLike())}
|
||||||
label={
|
label={
|
||||||
post.viewer?.like
|
post.viewer?.like
|
||||||
? _(
|
? l({
|
||||||
msg({
|
message: `Unlike (${plural(post.likeCount || 0, {
|
||||||
message: `Unlike (${plural(post.likeCount || 0, {
|
one: '# like',
|
||||||
one: '# like',
|
other: '# likes',
|
||||||
other: '# likes',
|
})})`,
|
||||||
})})`,
|
comment:
|
||||||
comment:
|
'Accessibility label for the like button when the post has been liked, verb followed by number of likes and noun',
|
||||||
'Accessibility label for the like button when the post has been liked, verb followed by number of likes and noun',
|
})
|
||||||
}),
|
: l({
|
||||||
)
|
message: `Like (${plural(post.likeCount || 0, {
|
||||||
: _(
|
one: '# like',
|
||||||
msg({
|
other: '# likes',
|
||||||
message: `Like (${plural(post.likeCount || 0, {
|
})})`,
|
||||||
one: '# like',
|
comment:
|
||||||
other: '# likes',
|
'Accessibility label for the like button when the post has not been liked, verb form followed by number of likes and noun form',
|
||||||
})})`,
|
})
|
||||||
comment:
|
|
||||||
'Accessibility label for the like button when the post has not been liked, verb form followed by number of likes and noun form',
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}>
|
}>
|
||||||
<AnimatedLikeIcon
|
<AnimatedLikeIcon
|
||||||
isLiked={Boolean(post.viewer?.like)}
|
isLiked={Boolean(post.viewer?.like)}
|
||||||
@@ -350,6 +339,7 @@ let PostControls = ({
|
|||||||
left: secondaryControlSpacingStyles.gap / 2,
|
left: secondaryControlSpacingStyles.gap / 2,
|
||||||
}}
|
}}
|
||||||
logContext={logContext}
|
logContext={logContext}
|
||||||
|
forceGoogleTranslate={forceGoogleTranslate}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {msg} from '@lingui/core/macro'
|
|||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
import {useQueryClient} from '@tanstack/react-query'
|
import {useQueryClient} from '@tanstack/react-query'
|
||||||
|
|
||||||
import {useTranslate} from '#/lib/hooks/useTranslate'
|
import {useGoogleTranslate} from '#/lib/hooks/useGoogleTranslate'
|
||||||
import {richTextToString} from '#/lib/strings/rich-text-helpers'
|
import {richTextToString} from '#/lib/strings/rich-text-helpers'
|
||||||
import {useConvoActive} from '#/state/messages/convo'
|
import {useConvoActive} from '#/state/messages/convo'
|
||||||
import {useLanguagePrefs} from '#/state/preferences'
|
import {useLanguagePrefs} from '#/state/preferences'
|
||||||
@@ -44,7 +44,7 @@ export let MessageContextMenu = ({
|
|||||||
const reportControl = usePromptControl()
|
const reportControl = usePromptControl()
|
||||||
const blockOrDeleteControl = usePromptControl()
|
const blockOrDeleteControl = usePromptControl()
|
||||||
const langPrefs = useLanguagePrefs()
|
const langPrefs = useLanguagePrefs()
|
||||||
const translate = useTranslate()
|
const translate = useGoogleTranslate()
|
||||||
|
|
||||||
const isFromSelf = message.sender?.did === currentAccount?.did
|
const isFromSelf = message.sender?.did === currentAccount?.did
|
||||||
|
|
||||||
@@ -57,12 +57,12 @@ export let MessageContextMenu = ({
|
|||||||
true,
|
true,
|
||||||
)
|
)
|
||||||
|
|
||||||
Clipboard.setStringAsync(str)
|
void Clipboard.setStringAsync(str)
|
||||||
Toast.show(_(msg`Copied to clipboard`), 'clipboard-check')
|
Toast.show(_(msg`Copied to clipboard`), 'clipboard-check')
|
||||||
}, [_, message.text, message.facets])
|
}, [_, message.text, message.facets])
|
||||||
|
|
||||||
const onPressTranslateMessage = useCallback(() => {
|
const onPressTranslateMessage = useCallback(() => {
|
||||||
translate(message.text, langPrefs.primaryLanguage)
|
void translate(message.text, langPrefs.primaryLanguage)
|
||||||
|
|
||||||
ax.metric('translate', {
|
ax.metric('translate', {
|
||||||
sourceLanguages: [],
|
sourceLanguages: [],
|
||||||
|
|||||||
Vendored
+3
@@ -49,3 +49,6 @@ export const IS_WEB_FIREFOX: boolean = false
|
|||||||
export const IS_HIGH_DPI: boolean = true
|
export const IS_HIGH_DPI: boolean = true
|
||||||
// ideally we'd use isLiquidGlassAvailable() from expo-glass-effect but checking iOS version is good enough for now
|
// ideally we'd use isLiquidGlassAvailable() from expo-glass-effect but checking iOS version is good enough for now
|
||||||
export const IS_LIQUID_GLASS: boolean = iOSMajorVersion >= 26
|
export const IS_LIQUID_GLASS: boolean = iOSMajorVersion >= 26
|
||||||
|
// So we can avoid attempting on-device translation when we know it's unsupported.
|
||||||
|
export const HAS_ON_DEVICE_TRANSLATION: boolean =
|
||||||
|
(IS_IOS && iOSMajorVersion >= 18) || IS_ANDROID
|
||||||
|
|||||||
Vendored
+1
@@ -48,3 +48,4 @@ export const IS_HIGH_DPI: boolean = window.matchMedia(
|
|||||||
'(min-resolution: 2dppx)',
|
'(min-resolution: 2dppx)',
|
||||||
).matches
|
).matches
|
||||||
export const IS_LIQUID_GLASS: boolean = false
|
export const IS_LIQUID_GLASS: boolean = false
|
||||||
|
export const HAS_ON_DEVICE_TRANSLATION: boolean = false
|
||||||
|
|||||||
@@ -6,10 +6,9 @@ import {getTranslatorLink} from '#/locale/helpers'
|
|||||||
import {IS_ANDROID} from '#/env'
|
import {IS_ANDROID} from '#/env'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Will always link out to Google Translate. If inline translation is desired,
|
* @deprecated Will always link out to Google Translate. Prefer `useTranslate`.
|
||||||
* use `useTranslateOnDevice`
|
|
||||||
*/
|
*/
|
||||||
export function useTranslate() {
|
export function useGoogleTranslate() {
|
||||||
const openLink = useOpenLink()
|
const openLink = useOpenLink()
|
||||||
|
|
||||||
return useCallback(
|
return useCallback(
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import {createContext} from 'react'
|
||||||
|
|
||||||
|
import {type TranslationFunctionParams, type TranslationState} from './types'
|
||||||
|
|
||||||
|
export const Context = createContext<{
|
||||||
|
translationState: Record<string, TranslationState>
|
||||||
|
translate: (
|
||||||
|
parameters: TranslationFunctionParams & {
|
||||||
|
key: string
|
||||||
|
forceGoogleTranslate: boolean
|
||||||
|
},
|
||||||
|
) => Promise<void>
|
||||||
|
clearTranslation: (key: string) => void
|
||||||
|
acquireTranslation: (key: string) => () => void
|
||||||
|
} | null>(null)
|
||||||
|
Context.displayName = 'TranslationContext'
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
import {useCallback, useContext, useEffect, useMemo, useState} from 'react'
|
||||||
|
import {LayoutAnimation, Platform} from 'react-native'
|
||||||
|
import {getLocales} from 'expo-localization'
|
||||||
|
import {onTranslateTask} from '@bsky.app/expo-translate-text'
|
||||||
|
import {type TranslationTaskResult} from '@bsky.app/expo-translate-text/build/ExpoTranslateText.types'
|
||||||
|
import {useLingui} from '@lingui/react/macro'
|
||||||
|
import {useFocusEffect} from '@react-navigation/native'
|
||||||
|
|
||||||
|
import {useGoogleTranslate} from '#/lib/hooks/useGoogleTranslate'
|
||||||
|
import {logger} from '#/logger'
|
||||||
|
import {useAnalytics} from '#/analytics'
|
||||||
|
import {HAS_ON_DEVICE_TRANSLATION} from '#/env'
|
||||||
|
import {Context} from './context'
|
||||||
|
import {type TranslationFunctionParams, type TranslationState} from './types'
|
||||||
|
import {guessLanguage} from './utils'
|
||||||
|
|
||||||
|
export * from './types'
|
||||||
|
export * from './utils'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempts on-device translation via @bsky.app/expo-translate-text.
|
||||||
|
* Uses a lazy import to avoid crashing if the native module isn't linked into
|
||||||
|
* the current build.
|
||||||
|
*/
|
||||||
|
async function attemptTranslation(
|
||||||
|
input: string,
|
||||||
|
targetLangCodeOriginal: string,
|
||||||
|
sourceLangCodeOriginal?: string, // Auto-detects if not provided
|
||||||
|
): Promise<{
|
||||||
|
translatedText: string
|
||||||
|
targetLanguage: TranslationTaskResult['targetLanguage']
|
||||||
|
sourceLanguage: TranslationTaskResult['sourceLanguage']
|
||||||
|
}> {
|
||||||
|
// Note that Android only supports two-character language codes and will fail
|
||||||
|
// on other input.
|
||||||
|
// https://developers.google.com/android/reference/com/google/mlkit/nl/translate/TranslateLanguage
|
||||||
|
let targetLangCode =
|
||||||
|
Platform.OS === 'android'
|
||||||
|
? targetLangCodeOriginal.split('-')[0]
|
||||||
|
: targetLangCodeOriginal
|
||||||
|
const sourceLangCode =
|
||||||
|
Platform.OS === 'android'
|
||||||
|
? sourceLangCodeOriginal?.split('-')[0]
|
||||||
|
: sourceLangCodeOriginal
|
||||||
|
|
||||||
|
// Special cases for regional languages since iOS differentiates and missing
|
||||||
|
// language packs must be downloaded and installed.
|
||||||
|
if (Platform.OS === 'ios') {
|
||||||
|
const deviceLocales = getLocales()
|
||||||
|
const primaryLanguageTag = deviceLocales[0]?.languageTag
|
||||||
|
switch (targetLangCodeOriginal) {
|
||||||
|
case 'en': // en-US, en-GB
|
||||||
|
case 'es': // es-419, es-ES
|
||||||
|
case 'pt': // pt-BR, pt-PT
|
||||||
|
case 'zh': // zh-Hans-CN, zh-Hant-HK, zh-Hant-TW
|
||||||
|
if (
|
||||||
|
primaryLanguageTag &&
|
||||||
|
primaryLanguageTag.startsWith(targetLangCodeOriginal)
|
||||||
|
) {
|
||||||
|
targetLangCode = primaryLanguageTag
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await onTranslateTask({
|
||||||
|
input,
|
||||||
|
targetLangCode,
|
||||||
|
sourceLangCode,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Since `input` is always a string, the result should always be a string.
|
||||||
|
const translatedText =
|
||||||
|
typeof result.translatedTexts === 'string' ? result.translatedTexts : ''
|
||||||
|
|
||||||
|
if (translatedText === input) {
|
||||||
|
throw new Error('Translation result is the same as the source text.')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (translatedText === '') {
|
||||||
|
throw new Error('Translation result is empty.')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
translatedText,
|
||||||
|
targetLanguage: result.targetLanguage,
|
||||||
|
sourceLanguage:
|
||||||
|
result.sourceLanguage ?? sourceLangCode ?? guessLanguage(input), // iOS doesn't return the source language
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Native translation hook. Attempts on-device translation using Apple
|
||||||
|
* Translation (iOS 18+) or Google ML Kit (Android).
|
||||||
|
*
|
||||||
|
* Falls back to Google Translate URL if the language pack is unavailable.
|
||||||
|
*
|
||||||
|
* Web uses index.web.ts which always opens Google Translate.
|
||||||
|
*/
|
||||||
|
export function useTranslate({
|
||||||
|
key,
|
||||||
|
forceGoogleTranslate = false,
|
||||||
|
}: {
|
||||||
|
key: string
|
||||||
|
forceGoogleTranslate?: boolean
|
||||||
|
}) {
|
||||||
|
const context = useContext(Context)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error(
|
||||||
|
'useTranslate must be used within a TranslateOnDeviceProvider',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
const cleanup = context.acquireTranslation(key)
|
||||||
|
return cleanup
|
||||||
|
}, [key, context]),
|
||||||
|
)
|
||||||
|
|
||||||
|
const translate = useCallback(
|
||||||
|
async (params: TranslationFunctionParams) => {
|
||||||
|
return context.translate({...params, key, forceGoogleTranslate})
|
||||||
|
},
|
||||||
|
[context, forceGoogleTranslate, key],
|
||||||
|
)
|
||||||
|
|
||||||
|
const clearTranslation = useCallback(
|
||||||
|
() => context.clearTranslation(key),
|
||||||
|
[context, key],
|
||||||
|
)
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() => ({
|
||||||
|
translationState: context.translationState[key] ?? {
|
||||||
|
status: 'idle',
|
||||||
|
},
|
||||||
|
translate,
|
||||||
|
clearTranslation,
|
||||||
|
}),
|
||||||
|
[clearTranslation, context.translationState, key, translate],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Provider({children}: React.PropsWithChildren<unknown>) {
|
||||||
|
const [translationState, setTranslationState] = useState<
|
||||||
|
Record<string, TranslationState>
|
||||||
|
>({})
|
||||||
|
const [refCounts, setRefCounts] = useState<Record<string, number>>({})
|
||||||
|
const ax = useAnalytics()
|
||||||
|
const {t: l} = useLingui()
|
||||||
|
const googleTranslate = useGoogleTranslate()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTranslationState(prev => {
|
||||||
|
const keysToDelete: string[] = []
|
||||||
|
|
||||||
|
for (const key of Object.keys(prev)) {
|
||||||
|
if ((refCounts[key] ?? 0) <= 0) {
|
||||||
|
keysToDelete.push(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keysToDelete.length > 0) {
|
||||||
|
const newState = {...prev}
|
||||||
|
keysToDelete.forEach(key => {
|
||||||
|
delete newState[key]
|
||||||
|
})
|
||||||
|
return newState
|
||||||
|
}
|
||||||
|
|
||||||
|
return prev
|
||||||
|
})
|
||||||
|
}, [refCounts])
|
||||||
|
|
||||||
|
const acquireTranslation = useCallback((key: string) => {
|
||||||
|
setRefCounts(prev => ({
|
||||||
|
...prev,
|
||||||
|
[key]: (prev[key] ?? 0) + 1,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
setRefCounts(prev => {
|
||||||
|
const newCount = (prev[key] ?? 1) - 1
|
||||||
|
if (newCount <= 0) {
|
||||||
|
const {[key]: _, ...rest} = prev
|
||||||
|
return rest
|
||||||
|
}
|
||||||
|
return {...prev, [key]: newCount}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const clearTranslation = useCallback((key: string) => {
|
||||||
|
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
|
||||||
|
setTranslationState(prev => {
|
||||||
|
delete prev[key]
|
||||||
|
return {...prev}
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const translate = useCallback(
|
||||||
|
async ({
|
||||||
|
key,
|
||||||
|
text,
|
||||||
|
targetLangCode,
|
||||||
|
sourceLangCode,
|
||||||
|
...options
|
||||||
|
}: {
|
||||||
|
key: string
|
||||||
|
text: string
|
||||||
|
targetLangCode: string
|
||||||
|
sourceLangCode?: string
|
||||||
|
forceGoogleTranslate?: boolean
|
||||||
|
}) => {
|
||||||
|
if (options?.forceGoogleTranslate || !HAS_ON_DEVICE_TRANSLATION) {
|
||||||
|
ax.metric('translate:result', {
|
||||||
|
method: 'google-translate',
|
||||||
|
os: Platform.OS,
|
||||||
|
sourceLanguage: sourceLangCode ?? null,
|
||||||
|
targetLanguage: targetLangCode,
|
||||||
|
})
|
||||||
|
await googleTranslate(text, targetLangCode, sourceLangCode)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Translate after the next state change.
|
||||||
|
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
|
||||||
|
setTranslationState(prev => ({
|
||||||
|
...prev,
|
||||||
|
[key]: {status: 'loading'},
|
||||||
|
}))
|
||||||
|
try {
|
||||||
|
const result = await attemptTranslation(
|
||||||
|
text,
|
||||||
|
targetLangCode,
|
||||||
|
sourceLangCode,
|
||||||
|
)
|
||||||
|
ax.metric('translate:result', {
|
||||||
|
method: 'on-device',
|
||||||
|
os: Platform.OS,
|
||||||
|
sourceLanguage: result.sourceLanguage,
|
||||||
|
targetLanguage: result.targetLanguage,
|
||||||
|
})
|
||||||
|
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
|
||||||
|
setTranslationState(prev => ({
|
||||||
|
...prev,
|
||||||
|
[key]: {
|
||||||
|
status: 'success',
|
||||||
|
translatedText: result.translatedText,
|
||||||
|
sourceLanguage: result.sourceLanguage,
|
||||||
|
targetLanguage: result.targetLanguage,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('Failed to translate post on device', {safeMessage: e})
|
||||||
|
// On-device translation failed (language pack missing or user
|
||||||
|
// dismissed the download prompt). Fall back to Google Translate.
|
||||||
|
ax.metric('translate:result', {
|
||||||
|
method: 'fallback-alert',
|
||||||
|
os: Platform.OS,
|
||||||
|
sourceLanguage: sourceLangCode ?? null,
|
||||||
|
targetLanguage: targetLangCode,
|
||||||
|
})
|
||||||
|
let errorMessage = l`Device failed to translate :(`
|
||||||
|
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
|
||||||
|
setTranslationState(prev => ({
|
||||||
|
...prev,
|
||||||
|
[key]: {status: 'error', message: errorMessage},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[ax, googleTranslate, l],
|
||||||
|
)
|
||||||
|
|
||||||
|
const ctx = useMemo(
|
||||||
|
() => ({acquireTranslation, clearTranslation, translate, translationState}),
|
||||||
|
[acquireTranslation, clearTranslation, translate, translationState],
|
||||||
|
)
|
||||||
|
|
||||||
|
return <Context.Provider value={ctx}>{children}</Context.Provider>
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import {useCallback, useContext, useMemo} from 'react'
|
||||||
|
|
||||||
|
import {useGoogleTranslate} from '#/lib/hooks/useGoogleTranslate'
|
||||||
|
import {useAnalytics} from '#/analytics'
|
||||||
|
import {Context} from './context'
|
||||||
|
import {type TranslationFunctionParams, type TranslationState} from './types'
|
||||||
|
|
||||||
|
export * from './types'
|
||||||
|
export * from './utils'
|
||||||
|
|
||||||
|
const translationState: Record<string, TranslationState> = {}
|
||||||
|
const acquireTranslation = (_key: string) => {
|
||||||
|
return () => {}
|
||||||
|
}
|
||||||
|
const clearTranslation = (_key: string) => {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Web always opens Google Translate.
|
||||||
|
*/
|
||||||
|
export function useTranslate({
|
||||||
|
key,
|
||||||
|
}: {
|
||||||
|
key: string
|
||||||
|
forceGoogleTranslate?: boolean
|
||||||
|
}) {
|
||||||
|
const context = useContext(Context)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error(
|
||||||
|
'useTranslate must be used within a TranslateOnDeviceProvider',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always call hooks in consistent order
|
||||||
|
const translate = useCallback(
|
||||||
|
async (params: TranslationFunctionParams) => {
|
||||||
|
return context.translate({...params, key, forceGoogleTranslate: true})
|
||||||
|
},
|
||||||
|
[key, context],
|
||||||
|
)
|
||||||
|
|
||||||
|
const clearTranslation = useCallback(() => {
|
||||||
|
return context.clearTranslation(key)
|
||||||
|
}, [key, context])
|
||||||
|
|
||||||
|
return {
|
||||||
|
translationState: context.translationState[key] ?? {
|
||||||
|
status: 'idle' as const,
|
||||||
|
},
|
||||||
|
translate,
|
||||||
|
clearTranslation,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Provider({children}: React.PropsWithChildren<unknown>) {
|
||||||
|
const ax = useAnalytics()
|
||||||
|
const googleTranslate = useGoogleTranslate()
|
||||||
|
|
||||||
|
const translate = useCallback(
|
||||||
|
async ({
|
||||||
|
text,
|
||||||
|
targetLangCode,
|
||||||
|
sourceLangCode,
|
||||||
|
}: {
|
||||||
|
key: string
|
||||||
|
text: string
|
||||||
|
targetLangCode: string
|
||||||
|
sourceLangCode?: string
|
||||||
|
}) => {
|
||||||
|
ax.metric('translate:result', {
|
||||||
|
method: 'google-translate',
|
||||||
|
os: 'web',
|
||||||
|
sourceLanguage: sourceLangCode ?? null,
|
||||||
|
targetLanguage: targetLangCode,
|
||||||
|
})
|
||||||
|
await googleTranslate(text, targetLangCode, sourceLangCode)
|
||||||
|
},
|
||||||
|
[ax, googleTranslate],
|
||||||
|
)
|
||||||
|
|
||||||
|
const ctx = useMemo(
|
||||||
|
() => ({acquireTranslation, clearTranslation, translate, translationState}),
|
||||||
|
[translate],
|
||||||
|
)
|
||||||
|
|
||||||
|
return <Context.Provider value={ctx}>{children}</Context.Provider>
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import {type TranslationTaskResult} from '@bsky.app/expo-translate-text/build/ExpoTranslateText.types'
|
||||||
|
|
||||||
|
export type TranslationState =
|
||||||
|
| {status: 'idle'}
|
||||||
|
| {status: 'loading'}
|
||||||
|
| {
|
||||||
|
status: 'success'
|
||||||
|
translatedText: string
|
||||||
|
sourceLanguage: TranslationTaskResult['sourceLanguage']
|
||||||
|
targetLanguage: TranslationTaskResult['targetLanguage']
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
status: 'error'
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TranslationFunctionParams = {
|
||||||
|
/**
|
||||||
|
* The text to be translated.
|
||||||
|
*/
|
||||||
|
text: string
|
||||||
|
/**
|
||||||
|
* The language to translate the text into.
|
||||||
|
*/
|
||||||
|
targetLangCode: string
|
||||||
|
/**
|
||||||
|
* The source language of the text. Will auto-detect if not provided.
|
||||||
|
*/
|
||||||
|
sourceLangCode?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TranslationFunction = (
|
||||||
|
parameters: TranslationFunctionParams,
|
||||||
|
) => Promise<void>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import lande from 'lande'
|
||||||
|
|
||||||
|
import {code3ToCode2Strict} from '#/locale/helpers'
|
||||||
|
|
||||||
|
// TODO: Replace with expo-guess-language
|
||||||
|
export function guessLanguage(text: string): string | null {
|
||||||
|
const results = lande(text)
|
||||||
|
// only return high-confidence results
|
||||||
|
if (results[0] && results[0][1] > 0.97) {
|
||||||
|
return code3ToCode2Strict(results[0][0]) ?? null
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
|||||||
import {memo, useCallback, useMemo} from 'react'
|
import {memo, useCallback, useMemo} from 'react'
|
||||||
import {type GestureResponderEvent, Text as RNText, View} from 'react-native'
|
import {Text as RNText, View} from 'react-native'
|
||||||
import {
|
import {
|
||||||
AppBskyFeedDefs,
|
AppBskyFeedDefs,
|
||||||
AppBskyFeedPost,
|
AppBskyFeedPost,
|
||||||
@@ -14,11 +14,6 @@ import {makeProfileLink} from '#/lib/routes/links'
|
|||||||
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||||
import {niceDate} from '#/lib/strings/time'
|
import {niceDate} from '#/lib/strings/time'
|
||||||
import {
|
|
||||||
getPostLanguage,
|
|
||||||
getTranslatorLink,
|
|
||||||
isPostInLanguage,
|
|
||||||
} from '#/locale/helpers'
|
|
||||||
import {
|
import {
|
||||||
POST_TOMBSTONE,
|
POST_TOMBSTONE,
|
||||||
type Shadow,
|
type Shadow,
|
||||||
@@ -26,7 +21,6 @@ import {
|
|||||||
} from '#/state/cache/post-shadow'
|
} from '#/state/cache/post-shadow'
|
||||||
import {useProfileShadow} from '#/state/cache/profile-shadow'
|
import {useProfileShadow} from '#/state/cache/profile-shadow'
|
||||||
import {FeedFeedbackProvider, useFeedFeedback} from '#/state/feed-feedback'
|
import {FeedFeedbackProvider, useFeedFeedback} from '#/state/feed-feedback'
|
||||||
import {useLanguagePrefs} from '#/state/preferences'
|
|
||||||
import {type ThreadItem} from '#/state/queries/usePostThread/types'
|
import {type ThreadItem} from '#/state/queries/usePostThread/types'
|
||||||
import {useSession} from '#/state/session'
|
import {useSession} from '#/state/session'
|
||||||
import {type OnPostSuccessData} from '#/state/shell/composer'
|
import {type OnPostSuccessData} from '#/state/shell/composer'
|
||||||
@@ -44,8 +38,7 @@ import {Button} from '#/components/Button'
|
|||||||
import {DebugFieldDisplay} from '#/components/DebugFieldDisplay'
|
import {DebugFieldDisplay} from '#/components/DebugFieldDisplay'
|
||||||
import {CalendarClock_Stroke2_Corner0_Rounded as CalendarClockIcon} from '#/components/icons/CalendarClock'
|
import {CalendarClock_Stroke2_Corner0_Rounded as CalendarClockIcon} from '#/components/icons/CalendarClock'
|
||||||
import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
|
import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
|
||||||
import {InlineLinkText, Link} from '#/components/Link'
|
import {Link} from '#/components/Link'
|
||||||
import {Loader} from '#/components/Loader'
|
|
||||||
import {ContentHider} from '#/components/moderation/ContentHider'
|
import {ContentHider} from '#/components/moderation/ContentHider'
|
||||||
import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe'
|
import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe'
|
||||||
import {PostAlerts} from '#/components/moderation/PostAlerts'
|
import {PostAlerts} from '#/components/moderation/PostAlerts'
|
||||||
@@ -63,10 +56,6 @@ import {VerificationCheckButton} from '#/components/verification/VerificationChe
|
|||||||
import {WhoCanReply} from '#/components/WhoCanReply'
|
import {WhoCanReply} from '#/components/WhoCanReply'
|
||||||
import {useAnalytics} from '#/analytics'
|
import {useAnalytics} from '#/analytics'
|
||||||
import {useActorStatus} from '#/features/liveNow'
|
import {useActorStatus} from '#/features/liveNow'
|
||||||
import {
|
|
||||||
Provider as TranslateOnDeviceProvider,
|
|
||||||
useTranslateOnDevice,
|
|
||||||
} from '#/translation'
|
|
||||||
import * as bsky from '#/types/bsky'
|
import * as bsky from '#/types/bsky'
|
||||||
|
|
||||||
export function ThreadItemAnchor({
|
export function ThreadItemAnchor({
|
||||||
@@ -89,18 +78,16 @@ export function ThreadItemAnchor({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TranslateOnDeviceProvider>
|
<ThreadItemAnchorInner
|
||||||
<ThreadItemAnchorInner
|
// Safeguard from clobbering per-post state below:
|
||||||
// Safeguard from clobbering per-post state below:
|
key={postShadow.uri}
|
||||||
key={postShadow.uri}
|
item={item}
|
||||||
item={item}
|
isRoot={isRoot}
|
||||||
isRoot={isRoot}
|
postShadow={postShadow}
|
||||||
postShadow={postShadow}
|
onPostSuccess={onPostSuccess}
|
||||||
onPostSuccess={onPostSuccess}
|
threadgateRecord={threadgateRecord}
|
||||||
threadgateRecord={threadgateRecord}
|
postSource={postSource}
|
||||||
postSource={postSource}
|
/>
|
||||||
/>
|
|
||||||
</TranslateOnDeviceProvider>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -420,8 +407,7 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
|
|||||||
shouldProxyLinks={true}
|
shouldProxyLinks={true}
|
||||||
/>
|
/>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
<TranslatedPost postText={record.text} hideLoading />
|
<TranslatedPost post={post} postText={record.text} />
|
||||||
<TranslateLink post={item.value.post} />
|
|
||||||
{post.embed && (
|
{post.embed && (
|
||||||
<View style={[a.py_xs]}>
|
<View style={[a.py_xs]}>
|
||||||
<Embed
|
<Embed
|
||||||
@@ -557,97 +543,6 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
function TranslateLink({
|
|
||||||
post,
|
|
||||||
}: {
|
|
||||||
post: Extract<ThreadItem, {type: 'threadPost'}>['value']['post']
|
|
||||||
}) {
|
|
||||||
const t = useTheme()
|
|
||||||
const ax = useAnalytics()
|
|
||||||
const {t: l} = useLingui()
|
|
||||||
const langPrefs = useLanguagePrefs()
|
|
||||||
|
|
||||||
const {translate, clearTranslation, translationState} = useTranslateOnDevice()
|
|
||||||
|
|
||||||
const needsTranslation = useMemo(
|
|
||||||
() =>
|
|
||||||
Boolean(
|
|
||||||
langPrefs.primaryLanguage &&
|
|
||||||
!isPostInLanguage(post, [langPrefs.primaryLanguage]),
|
|
||||||
),
|
|
||||||
[post, langPrefs.primaryLanguage],
|
|
||||||
)
|
|
||||||
|
|
||||||
const sourceLanguage = getPostLanguage(post)
|
|
||||||
|
|
||||||
const onTranslatePress = useCallback(
|
|
||||||
(e: GestureResponderEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
void translate(
|
|
||||||
post.record.text || '',
|
|
||||||
langPrefs.primaryLanguage,
|
|
||||||
sourceLanguage,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (
|
|
||||||
bsky.dangerousIsType<AppBskyFeedPost.Record>(
|
|
||||||
post.record,
|
|
||||||
AppBskyFeedPost.isRecord,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
ax.metric('translate', {
|
|
||||||
sourceLanguages: post.record.langs ?? [],
|
|
||||||
targetLanguage: langPrefs.primaryLanguage,
|
|
||||||
textLength: post.record.text.length,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
},
|
|
||||||
[ax, sourceLanguage, translate, langPrefs, post],
|
|
||||||
)
|
|
||||||
|
|
||||||
const onHideTranslation = useCallback(
|
|
||||||
(e: GestureResponderEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
clearTranslation()
|
|
||||||
return false
|
|
||||||
},
|
|
||||||
[clearTranslation],
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
needsTranslation && (
|
|
||||||
<View style={[a.gap_md, a.pt_md, a.align_start]}>
|
|
||||||
{translationState.status === 'loading' ? (
|
|
||||||
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
|
|
||||||
<Loader size="xs" />
|
|
||||||
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
|
|
||||||
<Trans>Translating…</Trans>
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
) : translationState.status === 'success' ? (
|
|
||||||
<InlineLinkText
|
|
||||||
to="#"
|
|
||||||
label={l`Hide translation`}
|
|
||||||
style={[a.text_sm]}
|
|
||||||
onPress={onHideTranslation}>
|
|
||||||
<Trans>Hide translation</Trans>
|
|
||||||
</InlineLinkText>
|
|
||||||
) : (
|
|
||||||
<InlineLinkText
|
|
||||||
to={getTranslatorLink(post.record.text, langPrefs.primaryLanguage)}
|
|
||||||
label={l`Translate`}
|
|
||||||
style={[a.text_sm]}
|
|
||||||
onPress={onTranslatePress}>
|
|
||||||
<Trans>Translate</Trans>
|
|
||||||
</InlineLinkText>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ExpandedPostDetails({
|
function ExpandedPostDetails({
|
||||||
post,
|
post,
|
||||||
isThreadAuthor,
|
isThreadAuthor,
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import {PostHider} from '#/components/moderation/PostHider'
|
|||||||
import {type AppModerationCause} from '#/components/Pills'
|
import {type AppModerationCause} from '#/components/Pills'
|
||||||
import {Embed, PostEmbedViewContext} from '#/components/Post/Embed'
|
import {Embed, PostEmbedViewContext} from '#/components/Post/Embed'
|
||||||
import {ShowMoreTextButton} from '#/components/Post/ShowMoreTextButton'
|
import {ShowMoreTextButton} from '#/components/Post/ShowMoreTextButton'
|
||||||
|
import {TranslatedPost} from '#/components/Post/Translated'
|
||||||
import {PostControls, PostControlsSkeleton} from '#/components/PostControls'
|
import {PostControls, PostControlsSkeleton} from '#/components/PostControls'
|
||||||
import {RichText} from '#/components/RichText'
|
import {RichText} from '#/components/RichText'
|
||||||
import * as Skele from '#/components/Skeleton'
|
import * as Skele from '#/components/Skeleton'
|
||||||
@@ -320,6 +321,11 @@ const ThreadItemPostInner = memo(function ThreadItemPostInner({
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
|
<TranslatedPost
|
||||||
|
hideTranslateLink={true}
|
||||||
|
post={post}
|
||||||
|
postText={record.text}
|
||||||
|
/>
|
||||||
{post.embed && (
|
{post.embed && (
|
||||||
<View style={[a.pb_xs]}>
|
<View style={[a.pb_xs]}>
|
||||||
<Embed
|
<Embed
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import {PostHider} from '#/components/moderation/PostHider'
|
|||||||
import {type AppModerationCause} from '#/components/Pills'
|
import {type AppModerationCause} from '#/components/Pills'
|
||||||
import {Embed, PostEmbedViewContext} from '#/components/Post/Embed'
|
import {Embed, PostEmbedViewContext} from '#/components/Post/Embed'
|
||||||
import {ShowMoreTextButton} from '#/components/Post/ShowMoreTextButton'
|
import {ShowMoreTextButton} from '#/components/Post/ShowMoreTextButton'
|
||||||
|
import {TranslatedPost} from '#/components/Post/Translated'
|
||||||
import {PostControls, PostControlsSkeleton} from '#/components/PostControls'
|
import {PostControls, PostControlsSkeleton} from '#/components/PostControls'
|
||||||
import {RichText} from '#/components/RichText'
|
import {RichText} from '#/components/RichText'
|
||||||
import * as Skele from '#/components/Skeleton'
|
import * as Skele from '#/components/Skeleton'
|
||||||
@@ -360,6 +361,11 @@ const ThreadItemTreePostInner = memo(function ThreadItemTreePostInner({
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
|
<TranslatedPost
|
||||||
|
hideTranslateLink={true}
|
||||||
|
post={post}
|
||||||
|
postText={record.text}
|
||||||
|
/>
|
||||||
{post.embed && (
|
{post.embed && (
|
||||||
<View style={[a.pb_xs]}>
|
<View style={[a.pb_xs]}>
|
||||||
<Embed
|
<Embed
|
||||||
|
|||||||
@@ -33,9 +33,7 @@ import {
|
|||||||
type ModerationDecision,
|
type ModerationDecision,
|
||||||
RichText as RichTextAPI,
|
RichText as RichTextAPI,
|
||||||
} from '@atproto/api'
|
} from '@atproto/api'
|
||||||
import {msg} from '@lingui/core/macro'
|
import {Trans, useLingui} from '@lingui/react/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
|
||||||
import {Trans} from '@lingui/react/macro'
|
|
||||||
import {
|
import {
|
||||||
type RouteProp,
|
type RouteProp,
|
||||||
useFocusEffect,
|
useFocusEffect,
|
||||||
@@ -451,7 +449,7 @@ function Feed() {
|
|||||||
}
|
}
|
||||||
onEndReached={() => {
|
onEndReached={() => {
|
||||||
if (hasNextPage && !isFetchingNextPage) {
|
if (hasNextPage && !isFetchingNextPage) {
|
||||||
fetchNextPage()
|
void fetchNextPage()
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
@@ -515,6 +513,7 @@ let VideoItem = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
|
ax,
|
||||||
active,
|
active,
|
||||||
post.uri,
|
post.uri,
|
||||||
post.author.did,
|
post.author.did,
|
||||||
@@ -621,7 +620,7 @@ function ModerationOverlay({
|
|||||||
embed: AppBskyEmbedVideo.View
|
embed: AppBskyEmbedVideo.View
|
||||||
onPressShow: () => void
|
onPressShow: () => void
|
||||||
}) {
|
}) {
|
||||||
const {_} = useLingui()
|
const {t: l} = useLingui()
|
||||||
const hider = Hider.useHider()
|
const hider = Hider.useHider()
|
||||||
const {bottom} = useSafeAreaInsets()
|
const {bottom} = useSafeAreaInsets()
|
||||||
|
|
||||||
@@ -648,7 +647,7 @@ function ModerationOverlay({
|
|||||||
<Trans>Hidden by your moderation settings.</Trans>
|
<Trans>Hidden by your moderation settings.</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
<Button
|
<Button
|
||||||
label={_(msg`Show anyway`)}
|
label={l`Show anyway`}
|
||||||
size="small"
|
size="small"
|
||||||
variant="solid"
|
variant="solid"
|
||||||
color="secondary_inverted"
|
color="secondary_inverted"
|
||||||
@@ -676,7 +675,7 @@ function ModerationOverlay({
|
|||||||
<Divider style={{borderColor: 'white'}} />
|
<Divider style={{borderColor: 'white'}} />
|
||||||
<View>
|
<View>
|
||||||
<Button
|
<Button
|
||||||
label={_(msg`View details`)}
|
label={l`View details`}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
hider.showInfoDialog()
|
hider.showInfoDialog()
|
||||||
}}
|
}}
|
||||||
@@ -724,7 +723,7 @@ function Overlay({
|
|||||||
feedContext: string | undefined
|
feedContext: string | undefined
|
||||||
reqId: string | undefined
|
reqId: string | undefined
|
||||||
}) {
|
}) {
|
||||||
const {_} = useLingui()
|
const {t: l} = useLingui()
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {openComposer} = useOpenComposer()
|
const {openComposer} = useOpenComposer()
|
||||||
const {currentAccount} = useSession()
|
const {currentAccount} = useSession()
|
||||||
@@ -811,11 +810,9 @@ function Overlay({
|
|||||||
<Animated.View style={[a.px_md, animatedStyle]}>
|
<Animated.View style={[a.px_md, animatedStyle]}>
|
||||||
<View style={[a.w_full, a.flex_row, a.align_center, a.gap_md]}>
|
<View style={[a.w_full, a.flex_row, a.align_center, a.gap_md]}>
|
||||||
<Link
|
<Link
|
||||||
label={_(
|
label={l`View ${sanitizeDisplayName(
|
||||||
msg`View ${sanitizeDisplayName(
|
post.author.displayName || post.author.handle,
|
||||||
post.author.displayName || post.author.handle,
|
)}'s profile`}
|
||||||
)}'s profile`,
|
|
||||||
)}
|
|
||||||
to={{
|
to={{
|
||||||
screen: 'Profile',
|
screen: 'Profile',
|
||||||
params: {name: post.author.did},
|
params: {name: post.author.did},
|
||||||
@@ -848,13 +845,11 @@ function Overlay({
|
|||||||
<Button
|
<Button
|
||||||
label={
|
label={
|
||||||
profile.viewer?.following
|
profile.viewer?.following
|
||||||
? _(msg`Following ${handle}`)
|
? l`Following ${handle}`
|
||||||
: _(msg`Follow ${handle}`)
|
: l`Follow ${handle}`
|
||||||
}
|
}
|
||||||
accessibilityHint={
|
accessibilityHint={
|
||||||
profile.viewer?.following
|
profile.viewer?.following ? l`Unfollows the user` : ''
|
||||||
? _(msg`Unfollows the user`)
|
|
||||||
: ''
|
|
||||||
}
|
}
|
||||||
size="small"
|
size="small"
|
||||||
variant="solid"
|
variant="solid"
|
||||||
@@ -862,8 +857,8 @@ function Overlay({
|
|||||||
style={[a.mb_xs]}
|
style={[a.mb_xs]}
|
||||||
onPress={() =>
|
onPress={() =>
|
||||||
profile.viewer?.following
|
profile.viewer?.following
|
||||||
? queueUnfollow()
|
? void queueUnfollow()
|
||||||
: queueFollow()
|
: void queueFollow()
|
||||||
}>
|
}>
|
||||||
{!!profile.viewer?.following && (
|
{!!profile.viewer?.following && (
|
||||||
<ButtonIcon icon={CheckIcon} />
|
<ButtonIcon icon={CheckIcon} />
|
||||||
@@ -892,6 +887,7 @@ function Overlay({
|
|||||||
record={record}
|
record={record}
|
||||||
feedContext={feedContext}
|
feedContext={feedContext}
|
||||||
logContext="FeedItem"
|
logContext="FeedItem"
|
||||||
|
forceGoogleTranslate={true}
|
||||||
onPressReply={() =>
|
onPressReply={() =>
|
||||||
navigation.navigate('PostThread', {
|
navigation.navigate('PostThread', {
|
||||||
name: post.author.did,
|
name: post.author.did,
|
||||||
@@ -947,7 +943,7 @@ function ExpandableRichTextView({
|
|||||||
const [hasBeenExpanded, setHasBeenExpanded] = useState(false)
|
const [hasBeenExpanded, setHasBeenExpanded] = useState(false)
|
||||||
const [constrained, setConstrained] = useState(false)
|
const [constrained, setConstrained] = useState(false)
|
||||||
const [contentHeight, setContentHeight] = useState(0)
|
const [contentHeight, setContentHeight] = useState(0)
|
||||||
const {_} = useLingui()
|
const {t: l} = useLingui()
|
||||||
const {screenReaderEnabled} = useA11y()
|
const {screenReaderEnabled} = useA11y()
|
||||||
|
|
||||||
if (expanded && !hasBeenExpanded) {
|
if (expanded && !hasBeenExpanded) {
|
||||||
@@ -988,8 +984,8 @@ function ExpandableRichTextView({
|
|||||||
/>
|
/>
|
||||||
{constrained && !screenReaderEnabled && (
|
{constrained && !screenReaderEnabled && (
|
||||||
<Pressable
|
<Pressable
|
||||||
accessibilityHint={_(msg`Expands or collapses post text`)}
|
accessibilityHint={l`Expands or collapses post text`}
|
||||||
accessibilityLabel={expanded ? _(msg`Read less`) : _(msg`Read more`)}
|
accessibilityLabel={expanded ? l`Read less` : l`Read more`}
|
||||||
hitSlop={HITSLOP_20}
|
hitSlop={HITSLOP_20}
|
||||||
onPress={() => setExpanded(prev => !prev)}
|
onPress={() => setExpanded(prev => !prev)}
|
||||||
style={[a.absolute, a.inset_0]}
|
style={[a.absolute, a.inset_0]}
|
||||||
@@ -1049,7 +1045,7 @@ function PlayPauseTapArea({
|
|||||||
feedContext: string | undefined
|
feedContext: string | undefined
|
||||||
reqId: string | undefined
|
reqId: string | undefined
|
||||||
}) {
|
}) {
|
||||||
const {_} = useLingui()
|
const {t: l} = useLingui()
|
||||||
const doubleTapRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const doubleTapRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
const playHaptic = useHaptics()
|
const playHaptic = useHaptics()
|
||||||
// TODO: implement viaRepost -sfn
|
// TODO: implement viaRepost -sfn
|
||||||
@@ -1092,7 +1088,7 @@ function PlayPauseTapArea({
|
|||||||
clearTimeout(doubleTapRef.current)
|
clearTimeout(doubleTapRef.current)
|
||||||
doubleTapRef.current = null
|
doubleTapRef.current = null
|
||||||
playHaptic('Light')
|
playHaptic('Light')
|
||||||
queueLike()
|
void queueLike()
|
||||||
sendInteraction({
|
sendInteraction({
|
||||||
item: post.uri,
|
item: post.uri,
|
||||||
event: 'app.bsky.feed.defs#interactionLike',
|
event: 'app.bsky.feed.defs#interactionLike',
|
||||||
@@ -1107,16 +1103,12 @@ function PlayPauseTapArea({
|
|||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
disabled={!player}
|
disabled={!player}
|
||||||
aria-valuetext={
|
aria-valuetext={isPlaying ? l`Video is playing` : l`Video is paused`}
|
||||||
isPlaying ? _(msg`Video is playing`) : _(msg`Video is paused`)
|
label={l`Video from ${sanitizeHandle(
|
||||||
}
|
post.author.handle,
|
||||||
label={_(
|
'@',
|
||||||
msg`Video from ${sanitizeHandle(
|
)}. Tap to play or pause the video`}
|
||||||
post.author.handle,
|
accessibilityHint={l`Double tap to like`}
|
||||||
'@',
|
|
||||||
)}. Tap to play or pause the video`,
|
|
||||||
)}
|
|
||||||
accessibilityHint={_(msg`Double tap to like`)}
|
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
style={[a.absolute, a.inset_0, a.z_10]}>
|
style={[a.absolute, a.inset_0, a.z_10]}>
|
||||||
<View />
|
<View />
|
||||||
@@ -1126,7 +1118,7 @@ function PlayPauseTapArea({
|
|||||||
|
|
||||||
function EndMessage() {
|
function EndMessage() {
|
||||||
const navigation = useNavigation<NavigationProp>()
|
const navigation = useNavigation<NavigationProp>()
|
||||||
const {_} = useLingui()
|
const {t: l} = useLingui()
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
@@ -1177,8 +1169,8 @@ function EndMessage() {
|
|||||||
variant="solid"
|
variant="solid"
|
||||||
color="secondary_inverted"
|
color="secondary_inverted"
|
||||||
size="small"
|
size="small"
|
||||||
label={_(msg`Go back`)}
|
label={l`Go back`}
|
||||||
accessibilityHint={_(msg`Returns to previous page`)}>
|
accessibilityHint={l`Returns to previous page`}>
|
||||||
<ButtonIcon icon={ArrowLeftIcon} />
|
<ButtonIcon icon={ArrowLeftIcon} />
|
||||||
<ButtonText>
|
<ButtonText>
|
||||||
<Trans>Go back</Trans>
|
<Trans>Go back</Trans>
|
||||||
|
|||||||
@@ -1,199 +0,0 @@
|
|||||||
import React, {
|
|
||||||
createContext,
|
|
||||||
useCallback,
|
|
||||||
useContext,
|
|
||||||
useMemo,
|
|
||||||
useState,
|
|
||||||
} from 'react'
|
|
||||||
import {LayoutAnimation, Platform} from 'react-native'
|
|
||||||
import {getLocales} from 'expo-localization'
|
|
||||||
import {type TranslationTaskResult} from '@bsky.app/expo-translate-text/build/ExpoTranslateText.types'
|
|
||||||
|
|
||||||
import {useOpenLink} from '#/lib/hooks/useOpenLink'
|
|
||||||
import {getTranslatorLink} from '#/locale/helpers'
|
|
||||||
import {logger} from '#/logger'
|
|
||||||
import {useLanguagePrefs} from '#/state/preferences'
|
|
||||||
import {useAnalytics} from '#/analytics'
|
|
||||||
import {IS_WEB} from '#/env'
|
|
||||||
|
|
||||||
type TranslationState =
|
|
||||||
| {status: 'idle'}
|
|
||||||
| {status: 'loading'}
|
|
||||||
| {
|
|
||||||
status: 'success'
|
|
||||||
translatedText: string
|
|
||||||
sourceLanguage: TranslationTaskResult['sourceLanguage']
|
|
||||||
targetLanguage: TranslationTaskResult['targetLanguage']
|
|
||||||
}
|
|
||||||
|
|
||||||
const IDLE: TranslationState = {status: 'idle'}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Attempts on-device translation via @bsky.app/expo-translate-text.
|
|
||||||
* Uses a lazy import to avoid crashing if the native module isn't linked into
|
|
||||||
* the current build.
|
|
||||||
*/
|
|
||||||
async function attemptTranslation(
|
|
||||||
input: string,
|
|
||||||
targetLangCodeOriginal: string,
|
|
||||||
sourceLangCodeOriginal?: string, // Auto-detects if not provided
|
|
||||||
): Promise<{
|
|
||||||
translatedText: string
|
|
||||||
targetLanguage: TranslationTaskResult['targetLanguage']
|
|
||||||
sourceLanguage: TranslationTaskResult['sourceLanguage']
|
|
||||||
}> {
|
|
||||||
// Note that Android only supports two-character language codes and will fail
|
|
||||||
// on other input.
|
|
||||||
// https://developers.google.com/android/reference/com/google/mlkit/nl/translate/TranslateLanguage
|
|
||||||
let targetLangCode =
|
|
||||||
Platform.OS === 'android'
|
|
||||||
? targetLangCodeOriginal.split('-')[0]
|
|
||||||
: targetLangCodeOriginal
|
|
||||||
const sourceLangCode =
|
|
||||||
Platform.OS === 'android'
|
|
||||||
? sourceLangCodeOriginal?.split('-')[0]
|
|
||||||
: sourceLangCodeOriginal
|
|
||||||
|
|
||||||
// Special cases for regional languages
|
|
||||||
if (Platform.OS !== 'android') {
|
|
||||||
const deviceLocales = getLocales()
|
|
||||||
const primaryLanguageTag = deviceLocales[0]?.languageTag
|
|
||||||
switch (targetLangCodeOriginal) {
|
|
||||||
case 'en': // en-US, en-GB
|
|
||||||
case 'es': // es-419, es-ES
|
|
||||||
case 'pt': // pt-BR, pt-PT
|
|
||||||
case 'zh': // zh-Hans-CN, zh-Hant-HK, zh-Hant-TW
|
|
||||||
targetLangCode = primaryLanguageTag ?? targetLangCodeOriginal
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const {onTranslateTask} =
|
|
||||||
// Needed in order to type check the dynamically imported module.
|
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
|
||||||
require('@bsky.app/expo-translate-text') as typeof import('@bsky.app/expo-translate-text')
|
|
||||||
const result = await onTranslateTask({
|
|
||||||
input,
|
|
||||||
targetLangCode,
|
|
||||||
sourceLangCode,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Since `input` is always a string, the result should always be a string.
|
|
||||||
return {
|
|
||||||
translatedText:
|
|
||||||
typeof result.translatedTexts === 'string' ? result.translatedTexts : '',
|
|
||||||
targetLanguage: result.targetLanguage,
|
|
||||||
sourceLanguage: result.sourceLanguage ?? sourceLangCode ?? null, // iOS doesn't return the source language
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const Context = createContext<{
|
|
||||||
translationState: TranslationState
|
|
||||||
translate: (
|
|
||||||
text: string,
|
|
||||||
targetLangCode: string,
|
|
||||||
sourceLangCode?: string,
|
|
||||||
) => Promise<void>
|
|
||||||
clearTranslation: () => void
|
|
||||||
}>({
|
|
||||||
translationState: IDLE,
|
|
||||||
translate: async () => {},
|
|
||||||
clearTranslation: () => {},
|
|
||||||
})
|
|
||||||
Context.displayName = 'TranslationContext'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Native translation hook. Attempts on-device translation using Apple
|
|
||||||
* Translation (iOS 18+) or Google ML Kit (Android).
|
|
||||||
*
|
|
||||||
* Falls back to Google Translate URL if the language pack is unavailable.
|
|
||||||
*
|
|
||||||
* Web uses index.web.ts which always opens Google Translate.
|
|
||||||
*/
|
|
||||||
export function useTranslateOnDevice() {
|
|
||||||
const context = useContext(Context)
|
|
||||||
if (!context) {
|
|
||||||
throw new Error(
|
|
||||||
'useTranslateOnDevice must be used within a TranslateOnDeviceProvider',
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return context
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Provider({children}: React.PropsWithChildren<unknown>) {
|
|
||||||
const [translationState, setTranslationState] =
|
|
||||||
useState<TranslationState>(IDLE)
|
|
||||||
const openLink = useOpenLink()
|
|
||||||
const ax = useAnalytics()
|
|
||||||
const {primaryLanguage} = useLanguagePrefs()
|
|
||||||
|
|
||||||
const clearTranslation = useCallback(() => {
|
|
||||||
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
|
|
||||||
setTranslationState(IDLE)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const translate = useCallback(
|
|
||||||
async (
|
|
||||||
text: string,
|
|
||||||
targetLangCode: string = primaryLanguage,
|
|
||||||
sourceLangCode?: string,
|
|
||||||
) => {
|
|
||||||
setTranslationState({status: 'loading'})
|
|
||||||
try {
|
|
||||||
const result = await attemptTranslation(
|
|
||||||
text,
|
|
||||||
targetLangCode,
|
|
||||||
sourceLangCode,
|
|
||||||
)
|
|
||||||
ax.metric('translate:result', {
|
|
||||||
method: 'on-device',
|
|
||||||
os: Platform.OS,
|
|
||||||
sourceLanguage: result.sourceLanguage,
|
|
||||||
targetLanguage: result.targetLanguage,
|
|
||||||
})
|
|
||||||
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
|
|
||||||
setTranslationState({
|
|
||||||
status: 'success',
|
|
||||||
translatedText: result.translatedText,
|
|
||||||
sourceLanguage: result.sourceLanguage,
|
|
||||||
targetLanguage: result.targetLanguage,
|
|
||||||
})
|
|
||||||
} catch (e) {
|
|
||||||
if (IS_WEB) {
|
|
||||||
// Web always opens Google Translate.
|
|
||||||
ax.metric('translate:result', {
|
|
||||||
method: 'google-translate',
|
|
||||||
os: Platform.OS,
|
|
||||||
sourceLanguage: sourceLangCode ?? null,
|
|
||||||
targetLanguage: targetLangCode,
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
logger.error('Failed to translate post on device', {safeMessage: e})
|
|
||||||
// On-device translation failed (language pack missing or user dismissed
|
|
||||||
// the download prompt). Fall back to Google Translate.
|
|
||||||
ax.metric('translate:result', {
|
|
||||||
method: 'fallback-alert',
|
|
||||||
os: Platform.OS,
|
|
||||||
sourceLanguage: sourceLangCode ?? null,
|
|
||||||
targetLanguage: targetLangCode,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
setTranslationState({status: 'idle'})
|
|
||||||
const translateUrl = getTranslatorLink(
|
|
||||||
text,
|
|
||||||
targetLangCode,
|
|
||||||
sourceLangCode,
|
|
||||||
)
|
|
||||||
await openLink(translateUrl)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[ax, openLink, primaryLanguage, setTranslationState],
|
|
||||||
)
|
|
||||||
|
|
||||||
const ctx = useMemo(
|
|
||||||
() => ({clearTranslation, translate, translationState}),
|
|
||||||
[clearTranslation, translate, translationState],
|
|
||||||
)
|
|
||||||
|
|
||||||
return <Context.Provider value={ctx}>{children}</Context.Provider>
|
|
||||||
}
|
|
||||||
@@ -33,6 +33,7 @@ import {PostAlerts} from '#/components/moderation/PostAlerts'
|
|||||||
import {Embed, PostEmbedViewContext} from '#/components/Post/Embed'
|
import {Embed, PostEmbedViewContext} from '#/components/Post/Embed'
|
||||||
import {PostRepliedTo} from '#/components/Post/PostRepliedTo'
|
import {PostRepliedTo} from '#/components/Post/PostRepliedTo'
|
||||||
import {ShowMoreTextButton} from '#/components/Post/ShowMoreTextButton'
|
import {ShowMoreTextButton} from '#/components/Post/ShowMoreTextButton'
|
||||||
|
import {TranslatedPost} from '#/components/Post/Translated'
|
||||||
import {PostControls} from '#/components/PostControls'
|
import {PostControls} from '#/components/PostControls'
|
||||||
import {RichText} from '#/components/RichText'
|
import {RichText} from '#/components/RichText'
|
||||||
import {SubtleHover} from '#/components/SubtleHover'
|
import {SubtleHover} from '#/components/SubtleHover'
|
||||||
@@ -152,6 +153,7 @@ function PostInner({
|
|||||||
}, [queryClient, post.author, outerOnBeforePress])
|
}, [queryClient, post.author, outerOnBeforePress])
|
||||||
|
|
||||||
const [hover, setHover] = useState(false)
|
const [hover, setHover] = useState(false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={itemHref}
|
href={itemHref}
|
||||||
@@ -217,6 +219,11 @@ function PostInner({
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
|
<TranslatedPost
|
||||||
|
hideTranslateLink={true}
|
||||||
|
post={post}
|
||||||
|
postText={record.text}
|
||||||
|
/>
|
||||||
{post.embed ? (
|
{post.embed ? (
|
||||||
<Embed
|
<Embed
|
||||||
embed={post.embed}
|
embed={post.embed}
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import {Embed} from '#/components/Post/Embed'
|
|||||||
import {PostEmbedViewContext} from '#/components/Post/Embed/types'
|
import {PostEmbedViewContext} from '#/components/Post/Embed/types'
|
||||||
import {PostRepliedTo} from '#/components/Post/PostRepliedTo'
|
import {PostRepliedTo} from '#/components/Post/PostRepliedTo'
|
||||||
import {ShowMoreTextButton} from '#/components/Post/ShowMoreTextButton'
|
import {ShowMoreTextButton} from '#/components/Post/ShowMoreTextButton'
|
||||||
|
import {TranslatedPost} from '#/components/Post/Translated'
|
||||||
import {PostControls} from '#/components/PostControls'
|
import {PostControls} from '#/components/PostControls'
|
||||||
import {DiscoverDebug} from '#/components/PostControls/DiscoverDebug'
|
import {DiscoverDebug} from '#/components/PostControls/DiscoverDebug'
|
||||||
import {RichText} from '#/components/RichText'
|
import {RichText} from '#/components/RichText'
|
||||||
@@ -450,6 +451,14 @@ let PostContent = ({
|
|||||||
: []
|
: []
|
||||||
}, [post, currentAccount?.did, threadgateHiddenReplies])
|
}, [post, currentAccount?.did, threadgateHiddenReplies])
|
||||||
|
|
||||||
|
const record = useMemo<AppBskyFeedPost.Record | undefined>(
|
||||||
|
() =>
|
||||||
|
bsky.validate(post.record, AppBskyFeedPost.validateRecord)
|
||||||
|
? post.record
|
||||||
|
: undefined,
|
||||||
|
[post],
|
||||||
|
)
|
||||||
|
|
||||||
const onPressShowMore = useCallback(() => {
|
const onPressShowMore = useCallback(() => {
|
||||||
setLimitLines(false)
|
setLimitLines(false)
|
||||||
}, [setLimitLines])
|
}, [setLimitLines])
|
||||||
@@ -481,6 +490,13 @@ let PostContent = ({
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
|
{record && (
|
||||||
|
<TranslatedPost
|
||||||
|
hideTranslateLink={true}
|
||||||
|
post={post}
|
||||||
|
postText={record.text}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{postEmbed ? (
|
{postEmbed ? (
|
||||||
<View style={[a.pb_xs]}>
|
<View style={[a.pb_xs]}>
|
||||||
<Embed
|
<Embed
|
||||||
|
|||||||
@@ -3791,10 +3791,10 @@
|
|||||||
resolved "https://registry.yarnpkg.com/@bsky.app/expo-image-crop-tool/-/expo-image-crop-tool-0.5.0.tgz#4308fbde5c15e6be9122601797bc3d9549c95e31"
|
resolved "https://registry.yarnpkg.com/@bsky.app/expo-image-crop-tool/-/expo-image-crop-tool-0.5.0.tgz#4308fbde5c15e6be9122601797bc3d9549c95e31"
|
||||||
integrity sha512-gmhQr2HWTRFyPO00fn5OmtiEVtikXusHMrN5Zoq26pu1VZX3zVE+aoc668etTqrvsQcm2Qu8fo96k5F3Wu+6wg==
|
integrity sha512-gmhQr2HWTRFyPO00fn5OmtiEVtikXusHMrN5Zoq26pu1VZX3zVE+aoc668etTqrvsQcm2Qu8fo96k5F3Wu+6wg==
|
||||||
|
|
||||||
"@bsky.app/expo-translate-text@^0.2.4":
|
"@bsky.app/expo-translate-text@^0.2.7":
|
||||||
version "0.2.4"
|
version "0.2.7"
|
||||||
resolved "https://registry.yarnpkg.com/@bsky.app/expo-translate-text/-/expo-translate-text-0.2.4.tgz#6e7f20f286111ee4d550c0c84f57393fc215a675"
|
resolved "https://registry.yarnpkg.com/@bsky.app/expo-translate-text/-/expo-translate-text-0.2.7.tgz#e34811d0f0300f8808762e5676aa50790ca5d5e8"
|
||||||
integrity sha512-7mvFggNfkJEufI5A3WnjfjdN3H9P6Dpx7CpDkA9npWqA8Cb2icXq3k3nz3MaXGrVKTYiJnytAffYtos7mPoeOg==
|
integrity sha512-J9zctP9hLxX0eustTKk5CBnCkk6cEdlu1s7GzUnpT65qkCSNbYqbbUCpcU2Z2S2dN/1+w6L/iHb+vmCEbZMOaQ==
|
||||||
|
|
||||||
"@bsky.app/react-native-mmkv@2.12.5":
|
"@bsky.app/react-native-mmkv@2.12.5":
|
||||||
version "2.12.5"
|
version "2.12.5"
|
||||||
|
|||||||
Reference in New Issue
Block a user