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:
Samuel Newman
2026-04-22 18:26:14 +03:00
committed by GitHub
parent 5d40532aa9
commit 1c38665d4c
10 changed files with 706 additions and 141 deletions
+7 -3
View File
@@ -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', () => ({
+2
View File
@@ -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",
+2
View File
@@ -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',
} }
+94
View File
@@ -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
+19 -11
View File
@@ -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) => {
+20
View File
@@ -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
}
+19
View File
@@ -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
currentLanguages?: string[]
}, function LanguageBtn({
) { currentLanguages: currentLanguagesProp,
nudgeAt = 0,
...props
}: Omit<ButtonProps, 'label' | 'children'> & {
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,31 +198,53 @@ 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 ( <>
<Text <Animated.View
pointerEvents="none"
style={[ style={[
{color}, a.absolute,
a.font_semi_bold, {
a.text_sm, top: 0,
a.leading_snug, right: 0,
{maxWidth: 100}, bottom: 0,
left: 0,
backgroundColor: t.atoms.bg_contrast_50.backgroundColor,
},
pulseStyle,
]} ]}
numberOfLines={1} />
maxFontSizeMultiplier={1.5}> {currentLanguages.length > 0 ? (
{currentLanguages <Text
.map(lang => codeToLanguageName(lang, langPrefs.appLanguage)) style={[
.join(', ')} {color},
</Text> a.font_semi_bold,
) a.text_sm,
} else { a.leading_snug,
return <GlobeIcon size="xs" style={{color}} /> {maxWidth: 100},
} ]}
numberOfLines={1}
maxFontSizeMultiplier={1.5}>
{currentLanguages
.map(lang => codeToLanguageName(lang, langPrefs.appLanguage))
.join(', ')}
</Text>
) : (
<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) *
return * 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(() => { // Cancel any pending debounced invocation on unmount / re-run so we
setSuggestedLanguage(guessLanguage(textTrimmed)) // 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. * 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 youre 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 [lang, value] = scores[0] }> {
// if the model doesn't give a score of 0.97 or above, it isn't certain enough const suggestions = await guessLanguageAsync(text)
if (value < 0.97) { const certain: LanguageResult[] = []
return undefined const uncertain: LanguageResult[] = []
}
return code3ToCode2Strict(lang)
}
function cleanUpLanguage(text: string | undefined): string | undefined { for (const suggestion of suggestions) {
if (!text) { const isDeviceLocale = deviceLanguageCodes.includes(suggestion.language)
return undefined 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()
} }
+8 -1
View File
@@ -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"