Bluesky app fork with some witchin' additions 💫 witchsky.app
bluesky fork client
141
fork

Configure Feed

Select the types of activity you want to include in your feed.

hue shift slider

authored by whey.party and committed by

Tangled c0a03f26 1320f38b

+315 -13
+2
package.json
··· 126 126 "babel-plugin-transform-remove-console": "^6.9.4", 127 127 "bcp-47": "^2.1.0", 128 128 "bcp-47-match": "^2.0.3", 129 + "culori": "^4.0.2", 129 130 "date-fns": "^2.30.0", 130 131 "email-validator": "^2.0.4", 131 132 "emoji-mart": "^5.5.2", ··· 235 236 "@sentry/webpack-plugin": "^3.2.2", 236 237 "@testing-library/jest-native": "^5.4.3", 237 238 "@testing-library/react-native": "^13.2.0", 239 + "@types/culori": "^4.0.1", 238 240 "@types/jest": "29.5.14", 239 241 "@types/lodash.chunk": "^4.2.7", 240 242 "@types/lodash.debounce": "^4.0.7",
+80 -6
src/alf/index.tsx
··· 1 1 import React from 'react' 2 - import {type Theme, type ThemeName} from '@bsky.app/alf' 2 + import {createTheme, type Theme, type ThemeName} from '@bsky.app/alf' 3 + import {formatHex, modeOklch, useMode as utilMode} from 'culori' 3 4 4 5 import {useThemePrefs} from '#/state/shell/color-mode' 5 6 import { ··· 14 15 blueskyscheme, 15 16 deerscheme, 16 17 kittyscheme, 18 + type Palette, 17 19 reddwarfscheme, 18 20 themes, 19 21 witchskyscheme, ··· 68 70 69 71 export type SchemeType = typeof themes 70 72 73 + function changeHue(color: string, hueShift: number) { 74 + if (!hueShift || hueShift === 0) return color 75 + 76 + let lablch = utilMode(modeOklch) 77 + const parsed = lablch(color) 78 + 79 + if (!parsed) return color 80 + 81 + const {l, c, h} = parsed as {l: number; c: number; h: number | undefined} 82 + 83 + const currentHue = h || 0 84 + 85 + const newHue = (currentHue + hueShift + 360) % 360 86 + 87 + return formatHex({mode: 'oklch', l, c, h: newHue}) 88 + } 89 + 90 + export function shiftPalette(palette: Palette, hueShift: number): Palette { 91 + const newPalette = {...palette} 92 + const keys = Object.keys(newPalette) as Array<keyof Palette> 93 + 94 + keys.forEach(key => { 95 + newPalette[key] = changeHue(newPalette[key], hueShift) 96 + }) 97 + 98 + return newPalette 99 + } 100 + 101 + export function hueShifter(scheme: SchemeType, hueShift: number): SchemeType { 102 + if (!hueShift || hueShift === 0) { 103 + return scheme 104 + } 105 + 106 + const lightPalette = shiftPalette(scheme.lightPalette, hueShift) 107 + const darkPalette = shiftPalette(scheme.darkPalette, hueShift) 108 + const dimPalette = shiftPalette(scheme.dimPalette, hueShift) 109 + 110 + const light = createTheme({ 111 + scheme: 'light', 112 + name: 'light', 113 + palette: lightPalette, 114 + }) 115 + 116 + const dark = createTheme({ 117 + scheme: 'dark', 118 + name: 'dark', 119 + palette: darkPalette, 120 + options: { 121 + shadowOpacity: 0.4, 122 + }, 123 + }) 124 + 125 + const dim = createTheme({ 126 + scheme: 'dark', 127 + name: 'dim', 128 + palette: dimPalette, 129 + options: { 130 + shadowOpacity: 0.4, 131 + }, 132 + }) 133 + 134 + return { 135 + lightPalette, 136 + darkPalette, 137 + dimPalette, 138 + light, 139 + dark, 140 + dim, 141 + } 142 + } 143 + 71 144 export function selectScheme(colorScheme: string | undefined): SchemeType { 72 145 switch (colorScheme) { 73 146 case 'witchsky': ··· 93 166 children, 94 167 theme: themeName, 95 168 }: React.PropsWithChildren<{theme: ThemeName}>) { 96 - const {colorScheme} = useThemePrefs() 169 + const {colorScheme, hue} = useThemePrefs() 97 170 const currentScheme = selectScheme(colorScheme) 98 171 const [fontScale, setFontScale] = React.useState<Alf['fonts']['scale']>(() => 99 172 getFontScale(), ··· 126 199 127 200 const value = React.useMemo<Alf>( 128 201 () => ({ 129 - themes: currentScheme, 202 + themes: hueShifter(currentScheme, hue), 130 203 themeName: themeName, 131 - theme: currentScheme[themeName], 204 + theme: hueShifter(currentScheme, hue)[themeName], 132 205 fonts: { 133 206 scale: fontScale, 134 207 scaleMultiplier: fontScaleMultiplier, ··· 140 213 }), 141 214 [ 142 215 currentScheme, 216 + hue, 143 217 themeName, 144 218 fontScale, 219 + fontScaleMultiplier, 220 + fontFamily, 145 221 setFontScaleAndPersist, 146 - fontFamily, 147 222 setFontFamilyAndPersist, 148 - fontScaleMultiplier, 149 223 ], 150 224 ) 151 225
+187
src/components/forms/Slider.tsx
··· 1 + import {useCallback, useEffect, useRef, useState} from 'react' 2 + import {type StyleProp, View, type ViewStyle} from 'react-native' 3 + import {Gesture, GestureDetector} from 'react-native-gesture-handler' 4 + import Animated, { 5 + runOnJS, 6 + useAnimatedStyle, 7 + useSharedValue, 8 + withSpring, 9 + } from 'react-native-reanimated' 10 + 11 + import {useHaptics} from '#/lib/haptics' 12 + import {atoms as a, platform, useTheme} from '#/alf' 13 + 14 + export interface SliderProps { 15 + value: number 16 + onValueChange: (value: number) => void 17 + min?: number 18 + max?: number 19 + step?: number 20 + label?: string 21 + accessibilityHint?: string 22 + style?: StyleProp<ViewStyle> 23 + debounce?: number 24 + } 25 + 26 + export function Slider({ 27 + value, 28 + onValueChange, 29 + min = 0, 30 + max = 100, 31 + step = 1, 32 + label, 33 + accessibilityHint, 34 + style, 35 + debounce, 36 + }: SliderProps) { 37 + const t = useTheme() 38 + const playHaptic = useHaptics() 39 + const timerRef = useRef<NodeJS.Timeout | undefined>(undefined) 40 + 41 + const [width, setWidth] = useState(0) 42 + 43 + const progress = useSharedValue(0) 44 + const isPressed = useSharedValue(false) 45 + 46 + useEffect(() => { 47 + if (!isPressed.value) { 48 + const clamped = Math.min(Math.max(value, min), max) 49 + const normalized = (clamped - min) / (max - min) 50 + progress.value = withSpring(normalized, {overshootClamping: true}) 51 + } 52 + }, [value, min, max, progress, isPressed]) 53 + 54 + useEffect(() => { 55 + return () => { 56 + if (timerRef.current) clearTimeout(timerRef.current) 57 + } 58 + }, []) 59 + 60 + const updateValueJS = useCallback( 61 + (val: number) => { 62 + if (debounce && debounce > 0) { 63 + if (timerRef.current) { 64 + clearTimeout(timerRef.current) 65 + } 66 + timerRef.current = setTimeout(() => { 67 + onValueChange(val) 68 + }, debounce) 69 + } else { 70 + onValueChange(val) 71 + } 72 + }, 73 + [onValueChange, debounce], 74 + ) 75 + 76 + const handleValueChange = useCallback( 77 + (newProgress: number) => { 78 + 'worklet' 79 + const rawValue = min + newProgress * (max - min) 80 + 81 + const steppedValue = Math.round(rawValue / step) * step 82 + const clamped = Math.min(Math.max(steppedValue, min), max) 83 + 84 + runOnJS(updateValueJS)(clamped) 85 + }, 86 + [min, max, step, updateValueJS], 87 + ) 88 + 89 + const pan = Gesture.Pan() 90 + .onBegin(e => { 91 + isPressed.value = true 92 + 93 + if (width > 0) { 94 + const newProgress = Math.min(Math.max(e.x / width, 0), 1) 95 + progress.value = newProgress 96 + handleValueChange(newProgress) 97 + } 98 + }) 99 + .onUpdate(e => { 100 + if (width === 0) return 101 + const newProgress = Math.min(Math.max(e.x / width, 0), 1) 102 + progress.value = newProgress 103 + handleValueChange(newProgress) 104 + }) 105 + .onFinalize(() => { 106 + isPressed.value = false 107 + runOnJS(playHaptic)('Light') 108 + }) 109 + 110 + const thumbAnimatedStyle = useAnimatedStyle(() => { 111 + const translateX = progress.value * width 112 + return { 113 + transform: [ 114 + {translateX: translateX - 12}, 115 + {scale: isPressed.value ? 1.1 : 1}, 116 + ], 117 + } 118 + }) 119 + 120 + const trackAnimatedStyle = useAnimatedStyle(() => { 121 + return { 122 + width: `${progress.value * 100}%`, 123 + } 124 + }) 125 + 126 + return ( 127 + <View 128 + style={[a.w_full, a.justify_center, {height: 28}, style]} 129 + accessibilityRole="adjustable" 130 + accessibilityLabel={label} 131 + accessibilityHint={accessibilityHint} 132 + accessibilityValue={{min, max, now: value}}> 133 + <GestureDetector gesture={pan}> 134 + <View 135 + style={[a.flex_1, a.justify_center, {cursor: 'pointer'}]} 136 + // @ts-ignore web-only style 137 + onLayout={e => setWidth(e.nativeEvent.layout.width)}> 138 + <View 139 + style={[ 140 + a.w_full, 141 + a.absolute, 142 + t.atoms.bg_contrast_50, 143 + {height: 4, borderRadius: 2}, 144 + ]} 145 + /> 146 + 147 + <Animated.View 148 + style={[ 149 + a.absolute, 150 + a.rounded_full, 151 + {height: 4, backgroundColor: t.palette.primary_500}, 152 + trackAnimatedStyle, 153 + ]} 154 + /> 155 + 156 + <Animated.View 157 + style={[ 158 + a.absolute, 159 + a.rounded_full, 160 + t.atoms.bg, 161 + { 162 + left: 0, 163 + width: 24, 164 + height: 24, 165 + borderWidth: 1, 166 + borderColor: t.atoms.border_contrast_low.borderColor, 167 + }, 168 + thumbAnimatedStyle, 169 + platform({ 170 + web: { 171 + boxShadow: '0px 2px 4px 0px #0000001A', 172 + }, 173 + ios: { 174 + shadowColor: '#000', 175 + shadowOffset: {width: 0, height: 2}, 176 + shadowOpacity: 0.15, 177 + shadowRadius: 4, 178 + }, 179 + android: {elevation: 3}, 180 + }), 181 + ]} 182 + /> 183 + </View> 184 + </GestureDetector> 185 + </View> 186 + ) 187 + }
+4 -4
src/lib/ThemeContext.tsx
··· 4 4 import {type ThemeName} from '@bsky.app/alf' 5 5 6 6 import {useThemePrefs} from '#/state/shell/color-mode' 7 - import {type SchemeType, selectScheme} from '#/alf' 7 + import {hueShifter, type SchemeType, selectScheme} from '#/alf' 8 8 import {themes} from '#/alf/themes' 9 9 import {darkTheme, defaultTheme, dimTheme} from './themes' 10 10 ··· 124 124 theme, 125 125 children, 126 126 }) => { 127 - const {colorScheme} = useThemePrefs() 127 + const {colorScheme, hue} = useThemePrefs() 128 128 129 129 const themeValue = useMemo(() => { 130 - const currentScheme = selectScheme(colorScheme) 130 + const currentScheme = hueShifter(selectScheme(colorScheme), hue) 131 131 return getTheme(theme, currentScheme) 132 - }, [theme, colorScheme]) 132 + }, [theme, colorScheme, hue]) 133 133 134 134 return ( 135 135 <ThemeContext.Provider value={themeValue}>{children}</ThemeContext.Provider>
+16 -2
src/screens/Settings/AppearanceSettings.tsx
··· 18 18 import {SettingsListItem as AppIconSettingsListItem} from '#/screens/Settings/AppIconSettings/SettingsListItem' 19 19 import {type Alf, atoms as a, native, useAlf, useTheme} from '#/alf' 20 20 import * as SegmentedControl from '#/components/forms/SegmentedControl' 21 + import {Slider} from '#/components/forms/Slider' 21 22 import * as Toggle from '#/components/forms/Toggle' 22 23 import {type Props as SVGIconProps} from '#/components/icons/common' 23 24 import {Moon_Stroke2_Corner0_Rounded as MoonIcon} from '#/components/icons/Moon' ··· 36 37 const {fonts} = useAlf() 37 38 const t = useTheme() 38 39 39 - const {colorMode, colorScheme, darkTheme} = useThemePrefs() 40 - const {setColorMode, setColorScheme, setDarkTheme} = useSetThemePrefs() 40 + const {colorMode, colorScheme, darkTheme, hue} = useThemePrefs() 41 + const {setColorMode, setColorScheme, setDarkTheme, setHue} = 42 + useSetThemePrefs() 41 43 42 44 const onChangeAppearance = useCallback( 43 45 (value: 'light' | 'system' | 'dark') => { ··· 178 180 ))} 179 181 </View> 180 182 </Toggle.Group> 183 + <Text style={[a.flex_1, t.atoms.text_contrast_medium]}> 184 + <Trans>Hue shift the colors:</Trans> 185 + </Text> 186 + <Slider 187 + label="Volume" 188 + value={hue} 189 + onValueChange={setHue} 190 + min={0} 191 + max={360} 192 + step={1} 193 + debounce={0.3} 194 + /> 181 195 </View> 182 196 </SettingsList.Group> 183 197
+2
src/state/persisted/schema.ts
··· 58 58 'kitty', 59 59 'reddwarf', 60 60 ]), 61 + hue: z.number(), 61 62 session: z.object({ 62 63 accounts: z.array(accountSchema), 63 64 currentAccount: currentAccountSchema.optional(), ··· 174 175 colorMode: 'system', 175 176 darkTheme: 'dim', 176 177 colorScheme: 'witchsky', 178 + hue: 0, 177 179 session: { 178 180 accounts: [], 179 181 currentAccount: undefined,
+14 -1
src/state/shell/color-mode.tsx
··· 6 6 colorMode: persisted.Schema['colorMode'] 7 7 darkTheme: persisted.Schema['darkTheme'] 8 8 colorScheme: persisted.Schema['colorScheme'] 9 + hue: persisted.Schema['hue'] 9 10 } 10 11 type SetContext = { 11 12 setColorMode: (v: persisted.Schema['colorMode']) => void 12 13 setDarkTheme: (v: persisted.Schema['darkTheme']) => void 13 14 setColorScheme: (v: persisted.Schema['colorScheme']) => void 15 + setHue: (v: persisted.Schema['hue']) => void 14 16 } 15 17 16 18 const stateContext = React.createContext<StateContext>({ 17 19 colorMode: 'system', 18 20 darkTheme: 'dark', 19 21 colorScheme: 'witchsky', 22 + hue: 0, 20 23 }) 21 24 stateContext.displayName = 'ColorModeStateContext' 22 25 const setContext = React.createContext<SetContext>({} as SetContext) ··· 28 31 const [colorScheme, setColorScheme] = React.useState( 29 32 persisted.get('colorScheme'), 30 33 ) 34 + const [hue, setHue] = React.useState(persisted.get('hue')) 31 35 32 36 const stateContextValue = React.useMemo( 33 37 () => ({ 34 38 colorMode, 35 39 darkTheme, 36 40 colorScheme, 41 + hue, 37 42 }), 38 - [colorMode, darkTheme, colorScheme], 43 + [colorMode, darkTheme, colorScheme, hue], 39 44 ) 40 45 41 46 const setContextValue = React.useMemo( ··· 52 57 setColorScheme(_colorScheme) 53 58 persisted.write('colorScheme', _colorScheme) 54 59 }, 60 + setHue: (_hue: persisted.Schema['hue']) => { 61 + setHue(_hue) 62 + persisted.write('hue', _hue) 63 + }, 55 64 }), 56 65 [], 57 66 ) ··· 66 75 const unsub3 = persisted.onUpdate('colorScheme', nextColorScheme => { 67 76 setColorScheme(nextColorScheme) 68 77 }) 78 + const unsub4 = persisted.onUpdate('hue', nextHue => { 79 + setHue(nextHue) 80 + }) 69 81 return () => { 70 82 unsub1() 71 83 unsub2() 72 84 unsub3() 85 + unsub4() 73 86 } 74 87 }, []) 75 88
+10
yarn.lock
··· 7442 7442 dependencies: 7443 7443 "@types/node" "*" 7444 7444 7445 + "@types/culori@^4.0.1": 7446 + version "4.0.1" 7447 + resolved "https://registry.yarnpkg.com/@types/culori/-/culori-4.0.1.tgz#39ed095e0ef7107342d9091b1707ae8fb8681297" 7448 + integrity sha512-43M51r/22CjhbOXyGT361GZ9vncSVQ39u62x5eJdBQFviI8zWp2X5jzqg7k4M6PVgDQAClpy2bUe2dtwEgEDVQ== 7449 + 7445 7450 "@types/elliptic@^6.4.9": 7446 7451 version "6.4.18" 7447 7452 resolved "https://registry.yarnpkg.com/@types/elliptic/-/elliptic-6.4.18.tgz#bc96e26e1ccccbabe8b6f0e409c85898635482e1" ··· 9974 9979 version "3.1.2" 9975 9980 resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.2.tgz#1d4bf9d572f11c14031f0436e1c10bc1f571f50b" 9976 9981 integrity sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ== 9982 + 9983 + culori@^4.0.2: 9984 + version "4.0.2" 9985 + resolved "https://registry.yarnpkg.com/culori/-/culori-4.0.2.tgz#fbb28dbeb8d13d0eeab7520191f74ab822a8ca71" 9986 + integrity sha512-1+BhOB8ahCn4O0cep0Sh2l9KCOfOdY+BXJnKMHFFzDEouSr/el18QwXEMRlOj9UY5nCeA8UN3a/82rUWRBeyBw== 9977 9987 9978 9988 data-urls@^3.0.2: 9979 9989 version "3.0.2"