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]),
}))
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', () => ({
+2
View File
@@ -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",
+2
View File
@@ -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',
}
+94
View File
@@ -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
+19 -11
View File
@@ -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) => {
+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([])
}
/**
* 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 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({
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()
}
+8 -1
View File
@@ -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"