Language selection and suggestion UX improvements (#9067)

* feat: don't retain accepted language suggestion after finishing or exiting post (#8886)

* feat: don't retain accepted language suggestion after finishing or exiting post

* fix: rebase fixes

* fix: rebase fixes

* chore: lint

* Rename onChange for clarity

* Improve logic in composer

* Handle user override more explicitly

* Drill in onSelectLanguage callback into dialog too

* Fix typo

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Make text crystal clear

* Handle multiple languages

---------

Co-authored-by: Elijah Seed-Arita <elijaharita@gmail.com>
Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>
This commit is contained in:
Eric Bailey
2025-10-02 09:18:52 -05:00
committed by GitHub
parent 5fd52b3d30
commit 02b189a40e
6 changed files with 241 additions and 63 deletions
+1 -1
View File
@@ -71,7 +71,7 @@ const schema = z.object({
contentLanguages: z.array(z.string()), contentLanguages: z.array(z.string()),
/** /**
* The language(s) the user is currently posting in, configured within the * The language(s) the user is currently posting in, configured within the
* composer. Multiple languages are psearate by commas. * composer. Multiple languages are separated by commas.
* *
* BCP-47 2-letter language code without region. * BCP-47 2-letter language code without region.
*/ */
+4
View File
@@ -156,6 +156,10 @@ export function toPostLanguages(postLanguage: string): string[] {
return postLanguage.split(',').filter(Boolean) return postLanguage.split(',').filter(Boolean)
} }
export function fromPostLanguages(languages: string[]): string {
return languages.filter(Boolean).join(',')
}
export function hasPostLanguage(postLanguage: string, code2: string): boolean { export function hasPostLanguage(postLanguage: string, code2: string): boolean {
return toPostLanguages(postLanguage).includes(code2) return toPostLanguages(postLanguage).includes(code2)
} }
+55 -6
View File
@@ -88,6 +88,7 @@ import {
import {useModalControls} from '#/state/modals' import {useModalControls} from '#/state/modals'
import {useRequireAltTextEnabled} from '#/state/preferences' import {useRequireAltTextEnabled} from '#/state/preferences'
import { import {
fromPostLanguages,
toPostLanguages, toPostLanguages,
useLanguagePrefs, useLanguagePrefs,
useLanguagePrefsApi, useLanguagePrefsApi,
@@ -197,6 +198,44 @@ export const ComposePost = ({
const [publishingStage, setPublishingStage] = useState('') const [publishingStage, setPublishingStage] = useState('')
const [error, setError] = useState('') const [error, setError] = useState('')
/**
* A temporary local reference to a language suggestion that the user has
* accepted. This overrides the global post language preference, but is not
* stored permanently.
*/
const [acceptedLanguageSuggestion, setAcceptedLanguageSuggestion] = useState<
string | null
>(null)
/**
* The language(s) of the post being replied to.
*/
const [replyToLanguages, setReplyToLanguages] = useState<string[]>(
replyTo?.langs || [],
)
/**
* The currently selected languages of the post. Prefer local temporary
* language suggestion over global lang prefs, if available.
*/
const currentLanguages = useMemo(
() =>
acceptedLanguageSuggestion
? [acceptedLanguageSuggestion]
: toPostLanguages(langPrefs.postLanguage),
[acceptedLanguageSuggestion, langPrefs.postLanguage],
)
/**
* When the user selects a language from the composer language selector,
* clear any temporary language suggestions they may have selected
* previously, and any we might try to suggest to them.
*/
const onSelectLanguage = () => {
setAcceptedLanguageSuggestion(null)
setReplyToLanguages([])
}
const [composerState, composerDispatch] = useReducer( const [composerState, composerDispatch] = useReducer(
composerReducer, composerReducer,
{ {
@@ -414,7 +453,7 @@ export const ComposePost = ({
thread, thread,
replyTo: replyTo?.uri, replyTo: replyTo?.uri,
onStateChange: setPublishingStage, onStateChange: setPublishingStage,
langs: toPostLanguages(langPrefs.postLanguage), langs: currentLanguages,
}) })
).uris[0] ).uris[0]
@@ -490,7 +529,7 @@ export const ComposePost = ({
isPartOfThread: thread.posts.length > 1, isPartOfThread: thread.posts.length > 1,
hasLink: !!post.embed.link, hasLink: !!post.embed.link,
hasQuote: !!post.embed.quote, hasQuote: !!post.embed.quote,
langs: langPrefs.postLanguage, langs: fromPostLanguages(currentLanguages),
logContext: 'Composer', logContext: 'Composer',
}) })
index++ index++
@@ -557,7 +596,7 @@ export const ComposePost = ({
thread, thread,
canPost, canPost,
isPublishing, isPublishing,
langPrefs.postLanguage, currentLanguages,
onClose, onClose,
onPost, onPost,
onPostSuccess, onPostSuccess,
@@ -654,8 +693,9 @@ export const ComposePost = ({
<> <>
<SuggestedLanguage <SuggestedLanguage
text={activePost.richtext.text} text={activePost.richtext.text}
// NOTE(@elijaharita): currently just choosing the first language if any exists replyToLanguages={replyToLanguages}
replyToLanguage={replyTo?.langs?.[0]} currentLanguages={currentLanguages}
onAcceptSuggestedLanguage={setAcceptedLanguageSuggestion}
/> />
<ComposerPills <ComposerPills
isReply={!!replyTo} isReply={!!replyTo}
@@ -678,6 +718,8 @@ export const ComposePost = ({
type: 'add_post', type: 'add_post',
}) })
}} }}
currentLanguages={currentLanguages}
onSelectLanguage={onSelectLanguage}
/> />
</> </>
) )
@@ -1289,6 +1331,8 @@ function ComposerFooter({
onEmojiButtonPress, onEmojiButtonPress,
onSelectVideo, onSelectVideo,
onAddPost, onAddPost,
currentLanguages,
onSelectLanguage,
}: { }: {
post: PostDraft post: PostDraft
dispatch: (action: PostAction) => void dispatch: (action: PostAction) => void
@@ -1297,6 +1341,8 @@ function ComposerFooter({
onError: (error: string) => void onError: (error: string) => void
onSelectVideo: (postId: string, asset: ImagePickerAsset) => void onSelectVideo: (postId: string, asset: ImagePickerAsset) => void
onAddPost: () => void onAddPost: () => void
currentLanguages: string[]
onSelectLanguage?: (language: string) => void
}) { }) {
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {_} = useLingui()
@@ -1450,7 +1496,10 @@ function ComposerFooter({
<PlusIcon size="lg" /> <PlusIcon size="lg" />
</Button> </Button>
)} )}
<PostLanguageSelect /> <PostLanguageSelect
currentLanguages={currentLanguages}
onSelectLanguage={onSelectLanguage}
/>
<CharProgress <CharProgress
count={post.shortenedGraphemeLength} count={post.shortenedGraphemeLength}
style={{width: 65}} style={{width: 65}}
@@ -17,7 +17,13 @@ import * as Menu from '#/components/Menu'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {PostLanguageSelectDialog} from './PostLanguageSelectDialog' import {PostLanguageSelectDialog} from './PostLanguageSelectDialog'
export function PostLanguageSelect() { export function PostLanguageSelect({
currentLanguages: currentLanguagesProp,
onSelectLanguage,
}: {
currentLanguages?: string[]
onSelectLanguage?: (language: string) => void
}) {
const {_} = useLingui() const {_} = useLingui()
const langPrefs = useLanguagePrefs() const langPrefs = useLanguagePrefs()
const setLangPrefs = useLanguagePrefsApi() const setLangPrefs = useLanguagePrefsApi()
@@ -27,6 +33,9 @@ export function PostLanguageSelect() {
new Set([...langPrefs.postLanguageHistory, langPrefs.postLanguage]), new Set([...langPrefs.postLanguageHistory, langPrefs.postLanguage]),
) )
const currentLanguages =
currentLanguagesProp ?? toPostLanguages(langPrefs.postLanguage)
if ( if (
dedupedHistory.length === 1 && dedupedHistory.length === 1 &&
dedupedHistory[0] === langPrefs.postLanguage dedupedHistory[0] === langPrefs.postLanguage
@@ -34,7 +43,10 @@ export function PostLanguageSelect() {
return ( return (
<> <>
<LanguageBtn onPress={languageDialogControl.open} /> <LanguageBtn onPress={languageDialogControl.open} />
<PostLanguageSelectDialog control={languageDialogControl} /> <PostLanguageSelectDialog
control={languageDialogControl}
currentLanguages={currentLanguages}
/>
</> </>
) )
} }
@@ -43,7 +55,9 @@ export function PostLanguageSelect() {
<> <>
<Menu.Root> <Menu.Root>
<Menu.Trigger label={_(msg`Select post language`)}> <Menu.Trigger label={_(msg`Select post language`)}>
{({props}) => <LanguageBtn {...props} />} {({props}) => (
<LanguageBtn currentLanguages={currentLanguages} {...props} />
)}
</Menu.Trigger> </Menu.Trigger>
<Menu.Outer> <Menu.Outer>
<Menu.Group> <Menu.Group>
@@ -56,10 +70,13 @@ export function PostLanguageSelect() {
<Menu.Item <Menu.Item
key={historyItem} key={historyItem}
label={_(msg`Select ${langName}`)} label={_(msg`Select ${langName}`)}
onPress={() => setLangPrefs.setPostLanguage(historyItem)}> onPress={() => {
setLangPrefs.setPostLanguage(historyItem)
onSelectLanguage?.(historyItem)
}}>
<Menu.ItemText>{langName}</Menu.ItemText> <Menu.ItemText>{langName}</Menu.ItemText>
<Menu.ItemRadio <Menu.ItemRadio
selected={historyItem === langPrefs.postLanguage} selected={currentLanguages.includes(historyItem)}
/> />
</Menu.Item> </Menu.Item>
) )
@@ -77,17 +94,26 @@ export function PostLanguageSelect() {
</Menu.Outer> </Menu.Outer>
</Menu.Root> </Menu.Root>
<PostLanguageSelectDialog control={languageDialogControl} /> <PostLanguageSelectDialog
control={languageDialogControl}
currentLanguages={currentLanguages}
onSelectLanguage={onSelectLanguage}
/>
</> </>
) )
} }
function LanguageBtn(props: Omit<ButtonProps, 'label' | 'children'>) { function LanguageBtn(
props: Omit<ButtonProps, 'label' | 'children'> & {
currentLanguages?: string[]
},
) {
const {_} = useLingui() const {_} = useLingui()
const langPrefs = useLanguagePrefs() const langPrefs = useLanguagePrefs()
const t = useTheme() const t = useTheme()
const postLanguagesPref = toPostLanguages(langPrefs.postLanguage) const postLanguagesPref = toPostLanguages(langPrefs.postLanguage)
const currentLanguages = props.currentLanguages ?? postLanguagesPref
return ( return (
<Button <Button
@@ -106,7 +132,7 @@ function LanguageBtn(props: Omit<ButtonProps, 'label' | 'children'>) {
{({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 (postLanguagesPref.length > 0) { if (currentLanguages.length > 0) {
return ( return (
<Text <Text
style={[ style={[
@@ -117,7 +143,7 @@ function LanguageBtn(props: Omit<ButtonProps, 'label' | 'children'>) {
{maxWidth: 100}, {maxWidth: 100},
]} ]}
numberOfLines={1}> numberOfLines={1}>
{postLanguagesPref {currentLanguages
.map(lang => codeToLanguageName(lang, langPrefs.appLanguage)) .map(lang => codeToLanguageName(lang, langPrefs.appLanguage))
.join(', ')} .join(', ')}
</Text> </Text>
@@ -8,6 +8,7 @@ import {languageName} from '#/locale/helpers'
import {type Language, LANGUAGES, LANGUAGES_MAP_CODE2} from '#/locale/languages' import {type Language, LANGUAGES, LANGUAGES_MAP_CODE2} from '#/locale/languages'
import {isNative, isWeb} from '#/platform/detection' import {isNative, isWeb} from '#/platform/detection'
import { import {
toPostLanguages,
useLanguagePrefs, useLanguagePrefs,
useLanguagePrefsApi, useLanguagePrefsApi,
} from '#/state/preferences/languages' } from '#/state/preferences/languages'
@@ -23,8 +24,16 @@ import {Text} from '#/components/Typography'
export function PostLanguageSelectDialog({ export function PostLanguageSelectDialog({
control, control,
/**
* Optionally can be passed to show different values than what is saved in
* langPrefs.
*/
currentLanguages,
onSelectLanguage,
}: { }: {
control: Dialog.DialogControlProps control: Dialog.DialogControlProps
currentLanguages?: string[]
onSelectLanguage?: (language: string) => void
}) { }) {
const {height} = useWindowDimensions() const {height} = useWindowDimensions()
const insets = useSafeAreaInsets() const insets = useSafeAreaInsets()
@@ -40,13 +49,22 @@ export function PostLanguageSelectDialog({
nativeOptions={{minHeight: height - insets.top}}> nativeOptions={{minHeight: height - insets.top}}>
<Dialog.Handle /> <Dialog.Handle />
<ErrorBoundary renderError={renderErrorBoundary}> <ErrorBoundary renderError={renderErrorBoundary}>
<DialogInner /> <DialogInner
currentLanguages={currentLanguages}
onSelectLanguage={onSelectLanguage}
/>
</ErrorBoundary> </ErrorBoundary>
</Dialog.Outer> </Dialog.Outer>
) )
} }
export function DialogInner() { export function DialogInner({
currentLanguages,
onSelectLanguage,
}: {
currentLanguages?: string[]
onSelectLanguage?: (language: string) => void
}) {
const control = Dialog.useDialogContext() const control = Dialog.useDialogContext()
const [headerHeight, setHeaderHeight] = useState(0) const [headerHeight, setHeaderHeight] = useState(0)
@@ -63,8 +81,11 @@ export function DialogInner() {
}, []) }, [])
const langPrefs = useLanguagePrefs() const langPrefs = useLanguagePrefs()
const postLanguagesPref =
currentLanguages ?? toPostLanguages(langPrefs.postLanguage)
const [checkedLanguagesCode2, setCheckedLanguagesCode2] = useState<string[]>( const [checkedLanguagesCode2, setCheckedLanguagesCode2] = useState<string[]>(
langPrefs.postLanguage.split(',') || [langPrefs.primaryLanguage], postLanguagesPref || [langPrefs.primaryLanguage],
) )
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
@@ -79,6 +100,7 @@ export function DialogInner() {
langsString = langPrefs.primaryLanguage langsString = langPrefs.primaryLanguage
} }
setLangPrefs.setPostLanguage(langsString) setLangPrefs.setPostLanguage(langsString)
onSelectLanguage?.(langsString)
}) })
} }
@@ -1,16 +1,12 @@
import {useEffect, useState} from 'react' import {useEffect, useState} from 'react'
import {View} from 'react-native' import {Text as RNText, View} from 'react-native'
import {parseLanguage} from '@atproto/api' import {parseLanguage} from '@atproto/api'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import lande from 'lande' import lande from 'lande'
import {code3ToCode2Strict, codeToLanguageName} from '#/locale/helpers' import {code3ToCode2Strict, codeToLanguageName} from '#/locale/helpers'
import { import {useLanguagePrefs} from '#/state/preferences/languages'
toPostLanguages,
useLanguagePrefs,
useLanguagePrefsApi,
} from '#/state/preferences/languages'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button' import {Button, ButtonText} from '#/components/Button'
import {Earth_Stroke2_Corner2_Rounded as EarthIcon} from '#/components/icons/Globe' import {Earth_Stroke2_Corner2_Rounded as EarthIcon} from '#/components/icons/Globe'
@@ -22,28 +18,42 @@ const cancelIdle = globalThis.cancelIdleCallback || clearTimeout
export function SuggestedLanguage({ export function SuggestedLanguage({
text, text,
replyToLanguage: replyToLanguageProp, replyToLanguages: replyToLanguagesProp,
currentLanguages,
onAcceptSuggestedLanguage,
}: { }: {
text: string text: string
replyToLanguage?: string /**
* All languages associated with the post being replied to.
*/
replyToLanguages: string[]
/**
* All languages currently selected for the post being composed.
*/
currentLanguages: string[]
/**
* Called when the user accepts a suggested language. We only pass a single
* language here. If the post being replied to has multiple languages, we
* only suggest the first one.
*/
onAcceptSuggestedLanguage: (language: string | null) => void
}) { }) {
const replyToLanguage = cleanUpLanguage(replyToLanguageProp) const langPrefs = useLanguagePrefs()
const replyToLanguages = replyToLanguagesProp
.map(lang => cleanUpLanguage(lang))
.filter(Boolean) as string[]
const [hasInteracted, setHasInteracted] = useState(false)
const [suggestedLanguage, setSuggestedLanguage] = useState< const [suggestedLanguage, setSuggestedLanguage] = useState<
string | undefined string | undefined
>(text.length === 0 ? replyToLanguage : undefined) >(undefined)
const langPrefs = useLanguagePrefs()
const setLangPrefs = useLanguagePrefsApi()
const t = useTheme()
const {_} = useLingui()
useEffect(() => { useEffect(() => {
// For replies, suggest the language of the post being replied to if no text if (text.length > 0 && !hasInteracted) {
// has been typed yet setHasInteracted(true)
if (replyToLanguage && text.length === 0) {
setSuggestedLanguage(replyToLanguage)
return
} }
}, [text, hasInteracted])
useEffect(() => {
const textTrimmed = text.trim() const textTrimmed = text.trim()
// Don't run the language model on small posts, the results are likely // Don't run the language model on small posts, the results are likely
@@ -58,55 +68,122 @@ export function SuggestedLanguage({
}) })
return () => cancelIdle(idle) return () => cancelIdle(idle)
}, [text, replyToLanguage]) }, [text])
if ( /*
suggestedLanguage && * We've detected a language, and the user hasn't already selected it.
!toPostLanguages(langPrefs.postLanguage).includes(suggestedLanguage) */
) { const hasLanguageSuggestion =
suggestedLanguage && !currentLanguages.includes(suggestedLanguage)
/*
* 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 hasSuggestedReplyLanguage =
!hasInteracted &&
!suggestedLanguage &&
replyToLanguages.length &&
!replyToLanguages.some(l => currentLanguages.includes(l))
if (hasLanguageSuggestion) {
const suggestedLanguageName = codeToLanguageName( const suggestedLanguageName = codeToLanguageName(
suggestedLanguage, suggestedLanguage,
langPrefs.appLanguage, langPrefs.appLanguage,
) )
return ( return (
<LanguageSuggestionButton
label={
<RNText>
<Trans>
Are you writing in{' '}
<Text style={[a.font_bold]}>{suggestedLanguageName}</Text>?
</Trans>
</RNText>
}
value={suggestedLanguage}
onAccept={onAcceptSuggestedLanguage}
/>
)
} 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}
/>
)
} else {
return null
}
}
function LanguageSuggestionButton({
label,
value,
onAccept,
}: {
label: React.ReactNode
value: string
onAccept: (language: string | null) => void
}) {
const t = useTheme()
const {_} = useLingui()
return (
<View style={[a.px_lg, a.py_sm]}>
<View <View
style={[ style={[
t.atoms.border_contrast_low, a.gap_md,
a.gap_sm,
a.border, a.border,
a.flex_row, a.flex_row,
a.align_center, a.align_center,
a.rounded_sm, a.rounded_sm,
a.px_lg, a.p_md,
a.py_md, a.pl_lg,
a.mx_md,
a.my_sm,
t.atoms.bg, t.atoms.bg,
t.atoms.border_contrast_low,
]}> ]}>
<EarthIcon /> <EarthIcon />
<Text style={[a.flex_1]}> <View style={[a.flex_1]}>
<Trans> <Text
Are you writing in{' '} style={[
<Text style={[a.font_semi_bold]}>{suggestedLanguageName}</Text>? a.flex_1,
</Trans> a.leading_snug,
</Text> {
maxWidth: 400,
},
]}>
{label}
</Text>
</View>
<Button <Button
color="secondary"
size="small" size="small"
variant="solid" color="secondary"
onPress={() => setLangPrefs.setPostLanguage(suggestedLanguage)} onPress={() => onAccept(value)}
label={_(msg`Change post language to ${suggestedLanguageName}`)}> label={_(msg`Accept this language suggestion`)}>
<ButtonText> <ButtonText>
<Trans>Yes</Trans> <Trans>Yes</Trans>
</ButtonText> </ButtonText>
</Button> </Button>
</View> </View>
) </View>
} else { )
return null
}
} }
/** /**