Bluesky app fork with some witchin' additions 馃挮
at main 215 lines 6.0 kB view raw
1import {useEffect, useState} from 'react' 2import {Text as RNText, View} from 'react-native' 3import {parseLanguage} from '@atproto/api' 4import {msg} from '@lingui/core/macro' 5import {useLingui} from '@lingui/react' 6import {Trans} from '@lingui/react/macro' 7import lande from 'lande' 8 9import {code3ToCode2Strict, codeToLanguageName} from '#/locale/helpers' 10import {useLanguagePrefs} from '#/state/preferences/languages' 11import {atoms as a, useTheme} from '#/alf' 12import {Button, ButtonText} from '#/components/Button' 13import {Earth_Stroke2_Corner2_Rounded as EarthIcon} from '#/components/icons/Globe' 14import {Text} from '#/components/Typography' 15 16// fallbacks for safari 17const onIdle = 18 globalThis.requestIdleCallback || ((cb: () => void) => setTimeout(cb, 1)) 19const cancelIdle = globalThis.cancelIdleCallback || clearTimeout 20 21export function SuggestedLanguage({ 22 text, 23 replyToLanguages: replyToLanguagesProp, 24 currentLanguages, 25 onAcceptSuggestedLanguage, 26}: { 27 text: string 28 /** 29 * All languages associated with the post being replied to. 30 */ 31 replyToLanguages: string[] 32 /** 33 * All languages currently selected for the post being composed. 34 */ 35 currentLanguages: string[] 36 /** 37 * Called when the user accepts a suggested language. We only pass a single 38 * language here. If the post being replied to has multiple languages, we 39 * only suggest the first one. 40 */ 41 onAcceptSuggestedLanguage: (language: string | null) => void 42}) { 43 const langPrefs = useLanguagePrefs() 44 const replyToLanguages = replyToLanguagesProp 45 .map(lang => cleanUpLanguage(lang)) 46 .filter(Boolean) as string[] 47 const [hasInteracted, setHasInteracted] = useState(false) 48 const [suggestedLanguage, setSuggestedLanguage] = useState< 49 string | undefined 50 >(undefined) 51 52 useEffect(() => { 53 if (text.length > 0 && !hasInteracted) { 54 setHasInteracted(true) 55 } 56 }, [text, hasInteracted]) 57 58 useEffect(() => { 59 const textTrimmed = text.trim() 60 61 // Don't run the language model on small posts, the results are likely 62 // to be inaccurate anyway. 63 if (textTrimmed.length < 40) { 64 setSuggestedLanguage(undefined) 65 return 66 } 67 68 const idle = onIdle(() => { 69 setSuggestedLanguage(guessLanguage(textTrimmed)) 70 }) 71 72 return () => cancelIdle(idle) 73 }, [text]) 74 75 /* 76 * We've detected a language, and the user hasn't already selected it. 77 */ 78 const hasLanguageSuggestion = 79 suggestedLanguage && !currentLanguages.includes(suggestedLanguage) 80 /* 81 * We have not detected a different language, and the user is not already 82 * using or has not already selected one of the languages of the post they 83 * are replying to. 84 */ 85 const hasSuggestedReplyLanguage = 86 !hasInteracted && 87 !suggestedLanguage && 88 replyToLanguages.length && 89 !replyToLanguages.some(l => currentLanguages.includes(l)) 90 91 if (hasLanguageSuggestion) { 92 const suggestedLanguageName = codeToLanguageName( 93 suggestedLanguage, 94 langPrefs.appLanguage, 95 ) 96 97 return ( 98 <LanguageSuggestionButton 99 label={ 100 <RNText> 101 <Trans> 102 Are you writing in{' '} 103 <Text style={[a.font_bold]}>{suggestedLanguageName}</Text>? 104 </Trans> 105 </RNText> 106 } 107 value={suggestedLanguage} 108 onAccept={onAcceptSuggestedLanguage} 109 /> 110 ) 111 } else if (hasSuggestedReplyLanguage) { 112 const suggestedLanguageName = codeToLanguageName( 113 replyToLanguages[0], 114 langPrefs.appLanguage, 115 ) 116 117 return ( 118 <LanguageSuggestionButton 119 label={ 120 <RNText> 121 <Trans> 122 The post you're replying to was marked as being written in{' '} 123 {suggestedLanguageName} by its author. Would you like to reply in{' '} 124 <Text style={[a.font_bold]}>{suggestedLanguageName}</Text>? 125 </Trans> 126 </RNText> 127 } 128 value={replyToLanguages[0]} 129 onAccept={onAcceptSuggestedLanguage} 130 /> 131 ) 132 } else { 133 return null 134 } 135} 136 137function LanguageSuggestionButton({ 138 label, 139 value, 140 onAccept, 141}: { 142 label: React.ReactNode 143 value: string 144 onAccept: (language: string | null) => void 145}) { 146 const t = useTheme() 147 const {_} = useLingui() 148 149 return ( 150 <View style={[a.px_lg, a.py_sm]}> 151 <View 152 style={[ 153 a.gap_md, 154 a.border, 155 a.flex_row, 156 a.align_center, 157 a.rounded_sm, 158 a.p_md, 159 a.pl_lg, 160 t.atoms.bg, 161 t.atoms.border_contrast_low, 162 ]}> 163 <EarthIcon /> 164 <View style={[a.flex_1]}> 165 <Text 166 style={[ 167 a.leading_snug, 168 { 169 maxWidth: 400, 170 }, 171 ]}> 172 {label} 173 </Text> 174 </View> 175 176 <Button 177 size="small" 178 color="secondary" 179 onPress={() => onAccept(value)} 180 label={_(msg`Accept this language suggestion`)}> 181 <ButtonText> 182 <Trans>Yes</Trans> 183 </ButtonText> 184 </Button> 185 </View> 186 </View> 187 ) 188} 189 190/** 191 * This function is using the lande language model to attempt to detect the language 192 * We want to only make suggestions when we feel a high degree of certainty 193 * The magic numbers are based on debugging sessions against some test strings 194 */ 195function guessLanguage(text: string): string | undefined { 196 const scores = lande(text).filter(([_lang, value]) => value >= 0.0002) 197 // if the model has multiple items with a score higher than 0.0002, it isn't certain enough 198 if (scores.length !== 1) { 199 return undefined 200 } 201 const [lang, value] = scores[0] 202 // if the model doesn't give a score of 0.97 or above, it isn't certain enough 203 if (value < 0.97) { 204 return undefined 205 } 206 return code3ToCode2Strict(lang) 207} 208 209function cleanUpLanguage(text: string | undefined): string | undefined { 210 if (!text) { 211 return undefined 212 } 213 214 return parseLanguage(text)?.language 215}