Bluesky app fork with some witchin' additions 馃挮
1import {useCallback, useMemo} from 'react'
2import {Pressable, View} from 'react-native'
3import Animated, {
4 FadeInUp,
5 FadeOutUp,
6 LayoutAnimationConfig,
7 LinearTransition,
8} from 'react-native-reanimated'
9import {msg} from '@lingui/core/macro'
10import {useLingui} from '@lingui/react'
11import {Trans} from '@lingui/react/macro'
12
13import {
14 type CommonNavigatorParams,
15 type NativeStackScreenProps,
16} from '#/lib/routes/types'
17import {type Schema} from '#/state/persisted'
18import {
19 useEnableSquareAvatars,
20 useSetEnableSquareAvatars,
21} from '#/state/preferences/enable-square-avatars'
22import {
23 useEnableSquareButtons,
24 useSetEnableSquareButtons,
25} from '#/state/preferences/enable-square-buttons'
26import {useKawaiiMode, useSetKawaiiMode} from '#/state/preferences/kawaii'
27import {useSetThemePrefs, useThemePrefs} from '#/state/shell'
28import {SettingsListItem as AppIconSettingsListItem} from '#/screens/Settings/AppIconSettings/SettingsListItem'
29import {type Alf, atoms as a, native, useAlf, useTheme} from '#/alf'
30import {
31 BLACKSKY_PALETTE,
32 BLUESKY_PALETTE,
33 CATPPUCIN_PALETTE,
34 DEER_PALETTE,
35 DEFAULT_PALETTE,
36 EVERGARDEN_PALETTE,
37 KITTY_PALETTE,
38 REDDWARF_PALETTE,
39 ZEPPELIN_PALETTE,
40} from '#/alf/themes'
41import {getMaterial3Colors} from '#/alf/util/material3Theme'
42import {useMaterialYouPalette} from '#/alf/util/materialYou'
43import * as SegmentedControl from '#/components/forms/SegmentedControl'
44import {Slider} from '#/components/forms/Slider'
45import * as Toggle from '#/components/forms/Toggle'
46import {Circle_And_Square_Stroke1_Corner0_Rounded_Filled as SquareIcon} from '#/components/icons/CircleAndSquare'
47import {ColorPalette_Stroke2_Corner0_Rounded as ColorPaletteIcon} from '#/components/icons/ColorPalette'
48import {type Props as SVGIconProps} from '#/components/icons/common'
49import {
50 Heart2_Filled_Stroke2_Corner0_Rounded as HeartIconFilled,
51 Heart2_Stroke2_Corner0_Rounded as HeartIconOutline,
52} from '#/components/icons/Heart2'
53import {Moon_Stroke2_Corner0_Rounded as MoonIcon} from '#/components/icons/Moon'
54import {Phone_Stroke2_Corner0_Rounded as PhoneIcon} from '#/components/icons/Phone'
55import {Sparkle_Stroke2_Corner0_Rounded as SparkleIcon} from '#/components/icons/Sparkle'
56import {TextSize_Stroke2_Corner0_Rounded as TextSize} from '#/components/icons/TextSize'
57import {TitleCase_Stroke2_Corner0_Rounded as Aa} from '#/components/icons/TitleCase'
58import * as Layout from '#/components/Layout'
59import {Text} from '#/components/Typography'
60import {IS_ANDROID, IS_INTERNAL, IS_NATIVE} from '#/env'
61import * as SettingsList from './components/SettingsList'
62
63type Props = NativeStackScreenProps<CommonNavigatorParams, 'AppearanceSettings'>
64
65type ColorSchemeName =
66 | 'witchsky'
67 | 'bluesky'
68 | 'blacksky'
69 | 'deer'
70 | 'zeppelin'
71 | 'kitty'
72 | 'reddwarf'
73 | 'catppuccin'
74 | 'evergarden'
75 | 'material3'
76
77type ColorSchemeOption = {
78 name: ColorSchemeName
79 label: string
80 primary: string
81}
82
83export function AppearanceSettingsScreen({}: Props) {
84 const {_} = useLingui()
85 const {fonts} = useAlf()
86 const t = useTheme()
87
88 const {
89 colorMode,
90 colorScheme,
91 darkTheme,
92 hue,
93 material3Accent,
94 material3Style,
95 } = useThemePrefs()
96 const {
97 setColorMode,
98 setColorScheme,
99 setDarkTheme,
100 setHue,
101 setMaterial3Accent,
102 setMaterial3Style,
103 } = useSetThemePrefs()
104
105 const kawaiiMode = useKawaiiMode()
106 const setKawaiiMode = useSetKawaiiMode()
107
108 const enableSquareAvatars = useEnableSquareAvatars()
109 const setEnableSquareAvatars = useSetEnableSquareAvatars()
110
111 const enableSquareButtons = useEnableSquareButtons()
112 const setEnableSquareButtons = useSetEnableSquareButtons()
113
114 const material3Palette = useMaterialYouPalette()
115 const cachedScheme = useMemo(
116 () => getMaterial3Colors(material3Palette),
117 [material3Palette],
118 )
119
120 const onChangeAppearance = useCallback(
121 (value: 'light' | 'system' | 'dark') => {
122 setColorMode(value)
123 },
124 [setColorMode],
125 )
126
127 const onChangeScheme = useCallback(
128 (value: ColorSchemeName) => {
129 setColorScheme(value)
130 },
131 [setColorScheme],
132 )
133
134 const onChangeDarkTheme = useCallback(
135 (value: 'dim' | 'dark') => {
136 setDarkTheme(value)
137 },
138 [setDarkTheme],
139 )
140
141 const onChangeFontFamily = useCallback(
142 (value: 'system' | 'theme' | 'material') => {
143 fonts.setFontFamily(value)
144 },
145 [fonts],
146 )
147
148 const onChangeFontScale = useCallback(
149 (value: Alf['fonts']['scale']) => {
150 fonts.setFontScale(value)
151 },
152 [fonts],
153 )
154
155 const colorSchemes: ColorSchemeOption[] = [
156 {
157 name: 'witchsky',
158 label: _(msg`Witchsky`),
159 primary: DEFAULT_PALETTE.primary_500,
160 },
161 {
162 name: 'bluesky',
163 label: _(msg`Bluesky`),
164 primary: BLUESKY_PALETTE.primary_500,
165 },
166 {
167 name: 'blacksky',
168 label: _(msg`Blacksky`),
169 primary: BLACKSKY_PALETTE.primary_500,
170 },
171 {
172 name: 'deer',
173 label: _(msg`Deer`),
174 primary: DEER_PALETTE.primary_500,
175 },
176 {
177 name: 'zeppelin',
178 label: _(msg`Zeppelin`),
179 primary: ZEPPELIN_PALETTE.primary_500,
180 },
181 {
182 name: 'kitty',
183 label: _(msg`Kitty`),
184 primary: KITTY_PALETTE.primary_500,
185 },
186 {
187 name: 'reddwarf',
188 label: _(msg`Red Dwarf`),
189 primary: REDDWARF_PALETTE.primary_500,
190 },
191 {
192 name: 'catppuccin',
193 label: _(msg`Catppuccin`),
194 primary: CATPPUCIN_PALETTE.primary_500,
195 },
196 {
197 name: 'evergarden',
198 label: _(msg`Evergarden`),
199 primary: EVERGARDEN_PALETTE.primary_500,
200 },
201 {
202 name: 'material3',
203 label: _(msg`Material You`),
204 primary: cachedScheme.regular.primary_500,
205 },
206 ]
207
208 return (
209 <LayoutAnimationConfig skipExiting skipEntering>
210 <Layout.Screen testID="preferencesThreadsScreen">
211 <Layout.Header.Outer>
212 <Layout.Header.BackButton />
213 <Layout.Header.Content>
214 <Layout.Header.TitleText>
215 <Trans>Appearance</Trans>
216 </Layout.Header.TitleText>
217 </Layout.Header.Content>
218 <Layout.Header.Slot />
219 </Layout.Header.Outer>
220 <Layout.Content>
221 <SettingsList.Container>
222 <AppearanceToggleButtonGroup
223 title={_(msg`Color mode`)}
224 icon={PhoneIcon}
225 items={[
226 {
227 label: _(msg`System`),
228 name: 'system',
229 },
230 {
231 label: _(msg`Light`),
232 name: 'light',
233 },
234 {
235 label: _(msg`Dark`),
236 name: 'dark',
237 },
238 ]}
239 value={colorMode}
240 onChange={onChangeAppearance}
241 />
242
243 {colorMode !== 'light' && (
244 <Animated.View
245 entering={native(FadeInUp)}
246 exiting={native(FadeOutUp)}>
247 <AppearanceToggleButtonGroup
248 title={_(msg`Dark theme`)}
249 icon={MoonIcon}
250 items={[
251 {
252 label: _(msg`Dim`),
253 name: 'dim',
254 },
255 {
256 label: _(msg`Dark`),
257 name: 'dark',
258 },
259 ]}
260 value={darkTheme ?? 'dim'}
261 onChange={onChangeDarkTheme}
262 />
263 </Animated.View>
264 )}
265
266 <SettingsList.Group>
267 <SettingsList.ItemIcon icon={ColorPaletteIcon} />
268 <SettingsList.ItemText>
269 <Trans>Color Theme</Trans>
270 </SettingsList.ItemText>
271 <View style={[a.w_full, a.gap_md]}>
272 <Text style={[a.flex_1, t.atoms.text_contrast_medium]}>
273 <Trans>Choose which color scheme to use:</Trans>
274 </Text>
275 <ColorSchemeGrid
276 schemes={colorSchemes}
277 selectedScheme={colorScheme}
278 onSchemeChange={onChangeScheme}
279 />
280 {colorScheme === 'material3' && !IS_ANDROID && (
281 <>
282 <Text style={[a.flex_1, t.atoms.text_contrast_medium]}>
283 <Trans>Accent hue:</Trans>
284 </Text>
285 <Slider
286 value={hexToHue(material3Accent)}
287 onValueChange={v => {
288 setMaterial3Accent(hueToHex(v))
289 setHue(0)
290 }}
291 minimumValue={0}
292 maximumValue={360}
293 step={1}
294 debounceFull={true}
295 />
296
297 <Text style={[a.flex_1, t.atoms.text_contrast_medium]}>
298 <Trans>Style:</Trans>
299 </Text>
300 <View style={[a.flex_row, a.flex_wrap, a.gap_sm]}>
301 {MATERIAL3_STYLE_OPTIONS.map(({name, label}) => {
302 const isSelected = material3Style === name
303 return (
304 <Pressable
305 accessibilityRole="button"
306 key={name}
307 onPress={() => setMaterial3Style(name)}
308 style={[
309 a.flex_1,
310 a.rounded_sm,
311 a.align_center,
312 a.px_sm,
313 a.py_sm,
314 a.border,
315 {minWidth: '22%'},
316 {
317 borderColor: isSelected
318 ? t.palette.primary_500
319 : t.atoms.border_contrast_low.borderColor,
320 borderWidth: 2,
321 backgroundColor: isSelected
322 ? t.palette.primary_100
323 : t.atoms.bg.backgroundColor,
324 },
325 ]}>
326 <Text
327 style={[
328 a.text_xs,
329 a.font_bold,
330 isSelected
331 ? {color: t.palette.primary_500}
332 : t.atoms.text,
333 ]}>
334 {label}
335 </Text>
336 </Pressable>
337 )
338 })}
339 </View>
340 </>
341 )}
342 {colorScheme !== 'material3' && (
343 <>
344 <Text style={[a.flex_1, t.atoms.text_contrast_medium]}>
345 <Trans>Hue shift the colors:</Trans>
346 </Text>
347 <Slider
348 value={hue}
349 onValueChange={setHue}
350 minimumValue={0}
351 maximumValue={360}
352 step={1}
353 debounceFull={true}
354 />
355 </>
356 )}
357 </View>
358 </SettingsList.Group>
359
360 <Animated.View layout={native(LinearTransition)}>
361 <SettingsList.Divider />
362
363 <AppearanceToggleButtonGroup
364 title={_(msg`Font`)}
365 description={_(
366 msg`For the best experience, we recommend using the theme font.`,
367 )}
368 icon={Aa}
369 items={[
370 {
371 label: _(msg`System`),
372 name: 'system',
373 },
374 {
375 label: _(msg`Theme`),
376 name: 'theme',
377 },
378 ...(IS_ANDROID
379 ? [
380 {
381 label: _(msg`Google Sans`),
382 name: 'material' as 'system' | 'theme' | 'material',
383 },
384 ]
385 : []),
386 ]}
387 value={fonts.family}
388 onChange={onChangeFontFamily}
389 />
390
391 <AppearanceToggleButtonGroup
392 title={_(msg`Font size`)}
393 icon={TextSize}
394 items={[
395 {
396 label: _(msg`Smaller`),
397 name: '-1',
398 },
399 {
400 label: _(msg`Default`),
401 name: '0',
402 },
403 {
404 label: _(msg`Larger`),
405 name: '1',
406 },
407 ]}
408 value={fonts.scale}
409 onChange={onChangeFontScale}
410 />
411
412 <SettingsList.Divider />
413
414 <SettingsList.Group contentContainerStyle={[a.gap_sm]}>
415 <SettingsList.ItemIcon icon={SparkleIcon} />
416 <SettingsList.ItemText>
417 <Trans>Logo</Trans>
418 </SettingsList.ItemText>
419 <Toggle.Item
420 name="kawaii_mode"
421 label={_(msg`Enable kawaii logo`)}
422 value={kawaiiMode}
423 onChange={value => setKawaiiMode(value)}
424 style={[a.w_full]}>
425 <Toggle.LabelText style={[a.flex_1]}>
426 <Trans>Enable kawaii logo</Trans>
427 </Toggle.LabelText>
428 <Toggle.Platform />
429 </Toggle.Item>
430 </SettingsList.Group>
431
432 <SettingsList.Group contentContainerStyle={[a.gap_sm]}>
433 <SettingsList.ItemIcon icon={SquareIcon} />
434 <SettingsList.ItemText>
435 <Trans>Shapes</Trans>
436 </SettingsList.ItemText>
437 <Toggle.Item
438 name="enable_square_avatars"
439 label={_(msg`Enable square avatars`)}
440 value={enableSquareAvatars}
441 onChange={value => setEnableSquareAvatars(value)}
442 style={[a.w_full]}>
443 <Toggle.LabelText style={[a.flex_1]}>
444 <Trans>Enable square avatars</Trans>
445 </Toggle.LabelText>
446 <Toggle.Platform />
447 </Toggle.Item>
448
449 <Toggle.Item
450 name="enable_square_buttons"
451 label={_(msg`Enable square buttons`)}
452 value={enableSquareButtons}
453 onChange={value => setEnableSquareButtons(value)}
454 style={[a.w_full]}>
455 <Toggle.LabelText style={[a.flex_1]}>
456 <Trans>Enable square buttons</Trans>
457 </Toggle.LabelText>
458 <Toggle.Platform />
459 </Toggle.Item>
460 </SettingsList.Group>
461 {IS_NATIVE && IS_INTERNAL && (
462 <>
463 <SettingsList.Divider />
464 <AppIconSettingsListItem />
465 </>
466 )}
467 </Animated.View>
468 </SettingsList.Container>
469 </Layout.Content>
470 </Layout.Screen>
471 </LayoutAnimationConfig>
472 )
473}
474
475function ColorSchemeGrid({
476 schemes,
477 selectedScheme,
478 onSchemeChange,
479}: {
480 schemes: ColorSchemeOption[]
481 selectedScheme: ColorSchemeName
482 onSchemeChange: (scheme: ColorSchemeName) => void
483}) {
484 const t = useTheme()
485 return (
486 <View style={[a.flex_row, a.flex_wrap, a.gap_sm]}>
487 {schemes.map(({name, label, primary}) => {
488 const isSelected = selectedScheme === name
489 const HeartIcon = isSelected ? HeartIconFilled : HeartIconOutline
490 return (
491 <Pressable
492 accessibilityRole="button"
493 key={name}
494 onPress={() => onSchemeChange(name)}
495 style={[
496 a.flex_1,
497 a.rounded_md,
498 a.overflow_hidden,
499 {minWidth: '30%'},
500 a.border,
501 {
502 borderColor: isSelected
503 ? primary
504 : t.atoms.border_contrast_low.borderColor,
505 borderWidth: 2,
506 },
507 ]}>
508 <View
509 style={[
510 a.p_sm,
511 a.gap_xs,
512 {backgroundColor: t.atoms.bg.backgroundColor},
513 ]}>
514 <View
515 style={[
516 a.w_full,
517 a.rounded_xs,
518 {backgroundColor: primary, height: 24},
519 ]}
520 />
521 <View
522 style={[
523 a.flex_row,
524 a.align_center,
525 a.justify_center,
526 a.gap_xs,
527 ]}>
528 <Text style={[a.text_sm, a.font_bold, t.atoms.text]}>
529 {label}
530 </Text>
531 <HeartIcon size="xs" style={[{color: primary}]} />
532 </View>
533 </View>
534 </Pressable>
535 )
536 })}
537 </View>
538 )
539}
540
541export function AppearanceToggleButtonGroup<T extends string>({
542 title,
543 description,
544 icon: Icon,
545 items,
546 value,
547 onChange,
548}: {
549 title: string
550 description?: string
551 icon: React.ComponentType<SVGIconProps>
552 items: {
553 label: string
554 name: T
555 }[]
556 value: T
557 onChange: (value: T) => void
558}) {
559 const t = useTheme()
560 return (
561 <>
562 <SettingsList.Group contentContainerStyle={[a.gap_sm]} iconInset={false}>
563 <SettingsList.ItemIcon icon={Icon} />
564 <SettingsList.ItemText>{title}</SettingsList.ItemText>
565 {description && (
566 <Text
567 style={[
568 a.text_sm,
569 a.leading_snug,
570 t.atoms.text_contrast_medium,
571 a.w_full,
572 ]}>
573 {description}
574 </Text>
575 )}
576 <SegmentedControl.Root
577 type="radio"
578 label={title}
579 value={value}
580 onChange={onChange}>
581 {items.map(item => (
582 <SegmentedControl.Item
583 key={item.name}
584 label={item.label}
585 value={item.name}>
586 <SegmentedControl.ItemText>
587 {item.label}
588 </SegmentedControl.ItemText>
589 </SegmentedControl.Item>
590 ))}
591 </SegmentedControl.Root>
592 </SettingsList.Group>
593 </>
594 )
595}
596
597const MATERIAL3_STYLE_OPTIONS: {
598 name: Schema['material3Style']
599 label: string
600}[] = [
601 {name: 'TONAL_SPOT', label: 'Tonal Spot'},
602 {name: 'VIBRANT', label: 'Vibrant'},
603 {name: 'EXPRESSIVE', label: 'Expressive'},
604 {name: 'SPRITZ', label: 'Spritz'},
605 {name: 'RAINBOW', label: 'Rainbow'},
606 {name: 'FRUIT_SALAD', label: 'Fruit Salad'},
607 {name: 'CONTENT', label: 'Content'},
608 {name: 'MONOCHROMATIC', label: 'Mono'},
609]
610
611function hueToHex(hue: number): string {
612 const h = hue / 60
613 const x = 1 - Math.abs((h % 2) - 1)
614 let r = 0,
615 g = 0,
616 b = 0
617 if (h < 1) {
618 r = 1
619 g = x
620 } else if (h < 2) {
621 r = x
622 g = 1
623 } else if (h < 3) {
624 g = 1
625 b = x
626 } else if (h < 4) {
627 g = x
628 b = 1
629 } else if (h < 5) {
630 r = x
631 b = 1
632 } else {
633 r = 1
634 b = x
635 }
636 const toHex = (v: number) =>
637 Math.round(v * 255)
638 .toString(16)
639 .padStart(2, '0')
640 return `#${toHex(r)}${toHex(g)}${toHex(b)}`
641}
642
643function hexToHue(hex: string): number {
644 const r = parseInt(hex.slice(1, 3), 16) / 255
645 const g = parseInt(hex.slice(3, 5), 16) / 255
646 const b = parseInt(hex.slice(5, 7), 16) / 255
647 const max = Math.max(r, g, b)
648 const min = Math.min(r, g, b)
649 const d = max - min
650 if (d === 0) return 0
651 let h = 0
652 if (max === r) h = ((g - b) / d) % 6
653 else if (max === g) h = (b - r) / d + 2
654 else h = (r - g) / d + 4
655 h = Math.round(h * 60)
656 return h < 0 ? h + 360 : h
657}