Bluesky app fork with some witchin' additions 馃挮
at readme-update 213 lines 5.9 kB view raw
1import {useEffect, useState} from 'react' 2import {Text as RNText, View} from 'react-native' 3import {parseLanguage} from '@atproto/api' 4import {msg, Trans} from '@lingui/macro' 5import {useLingui} from '@lingui/react' 6import lande from 'lande' 7 8import {code3ToCode2Strict, codeToLanguageName} from '#/locale/helpers' 9import {useLanguagePrefs} from '#/state/preferences/languages' 10import {atoms as a, useTheme} from '#/alf' 11import {Button, ButtonText} from '#/components/Button' 12import {Earth_Stroke2_Corner2_Rounded as EarthIcon} from '#/components/icons/Globe' 13import {Text} from '#/components/Typography' 14 15// fallbacks for safari 16const onIdle = globalThis.requestIdleCallback || (cb => setTimeout(cb, 1)) 17const cancelIdle = globalThis.cancelIdleCallback || clearTimeout 18 19export function SuggestedLanguage({ 20 text, 21 replyToLanguages: replyToLanguagesProp, 22 currentLanguages, 23 onAcceptSuggestedLanguage, 24}: { 25 text: 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 40}) { 41 const langPrefs = useLanguagePrefs() 42 const replyToLanguages = replyToLanguagesProp 43 .map(lang => cleanUpLanguage(lang)) 44 .filter(Boolean) as string[] 45 const [hasInteracted, setHasInteracted] = useState(false) 46 const [suggestedLanguage, setSuggestedLanguage] = useState< 47 string | undefined 48 >(undefined) 49 50 useEffect(() => { 51 if (text.length > 0 && !hasInteracted) { 52 setHasInteracted(true) 53 } 54 }, [text, hasInteracted]) 55 56 useEffect(() => { 57 const textTrimmed = text.trim() 58 59 // Don't run the language model on small posts, the results are likely 60 // to be inaccurate anyway. 61 if (textTrimmed.length < 40) { 62 setSuggestedLanguage(undefined) 63 return 64 } 65 66 const idle = onIdle(() => { 67 setSuggestedLanguage(guessLanguage(textTrimmed)) 68 }) 69 70 return () => cancelIdle(idle) 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)) 88 89 if (hasLanguageSuggestion) { 90 const suggestedLanguageName = codeToLanguageName( 91 suggestedLanguage, 92 langPrefs.appLanguage, 93 ) 94 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 skeet 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 135function 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]}> 149 <View 150 style={[ 151 a.gap_md, 152 a.border, 153 a.flex_row, 154 a.align_center, 155 a.rounded_sm, 156 a.p_md, 157 a.pl_lg, 158 t.atoms.bg, 159 t.atoms.border_contrast_low, 160 ]}> 161 <EarthIcon /> 162 <View style={[a.flex_1]}> 163 <Text 164 style={[ 165 a.leading_snug, 166 { 167 maxWidth: 400, 168 }, 169 ]}> 170 {label} 171 </Text> 172 </View> 173 174 <Button 175 size="small" 176 color="secondary" 177 onPress={() => onAccept(value)} 178 label={_(msg`Accept this language suggestion`)}> 179 <ButtonText> 180 <Trans>Yes</Trans> 181 </ButtonText> 182 </Button> 183 </View> 184 </View> 185 ) 186} 187 188/** 189 * This function is using the lande language model to attempt to detect the language 190 * We want to only make suggestions when we feel a high degree of certainty 191 * The magic numbers are based on debugging sessions against some test strings 192 */ 193function guessLanguage(text: string): string | undefined { 194 const scores = lande(text).filter(([_lang, value]) => value >= 0.0002) 195 // if the model has multiple items with a score higher than 0.0002, it isn't certain enough 196 if (scores.length !== 1) { 197 return undefined 198 } 199 const [lang, value] = scores[0] 200 // if the model doesn't give a score of 0.97 or above, it isn't certain enough 201 if (value < 0.97) { 202 return undefined 203 } 204 return code3ToCode2Strict(lang) 205} 206 207function cleanUpLanguage(text: string | undefined): string | undefined { 208 if (!text) { 209 return undefined 210 } 211 212 return parseLanguage(text)?.language 213}