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]),
|
||||
}))
|
||||
|
||||
jest.mock('lande', () => ({
|
||||
__esModule: true, // this property makes it work
|
||||
default: jest.fn().mockReturnValue([['eng']]),
|
||||
jest.mock('@bsky.app/expo-guess-language', () => ({
|
||||
guessLanguageSync: jest
|
||||
.fn()
|
||||
.mockReturnValue([{language: 'en', confidence: 1}]),
|
||||
guessLanguageAsync: jest
|
||||
.fn()
|
||||
.mockResolvedValue([{language: 'en', confidence: 1}]),
|
||||
}))
|
||||
|
||||
jest.mock('sentry-expo', () => ({
|
||||
|
||||
@@ -82,9 +82,11 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@atproto/api": "^0.19.10",
|
||||
"@atproto/syntax": "0.5.2",
|
||||
"@bitdrift/react-native": "^0.6.8",
|
||||
"@braintree/sanitize-url": "^6.0.2",
|
||||
"@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-scroll-edge-effect": "^0.1.4",
|
||||
"@bsky.app/expo-translate-text": "^0.2.9",
|
||||
|
||||
@@ -14,7 +14,9 @@ export enum Features {
|
||||
GroupChatsEnable = 'group_chats:enable',
|
||||
GroupChatsHasBeenReleased = 'group_chats:has_been_released',
|
||||
DmsNewMessageComposerEnable = 'dms:new_message_composer:enable',
|
||||
ComposerLanguageDetectionEnable = 'composer:language_detection:enable',
|
||||
KlipyGifProviderEnable = 'klipy_gif_provider:enable',
|
||||
PostGalleryEmbedEnable = 'post_gallery_embed:enable',
|
||||
|
||||
AATest = 'aa-test',
|
||||
}
|
||||
|
||||
@@ -800,6 +800,100 @@ export type Events = {
|
||||
*/
|
||||
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': {
|
||||
uri: string
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
import {useCallback, useInsertionEffect, useRef} from 'react'
|
||||
|
||||
// This should be used sparingly. It erases reactivity, i.e. when the inputs
|
||||
// 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"
|
||||
// to this change (e.g. by knowing to call your function again).
|
||||
//
|
||||
// Also, you should avoid calling the returned function during rendering
|
||||
// since the values captured by it are going to lag behind.
|
||||
export function useNonReactiveCallback<T extends Function>(fn: T): T {
|
||||
const ref = useRef(fn)
|
||||
const noop = () => {}
|
||||
|
||||
/**
|
||||
* This should be used sparingly. It erases reactivity, i.e. when the inputs
|
||||
* 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" to
|
||||
* this change (e.g. by knowing to call your function again).
|
||||
*
|
||||
* 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(() => {
|
||||
ref.current = fn
|
||||
ref.current = (fn ?? noop) as T
|
||||
}, [fn])
|
||||
return useCallback(
|
||||
(...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([])
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(
|
||||
composerReducer,
|
||||
{
|
||||
@@ -1146,6 +1160,7 @@ export const ComposePost = ({
|
||||
replyToLanguages={replyToLanguages}
|
||||
currentLanguages={currentLanguages}
|
||||
onAcceptSuggestedLanguage={setAcceptedLanguageSuggestion}
|
||||
onNudge={onLanguageNudge}
|
||||
/>
|
||||
<ComposerPills
|
||||
isReply={!!replyTo}
|
||||
@@ -1169,6 +1184,7 @@ export const ComposePost = ({
|
||||
}}
|
||||
currentLanguages={currentLanguages}
|
||||
onSelectLanguage={onSelectLanguage}
|
||||
languageNudgeAt={languageNudgeAt}
|
||||
openGallery={openGallery}
|
||||
textInputRef={textInputRef}
|
||||
/>
|
||||
@@ -1873,6 +1889,7 @@ function ComposerFooter({
|
||||
onAddPost,
|
||||
currentLanguages,
|
||||
onSelectLanguage,
|
||||
languageNudgeAt,
|
||||
openGallery,
|
||||
textInputRef,
|
||||
}: {
|
||||
@@ -1884,6 +1901,7 @@ function ComposerFooter({
|
||||
onAddPost: () => void
|
||||
currentLanguages: string[]
|
||||
onSelectLanguage?: (language: string) => void
|
||||
languageNudgeAt: number
|
||||
openGallery?: boolean
|
||||
textInputRef: React.RefObject<TextInputRef | null>
|
||||
}) {
|
||||
@@ -2049,6 +2067,7 @@ function ComposerFooter({
|
||||
<PostLanguageSelect
|
||||
currentLanguages={currentLanguages}
|
||||
onSelectLanguage={onSelectLanguage}
|
||||
nudgeAt={languageNudgeAt}
|
||||
/>
|
||||
<CharProgress
|
||||
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 {useLingui} from '@lingui/react'
|
||||
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 * as Menu from '#/components/Menu'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {useAnalytics} from '#/analytics'
|
||||
|
||||
export function PostLanguageSelect({
|
||||
currentLanguages: currentLanguagesProp,
|
||||
onSelectLanguage,
|
||||
nudgeAt = 0,
|
||||
}: {
|
||||
currentLanguages?: string[]
|
||||
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 langPrefs = useLanguagePrefs()
|
||||
@@ -52,7 +70,7 @@ export function PostLanguageSelect({
|
||||
) {
|
||||
return (
|
||||
<>
|
||||
<LanguageBtn onPress={languageDialogControl.open} />
|
||||
<LanguageBtn onPress={languageDialogControl.open} nudgeAt={nudgeAt} />
|
||||
<LanguageSelectDialog
|
||||
titleText={<Trans>Choose post languages</Trans>}
|
||||
subtitleText={
|
||||
@@ -72,7 +90,11 @@ export function PostLanguageSelect({
|
||||
<Menu.Root>
|
||||
<Menu.Trigger label={_(msg`Select post language`)}>
|
||||
{({props}) => (
|
||||
<LanguageBtn currentLanguages={currentLanguages} {...props} />
|
||||
<LanguageBtn
|
||||
currentLanguages={currentLanguages}
|
||||
nudgeAt={nudgeAt}
|
||||
{...props}
|
||||
/>
|
||||
)}
|
||||
</Menu.Trigger>
|
||||
<Menu.Outer>
|
||||
@@ -122,17 +144,47 @@ export function PostLanguageSelect({
|
||||
)
|
||||
}
|
||||
|
||||
function LanguageBtn(
|
||||
props: Omit<ButtonProps, 'label' | 'children'> & {
|
||||
currentLanguages?: string[]
|
||||
},
|
||||
) {
|
||||
const PULSE_FADE_IN_MS = 300
|
||||
const PULSE_FADE_OUT_MS = 500
|
||||
|
||||
function LanguageBtn({
|
||||
currentLanguages: currentLanguagesProp,
|
||||
nudgeAt = 0,
|
||||
...props
|
||||
}: Omit<ButtonProps, 'label' | 'children'> & {
|
||||
currentLanguages?: string[]
|
||||
nudgeAt?: number
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const ax = useAnalytics()
|
||||
const {_} = useLingui()
|
||||
const langPrefs = useLanguagePrefs()
|
||||
const t = useTheme()
|
||||
|
||||
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 (
|
||||
<Button
|
||||
@@ -146,31 +198,53 @@ function LanguageBtn(
|
||||
}),
|
||||
)}
|
||||
accessibilityHint={_(msg`Opens post language settings`)}
|
||||
style={[a.mr_xs]}
|
||||
{...props}>
|
||||
style={[a.mr_xs, a.overflow_hidden]}
|
||||
{...props}
|
||||
onPress={e => {
|
||||
props.onPress?.(e)
|
||||
ax.metric('composer:language:langSelectorPressed', {
|
||||
wasNudged: nudgeAt > 0,
|
||||
})
|
||||
}}>
|
||||
{({pressed, hovered}) => {
|
||||
const color =
|
||||
pressed || hovered ? t.palette.primary_300 : t.palette.primary_500
|
||||
if (currentLanguages.length > 0) {
|
||||
return (
|
||||
<Text
|
||||
return (
|
||||
<>
|
||||
<Animated.View
|
||||
pointerEvents="none"
|
||||
style={[
|
||||
{color},
|
||||
a.font_semi_bold,
|
||||
a.text_sm,
|
||||
a.leading_snug,
|
||||
{maxWidth: 100},
|
||||
a.absolute,
|
||||
{
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
backgroundColor: t.atoms.bg_contrast_50.backgroundColor,
|
||||
},
|
||||
pulseStyle,
|
||||
]}
|
||||
numberOfLines={1}
|
||||
maxFontSizeMultiplier={1.5}>
|
||||
{currentLanguages
|
||||
.map(lang => codeToLanguageName(lang, langPrefs.appLanguage))
|
||||
.join(', ')}
|
||||
</Text>
|
||||
)
|
||||
} else {
|
||||
return <GlobeIcon size="xs" style={{color}} />
|
||||
}
|
||||
/>
|
||||
{currentLanguages.length > 0 ? (
|
||||
<Text
|
||||
style={[
|
||||
{color},
|
||||
a.font_semi_bold,
|
||||
a.text_sm,
|
||||
a.leading_snug,
|
||||
{maxWidth: 100},
|
||||
]}
|
||||
numberOfLines={1}
|
||||
maxFontSizeMultiplier={1.5}>
|
||||
{currentLanguages
|
||||
.map(lang => codeToLanguageName(lang, langPrefs.appLanguage))
|
||||
.join(', ')}
|
||||
</Text>
|
||||
) : (
|
||||
<GlobeIcon size="xs" style={{color}} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}}
|
||||
</Button>
|
||||
)
|
||||
|
||||
@@ -1,28 +1,110 @@
|
||||
import {useEffect, useState} from 'react'
|
||||
import {Text as RNText, View} from 'react-native'
|
||||
import {parseLanguage} from '@atproto/api'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
import lande from 'lande'
|
||||
import {useEffect, useMemo, useRef, useState} from 'react'
|
||||
import {Platform, Text as RNText, View} from 'react-native'
|
||||
import {RichText} from '@atproto/api'
|
||||
import {parseLanguageString} from '@atproto/syntax'
|
||||
import {
|
||||
guessLanguageAsync,
|
||||
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 {atoms as a, useTheme} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import {atoms as a, platform, useTheme} from '#/alf'
|
||||
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 {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {useAnalytics} from '#/analytics'
|
||||
import {IS_WEB} from '#/env'
|
||||
|
||||
// fallbacks for safari
|
||||
const onIdle =
|
||||
globalThis.requestIdleCallback || ((cb: () => void) => setTimeout(cb, 1))
|
||||
const cancelIdle = globalThis.cancelIdleCallback || clearTimeout
|
||||
type LanguageDetectionPerLanguageConfig = {
|
||||
acceptanceThreshold?: number
|
||||
deviceLocaleAcceptanceThreshold?: number
|
||||
}
|
||||
|
||||
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({
|
||||
text,
|
||||
replyToLanguages: replyToLanguagesProp,
|
||||
currentLanguages,
|
||||
onAcceptSuggestedLanguage,
|
||||
onNudge,
|
||||
}: {
|
||||
text: string
|
||||
/**
|
||||
@@ -39,94 +121,181 @@ export function SuggestedLanguage({
|
||||
* only suggest the first one.
|
||||
*/
|
||||
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 replyToLanguages = replyToLanguagesProp
|
||||
.map(lang => cleanUpLanguage(lang))
|
||||
.filter(Boolean) as string[]
|
||||
const ax = useAnalytics()
|
||||
const [hasInteracted, setHasInteracted] = useState(false)
|
||||
const [suggestedLanguage, setSuggestedLanguage] = useState<
|
||||
string | undefined
|
||||
>(undefined)
|
||||
const [suggLang, setSuggLang] = useState<string | undefined>(undefined)
|
||||
const declinedSuggLangsRef = useRef<string[]>([])
|
||||
|
||||
/*
|
||||
* 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(() => {
|
||||
// show reply prompt if there's not enough text to start using the model
|
||||
if (text.length > 0 && !hasInteracted) {
|
||||
setHasInteracted(true)
|
||||
}
|
||||
}, [text, hasInteracted])
|
||||
|
||||
useEffect(() => {
|
||||
const textTrimmed = text.trim()
|
||||
if (ax.features.enabled(ax.features.ComposerLanguageDetectionEnable)) {
|
||||
const textTrimmed = sanitizeTextForDetection(text)
|
||||
|
||||
// Don't run the language model on small posts, the results are likely
|
||||
// to be inaccurate anyway.
|
||||
if (textTrimmed.length < 40) {
|
||||
setSuggestedLanguage(undefined)
|
||||
return
|
||||
/*
|
||||
* If text drops under the min length requirement, reset suggestions state
|
||||
* objects.
|
||||
*
|
||||
* 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
|
||||
}
|
||||
|
||||
void detectLanguage(textTrimmed)
|
||||
}
|
||||
|
||||
const idle = onIdle(() => {
|
||||
setSuggestedLanguage(guessLanguage(textTrimmed))
|
||||
})
|
||||
// Cancel any pending debounced invocation on unmount / re-run so we
|
||||
// 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])
|
||||
|
||||
return () => cancelIdle(idle)
|
||||
}, [text])
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
const hasLanguageSuggestion =
|
||||
suggestedLanguage && !currentLanguages.includes(suggestedLanguage)
|
||||
const hasLanguageSuggestion = suggLang && !currentLanguages.includes(suggLang)
|
||||
|
||||
/*
|
||||
* 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
|
||||
* are replying to.
|
||||
*/
|
||||
const replyToLanguages = replyToLanguagesProp
|
||||
.filter(Boolean)
|
||||
.map(lang => parseLanguageString(lang)?.language)
|
||||
.filter(Boolean) as string[]
|
||||
const hasSuggestedReplyLanguage =
|
||||
!hasInteracted &&
|
||||
!suggestedLanguage &&
|
||||
!suggLang &&
|
||||
replyToLanguages.length &&
|
||||
!replyToLanguages.some(l => currentLanguages.includes(l))
|
||||
|
||||
if (hasLanguageSuggestion) {
|
||||
const suggestedLanguageName = codeToLanguageName(
|
||||
suggestedLanguage,
|
||||
langPrefs.appLanguage,
|
||||
)
|
||||
|
||||
if (hasDeclined) {
|
||||
return null
|
||||
} else if (hasLanguageSuggestion) {
|
||||
return (
|
||||
<LanguageSuggestionButton
|
||||
label={
|
||||
<RNText>
|
||||
<Trans>
|
||||
Are you writing in{' '}
|
||||
<Text style={[a.font_bold]}>{suggestedLanguageName}</Text>?
|
||||
</Trans>
|
||||
</RNText>
|
||||
}
|
||||
value={suggestedLanguage}
|
||||
onAccept={onAcceptSuggestedLanguage}
|
||||
<GuessedLanguage
|
||||
language={suggLang}
|
||||
metadata={{currentTargetLanguages: currentLanguages, rawText: text}}
|
||||
onAccept={onAccept}
|
||||
onDecline={onDecline}
|
||||
/>
|
||||
)
|
||||
} else if (hasSuggestedReplyLanguage) {
|
||||
const suggestedLanguageName = codeToLanguageName(
|
||||
replyToLanguages[0],
|
||||
langPrefs.appLanguage,
|
||||
)
|
||||
|
||||
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_bold]}>{suggestedLanguageName}</Text>?
|
||||
</Trans>
|
||||
</RNText>
|
||||
}
|
||||
value={replyToLanguages[0]}
|
||||
onAccept={onAcceptSuggestedLanguage}
|
||||
<ReplyLanguageNudge
|
||||
language={replyToLanguages[0]}
|
||||
metadata={{currentTargetLanguages: currentLanguages}}
|
||||
onAccept={onAccept}
|
||||
onDecline={onDecline}
|
||||
/>
|
||||
)
|
||||
} 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({
|
||||
label,
|
||||
value,
|
||||
onAccept,
|
||||
onDecline,
|
||||
}: {
|
||||
label: React.ReactNode
|
||||
value: string
|
||||
onAccept: (language: string | null) => void
|
||||
onDecline: () => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {t: l} = useLingui()
|
||||
|
||||
return (
|
||||
<View style={[a.px_lg, a.py_sm]}>
|
||||
@@ -175,12 +464,20 @@ function LanguageSuggestionButton({
|
||||
|
||||
<Button
|
||||
size="small"
|
||||
color="secondary"
|
||||
color="primary_subtle"
|
||||
shape="round"
|
||||
onPress={() => onAccept(value)}
|
||||
label={_(msg`Accept this language suggestion`)}>
|
||||
<ButtonText>
|
||||
<Trans>Yes</Trans>
|
||||
</ButtonText>
|
||||
label={l`Accept this language suggestion`}>
|
||||
<ButtonIcon icon={CheckIcon} size="sm" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="small"
|
||||
color="secondary"
|
||||
shape="round"
|
||||
onPress={() => onDecline()}
|
||||
label={l`Decline this language suggestion`}>
|
||||
<ButtonIcon icon={XIcon} size="sm" />
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
@@ -188,28 +485,66 @@ function LanguageSuggestionButton({
|
||||
}
|
||||
|
||||
/**
|
||||
* This function is using the lande language model to attempt to detect the language
|
||||
* We want to only make suggestions when we feel a high degree of certainty
|
||||
* The magic numbers are based on debugging sessions against some test strings
|
||||
* Run detection and partition candidates into "certain" (confident enough
|
||||
* to suggest on their own) and "uncertain" (above the noise floor but not
|
||||
* 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 {
|
||||
const scores = lande(text).filter(([_lang, value]) => value >= 0.0002)
|
||||
// if the model has multiple items with a score higher than 0.0002, it isn't certain enough
|
||||
if (scores.length !== 1) {
|
||||
return undefined
|
||||
}
|
||||
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)
|
||||
}
|
||||
async function guessLanguage(
|
||||
text: string,
|
||||
config: LanguageDetectionConfig,
|
||||
): Promise<{
|
||||
certain: LanguageResult[]
|
||||
uncertain: LanguageResult[]
|
||||
}> {
|
||||
const suggestions = await guessLanguageAsync(text)
|
||||
const certain: LanguageResult[] = []
|
||||
const uncertain: LanguageResult[] = []
|
||||
|
||||
function cleanUpLanguage(text: string | undefined): string | undefined {
|
||||
if (!text) {
|
||||
return undefined
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
return parseLanguage(text)?.language
|
||||
return {certain, uncertain}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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"
|
||||
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"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/syntax/-/syntax-0.5.2.tgz#d4b32c9feb421ceeb5ade1fa80bc42764d51e52e"
|
||||
integrity sha512-W41szOnkppoHr0iCUrzL8gy3OD6qmDyp1UvUgmTx2oFQfgbudpz51T/gznesiCcqiUT5obfHdx4PJ+WdlEOE7Q==
|
||||
@@ -2424,6 +2424,13 @@
|
||||
dependencies:
|
||||
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":
|
||||
version "0.5.0"
|
||||
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