From 1c38665d4ce04e7e787a8d69b51ef8d8b22af761 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Wed, 22 Apr 2026 18:26:14 +0300 Subject: [PATCH] Replace lande with native language detection (#9974) Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: DS Boyce <260543580+ds-boyce@users.noreply.github.com> Co-authored-by: Eric Bailey --- jest/jestSetup.js | 10 +- package.json | 2 + src/analytics/features/types.ts | 2 + src/analytics/metrics/types.ts | 94 ++++ src/lib/hooks/useNonReactiveCallback.ts | 30 +- src/lib/hooks/useNonReactiveObject.ts | 20 + src/view/com/composer/Composer.tsx | 19 + .../select-language/PostLanguageSelect.tsx | 132 ++++- .../select-language/SuggestedLanguage.tsx | 529 ++++++++++++++---- yarn.lock | 9 +- 10 files changed, 706 insertions(+), 141 deletions(-) create mode 100644 src/lib/hooks/useNonReactiveObject.ts diff --git a/jest/jestSetup.js b/jest/jestSetup.js index 6a6987c79d..73a509d036 100644 --- a/jest/jestSetup.js +++ b/jest/jestSetup.js @@ -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', () => ({ diff --git a/package.json b/package.json index 78a7ea4978..9ed7549b55 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/analytics/features/types.ts b/src/analytics/features/types.ts index 42edd1dc44..1e039e4571 100644 --- a/src/analytics/features/types.ts +++ b/src/analytics/features/types.ts @@ -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', } diff --git a/src/analytics/metrics/types.ts b/src/analytics/metrics/types.ts index 2b374e17ad..df0d08c52b 100644 --- a/src/analytics/metrics/types.ts +++ b/src/analytics/metrics/types.ts @@ -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 diff --git a/src/lib/hooks/useNonReactiveCallback.ts b/src/lib/hooks/useNonReactiveCallback.ts index 4b3d6abb93..8b907d3418 100644 --- a/src/lib/hooks/useNonReactiveCallback.ts +++ b/src/lib/hooks/useNonReactiveCallback.ts @@ -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(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 void>( + fn?: T, +): T { + const ref = useRef((fn ?? noop) as T) useInsertionEffect(() => { - ref.current = fn + ref.current = (fn ?? noop) as T }, [fn]) return useCallback( (...args: any) => { diff --git a/src/lib/hooks/useNonReactiveObject.ts b/src/lib/hooks/useNonReactiveObject.ts new file mode 100644 index 0000000000..e276b5080b --- /dev/null +++ b/src/lib/hooks/useNonReactiveObject.ts @@ -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>( + o: T, +): React.RefObject { + const ref = useRef(o) + useInsertionEffect(() => { + ref.current = o + }, [o]) + return ref +} diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 2801239a81..eb3f5f25cd 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -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} /> @@ -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 }) { @@ -2049,6 +2067,7 @@ function ComposerFooter({ 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 ( <> - + Choose post languages} subtitleText={ @@ -72,7 +90,11 @@ export function PostLanguageSelect({ {({props}) => ( - + )} @@ -122,17 +144,47 @@ export function PostLanguageSelect({ ) } -function LanguageBtn( - props: Omit & { - currentLanguages?: string[] - }, -) { +const PULSE_FADE_IN_MS = 300 +const PULSE_FADE_OUT_MS = 500 + +function LanguageBtn({ + currentLanguages: currentLanguagesProp, + nudgeAt = 0, + ...props +}: Omit & { + 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 ( ) diff --git a/src/view/com/composer/select-language/SuggestedLanguage.tsx b/src/view/com/composer/select-language/SuggestedLanguage.tsx index acb35d6d8f..b8e067ffe2 100644 --- a/src/view/com/composer/select-language/SuggestedLanguage.tsx +++ b/src/view/com/composer/select-language/SuggestedLanguage.tsx @@ -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 +} + +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(undefined) + const declinedSuggLangsRef = useRef([]) + + /* + * 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 ( - - - Are you writing in{' '} - {suggestedLanguageName}? - - - } - value={suggestedLanguage} - onAccept={onAcceptSuggestedLanguage} + ) } else if (hasSuggestedReplyLanguage) { - const suggestedLanguageName = codeToLanguageName( - replyToLanguages[0], - langPrefs.appLanguage, - ) - return ( - - - The post you're replying to was marked as being written in{' '} - {suggestedLanguageName} by its author. Would you like to reply in{' '} - {suggestedLanguageName}? - - - } - value={replyToLanguages[0]} - onAccept={onAcceptSuggestedLanguage} + ) } 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 ( + + + Are you writing in{' '} + {suggestedLanguageName}? + + + } + 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 ( + + + The post you’re replying to was marked as being written in{' '} + {suggestedLanguageName} by its author. Would you like to reply in{' '} + {suggestedLanguageName}? + + + } + 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 ( @@ -175,12 +464,20 @@ function LanguageSuggestionButton({ + + @@ -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() } diff --git a/yarn.lock b/yarn.lock index a4728474c7..a0f45c607a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"