An ATproto social media client -- with an independent Appview.

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>

authored by Eric Bailey surfdude29 Elijah Seed-Arita and committed by GitHub 02b189a4 5fd52b3d

Changed files
+241 -63
src
+1 -1
src/state/persisted/schema.ts
··· 71 71 contentLanguages: z.array(z.string()), 72 72 /** 73 73 * The language(s) the user is currently posting in, configured within the 74 - * composer. Multiple languages are psearate by commas. 74 + * composer. Multiple languages are separated by commas. 75 75 * 76 76 * BCP-47 2-letter language code without region. 77 77 */
+4
src/state/preferences/languages.tsx
··· 156 156 return postLanguage.split(',').filter(Boolean) 157 157 } 158 158 159 + export function fromPostLanguages(languages: string[]): string { 160 + return languages.filter(Boolean).join(',') 161 + } 162 + 159 163 export function hasPostLanguage(postLanguage: string, code2: string): boolean { 160 164 return toPostLanguages(postLanguage).includes(code2) 161 165 }
+55 -6
src/view/com/composer/Composer.tsx
··· 88 88 import {useModalControls} from '#/state/modals' 89 89 import {useRequireAltTextEnabled} from '#/state/preferences' 90 90 import { 91 + fromPostLanguages, 91 92 toPostLanguages, 92 93 useLanguagePrefs, 93 94 useLanguagePrefsApi, ··· 197 198 const [publishingStage, setPublishingStage] = useState('') 198 199 const [error, setError] = useState('') 199 200 201 + /** 202 + * A temporary local reference to a language suggestion that the user has 203 + * accepted. This overrides the global post language preference, but is not 204 + * stored permanently. 205 + */ 206 + const [acceptedLanguageSuggestion, setAcceptedLanguageSuggestion] = useState< 207 + string | null 208 + >(null) 209 + 210 + /** 211 + * The language(s) of the post being replied to. 212 + */ 213 + const [replyToLanguages, setReplyToLanguages] = useState<string[]>( 214 + replyTo?.langs || [], 215 + ) 216 + 217 + /** 218 + * The currently selected languages of the post. Prefer local temporary 219 + * language suggestion over global lang prefs, if available. 220 + */ 221 + const currentLanguages = useMemo( 222 + () => 223 + acceptedLanguageSuggestion 224 + ? [acceptedLanguageSuggestion] 225 + : toPostLanguages(langPrefs.postLanguage), 226 + [acceptedLanguageSuggestion, langPrefs.postLanguage], 227 + ) 228 + 229 + /** 230 + * When the user selects a language from the composer language selector, 231 + * clear any temporary language suggestions they may have selected 232 + * previously, and any we might try to suggest to them. 233 + */ 234 + const onSelectLanguage = () => { 235 + setAcceptedLanguageSuggestion(null) 236 + setReplyToLanguages([]) 237 + } 238 + 200 239 const [composerState, composerDispatch] = useReducer( 201 240 composerReducer, 202 241 { ··· 414 453 thread, 415 454 replyTo: replyTo?.uri, 416 455 onStateChange: setPublishingStage, 417 - langs: toPostLanguages(langPrefs.postLanguage), 456 + langs: currentLanguages, 418 457 }) 419 458 ).uris[0] 420 459 ··· 490 529 isPartOfThread: thread.posts.length > 1, 491 530 hasLink: !!post.embed.link, 492 531 hasQuote: !!post.embed.quote, 493 - langs: langPrefs.postLanguage, 532 + langs: fromPostLanguages(currentLanguages), 494 533 logContext: 'Composer', 495 534 }) 496 535 index++ ··· 557 596 thread, 558 597 canPost, 559 598 isPublishing, 560 - langPrefs.postLanguage, 599 + currentLanguages, 561 600 onClose, 562 601 onPost, 563 602 onPostSuccess, ··· 654 693 <> 655 694 <SuggestedLanguage 656 695 text={activePost.richtext.text} 657 - // NOTE(@elijaharita): currently just choosing the first language if any exists 658 - replyToLanguage={replyTo?.langs?.[0]} 696 + replyToLanguages={replyToLanguages} 697 + currentLanguages={currentLanguages} 698 + onAcceptSuggestedLanguage={setAcceptedLanguageSuggestion} 659 699 /> 660 700 <ComposerPills 661 701 isReply={!!replyTo} ··· 678 718 type: 'add_post', 679 719 }) 680 720 }} 721 + currentLanguages={currentLanguages} 722 + onSelectLanguage={onSelectLanguage} 681 723 /> 682 724 </> 683 725 ) ··· 1289 1331 onEmojiButtonPress, 1290 1332 onSelectVideo, 1291 1333 onAddPost, 1334 + currentLanguages, 1335 + onSelectLanguage, 1292 1336 }: { 1293 1337 post: PostDraft 1294 1338 dispatch: (action: PostAction) => void ··· 1297 1341 onError: (error: string) => void 1298 1342 onSelectVideo: (postId: string, asset: ImagePickerAsset) => void 1299 1343 onAddPost: () => void 1344 + currentLanguages: string[] 1345 + onSelectLanguage?: (language: string) => void 1300 1346 }) { 1301 1347 const t = useTheme() 1302 1348 const {_} = useLingui() ··· 1450 1496 <PlusIcon size="lg" /> 1451 1497 </Button> 1452 1498 )} 1453 - <PostLanguageSelect /> 1499 + <PostLanguageSelect 1500 + currentLanguages={currentLanguages} 1501 + onSelectLanguage={onSelectLanguage} 1502 + /> 1454 1503 <CharProgress 1455 1504 count={post.shortenedGraphemeLength} 1456 1505 style={{width: 65}}
+35 -9
src/view/com/composer/select-language/PostLanguageSelect.tsx
··· 17 17 import {Text} from '#/components/Typography' 18 18 import {PostLanguageSelectDialog} from './PostLanguageSelectDialog' 19 19 20 - export function PostLanguageSelect() { 20 + export function PostLanguageSelect({ 21 + currentLanguages: currentLanguagesProp, 22 + onSelectLanguage, 23 + }: { 24 + currentLanguages?: string[] 25 + onSelectLanguage?: (language: string) => void 26 + }) { 21 27 const {_} = useLingui() 22 28 const langPrefs = useLanguagePrefs() 23 29 const setLangPrefs = useLanguagePrefsApi() ··· 26 32 const dedupedHistory = Array.from( 27 33 new Set([...langPrefs.postLanguageHistory, langPrefs.postLanguage]), 28 34 ) 35 + 36 + const currentLanguages = 37 + currentLanguagesProp ?? toPostLanguages(langPrefs.postLanguage) 29 38 30 39 if ( 31 40 dedupedHistory.length === 1 && ··· 34 43 return ( 35 44 <> 36 45 <LanguageBtn onPress={languageDialogControl.open} /> 37 - <PostLanguageSelectDialog control={languageDialogControl} /> 46 + <PostLanguageSelectDialog 47 + control={languageDialogControl} 48 + currentLanguages={currentLanguages} 49 + /> 38 50 </> 39 51 ) 40 52 } ··· 43 55 <> 44 56 <Menu.Root> 45 57 <Menu.Trigger label={_(msg`Select post language`)}> 46 - {({props}) => <LanguageBtn {...props} />} 58 + {({props}) => ( 59 + <LanguageBtn currentLanguages={currentLanguages} {...props} /> 60 + )} 47 61 </Menu.Trigger> 48 62 <Menu.Outer> 49 63 <Menu.Group> ··· 56 70 <Menu.Item 57 71 key={historyItem} 58 72 label={_(msg`Select ${langName}`)} 59 - onPress={() => setLangPrefs.setPostLanguage(historyItem)}> 73 + onPress={() => { 74 + setLangPrefs.setPostLanguage(historyItem) 75 + onSelectLanguage?.(historyItem) 76 + }}> 60 77 <Menu.ItemText>{langName}</Menu.ItemText> 61 78 <Menu.ItemRadio 62 - selected={historyItem === langPrefs.postLanguage} 79 + selected={currentLanguages.includes(historyItem)} 63 80 /> 64 81 </Menu.Item> 65 82 ) ··· 77 94 </Menu.Outer> 78 95 </Menu.Root> 79 96 80 - <PostLanguageSelectDialog control={languageDialogControl} /> 97 + <PostLanguageSelectDialog 98 + control={languageDialogControl} 99 + currentLanguages={currentLanguages} 100 + onSelectLanguage={onSelectLanguage} 101 + /> 81 102 </> 82 103 ) 83 104 } 84 105 85 - function LanguageBtn(props: Omit<ButtonProps, 'label' | 'children'>) { 106 + function LanguageBtn( 107 + props: Omit<ButtonProps, 'label' | 'children'> & { 108 + currentLanguages?: string[] 109 + }, 110 + ) { 86 111 const {_} = useLingui() 87 112 const langPrefs = useLanguagePrefs() 88 113 const t = useTheme() 89 114 90 115 const postLanguagesPref = toPostLanguages(langPrefs.postLanguage) 116 + const currentLanguages = props.currentLanguages ?? postLanguagesPref 91 117 92 118 return ( 93 119 <Button ··· 106 132 {({pressed, hovered}) => { 107 133 const color = 108 134 pressed || hovered ? t.palette.primary_300 : t.palette.primary_500 109 - if (postLanguagesPref.length > 0) { 135 + if (currentLanguages.length > 0) { 110 136 return ( 111 137 <Text 112 138 style={[ ··· 117 143 {maxWidth: 100}, 118 144 ]} 119 145 numberOfLines={1}> 120 - {postLanguagesPref 146 + {currentLanguages 121 147 .map(lang => codeToLanguageName(lang, langPrefs.appLanguage)) 122 148 .join(', ')} 123 149 </Text>
+25 -3
src/view/com/composer/select-language/PostLanguageSelectDialog.tsx
··· 8 8 import {type Language, LANGUAGES, LANGUAGES_MAP_CODE2} from '#/locale/languages' 9 9 import {isNative, isWeb} from '#/platform/detection' 10 10 import { 11 + toPostLanguages, 11 12 useLanguagePrefs, 12 13 useLanguagePrefsApi, 13 14 } from '#/state/preferences/languages' ··· 23 24 24 25 export function PostLanguageSelectDialog({ 25 26 control, 27 + /** 28 + * Optionally can be passed to show different values than what is saved in 29 + * langPrefs. 30 + */ 31 + currentLanguages, 32 + onSelectLanguage, 26 33 }: { 27 34 control: Dialog.DialogControlProps 35 + currentLanguages?: string[] 36 + onSelectLanguage?: (language: string) => void 28 37 }) { 29 38 const {height} = useWindowDimensions() 30 39 const insets = useSafeAreaInsets() ··· 40 49 nativeOptions={{minHeight: height - insets.top}}> 41 50 <Dialog.Handle /> 42 51 <ErrorBoundary renderError={renderErrorBoundary}> 43 - <DialogInner /> 52 + <DialogInner 53 + currentLanguages={currentLanguages} 54 + onSelectLanguage={onSelectLanguage} 55 + /> 44 56 </ErrorBoundary> 45 57 </Dialog.Outer> 46 58 ) 47 59 } 48 60 49 - export function DialogInner() { 61 + export function DialogInner({ 62 + currentLanguages, 63 + onSelectLanguage, 64 + }: { 65 + currentLanguages?: string[] 66 + onSelectLanguage?: (language: string) => void 67 + }) { 50 68 const control = Dialog.useDialogContext() 51 69 const [headerHeight, setHeaderHeight] = useState(0) 52 70 ··· 63 81 }, []) 64 82 65 83 const langPrefs = useLanguagePrefs() 84 + const postLanguagesPref = 85 + currentLanguages ?? toPostLanguages(langPrefs.postLanguage) 86 + 66 87 const [checkedLanguagesCode2, setCheckedLanguagesCode2] = useState<string[]>( 67 - langPrefs.postLanguage.split(',') || [langPrefs.primaryLanguage], 88 + postLanguagesPref || [langPrefs.primaryLanguage], 68 89 ) 69 90 const [search, setSearch] = useState('') 70 91 ··· 79 100 langsString = langPrefs.primaryLanguage 80 101 } 81 102 setLangPrefs.setPostLanguage(langsString) 103 + onSelectLanguage?.(langsString) 82 104 }) 83 105 } 84 106
+121 -44
src/view/com/composer/select-language/SuggestedLanguage.tsx
··· 1 1 import {useEffect, useState} from 'react' 2 - import {View} from 'react-native' 2 + import {Text as RNText, View} from 'react-native' 3 3 import {parseLanguage} from '@atproto/api' 4 4 import {msg, Trans} from '@lingui/macro' 5 5 import {useLingui} from '@lingui/react' 6 6 import lande from 'lande' 7 7 8 8 import {code3ToCode2Strict, codeToLanguageName} from '#/locale/helpers' 9 - import { 10 - toPostLanguages, 11 - useLanguagePrefs, 12 - useLanguagePrefsApi, 13 - } from '#/state/preferences/languages' 9 + import {useLanguagePrefs} from '#/state/preferences/languages' 14 10 import {atoms as a, useTheme} from '#/alf' 15 11 import {Button, ButtonText} from '#/components/Button' 16 12 import {Earth_Stroke2_Corner2_Rounded as EarthIcon} from '#/components/icons/Globe' ··· 22 18 23 19 export function SuggestedLanguage({ 24 20 text, 25 - replyToLanguage: replyToLanguageProp, 21 + replyToLanguages: replyToLanguagesProp, 22 + currentLanguages, 23 + onAcceptSuggestedLanguage, 26 24 }: { 27 25 text: string 28 - replyToLanguage?: string 26 + /** 27 + * All languages associated with the post being replied to. 28 + */ 29 + replyToLanguages: string[] 30 + /** 31 + * All languages currently selected for the post being composed. 32 + */ 33 + currentLanguages: string[] 34 + /** 35 + * Called when the user accepts a suggested language. We only pass a single 36 + * language here. If the post being replied to has multiple languages, we 37 + * only suggest the first one. 38 + */ 39 + onAcceptSuggestedLanguage: (language: string | null) => void 29 40 }) { 30 - const replyToLanguage = cleanUpLanguage(replyToLanguageProp) 41 + const langPrefs = useLanguagePrefs() 42 + const replyToLanguages = replyToLanguagesProp 43 + .map(lang => cleanUpLanguage(lang)) 44 + .filter(Boolean) as string[] 45 + const [hasInteracted, setHasInteracted] = useState(false) 31 46 const [suggestedLanguage, setSuggestedLanguage] = useState< 32 47 string | undefined 33 - >(text.length === 0 ? replyToLanguage : undefined) 34 - const langPrefs = useLanguagePrefs() 35 - const setLangPrefs = useLanguagePrefsApi() 36 - const t = useTheme() 37 - const {_} = useLingui() 48 + >(undefined) 38 49 39 50 useEffect(() => { 40 - // For replies, suggest the language of the post being replied to if no text 41 - // has been typed yet 42 - if (replyToLanguage && text.length === 0) { 43 - setSuggestedLanguage(replyToLanguage) 44 - return 51 + if (text.length > 0 && !hasInteracted) { 52 + setHasInteracted(true) 45 53 } 54 + }, [text, hasInteracted]) 46 55 56 + useEffect(() => { 47 57 const textTrimmed = text.trim() 48 58 49 59 // Don't run the language model on small posts, the results are likely ··· 58 68 }) 59 69 60 70 return () => cancelIdle(idle) 61 - }, [text, replyToLanguage]) 71 + }, [text]) 72 + 73 + /* 74 + * We've detected a language, and the user hasn't already selected it. 75 + */ 76 + const hasLanguageSuggestion = 77 + suggestedLanguage && !currentLanguages.includes(suggestedLanguage) 78 + /* 79 + * We have not detected a different language, and the user is not already 80 + * using or has not already selected one of the languages of the post they 81 + * are replying to. 82 + */ 83 + const hasSuggestedReplyLanguage = 84 + !hasInteracted && 85 + !suggestedLanguage && 86 + replyToLanguages.length && 87 + !replyToLanguages.some(l => currentLanguages.includes(l)) 62 88 63 - if ( 64 - suggestedLanguage && 65 - !toPostLanguages(langPrefs.postLanguage).includes(suggestedLanguage) 66 - ) { 89 + if (hasLanguageSuggestion) { 67 90 const suggestedLanguageName = codeToLanguageName( 68 91 suggestedLanguage, 69 92 langPrefs.appLanguage, 70 93 ) 71 94 72 95 return ( 96 + <LanguageSuggestionButton 97 + label={ 98 + <RNText> 99 + <Trans> 100 + Are you writing in{' '} 101 + <Text style={[a.font_bold]}>{suggestedLanguageName}</Text>? 102 + </Trans> 103 + </RNText> 104 + } 105 + value={suggestedLanguage} 106 + onAccept={onAcceptSuggestedLanguage} 107 + /> 108 + ) 109 + } else if (hasSuggestedReplyLanguage) { 110 + const suggestedLanguageName = codeToLanguageName( 111 + replyToLanguages[0], 112 + langPrefs.appLanguage, 113 + ) 114 + 115 + return ( 116 + <LanguageSuggestionButton 117 + label={ 118 + <RNText> 119 + <Trans> 120 + The post you're replying to was marked as being written in{' '} 121 + {suggestedLanguageName} by its author. Would you like to reply in{' '} 122 + <Text style={[a.font_bold]}>{suggestedLanguageName}</Text>? 123 + </Trans> 124 + </RNText> 125 + } 126 + value={replyToLanguages[0]} 127 + onAccept={onAcceptSuggestedLanguage} 128 + /> 129 + ) 130 + } else { 131 + return null 132 + } 133 + } 134 + 135 + function LanguageSuggestionButton({ 136 + label, 137 + value, 138 + onAccept, 139 + }: { 140 + label: React.ReactNode 141 + value: string 142 + onAccept: (language: string | null) => void 143 + }) { 144 + const t = useTheme() 145 + const {_} = useLingui() 146 + 147 + return ( 148 + <View style={[a.px_lg, a.py_sm]}> 73 149 <View 74 150 style={[ 75 - t.atoms.border_contrast_low, 76 - a.gap_sm, 151 + a.gap_md, 77 152 a.border, 78 153 a.flex_row, 79 154 a.align_center, 80 155 a.rounded_sm, 81 - a.px_lg, 82 - a.py_md, 83 - a.mx_md, 84 - a.my_sm, 156 + a.p_md, 157 + a.pl_lg, 85 158 t.atoms.bg, 159 + t.atoms.border_contrast_low, 86 160 ]}> 87 161 <EarthIcon /> 88 - <Text style={[a.flex_1]}> 89 - <Trans> 90 - Are you writing in{' '} 91 - <Text style={[a.font_semi_bold]}>{suggestedLanguageName}</Text>? 92 - </Trans> 93 - </Text> 162 + <View style={[a.flex_1]}> 163 + <Text 164 + style={[ 165 + a.flex_1, 166 + a.leading_snug, 167 + { 168 + maxWidth: 400, 169 + }, 170 + ]}> 171 + {label} 172 + </Text> 173 + </View> 94 174 95 175 <Button 96 - color="secondary" 97 176 size="small" 98 - variant="solid" 99 - onPress={() => setLangPrefs.setPostLanguage(suggestedLanguage)} 100 - label={_(msg`Change post language to ${suggestedLanguageName}`)}> 177 + color="secondary" 178 + onPress={() => onAccept(value)} 179 + label={_(msg`Accept this language suggestion`)}> 101 180 <ButtonText> 102 181 <Trans>Yes</Trans> 103 182 </ButtonText> 104 183 </Button> 105 184 </View> 106 - ) 107 - } else { 108 - return null 109 - } 185 + </View> 186 + ) 110 187 } 111 188 112 189 /**