Refine post translation UX
This commit is contained in:
@@ -1,38 +1,41 @@
|
||||
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 {Trans, useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {HITSLOP_30} from '#/lib/constants'
|
||||
import {useTranslateOnDevice, useTranslationKey} from '#/lib/translation'
|
||||
import {codeToLanguageName, languageName} from '#/locale/helpers'
|
||||
import {LANGUAGES} from '#/locale/languages'
|
||||
import {useLanguagePrefs} from '#/state/preferences'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {atoms as a, native, useTheme} from '#/alf'
|
||||
import {Button} from '#/components/Button'
|
||||
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({
|
||||
translationKey,
|
||||
postText,
|
||||
hideLoading = false,
|
||||
}: {
|
||||
translationKey: string
|
||||
postText: string
|
||||
hideLoading: boolean
|
||||
}) {
|
||||
const {translationState} = useTranslateOnDevice()
|
||||
// Register this component as using this translation key with focus-based cleanup
|
||||
useTranslationKey(translationKey)
|
||||
|
||||
if (translationState.status === 'loading' && !hideLoading) {
|
||||
if (translationState[translationKey]?.status === 'loading') {
|
||||
return <TranslationLoading />
|
||||
}
|
||||
|
||||
if (translationState.status === 'success') {
|
||||
if (translationState[translationKey]?.status === 'success') {
|
||||
return (
|
||||
<TranslationResult
|
||||
translationKey={translationKey}
|
||||
postText={postText}
|
||||
sourceLanguage={translationState.sourceLanguage}
|
||||
translatedText={translationState.translatedText}
|
||||
sourceLanguage={translationState[translationKey]?.sourceLanguage}
|
||||
translatedText={translationState[translationKey]?.translatedText}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -44,20 +47,24 @@ 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 style={[a.gap_md, a.pt_md, a.align_start]}>
|
||||
<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>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function TranslationResult({
|
||||
translationKey,
|
||||
postText,
|
||||
sourceLanguage,
|
||||
translatedText,
|
||||
}: {
|
||||
translationKey: string
|
||||
postText: string
|
||||
sourceLanguage: string | null
|
||||
translatedText: string
|
||||
@@ -71,25 +78,28 @@ function TranslationResult({
|
||||
|
||||
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>
|
||||
)}
|
||||
<View style={[a.flex_row, a.align_center]}>
|
||||
<Text style={[a.text_xs, t.atoms.text_contrast_medium]}>
|
||||
{langName ? (
|
||||
<Trans>Translated from {langName}</Trans>
|
||||
) : (
|
||||
<Trans>Translated</Trans>
|
||||
)}
|
||||
</Text>
|
||||
{sourceLanguage != null && (
|
||||
<>
|
||||
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
|
||||
{' '}
|
||||
·
|
||||
</Text>{' '}
|
||||
·{' '}
|
||||
</Text>
|
||||
<TranslationLanguageSelect
|
||||
sourceLanguage={sourceLanguage}
|
||||
translationKey={translationKey}
|
||||
postText={postText}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
</View>
|
||||
<Text emoji selectable style={[a.text_md, a.leading_snug]}>
|
||||
{translatedText}
|
||||
</Text>
|
||||
@@ -98,14 +108,16 @@ function TranslationResult({
|
||||
}
|
||||
|
||||
function TranslationLanguageSelect({
|
||||
translationKey,
|
||||
postText,
|
||||
sourceLanguage,
|
||||
}: {
|
||||
translationKey: string
|
||||
postText: string
|
||||
sourceLanguage: string
|
||||
}) {
|
||||
const ax = useAnalytics()
|
||||
const {_} = useLingui()
|
||||
const {t: l} = useLingui()
|
||||
const langPrefs = useLanguagePrefs()
|
||||
const {translate} = useTranslateOnDevice()
|
||||
|
||||
@@ -136,24 +148,35 @@ function TranslationLanguageSelect({
|
||||
sourceLanguage: sourceLangCode,
|
||||
targetLanguage: langPrefs.primaryLanguage,
|
||||
})
|
||||
void translate(postText, langPrefs.primaryLanguage, sourceLangCode)
|
||||
void translate(
|
||||
translationKey,
|
||||
postText,
|
||||
langPrefs.primaryLanguage,
|
||||
sourceLangCode,
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Select.Root
|
||||
value={sourceLanguage}
|
||||
onValueChange={handleChangeTranslationLanguage}>
|
||||
<Select.Trigger hitSlop={10} label={_(msg`Change source language`)}>
|
||||
<Select.Trigger label={l`Change the source language`}>
|
||||
{({props}) => {
|
||||
return (
|
||||
<Text {...props} style={[a.text_xs]}>
|
||||
<Trans>Change</Trans>
|
||||
</Text>
|
||||
<Button
|
||||
label={props.accessibilityLabel}
|
||||
{...props}
|
||||
hitSlop={HITSLOP_30}
|
||||
hoverStyle={native({opacity: 0.5})}>
|
||||
<Text style={[a.text_xs]}>
|
||||
<Trans>Change</Trans>
|
||||
</Text>
|
||||
</Button>
|
||||
)
|
||||
}}
|
||||
</Select.Trigger>
|
||||
<Select.Content
|
||||
label={_(msg`Select the source language`)}
|
||||
label={l`Select the source language`}
|
||||
renderItem={({label, value}) => (
|
||||
<Select.Item value={value} label={label}>
|
||||
<Select.ItemIndicator />
|
||||
|
||||
@@ -13,13 +13,12 @@ import {
|
||||
AtUri,
|
||||
type RichText as RichTextAPI,
|
||||
} from '@atproto/api'
|
||||
import {msg, plural} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {plural} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react/macro'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
|
||||
import {DISCOVER_DEBUG_DIDS} from '#/lib/constants'
|
||||
import {useOpenLink} from '#/lib/hooks/useOpenLink'
|
||||
import {useTranslate} from '#/lib/hooks/useTranslate'
|
||||
import {getCurrentRoute} from '#/lib/routes/helpers'
|
||||
import {makeProfileLink} from '#/lib/routes/links'
|
||||
import {
|
||||
@@ -28,6 +27,7 @@ import {
|
||||
} from '#/lib/routes/types'
|
||||
import {richTextToString} from '#/lib/strings/rich-text-helpers'
|
||||
import {toShareUrl} from '#/lib/strings/url-helpers'
|
||||
import {useTranslateOnDevice} from '#/lib/translation'
|
||||
import {logger} from '#/logger'
|
||||
import {type Shadow} from '#/state/cache/post-shadow'
|
||||
import {useProfileShadow} from '#/state/cache/profile-shadow'
|
||||
@@ -106,6 +106,7 @@ let PostMenuItems = ({
|
||||
threadgateRecord,
|
||||
onShowLess,
|
||||
logContext,
|
||||
googleTranslate,
|
||||
}: {
|
||||
testID: string
|
||||
post: Shadow<AppBskyFeedDefs.PostView>
|
||||
@@ -120,9 +121,10 @@ let PostMenuItems = ({
|
||||
threadgateRecord?: AppBskyFeedThreadgate.Record
|
||||
onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void
|
||||
logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
|
||||
googleTranslate: boolean
|
||||
}): React.ReactNode => {
|
||||
const {hasSession, currentAccount} = useSession()
|
||||
const {_} = useLingui()
|
||||
const {t: l} = useLingui()
|
||||
const ax = useAnalytics()
|
||||
const langPrefs = useLanguagePrefs()
|
||||
const {mutateAsync: deletePostMutate} = usePostDeleteMutation()
|
||||
@@ -133,7 +135,7 @@ let PostMenuItems = ({
|
||||
const {hidePost} = useHiddenPostsApi()
|
||||
const feedFeedback = useFeedFeedbackContext()
|
||||
const openLink = useOpenLink()
|
||||
const translate = useTranslate()
|
||||
const {clearTranslation, translate, translationState} = useTranslateOnDevice()
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
const {mutedWordsDialogControl} = useGlobalDialogsControlContext()
|
||||
const blockPromptControl = useDialogControl()
|
||||
@@ -188,10 +190,12 @@ let PostMenuItems = ({
|
||||
return makeProfileLink(postAuthor, 'post', urip.rkey)
|
||||
}, [postUri, postAuthor])
|
||||
|
||||
const translationKey = post.uri
|
||||
|
||||
const onDeletePost = () => {
|
||||
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())
|
||||
if (route.name === 'PostThread') {
|
||||
@@ -211,7 +215,7 @@ let PostMenuItems = ({
|
||||
},
|
||||
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 +230,7 @@ let PostMenuItems = ({
|
||||
logContext,
|
||||
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 {
|
||||
void muteThread()
|
||||
ax.metric('post:mute', {
|
||||
@@ -235,18 +239,13 @@ let PostMenuItems = ({
|
||||
logContext,
|
||||
feedDescriptor: feedFeedback.feedDescriptor,
|
||||
})
|
||||
Toast.show(
|
||||
_(msg`You will no longer receive notifications for this thread`),
|
||||
)
|
||||
Toast.show(l`You will no longer receive notifications for this thread`)
|
||||
}
|
||||
} catch (err) {
|
||||
const e = err as Error
|
||||
if (e?.name !== 'AbortError') {
|
||||
logger.error('Failed to toggle thread mute', {message: e})
|
||||
Toast.show(
|
||||
_(msg`Failed to toggle thread mute, please try again`),
|
||||
'xmark',
|
||||
)
|
||||
Toast.show(l`Failed to toggle thread mute, please try again`, 'xmark')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -255,11 +254,19 @@ let PostMenuItems = ({
|
||||
const str = richTextToString(richText, true)
|
||||
|
||||
void Clipboard.setStringAsync(str)
|
||||
Toast.show(_(msg`Copied to clipboard`), 'clipboard-check')
|
||||
Toast.show(l`Copied to clipboard`, 'clipboard-check')
|
||||
}
|
||||
|
||||
const onPressTranslate = () => {
|
||||
void translate(record.text, langPrefs.primaryLanguage)
|
||||
void translate(
|
||||
translationKey,
|
||||
record.text,
|
||||
langPrefs.primaryLanguage,
|
||||
undefined,
|
||||
{
|
||||
googleTranslate,
|
||||
},
|
||||
)
|
||||
|
||||
if (
|
||||
bsky.dangerousIsType<AppBskyFeedPost.Record>(
|
||||
@@ -297,9 +304,7 @@ let PostMenuItems = ({
|
||||
logContext,
|
||||
feedDescriptor: feedFeedback.feedDescriptor,
|
||||
})
|
||||
Toast.show(
|
||||
_(msg({message: 'Feedback sent to feed operator', context: 'toast'})),
|
||||
)
|
||||
Toast.show(l({message: 'Feedback sent to feed operator', context: 'toast'}))
|
||||
}
|
||||
|
||||
const onPressShowLess = () => {
|
||||
@@ -322,7 +327,7 @@ let PostMenuItems = ({
|
||||
})
|
||||
} else {
|
||||
Toast.show(
|
||||
_(msg({message: 'Feedback sent to feed operator', context: 'toast'})),
|
||||
l({message: 'Feedback sent to feed operator', context: 'toast'}),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -341,13 +346,13 @@ let PostMenuItems = ({
|
||||
})
|
||||
Toast.show(
|
||||
isDetach
|
||||
? _(msg`Quote post was successfully detached`)
|
||||
: _(msg`Quote post was re-attached`),
|
||||
? l`Quote post was successfully detached`
|
||||
: l`Quote post was re-attached`,
|
||||
)
|
||||
} catch (err) {
|
||||
const e = err as Error
|
||||
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})
|
||||
}
|
||||
@@ -379,31 +384,27 @@ let PostMenuItems = ({
|
||||
|
||||
Toast.show(
|
||||
isHide
|
||||
? _(msg`Reply was successfully hidden`)
|
||||
: _(msg({message: 'Reply visibility updated', context: 'toast'})),
|
||||
? l`Reply was successfully hidden`
|
||||
: l({message: 'Reply visibility updated', context: 'toast'}),
|
||||
)
|
||||
} catch (err) {
|
||||
const e = err as Error
|
||||
if (e instanceof MaxHiddenRepliesError) {
|
||||
Toast.show(
|
||||
_(
|
||||
plural(MAX_HIDDEN_REPLIES, {
|
||||
other: 'You can hide a maximum of # replies.',
|
||||
}),
|
||||
),
|
||||
plural(MAX_HIDDEN_REPLIES, {
|
||||
other: 'You can hide a maximum of # replies.',
|
||||
}),
|
||||
)
|
||||
} else if (e instanceof InvalidInteractionSettingsError) {
|
||||
Toast.show(
|
||||
_(msg({message: 'Invalid interaction settings.', context: 'toast'})),
|
||||
l({message: 'Invalid interaction settings.', context: 'toast'}),
|
||||
)
|
||||
} else {
|
||||
Toast.show(
|
||||
_(
|
||||
msg({
|
||||
message: 'Updating reply visibility failed',
|
||||
context: 'toast',
|
||||
}),
|
||||
),
|
||||
l({
|
||||
message: 'Updating reply visibility failed',
|
||||
context: 'toast',
|
||||
}),
|
||||
)
|
||||
logger.error(`Failed to ${action} reply`, {safeMessage: e.message})
|
||||
}
|
||||
@@ -422,12 +423,12 @@ let PostMenuItems = ({
|
||||
const onBlockAuthor = async () => {
|
||||
try {
|
||||
await queueBlock()
|
||||
Toast.show(_(msg({message: 'Account blocked', context: 'toast'})))
|
||||
Toast.show(l({message: 'Account blocked', context: 'toast'}))
|
||||
} catch (err) {
|
||||
const e = err as Error
|
||||
if (e?.name !== 'AbortError') {
|
||||
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 +437,23 @@ let PostMenuItems = ({
|
||||
if (postAuthor.viewer?.muted) {
|
||||
try {
|
||||
await queueUnmute()
|
||||
Toast.show(_(msg({message: 'Account unmuted', context: 'toast'})))
|
||||
Toast.show(l({message: 'Account unmuted', context: 'toast'}))
|
||||
} catch (err) {
|
||||
const e = err as Error
|
||||
if (e?.name !== 'AbortError') {
|
||||
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 {
|
||||
try {
|
||||
await queueMute()
|
||||
Toast.show(_(msg({message: 'Account muted', context: 'toast'})))
|
||||
Toast.show(l({message: 'Account muted', context: 'toast'}))
|
||||
} catch (err) {
|
||||
const e = err as Error
|
||||
if (e?.name !== 'AbortError') {
|
||||
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 +468,8 @@ let PostMenuItems = ({
|
||||
|
||||
const onSignIn = () => requireSignIn(() => {})
|
||||
|
||||
const onPressHideTranslation = () => clearTranslation(translationKey)
|
||||
|
||||
const isDiscoverDebugUser =
|
||||
IS_INTERNAL ||
|
||||
DISCOVER_DEBUG_DIDS[currentAccount?.did || ''] ||
|
||||
@@ -481,16 +484,12 @@ let PostMenuItems = ({
|
||||
<Menu.Item
|
||||
testID="pinPostBtn"
|
||||
label={
|
||||
isPinned
|
||||
? _(msg`Unpin from profile`)
|
||||
: _(msg`Pin to your profile`)
|
||||
isPinned ? l`Unpin from profile` : l`Pin to your profile`
|
||||
}
|
||||
disabled={isPinPending}
|
||||
onPress={onPressPin}>
|
||||
<Menu.ItemText>
|
||||
{isPinned
|
||||
? _(msg`Unpin from profile`)
|
||||
: _(msg`Pin to your profile`)}
|
||||
{isPinned ? l`Unpin from profile` : l`Pin to your profile`}
|
||||
</Menu.ItemText>
|
||||
<Menu.ItemIcon
|
||||
icon={isPinPending ? Loader : PinIcon}
|
||||
@@ -505,28 +504,46 @@ let PostMenuItems = ({
|
||||
<Menu.Group>
|
||||
{!hideInPWI || hasSession ? (
|
||||
<>
|
||||
<Menu.Item
|
||||
testID="postDropdownTranslateBtn"
|
||||
label={_(msg`Translate`)}
|
||||
onPress={onPressTranslate}>
|
||||
<Menu.ItemText>{_(msg`Translate`)}</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={Translate} position="right" />
|
||||
</Menu.Item>
|
||||
{translationState[translationKey]?.status === 'loading' ? (
|
||||
<Menu.Item
|
||||
testID="postDropdownTranslateBtn"
|
||||
label={l`Translating…`}
|
||||
onPress={() => {}}>
|
||||
<Menu.ItemText>{l`Translating…`}</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={Translate} position="right" />
|
||||
</Menu.Item>
|
||||
) : translationState[translationKey]?.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
|
||||
testID="postDropdownCopyTextBtn"
|
||||
label={_(msg`Copy post text`)}
|
||||
label={l`Copy post text`}
|
||||
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.Item>
|
||||
</>
|
||||
) : (
|
||||
<Menu.Item
|
||||
testID="postDropdownSignInBtn"
|
||||
label={_(msg`Sign in to view post`)}
|
||||
label={l`Sign in to view post`}
|
||||
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.Item>
|
||||
)}
|
||||
@@ -538,17 +555,17 @@ let PostMenuItems = ({
|
||||
<Menu.Group>
|
||||
<Menu.Item
|
||||
testID="postDropdownShowMoreBtn"
|
||||
label={_(msg`Show more like this`)}
|
||||
label={l`Show more like this`}
|
||||
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.Item>
|
||||
|
||||
<Menu.Item
|
||||
testID="postDropdownShowLessBtn"
|
||||
label={_(msg`Show less like this`)}
|
||||
label={l`Show less like this`}
|
||||
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.Item>
|
||||
</Menu.Group>
|
||||
@@ -560,9 +577,9 @@ let PostMenuItems = ({
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
testID="postDropdownReportMisclassificationBtn"
|
||||
label={_(msg`Assign topic for algo`)}
|
||||
label={l`Assign topic for algo`}
|
||||
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.Item>
|
||||
</>
|
||||
@@ -574,12 +591,10 @@ let PostMenuItems = ({
|
||||
<Menu.Group>
|
||||
<Menu.Item
|
||||
testID="postDropdownMuteThreadBtn"
|
||||
label={
|
||||
isThreadMuted ? _(msg`Unmute thread`) : _(msg`Mute thread`)
|
||||
}
|
||||
label={isThreadMuted ? l`Unmute thread` : l`Mute thread`}
|
||||
onPress={onToggleThreadMute}>
|
||||
<Menu.ItemText>
|
||||
{isThreadMuted ? _(msg`Unmute thread`) : _(msg`Mute thread`)}
|
||||
{isThreadMuted ? l`Unmute thread` : l`Mute thread`}
|
||||
</Menu.ItemText>
|
||||
<Menu.ItemIcon
|
||||
icon={isThreadMuted ? Unmute : Mute}
|
||||
@@ -589,9 +604,9 @@ let PostMenuItems = ({
|
||||
|
||||
<Menu.Item
|
||||
testID="postDropdownMuteWordsBtn"
|
||||
label={_(msg`Mute words & tags`)}
|
||||
label={l`Mute words & tags`}
|
||||
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.Item>
|
||||
</Menu.Group>
|
||||
@@ -606,16 +621,10 @@ let PostMenuItems = ({
|
||||
{canHidePostForMe && (
|
||||
<Menu.Item
|
||||
testID="postDropdownHideBtn"
|
||||
label={
|
||||
isReply
|
||||
? _(msg`Hide reply for me`)
|
||||
: _(msg`Hide post for me`)
|
||||
}
|
||||
label={isReply ? l`Hide reply for me` : l`Hide post for me`}
|
||||
onPress={() => hidePromptControl.open()}>
|
||||
<Menu.ItemText>
|
||||
{isReply
|
||||
? _(msg`Hide reply for me`)
|
||||
: _(msg`Hide post for me`)}
|
||||
{isReply ? l`Hide reply for me` : l`Hide post for me`}
|
||||
</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={EyeSlash} position="right" />
|
||||
</Menu.Item>
|
||||
@@ -625,8 +634,8 @@ let PostMenuItems = ({
|
||||
testID="postDropdownHideBtn"
|
||||
label={
|
||||
isReplyHiddenByThreadgate
|
||||
? _(msg`Show reply for everyone`)
|
||||
: _(msg`Hide reply for everyone`)
|
||||
? l`Show reply for everyone`
|
||||
: l`Hide reply for everyone`
|
||||
}
|
||||
onPress={
|
||||
isReplyHiddenByThreadgate
|
||||
@@ -635,8 +644,8 @@ let PostMenuItems = ({
|
||||
}>
|
||||
<Menu.ItemText>
|
||||
{isReplyHiddenByThreadgate
|
||||
? _(msg`Show reply for everyone`)
|
||||
: _(msg`Hide reply for everyone`)}
|
||||
? l`Show reply for everyone`
|
||||
: l`Hide reply for everyone`}
|
||||
</Menu.ItemText>
|
||||
<Menu.ItemIcon
|
||||
icon={isReplyHiddenByThreadgate ? Eye : EyeSlash}
|
||||
@@ -651,8 +660,8 @@ let PostMenuItems = ({
|
||||
testID="postDropdownHideBtn"
|
||||
label={
|
||||
quoteEmbed.isDetached
|
||||
? _(msg`Re-attach quote`)
|
||||
: _(msg`Detach quote`)
|
||||
? l`Re-attach quote`
|
||||
: l`Detach quote`
|
||||
}
|
||||
onPress={
|
||||
quoteEmbed.isDetached
|
||||
@@ -661,8 +670,8 @@ let PostMenuItems = ({
|
||||
}>
|
||||
<Menu.ItemText>
|
||||
{quoteEmbed.isDetached
|
||||
? _(msg`Re-attach quote`)
|
||||
: _(msg`Detach quote`)}
|
||||
? l`Re-attach quote`
|
||||
: l`Detach quote`}
|
||||
</Menu.ItemText>
|
||||
<Menu.ItemIcon
|
||||
icon={
|
||||
@@ -690,14 +699,14 @@ let PostMenuItems = ({
|
||||
testID="postDropdownMuteBtn"
|
||||
label={
|
||||
postAuthor.viewer?.muted
|
||||
? _(msg`Unmute account`)
|
||||
: _(msg`Mute account`)
|
||||
? l`Unmute account`
|
||||
: l`Mute account`
|
||||
}
|
||||
onPress={() => void onMuteAuthor()}>
|
||||
<Menu.ItemText>
|
||||
{postAuthor.viewer?.muted
|
||||
? _(msg`Unmute account`)
|
||||
: _(msg`Mute account`)}
|
||||
? l`Unmute account`
|
||||
: l`Mute account`}
|
||||
</Menu.ItemText>
|
||||
<Menu.ItemIcon
|
||||
icon={postAuthor.viewer?.muted ? UnmuteIcon : MuteIcon}
|
||||
@@ -708,18 +717,18 @@ let PostMenuItems = ({
|
||||
{!postAuthor.viewer?.blocking && (
|
||||
<Menu.Item
|
||||
testID="postDropdownBlockBtn"
|
||||
label={_(msg`Block account`)}
|
||||
label={l`Block account`}
|
||||
onPress={() => blockPromptControl.open()}>
|
||||
<Menu.ItemText>{_(msg`Block account`)}</Menu.ItemText>
|
||||
<Menu.ItemText>{l`Block account`}</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={PersonX} position="right" />
|
||||
</Menu.Item>
|
||||
)}
|
||||
|
||||
<Menu.Item
|
||||
testID="postDropdownReportBtn"
|
||||
label={_(msg`Report post`)}
|
||||
label={l`Report post`}
|
||||
onPress={() => reportDialogControl.open()}>
|
||||
<Menu.ItemText>{_(msg`Report post`)}</Menu.ItemText>
|
||||
<Menu.ItemText>{l`Report post`}</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={Warning} position="right" />
|
||||
</Menu.Item>
|
||||
</>
|
||||
@@ -729,7 +738,7 @@ let PostMenuItems = ({
|
||||
<>
|
||||
<Menu.Item
|
||||
testID="postDropdownEditPostInteractions"
|
||||
label={_(msg`Edit interaction settings`)}
|
||||
label={l`Edit interaction settings`}
|
||||
onPress={() => postInteractionSettingsDialogControl.open()}
|
||||
{...(isAuthor
|
||||
? Platform.select({
|
||||
@@ -742,15 +751,15 @@ let PostMenuItems = ({
|
||||
})
|
||||
: {})}>
|
||||
<Menu.ItemText>
|
||||
{_(msg`Edit interaction settings`)}
|
||||
{l`Edit interaction settings`}
|
||||
</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={Gear} position="right" />
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
testID="postDropdownDeleteBtn"
|
||||
label={_(msg`Delete post`)}
|
||||
label={l`Delete post`}
|
||||
onPress={() => deletePromptControl.open()}>
|
||||
<Menu.ItemText>{_(msg`Delete post`)}</Menu.ItemText>
|
||||
<Menu.ItemText>{l`Delete post`}</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={Trash} position="right" />
|
||||
</Menu.Item>
|
||||
</>
|
||||
@@ -759,28 +768,21 @@ let PostMenuItems = ({
|
||||
</>
|
||||
)}
|
||||
</Menu.Outer>
|
||||
|
||||
<Prompt.Basic
|
||||
control={deletePromptControl}
|
||||
title={_(msg`Delete this post?`)}
|
||||
description={_(
|
||||
msg`If you remove this post, you won't be able to recover it.`,
|
||||
)}
|
||||
title={l`Delete this post?`}
|
||||
description={l`If you remove this post, you won't be able to recover it.`}
|
||||
onConfirm={onDeletePost}
|
||||
confirmButtonCta={_(msg`Delete`)}
|
||||
confirmButtonCta={l`Delete`}
|
||||
confirmButtonColor="negative"
|
||||
/>
|
||||
|
||||
<Prompt.Basic
|
||||
control={hidePromptControl}
|
||||
title={isReply ? _(msg`Hide this reply?`) : _(msg`Hide this post?`)}
|
||||
description={_(
|
||||
msg`This post will be hidden from feeds and threads. This cannot be undone.`,
|
||||
)}
|
||||
title={isReply ? l`Hide this reply?` : l`Hide this post?`}
|
||||
description={l`This post will be hidden from feeds and threads. This cannot be undone.`}
|
||||
onConfirm={onHidePost}
|
||||
confirmButtonCta={_(msg`Hide`)}
|
||||
confirmButtonCta={l`Hide`}
|
||||
/>
|
||||
|
||||
<ReportDialog
|
||||
control={reportDialogControl}
|
||||
subject={{
|
||||
@@ -788,42 +790,32 @@ let PostMenuItems = ({
|
||||
$type: 'app.bsky.feed.defs#postView',
|
||||
}}
|
||||
/>
|
||||
|
||||
<PostInteractionSettingsDialog
|
||||
control={postInteractionSettingsDialogControl}
|
||||
postUri={post.uri}
|
||||
rootPostUri={rootUri}
|
||||
initialThreadgateView={post.threadgate}
|
||||
/>
|
||||
|
||||
<Prompt.Basic
|
||||
control={quotePostDetachConfirmControl}
|
||||
title={_(msg`Detach quote post?`)}
|
||||
description={_(
|
||||
msg`This will remove your post from this quote post for all users, and replace it with a placeholder.`,
|
||||
)}
|
||||
title={l`Detach quote post?`}
|
||||
description={l`This will remove your post from this quote post for all users, and replace it with a placeholder.`}
|
||||
onConfirm={() => void onToggleQuotePostAttachment()}
|
||||
confirmButtonCta={_(msg`Yes, detach`)}
|
||||
confirmButtonCta={l`Yes, detach`}
|
||||
/>
|
||||
|
||||
<Prompt.Basic
|
||||
control={hideReplyConfirmControl}
|
||||
title={_(msg`Hide this reply?`)}
|
||||
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.`,
|
||||
)}
|
||||
title={l`Hide this reply?`}
|
||||
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.`}
|
||||
onConfirm={() => void onToggleReplyVisibility()}
|
||||
confirmButtonCta={_(msg`Yes, hide`)}
|
||||
confirmButtonCta={l`Yes, hide`}
|
||||
/>
|
||||
|
||||
<Prompt.Basic
|
||||
control={blockPromptControl}
|
||||
title={_(msg`Block Account?`)}
|
||||
description={_(
|
||||
msg`Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`,
|
||||
)}
|
||||
title={l`Block Account?`}
|
||||
description={l`Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`}
|
||||
onConfirm={() => void onBlockAuthor()}
|
||||
confirmButtonCta={_(msg`Block`)}
|
||||
confirmButtonCta={l`Block`}
|
||||
confirmButtonColor="negative"
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -6,8 +6,7 @@ import {
|
||||
type AppBskyFeedThreadgate,
|
||||
type RichText as RichTextAPI,
|
||||
} from '@atproto/api'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {type Shadow} from '#/state/cache/post-shadow'
|
||||
import {EventStopper} from '#/view/com/util/EventStopper'
|
||||
@@ -30,6 +29,7 @@ let PostMenuButton = ({
|
||||
onShowLess,
|
||||
hitSlop,
|
||||
logContext,
|
||||
googleTranslate,
|
||||
}: {
|
||||
testID: string
|
||||
post: Shadow<AppBskyFeedDefs.PostView>
|
||||
@@ -43,8 +43,9 @@ let PostMenuButton = ({
|
||||
onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void
|
||||
hitSlop?: Insets
|
||||
logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
|
||||
googleTranslate: boolean
|
||||
}): React.ReactNode => {
|
||||
const {_} = useLingui()
|
||||
const {t: l} = useLingui()
|
||||
|
||||
const menuControl = useMenuControl()
|
||||
const [hasBeenOpen, setHasBeenOpen] = useState(false)
|
||||
@@ -63,7 +64,7 @@ let PostMenuButton = ({
|
||||
return (
|
||||
<EventStopper onKeyDown={false}>
|
||||
<Menu.Root control={lazyMenuControl}>
|
||||
<Menu.Trigger label={_(msg`Open post options menu`)}>
|
||||
<Menu.Trigger label={l`Open post options menu`}>
|
||||
{({props}) => {
|
||||
return (
|
||||
<PostControlButton
|
||||
@@ -90,6 +91,7 @@ let PostMenuButton = ({
|
||||
threadgateRecord={threadgateRecord}
|
||||
onShowLess={onShowLess}
|
||||
logContext={logContext}
|
||||
googleTranslate={googleTranslate}
|
||||
/>
|
||||
)}
|
||||
</Menu.Root>
|
||||
|
||||
@@ -6,8 +6,8 @@ import {
|
||||
type AppBskyFeedThreadgate,
|
||||
type RichText as RichTextAPI,
|
||||
} from '@atproto/api'
|
||||
import {msg, plural} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {plural} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {CountWheel} from '#/lib/custom-animations/CountWheel'
|
||||
import {AnimatedLikeIcon} from '#/lib/custom-animations/LikeIcon'
|
||||
@@ -55,6 +55,7 @@ let PostControls = ({
|
||||
onShowLess,
|
||||
viaRepost,
|
||||
variant,
|
||||
googleTranslate = true,
|
||||
}: {
|
||||
big?: boolean
|
||||
post: Shadow<AppBskyFeedDefs.PostView>
|
||||
@@ -70,9 +71,10 @@ let PostControls = ({
|
||||
onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void
|
||||
viaRepost?: {uri: string; cid: string}
|
||||
variant?: 'compact' | 'normal' | 'large'
|
||||
googleTranslate?: boolean
|
||||
}): React.ReactNode => {
|
||||
const ax = useAnalytics()
|
||||
const {_} = useLingui()
|
||||
const {t: l} = useLingui()
|
||||
const {openComposer} = useOpenComposer()
|
||||
const {feedDescriptor} = useFeedFeedbackContext()
|
||||
const [queueLike, queueUnlike] = usePostLikeMutationQueue(
|
||||
@@ -104,10 +106,7 @@ let PostControls = ({
|
||||
|
||||
const onPressToggleLike = async () => {
|
||||
if (isBlocked) {
|
||||
Toast.show(
|
||||
_(msg`Cannot interact with a blocked user`),
|
||||
'exclamation-circle',
|
||||
)
|
||||
Toast.show(l`Cannot interact with a blocked user`, 'exclamation-circle')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -135,10 +134,7 @@ let PostControls = ({
|
||||
|
||||
const onRepost = async () => {
|
||||
if (isBlocked) {
|
||||
Toast.show(
|
||||
_(msg`Cannot interact with a blocked user`),
|
||||
'exclamation-circle',
|
||||
)
|
||||
Toast.show(l`Cannot interact with a blocked user`, 'exclamation-circle')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -163,10 +159,7 @@ let PostControls = ({
|
||||
|
||||
const onQuote = () => {
|
||||
if (isBlocked) {
|
||||
Toast.show(
|
||||
_(msg`Cannot interact with a blocked user`),
|
||||
'exclamation-circle',
|
||||
)
|
||||
Toast.show(l`Cannot interact with a blocked user`, 'exclamation-circle')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -238,16 +231,14 @@ let PostControls = ({
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
label={_(
|
||||
msg({
|
||||
message: `Reply (${plural(post.replyCount || 0, {
|
||||
one: '# reply',
|
||||
other: '# replies',
|
||||
})})`,
|
||||
comment:
|
||||
'Accessibility label for the reply button, verb form followed by number of replies and noun form',
|
||||
}),
|
||||
)}
|
||||
label={l({
|
||||
message: `Reply (${plural(post.replyCount || 0, {
|
||||
one: '# reply',
|
||||
other: '# replies',
|
||||
})})`,
|
||||
comment:
|
||||
'Accessibility label for the reply button, verb form followed by number of replies and noun form',
|
||||
})}
|
||||
big={big}>
|
||||
<PostControlButtonIcon icon={Bubble} />
|
||||
{typeof post.replyCount !== 'undefined' && post.replyCount > 0 && (
|
||||
@@ -274,26 +265,22 @@ let PostControls = ({
|
||||
onPress={() => requireAuth(() => onPressToggleLike())}
|
||||
label={
|
||||
post.viewer?.like
|
||||
? _(
|
||||
msg({
|
||||
message: `Unlike (${plural(post.likeCount || 0, {
|
||||
one: '# like',
|
||||
other: '# likes',
|
||||
})})`,
|
||||
comment:
|
||||
'Accessibility label for the like button when the post has been liked, verb followed by number of likes and noun',
|
||||
}),
|
||||
)
|
||||
: _(
|
||||
msg({
|
||||
message: `Like (${plural(post.likeCount || 0, {
|
||||
one: '# like',
|
||||
other: '# likes',
|
||||
})})`,
|
||||
comment:
|
||||
'Accessibility label for the like button when the post has not been liked, verb form followed by number of likes and noun form',
|
||||
}),
|
||||
)
|
||||
? l({
|
||||
message: `Unlike (${plural(post.likeCount || 0, {
|
||||
one: '# like',
|
||||
other: '# likes',
|
||||
})})`,
|
||||
comment:
|
||||
'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',
|
||||
other: '# likes',
|
||||
})})`,
|
||||
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
|
||||
isLiked={Boolean(post.viewer?.like)}
|
||||
@@ -350,6 +337,7 @@ let PostControls = ({
|
||||
left: secondaryControlSpacingStyles.gap / 2,
|
||||
}}
|
||||
logContext={logContext}
|
||||
googleTranslate={googleTranslate}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import {createContext} from 'react'
|
||||
|
||||
import {type Options, type TranslationState} from './types'
|
||||
|
||||
export const Context = createContext<{
|
||||
translationState: Record<string, TranslationState>
|
||||
translate: (
|
||||
key: string,
|
||||
text: string,
|
||||
targetLangCode: string,
|
||||
sourceLangCode?: string,
|
||||
options?: Options,
|
||||
) => Promise<void>
|
||||
clearTranslation: (key: string) => void
|
||||
acquireTranslation: (key: string) => () => void
|
||||
} | null>(null)
|
||||
Context.displayName = 'TranslationContext'
|
||||
@@ -1,32 +1,16 @@
|
||||
import React, {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react'
|
||||
import {useCallback, useContext, useEffect, 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 {useFocusEffect} from '@react-navigation/native'
|
||||
|
||||
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'}
|
||||
import {Context} from './context'
|
||||
import {type Options, type TranslationState} from './types'
|
||||
|
||||
/**
|
||||
* Attempts on-device translation via @bsky.app/expo-translate-text.
|
||||
@@ -87,21 +71,6 @@ async function attemptTranslation(
|
||||
}
|
||||
}
|
||||
|
||||
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).
|
||||
@@ -120,25 +89,108 @@ export function useTranslateOnDevice() {
|
||||
return context
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to register a component as using a translation key.
|
||||
* Automatically handles ref counting with screen focus management
|
||||
* via useFocusEffect and cleans up the translation when the component
|
||||
* loses focus.
|
||||
*/
|
||||
export function useTranslationKey(key: string) {
|
||||
const {acquireTranslation} = useTranslateOnDevice()
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
const cleanup = acquireTranslation(key)
|
||||
return cleanup
|
||||
}, [key, acquireTranslation]),
|
||||
)
|
||||
}
|
||||
|
||||
export function Provider({children}: React.PropsWithChildren<unknown>) {
|
||||
const [translationState, setTranslationState] =
|
||||
useState<TranslationState>(IDLE)
|
||||
const [translationState, setTranslationState] = useState<
|
||||
Record<string, TranslationState>
|
||||
>({})
|
||||
const [refCounts, setRefCounts] = useState<Record<string, number>>({})
|
||||
const openLink = useOpenLink()
|
||||
const ax = useAnalytics()
|
||||
const {primaryLanguage} = useLanguagePrefs()
|
||||
|
||||
const clearTranslation = useCallback(() => {
|
||||
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(IDLE)
|
||||
setTranslationState(prev => {
|
||||
delete prev[key]
|
||||
return {...prev}
|
||||
})
|
||||
}, [])
|
||||
|
||||
const translate = useCallback(
|
||||
async (
|
||||
key: string,
|
||||
text: string,
|
||||
targetLangCode: string = primaryLanguage,
|
||||
sourceLangCode?: string,
|
||||
options?: Options,
|
||||
) => {
|
||||
setTranslationState({status: 'loading'})
|
||||
const translateUrl = getTranslatorLink(
|
||||
text,
|
||||
targetLangCode,
|
||||
sourceLangCode,
|
||||
)
|
||||
if (options?.googleTranslate) {
|
||||
ax.metric('translate:result', {
|
||||
method: 'google-translate',
|
||||
os: Platform.OS,
|
||||
sourceLanguage: sourceLangCode ?? null,
|
||||
targetLanguage: targetLangCode,
|
||||
})
|
||||
await openLink(translateUrl)
|
||||
return
|
||||
}
|
||||
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
|
||||
setTranslationState(prev => ({
|
||||
...prev,
|
||||
[key]: {status: 'loading'},
|
||||
}))
|
||||
try {
|
||||
const result = await attemptTranslation(
|
||||
text,
|
||||
@@ -152,47 +204,39 @@ export function Provider({children}: React.PropsWithChildren<unknown>) {
|
||||
targetLanguage: result.targetLanguage,
|
||||
})
|
||||
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
|
||||
setTranslationState({
|
||||
status: 'success',
|
||||
translatedText: result.translatedText,
|
||||
sourceLanguage: result.sourceLanguage,
|
||||
targetLanguage: result.targetLanguage,
|
||||
})
|
||||
setTranslationState(prev => ({
|
||||
...prev,
|
||||
[key]: {
|
||||
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,
|
||||
)
|
||||
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,
|
||||
})
|
||||
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
|
||||
setTranslationState(prev => ({
|
||||
...prev,
|
||||
[key]: {status: 'idle'},
|
||||
}))
|
||||
await openLink(translateUrl)
|
||||
}
|
||||
},
|
||||
[ax, openLink, primaryLanguage, setTranslationState],
|
||||
[ax, openLink, primaryLanguage],
|
||||
)
|
||||
|
||||
const ctx = useMemo(
|
||||
() => ({clearTranslation, translate, translationState}),
|
||||
[clearTranslation, translate, translationState],
|
||||
() => ({acquireTranslation, clearTranslation, translate, translationState}),
|
||||
[acquireTranslation, clearTranslation, translate, translationState],
|
||||
)
|
||||
|
||||
return <Context.Provider value={ctx}>{children}</Context.Provider>
|
||||
@@ -0,0 +1,66 @@
|
||||
import {useCallback, useContext, useMemo} from 'react'
|
||||
|
||||
import {useOpenLink} from '#/lib/hooks/useOpenLink'
|
||||
import {getTranslatorLink} from '#/locale/helpers'
|
||||
import {useLanguagePrefs} from '#/state/preferences'
|
||||
import {useAnalytics} from '#/analytics'
|
||||
import {Context} from './context'
|
||||
import {type Options, type TranslationState} from './types'
|
||||
|
||||
const translationState: Record<string, TranslationState> = {}
|
||||
const acquireTranslation = (_key: string) => {
|
||||
return () => {}
|
||||
}
|
||||
const clearTranslation = (_key: string) => {}
|
||||
|
||||
export function useTranslationKey(_key: string) {}
|
||||
|
||||
/**
|
||||
* Web 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 openLink = useOpenLink()
|
||||
const ax = useAnalytics()
|
||||
const {primaryLanguage} = useLanguagePrefs()
|
||||
|
||||
const translate = useCallback(
|
||||
async (
|
||||
_key: string,
|
||||
text: string,
|
||||
targetLangCode: string = primaryLanguage,
|
||||
sourceLangCode?: string,
|
||||
_options?: Options,
|
||||
) => {
|
||||
ax.metric('translate:result', {
|
||||
method: 'google-translate',
|
||||
os: 'web',
|
||||
sourceLanguage: sourceLangCode ?? null,
|
||||
targetLanguage: targetLangCode,
|
||||
})
|
||||
const translateUrl = getTranslatorLink(
|
||||
text,
|
||||
targetLangCode,
|
||||
sourceLangCode,
|
||||
)
|
||||
await openLink(translateUrl)
|
||||
},
|
||||
[ax, openLink, primaryLanguage],
|
||||
)
|
||||
|
||||
const ctx = useMemo(
|
||||
() => ({acquireTranslation, clearTranslation, translate, translationState}),
|
||||
[translate],
|
||||
)
|
||||
|
||||
return <Context.Provider value={ctx}>{children}</Context.Provider>
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
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']
|
||||
}
|
||||
|
||||
export type Options = {
|
||||
/**
|
||||
* Whether to force the use of Google Translate. Default is false.
|
||||
*/
|
||||
googleTranslate?: boolean
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
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 {
|
||||
AppBskyFeedDefs,
|
||||
AppBskyFeedPost,
|
||||
@@ -14,11 +14,6 @@ import {makeProfileLink} from '#/lib/routes/links'
|
||||
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||
import {niceDate} from '#/lib/strings/time'
|
||||
import {
|
||||
getPostLanguage,
|
||||
getTranslatorLink,
|
||||
isPostInLanguage,
|
||||
} from '#/locale/helpers'
|
||||
import {
|
||||
POST_TOMBSTONE,
|
||||
type Shadow,
|
||||
@@ -26,7 +21,6 @@ import {
|
||||
} from '#/state/cache/post-shadow'
|
||||
import {useProfileShadow} from '#/state/cache/profile-shadow'
|
||||
import {FeedFeedbackProvider, useFeedFeedback} from '#/state/feed-feedback'
|
||||
import {useLanguagePrefs} from '#/state/preferences'
|
||||
import {type ThreadItem} from '#/state/queries/usePostThread/types'
|
||||
import {useSession} from '#/state/session'
|
||||
import {type OnPostSuccessData} from '#/state/shell/composer'
|
||||
@@ -44,8 +38,7 @@ import {Button} from '#/components/Button'
|
||||
import {DebugFieldDisplay} from '#/components/DebugFieldDisplay'
|
||||
import {CalendarClock_Stroke2_Corner0_Rounded as CalendarClockIcon} from '#/components/icons/CalendarClock'
|
||||
import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
|
||||
import {InlineLinkText, Link} from '#/components/Link'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {Link} from '#/components/Link'
|
||||
import {ContentHider} from '#/components/moderation/ContentHider'
|
||||
import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe'
|
||||
import {PostAlerts} from '#/components/moderation/PostAlerts'
|
||||
@@ -63,10 +56,6 @@ import {VerificationCheckButton} from '#/components/verification/VerificationChe
|
||||
import {WhoCanReply} from '#/components/WhoCanReply'
|
||||
import {useAnalytics} from '#/analytics'
|
||||
import {useActorStatus} from '#/features/liveNow'
|
||||
import {
|
||||
Provider as TranslateOnDeviceProvider,
|
||||
useTranslateOnDevice,
|
||||
} from '#/translation'
|
||||
import * as bsky from '#/types/bsky'
|
||||
|
||||
export function ThreadItemAnchor({
|
||||
@@ -89,18 +78,16 @@ export function ThreadItemAnchor({
|
||||
}
|
||||
|
||||
return (
|
||||
<TranslateOnDeviceProvider>
|
||||
<ThreadItemAnchorInner
|
||||
// Safeguard from clobbering per-post state below:
|
||||
key={postShadow.uri}
|
||||
item={item}
|
||||
isRoot={isRoot}
|
||||
postShadow={postShadow}
|
||||
onPostSuccess={onPostSuccess}
|
||||
threadgateRecord={threadgateRecord}
|
||||
postSource={postSource}
|
||||
/>
|
||||
</TranslateOnDeviceProvider>
|
||||
<ThreadItemAnchorInner
|
||||
// Safeguard from clobbering per-post state below:
|
||||
key={postShadow.uri}
|
||||
item={item}
|
||||
isRoot={isRoot}
|
||||
postShadow={postShadow}
|
||||
onPostSuccess={onPostSuccess}
|
||||
threadgateRecord={threadgateRecord}
|
||||
postSource={postSource}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -325,6 +312,8 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
|
||||
}
|
||||
}
|
||||
|
||||
const translationKey = post.uri
|
||||
|
||||
return (
|
||||
<>
|
||||
<ThreadItemAnchorParentReplyLine isRoot={isRoot} />
|
||||
@@ -420,8 +409,10 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
|
||||
shouldProxyLinks={true}
|
||||
/>
|
||||
) : undefined}
|
||||
<TranslatedPost postText={record.text} hideLoading />
|
||||
<TranslateLink post={item.value.post} />
|
||||
<TranslatedPost
|
||||
translationKey={translationKey}
|
||||
postText={record.text}
|
||||
/>
|
||||
{post.embed && (
|
||||
<View style={[a.py_xs]}>
|
||||
<Embed
|
||||
@@ -547,6 +538,7 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
|
||||
feedContext={postSource?.post?.feedContext}
|
||||
reqId={postSource?.post?.reqId}
|
||||
viaRepost={viaRepost}
|
||||
googleTranslate={false}
|
||||
/>
|
||||
</FeedFeedbackProvider>
|
||||
</View>
|
||||
@@ -557,97 +549,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({
|
||||
post,
|
||||
isThreadAuthor,
|
||||
|
||||
@@ -33,6 +33,7 @@ import {PostAlerts} from '#/components/moderation/PostAlerts'
|
||||
import {Embed, PostEmbedViewContext} from '#/components/Post/Embed'
|
||||
import {PostRepliedTo} from '#/components/Post/PostRepliedTo'
|
||||
import {ShowMoreTextButton} from '#/components/Post/ShowMoreTextButton'
|
||||
import {TranslatedPost} from '#/components/Post/Translated'
|
||||
import {PostControls} from '#/components/PostControls'
|
||||
import {RichText} from '#/components/RichText'
|
||||
import {SubtleHover} from '#/components/SubtleHover'
|
||||
@@ -152,6 +153,9 @@ function PostInner({
|
||||
}, [queryClient, post.author, outerOnBeforePress])
|
||||
|
||||
const [hover, setHover] = useState(false)
|
||||
|
||||
const translationKey = post.uri
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={itemHref}
|
||||
@@ -215,6 +219,10 @@ function PostInner({
|
||||
onPress={onPressShowMore}
|
||||
/>
|
||||
)}
|
||||
<TranslatedPost
|
||||
translationKey={translationKey}
|
||||
postText={record.text}
|
||||
/>
|
||||
</View>
|
||||
) : undefined}
|
||||
{post.embed ? (
|
||||
@@ -231,6 +239,7 @@ function PostInner({
|
||||
richText={richText}
|
||||
onPressReply={onPressReply}
|
||||
logContext="Post"
|
||||
googleTranslate={false}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -11,6 +11,7 @@ import {useIntentHandler} from '#/lib/hooks/useIntentHandler'
|
||||
import {useNotificationsHandler} from '#/lib/hooks/useNotificationHandler'
|
||||
import {useNotificationsRegistration} from '#/lib/notifications/notifications'
|
||||
import {isStateAtTabRoot} from '#/lib/routes/helpers'
|
||||
import {Provider as TranslateOnDeviceProvider} from '#/lib/translation'
|
||||
import {useDialogFullyExpandedCountContext} from '#/state/dialogs'
|
||||
import {useSession} from '#/state/session'
|
||||
import {
|
||||
@@ -240,7 +241,9 @@ export function Shell() {
|
||||
<NoAccessScreen />
|
||||
) : (
|
||||
<RoutesContainer>
|
||||
<ShellInner />
|
||||
<TranslateOnDeviceProvider>
|
||||
<ShellInner />
|
||||
</TranslateOnDeviceProvider>
|
||||
</RoutesContainer>
|
||||
)}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import {RemoveScrollBar} from 'react-remove-scroll-bar'
|
||||
|
||||
import {useIntentHandler} from '#/lib/hooks/useIntentHandler'
|
||||
import {type NavigationProp} from '#/lib/routes/types'
|
||||
import {Provider as TranslateOnDeviceProvider} from '#/lib/translation'
|
||||
import {useSession} from '#/state/session'
|
||||
import {useIsDrawerOpen, useSetDrawerOpen} from '#/state/shell'
|
||||
import {useComposerKeyboardShortcut} from '#/state/shell/composer/useComposerKeyboardShortcut'
|
||||
@@ -175,7 +176,9 @@ export function Shell() {
|
||||
<NoAccessScreen />
|
||||
) : (
|
||||
<RoutesContainer>
|
||||
<ShellInner />
|
||||
<TranslateOnDeviceProvider>
|
||||
<ShellInner />
|
||||
</TranslateOnDeviceProvider>
|
||||
</RoutesContainer>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user