forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
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}