about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/App.native.tsx13
-rw-r--r--src/App.web.tsx13
-rw-r--r--src/state/models/ui/preferences.ts136
-rw-r--r--src/state/preferences/index.tsx8
-rw-r--r--src/state/preferences/languages.tsx122
-rw-r--r--src/view/com/composer/Composer.tsx12
-rw-r--r--src/view/com/composer/select-language/SelectLangBtn.tsx24
-rw-r--r--src/view/com/modals/lang-settings/ContentLanguagesSettings.tsx17
-rw-r--r--src/view/com/modals/lang-settings/LanguageToggle.tsx16
-rw-r--r--src/view/com/modals/lang-settings/PostLanguagesSettings.tsx25
-rw-r--r--src/view/com/post-thread/PostThreadItem.tsx10
-rw-r--r--src/view/com/post/Post.tsx4
-rw-r--r--src/view/com/posts/FeedItem.tsx4
-rw-r--r--src/view/screens/AppPasswords.tsx4
-rw-r--r--src/view/screens/LanguageSettings.tsx15
15 files changed, 233 insertions, 190 deletions
diff --git a/src/App.native.tsx b/src/App.native.tsx
index 865e6dc19..ccc7de32d 100644
--- a/src/App.native.tsx
+++ b/src/App.native.tsx
@@ -24,6 +24,7 @@ import {TestCtrls} from 'view/com/testing/TestCtrls'
 import {Provider as ShellStateProvider} from 'state/shell'
 import {Provider as MutedThreadsProvider} from 'state/muted-threads'
 import {Provider as InvitesStateProvider} from 'state/invites'
+import {Provider as PrefsStateProvider} from 'state/preferences'
 
 SplashScreen.preventAutoHideAsync()
 
@@ -80,11 +81,13 @@ function App() {
 
   return (
     <ShellStateProvider>
-      <MutedThreadsProvider>
-        <InvitesStateProvider>
-          <InnerApp />
-        </InvitesStateProvider>
-      </MutedThreadsProvider>
+      <PrefsStateProvider>
+        <MutedThreadsProvider>
+          <InvitesStateProvider>
+            <InnerApp />
+          </InvitesStateProvider>
+        </MutedThreadsProvider>
+      </PrefsStateProvider>
     </ShellStateProvider>
   )
 }
diff --git a/src/App.web.tsx b/src/App.web.tsx
index cfc2a0028..363161bfc 100644
--- a/src/App.web.tsx
+++ b/src/App.web.tsx
@@ -19,6 +19,7 @@ import {queryClient} from 'lib/react-query'
 import {Provider as ShellStateProvider} from 'state/shell'
 import {Provider as MutedThreadsProvider} from 'state/muted-threads'
 import {Provider as InvitesStateProvider} from 'state/invites'
