Use on-device translation on mobile when available (#9930)
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
This commit is contained in:
@@ -85,6 +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/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/react": "^1.1.1",
|
"@emoji-mart/react": "^1.1.1",
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
* Do not import runtime code into this file
|
* Do not import runtime code into this file
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import {type Platform} from 'react-native'
|
||||||
|
|
||||||
import {type NotificationReason} from '#/lib/hooks/useNotificationHandler'
|
import {type NotificationReason} from '#/lib/hooks/useNotificationHandler'
|
||||||
import {type FeedDescriptor} from '#/state/queries/post-feed'
|
import {type FeedDescriptor} from '#/state/queries/post-feed'
|
||||||
import {type LiveEventFeedMetricContext} from '#/features/liveEvents/types'
|
import {type LiveEventFeedMetricContext} from '#/features/liveEvents/types'
|
||||||
@@ -679,6 +681,17 @@ export type Events = {
|
|||||||
targetLanguage: string
|
targetLanguage: string
|
||||||
textLength: number
|
textLength: number
|
||||||
}
|
}
|
||||||
|
'translate:result': {
|
||||||
|
method: 'on-device' | 'google-translate' | 'fallback-alert'
|
||||||
|
os: Platform['OS']
|
||||||
|
sourceLanguage: string | null
|
||||||
|
targetLanguage: string
|
||||||
|
}
|
||||||
|
'translate:override': {
|
||||||
|
os: Platform['OS']
|
||||||
|
sourceLanguage: string
|
||||||
|
targetLanguage: string
|
||||||
|
}
|
||||||
|
|
||||||
'verification:create': {}
|
'verification:create': {}
|
||||||
'verification:revoke': {}
|
'verification:revoke': {}
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import {useMemo} from 'react'
|
||||||
|
import {Platform, View} from 'react-native'
|
||||||
|
import {msg} from '@lingui/core/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
import {Trans} from '@lingui/react/macro'
|
||||||
|
|
||||||
|
import {codeToLanguageName, languageName} from '#/locale/helpers'
|
||||||
|
import {LANGUAGES} from '#/locale/languages'
|
||||||
|
import {useLanguagePrefs} from '#/state/preferences'
|
||||||
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
|
import {Loader} from '#/components/Loader'
|
||||||
|
import * as Select from '#/components/Select'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
import {useAnalytics} from '#/analytics'
|
||||||
|
import {useTranslateOnDevice} from '#/translation'
|
||||||
|
|
||||||
|
export function TranslatedPost({
|
||||||
|
postText,
|
||||||
|
hideLoading = false,
|
||||||
|
}: {
|
||||||
|
postText: string
|
||||||
|
hideLoading: boolean
|
||||||
|
}) {
|
||||||
|
const {translationState} = useTranslateOnDevice()
|
||||||
|
|
||||||
|
if (translationState.status === 'loading' && !hideLoading) {
|
||||||
|
return <TranslationLoading />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (translationState.status === 'success') {
|
||||||
|
return (
|
||||||
|
<TranslationResult
|
||||||
|
postText={postText}
|
||||||
|
sourceLanguage={translationState.sourceLanguage}
|
||||||
|
translatedText={translationState.translatedText}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function TranslationLoading() {
|
||||||
|
const t = useTheme()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[a.flex_row, a.align_center, a.gap_sm, a.py_xs]}>
|
||||||
|
<Loader size="sm" />
|
||||||
|
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
|
||||||
|
<Trans>Translating…</Trans>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TranslationResult({
|
||||||
|
postText,
|
||||||
|
sourceLanguage,
|
||||||
|
translatedText,
|
||||||
|
}: {
|
||||||
|
postText: string
|
||||||
|
sourceLanguage: string | null
|
||||||
|
translatedText: string
|
||||||
|
}) {
|
||||||
|
const t = useTheme()
|
||||||
|
const {i18n} = useLingui()
|
||||||
|
|
||||||
|
const langName = sourceLanguage
|
||||||
|
? codeToLanguageName(sourceLanguage, i18n.locale)
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[a.py_xs, a.gap_xs, a.mt_sm]}>
|
||||||
|
<Text style={[a.text_xs, t.atoms.text_contrast_medium]}>
|
||||||
|
{langName ? (
|
||||||
|
<Trans>Translated from {langName}</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>Translated</Trans>
|
||||||
|
)}
|
||||||
|
{sourceLanguage != null && (
|
||||||
|
<>
|
||||||
|
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
|
||||||
|
{' '}
|
||||||
|
·
|
||||||
|
</Text>{' '}
|
||||||
|
<TranslationLanguageSelect
|
||||||
|
sourceLanguage={sourceLanguage}
|
||||||
|
postText={postText}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
<Text emoji selectable style={[a.text_md, a.leading_snug]}>
|
||||||
|
{translatedText}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TranslationLanguageSelect({
|
||||||
|
postText,
|
||||||
|
sourceLanguage,
|
||||||
|
}: {
|
||||||
|
postText: string
|
||||||
|
sourceLanguage: string
|
||||||
|
}) {
|
||||||
|
const ax = useAnalytics()
|
||||||
|
const {_} = useLingui()
|
||||||
|
const langPrefs = useLanguagePrefs()
|
||||||
|
const {translate} = useTranslateOnDevice()
|
||||||
|
|
||||||
|
const items = useMemo(
|
||||||
|
() =>
|
||||||
|
LANGUAGES.filter(
|
||||||
|
(lang, index, self) =>
|
||||||
|
!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)
|
||||||
|
)
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
languageName(a, langPrefs.appLanguage).localeCompare(
|
||||||
|
languageName(b, langPrefs.appLanguage),
|
||||||
|
langPrefs.appLanguage,
|
||||||
|
), // Localized sort
|
||||||
|
)
|
||||||
|
.map(l => ({
|
||||||
|
label: languageName(l, langPrefs.appLanguage), // The viewer may not be familiar with the source language, so localize the name
|
||||||
|
value: l.code2,
|
||||||
|
})),
|
||||||
|
[langPrefs],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleChangeTranslationLanguage = (sourceLangCode: string) => {
|
||||||
|
ax.metric('translate:override', {
|
||||||
|
os: Platform.OS,
|
||||||
|
sourceLanguage: sourceLangCode,
|
||||||
|
targetLanguage: langPrefs.primaryLanguage,
|
||||||
|
})
|
||||||
|
void translate(postText, langPrefs.primaryLanguage, sourceLangCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Select.Root
|
||||||
|
value={sourceLanguage}
|
||||||
|
onValueChange={handleChangeTranslationLanguage}>
|
||||||
|
<Select.Trigger hitSlop={10} label={_(msg`Change source language`)}>
|
||||||
|
{({props}) => {
|
||||||
|
return (
|
||||||
|
<Text {...props} style={[a.text_xs]}>
|
||||||
|
<Trans>Edit</Trans>
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</Select.Trigger>
|
||||||
|
<Select.Content
|
||||||
|
label={_(msg`Select the source language`)}
|
||||||
|
renderItem={({label, value}) => (
|
||||||
|
<Select.Item value={value} label={label}>
|
||||||
|
<Select.ItemIndicator />
|
||||||
|
<Select.ItemText>{label}</Select.ItemText>
|
||||||
|
</Select.Item>
|
||||||
|
)}
|
||||||
|
items={items}
|
||||||
|
/>
|
||||||
|
</Select.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -219,7 +219,7 @@ let PostMenuItems = ({
|
|||||||
const onToggleThreadMute = () => {
|
const onToggleThreadMute = () => {
|
||||||
try {
|
try {
|
||||||
if (isThreadMuted) {
|
if (isThreadMuted) {
|
||||||
unmuteThread()
|
void unmuteThread()
|
||||||
ax.metric('post:unmute', {
|
ax.metric('post:unmute', {
|
||||||
uri: postUri,
|
uri: postUri,
|
||||||
authorDid: postAuthor.did,
|
authorDid: postAuthor.did,
|
||||||
@@ -228,7 +228,7 @@ let PostMenuItems = ({
|
|||||||
})
|
})
|
||||||
Toast.show(_(msg`You will now receive notifications for this thread`))
|
Toast.show(_(msg`You will now receive notifications for this thread`))
|
||||||
} else {
|
} else {
|
||||||
muteThread()
|
void muteThread()
|
||||||
ax.metric('post:mute', {
|
ax.metric('post:mute', {
|
||||||
uri: postUri,
|
uri: postUri,
|
||||||
authorDid: postAuthor.did,
|
authorDid: postAuthor.did,
|
||||||
@@ -239,7 +239,8 @@ let PostMenuItems = ({
|
|||||||
_(msg`You will no longer receive notifications for this thread`),
|
_(msg`You will no longer receive notifications for this thread`),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (err) {
|
||||||
|
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(
|
||||||
@@ -253,12 +254,12 @@ let PostMenuItems = ({
|
|||||||
const onCopyPostText = () => {
|
const onCopyPostText = () => {
|
||||||
const str = richTextToString(richText, true)
|
const str = richTextToString(richText, true)
|
||||||
|
|
||||||
Clipboard.setStringAsync(str)
|
void Clipboard.setStringAsync(str)
|
||||||
Toast.show(_(msg`Copied to clipboard`), 'clipboard-check')
|
Toast.show(_(msg`Copied to clipboard`), 'clipboard-check')
|
||||||
}
|
}
|
||||||
|
|
||||||
const onPressTranslate = () => {
|
const onPressTranslate = () => {
|
||||||
translate(record.text, langPrefs.primaryLanguage)
|
void translate(record.text, langPrefs.primaryLanguage)
|
||||||
|
|
||||||
if (
|
if (
|
||||||
bsky.dangerousIsType<AppBskyFeedPost.Record>(
|
bsky.dangerousIsType<AppBskyFeedPost.Record>(
|
||||||
@@ -343,7 +344,8 @@ let PostMenuItems = ({
|
|||||||
? _(msg`Quote post was successfully detached`)
|
? _(msg`Quote post was successfully detached`)
|
||||||
: _(msg`Quote post was re-attached`),
|
: _(msg`Quote post was re-attached`),
|
||||||
)
|
)
|
||||||
} catch (e: any) {
|
} catch (err) {
|
||||||
|
const e = err as Error
|
||||||
Toast.show(
|
Toast.show(
|
||||||
_(msg({message: 'Updating quote attachment failed', context: 'toast'})),
|
_(msg({message: 'Updating quote attachment failed', context: 'toast'})),
|
||||||
)
|
)
|
||||||
@@ -380,7 +382,8 @@ let PostMenuItems = ({
|
|||||||
? _(msg`Reply was successfully hidden`)
|
? _(msg`Reply was successfully hidden`)
|
||||||
: _(msg({message: 'Reply visibility updated', context: 'toast'})),
|
: _(msg({message: 'Reply visibility updated', context: 'toast'})),
|
||||||
)
|
)
|
||||||
} catch (e: any) {
|
} catch (err) {
|
||||||
|
const e = err as Error
|
||||||
if (e instanceof MaxHiddenRepliesError) {
|
if (e instanceof MaxHiddenRepliesError) {
|
||||||
Toast.show(
|
Toast.show(
|
||||||
_(
|
_(
|
||||||
@@ -409,7 +412,7 @@ let PostMenuItems = ({
|
|||||||
|
|
||||||
const onPressPin = () => {
|
const onPressPin = () => {
|
||||||
ax.metric(isPinned ? 'post:unpin' : 'post:pin', {})
|
ax.metric(isPinned ? 'post:unpin' : 'post:pin', {})
|
||||||
pinPostMutate({
|
void pinPostMutate({
|
||||||
postUri,
|
postUri,
|
||||||
postCid,
|
postCid,
|
||||||
action: isPinned ? 'unpin' : 'pin',
|
action: isPinned ? 'unpin' : 'pin',
|
||||||
@@ -420,7 +423,8 @@ let PostMenuItems = ({
|
|||||||
try {
|
try {
|
||||||
await queueBlock()
|
await queueBlock()
|
||||||
Toast.show(_(msg({message: 'Account blocked', context: 'toast'})))
|
Toast.show(_(msg({message: 'Account blocked', context: 'toast'})))
|
||||||
} catch (e: any) {
|
} catch (err) {
|
||||||
|
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(_(msg`There was an issue! ${e.toString()}`), 'xmark')
|
||||||
@@ -433,7 +437,8 @@ let PostMenuItems = ({
|
|||||||
try {
|
try {
|
||||||
await queueUnmute()
|
await queueUnmute()
|
||||||
Toast.show(_(msg({message: 'Account unmuted', context: 'toast'})))
|
Toast.show(_(msg({message: 'Account unmuted', context: 'toast'})))
|
||||||
} catch (e: any) {
|
} catch (err) {
|
||||||
|
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(_(msg`There was an issue! ${e.toString()}`), 'xmark')
|
||||||
@@ -443,7 +448,8 @@ let PostMenuItems = ({
|
|||||||
try {
|
try {
|
||||||
await queueMute()
|
await queueMute()
|
||||||
Toast.show(_(msg({message: 'Account muted', context: 'toast'})))
|
Toast.show(_(msg({message: 'Account muted', context: 'toast'})))
|
||||||
} catch (e: any) {
|
} catch (err) {
|
||||||
|
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(_(msg`There was an issue! ${e.toString()}`), 'xmark')
|
||||||
@@ -456,7 +462,7 @@ let PostMenuItems = ({
|
|||||||
const url = `https://docs.google.com/forms/d/e/1FAIpQLSd0QPqhNFksDQf1YyOos7r1ofCLvmrKAH1lU042TaS3GAZaWQ/viewform?entry.1756031717=${toShareUrl(
|
const url = `https://docs.google.com/forms/d/e/1FAIpQLSd0QPqhNFksDQf1YyOos7r1ofCLvmrKAH1lU042TaS3GAZaWQ/viewform?entry.1756031717=${toShareUrl(
|
||||||
href,
|
href,
|
||||||
)}`
|
)}`
|
||||||
openLink(url)
|
void openLink(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
const onSignIn = () => requireSignIn(() => {})
|
const onSignIn = () => requireSignIn(() => {})
|
||||||
@@ -687,7 +693,7 @@ let PostMenuItems = ({
|
|||||||
? _(msg`Unmute account`)
|
? _(msg`Unmute account`)
|
||||||
: _(msg`Mute account`)
|
: _(msg`Mute account`)
|
||||||
}
|
}
|
||||||
onPress={onMuteAuthor}>
|
onPress={() => void onMuteAuthor()}>
|
||||||
<Menu.ItemText>
|
<Menu.ItemText>
|
||||||
{postAuthor.viewer?.muted
|
{postAuthor.viewer?.muted
|
||||||
? _(msg`Unmute account`)
|
? _(msg`Unmute account`)
|
||||||
@@ -796,7 +802,7 @@ let PostMenuItems = ({
|
|||||||
description={_(
|
description={_(
|
||||||
msg`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={onToggleQuotePostAttachment}
|
onConfirm={() => void onToggleQuotePostAttachment()}
|
||||||
confirmButtonCta={_(msg`Yes, detach`)}
|
confirmButtonCta={_(msg`Yes, detach`)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -806,7 +812,7 @@ let PostMenuItems = ({
|
|||||||
description={_(
|
description={_(
|
||||||
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.`,
|
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={onToggleReplyVisibility}
|
onConfirm={() => void onToggleReplyVisibility()}
|
||||||
confirmButtonCta={_(msg`Yes, hide`)}
|
confirmButtonCta={_(msg`Yes, hide`)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -816,7 +822,7 @@ let PostMenuItems = ({
|
|||||||
description={_(
|
description={_(
|
||||||
msg`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={onBlockAuthor}
|
onConfirm={() => void onBlockAuthor()}
|
||||||
confirmButtonCta={_(msg`Block`)}
|
confirmButtonCta={_(msg`Block`)}
|
||||||
confirmButtonColor="negative"
|
confirmButtonColor="negative"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ export function Root({children, value, onValueChange, disabled}: RootProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Trigger({children, label}: TriggerProps) {
|
export function Trigger({children, hitSlop, label}: TriggerProps) {
|
||||||
const {control} = useSelectContext()
|
const {control} = useSelectContext()
|
||||||
const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
|
const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
|
||||||
const {
|
const {
|
||||||
@@ -100,6 +100,7 @@ export function Trigger({children, label}: TriggerProps) {
|
|||||||
} else {
|
} else {
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
|
hitSlop={hitSlop}
|
||||||
label={label}
|
label={label}
|
||||||
onPress={control.open}
|
onPress={control.open}
|
||||||
style={[a.flex_1, a.justify_between, a.pl_lg, a.pr_md]}
|
style={[a.flex_1, a.justify_between, a.pl_lg, a.pr_md]}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
type AccessibilityProps,
|
type AccessibilityProps,
|
||||||
|
type Insets,
|
||||||
type StyleProp,
|
type StyleProp,
|
||||||
type TextStyle,
|
type TextStyle,
|
||||||
type ViewStyle,
|
type ViewStyle,
|
||||||
@@ -60,6 +61,7 @@ export type RadixPassThroughTriggerProps = {
|
|||||||
|
|
||||||
export type TriggerProps = {
|
export type TriggerProps = {
|
||||||
children: React.ReactNode | ((props: TriggerChildProps) => React.ReactNode)
|
children: React.ReactNode | ((props: TriggerChildProps) => React.ReactNode)
|
||||||
|
hitSlop?: number | Insets | null
|
||||||
label: string
|
label: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {msg} from '@lingui/core/macro'
|
|||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
import {Trans} from '@lingui/react/macro'
|
import {Trans} from '@lingui/react/macro'
|
||||||
|
|
||||||
import {languageName} from '#/locale/helpers'
|
|
||||||
import {type Language, LANGUAGES, LANGUAGES_MAP_CODE2} from '#/locale/languages'
|
import {type Language, LANGUAGES, LANGUAGES_MAP_CODE2} from '#/locale/languages'
|
||||||
import {useLanguagePrefs} from '#/state/preferences/languages'
|
import {useLanguagePrefs} from '#/state/preferences/languages'
|
||||||
import {ErrorScreen} from '#/view/com/util/error/ErrorScreen'
|
import {ErrorScreen} from '#/view/com/util/error/ErrorScreen'
|
||||||
@@ -19,6 +18,16 @@ import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Ti
|
|||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
import {IS_LIQUID_GLASS, IS_NATIVE, IS_WEB} from '#/env'
|
import {IS_LIQUID_GLASS, IS_NATIVE, IS_WEB} from '#/env'
|
||||||
|
|
||||||
|
type FlatListItem =
|
||||||
|
| {
|
||||||
|
type: 'header'
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'item'
|
||||||
|
lang: Language
|
||||||
|
}
|
||||||
|
|
||||||
export function LanguageSelectDialog({
|
export function LanguageSelectDialog({
|
||||||
titleText,
|
titleText,
|
||||||
subtitleText,
|
subtitleText,
|
||||||
@@ -270,7 +279,7 @@ export function DialogInner({
|
|||||||
]}
|
]}
|
||||||
style={[IS_NATIVE && a.px_lg, IS_WEB && {paddingBottom: 120}]}
|
style={[IS_NATIVE && a.px_lg, IS_WEB && {paddingBottom: 120}]}
|
||||||
scrollIndicatorInsets={{top: headerHeight, bottom: footerHeight}}
|
scrollIndicatorInsets={{top: headerHeight, bottom: footerHeight}}
|
||||||
renderItem={({item, index}) => {
|
renderItem={({item, index}: {item: FlatListItem; index: number}) => {
|
||||||
if (item.type === 'header') {
|
if (item.type === 'header') {
|
||||||
return (
|
return (
|
||||||
<Text
|
<Text
|
||||||
@@ -295,7 +304,7 @@ export function DialogInner({
|
|||||||
<Toggle.Item
|
<Toggle.Item
|
||||||
key={lang.code2}
|
key={lang.code2}
|
||||||
name={lang.code2}
|
name={lang.code2}
|
||||||
label={languageName(lang, langPrefs.appLanguage)}
|
label={lang.name}
|
||||||
style={[
|
style={[
|
||||||
t.atoms.border_contrast_low,
|
t.atoms.border_contrast_low,
|
||||||
!isLastItem && a.border_b,
|
!isLastItem && a.border_b,
|
||||||
@@ -304,7 +313,7 @@ export function DialogInner({
|
|||||||
a.py_md,
|
a.py_md,
|
||||||
]}>
|
]}>
|
||||||
<Toggle.LabelText style={[a.flex_1]}>
|
<Toggle.LabelText style={[a.flex_1]}>
|
||||||
{languageName(lang, langPrefs.appLanguage)}
|
{lang.name}
|
||||||
</Toggle.LabelText>
|
</Toggle.LabelText>
|
||||||
<Toggle.Checkbox />
|
<Toggle.Checkbox />
|
||||||
</Toggle.Item>
|
</Toggle.Item>
|
||||||
|
|||||||
@@ -1,19 +1,27 @@
|
|||||||
import {useCallback} from 'react'
|
import {useCallback} from 'react'
|
||||||
import * as IntentLauncher from 'expo-intent-launcher'
|
import * as IntentLauncher from 'expo-intent-launcher'
|
||||||
|
|
||||||
|
import {useOpenLink} from '#/lib/hooks/useOpenLink'
|
||||||
import {getTranslatorLink} from '#/locale/helpers'
|
import {getTranslatorLink} from '#/locale/helpers'
|
||||||
import {IS_ANDROID} from '#/env'
|
import {IS_ANDROID} from '#/env'
|
||||||
import {useOpenLink} from './useOpenLink'
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Will always link out to Google Translate. If inline translation is desired,
|
||||||
|
* use `useTranslateOnDevice`
|
||||||
|
*/
|
||||||
export function useTranslate() {
|
export function useTranslate() {
|
||||||
const openLink = useOpenLink()
|
const openLink = useOpenLink()
|
||||||
|
|
||||||
return useCallback(
|
return useCallback(
|
||||||
async (text: string, language: string) => {
|
async (text: string, targetLangCode: string, sourceLanguage?: string) => {
|
||||||
const translateUrl = getTranslatorLink(text, language)
|
const translateUrl = getTranslatorLink(
|
||||||
|
text,
|
||||||
|
targetLangCode,
|
||||||
|
sourceLanguage,
|
||||||
|
)
|
||||||
if (IS_ANDROID) {
|
if (IS_ANDROID) {
|
||||||
try {
|
try {
|
||||||
// use getApplicationIconAsync to determine if the translate app is installed
|
// use `getApplicationIconAsync` to determine if the translate app is installed
|
||||||
if (
|
if (
|
||||||
!(await IntentLauncher.getApplicationIconAsync(
|
!(await IntentLauncher.getApplicationIconAsync(
|
||||||
'com.google.android.apps.translate',
|
'com.google.android.apps.translate',
|
||||||
|
|||||||
@@ -127,8 +127,12 @@ export function isPostInLanguage(
|
|||||||
return bcp47Match.basicFilter(lang, targetLangs).length > 0
|
return bcp47Match.basicFilter(lang, targetLangs).length > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getTranslatorLink(text: string, lang: string): string {
|
export function getTranslatorLink(
|
||||||
return `https://translate.google.com/?sl=auto&tl=${lang}&text=${encodeURIComponent(
|
text: string,
|
||||||
|
targetLangCode: string,
|
||||||
|
sourceLanguage?: string,
|
||||||
|
): string {
|
||||||
|
return `https://translate.google.com/?sl=${sourceLanguage ?? 'auto'}&tl=${targetLangCode}&text=${encodeURIComponent(
|
||||||
text,
|
text,
|
||||||
)}`
|
)}`
|
||||||
}
|
}
|
||||||
|
|||||||
+4011
-498
File diff suppressed because it is too large
Load Diff
@@ -7,17 +7,18 @@ import {
|
|||||||
AtUri,
|
AtUri,
|
||||||
RichText as RichTextAPI,
|
RichText as RichTextAPI,
|
||||||
} from '@atproto/api'
|
} from '@atproto/api'
|
||||||
import {msg} from '@lingui/core/macro'
|
import {Plural, Trans, useLingui} from '@lingui/react/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
|
||||||
import {Plural, Trans} from '@lingui/react/macro'
|
|
||||||
|
|
||||||
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
|
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
|
||||||
import {useTranslate} from '#/lib/hooks/useTranslate'
|
|
||||||
import {makeProfileLink} from '#/lib/routes/links'
|
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 {getTranslatorLink, isPostInLanguage} from '#/locale/helpers'
|
import {
|
||||||
|
getPostLanguage,
|
||||||
|
getTranslatorLink,
|
||||||
|
isPostInLanguage,
|
||||||
|
} from '#/locale/helpers'
|
||||||
import {
|
import {
|
||||||
POST_TOMBSTONE,
|
POST_TOMBSTONE,
|
||||||
type Shadow,
|
type Shadow,
|
||||||
@@ -44,11 +45,13 @@ 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 {InlineLinkText, 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'
|
||||||
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 {TranslatedPost} from '#/components/Post/Translated'
|
||||||
import {PostControls, PostControlsSkeleton} from '#/components/PostControls'
|
import {PostControls, PostControlsSkeleton} from '#/components/PostControls'
|
||||||
import {useFormatPostStatCount} from '#/components/PostControls/util'
|
import {useFormatPostStatCount} from '#/components/PostControls/util'
|
||||||
import {ProfileHoverCard} from '#/components/ProfileHoverCard'
|
import {ProfileHoverCard} from '#/components/ProfileHoverCard'
|
||||||
@@ -60,6 +63,10 @@ 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({
|
||||||
@@ -82,6 +89,7 @@ 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}
|
||||||
@@ -92,6 +100,7 @@ export function ThreadItemAnchor({
|
|||||||
threadgateRecord={threadgateRecord}
|
threadgateRecord={threadgateRecord}
|
||||||
postSource={postSource}
|
postSource={postSource}
|
||||||
/>
|
/>
|
||||||
|
</TranslateOnDeviceProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,7 +187,7 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
|
|||||||
}) {
|
}) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const ax = useAnalytics()
|
const ax = useAnalytics()
|
||||||
const {_} = useLingui()
|
const {t: l} = useLingui()
|
||||||
const {openComposer} = useOpenComposer()
|
const {openComposer} = useOpenComposer()
|
||||||
const {currentAccount, hasSession} = useSession()
|
const {currentAccount, hasSession} = useSession()
|
||||||
const feedFeedback = useFeedFeedback(postSource?.feedSourceInfo, hasSession)
|
const feedFeedback = useFeedFeedback(postSource?.feedSourceInfo, hasSession)
|
||||||
@@ -411,6 +420,8 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
|
|||||||
shouldProxyLinks={true}
|
shouldProxyLinks={true}
|
||||||
/>
|
/>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
|
<TranslatedPost postText={record.text} hideLoading />
|
||||||
|
<TranslateLink post={item.value.post} />
|
||||||
{post.embed && (
|
{post.embed && (
|
||||||
<View style={[a.py_xs]}>
|
<View style={[a.py_xs]}>
|
||||||
<Embed
|
<Embed
|
||||||
@@ -447,7 +458,7 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
|
|||||||
t.atoms.border_contrast_low,
|
t.atoms.border_contrast_low,
|
||||||
]}>
|
]}>
|
||||||
{post.repostCount != null && post.repostCount !== 0 ? (
|
{post.repostCount != null && post.repostCount !== 0 ? (
|
||||||
<Link to={repostsHref} label={_(msg`Reposts of this post`)}>
|
<Link to={repostsHref} label={l`Reposts of this post`}>
|
||||||
<Text
|
<Text
|
||||||
testID="repostCount-expanded"
|
testID="repostCount-expanded"
|
||||||
style={[a.text_md, t.atoms.text_contrast_medium]}>
|
style={[a.text_md, t.atoms.text_contrast_medium]}>
|
||||||
@@ -467,7 +478,7 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
|
|||||||
{post.quoteCount != null &&
|
{post.quoteCount != null &&
|
||||||
post.quoteCount !== 0 &&
|
post.quoteCount !== 0 &&
|
||||||
!post.viewer?.embeddingDisabled ? (
|
!post.viewer?.embeddingDisabled ? (
|
||||||
<Link to={quotesHref} label={_(msg`Quotes of this post`)}>
|
<Link to={quotesHref} label={l`Quotes of this post`}>
|
||||||
<Text
|
<Text
|
||||||
testID="quoteCount-expanded"
|
testID="quoteCount-expanded"
|
||||||
style={[a.text_md, t.atoms.text_contrast_medium]}>
|
style={[a.text_md, t.atoms.text_contrast_medium]}>
|
||||||
@@ -485,7 +496,7 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
|
|||||||
</Link>
|
</Link>
|
||||||
) : null}
|
) : null}
|
||||||
{post.likeCount != null && post.likeCount !== 0 ? (
|
{post.likeCount != null && post.likeCount !== 0 ? (
|
||||||
<Link to={likesHref} label={_(msg`Likes on this post`)}>
|
<Link to={likesHref} label={l`Likes on this post`}>
|
||||||
<Text
|
<Text
|
||||||
testID="likeCount-expanded"
|
testID="likeCount-expanded"
|
||||||
style={[a.text_md, t.atoms.text_contrast_medium]}>
|
style={[a.text_md, t.atoms.text_contrast_medium]}>
|
||||||
@@ -546,20 +557,18 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
function ExpandedPostDetails({
|
function TranslateLink({
|
||||||
post,
|
post,
|
||||||
isThreadAuthor,
|
|
||||||
}: {
|
}: {
|
||||||
post: Extract<ThreadItem, {type: 'threadPost'}>['value']['post']
|
post: Extract<ThreadItem, {type: 'threadPost'}>['value']['post']
|
||||||
isThreadAuthor: boolean
|
|
||||||
}) {
|
}) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const ax = useAnalytics()
|
const ax = useAnalytics()
|
||||||
const {_, i18n} = useLingui()
|
const {t: l} = useLingui()
|
||||||
const translate = useTranslate()
|
|
||||||
const isRootPost = !('reply' in post.record)
|
|
||||||
const langPrefs = useLanguagePrefs()
|
const langPrefs = useLanguagePrefs()
|
||||||
|
|
||||||
|
const {translate, clearTranslation, translationState} = useTranslateOnDevice()
|
||||||
|
|
||||||
const needsTranslation = useMemo(
|
const needsTranslation = useMemo(
|
||||||
() =>
|
() =>
|
||||||
Boolean(
|
Boolean(
|
||||||
@@ -569,10 +578,16 @@ function ExpandedPostDetails({
|
|||||||
[post, langPrefs.primaryLanguage],
|
[post, langPrefs.primaryLanguage],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const sourceLanguage = getPostLanguage(post)
|
||||||
|
|
||||||
const onTranslatePress = useCallback(
|
const onTranslatePress = useCallback(
|
||||||
(e: GestureResponderEvent) => {
|
(e: GestureResponderEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
translate(post.record.text || '', langPrefs.primaryLanguage)
|
void translate(
|
||||||
|
post.record.text || '',
|
||||||
|
langPrefs.primaryLanguage,
|
||||||
|
sourceLanguage,
|
||||||
|
)
|
||||||
|
|
||||||
if (
|
if (
|
||||||
bsky.dangerousIsType<AppBskyFeedPost.Record>(
|
bsky.dangerousIsType<AppBskyFeedPost.Record>(
|
||||||
@@ -589,9 +604,61 @@ function ExpandedPostDetails({
|
|||||||
|
|
||||||
return false
|
return false
|
||||||
},
|
},
|
||||||
[ax, translate, langPrefs, post],
|
[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({
|
||||||
|
post,
|
||||||
|
isThreadAuthor,
|
||||||
|
}: {
|
||||||
|
post: Extract<ThreadItem, {type: 'threadPost'}>['value']['post']
|
||||||
|
isThreadAuthor: boolean
|
||||||
|
}) {
|
||||||
|
const t = useTheme()
|
||||||
|
const {i18n} = useLingui()
|
||||||
|
const isRootPost = !('reply' in post.record)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[a.gap_md, a.pt_md, a.align_start]}>
|
<View style={[a.gap_md, a.pt_md, a.align_start]}>
|
||||||
<BackdatedPostIndicator post={post} />
|
<BackdatedPostIndicator post={post} />
|
||||||
@@ -602,26 +669,6 @@ function ExpandedPostDetails({
|
|||||||
{isRootPost && (
|
{isRootPost && (
|
||||||
<WhoCanReply post={post} isThreadAuthor={isThreadAuthor} />
|
<WhoCanReply post={post} isThreadAuthor={isThreadAuthor} />
|
||||||
)}
|
)}
|
||||||
{needsTranslation && (
|
|
||||||
<>
|
|
||||||
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
|
|
||||||
·
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<InlineLinkText
|
|
||||||
// overridden to open an intent on android, but keep
|
|
||||||
// as anchor tag for accessibility
|
|
||||||
to={getTranslatorLink(
|
|
||||||
post.record.text,
|
|
||||||
langPrefs.primaryLanguage,
|
|
||||||
)}
|
|
||||||
label={_(msg`Translate`)}
|
|
||||||
style={[a.text_sm]}
|
|
||||||
onPress={onTranslatePress}>
|
|
||||||
<Trans>Translate</Trans>
|
|
||||||
</InlineLinkText>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
@@ -629,7 +676,7 @@ function ExpandedPostDetails({
|
|||||||
|
|
||||||
function BackdatedPostIndicator({post}: {post: AppBskyFeedDefs.PostView}) {
|
function BackdatedPostIndicator({post}: {post: AppBskyFeedDefs.PostView}) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {_, i18n} = useLingui()
|
const {t: l, i18n} = useLingui()
|
||||||
const control = Prompt.usePromptControl()
|
const control = Prompt.usePromptControl()
|
||||||
|
|
||||||
const indexedAt = new Date(post.indexedAt)
|
const indexedAt = new Date(post.indexedAt)
|
||||||
@@ -649,10 +696,8 @@ function BackdatedPostIndicator({post}: {post: AppBskyFeedDefs.PostView}) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
label={_(msg`Archived post`)}
|
label={l`Archived post`}
|
||||||
accessibilityHint={_(
|
accessibilityHint={l`Shows information about when this post was created`}
|
||||||
msg`Shows information about when this post was created`,
|
|
||||||
)}
|
|
||||||
onPress={e => {
|
onPress={e => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
@@ -711,7 +756,7 @@ function BackdatedPostIndicator({post}: {post: AppBskyFeedDefs.PostView}) {
|
|||||||
</Prompt.DescriptionText>
|
</Prompt.DescriptionText>
|
||||||
</Prompt.Content>
|
</Prompt.Content>
|
||||||
<Prompt.Actions>
|
<Prompt.Actions>
|
||||||
<Prompt.Action cta={_(msg`Okay`)} onPress={() => {}} />
|
<Prompt.Action cta={l`Okay`} onPress={() => {}} />
|
||||||
</Prompt.Actions>
|
</Prompt.Actions>
|
||||||
</Prompt.Outer>
|
</Prompt.Outer>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export function SearchLanguageDropdown({
|
|||||||
onChange(value: string): void
|
onChange(value: string): void
|
||||||
}) {
|
}) {
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const {appLanguage, contentLanguages} = useLanguagePrefs()
|
const {appLanguage, contentLanguages, primaryLanguage} = useLanguagePrefs()
|
||||||
|
|
||||||
const languages = useMemo(() => {
|
const languages = useMemo(() => {
|
||||||
return LANGUAGES.filter(
|
return LANGUAGES.filter(
|
||||||
@@ -47,19 +47,19 @@ export function SearchLanguageDropdown({
|
|||||||
al =>
|
al =>
|
||||||
// skip `ast`, because it uses a 3-letter code which conflicts with `as`
|
// skip `ast`, because it uses a 3-letter code which conflicts with `as`
|
||||||
// it begins with `a` anyway so still is top of the list
|
// it begins with `a` anyway so still is top of the list
|
||||||
al.code2 !== 'ast' && al.code2.startsWith(a.value),
|
(al.code2 as string) !== 'ast' && al.code2.startsWith(a.value),
|
||||||
)
|
)
|
||||||
const bIsCommon = !!APP_LANGUAGES.find(
|
const bIsCommon = !!APP_LANGUAGES.find(
|
||||||
al =>
|
al =>
|
||||||
// ditto
|
// ditto
|
||||||
al.code2 !== 'ast' && al.code2.startsWith(b.value),
|
(al.code2 as string) !== 'ast' && al.code2.startsWith(b.value),
|
||||||
)
|
)
|
||||||
if (aIsCommon && !bIsCommon) return -1
|
if (aIsCommon && !bIsCommon) return -1
|
||||||
if (bIsCommon && !aIsCommon) return 1
|
if (bIsCommon && !aIsCommon) return 1
|
||||||
// fall back to alphabetical
|
// fall back to alphabetical
|
||||||
return a.label.localeCompare(b.label)
|
return a.label.localeCompare(b.label, primaryLanguage)
|
||||||
})
|
})
|
||||||
}, [appLanguage, contentLanguages])
|
}, [appLanguage, contentLanguages, primaryLanguage])
|
||||||
|
|
||||||
const currentLanguageLabel =
|
const currentLanguageLabel =
|
||||||
languages.find(lang => lang.value === value)?.label ?? _(msg`All languages`)
|
languages.find(lang => lang.value === value)?.label ?? _(msg`All languages`)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
type CommonNavigatorParams,
|
type CommonNavigatorParams,
|
||||||
type NativeStackScreenProps,
|
type NativeStackScreenProps,
|
||||||
} from '#/lib/routes/types'
|
} from '#/lib/routes/types'
|
||||||
import {languageName, sanitizeAppLanguageSetting} from '#/locale/helpers'
|
import {sanitizeAppLanguageSetting} from '#/locale/helpers'
|
||||||
import {APP_LANGUAGES, LANGUAGES} from '#/locale/languages'
|
import {APP_LANGUAGES, LANGUAGES} from '#/locale/languages'
|
||||||
import {useLanguagePrefs, useLanguagePrefsApi} from '#/state/preferences'
|
import {useLanguagePrefs, useLanguagePrefsApi} from '#/state/preferences'
|
||||||
import {atoms as a, web} from '#/alf'
|
import {atoms as a, web} from '#/alf'
|
||||||
@@ -144,8 +144,11 @@ export function LanguageSettingsScreen({}: Props) {
|
|||||||
<Select.ItemText>{label}</Select.ItemText>
|
<Select.ItemText>{label}</Select.ItemText>
|
||||||
</Select.Item>
|
</Select.Item>
|
||||||
)}
|
)}
|
||||||
items={DEDUPED_LANGUAGES.map(l => ({
|
items={DEDUPED_LANGUAGES.sort(
|
||||||
label: languageName(l, langPrefs.appLanguage),
|
(a, b) =>
|
||||||
|
a.name.localeCompare(b.name, langPrefs.appLanguage), // Localized sort
|
||||||
|
).map(l => ({
|
||||||
|
label: l.name, // Pre-generated name using Intl.DisplayNames
|
||||||
value: l.code2,
|
value: l.code2,
|
||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
@@ -177,8 +180,13 @@ export function LanguageSettingsScreen({}: Props) {
|
|||||||
values={langPrefs.contentLanguages}
|
values={langPrefs.contentLanguages}
|
||||||
onChange={setLangPrefs.setContentLanguages}>
|
onChange={setLangPrefs.setContentLanguages}>
|
||||||
<Toggle.PanelGroup>
|
<Toggle.PanelGroup>
|
||||||
{possibleLanguages.map((language, index) => {
|
{possibleLanguages
|
||||||
const name = languageName(language, langPrefs.appLanguage)
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
a.name.localeCompare(b.name, langPrefs.appLanguage), // Localized sort
|
||||||
|
)
|
||||||
|
.map((language, index) => {
|
||||||
|
const name = language.name // Pre-generated name using Intl.DisplayNames
|
||||||
return (
|
return (
|
||||||
<Toggle.Item
|
<Toggle.Item
|
||||||
key={language.code2}
|
key={language.code2}
|
||||||
@@ -196,13 +204,11 @@ export function LanguageSettingsScreen({}: Props) {
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
<Button
|
<Button
|
||||||
label={_(msg`Add more languages...`)}
|
label={_(msg`Add more languages…`)}
|
||||||
onPress={contentLanguagePrefsControl.open}>
|
onPress={contentLanguagePrefsControl.open}>
|
||||||
<Toggle.Panel adjacent="leading">
|
<Toggle.Panel adjacent="leading">
|
||||||
<Toggle.PanelIcon icon={PlusIcon} />
|
<Toggle.PanelIcon icon={PlusIcon} />
|
||||||
<Toggle.PanelText>
|
<Toggle.PanelText>Add more languages…</Toggle.PanelText>
|
||||||
Add more languages...
|
|
||||||
</Toggle.PanelText>
|
|
||||||
</Toggle.Panel>
|
</Toggle.Panel>
|
||||||
</Button>
|
</Button>
|
||||||
</Toggle.PanelGroup>
|
</Toggle.PanelGroup>
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
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'
|
||||||
|
|
||||||
|
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}: {children?: React.ReactNode}) {
|
||||||
|
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) {
|
||||||
|
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>
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import {useCallback} from 'react'
|
||||||
|
import {Platform} from 'react-native'
|
||||||
|
|
||||||
|
import {useOpenLink} from '#/lib/hooks/useOpenLink'
|
||||||
|
import {getTranslatorLink} from '#/locale/helpers'
|
||||||
|
import {useLanguagePrefs} from '#/state/preferences'
|
||||||
|
import {useAnalytics} from '#/analytics'
|
||||||
|
|
||||||
|
const translationState = {status: 'idle'} // No on-device translations for web.
|
||||||
|
|
||||||
|
const clearTranslation = () => {} // no-op on web
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Web always opens Google Translate.
|
||||||
|
*/
|
||||||
|
export function useTranslateOnDevice() {
|
||||||
|
const openLink = useOpenLink()
|
||||||
|
const ax = useAnalytics()
|
||||||
|
const {primaryLanguage} = useLanguagePrefs()
|
||||||
|
|
||||||
|
const translate = useCallback(
|
||||||
|
async (
|
||||||
|
text: string,
|
||||||
|
targetLangCode: string = primaryLanguage,
|
||||||
|
sourceLangCode: string,
|
||||||
|
) => {
|
||||||
|
const translateUrl = getTranslatorLink(
|
||||||
|
text,
|
||||||
|
targetLangCode,
|
||||||
|
sourceLangCode,
|
||||||
|
)
|
||||||
|
ax.metric('translate:result', {
|
||||||
|
method: 'google-translate',
|
||||||
|
os: Platform.OS,
|
||||||
|
sourceLanguage: sourceLangCode ?? null,
|
||||||
|
targetLanguage: targetLangCode,
|
||||||
|
})
|
||||||
|
await openLink(translateUrl)
|
||||||
|
},
|
||||||
|
[ax, openLink, primaryLanguage],
|
||||||
|
)
|
||||||
|
return {clearTranslation, translate, translationState}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import {msg} from '@lingui/core/macro'
|
|||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
import type React from 'react'
|
import type React from 'react'
|
||||||
|
|
||||||
|
import {HITSLOP_10} from '#/lib/constants'
|
||||||
import {useKawaiiMode} from '#/state/preferences/kawaii'
|
import {useKawaiiMode} from '#/state/preferences/kawaii'
|
||||||
import {useSession} from '#/state/session'
|
import {useSession} from '#/state/session'
|
||||||
import {useShellLayout} from '#/state/shell/shell-layout'
|
import {useShellLayout} from '#/state/shell/shell-layout'
|
||||||
@@ -53,7 +54,7 @@ function HomeHeaderLayoutDesktopAndTablet({
|
|||||||
</View>
|
</View>
|
||||||
<Link
|
<Link
|
||||||
to="/feeds"
|
to="/feeds"
|
||||||
hitSlop={10}
|
hitSlop={HITSLOP_10}
|
||||||
label={_(msg`View your feeds and explore more`)}
|
label={_(msg`View your feeds and explore more`)}
|
||||||
size="small"
|
size="small"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@@ -3731,6 +3731,11 @@
|
|||||||
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":
|
||||||
|
version "0.2.4"
|
||||||
|
resolved "https://registry.yarnpkg.com/@bsky.app/expo-translate-text/-/expo-translate-text-0.2.4.tgz#6e7f20f286111ee4d550c0c84f57393fc215a675"
|
||||||
|
integrity sha512-7mvFggNfkJEufI5A3WnjfjdN3H9P6Dpx7CpDkA9npWqA8Cb2icXq3k3nz3MaXGrVKTYiJnytAffYtos7mPoeOg==
|
||||||
|
|
||||||
"@bsky.app/react-native-mmkv@2.12.5":
|
"@bsky.app/react-native-mmkv@2.12.5":
|
||||||
version "2.12.5"
|
version "2.12.5"
|
||||||
resolved "https://registry.yarnpkg.com/@bsky.app/react-native-mmkv/-/react-native-mmkv-2.12.5.tgz#eb17d31a6158c74393f617a1763ac223ff3f83a6"
|
resolved "https://registry.yarnpkg.com/@bsky.app/react-native-mmkv/-/react-native-mmkv-2.12.5.tgz#eb17d31a6158c74393f617a1763ac223ff3f83a6"
|
||||||
|
|||||||
Reference in New Issue
Block a user