Replace lande with native language detection (#9974)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: DS Boyce <260543580+ds-boyce@users.noreply.github.com> Co-authored-by: Eric Bailey <git@esb.lol>
This commit is contained in:
+7
-3
@@ -61,9 +61,13 @@ jest.mock('expo-media-library', () => ({
|
|||||||
usePermissions: jest.fn(() => [true]),
|
usePermissions: jest.fn(() => [true]),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
jest.mock('lande', () => ({
|
jest.mock('@bsky.app/expo-guess-language', () => ({
|
||||||
__esModule: true, // this property makes it work
|
guessLanguageSync: jest
|
||||||
default: jest.fn().mockReturnValue([['eng']]),
|
.fn()
|
||||||
|
.mockReturnValue([{language: 'en', confidence: 1}]),
|
||||||
|
guessLanguageAsync: jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue([{language: 'en', confidence: 1}]),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
jest.mock('sentry-expo', () => ({
|
jest.mock('sentry-expo', () => ({
|
||||||
|
|||||||
@@ -82,9 +82,11 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@atproto/api": "^0.19.10",
|
"@atproto/api": "^0.19.10",
|
||||||
|
"@atproto/syntax": "0.5.2",
|
||||||
"@bitdrift/react-native": "^0.6.8",
|
"@bitdrift/react-native": "^0.6.8",
|
||||||
"@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-guess-language": "^0.2.8",
|
||||||
"@bsky.app/expo-image-crop-tool": "^0.5.0",
|
"@bsky.app/expo-image-crop-tool": "^0.5.0",
|
||||||
"@bsky.app/expo-scroll-edge-effect": "^0.1.4",
|
"@bsky.app/expo-scroll-edge-effect": "^0.1.4",
|
||||||
"@bsky.app/expo-translate-text": "^0.2.9",
|
"@bsky.app/expo-translate-text": "^0.2.9",
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ export enum Features {
|
|||||||
GroupChatsEnable = 'group_chats:enable',
|
GroupChatsEnable = 'group_chats:enable',
|
||||||
GroupChatsHasBeenReleased = 'group_chats:has_been_released',
|
GroupChatsHasBeenReleased = 'group_chats:has_been_released',
|
||||||
DmsNewMessageComposerEnable = 'dms:new_message_composer:enable',
|
DmsNewMessageComposerEnable = 'dms:new_message_composer:enable',
|
||||||
|
ComposerLanguageDetectionEnable = 'composer:language_detection:enable',
|
||||||
KlipyGifProviderEnable = 'klipy_gif_provider:enable',
|
KlipyGifProviderEnable = 'klipy_gif_provider:enable',
|
||||||
PostGalleryEmbedEnable = 'post_gallery_embed:enable',
|
PostGalleryEmbedEnable = 'post_gallery_embed:enable',
|
||||||
|
|
||||||
AATest = 'aa-test',
|
AATest = 'aa-test',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -800,6 +800,100 @@ export type Events = {
|
|||||||
*/
|
*/
|
||||||
resultSourceLanguage: string
|
resultSourceLanguage: string
|
||||||
}
|
}
|
||||||
|
'composer:language:suggestLanguage': {
|
||||||
|
os: Platform['OS']
|
||||||
|
/**
|
||||||
|
* The language we detected and suggested to the user as an override for the
|
||||||
|
* expected target language.
|
||||||
|
*/
|
||||||
|
suggestedLanguage: string | undefined
|
||||||
|
/**
|
||||||
|
* This is the user's current composer languages, which are always defined.
|
||||||
|
*/
|
||||||
|
currentTargetLanguages: string[]
|
||||||
|
/**
|
||||||
|
* The length of the text being translated. We assume shorter texts are
|
||||||
|
* more likely to have inaccurate translations.
|
||||||
|
*/
|
||||||
|
textLength: number
|
||||||
|
}
|
||||||
|
'composer:language:acceptSuggestion': {
|
||||||
|
os: Platform['OS']
|
||||||
|
/**
|
||||||
|
* The language we detected and suggested to the user as an override for the
|
||||||
|
* expected target language.
|
||||||
|
*/
|
||||||
|
suggestedLanguage: string | undefined
|
||||||
|
/**
|
||||||
|
* This is the user's current composer languages, which are always defined.
|
||||||
|
*/
|
||||||
|
currentTargetLanguages: string[]
|
||||||
|
/**
|
||||||
|
* The length of the text being translated. We assume shorter texts are
|
||||||
|
* more likely to have inaccurate translations.
|
||||||
|
*/
|
||||||
|
textLength: number
|
||||||
|
}
|
||||||
|
'composer:language:declineSuggestion': {
|
||||||
|
os: Platform['OS']
|
||||||
|
/**
|
||||||
|
* The language we detected and suggested to the user as an override for the
|
||||||
|
* expected target language.
|
||||||
|
*/
|
||||||
|
suggestedLanguage: string | undefined
|
||||||
|
/**
|
||||||
|
* This is the user's current composer languages, which are always defined.
|
||||||
|
*/
|
||||||
|
currentTargetLanguages: string[]
|
||||||
|
/**
|
||||||
|
* The length of the text being translated. We assume shorter texts are
|
||||||
|
* more likely to have inaccurate translations.
|
||||||
|
*/
|
||||||
|
textLength: number
|
||||||
|
}
|
||||||
|
'composer:language:replyNudgeAccept': {
|
||||||
|
/**
|
||||||
|
* The language of the post the user is replying to.
|
||||||
|
*/
|
||||||
|
replyToLanguage: string
|
||||||
|
/**
|
||||||
|
* This is the user's current composer languages, which are always defined.
|
||||||
|
*/
|
||||||
|
currentTargetLanguages: string[]
|
||||||
|
}
|
||||||
|
'composer:language:replyNudgeDecline': {
|
||||||
|
/**
|
||||||
|
* The language of the post the user is replying to.
|
||||||
|
*/
|
||||||
|
replyToLanguage: string
|
||||||
|
/**
|
||||||
|
* This is the user's current composer languages, which are always defined.
|
||||||
|
*/
|
||||||
|
currentTargetLanguages: string[]
|
||||||
|
}
|
||||||
|
'composer:language:nudgeUser': {
|
||||||
|
os: Platform['OS']
|
||||||
|
/**
|
||||||
|
* The language we detected and suggested to the user as an override for the
|
||||||
|
* expected target language.
|
||||||
|
*/
|
||||||
|
suggestedLanguage: string | undefined
|
||||||
|
/**
|
||||||
|
* This is the user's current composer languages, which are always defined.
|
||||||
|
*/
|
||||||
|
currentTargetLanguages: string[]
|
||||||
|
/**
|
||||||
|
* The length of the text being translated. We assume shorter texts are
|
||||||
|
* more likely to have inaccurate translations.
|
||||||
|
*/
|
||||||
|
textLength: number
|
||||||
|
}
|
||||||
|
'composer:language:langSelectorPressed': {
|
||||||
|
/**
|
||||||
|
* If the user was nudged by our language detection to update their language
|
||||||
|
*/
|
||||||
|
wasNudged: boolean
|
||||||
|
}
|
||||||
|
|
||||||
'postMenu:openMuteWordsDialog': {
|
'postMenu:openMuteWordsDialog': {
|
||||||
uri: string
|
uri: string
|
||||||
|
|||||||
@@ -1,17 +1,25 @@
|
|||||||
import {useCallback, useInsertionEffect, useRef} from 'react'
|
import {useCallback, useInsertionEffect, useRef} from 'react'
|
||||||
|
|
||||||
// This should be used sparingly. It erases reactivity, i.e. when the inputs
|
const noop = () => {}
|
||||||
// change, the function itself will remain the same. This means that if you
|
|
||||||
// use this at a higher level of your tree, and then some state you read in it
|
/**
|
||||||
// changes, there is no mechanism for anything below in the tree to "react"
|
* This should be used sparingly. It erases reactivity, i.e. when the inputs
|
||||||
// to this change (e.g. by knowing to call your function again).
|
* change, the function itself will remain the same. This means that if you use
|
||||||
//
|
* this at a higher level of your tree, and then some state you read in it
|
||||||
// Also, you should avoid calling the returned function during rendering
|
* changes, there is no mechanism for anything below in the tree to "react" to
|
||||||
// since the values captured by it are going to lag behind.
|
* this change (e.g. by knowing to call your function again).
|
||||||
export function useNonReactiveCallback<T extends Function>(fn: T): T {
|
*
|
||||||
const ref = useRef(fn)
|
* Also, you should avoid calling the returned function during rendering since
|
||||||
|
* the values captured by it are going to lag behind.
|
||||||
|
*
|
||||||
|
* For objects, see `useNonReactiveObject` instead.
|
||||||
|
*/
|
||||||
|
export function useNonReactiveCallback<T extends Function = () => void>(
|
||||||
|
fn?: T,
|
||||||
|
): T {
|
||||||
|
const ref = useRef<T>((fn ?? noop) as T)
|
||||||
useInsertionEffect(() => {
|
useInsertionEffect(() => {
|
||||||
ref.current = fn
|
ref.current = (fn ?? noop) as T
|
||||||
}, [fn])
|
}, [fn])
|
||||||
return useCallback(
|
return useCallback(
|
||||||
(...args: any) => {
|
(...args: any) => {
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import {useInsertionEffect, useRef} from 'react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This should be used sparingly. It erases reactivity, i.e. when the inputs
|
||||||
|
* change, the returned object itself will remain the same. This means that if
|
||||||
|
* you use this at a higher level of your tree, and then some state you read in
|
||||||
|
* it changes, there is no mechanism for anything below in the tree to "react"
|
||||||
|
* to this change (e.g. by knowing to call your function again).
|
||||||
|
*
|
||||||
|
* For callbacks, see `useNonReactiveCallback` instead.
|
||||||
|
*/
|
||||||
|
export function useNonReactiveObject<T extends Record<string, unknown>>(
|
||||||
|
o: T,
|
||||||
|
): React.RefObject<T> {
|
||||||
|
const ref = useRef(o)
|
||||||
|
useInsertionEffect(() => {
|
||||||
|
ref.current = o
|
||||||
|
}, [o])
|
||||||
|
return ref
|
||||||
|
}
|
||||||
@@ -268,6 +268,20 @@ export const ComposePost = ({
|
|||||||
setReplyToLanguages([])
|
setReplyToLanguages([])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Timestamp (ms) of the last honored nudge from language detection.
|
||||||
|
* Used to rate-limit the pulse animation: we ignore back-to-back
|
||||||
|
* nudges that arrive within NUDGE_COOLDOWN_MS. Consumers key an effect
|
||||||
|
* on this value — it only changes when we actually want to re-pulse.
|
||||||
|
*/
|
||||||
|
const [languageNudgeAt, setLanguageNudgeAt] = useState(0)
|
||||||
|
const onLanguageNudge = () => {
|
||||||
|
const now = Date.now()
|
||||||
|
// ignore back-to-back nudges within 10s; only update state (and
|
||||||
|
// therefore re-pulse) once the cooldown has elapsed
|
||||||
|
setLanguageNudgeAt(prev => (now - prev > 10_000 ? now : prev))
|
||||||
|
}
|
||||||
|
|
||||||
const [composerState, composerDispatch] = useReducer(
|
const [composerState, composerDispatch] = useReducer(
|
||||||
composerReducer,
|
composerReducer,
|
||||||
{
|
{
|
||||||
@@ -1146,6 +1160,7 @@ export const ComposePost = ({
|
|||||||
replyToLanguages={replyToLanguages}
|
replyToLanguages={replyToLanguages}
|
||||||
currentLanguages={currentLanguages}
|
currentLanguages={currentLanguages}
|
||||||
onAcceptSuggestedLanguage={setAcceptedLanguageSuggestion}
|
onAcceptSuggestedLanguage={setAcceptedLanguageSuggestion}
|
||||||
|
onNudge={onLanguageNudge}
|
||||||
/>
|
/>
|
||||||
<ComposerPills
|
<ComposerPills
|
||||||
isReply={!!replyTo}
|
isReply={!!replyTo}
|
||||||
@@ -1169,6 +1184,7 @@ export const ComposePost = ({
|
|||||||
}}
|
}}
|
||||||
currentLanguages={currentLanguages}
|
currentLanguages={currentLanguages}
|
||||||
onSelectLanguage={onSelectLanguage}
|
onSelectLanguage={onSelectLanguage}
|
||||||
|
languageNudgeAt={languageNudgeAt}
|
||||||
openGallery={openGallery}
|
openGallery={openGallery}
|
||||||
textInputRef={textInputRef}
|
textInputRef={textInputRef}
|
||||||
/>
|
/>
|
||||||
@@ -1873,6 +1889,7 @@ function ComposerFooter({
|
|||||||
onAddPost,
|
onAddPost,
|
||||||
currentLanguages,
|
currentLanguages,
|
||||||
onSelectLanguage,
|
onSelectLanguage,
|
||||||
|
languageNudgeAt,
|
||||||
openGallery,
|
openGallery,
|
||||||
textInputRef,
|
textInputRef,
|
||||||
}: {
|
}: {
|
||||||
@@ -1884,6 +1901,7 @@ function ComposerFooter({
|
|||||||
onAddPost: () => void
|
onAddPost: () => void
|
||||||
currentLanguages: string[]
|
currentLanguages: string[]
|
||||||
onSelectLanguage?: (language: string) => void
|
onSelectLanguage?: (language: string) => void
|
||||||
|
languageNudgeAt: number
|
||||||
openGallery?: boolean
|
openGallery?: boolean
|
||||||
textInputRef: React.RefObject<TextInputRef | null>
|
textInputRef: React.RefObject<TextInputRef | null>
|
||||||
}) {
|
}) {
|
||||||
@@ -2049,6 +2067,7 @@ function ComposerFooter({
|
|||||||
<PostLanguageSelect
|
<PostLanguageSelect
|
||||||
currentLanguages={currentLanguages}
|
currentLanguages={currentLanguages}
|
||||||
onSelectLanguage={onSelectLanguage}
|
onSelectLanguage={onSelectLanguage}
|
||||||
|
nudgeAt={languageNudgeAt}
|
||||||
/>
|
/>
|
||||||
<CharProgress
|
<CharProgress
|
||||||
count={post.shortenedGraphemeLength}
|
count={post.shortenedGraphemeLength}
|
||||||
|
|||||||
@@ -1,3 +1,11 @@
|
|||||||
|
import {useEffect} from 'react'
|
||||||
|
import Animated, {
|
||||||
|
Easing,
|
||||||
|
useAnimatedStyle,
|
||||||
|
useSharedValue,
|
||||||
|
withSequence,
|
||||||
|
withTiming,
|
||||||
|
} from 'react-native-reanimated'
|
||||||
import {msg} from '@lingui/core/macro'
|
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'
|
||||||
@@ -17,13 +25,23 @@ import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRightIcon} from '#/compon
|
|||||||
import {Globe_Stroke2_Corner0_Rounded as GlobeIcon} from '#/components/icons/Globe'
|
import {Globe_Stroke2_Corner0_Rounded as GlobeIcon} from '#/components/icons/Globe'
|
||||||
import * as Menu from '#/components/Menu'
|
import * as Menu from '#/components/Menu'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
|
import {useAnalytics} from '#/analytics'
|
||||||
|
|
||||||
export function PostLanguageSelect({
|
export function PostLanguageSelect({
|
||||||
currentLanguages: currentLanguagesProp,
|
currentLanguages: currentLanguagesProp,
|
||||||
onSelectLanguage,
|
onSelectLanguage,
|
||||||
|
nudgeAt = 0,
|
||||||
}: {
|
}: {
|
||||||
currentLanguages?: string[]
|
currentLanguages?: string[]
|
||||||
onSelectLanguage?: (language: string) => void
|
onSelectLanguage?: (language: string) => void
|
||||||
|
/**
|
||||||
|
* Timestamp (ms) of the last honored language-detection nudge. Each
|
||||||
|
* time this changes, the button flashes a transient hint and fades.
|
||||||
|
* The parent rate-limits updates, so successive detector firings inside
|
||||||
|
* the cooldown won't re-flash. The initial `0` on mount is intentionally
|
||||||
|
* ignored.
|
||||||
|
*/
|
||||||
|
nudgeAt?: number
|
||||||
}) {
|
}) {
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const langPrefs = useLanguagePrefs()
|
const langPrefs = useLanguagePrefs()
|
||||||
@@ -52,7 +70,7 @@ export function PostLanguageSelect({
|
|||||||
) {
|
) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<LanguageBtn onPress={languageDialogControl.open} />
|
<LanguageBtn onPress={languageDialogControl.open} nudgeAt={nudgeAt} />
|
||||||
<LanguageSelectDialog
|
<LanguageSelectDialog
|
||||||
titleText={<Trans>Choose post languages</Trans>}
|
titleText={<Trans>Choose post languages</Trans>}
|
||||||
subtitleText={
|
subtitleText={
|
||||||
@@ -72,7 +90,11 @@ export function PostLanguageSelect({
|
|||||||
<Menu.Root>
|
<Menu.Root>
|
||||||
<Menu.Trigger label={_(msg`Select post language`)}>
|
<Menu.Trigger label={_(msg`Select post language`)}>
|
||||||
{({props}) => (
|
{({props}) => (
|
||||||
<LanguageBtn currentLanguages={currentLanguages} {...props} />
|
<LanguageBtn
|
||||||
|
currentLanguages={currentLanguages}
|
||||||
|
nudgeAt={nudgeAt}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</Menu.Trigger>
|
</Menu.Trigger>
|
||||||
<Menu.Outer>
|
<Menu.Outer>
|
||||||
@@ -122,17 +144,47 @@ export function PostLanguageSelect({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function LanguageBtn(
|
const PULSE_FADE_IN_MS = 300
|
||||||
props: Omit<ButtonProps, 'label' | 'children'> & {
|
const PULSE_FADE_OUT_MS = 500
|
||||||
|
|
||||||
|
function LanguageBtn({
|
||||||
|
currentLanguages: currentLanguagesProp,
|
||||||
|
nudgeAt = 0,
|
||||||
|
...props
|
||||||
|
}: Omit<ButtonProps, 'label' | 'children'> & {
|
||||||
currentLanguages?: string[]
|
currentLanguages?: string[]
|
||||||
},
|
nudgeAt?: number
|
||||||
) {
|
}) {
|
||||||
|
const t = useTheme()
|
||||||
|
const ax = useAnalytics()
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const langPrefs = useLanguagePrefs()
|
const langPrefs = useLanguagePrefs()
|
||||||
const t = useTheme()
|
|
||||||
|
|
||||||
const postLanguagesPref = toPostLanguages(langPrefs.postLanguage)
|
const postLanguagesPref = toPostLanguages(langPrefs.postLanguage)
|
||||||
const currentLanguages = props.currentLanguages ?? postLanguagesPref
|
const currentLanguages = currentLanguagesProp ?? postLanguagesPref
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Stays at 0 when idle; each nudge runs two pulses with a faster
|
||||||
|
* fade-in and slower fade-out, ease-in-out throughout. Reassigning
|
||||||
|
* `value` cancels any prior sequence, so rapid re-nudges cleanly
|
||||||
|
* restart.
|
||||||
|
*/
|
||||||
|
const nudgePulse = useSharedValue(0)
|
||||||
|
useEffect(() => {
|
||||||
|
if (nudgeAt === 0) return
|
||||||
|
const easing = Easing.inOut(Easing.quad)
|
||||||
|
const fadeIn = {duration: PULSE_FADE_IN_MS, easing}
|
||||||
|
const fadeOut = {duration: PULSE_FADE_OUT_MS, easing}
|
||||||
|
nudgePulse.value = withSequence(
|
||||||
|
withTiming(1, fadeIn),
|
||||||
|
withTiming(0, fadeOut),
|
||||||
|
withTiming(1, fadeIn),
|
||||||
|
withTiming(0, fadeOut),
|
||||||
|
)
|
||||||
|
}, [nudgeAt, nudgePulse])
|
||||||
|
const pulseStyle = useAnimatedStyle(() => ({
|
||||||
|
opacity: nudgePulse.value,
|
||||||
|
}))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
@@ -146,13 +198,34 @@ function LanguageBtn(
|
|||||||
}),
|
}),
|
||||||
)}
|
)}
|
||||||
accessibilityHint={_(msg`Opens post language settings`)}
|
accessibilityHint={_(msg`Opens post language settings`)}
|
||||||
style={[a.mr_xs]}
|
style={[a.mr_xs, a.overflow_hidden]}
|
||||||
{...props}>
|
{...props}
|
||||||
|
onPress={e => {
|
||||||
|
props.onPress?.(e)
|
||||||
|
ax.metric('composer:language:langSelectorPressed', {
|
||||||
|
wasNudged: nudgeAt > 0,
|
||||||
|
})
|
||||||
|
}}>
|
||||||
{({pressed, hovered}) => {
|
{({pressed, hovered}) => {
|
||||||
const color =
|
const color =
|
||||||
pressed || hovered ? t.palette.primary_300 : t.palette.primary_500
|
pressed || hovered ? t.palette.primary_300 : t.palette.primary_500
|
||||||
if (currentLanguages.length > 0) {
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
<Animated.View
|
||||||
|
pointerEvents="none"
|
||||||
|
style={[
|
||||||
|
a.absolute,
|
||||||
|
{
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
backgroundColor: t.atoms.bg_contrast_50.backgroundColor,
|
||||||
|
},
|
||||||
|
pulseStyle,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
{currentLanguages.length > 0 ? (
|
||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
{color},
|
{color},
|
||||||
@@ -167,10 +240,11 @@ function LanguageBtn(
|
|||||||
.map(lang => codeToLanguageName(lang, langPrefs.appLanguage))
|
.map(lang => codeToLanguageName(lang, langPrefs.appLanguage))
|
||||||
.join(', ')}
|
.join(', ')}
|
||||||
</Text>
|
</Text>
|
||||||
|
) : (
|
||||||
|
<GlobeIcon size="xs" style={{color}} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
} else {
|
|
||||||
return <GlobeIcon size="xs" style={{color}} />
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,28 +1,110 @@
|
|||||||
import {useEffect, useState} from 'react'
|
import {useEffect, useMemo, useRef, useState} from 'react'
|
||||||
import {Text as RNText, View} from 'react-native'
|
import {Platform, Text as RNText, View} from 'react-native'
|
||||||
import {parseLanguage} from '@atproto/api'
|
import {RichText} from '@atproto/api'
|
||||||
import {msg} from '@lingui/core/macro'
|
import {parseLanguageString} from '@atproto/syntax'
|
||||||
import {useLingui} from '@lingui/react'
|
import {
|
||||||
import {Trans} from '@lingui/react/macro'
|
guessLanguageAsync,
|
||||||
import lande from 'lande'
|
type LanguageResult,
|
||||||
|
} from '@bsky.app/expo-guess-language'
|
||||||
|
import {Trans, useLingui} from '@lingui/react/macro'
|
||||||
|
import debounce from 'lodash.debounce'
|
||||||
|
|
||||||
import {code3ToCode2Strict, codeToLanguageName} from '#/locale/helpers'
|
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
||||||
|
import {useNonReactiveObject} from '#/lib/hooks/useNonReactiveObject'
|
||||||
|
import {deviceLanguageCodes} from '#/locale/deviceLocales'
|
||||||
|
import {codeToLanguageName} from '#/locale/helpers'
|
||||||
import {useLanguagePrefs} from '#/state/preferences/languages'
|
import {useLanguagePrefs} from '#/state/preferences/languages'
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a, platform, useTheme} from '#/alf'
|
||||||
import {Button, ButtonText} from '#/components/Button'
|
import {Button, ButtonIcon} from '#/components/Button'
|
||||||
|
import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check'
|
||||||
import {Earth_Stroke2_Corner2_Rounded as EarthIcon} from '#/components/icons/Globe'
|
import {Earth_Stroke2_Corner2_Rounded as EarthIcon} from '#/components/icons/Globe'
|
||||||
|
import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
|
import {useAnalytics} from '#/analytics'
|
||||||
|
import {IS_WEB} from '#/env'
|
||||||
|
|
||||||
// fallbacks for safari
|
type LanguageDetectionPerLanguageConfig = {
|
||||||
const onIdle =
|
acceptanceThreshold?: number
|
||||||
globalThis.requestIdleCallback || ((cb: () => void) => setTimeout(cb, 1))
|
deviceLocaleAcceptanceThreshold?: number
|
||||||
const cancelIdle = globalThis.cancelIdleCallback || clearTimeout
|
}
|
||||||
|
|
||||||
|
type LanguageDetectionConfig = {
|
||||||
|
acceptanceThreshold: number
|
||||||
|
deviceLocaleAcceptanceThreshold: number
|
||||||
|
overrides: Record<string, LanguageDetectionPerLanguageConfig>
|
||||||
|
}
|
||||||
|
|
||||||
|
const MIN_TEXT_LENGTH = IS_WEB ? 20 : 10
|
||||||
|
const NOISE_FLOOR = 0.1
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Platform-resolved defaults. Web uses `lande` under the hood, which
|
||||||
|
* spreads probability across many candidates — so both the noise floor
|
||||||
|
* and the acceptance bar sit higher than on native (MLKit).
|
||||||
|
*
|
||||||
|
* Per-language carve-outs override the platform-level acceptance
|
||||||
|
* threshold.
|
||||||
|
*/
|
||||||
|
const DEFAULT_CONFIG: LanguageDetectionConfig = {
|
||||||
|
acceptanceThreshold: platform({
|
||||||
|
web: 0.97,
|
||||||
|
ios: 0.9,
|
||||||
|
android: 0.9,
|
||||||
|
default: 0.97,
|
||||||
|
}),
|
||||||
|
/*
|
||||||
|
* Device locales are an independent prior — the OS tells us which
|
||||||
|
* languages the user has installed, separate from what the model sees
|
||||||
|
* in the text. Combining the two lets us accept a candidate at lower
|
||||||
|
* model confidence when the language is one the user actually reads.
|
||||||
|
* It also fails softer: a wrong suggestion for a language the user
|
||||||
|
* knows ("are you writing in Spanish?") is easier to dismiss than one
|
||||||
|
* for a language they don't ("are you writing in Japanese?"), so we
|
||||||
|
* can afford to be more aggressive there.
|
||||||
|
*
|
||||||
|
* Native-only. On web we keep the bar at 0.97 because (a) lande's
|
||||||
|
* confidence is tightly bimodal — a score of 0.85 means the model
|
||||||
|
* doesn't know, not that it's "mostly sure" — and (b) the browser's
|
||||||
|
* locale signal is noisier (navigator.languages usually includes
|
||||||
|
* English regardless of what the user actually reads).
|
||||||
|
*/
|
||||||
|
deviceLocaleAcceptanceThreshold: platform({
|
||||||
|
web: 0.97,
|
||||||
|
ios: 0.8,
|
||||||
|
android: 0.8,
|
||||||
|
default: 0.97,
|
||||||
|
}),
|
||||||
|
/*
|
||||||
|
* Per-language carve-outs for known confusable pairs / clusters. The
|
||||||
|
* acceptance bar is raised above the platform baseline because these
|
||||||
|
* are languages the detector (especially `lande` on web) is known to
|
||||||
|
* misclassify or over-commit on.
|
||||||
|
*
|
||||||
|
* The device-locale bar is also raised for most tightly-confusable
|
||||||
|
* pairs: if the user has both languages in the pair installed (common
|
||||||
|
* for id/ms or nb/da speakers), the device-locale prior no longer
|
||||||
|
* discriminates between them, so we can't afford to drop the bar as
|
||||||
|
* aggressively.
|
||||||
|
*
|
||||||
|
* Each value uses `platform({web, default})` — `default` applies to
|
||||||
|
* iOS/Android/etc. (MLKit is better at these distinctions, so the
|
||||||
|
* bump above baseline is smaller).
|
||||||
|
*/
|
||||||
|
overrides: {
|
||||||
|
// Example
|
||||||
|
// id: {
|
||||||
|
// acceptanceThreshold: platform({web: 0.99, default: 0.95}),
|
||||||
|
// deviceLocaleAcceptanceThreshold: platform({web: 0.97, default: 0.9}),
|
||||||
|
// },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
export function SuggestedLanguage({
|
export function SuggestedLanguage({
|
||||||
text,
|
text,
|
||||||
replyToLanguages: replyToLanguagesProp,
|
replyToLanguages: replyToLanguagesProp,
|
||||||
currentLanguages,
|
currentLanguages,
|
||||||
onAcceptSuggestedLanguage,
|
onAcceptSuggestedLanguage,
|
||||||
|
onNudge,
|
||||||
}: {
|
}: {
|
||||||
text: string
|
text: string
|
||||||
/**
|
/**
|
||||||
@@ -39,94 +121,181 @@ export function SuggestedLanguage({
|
|||||||
* only suggest the first one.
|
* only suggest the first one.
|
||||||
*/
|
*/
|
||||||
onAcceptSuggestedLanguage: (language: string | null) => void
|
onAcceptSuggestedLanguage: (language: string | null) => void
|
||||||
|
/**
|
||||||
|
* Fired when detection produced ambiguous results — no strong suggestion
|
||||||
|
* to show, but we want to hint to the user that the detector is unsure.
|
||||||
|
* Expected to be an incrementing counter setter on the parent so the
|
||||||
|
* nudge can re-fire on each detection cycle.
|
||||||
|
*/
|
||||||
|
onNudge?: () => void
|
||||||
}) {
|
}) {
|
||||||
const langPrefs = useLanguagePrefs()
|
const ax = useAnalytics()
|
||||||
const replyToLanguages = replyToLanguagesProp
|
|
||||||
.map(lang => cleanUpLanguage(lang))
|
|
||||||
.filter(Boolean) as string[]
|
|
||||||
const [hasInteracted, setHasInteracted] = useState(false)
|
const [hasInteracted, setHasInteracted] = useState(false)
|
||||||
const [suggestedLanguage, setSuggestedLanguage] = useState<
|
const [suggLang, setSuggLang] = useState<string | undefined>(undefined)
|
||||||
string | undefined
|
const declinedSuggLangsRef = useRef<string[]>([])
|
||||||
>(undefined)
|
|
||||||
|
/*
|
||||||
|
* Shared callbacks
|
||||||
|
*/
|
||||||
|
const onAccept = (language: string) => {
|
||||||
|
onAcceptSuggestedLanguage(language)
|
||||||
|
// clear
|
||||||
|
setSuggLang(undefined)
|
||||||
|
}
|
||||||
|
const onDecline = () => {
|
||||||
|
if (suggLang) {
|
||||||
|
declinedSuggLangsRef.current.push(suggLang)
|
||||||
|
// clear
|
||||||
|
setSuggLang(undefined)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge in remote config (eventually)
|
||||||
|
*/
|
||||||
|
const config = useMemo(() => DEFAULT_CONFIG, [])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create non-reactive ref for debounced detection method.
|
||||||
|
*/
|
||||||
|
const detectionPropsRef = useNonReactiveObject({
|
||||||
|
config,
|
||||||
|
currentLanguages,
|
||||||
|
})
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Held in a ref so the debounced detection closure always sees the
|
||||||
|
* latest callback identity without rebuilding the debounce timer.
|
||||||
|
*/
|
||||||
|
const handleOnNudge = useNonReactiveCallback(onNudge)
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Main language detection effect
|
||||||
|
*/
|
||||||
|
const detectLanguage = useMemo(() => {
|
||||||
|
return debounce(async (text: string) => {
|
||||||
|
try {
|
||||||
|
const currLangs = detectionPropsRef.current.currentLanguages
|
||||||
|
const {certain, uncertain} = await guessLanguage(
|
||||||
|
text,
|
||||||
|
detectionPropsRef.current.config,
|
||||||
|
)
|
||||||
|
const topCandidate = certain.at(0)?.language
|
||||||
|
if (
|
||||||
|
certain.length === 1 &&
|
||||||
|
uncertain.length === 0 &&
|
||||||
|
topCandidate !== undefined &&
|
||||||
|
!currLangs.includes(topCandidate) &&
|
||||||
|
!declinedSuggLangsRef.current.includes(topCandidate)
|
||||||
|
) {
|
||||||
|
// we have a single confident candidate with no competitors — show it!
|
||||||
|
setSuggLang(topCandidate)
|
||||||
|
} else {
|
||||||
|
const nextBestCandidate = uncertain.at(0)?.language
|
||||||
|
// ambiguous results — if the top candidate isn't already
|
||||||
|
// selected or previously declined, nudge the user
|
||||||
|
if (
|
||||||
|
nextBestCandidate !== undefined &&
|
||||||
|
!currLangs.includes(nextBestCandidate) &&
|
||||||
|
!declinedSuggLangsRef.current.includes(nextBestCandidate)
|
||||||
|
) {
|
||||||
|
handleOnNudge()
|
||||||
|
ax.metric('composer:language:nudgeUser', {
|
||||||
|
os: Platform.OS,
|
||||||
|
suggestedLanguage: nextBestCandidate,
|
||||||
|
currentTargetLanguages: currLangs,
|
||||||
|
textLength: text.length,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
setSuggLang(undefined)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
ax.logger.error('Error detecting language', {safeMessage: e})
|
||||||
|
}
|
||||||
|
}, 500)
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// show reply prompt if there's not enough text to start using the model
|
||||||
if (text.length > 0 && !hasInteracted) {
|
if (text.length > 0 && !hasInteracted) {
|
||||||
setHasInteracted(true)
|
setHasInteracted(true)
|
||||||
}
|
}
|
||||||
}, [text, hasInteracted])
|
|
||||||
|
|
||||||
useEffect(() => {
|
if (ax.features.enabled(ax.features.ComposerLanguageDetectionEnable)) {
|
||||||
const textTrimmed = text.trim()
|
const textTrimmed = sanitizeTextForDetection(text)
|
||||||
|
|
||||||
// Don't run the language model on small posts, the results are likely
|
/*
|
||||||
// to be inaccurate anyway.
|
* If text drops under the min length requirement, reset suggestions state
|
||||||
if (textTrimmed.length < 40) {
|
* objects.
|
||||||
setSuggestedLanguage(undefined)
|
*
|
||||||
|
* And we don't run the language model on small posts, the results are
|
||||||
|
* likely to be inaccurate.
|
||||||
|
*/
|
||||||
|
if (textTrimmed.length < MIN_TEXT_LENGTH) {
|
||||||
|
setSuggLang(undefined)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const idle = onIdle(() => {
|
void detectLanguage(textTrimmed)
|
||||||
setSuggestedLanguage(guessLanguage(textTrimmed))
|
}
|
||||||
})
|
|
||||||
|
|
||||||
return () => cancelIdle(idle)
|
// Cancel any pending debounced invocation on unmount / re-run so we
|
||||||
}, [text])
|
// don't call setSuggLang after the composer has closed (or after the
|
||||||
|
// user has already accepted a language).
|
||||||
|
return () => {
|
||||||
|
detectLanguage.cancel()
|
||||||
|
}
|
||||||
|
}, [text, hasInteracted, detectLanguage, ax])
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This is intentionally computed based on a ref. Since we set and clear
|
||||||
|
* `suggLang` this derivation is safe, but be aware of it
|
||||||
|
* when making changes.
|
||||||
|
*/
|
||||||
|
const hasDeclined = suggLang
|
||||||
|
? // eslint-disable-next-line react-hooks/refs
|
||||||
|
declinedSuggLangsRef.current.includes(suggLang)
|
||||||
|
: false
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* We've detected a language, and the user hasn't already selected it.
|
* We've detected a language, and the user hasn't already selected it.
|
||||||
*/
|
*/
|
||||||
const hasLanguageSuggestion =
|
const hasLanguageSuggestion = suggLang && !currentLanguages.includes(suggLang)
|
||||||
suggestedLanguage && !currentLanguages.includes(suggestedLanguage)
|
|
||||||
/*
|
/*
|
||||||
* We have not detected a different language, and the user is not already
|
* We have not detected a different language, and the user is not already
|
||||||
* using or has not already selected one of the languages of the post they
|
* using or has not already selected one of the languages of the post they
|
||||||
* are replying to.
|
* are replying to.
|
||||||
*/
|
*/
|
||||||
|
const replyToLanguages = replyToLanguagesProp
|
||||||
|
.filter(Boolean)
|
||||||
|
.map(lang => parseLanguageString(lang)?.language)
|
||||||
|
.filter(Boolean) as string[]
|
||||||
const hasSuggestedReplyLanguage =
|
const hasSuggestedReplyLanguage =
|
||||||
!hasInteracted &&
|
!hasInteracted &&
|
||||||
!suggestedLanguage &&
|
!suggLang &&
|
||||||
replyToLanguages.length &&
|
replyToLanguages.length &&
|
||||||
!replyToLanguages.some(l => currentLanguages.includes(l))
|
!replyToLanguages.some(l => currentLanguages.includes(l))
|
||||||
|
|
||||||
if (hasLanguageSuggestion) {
|
if (hasDeclined) {
|
||||||
const suggestedLanguageName = codeToLanguageName(
|
return null
|
||||||
suggestedLanguage,
|
} else if (hasLanguageSuggestion) {
|
||||||
langPrefs.appLanguage,
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LanguageSuggestionButton
|
<GuessedLanguage
|
||||||
label={
|
language={suggLang}
|
||||||
<RNText>
|
metadata={{currentTargetLanguages: currentLanguages, rawText: text}}
|
||||||
<Trans>
|
onAccept={onAccept}
|
||||||
Are you writing in{' '}
|
onDecline={onDecline}
|
||||||
<Text style={[a.font_bold]}>{suggestedLanguageName}</Text>?
|
|
||||||
</Trans>
|
|
||||||
</RNText>
|
|
||||||
}
|
|
||||||
value={suggestedLanguage}
|
|
||||||
onAccept={onAcceptSuggestedLanguage}
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
} else if (hasSuggestedReplyLanguage) {
|
} else if (hasSuggestedReplyLanguage) {
|
||||||
const suggestedLanguageName = codeToLanguageName(
|
|
||||||
replyToLanguages[0],
|
|
||||||
langPrefs.appLanguage,
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LanguageSuggestionButton
|
<ReplyLanguageNudge
|
||||||
label={
|
language={replyToLanguages[0]}
|
||||||
<RNText>
|
metadata={{currentTargetLanguages: currentLanguages}}
|
||||||
<Trans>
|
onAccept={onAccept}
|
||||||
The post you're replying to was marked as being written in{' '}
|
onDecline={onDecline}
|
||||||
{suggestedLanguageName} by its author. Would you like to reply in{' '}
|
|
||||||
<Text style={[a.font_bold]}>{suggestedLanguageName}</Text>?
|
|
||||||
</Trans>
|
|
||||||
</RNText>
|
|
||||||
}
|
|
||||||
value={replyToLanguages[0]}
|
|
||||||
onAccept={onAcceptSuggestedLanguage}
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
@@ -134,17 +303,137 @@ export function SuggestedLanguage({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function GuessedLanguage({
|
||||||
|
language,
|
||||||
|
metadata,
|
||||||
|
onAccept: onAcceptOuter,
|
||||||
|
onDecline: onDeclineOuter,
|
||||||
|
}: {
|
||||||
|
language: string
|
||||||
|
metadata: {
|
||||||
|
currentTargetLanguages: string[]
|
||||||
|
rawText: string
|
||||||
|
}
|
||||||
|
onAccept: (language: string) => void
|
||||||
|
onDecline: () => void
|
||||||
|
}) {
|
||||||
|
const ax = useAnalytics()
|
||||||
|
const langPrefs = useLanguagePrefs()
|
||||||
|
const suggestedLanguageName = codeToLanguageName(
|
||||||
|
language,
|
||||||
|
langPrefs.appLanguage,
|
||||||
|
)
|
||||||
|
const onAccept = () => {
|
||||||
|
ax.metric('composer:language:acceptSuggestion', {
|
||||||
|
os: Platform.OS,
|
||||||
|
suggestedLanguage: language,
|
||||||
|
currentTargetLanguages: metadata.currentTargetLanguages,
|
||||||
|
textLength: sanitizeTextForDetection(metadata.rawText).length,
|
||||||
|
})
|
||||||
|
onAcceptOuter(language)
|
||||||
|
}
|
||||||
|
const onDecline = () => {
|
||||||
|
ax.metric('composer:language:declineSuggestion', {
|
||||||
|
os: Platform.OS,
|
||||||
|
suggestedLanguage: language,
|
||||||
|
currentTargetLanguages: metadata.currentTargetLanguages,
|
||||||
|
textLength: sanitizeTextForDetection(metadata.rawText).length,
|
||||||
|
})
|
||||||
|
onDeclineOuter()
|
||||||
|
}
|
||||||
|
|
||||||
|
const metaRef = useNonReactiveObject(metadata)
|
||||||
|
useEffect(() => {
|
||||||
|
ax.metric('composer:language:suggestLanguage', {
|
||||||
|
os: Platform.OS,
|
||||||
|
suggestedLanguage: language,
|
||||||
|
currentTargetLanguages: metaRef.current.currentTargetLanguages,
|
||||||
|
textLength: sanitizeTextForDetection(metadata.rawText).length,
|
||||||
|
})
|
||||||
|
}, [ax, language])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LanguageSuggestionButton
|
||||||
|
label={
|
||||||
|
<RNText>
|
||||||
|
<Trans>
|
||||||
|
Are you writing in{' '}
|
||||||
|
<Text style={[a.font_semi_bold]}>{suggestedLanguageName}</Text>?
|
||||||
|
</Trans>
|
||||||
|
</RNText>
|
||||||
|
}
|
||||||
|
value={language}
|
||||||
|
onAccept={onAccept}
|
||||||
|
onDecline={onDecline}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReplyLanguageNudge({
|
||||||
|
language,
|
||||||
|
metadata,
|
||||||
|
onAccept: onAcceptOuter,
|
||||||
|
onDecline: onDeclineOuter,
|
||||||
|
}: {
|
||||||
|
language: string
|
||||||
|
metadata: {
|
||||||
|
currentTargetLanguages: string[]
|
||||||
|
}
|
||||||
|
onAccept: (language: string) => void
|
||||||
|
onDecline: () => void
|
||||||
|
}) {
|
||||||
|
const ax = useAnalytics()
|
||||||
|
const langPrefs = useLanguagePrefs()
|
||||||
|
const suggestedLanguageName = codeToLanguageName(
|
||||||
|
language,
|
||||||
|
langPrefs.appLanguage,
|
||||||
|
)
|
||||||
|
const onAccept = () => {
|
||||||
|
ax.metric('composer:language:replyNudgeAccept', {
|
||||||
|
replyToLanguage: language,
|
||||||
|
currentTargetLanguages: metadata.currentTargetLanguages,
|
||||||
|
})
|
||||||
|
onAcceptOuter(language)
|
||||||
|
}
|
||||||
|
const onDecline = () => {
|
||||||
|
ax.metric('composer:language:replyNudgeDecline', {
|
||||||
|
replyToLanguage: language,
|
||||||
|
currentTargetLanguages: metadata.currentTargetLanguages,
|
||||||
|
})
|
||||||
|
onDeclineOuter()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LanguageSuggestionButton
|
||||||
|
label={
|
||||||
|
<RNText>
|
||||||
|
<Trans>
|
||||||
|
The post you’re replying to was marked as being written in{' '}
|
||||||
|
{suggestedLanguageName} by its author. Would you like to reply in{' '}
|
||||||
|
<Text style={[a.font_semi_bold]}>{suggestedLanguageName}</Text>?
|
||||||
|
</Trans>
|
||||||
|
</RNText>
|
||||||
|
}
|
||||||
|
value={language}
|
||||||
|
onAccept={onAccept}
|
||||||
|
onDecline={onDecline}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function LanguageSuggestionButton({
|
function LanguageSuggestionButton({
|
||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
onAccept,
|
onAccept,
|
||||||
|
onDecline,
|
||||||
}: {
|
}: {
|
||||||
label: React.ReactNode
|
label: React.ReactNode
|
||||||
value: string
|
value: string
|
||||||
onAccept: (language: string | null) => void
|
onAccept: (language: string | null) => void
|
||||||
|
onDecline: () => void
|
||||||
}) {
|
}) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {_} = useLingui()
|
const {t: l} = useLingui()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[a.px_lg, a.py_sm]}>
|
<View style={[a.px_lg, a.py_sm]}>
|
||||||
@@ -175,12 +464,20 @@ function LanguageSuggestionButton({
|
|||||||
|
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
color="secondary"
|
color="primary_subtle"
|
||||||
|
shape="round"
|
||||||
onPress={() => onAccept(value)}
|
onPress={() => onAccept(value)}
|
||||||
label={_(msg`Accept this language suggestion`)}>
|
label={l`Accept this language suggestion`}>
|
||||||
<ButtonText>
|
<ButtonIcon icon={CheckIcon} size="sm" />
|
||||||
<Trans>Yes</Trans>
|
</Button>
|
||||||
</ButtonText>
|
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
color="secondary"
|
||||||
|
shape="round"
|
||||||
|
onPress={() => onDecline()}
|
||||||
|
label={l`Decline this language suggestion`}>
|
||||||
|
<ButtonIcon icon={XIcon} size="sm" />
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -188,28 +485,66 @@ function LanguageSuggestionButton({
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This function is using the lande language model to attempt to detect the language
|
* Run detection and partition candidates into "certain" (confident enough
|
||||||
* We want to only make suggestions when we feel a high degree of certainty
|
* to suggest on their own) and "uncertain" (above the noise floor but not
|
||||||
* The magic numbers are based on debugging sessions against some test strings
|
* confident enough to suggest). Callers decide what to do with the shape:
|
||||||
|
* a single certain candidate with no uncertain competitors is a strong
|
||||||
|
* suggestion; everything else is ambiguous.
|
||||||
|
*
|
||||||
|
* The acceptance threshold is resolved per candidate with this precedence:
|
||||||
|
* 1. Per-language override (e.g. maybe `id` requires higher confidence)
|
||||||
|
* 2. Device-locale bar (lower on native — the user likely writes in a
|
||||||
|
* language they have installed)
|
||||||
|
* 3. Platform-level bar
|
||||||
*/
|
*/
|
||||||
function guessLanguage(text: string): string | undefined {
|
async function guessLanguage(
|
||||||
const scores = lande(text).filter(([_lang, value]) => value >= 0.0002)
|
text: string,
|
||||||
// if the model has multiple items with a score higher than 0.0002, it isn't certain enough
|
config: LanguageDetectionConfig,
|
||||||
if (scores.length !== 1) {
|
): Promise<{
|
||||||
return undefined
|
certain: LanguageResult[]
|
||||||
|
uncertain: LanguageResult[]
|
||||||
|
}> {
|
||||||
|
const suggestions = await guessLanguageAsync(text)
|
||||||
|
const certain: LanguageResult[] = []
|
||||||
|
const uncertain: LanguageResult[] = []
|
||||||
|
|
||||||
|
for (const suggestion of suggestions) {
|
||||||
|
const isDeviceLocale = deviceLanguageCodes.includes(suggestion.language)
|
||||||
|
const override = config.overrides[suggestion.language]
|
||||||
|
const threshold = isDeviceLocale
|
||||||
|
? (override?.deviceLocaleAcceptanceThreshold ??
|
||||||
|
config.deviceLocaleAcceptanceThreshold)
|
||||||
|
: (override?.acceptanceThreshold ?? config.acceptanceThreshold)
|
||||||
|
|
||||||
|
if (suggestion.confidence >= threshold) {
|
||||||
|
certain.push(suggestion)
|
||||||
|
} else if (suggestion.confidence >= NOISE_FLOOR) {
|
||||||
|
uncertain.push(suggestion)
|
||||||
}
|
}
|
||||||
const [lang, value] = scores[0]
|
|
||||||
// if the model doesn't give a score of 0.97 or above, it isn't certain enough
|
|
||||||
if (value < 0.97) {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
return code3ToCode2Strict(lang)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function cleanUpLanguage(text: string | undefined): string | undefined {
|
return {certain, uncertain}
|
||||||
if (!text) {
|
|
||||||
return undefined
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return parseLanguage(text)?.language
|
/**
|
||||||
|
* Strip any detected facets from the text to improve language detection
|
||||||
|
* accuracy. For example, URLs and mentions.
|
||||||
|
*
|
||||||
|
* Tags are intentionally kept — their word content is usually in the
|
||||||
|
* post's language and helps detection; the leading `#` is short enough
|
||||||
|
* not to distort results.
|
||||||
|
*/
|
||||||
|
function sanitizeTextForDetection(text: string): string {
|
||||||
|
const rt = new RichText({text: text.trim()})
|
||||||
|
rt.detectFacetsWithoutResolution()
|
||||||
|
|
||||||
|
let sanitized = ''
|
||||||
|
for (const segment of rt.segments()) {
|
||||||
|
if (segment.isLink() || segment.isMention() || segment.isTag()) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sanitized += segment.text
|
||||||
|
}
|
||||||
|
|
||||||
|
return sanitized.trim()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,7 +101,7 @@
|
|||||||
multiformats "^9.9.0"
|
multiformats "^9.9.0"
|
||||||
zod "^3.23.8"
|
zod "^3.23.8"
|
||||||
|
|
||||||
"@atproto/syntax@^0.5.0", "@atproto/syntax@^0.5.1":
|
"@atproto/syntax@0.5.2", "@atproto/syntax@^0.5.0", "@atproto/syntax@^0.5.1":
|
||||||
version "0.5.2"
|
version "0.5.2"
|
||||||
resolved "https://registry.yarnpkg.com/@atproto/syntax/-/syntax-0.5.2.tgz#d4b32c9feb421ceeb5ade1fa80bc42764d51e52e"
|
resolved "https://registry.yarnpkg.com/@atproto/syntax/-/syntax-0.5.2.tgz#d4b32c9feb421ceeb5ade1fa80bc42764d51e52e"
|
||||||
integrity sha512-W41szOnkppoHr0iCUrzL8gy3OD6qmDyp1UvUgmTx2oFQfgbudpz51T/gznesiCcqiUT5obfHdx4PJ+WdlEOE7Q==
|
integrity sha512-W41szOnkppoHr0iCUrzL8gy3OD6qmDyp1UvUgmTx2oFQfgbudpz51T/gznesiCcqiUT5obfHdx4PJ+WdlEOE7Q==
|
||||||
@@ -2424,6 +2424,13 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
react-responsive "^10.0.1"
|
react-responsive "^10.0.1"
|
||||||
|
|
||||||
|
"@bsky.app/expo-guess-language@^0.2.8":
|
||||||
|
version "0.2.8"
|
||||||
|
resolved "https://registry.yarnpkg.com/@bsky.app/expo-guess-language/-/expo-guess-language-0.2.8.tgz#e1c2d03b8852eb5fb7397316b0ec8cd7c4f98747"
|
||||||
|
integrity sha512-krcQfMSJn39kaFRpaOWxLUW9rT04reoBqjQviu2fTGQWXWEImG25SJondSObVNyGXlmRMrltt72Sc+aRPpQeog==
|
||||||
|
dependencies:
|
||||||
|
lande "^1.0.10"
|
||||||
|
|
||||||
"@bsky.app/expo-image-crop-tool@^0.5.0":
|
"@bsky.app/expo-image-crop-tool@^0.5.0":
|
||||||
version "0.5.0"
|
version "0.5.0"
|
||||||
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"
|
||||||
|
|||||||
Reference in New Issue
Block a user