Bluesky app fork with some witchin' additions 💫

MAKE CUSTOM POST PHRASE FEATURE WORK!!!

And changed some defaults (deer settings fixes)

xan.lol d5bdcf84 e31d3ba4

verified
+202 -114
+9 -9
README.md
··· 16 16 17 17 - Cooler name (and kawaii logo) 18 18 - Color scheme options and hue slider (defaults to Witchsky orange) 19 - - Posts are called Skeets (may let you choose in the future) 19 + - You can change 20 20 - Choose between sharing witchsky.app or bsky.app links 21 21 - Embed player works with [stream.place](https://stream.place/) links! 22 - - Open skeets in PDSls and original pages of bridged posts 23 - - You can redraft skeets 22 + - Open posts in PDSls and original pages of bridged posts 23 + - You can redraft posts 24 24 - Better defaults (alt text required 😉 autoplay off 🫨) 25 25 - More unique repost icons 26 26 - Can download videos ··· 33 33 These are all available as options in a sub-page of the app's settings. 34 34 35 35 - Toggle go.bsky.app link proxying for analytics 36 - - Toggle to see skeets in quotes through blocks and detachments 36 + - Toggle to see posts in quotes through blocks and detachments 37 37 - Toggle for buttons to show original fedi posts and in PDSls 38 38 - Toggle to trust your own preferred verifiers (and to operate as one yourself) 39 39 - Toggle to change Constellation instance for custom features ··· 42 42 #### Tweaks 43 43 44 44 - Toggle to turn non-bsky.social handles into clickable links 45 - - Toggle to combine reskeets in horizontal carousels 45 + - Toggle to combine reposts in horizontal carousels 46 46 - Toggle the following feed fallback to the discover feed 47 47 - Toggle displaying images in higher quality 48 48 - Toggle to only show a single tab if only one feed is pinned 49 - - Toggle to prevent others from getting notified when you interact with their reskeets 49 + - Toggle to prevent others from getting notified when you interact with their reposts 50 50 - Toggle similar account recommendations 51 51 - Toggle to make all user avatars square (like labelers) 52 52 - Toggle for more square-ish UI (still slightly rounded) ··· 58 58 You can completely disable the visiblity of all metrics individually, including the number of: 59 59 60 60 - likes 61 - - reskeets 61 + - reposts 62 62 - quotes 63 63 - saves 64 64 - replies ··· 68 68 69 69 ## Upcoming or wishful features 70 70 71 - - Better OpenGraph support for sharing profiles & skeets (including videos & fixing quotes) 71 + - Better OpenGraph support for sharing profiles & posts (including videos & fixing quotes) 72 72 - Selecting a custom AppView 73 73 - Seeing past blocks in threads (the nuclear block in reply chains) 74 74 - Configure the location used to determine regional labelers ··· 115 115 116 116 > Witchsky is a community fork, and we'd love to merge your PR! 117 117 118 - As a rule of thumb, the best features for Witchsky are those that have a disproportionately positive impact on the user experience compared to the maintenance overhead. Unlike some open source projects, since Witchsky is a soft fork, any features (patches) we add on top of upstream social-app need to be maintained. For example, a change to the way skeets are composed may be very invasive, touching lots of code across the codebase. If upstream refactors this component, we will need to rewrite this feature to be compatible or drop it from the client. 118 + As a rule of thumb, the best features for Witchsky are those that have a disproportionately positive impact on the user experience compared to the maintenance overhead. Unlike some open source projects, since Witchsky is a soft fork, any features (patches) we add on top of upstream social-app need to be maintained. For example, a change to the way posts are composed may be very invasive, touching lots of code across the codebase. If upstream refactors this component, we will need to rewrite this feature to be compatible or drop it from the client. 119 119 120 120 For this reason, only features that require changing only a small amount of code from upstream should be considered. 121 121
+3 -3
src/locale/i18n.ts
··· 13 13 14 14 import {sanitizeAppLanguageSetting} from '#/locale/helpers' 15 15 import {AppLanguage} from '#/locale/languages' 16 - import {applySkeetReplacements} from '#/locale/linguiHook' 16 + import {applyPostReplacements} from '#/locale/linguiHook' 17 17 import {messages as messagesAn} from '#/locale/locales/an/messages' 18 18 import {messages as messagesAst} from '#/locale/locales/ast/messages' 19 19 import {messages as messagesCa} from '#/locale/locales/ca/messages' ··· 126 126 break 127 127 } 128 128 case AppLanguage.en_GB: { 129 - const transformedMsgs = applySkeetReplacements(messagesEn_GB, locale) 129 + const transformedMsgs = applyPostReplacements(messagesEn_GB, locale) 130 130 i18n.loadAndActivate({locale, messages: transformedMsgs}) 131 131 await Promise.all([ 132 132 import('@formatjs/intl-pluralrules/locale-data/en'), ··· 424 424 break 425 425 } 426 426 default: { 427 - const transformedMsgs = applySkeetReplacements(messagesEn, locale) 427 + const transformedMsgs = applyPostReplacements(messagesEn, locale) 428 428 i18n.loadAndActivate({locale, messages: transformedMsgs}) 429 429 break 430 430 }
+4 -4
src/locale/i18n.web.ts
··· 3 3 4 4 import {sanitizeAppLanguageSetting} from '#/locale/helpers' 5 5 import {AppLanguage} from '#/locale/languages' 6 - import {applySkeetReplacements} from '#/locale/linguiHook' 6 + import {applyPostReplacements} from '#/locale/linguiHook' 7 7 import {useLanguagePrefs} from '#/state/preferences' 8 8 9 9 /** ··· 43 43 } 44 44 case AppLanguage.en: { 45 45 mod = await import(`./locales/en/messages`) 46 - const transformedEnMessages = applySkeetReplacements(mod.messages, locale) 46 + const transformedEnMessages = applyPostReplacements(mod.messages, locale) 47 47 i18n.load(locale, transformedEnMessages) 48 48 i18n.activate(locale) 49 49 break 50 50 } 51 51 case AppLanguage.en_GB: { 52 52 mod = await import(`./locales/en-GB/messages`) 53 - const transformedEnGbMessages = applySkeetReplacements( 53 + const transformedEnGbMessages = applyPostReplacements( 54 54 mod.messages, 55 55 locale, 56 56 ) ··· 188 188 } 189 189 default: { 190 190 mod = await import(`./locales/en/messages`) 191 - const transformedDefaultMessages = applySkeetReplacements( 191 + const transformedDefaultMessages = applyPostReplacements( 192 192 mod.messages, 193 193 locale, 194 194 )
+14 -5
src/locale/linguiHook.ts
··· 4 4 5 5 // Helper to apply the replacement to a single string 6 6 function replaceInString(text: string): string { 7 - const {string: replacement, enabled} = persisted.get('postReplacement') 7 + const {postName, postsName, enabled} = persisted.get('postReplacement') 8 8 if (!enabled) return text 9 - let repl = replacement?.length ? replacement.toLowerCase() : 'skeet' 9 + 10 + const singular = postName?.length ? postName : 'skeet' 11 + const plural = postsName?.length ? postsName : 'skeets' 12 + 13 + // Capitalize first letter for proper noun replacements 14 + const singularCapitalized = singular[0].toUpperCase() + singular.slice(1) 15 + const pluralCapitalized = plural[0].toUpperCase() + plural.slice(1) 16 + 10 17 return text 11 - .replaceAll('Post', repl[0].toUpperCase() + repl.slice(1)) 12 - .replaceAll('post', repl) 18 + .replaceAll('Posts', pluralCapitalized) 19 + .replaceAll('posts', plural) 20 + .replaceAll('Post', singularCapitalized) 21 + .replaceAll('post', singular) 13 22 } 14 23 15 24 // Recursive helper to traverse and replace strings in nested structures ··· 40 49 * @returns The messages object with replacements applied if the locale is English, 41 50 * otherwise the original messages object. 42 51 */ 43 - export function applySkeetReplacements( 52 + export function applyPostReplacements( 44 53 messages: Messages, 45 54 locale: string, 46 55 ): Messages {
+152 -76
src/screens/Settings/DeerSettings.tsx
··· 7 7 8 8 import {usePalette} from '#/lib/hooks/usePalette' 9 9 import {type CommonNavigatorParams} from '#/lib/routes/types' 10 + import {dynamicActivate} from '#/locale/i18n' 11 + import {dynamicActivate as dynamicActivateWeb} from '#/locale/i18n.web' 12 + import {type AppLanguage} from '#/locale/languages' 10 13 import * as persisted from '#/state/persisted' 11 14 import {useGoLinksEnabled, useSetGoLinksEnabled} from '#/state/preferences' 12 15 import { ··· 123 126 import {Admonition} from '#/components/Admonition' 124 127 import {Button, ButtonText} from '#/components/Button' 125 128 import * as Dialog from '#/components/Dialog' 126 - import * as TextField from '#/components/forms/TextField' 127 129 import * as Toggle from '#/components/forms/Toggle' 128 130 import {Atom_Stroke2_Corner0_Rounded as DeerIcon} from '#/components/icons/Atom' 129 131 import {ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon} from '#/components/icons/ChainLink' ··· 295 297 ) 296 298 } 297 299 300 + function PostReplacementDialog({ 301 + control, 302 + }: { 303 + control: Dialog.DialogControlProps 304 + }) { 305 + const pal = usePalette('default') 306 + const {_, i18n} = useLingui() 307 + 308 + const postReplacement = usePostReplacement() 309 + const setPostReplacement = useSetPostReplacement() 310 + 311 + const [singular, setSingular] = useState(postReplacement.postName) 312 + const [plural, setPlural] = useState(postReplacement.postsName) 313 + const [pluralManuallyEdited, setPluralManuallyEdited] = useState(false) 314 + 315 + const submit = async () => { 316 + setPostReplacement({ 317 + enabled: singular.trim().toLowerCase() !== 'post', 318 + postName: singular, 319 + postsName: plural, 320 + }) 321 + 322 + // Force reload the i18n messages to apply the replacement immediately 323 + const locale = i18n.locale 324 + await (IS_WEB 325 + ? dynamicActivateWeb(locale as AppLanguage) 326 + : dynamicActivate(locale as AppLanguage)) 327 + 328 + control.close() 329 + } 330 + 331 + const handleSingularChange = (value: string) => { 332 + setSingular(value) 333 + if (!pluralManuallyEdited) { 334 + setPlural(value + 's') 335 + } 336 + } 337 + 338 + const handlePluralChange = (value: string) => { 339 + setPlural(value) 340 + setPluralManuallyEdited(true) 341 + } 342 + 343 + const handlePresetSelect = (singularForm: string, pluralForm: string) => { 344 + setSingular(singularForm) 345 + setPlural(pluralForm) 346 + setPluralManuallyEdited(false) 347 + } 348 + 349 + const shouldDisable = () => { 350 + return !singular.trim() || !plural.trim() 351 + } 352 + 353 + return ( 354 + <Dialog.Outer 355 + control={control} 356 + nativeOptions={{preventExpansion: true}} 357 + onClose={() => { 358 + setSingular(postReplacement.postName) 359 + setPlural(postReplacement.postsName) 360 + setPluralManuallyEdited(false) 361 + }}> 362 + <Dialog.Handle /> 363 + <Dialog.ScrollableInner label={_(msg`Custom post phrase`)}> 364 + <View style={[a.gap_sm, a.pb_lg]}> 365 + <Text style={[a.text_2xl, a.font_bold]}> 366 + <Trans>Custom post phrase</Trans> 367 + </Text> 368 + </View> 369 + 370 + <View style={a.gap_lg}> 371 + <Dialog.Input 372 + label="Singular form" 373 + autoFocus 374 + style={[styles.textInput, pal.border, pal.text]} 375 + onChangeText={handleSingularChange} 376 + placeholder="skeet" 377 + placeholderTextColor={pal.colors.textLight} 378 + accessibilityHint={_(msg`Input the singular form (e.g., "skeet")`)} 379 + value={singular} 380 + /> 381 + 382 + <View style={[a.flex_row, a.flex_wrap, a.mb_xs]}> 383 + {[ 384 + {singular: 'post', plural: 'posts'}, 385 + {singular: 'skeet', plural: 'skeets'}, 386 + {singular: 'note', plural: 'notes'}, 387 + {singular: 'woot', plural: 'woots'}, 388 + {singular: 'toot', plural: 'toots'}, 389 + {singular: 'silly', plural: 'sillies'}, 390 + ].map(preset => ( 391 + <Button 392 + key={preset.singular} 393 + variant="ghost" 394 + color="primary" 395 + label={preset.singular} 396 + style={[a.px_sm, a.py_xs, a.rounded_sm, a.gap_sm]} 397 + onPress={() => 398 + handlePresetSelect(preset.singular, preset.plural) 399 + }> 400 + <ButtonText>{preset.singular}</ButtonText> 401 + </Button> 402 + ))} 403 + </View> 404 + 405 + <Dialog.Input 406 + label="Plural form" 407 + style={[styles.textInput, pal.border, pal.text]} 408 + onChangeText={handlePluralChange} 409 + placeholder="skeets" 410 + placeholderTextColor={pal.colors.textLight} 411 + accessibilityHint={_(msg`Input the plural form (e.g., "skeets")`)} 412 + value={plural} 413 + /> 414 + 415 + <View style={IS_WEB && [a.flex_row, a.justify_end]}> 416 + <Button 417 + label={_(msg`Save`)} 418 + size="large" 419 + onPress={submit} 420 + variant="solid" 421 + color="primary" 422 + disabled={shouldDisable()}> 423 + <ButtonText> 424 + <Trans>Save</Trans> 425 + </ButtonText> 426 + </Button> 427 + </View> 428 + </View> 429 + 430 + <Dialog.Close /> 431 + </Dialog.ScrollableInner> 432 + </Dialog.Outer> 433 + ) 434 + } 435 + 298 436 function TrustedVerifiersDialog({ 299 437 control, 300 438 }: { ··· 431 569 432 570 const setLibreTranslateInstanceControl = Dialog.useDialogControl() 433 571 434 - const postReplacement = usePostReplacement() 435 - const setPostReplacement = useSetPostReplacement() 572 + const setPostReplacementDialogControl = Dialog.useDialogControl() 436 573 437 574 return ( 438 575 <Layout.Screen> ··· 584 721 585 722 <SettingsList.Divider /> 586 723 587 - <SettingsList.Group contentContainerStyle={[a.gap_sm]}> 724 + <SettingsList.Item> 588 725 <SettingsList.ItemIcon icon={PencilIcon} /> 589 726 <SettingsList.ItemText> 590 - <Trans> 591 - Call posts{' '} 592 - {postReplacement.string.length 593 - ? postReplacement.string.toLowerCase() 594 - : 'skeet'} 595 - s 596 - </Trans> 727 + <Trans>{`Custom post phrase`}</Trans> 597 728 </SettingsList.ItemText> 598 - <Toggle.Item 599 - name="call_posts_skeets" 600 - label={_( 601 - msg`Changes post to another word of your choosing. Requires a refresh to update.`, 602 - )} 603 - value={postReplacement.enabled} 604 - onChange={value => 605 - setPostReplacement({ 606 - enabled: value, 607 - string: postReplacement.string, 608 - }) 609 - } 610 - style={[a.w_full]}> 611 - <Toggle.LabelText style={[a.flex_1]}> 612 - <Trans> 613 - Changes post to another word of your choosing. Requires a 614 - refresh to update. 615 - </Trans> 616 - </Toggle.LabelText> 617 - <Toggle.Platform /> 618 - </Toggle.Item> 729 + <SettingsList.BadgeButton 730 + label={_(msg`Change`)} 731 + onPress={() => setPostReplacementDialogControl.open()} 732 + /> 733 + </SettingsList.Item> 619 734 620 - {postReplacement.enabled && ( 621 - <SettingsList.Item> 622 - <TextField.Root> 623 - <TextField.Input 624 - label={_(msg`Custom post name`)} 625 - value={postReplacement.string} 626 - onChangeText={(value: string) => 627 - setPostReplacement( 628 - (curr: {enabled: boolean; string: string}) => ({ 629 - ...curr, 630 - string: value, 631 - }), 632 - ) 633 - } 634 - /> 635 - </TextField.Root> 636 - </SettingsList.Item> 637 - )} 638 - </SettingsList.Group> 735 + <SettingsList.Divider /> 639 736 640 737 <SettingsList.Group contentContainerStyle={[a.gap_sm]}> 641 738 <SettingsList.ItemIcon icon={PaintRollerIcon} /> ··· 653 750 </Toggle.LabelText> 654 751 <Toggle.Platform /> 655 752 </Toggle.Item> 656 - <Toggle.Item 657 - name="no_discover_fallback" 658 - label={_(msg`Do not fall back to discover feed`)} 659 - value={noDiscoverFallback} 660 - onChange={value => setNoDiscoverFallback(value)} 661 - style={[a.w_full]}> 662 - <Toggle.LabelText style={[a.flex_1]}> 663 - <Trans>Do not fall back to discover feed</Trans> 664 - </Toggle.LabelText> 665 - <Toggle.Platform /> 666 - </Toggle.Item> 753 + 667 754 <Toggle.Item 668 755 name="show_link_in_handle" 669 756 label={_( ··· 676 763 <Trans> 677 764 On non-bsky.social handles, show a link to that URL 678 765 </Trans> 679 - </Toggle.LabelText> 680 - <Toggle.Platform /> 681 - </Toggle.Item> 682 - 683 - <Toggle.Item 684 - name="repost_carousel" 685 - label={_(msg`Combine reposts into a horizontal carousel`)} 686 - value={repostCarouselEnabled} 687 - onChange={value => setRepostCarouselEnabled(value)} 688 - style={[a.w_full]}> 689 - <Toggle.LabelText style={[a.flex_1]}> 690 - <Trans>Combine reposts into a horizontal carousel</Trans> 691 766 </Toggle.LabelText> 692 767 <Toggle.Platform /> 693 768 </Toggle.Item> ··· 908 983 909 984 <Toggle.Item 910 985 name="disable_reposts_metrics" 911 - label={_(msg`Disable Reposts Metrics`)} 986 + label={_(msg`Disable reposts metrics`)} 912 987 value={disableRepostsMetrics} 913 988 onChange={value => setDisableRepostsMetrics(value)} 914 989 style={[a.w_full]}> 915 990 <Toggle.LabelText style={[a.flex_1]}> 916 - <Trans>Disable Reposts Metrics</Trans> 991 + <Trans>Disable reposts metrics</Trans> 917 992 </Toggle.LabelText> 918 993 <Toggle.Platform /> 919 994 </Toggle.Item> ··· 1053 1128 <LibreTranslateInstanceDialog 1054 1129 control={setLibreTranslateInstanceControl} 1055 1130 /> 1131 + <PostReplacementDialog control={setPostReplacementDialogControl} /> 1056 1132 </Layout.Screen> 1057 1133 ) 1058 1134 }
+6 -4
src/state/persisted/schema.ts
··· 172 172 173 173 postReplacement: z.object({ 174 174 enabled: z.boolean().optional(), 175 - string: z.string().optional(), 175 + postName: z.string().optional(), 176 + postsName: z.string().optional(), 176 177 }), 177 178 178 179 showExternalShareButtons: z.boolean().optional(), ··· 218 219 ]), 219 220 }, 220 221 requireAltTextEnabled: true, 221 - largeAltBadgeEnabled: true, 222 + largeAltBadgeEnabled: false, 222 223 externalEmbeds: {}, 223 224 mutedThreads: [], 224 225 invites: { ··· 242 243 // deer 243 244 goLinksEnabled: true, 244 245 constellationEnabled: true, 245 - directFetchRecords: false, 246 + directFetchRecords: true, 246 247 noAppLabelers: false, 247 248 noDiscoverFallback: false, 248 249 repostCarouselEnabled: false, ··· 299 300 300 301 postReplacement: { 301 302 enabled: false, 302 - string: 'skeet', 303 + postName: 'skeet', 304 + postsName: 'skeets', 303 305 }, 304 306 } 305 307
+14 -13
src/state/preferences/post-name-replacement.tsx
··· 4 4 5 5 interface PostReplacementState { 6 6 enabled: boolean 7 - string: string 7 + postName: string 8 + postsName: string 8 9 } 9 10 10 11 type StateContext = PostReplacementState ··· 31 32 return { 32 33 enabled: 33 34 persistedState?.enabled ?? persisted.defaults.postReplacement.enabled!, 34 - string: 35 - persistedState?.string ?? persisted.defaults.postReplacement.string!, 35 + postName: 36 + persistedState?.postName ?? 37 + persisted.defaults.postReplacement.postName!, 38 + postsName: 39 + persistedState?.postsName ?? 40 + persisted.defaults.postReplacement.postsName!, 36 41 } 37 42 }) 38 43 ··· 53 58 54 59 React.useEffect(() => { 55 60 return persisted.onUpdate('postReplacement', next => { 56 - setState({string: next.string ?? 'skeet', enabled: next.enabled ?? true}) 57 - /* 58 - if (nextVal) { 59 - _setState({ 60 - enabled: 61 - nextVal.enabled ?? persisted.defaults.postReplacement.enabled!, 62 - string: nextVal.string ?? persisted.defaults.postReplacement.string!, 63 - }) 64 - }*/ 61 + setState({ 62 + postName: next.postName ?? 'skeet', 63 + postsName: next.postsName ?? 'skeets', 64 + enabled: next.enabled ?? true, 65 + }) 65 66 }) 66 - }, []) 67 + }, [setState]) 67 68 68 69 return ( 69 70 <stateContext.Provider value={state}>