+import {Provider as PrefsStateProvider} from 'state/preferences'
 
 const InnerApp = observer(function AppImpl() {
   const colorMode = useColorMode()
@@ -70,11 +71,13 @@ function App() {
 
   return (
     <ShellStateProvider>
-      <MutedThreadsProvider>
-        <InvitesStateProvider>
-          <InnerApp />
-        </InvitesStateProvider>
-      </MutedThreadsProvider>
+      <PrefsStateProvider>
+        <MutedThreadsProvider>
+          <InvitesStateProvider>
+            <InnerApp />
+          </InvitesStateProvider>
+        </MutedThreadsProvider>
+      </PrefsStateProvider>
     </ShellStateProvider>
   )
 }
diff --git a/src/state/models/ui/preferences.ts b/src/state/models/ui/preferences.ts
index d03fa8d24..951486592 100644
--- a/src/state/models/ui/preferences.ts
+++ b/src/state/models/ui/preferences.ts
@@ -10,11 +10,10 @@ import {isObj, hasProp} from 'lib/type-guards'
 import {RootStoreModel} from '../root-store'
 import {ModerationOpts} from '@atproto/api'
 import {DEFAULT_FEEDS} from 'lib/constants'
-import {deviceLocales} from 'platform/detection'
 import {getAge} from 'lib/strings/time'
 import {FeedTuner} from 'lib/api/feed-manip'
-import {LANGUAGES} from '../../../locale/languages'
 import {logger} from '#/logger'
+import {getContentLanguages} from '#/state/preferences/languages'
 
 // TEMP we need to permanently convert 'show' to 'ignore', for now we manually convert -prf
 export type LabelPreference = APILabelPreference | 'show'
@@ -34,9 +33,6 @@ const LABEL_GROUPS = [
   'impersonation',
 ]
 const VISIBILITY_VALUES = ['ignore', 'warn', 'hide']
-const DEFAULT_LANG_CODES = (deviceLocales || [])
-  .concat(['en', 'ja', 'pt', 'de'])
-  .slice(0, 6)
 const THREAD_SORT_VALUES = ['oldest', 'newest', 'most-likes', 'random']
 
 interface LegacyPreferences {
@@ -62,10 +58,6 @@ export class LabelPreferencesModel {
 
 export class PreferencesModel {
   adultContentEnabled = false
-  primaryLanguage: string = deviceLocales[0] || 'en'
-  contentLanguages: string[] = deviceLocales || []
-  postLanguage: string = deviceLocales[0] || 'en'
-  postLanguageHistory: string[] = DEFAULT_LANG_CODES
   contentLabels = new LabelPreferencesModel()
   savedFeeds: string[] = []
   pinnedFeeds: string[] = []
@@ -103,10 +95,6 @@ export class PreferencesModel {
 
   serialize() {
     return {
-      primaryLanguage: this.primaryLanguage,
-      contentLanguages: this.contentLanguages,
-      postLanguage: this.postLanguage,
-      postLanguageHistory: this.postLanguageHistory,
       contentLabels: this.contentLabels,
       savedFeeds: this.savedFeeds,
       pinnedFeeds: this.pinnedFeeds,
@@ -120,44 +108,6 @@ export class PreferencesModel {
    */
   hydrate(v: unknown) {
     if (isObj(v)) {
-      if (
-        hasProp(v, 'primaryLanguage') &&
-        typeof v.primaryLanguage === 'string'
-      ) {
-        this.primaryLanguage = v.primaryLanguage
-      } else {
-        // default to the device languages
-        this.primaryLanguage = deviceLocales[0] || 'en'
-      }
-      // check if content languages in preferences exist, otherwise default to device languages
-      if (
-        hasProp(v, 'contentLanguages') &&
-        Array.isArray(v.contentLanguages) &&
-        typeof v.contentLanguages.every(item => typeof item === 'string')
-      ) {
-        this.contentLanguages = v.contentLanguages
-      } else {
-        // default to the device languages
-        this.contentLanguages = deviceLocales
-      }
-      if (hasProp(v, 'postLanguage') && typeof v.postLanguage === 'string') {
-        this.postLanguage = v.postLanguage
-      } else {
-        // default to the device languages
-        this.postLanguage = deviceLocales[0] || 'en'
-      }
-      if (
-        hasProp(v, 'postLanguageHistory') &&
-        Array.isArray(v.postLanguageHistory) &&
-        typeof v.postLanguageHistory.every(item => typeof item === 'string')
-      ) {
-        this.postLanguageHistory = v.postLanguageHistory
-          .concat(DEFAULT_LANG_CODES)
-          .slice(0, 6)
-      } else {
-        // default to a starter set
-        this.postLanguageHistory = DEFAULT_LANG_CODES
-      }
       // check if content labels in preferences exist, then hydrate
       if (hasProp(v, 'contentLabels') && typeof v.contentLabels === 'object') {
         Object.assign(this.contentLabels, v.contentLabels)
@@ -262,9 +212,6 @@ export class PreferencesModel {
     try {
       runInAction(() => {
         this.contentLabels = new LabelPreferencesModel()
-        this.contentLanguages = deviceLocales
-        this.postLanguage = deviceLocales ? deviceLocales.join(',') : 'en'
-        this.postLanguageHistory = DEFAULT_LANG_CODES
         this.savedFeeds = []
         this.pinnedFeeds = []
       })
@@ -276,81 +223,6 @@ export class PreferencesModel {
     }
   }
 
-  // languages
-  // =
-
-  hasContentLanguage(code2: string) {
-    return this.contentLanguages.includes(code2)
-  }
-
-  toggleContentLanguage(code2: string) {
-    if (this.hasContentLanguage(code2)) {
-      this.contentLanguages = this.contentLanguages.filter(
-        lang => lang !== code2,
-      )
-    } else {
-      this.contentLanguages = this.contentLanguages.concat([code2])
-    }
-  }
-
-  /**
-   * A getter that splits `this.postLanguage` into an array of strings.
-   *
-   * This was previously the main field on this model, but now we're
-   * concatenating lang codes to make multi-selection a little better.
-   */
-  get postLanguages() {
-    // filter out empty strings if exist
-    return this.postLanguage.split(',').filter(Boolean)
-  }
-
-  hasPostLanguage(code2: string) {
-    return this.postLanguages.includes(code2)
-  }
-
-  togglePostLanguage(code2: string) {
-    if (this.hasPostLanguage(code2)) {
-      this.postLanguage = this.postLanguages
-        .filter(lang => lang !== code2)
-        .join(',')
-    } else {
-      // sort alphabetically for deterministic comparison in context menu
-      this.postLanguage = this.postLanguages
-        .concat([code2])
-        .sort((a, b) => a.localeCompare(b))
-        .join(',')
-    }
-  }
-
-  setPostLanguage(commaSeparatedLangCodes: string) {
-    this.postLanguage = commaSeparatedLangCodes
-  }
-
-  /**
-   * Saves whatever language codes are currently selected into a history array,
-   * which is then used to populate the language selector menu.
-   */
-  savePostLanguageToHistory() {
-    // filter out duplicate `this.postLanguage` if exists, and prepend
-    // value to start of array
-    this.postLanguageHistory = [this.postLanguage]
-      .concat(
-        this.postLanguageHistory.filter(
-          commaSeparatedLangCodes =>
-            commaSeparatedLangCodes !== this.postLanguage,
-        ),
-      )
-      .slice(0, 6)
-  }
-
-  getReadablePostLanguages() {
-    const all = this.postLanguages.map(code2 => {
-      const lang = LANGUAGES.find(l => l.code2 === code2)
-      return lang ? lang.name : code2
-    })
-    return all.join(', ')
-  }
-
   // moderation
   // =
 
@@ -599,17 +471,13 @@ export class PreferencesModel {
     }
   }
 
-  setPrimaryLanguage(lang: string) {
-    this.primaryLanguage = lang
-  }
-
   getFeedTuners(
     feedType: 'home' | 'following' | 'author' | 'custom' | 'list' | 'likes',
   ) {
     if (feedType === 'custom') {
       return [
         FeedTuner.dedupReposts,
-        FeedTuner.preferredLangOnly(this.contentLanguages),
+        FeedTuner.preferredLangOnly(getContentLanguages()),
       ]
     }
     if (feedType === 'list') {
diff --git a/src/state/preferences/index.tsx b/src/state/preferences/index.tsx
new file mode 100644
index 000000000..56c93f812
--- /dev/null
+++ b/src/state/preferences/index.tsx
@@ -0,0 +1,8 @@
+import React from 'react'
+import {Provider as LanguagesProvider} from './languages'
+
+export {useLanguagePrefs, useSetLanguagePrefs} from './languages'
+
+export function Provider({children}: React.PropsWithChildren<{}>) {
+  return <LanguagesProvider>{children}</LanguagesProvider>
+}
diff --git a/src/state/preferences/languages.tsx b/src/state/preferences/languages.tsx
new file mode 100644
index 000000000..49b63550d
--- /dev/null
+++ b/src/state/preferences/languages.tsx
@@ -0,0 +1,122 @@
+import React from 'react'
+import * as persisted from '#/state/persisted'
+
+type SetStateCb = (
+  v: persisted.Schema['languagePrefs'],
+) => persisted.Schema['languagePrefs']
+type StateContext = persisted.Schema['languagePrefs']
+type SetContext = (fn: SetStateCb) => void
+
+const stateContext = React.createContext<StateContext>(
+  persisted.defaults.languagePrefs,
+)
+const setContext = React.createContext<SetContext>((_: SetStateCb) => {})
+
+export function Provider({children}: React.PropsWithChildren<{}>) {
+  const [state, setState] = React.useState(persisted.get('languagePrefs'))
+
+  const setStateWrapped = React.useCallback(
+    (fn: SetStateCb) => {
+      const v = fn(persisted.get('languagePrefs'))
+      setState(v)
+      persisted.write('languagePrefs', v)
+    },
+    [setState],
+  )
+
+  React.useEffect(() => {
+    return persisted.onUpdate(() => {
+      setState(persisted.get('languagePrefs'))
+    })
+  }, [setStateWrapped])
+
+  return (
+    <stateContext.Provider value={state}>
+      <setContext.Provider value={setStateWrapped}>
+        {children}
+      </setContext.Provider>
+    </stateContext.Provider>
+  )
+}
+
+export function useLanguagePrefs() {
+  return React.useContext(stateContext)
+}
+
+export function useSetLanguagePrefs() {
+  return React.useContext(setContext)
+}
+
+export function getContentLanguages() {
+  return persisted.get('languagePrefs').contentLanguages
+}
+
+export function toggleContentLanguage(
+  state: StateContext,
+  setState: SetContext,
+  code2: string,
+) {
+  if (state.contentLanguages.includes(code2)) {
+    setState(v => ({
+      ...v,
+      contentLanguages: v.contentLanguages.filter(lang => lang !== code2),
+    }))
+  } else {
+    setState(v => ({
+      ...v,
+      contentLanguages: v.contentLanguages.concat(code2),
+    }))
+  }
+}
+
+export function toPostLanguages(postLanguage: string): string[] {
+  // filter out empty strings if exist
+  return postLanguage.split(',').filter(Boolean)
+}
+
+export function hasPostLanguage(postLanguage: string, code2: string): boolean {
+  return toPostLanguages(postLanguage).includes(code2)
+}
+
+export function togglePostLanguage(
+  state: StateContext,
+  setState: SetContext,
+  code2: string,
+) {
+  if (hasPostLanguage(state.postLanguage, code2)) {
+    setState(v => ({
+      ...v,
+      postLanguage: toPostLanguages(v.postLanguage)
+        .filter(lang => lang !== code2)
+        .join(','),
+    }))
+  } else {
+    // sort alphabetically for deterministic comparison in context menu
+    setState(v => ({
+      ...v,
+      postLanguage: toPostLanguages(v.postLanguage)
+        .concat([code2])
+        .sort((a, b) => a.localeCompare(b))
+        .join(','),
+    }))
+  }
+}
+
+/**
+ * Saves whatever language codes are currently selected into a history array,
+ * which is then used to populate the language selector menu.
+ */
+export function savePostLanguageToHistory(setState: SetContext) {
+  // filter out duplicate `this.postLanguage` if exists, and prepend
+  // value to start of array
+  setState(v => ({
+    ...v,
+    postLanguageHistory: [v.postLanguage]
+      .concat(
+        v.postLanguageHistory.filter(
+          commaSeparatedLangCodes => commaSeparatedLangCodes !== v.postLanguage,
+        ),
+      )
+      .slice(0, 6),
+  }))
+}
diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx
index a08992df4..4ebbfd666 100644
--- a/src/view/com/composer/Composer.tsx
+++ b/src/view/com/composer/Composer.tsx
@@ -50,6 +50,12 @@ import {SelectLangBtn} from './select-language/SelectLangBtn'
 import {EmojiPickerButton} from './text-input/web/EmojiPicker.web'
 import {insertMentionAt} from 'lib/strings/mention-manip'
 import {useRequireAltTextEnabled} from '#/state/shell'
+import {
+  useLanguagePrefs,
+  useSetLanguagePrefs,
+  toPostLanguages,
+  savePostLanguageToHistory,
+} from '#/state/preferences/languages'
 
 type Props = ComposerOpts
 export const ComposePost = observer(function ComposePost({
@@ -63,6 +69,8 @@ export const ComposePost = observer(function ComposePost({
   const {isDesktop, isMobile} = useWebMediaQueries()
   const store = useStores()
   const requireAltTextEnabled = useRequireAltTextEnabled()
+  const langPrefs = useLanguagePrefs()
+  const setLangPrefs = useSetLanguagePrefs()
   const textInput = useRef<TextInputRef>(null)
   const [isKeyboardVisible] = useIsKeyboardVisible({iosUseWillEvents: true})
   const [isProcessing, setIsProcessing] = useState(false)
@@ -212,7 +220,7 @@ export const ComposePost = observer(function ComposePost({
         labels,
         onStateChange: setProcessingState,
         knownHandles: autocompleteView.knownHandles,
-        langs: store.preferences.postLanguages,
+        langs: toPostLanguages(langPrefs.postLanguage),
       })
     } catch (e: any) {
       if (extLink) {
@@ -234,7 +242,7 @@ export const ComposePost = observer(function ComposePost({
     if (!replyTo) {
       store.me.mainFeed.onPostCreated()
     }
-    store.preferences.savePostLanguageToHistory()
+    savePostLanguageToHistory(setLangPrefs)
     onPost?.()
     onClose()
     Toast.show(`Your ${replyTo ? 'reply' : 'post'} has been published`)
diff --git a/src/view/com/composer/select-language/SelectLangBtn.tsx b/src/view/com/composer/select-language/SelectLangBtn.tsx
index 4faac3750..646542387 100644
--- a/src/view/com/composer/select-language/SelectLangBtn.tsx
+++ b/src/view/com/composer/select-language/SelectLangBtn.tsx
@@ -15,10 +15,18 @@ import {usePalette} from 'lib/hooks/usePalette'
 import {useStores} from 'state/index'
 import {isNative} from 'platform/detection'
 import {codeToLanguageName} from '../../../../locale/helpers'
+import {
+  useLanguagePrefs,
+  useSetLanguagePrefs,
+  toPostLanguages,
+  hasPostLanguage,
+} from '#/state/preferences/languages'
 
 export const SelectLangBtn = observer(function SelectLangBtn() {
   const pal = usePalette('default')
   const store = useStores()
+  const langPrefs = useLanguagePrefs()
+  const setLangPrefs = useSetLanguagePrefs()
 
   const onPressMore = useCallback(async () => {
     if (isNative) {
@@ -29,8 +37,7 @@ export const SelectLangBtn = observer(function SelectLangBtn() {
     store.shell.openModal({name: 'post-languages-settings'})
   }, [store])
 
-  const postLanguagesPref = store.preferences.postLanguages
-  const postLanguagePref = store.preferences.postLanguage
+  const postLanguagesPref = toPostLanguages(langPrefs.postLanguage)
   const items: DropdownItem[] = useMemo(() => {
     let arr: DropdownItemButton[] = []
 
@@ -49,13 +56,14 @@ export const SelectLangBtn = observer(function SelectLangBtn() {
 
       arr.push({
         icon:
-          langCodes.every(code => store.preferences.hasPostLanguage(code)) &&
-          langCodes.length === postLanguagesPref.length
+          langCodes.every(code =>
+            hasPostLanguage(langPrefs.postLanguage, code),
+          ) && langCodes.length === postLanguagesPref.length
             ? ['fas', 'circle-dot']
             : ['far', 'circle'],
         label: langName,
         onPress() {
-          store.preferences.setPostLanguage(commaSeparatedLangCodes)
+          setLangPrefs(v => ({...v, postLanguage: commaSeparatedLangCodes}))
         },
       })
     }
@@ -65,11 +73,11 @@ export const SelectLangBtn = observer(function SelectLangBtn() {
        * Re-join here after sanitization bc postLanguageHistory is an array of
        * comma-separated strings too
        */
-      add(postLanguagePref)
+      add(langPrefs.postLanguage)
     }
 
     // comma-separted strings of lang codes that have been used in the past
-    for (const lang of store.preferences.postLanguageHistory) {
+    for (const lang of langPrefs.postLanguageHistory) {
       add(lang)
     }
 
@@ -82,7 +90,7 @@ export const SelectLangBtn = observer(function SelectLangBtn() {
         onPress: onPressMore,
       },
     ]
-  }, [store.preferences, onPressMore, postLanguagePref, postLanguagesPref])
+  }, [onPressMore, langPrefs, setLangPrefs, postLanguagesPref])
 
   return (
     <DropdownButton
diff --git a/src/view/com/modals/lang-settings/ContentLanguagesSettings.tsx b/src/view/com/modals/lang-settings/ContentLanguagesSettings.tsx
index 910522f90..659245616 100644
--- a/src/view/com/modals/lang-settings/ContentLanguagesSettings.tsx
+++ b/src/view/com/modals/lang-settings/ContentLanguagesSettings.tsx
@@ -9,11 +9,18 @@ import {deviceLocales} from 'platform/detection'
 import {LANGUAGES, LANGUAGES_MAP_CODE2} from '../../../../locale/languages'
 import {LanguageToggle} from './LanguageToggle'
 import {ConfirmLanguagesButton} from './ConfirmLanguagesButton'
+import {
+  useLanguagePrefs,
+  useSetLanguagePrefs,
+  toggleContentLanguage,
+} from '#/state/preferences/languages'
 
 export const snapPoints = ['100%']
 
 export function Component({}: {}) {
   const store = useStores()
+  const langPrefs = useLanguagePrefs()
+  const setLangPrefs = useSetLanguagePrefs()
   const pal = usePalette('default')
   const {isMobile} = useWebMediaQueries()
   const onPressDone = React.useCallback(() => {
@@ -29,23 +36,23 @@ export function Component({}: {}) {
     // sort so that device & selected languages are on top, then alphabetically
     langs.sort((a, b) => {
       const hasA =
-        store.preferences.hasContentLanguage(a.code2) ||
+        langPrefs.contentLanguages.includes(a.code2) ||
         deviceLocales.includes(a.code2)
       const hasB =
-        store.preferences.hasContentLanguage(b.code2) ||
+        langPrefs.contentLanguages.includes(b.code2) ||
         deviceLocales.includes(b.code2)
       if (hasA === hasB) return a.name.localeCompare(b.name)
       if (hasA) return -1
       return 1
     })
     return langs
-  }, [store])
+  }, [langPrefs])
 
   const onPress = React.useCallback(
     (code2: string) => {
-      store.preferences.toggleContentLanguage(code2)
+      toggleContentLanguage(langPrefs, setLangPrefs, code2)
     },
-    [store],
+    [langPrefs, setLangPrefs],
   )
 
   return (
diff --git a/src/view/com/modals/lang-settings/LanguageToggle.tsx b/src/view/com/modals/lang-settings/LanguageToggle.tsx
index 187b46e8c..86e38a4d2 100644
--- a/src/view/com/modals/lang-settings/LanguageToggle.tsx
+++ b/src/view/com/modals/lang-settings/LanguageToggle.tsx
@@ -3,7 +3,7 @@ import {StyleSheet} from 'react-native'
 import {usePalette} from 'lib/hooks/usePalette'
 import {observer} from 'mobx-react-lite'
 import {ToggleButton} from 'view/com/util/forms/ToggleButton'
-import {useStores} from 'state/index'
+import {useLanguagePrefs, toPostLanguages} from '#/state/preferences/languages'
 
 export const LanguageToggle = observer(function LanguageToggleImpl({
   code2,
@@ -17,17 +17,17 @@ export const LanguageToggle = observer(function LanguageToggleImpl({
   langType: 'contentLanguages' | 'postLanguages'
 }) {
   const pal = usePalette('default')
-  const store = useStores()
+  const langPrefs = useLanguagePrefs()
 
-  const isSelected = store.preferences[langType].includes(code2)
+  const values =
+    langType === 'contentLanguages'
+      ? langPrefs.contentLanguages
+      : toPostLanguages(langPrefs.postLanguage)
+  const isSelected = values.includes(code2)
 
   // enforce a max of 3 selections for post languages
   let isDisabled = false
-  if (
-    langType === 'postLanguages' &&
-    store.preferences[langType].length >= 3 &&
-    !isSelected
-  ) {
+  if (langType === 'postLanguages' && values.length >= 3 && !isSelected) {
     isDisabled = true
   }
 
diff --git a/src/view/com/modals/lang-settings/PostLanguagesSettings.tsx b/src/view/com/modals/lang-settings/PostLanguagesSettings.tsx
index d74d884cc..435fb9e1a 100644
--- a/src/view/com/modals/lang-settings/PostLanguagesSettings.tsx
+++ b/src/view/com/modals/lang-settings/PostLanguagesSettings.tsx
@@ -10,11 +10,19 @@ import {deviceLocales} from 'platform/detection'
 import {LANGUAGES, LANGUAGES_MAP_CODE2} from '../../../../locale/languages'
 import {ConfirmLanguagesButton} from './ConfirmLanguagesButton'
 import {ToggleButton} from 'view/com/util/forms/ToggleButton'
+import {
+  useLanguagePrefs,
+  useSetLanguagePrefs,
+  hasPostLanguage,
+  togglePostLanguage,
+} from '#/state/preferences/languages'
 
 export const snapPoints = ['100%']
 
 export const Component = observer(function PostLanguagesSettingsImpl() {
   const store = useStores()
+  const langPrefs = useLanguagePrefs()
+  const setLangPrefs = useSetLanguagePrefs()
   const pal = usePalette('default')
   const {isMobile} = useWebMediaQueries()
   const onPressDone = React.useCallback(() => {
@@ -30,23 +38,23 @@ export const Component = observer(function PostLanguagesSettingsImpl() {
     // sort so that device & selected languages are on top, then alphabetically
     langs.sort((a, b) => {
       const hasA =
-        store.preferences.hasPostLanguage(a.code2) ||
+        hasPostLanguage(langPrefs.postLanguage, a.code2) ||
         deviceLocales.includes(a.code2)
       const hasB =
-        store.preferences.hasPostLanguage(b.code2) ||
+        hasPostLanguage(langPrefs.postLanguage, b.code2) ||
         deviceLocales.includes(b.code2)
       if (hasA === hasB) return a.name.localeCompare(b.name)
       if (hasA) return -1
       return 1
     })
     return langs
-  }, [store])
+  }, [langPrefs])
 
   const onPress = React.useCallback(
     (code2: string) => {
-      store.preferences.togglePostLanguage(code2)
+      togglePostLanguage(langPrefs, setLangPrefs, code2)
     },
-    [store],
+    [langPrefs, setLangPrefs],
   )
 
   return (
@@ -70,14 +78,11 @@ export const Component = observer(function PostLanguagesSettingsImpl() {
       </Text>
       <ScrollView style={styles.scrollContainer}>
         {languages.map(lang => {
-          const isSelected = store.preferences.hasPostLanguage(lang.code2)
+          const isSelected = hasPostLanguage(langPrefs.postLanguage, lang.code2)
 
           // enforce a max of 3 selections for post languages
           let isDisabled = false
-          if (
-            store.preferences.postLanguage.split(',').length >= 3 &&
-            !isSelected
-          ) {
+          if (langPrefs.postLanguage.split(',').length >= 3 && !isSelected) {
             isDisabled = true
           }
 
diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx
index 9aec638ec..b72121a37 100644
--- a/src/view/com/post-thread/PostThreadItem.tsx
+++ b/src/view/com/post-thread/PostThreadItem.tsx
@@ -38,6 +38,7 @@ import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
 import {MAX_POST_LINES} from 'lib/constants'
 import {logger} from '#/logger'
 import {useMutedThreads, useToggleThreadMute} from '#/state/muted-threads'
+import {useLanguagePrefs} from '#/state/preferences'
 
 export const PostThreadItem = observer(function PostThreadItem({
   item,
@@ -54,6 +55,7 @@ export const PostThreadItem = observer(function PostThreadItem({
   const store = useStores()
   const mutedThreads = useMutedThreads()
   const toggleThreadMute = useToggleThreadMute()
+  const langPrefs = useLanguagePrefs()
   const [deleted, setDeleted] = React.useState(false)
   const [limitLines, setLimitLines] = React.useState(
     countLines(item.richText?.text) >= MAX_POST_LINES,
@@ -85,15 +87,15 @@ export const PostThreadItem = observer(function PostThreadItem({
 
   const translatorUrl = getTranslatorLink(
     record?.text || '',
-    store.preferences.primaryLanguage,
+    langPrefs.primaryLanguage,
   )
   const needsTranslation = useMemo(
     () =>
       Boolean(
-        store.preferences.primaryLanguage &&
-          !isPostInLanguage(item.post, [store.preferences.primaryLanguage]),
+        langPrefs.primaryLanguage &&
+          !isPostInLanguage(item.post, [langPrefs.primaryLanguage]),
       ),
-    [item.post, store.preferences.primaryLanguage],
+    [item.post, langPrefs.primaryLanguage],
   )
 
   const onPressReply = React.useCallback(() => {
diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx
index db490333d..667584f68 100644
--- a/src/view/com/post/Post.tsx
+++ b/src/view/com/post/Post.tsx
@@ -34,6 +34,7 @@ import {MAX_POST_LINES} from 'lib/constants'
 import {countLines} from 'lib/strings/helpers'
 import {logger} from '#/logger'
 import {useMutedThreads, useToggleThreadMute} from '#/state/muted-threads'
+import {useLanguagePrefs} from '#/state/preferences'
 
 export const Post = observer(function PostImpl({
   view,
@@ -109,6 +110,7 @@ const PostLoaded = observer(function PostLoadedImpl({
   const store = useStores()
   const mutedThreads = useMutedThreads()
   const toggleThreadMute = useToggleThreadMute()
+  const langPrefs = useLanguagePrefs()
   const [limitLines, setLimitLines] = React.useState(
     countLines(item.richText?.text) >= MAX_POST_LINES,
   )
@@ -125,7 +127,7 @@ const PostLoaded = observer(function PostLoadedImpl({
 
   const translatorUrl = getTranslatorLink(
     record?.text || '',
-    store.preferences.primaryLanguage,
+    langPrefs.primaryLanguage,
   )
 
   const onPressReply = React.useCallback(() => {
diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx
index 772bb2561..527cbb76f 100644
--- a/src/view/com/posts/FeedItem.tsx
+++ b/src/view/com/posts/FeedItem.tsx
@@ -34,6 +34,7 @@ import {MAX_POST_LINES} from 'lib/constants'
 import {countLines} from 'lib/strings/helpers'
 import {logger} from '#/logger'
 import {useMutedThreads, useToggleThreadMute} from '#/state/muted-threads'
+import {useLanguagePrefs} from '#/state/preferences'
 
 export const FeedItem = observer(function FeedItemImpl({
   item,
@@ -50,6 +51,7 @@ export const FeedItem = observer(function FeedItemImpl({
   showReplyLine?: boolean
 }) {
   const store = useStores()
+  const langPrefs = useLanguagePrefs()
   const pal = usePalette('default')
   const mutedThreads = useMutedThreads()
   const toggleThreadMute = useToggleThreadMute()
@@ -75,7 +77,7 @@ export const FeedItem = observer(function FeedItemImpl({
   }, [record?.reply])
   const translatorUrl = getTranslatorLink(
     record?.text || '',
-    store.preferences.primaryLanguage,
+    langPrefs.primaryLanguage,
   )
 
   const onPressReply = React.useCallback(() => {
diff --git a/src/view/screens/AppPasswords.tsx b/src/view/screens/AppPasswords.tsx
index 74d293ef4..b654055c4 100644
--- a/src/view/screens/AppPasswords.tsx
+++ b/src/view/screens/AppPasswords.tsx
@@ -17,6 +17,7 @@ import {useFocusEffect} from '@react-navigation/native'
 import {ViewHeader} from '../com/util/ViewHeader'
 import {CenteredView} from 'view/com/util/Views'
 import {useSetMinimalShellMode} from '#/state/shell'
+import {useLanguagePrefs} from '#/state/preferences'
 
 type Props = NativeStackScreenProps<CommonNavigatorParams, 'AppPasswords'>
 export const AppPasswords = withAuthRequired(
@@ -161,6 +162,7 @@ function AppPassword({
 }) {
   const pal = usePalette('default')
   const store = useStores()
+  const {contentLanguages} = useLanguagePrefs()
 
   const onDelete = React.useCallback(async () => {
     store.shell.openModal({
@@ -174,8 +176,6 @@ function AppPassword({
     })
   }, [store, name])
 
-  const {contentLanguages} = store.preferences
-
   const primaryLocale =
     contentLanguages.length > 0 ? contentLanguages[0] : 'en-US'
 
diff --git a/src/view/screens/LanguageSettings.tsx b/src/view/screens/LanguageSettings.tsx
index a68a3b5e3..4cf178949 100644
--- a/src/view/screens/LanguageSettings.tsx
+++ b/src/view/screens/LanguageSettings.tsx
@@ -19,6 +19,7 @@ import {useFocusEffect} from '@react-navigation/native'
 import {LANGUAGES} from 'lib/../locale/languages'
 import RNPickerSelect, {PickerSelectProps} from 'react-native-picker-select'
 import {useSetMinimalShellMode} from '#/state/shell'
+import {useLanguagePrefs, useSetLanguagePrefs} from '#/state/preferences'
 
 type Props = NativeStackScreenProps<CommonNavigatorParams, 'LanguageSettings'>
 
@@ -27,6 +28,8 @@ export const LanguageSettingsScreen = observer(function LanguageSettingsImpl(
 ) {
   const pal = usePalette('default')
   const store = useStores()
+  const langPrefs = useLanguagePrefs()
+  const setLangPrefs = useSetLanguagePrefs()
   const {isTabletOrDesktop} = useWebMediaQueries()
   const {screen, track} = useAnalytics()
   const setMinimalShellMode = useSetMinimalShellMode()
@@ -45,21 +48,23 @@ export const LanguageSettingsScreen = observer(function LanguageSettingsImpl(
 
   const onChangePrimaryLanguage = React.useCallback(
     (value: Parameters<PickerSelectProps['onValueChange']>[0]) => {
-      store.preferences.setPrimaryLanguage(value)
+      if (langPrefs.primaryLanguage !== value) {
+        setLangPrefs(v => ({...v, primaryLanguage: value}))
+      }
     },
-    [store.preferences],
+    [langPrefs, setLangPrefs],
   )
 
   const myLanguages = React.useMemo(() => {
     return (
-      store.preferences.contentLanguages
+      langPrefs.contentLanguages
         .map(lang => LANGUAGES.find(l => l.code2 === lang))
         .filter(Boolean)
         // @ts-ignore
         .map(l => l.name)
         .join(', ')
     )
-  }, [store.preferences.contentLanguages])
+  }, [langPrefs.contentLanguages])
 
   return (
     <CenteredView
@@ -82,7 +87,7 @@ export const LanguageSettingsScreen = observer(function LanguageSettingsImpl(
 
           <View style={{position: 'relative'}}>
             <RNPickerSelect
-              value={store.preferences.primaryLanguage}
+              value={langPrefs.primaryLanguage}
               onValueChange={onChangePrimaryLanguage}
               items={LANGUAGES.filter(l => Boolean(l.code2)).map(l => ({
                 label: l.name,