forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1import {useEffect} from 'react'
2import Animated, {
3 Easing,
4 useAnimatedStyle,
5 useSharedValue,
6 withSequence,
7 withTiming,
8} from 'react-native-reanimated'
9import {msg} from '@lingui/core/macro'
10import {useLingui} from '@lingui/react'
11import {Trans} from '@lingui/react/macro'
12
13import {LANG_DROPDOWN_HITSLOP} from '#/lib/constants'
14import {codeToLanguageName} from '#/locale/helpers'
15import {
16 toPostLanguages,
17 useLanguagePrefs,
18 useLanguagePrefsApi,
19} from '#/state/preferences/languages'
20import {atoms as a, useTheme} from '#/alf'
21import {Button, type ButtonProps} from '#/components/Button'
22import * as Dialog from '#/components/Dialog'
23import {LanguageSelectDialog} from '#/components/dialogs/LanguageSelectDialog'
24import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRightIcon} from '#/components/icons/Chevron'
25import {Globe_Stroke2_Corner0_Rounded as GlobeIcon} from '#/components/icons/Globe'
26import * as Menu from '#/components/Menu'
27import {Text} from '#/components/Typography'
28import {useAnalytics} from '#/analytics'
29
30export function PostLanguageSelect({
31 currentLanguages: currentLanguagesProp,
32 onSelectLanguage,
33 nudgeAt = 0,
34}: {
35 currentLanguages?: string[]
36 onSelectLanguage?: (language: string) => void
37 /**
38 * Timestamp (ms) of the last honored language-detection nudge. Each
39 * time this changes, the button flashes a transient hint and fades.
40 * The parent rate-limits updates, so successive detector firings inside
41 * the cooldown won't re-flash. The initial `0` on mount is intentionally
42 * ignored.
43 */
44 nudgeAt?: number
45}) {
46 const {_} = useLingui()
47 const langPrefs = useLanguagePrefs()
48 const setLangPrefs = useLanguagePrefsApi()
49 const languageDialogControl = Dialog.useDialogControl()
50
51 const dedupedHistory = Array.from(
52 new Set([...langPrefs.postLanguageHistory, langPrefs.postLanguage]),
53 )
54
55 const currentLanguages =
56 currentLanguagesProp ?? toPostLanguages(langPrefs.postLanguage)
57
58 const onSelectLanguages = (languages: string[]) => {
59 let langsString = languages.join(',')
60 if (!langsString) {
61 langsString = langPrefs.primaryLanguage
62 }
63 setLangPrefs.setPostLanguage(langsString)
64 onSelectLanguage?.(langsString)
65 }
66
67 if (
68 dedupedHistory.length === 1 &&
69 dedupedHistory[0] === langPrefs.postLanguage
70 ) {
71 return (
72 <>
73 <LanguageBtn onPress={languageDialogControl.open} nudgeAt={nudgeAt} />
74 <LanguageSelectDialog
75 titleText={<Trans>Choose post languages</Trans>}
76 subtitleText={
77 <Trans>Select up to 3 languages used in this post</Trans>
78 }
79 control={languageDialogControl}
80 currentLanguages={currentLanguages}
81 onSelectLanguages={onSelectLanguages}
82 maxLanguages={3}
83 />
84 </>
85 )
86 }
87
88 return (
89 <>
90 <Menu.Root>
91 <Menu.Trigger label={_(msg`Select post language`)}>
92 {({props}) => (
93 <LanguageBtn
94 currentLanguages={currentLanguages}
95 nudgeAt={nudgeAt}
96 {...props}
97 />
98 )}
99 </Menu.Trigger>
100 <Menu.Outer>
101 <Menu.Group>
102 {dedupedHistory.map(historyItem => {
103 const langCodes = historyItem.split(',')
104 const langName = langCodes
105 .map(code => codeToLanguageName(code, langPrefs.appLanguage))
106 .join(' + ')
107 return (
108 <Menu.Item
109 key={historyItem}
110 label={_(msg`Select ${langName}`)}
111 onPress={() => {
112 setLangPrefs.setPostLanguage(historyItem)
113 onSelectLanguage?.(historyItem)
114 }}>
115 <Menu.ItemText>{langName}</Menu.ItemText>
116 <Menu.ItemRadio
117 selected={currentLanguages.includes(historyItem)}
118 />
119 </Menu.Item>
120 )
121 })}
122 </Menu.Group>
123 <Menu.Divider />
124 <Menu.Item
125 label={_(msg`More languages...`)}
126 onPress={languageDialogControl.open}>
127 <Menu.ItemText>
128 <Trans>More languages...</Trans>
129 </Menu.ItemText>
130 <Menu.ItemIcon icon={ChevronRightIcon} />
131 </Menu.Item>
132 </Menu.Outer>
133 </Menu.Root>
134
135 <LanguageSelectDialog
136 titleText={<Trans>Choose post languages</Trans>}
137 subtitleText={<Trans>Select up to 3 languages used in this post</Trans>}
138 control={languageDialogControl}
139 currentLanguages={currentLanguages}
140 onSelectLanguages={onSelectLanguages}
141 maxLanguages={3}
142 />
143 </>
144 )
145}
146
147const PULSE_FADE_IN_MS = 300
148const PULSE_FADE_OUT_MS = 500
149
150function LanguageBtn({
151 currentLanguages: currentLanguagesProp,
152 nudgeAt = 0,
153 ...props
154}: Omit<ButtonProps, 'label' | 'children'> & {
155 currentLanguages?: string[]
156 nudgeAt?: number
157}) {
158 const t = useTheme()
159 const ax = useAnalytics()
160 const {_} = useLingui()
161 const langPrefs = useLanguagePrefs()
162
163 const postLanguagesPref = toPostLanguages(langPrefs.postLanguage)
164 const currentLanguages = currentLanguagesProp ?? postLanguagesPref
165
166 /*
167 * Stays at 0 when idle; each nudge runs two pulses with a faster
168 * fade-in and slower fade-out, ease-in-out throughout. Reassigning
169 * `value` cancels any prior sequence, so rapid re-nudges cleanly
170 * restart.
171 */
172 const nudgePulse = useSharedValue(0)
173 useEffect(() => {
174 if (nudgeAt === 0) return
175 const easing = Easing.inOut(Easing.quad)
176 const fadeIn = {duration: PULSE_FADE_IN_MS, easing}
177 const fadeOut = {duration: PULSE_FADE_OUT_MS, easing}
178 nudgePulse.value = withSequence(
179 withTiming(1, fadeIn),
180 withTiming(0, fadeOut),
181 withTiming(1, fadeIn),
182 withTiming(0, fadeOut),
183 )
184 }, [nudgeAt, nudgePulse])
185 const pulseStyle = useAnimatedStyle(() => ({
186 opacity: nudgePulse.value,
187 }))
188
189 return (
190 <Button
191 testID="selectLangBtn"
192 size="small"
193 hitSlop={LANG_DROPDOWN_HITSLOP}
194 label={_(
195 msg({
196 message: `Post language selection`,
197 comment: `Accessibility label for button that opens dialog to choose post language settings`,
198 }),
199 )}
200 accessibilityHint={_(msg`Opens post language settings`)}
201 style={[a.mr_xs, a.overflow_hidden]}
202 {...props}
203 onPress={e => {
204 props.onPress?.(e)
205 ax.metric('composer:language:langSelectorPressed', {
206 wasNudged: nudgeAt > 0,
207 })
208 }}>
209 {({pressed, hovered}) => {
210 const color =
211 pressed || hovered ? t.palette.primary_300 : t.palette.primary_500
212 return (
213 <>
214 <Animated.View
215 pointerEvents="none"
216 style={[
217 a.absolute,
218 {
219 top: 0,
220 right: 0,
221 bottom: 0,
222 left: 0,
223 backgroundColor: t.atoms.bg_contrast_50.backgroundColor,
224 },
225 pulseStyle,
226 ]}
227 />
228 {currentLanguages.length > 0 ? (
229 <Text
230 style={[
231 {color},
232 a.font_semi_bold,
233 a.text_sm,
234 a.leading_snug,
235 {maxWidth: 100},
236 ]}
237 numberOfLines={1}
238 maxFontSizeMultiplier={1.5}>
239 {currentLanguages
240 .map(lang => codeToLanguageName(lang, langPrefs.appLanguage))
241 .join(', ')}
242 </Text>
243 ) : (
244 <GlobeIcon size="xs" style={{color}} />
245 )}
246 </>
247 )
248 }}
249 </Button>
250 )
251}