Bluesky app fork with some witchin' additions 💫

implement custom post name feature with replace

replaces "Post" with "Skeet" when language files are loaded

finish up the feature

+299 -47
+5 -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 17 import {messages as messagesAn} from '#/locale/locales/an/messages' 17 18 import {messages as messagesAst} from '#/locale/locales/ast/messages' 18 19 import {messages as messagesCa} from '#/locale/locales/ca/messages' ··· 125 126 break 126 127 } 127 128 case AppLanguage.en_GB: { 128 - i18n.loadAndActivate({locale, messages: messagesEn_GB}) 129 + const transformedMsgs = applySkeetReplacements(messagesEn_GB, locale) 130 + i18n.loadAndActivate({locale, messages: transformedMsgs}) 129 131 await Promise.all([ 130 132 import('@formatjs/intl-pluralrules/locale-data/en'), 131 133 import('@formatjs/intl-numberformat/locale-data/en-GB'), ··· 418 420 await Promise.all([ 419 421 import('@formatjs/intl-pluralrules/locale-data/zh'), 420 422 import('@formatjs/intl-numberformat/locale-data/zh'), 421 - import('@formatjs/intl-displaynames/locale-data/zh'), 422 423 ]) 423 424 break 424 425 } 425 426 default: { 426 - i18n.loadAndActivate({locale, messages: messagesEn}) 427 + const transformedMsgs = applySkeetReplacements(messagesEn, locale) 428 + i18n.loadAndActivate({locale, messages: transformedMsgs}) 427 429 break 428 430 } 429 431 }
+22 -5
src/locale/i18n.web.ts
··· 1 1 import {useEffect} from 'react' 2 - import {i18n} from '@lingui/core' 2 + import {i18n, type Messages} from '@lingui/core' 3 3 4 4 import {sanitizeAppLanguageSetting} from '#/locale/helpers' 5 5 import {AppLanguage} from '#/locale/languages' 6 + import {applySkeetReplacements} from '#/locale/linguiHook' 6 7 import {useLanguagePrefs} from '#/state/preferences' 7 8 8 9 /** 9 10 * We do a dynamic import of just the catalog that we need 10 11 */ 11 12 export async function dynamicActivate(locale: AppLanguage) { 12 - let mod: any 13 + let mod: {messages: Messages} 13 14 14 15 switch (locale) { 15 16 case AppLanguage.an: { ··· 40 41 mod = await import(`./locales/el/messages`) 41 42 break 42 43 } 44 + case AppLanguage.en: { 45 + mod = await import(`./locales/en/messages`) 46 + const transformedEnMessages = applySkeetReplacements(mod.messages, locale) 47 + i18n.load(locale, transformedEnMessages) 48 + i18n.activate(locale) 49 + break 50 + } 43 51 case AppLanguage.en_GB: { 44 52 mod = await import(`./locales/en-GB/messages`) 53 + const transformedEnGbMessages = applySkeetReplacements( 54 + mod.messages, 55 + locale, 56 + ) 57 + i18n.load(locale, transformedEnGbMessages) 58 + i18n.activate(locale) 45 59 break 46 60 } 47 61 case AppLanguage.eo: { ··· 174 188 } 175 189 default: { 176 190 mod = await import(`./locales/en/messages`) 191 + const transformedDefaultMessages = applySkeetReplacements( 192 + mod.messages, 193 + locale, 194 + ) 195 + i18n.load(locale, transformedDefaultMessages) 196 + i18n.activate(locale) 177 197 break 178 198 } 179 199 } 180 - 181 - i18n.load(locale, mod.messages) 182 - i18n.activate(locale) 183 200 } 184 201 185 202 export function useLocaleLanguage() {
+61
src/locale/linguiHook.ts
··· 1 + import {type Messages} from '@lingui/core' 2 + 3 + import * as persisted from '#/state/persisted' 4 + 5 + // Helper to apply the replacement to a single string 6 + function replaceInString(text: string): string { 7 + const {string: replacement, enabled} = persisted.get('postReplacement') 8 + if (!enabled) return text 9 + let repl = replacement?.length ? replacement.toLowerCase() : 'skeet' 10 + return text 11 + .replaceAll('Post', repl[0].toUpperCase() + repl.slice(1)) 12 + .replaceAll('post', repl) 13 + } 14 + 15 + // Recursive helper to traverse and replace strings in nested structures 16 + function traverseAndReplace(value: any): any { 17 + if (typeof value === 'string') { 18 + return replaceInString(value) 19 + } 20 + if (Array.isArray(value)) { 21 + return value.map(item => traverseAndReplace(item)) 22 + } 23 + if (typeof value === 'object' && value !== null) { 24 + const newObject: Record<string, any> = {} 25 + for (const key in value) { 26 + if (Object.prototype.hasOwnProperty.call(value, key)) { 27 + newObject[key] = traverseAndReplace(value[key]) 28 + } 29 + } 30 + return newObject 31 + } 32 + return value 33 + } 34 + 35 + /** 36 + * Applies "Post" to "Skeet" replacements (case-insensitive) to messages 37 + * for English locales. 38 + * @param messages The raw messages object loaded from Lingui. 39 + * @param locale The current locale string. 40 + * @returns The messages object with replacements applied if the locale is English, 41 + * otherwise the original messages object. 42 + */ 43 + export function applySkeetReplacements( 44 + messages: Messages, 45 + locale: string, 46 + ): Messages { 47 + const {enabled} = persisted.get('postReplacement') 48 + console.log('replacements enabled:', enabled) 49 + if (!enabled || !locale.startsWith('en')) { 50 + return messages 51 + } 52 + 53 + // Traverse the entire messages catalog and apply replacements 54 + const transformedCatalog: Messages = {} 55 + for (const key in messages) { 56 + if (Object.prototype.hasOwnProperty.call(messages, key)) { 57 + transformedCatalog[key] = traverseAndReplace(messages[key]) 58 + } 59 + } 60 + return transformedCatalog 61 + }
+47 -30
src/screens/Profile/Header/Metrics.tsx
··· 37 37 const disablePostsMetrics = useDisablePostsMetrics() 38 38 39 39 return ( 40 - <View 41 - style={[a.flex_row, a.gap_sm, a.align_center]} 42 - pointerEvents="box-none"> 43 - <InlineLinkText 44 - testID="profileHeaderFollowersButton" 45 - style={[a.flex_row, t.atoms.text]} 46 - to={makeProfileLink(profile, 'followers')} 47 - label={`${profile.followersCount || 0} ${pluralizedFollowers}`}> 48 - <Text style={[a.font_semi_bold, a.text_md]}>{followers} </Text> 49 - <Text style={[t.atoms.text_contrast_medium, a.text_md]}> 50 - {pluralizedFollowers} 51 - </Text> 52 - </InlineLinkText> 53 - <InlineLinkText 54 - testID="profileHeaderFollowsButton" 55 - style={[a.flex_row, t.atoms.text]} 56 - to={makeProfileLink(profile, 'follows')} 57 - label={_(msg`${profile.followsCount || 0} following`)}> 58 - <Text style={[a.font_semi_bold, a.text_md]}>{following} </Text> 59 - <Text style={[t.atoms.text_contrast_medium, a.text_md]}> 60 - {pluralizedFollowings} 61 - </Text> 62 - </InlineLinkText> 63 - <Text style={[a.font_semi_bold, t.atoms.text, a.text_md]}> 64 - {formatCount(i18n, profile.postsCount || 0)}{' '} 65 - <Text style={[t.atoms.text_contrast_medium, a.font_normal, a.text_md]}> 66 - {plural(profile.postsCount || 0, {one: 'post', other: 'posts'})} 67 - </Text> 68 - </Text> 69 - </View> 40 + <> 41 + {disableFollowersMetrics && 42 + disableFollowingMetrics && 43 + disablePostsMetrics ? null : ( 44 + <View 45 + style={[a.flex_row, a.gap_sm, a.align_center]} 46 + pointerEvents="box-none"> 47 + {!disableFollowersMetrics ? ( 48 + <InlineLinkText 49 + testID="profileHeaderFollowersButton" 50 + style={[a.flex_row, t.atoms.text]} 51 + to={makeProfileLink(profile, 'followers')} 52 + label={`${profile.followersCount || 0} ${pluralizedFollowers}`}> 53 + <Text style={[a.font_semi_bold, a.text_md]}>{followers} </Text> 54 + <Text style={[t.atoms.text_contrast_medium, a.text_md]}> 55 + {pluralizedFollowers} 56 + </Text> 57 + </InlineLinkText> 58 + ) : null} 59 + {!disableFollowingMetrics ? ( 60 + <InlineLinkText 61 + testID="profileHeaderFollowsButton" 62 + style={[a.flex_row, t.atoms.text]} 63 + to={makeProfileLink(profile, 'follows')} 64 + label={_(msg`${profile.followsCount || 0} following`)}> 65 + <Text style={[a.font_semi_bold, a.text_md]}>{following} </Text> 66 + <Text style={[t.atoms.text_contrast_medium, a.text_md]}> 67 + {pluralizedFollowings} 68 + </Text> 69 + </InlineLinkText> 70 + ) : null} 71 + {!disablePostsMetrics ? ( 72 + <Text style={[a.font_semi_bold, t.atoms.text, a.text_md]}> 73 + {formatCount(i18n, profile.postsCount || 0)}{' '} 74 + <Text 75 + style={[ 76 + t.atoms.text_contrast_medium, 77 + a.font_normal, 78 + a.text_md, 79 + ]}> 80 + {plural(profile.postsCount || 0, {one: 'post', other: 'posts'})} 81 + </Text> 82 + </Text> 83 + ) : null} 84 + </View> 85 + )} 86 + </> 70 87 ) 71 88 }
+61
src/screens/Settings/DeerSettings.tsx
··· 100 100 useSetNoDiscoverFallback, 101 101 } from '#/state/preferences/no-discover-fallback' 102 102 import { 103 + usePostReplacement, 104 + useSetPostReplacement, 105 + } from '#/state/preferences/post-name-replacement' 106 + import { 103 107 useRepostCarouselEnabled, 104 108 useSetRepostCarouselEnabled, 105 109 } from '#/state/preferences/repost-carousel-enabled' ··· 119 123 import {Admonition} from '#/components/Admonition' 120 124 import {Button, ButtonText} from '#/components/Button' 121 125 import * as Dialog from '#/components/Dialog' 126 + import * as TextField from '#/components/forms/TextField' 122 127 import * as Toggle from '#/components/forms/Toggle' 123 128 import {Atom_Stroke2_Corner0_Rounded as DeerIcon} from '#/components/icons/Atom' 124 129 import {ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon} from '#/components/icons/ChainLink' ··· 425 430 426 431 const setLibreTranslateInstanceControl = Dialog.useDialogControl() 427 432 433 + const postReplacement = usePostReplacement() 434 + const setPostReplacement = useSetPostReplacement() 435 + 428 436 return ( 429 437 <Layout.Screen> 430 438 <Layout.Header.Outer> ··· 574 582 </SettingsList.Item> 575 583 576 584 <SettingsList.Divider /> 585 + 586 + <SettingsList.Group contentContainerStyle={[a.gap_sm]}> 587 + <SettingsList.ItemIcon icon={VerifiedIcon} /> 588 + <SettingsList.ItemText> 589 + <Trans> 590 + Call posts{' '} 591 + {postReplacement.string.length 592 + ? postReplacement.string.toLowerCase() 593 + : 'skeet'} 594 + s 595 + </Trans> 596 + </SettingsList.ItemText> 597 + <Toggle.Item 598 + name="call_posts_skeets" 599 + label={_( 600 + msg`Changes post to another word of your choosing. Requires a refresh to update.`, 601 + )} 602 + value={postReplacement.enabled} 603 + onChange={value => 604 + setPostReplacement({ 605 + enabled: value, 606 + string: postReplacement.string, 607 + }) 608 + } 609 + style={[a.w_full]}> 610 + <Toggle.LabelText style={[a.flex_1]}> 611 + <Trans> 612 + Changes post to another word of your choosing. Requires a 613 + refresh to update. 614 + </Trans> 615 + </Toggle.LabelText> 616 + <Toggle.Platform /> 617 + </Toggle.Item> 618 + 619 + {postReplacement.enabled && ( 620 + <SettingsList.Item> 621 + <TextField.Root> 622 + <TextField.Input 623 + label={_(msg`Custom post name`)} 624 + value={postReplacement.string} 625 + onChangeText={(value: string) => 626 + setPostReplacement( 627 + (curr: {enabled: boolean; string: string}) => ({ 628 + ...curr, 629 + string: value, 630 + }), 631 + ) 632 + } 633 + /> 634 + </TextField.Root> 635 + </SettingsList.Item> 636 + )} 637 + </SettingsList.Group> 577 638 578 639 <SettingsList.Group contentContainerStyle={[a.gap_sm]}> 579 640 <SettingsList.ItemIcon icon={PaintRollerIcon} />
+10
src/state/persisted/schema.ts
··· 170 170 highQualityImages: z.boolean().optional(), 171 171 hideUnreplyablePosts: z.boolean().optional(), 172 172 173 + postReplacement: z.object({ 174 + enabled: z.boolean().optional(), 175 + string: z.string().optional(), 176 + }), 177 + 173 178 showExternalShareButtons: z.boolean().optional(), 174 179 175 180 translationServicePreference: z.enum([ ··· 291 296 showExternalShareButtons: false, 292 297 translationServicePreference: 'google', 293 298 libreTranslateInstance: 'https://libretranslate.com/', 299 + 300 + postReplacement: { 301 + enabled: false, 302 + string: 'skeet', 303 + }, 294 304 } 295 305 296 306 export function tryParse(rawData: string): Schema | undefined {
+12 -9
src/state/preferences/index.tsx
··· 35 35 import {Provider as LargeAltBadgeProvider} from './large-alt-badge' 36 36 import {Provider as NoAppLabelersProvider} from './no-app-labelers' 37 37 import {Provider as NoDiscoverProvider} from './no-discover-fallback' 38 + import {Provider as PostNameReplacementProvider} from './post-name-replacement.tsx' 38 39 import {Provider as RepostCarouselProvider} from './repost-carousel-enabled' 39 40 import {Provider as ShowLinkInHandleProvider} from './show-link-in-handle' 40 41 import {Provider as SubtitlesProvider} from './subtitles' ··· 110 111 <HideUnreplyablePostsProvider> 111 112 <EnableSquareAvatarsProvider> 112 113 <EnableSquareButtonsProvider> 113 - <DisableVerifyEmailReminderProvider> 114 - <TranslationServicePreferenceProvider> 115 - <DisableComposerPromptProvider> 116 - { 117 - children 118 - } 119 - </DisableComposerPromptProvider> 120 - </TranslationServicePreferenceProvider> 121 - </DisableVerifyEmailReminderProvider> 114 + <PostNameReplacementProvider> 115 + <DisableVerifyEmailReminderProvider> 116 + <TranslationServicePreferenceProvider> 117 + <DisableComposerPromptProvider> 118 + { 119 + children 120 + } 121 + </DisableComposerPromptProvider> 122 + </TranslationServicePreferenceProvider> 123 + </DisableVerifyEmailReminderProvider> 124 + </PostNameReplacementProvider> 122 125 </EnableSquareButtonsProvider> 123 126 </EnableSquareAvatarsProvider> 124 127 </HideUnreplyablePostsProvider>
+81
src/state/preferences/post-name-replacement.tsx
··· 1 + import React from 'react' 2 + 3 + import * as persisted from '#/state/persisted' 4 + 5 + interface PostReplacementState { 6 + enabled: boolean 7 + string: string 8 + } 9 + 10 + type StateContext = PostReplacementState 11 + type SetContext = ( 12 + v: 13 + | PostReplacementState 14 + | ((curr: PostReplacementState) => PostReplacementState), 15 + ) => void 16 + 17 + const stateContext = React.createContext<StateContext>( 18 + persisted.defaults.postReplacement as PostReplacementState, 19 + ) 20 + const setContext = React.createContext<SetContext>( 21 + ( 22 + _: 23 + | PostReplacementState 24 + | ((curr: PostReplacementState) => PostReplacementState), 25 + ) => {}, 26 + ) 27 + 28 + export function Provider({children}: React.PropsWithChildren<{}>) { 29 + const [state, _setState] = React.useState<PostReplacementState>(() => { 30 + const persistedState = persisted.get('postReplacement') 31 + return { 32 + enabled: 33 + persistedState?.enabled ?? persisted.defaults.postReplacement.enabled!, 34 + string: 35 + persistedState?.string ?? persisted.defaults.postReplacement.string!, 36 + } 37 + }) 38 + 39 + const setState = React.useCallback( 40 + ( 41 + val: 42 + | PostReplacementState 43 + | ((curr: PostReplacementState) => PostReplacementState), 44 + ) => { 45 + _setState(curr => { 46 + const next = typeof val === 'function' ? val(curr) : val 47 + persisted.write('postReplacement', next) 48 + return next 49 + }) 50 + }, 51 + [], 52 + ) 53 + 54 + React.useEffect(() => { 55 + 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 + }*/ 65 + }) 66 + }, []) 67 + 68 + return ( 69 + <stateContext.Provider value={state}> 70 + <setContext.Provider value={setState}>{children}</setContext.Provider> 71 + </stateContext.Provider> 72 + ) 73 + } 74 + 75 + export function usePostReplacement() { 76 + return React.useContext(stateContext) 77 + } 78 + 79 + export function useSetPostReplacement() { 80 + return React.useContext(setContext) 81 + }