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} 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}