about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/lib/api/feed-manip.ts59
-rw-r--r--src/lib/api/index.ts12
-rw-r--r--src/lib/functions.ts5
-rw-r--r--src/platform/detection.ts6
-rw-r--r--src/state/models/ui/preferences.ts59
-rw-r--r--src/state/models/ui/shell.ts5
-rw-r--r--src/view/com/composer/Composer.tsx26
-rw-r--r--src/view/com/composer/select-language/SelectLangBtn.tsx56
-rw-r--r--src/view/com/modals/ContentLanguagesSettings.tsx143
-rw-r--r--src/view/com/modals/Modal.tsx6
-rw-r--r--src/view/com/modals/Modal.web.tsx6
-rw-r--r--src/view/com/modals/lang-settings/ConfirmLanguagesButton.tsx52
-rw-r--r--src/view/com/modals/lang-settings/ContentLanguagesSettings.tsx100
-rw-r--r--src/view/com/modals/lang-settings/LanguageToggle.tsx56
-rw-r--r--src/view/com/modals/lang-settings/PostLanguagesSettings.tsx97
-rw-r--r--src/view/com/util/forms/ToggleButton.tsx2
16 files changed, 516 insertions, 174 deletions
diff --git a/src/lib/api/feed-manip.ts b/src/lib/api/feed-manip.ts
index d1f516bc0..da89ca88f 100644
--- a/src/lib/api/feed-manip.ts
+++ b/src/lib/api/feed-manip.ts
@@ -4,6 +4,7 @@ import {
   AppBskyEmbedRecordWithMedia,
   AppBskyEmbedRecord,
 } from '@atproto/api'
+import * as bcp47Match from 'bcp-47-match'
 import lande from 'lande'
 import {hasProp} from 'lib/type-guards'
 import {LANGUAGES_MAP_CODE2} from '../../locale/languages'
@@ -236,44 +237,84 @@ export class FeedTuner {
     }
   }
 
-  static preferredLangOnly(langsCode2: string[]) {
-    const langsCode3 = langsCode2.map(l => LANGUAGES_MAP_CODE2[l]?.code3 || l)
+  /**
+   * This function filters a list of FeedViewPostsSlice items based on whether they contain text in a
+   * preferred language.
+   * @param {string[]} preferredLangsCode2 - An array of prefered language codes in ISO 639-1 or ISO 639-2 format.
+   * @returns A function that takes in a `FeedTuner` and an array of `FeedViewPostsSlice` objects and
+   * returns an array of `FeedViewPostsSlice` objects.
+   */
+  static preferredLangOnly(preferredLangsCode2: string[]) {
+    const langsCode3 = preferredLangsCode2.map(
+      l => LANGUAGES_MAP_CODE2[l]?.code3 || l,
+    )
     return (
       tuner: FeedTuner,
       slices: FeedViewPostsSlice[],
     ): FeedViewPostsSlice[] => {
-      if (!langsCode2.length) {
+      // 1. Early return if no languages have been specified
+      if (!preferredLangsCode2.length || preferredLangsCode2.length === 0) {
         return slices
       }
+
       for (let i = slices.length - 1; i >= 0; i--) {
+        // 2. Set a flag to indicate whether the item has text in a preferred language
         let hasPreferredLang = false
         for (const item of slices[i].items) {
+          // 3. check if the post has a `langs` property and if it is in the list of preferred languages
+          // if it is, set the flag to true
+          // if language is declared, regardless of a match, break out of the loop
           if (
+            hasProp(item.post.record, 'langs') &&
+            Array.isArray(item.post.record.langs)
+          ) {
+            if (
+              bcp47Match.basicFilter(
+                item.post.record.langs,
+                preferredLangsCode2,
+              ).length > 0
+            ) {
+              hasPreferredLang = true
+            }
+            break
+          }
+          // 4. FALLBACK if no language declared :
+          // Get the most likely language of the text in the post from the `lande` library and
+          // check if it is in the list of preferred languages
+          // if it is, set the flag to true and break out of the loop
+          else if (
             hasProp(item.post.record, 'text') &&
             typeof item.post.record.text === 'string'
           ) {
-            // Treat empty text the same as no text.
+            // Treat empty text the same as no text
             if (item.post.record.text.length === 0) {
               hasPreferredLang = true
               break
             }
+            const langsProbabilityMap = lande(item.post.record.text)
+            const mostLikelyLang = langsProbabilityMap[0][0]
+            // const secondMostLikelyLang = langsProbabilityMap[1][0]
+            // const thirdMostLikelyLang = langsProbabilityMap[2][0]
 
-            const res = lande(item.post.record.text)
-
-            if (langsCode3.includes(res[0][0])) {
+            // we check for code3 here because that is what the `lande` library returns
+            if (langsCode3.includes(mostLikelyLang)) {
               hasPreferredLang = true
               break
             }
-          } else {
-            // no text? roll with it
+          }
+          // 5. no text? roll with it (eg: image-only posts, reposts, etc.)
+          else {
             hasPreferredLang = true
             break
           }
         }
+
+        // 6. if item does not fit preferred language, remove it
         if (!hasPreferredLang) {
           slices.splice(i, 1)
         }
       }
+      // 7. return the filtered list of items
       return slices
     }
   }
diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts
index 6235ca343..458ef7baa 100644
--- a/src/lib/api/index.ts
+++ b/src/lib/api/index.ts
@@ -65,6 +65,7 @@ interface PostOpts {
   images?: ImageModel[]
   knownHandles?: Set<string>
   onStateChange?: (state: string) => void
+  langs?: string[]
 }
 
 export async function post(store: RootStoreModel, opts: PostOpts) {
@@ -96,6 +97,7 @@ export async function post(store: RootStoreModel, opts: PostOpts) {
     return true
   })
 
+  // add quote embed if present
   if (opts.quote) {
     embed = {
       $type: 'app.bsky.embed.record',
@@ -106,6 +108,7 @@ export async function post(store: RootStoreModel, opts: PostOpts) {
     } as AppBskyEmbedRecord.Main
   }
 
+  // add image embed if present
   if (opts.images?.length) {
     const images: AppBskyEmbedImages.Image[] = []
     for (const image of opts.images) {
@@ -136,6 +139,7 @@ export async function post(store: RootStoreModel, opts: PostOpts) {
     }
   }
 
+  // add external embed if present
   if (opts.extLink && !opts.images?.length) {
     if (opts.extLink.embed) {
       embed = opts.extLink.embed
@@ -197,6 +201,7 @@ export async function post(store: RootStoreModel, opts: PostOpts) {
     }
   }
 
+  // add replyTo if post is a reply to another post
   if (opts.replyTo) {
     const replyToUrip = new AtUri(opts.replyTo)
     const parentPost = await store.agent.getPost({
@@ -215,6 +220,12 @@ export async function post(store: RootStoreModel, opts: PostOpts) {
     }
   }
 
+  // add top 3 languages from user preferences if langs is provided
+  let langs = opts.langs
+  if (opts.langs) {
+    langs = opts.langs.slice(0, 3)
+  }
+
   try {
     opts.onStateChange?.('Posting...')
     return await store.agent.post({
@@ -222,6 +233,7 @@ export async function post(store: RootStoreModel, opts: PostOpts) {
       facets: rt.facets,
       reply,
       embed,
+      langs,
     })
   } catch (e: any) {
     console.error(`Failed to create post: ${e.toString()}`)
diff --git a/src/lib/functions.ts b/src/lib/functions.ts
index d6fbf5b92..b45c7fa6d 100644
--- a/src/lib/functions.ts
+++ b/src/lib/functions.ts
@@ -4,3 +4,8 @@ export function choose<U, T extends Record<string, U>>(
 ): U {
   return choices[value]
 }
+
+export function dedupArray<T>(arr: T[]): T[] {
+  const s = new Set(arr)
+  return [...s]
+}
diff --git a/src/platform/detection.ts b/src/platform/detection.ts
index da33fdca7..3069c9be2 100644
--- a/src/platform/detection.ts
+++ b/src/platform/detection.ts
@@ -1,4 +1,6 @@
 import {Platform} from 'react-native'
+import {getLocales} from 'expo-localization'
+import {dedupArray} from 'lib/functions'
 
 export const isIOS = Platform.OS === 'ios'
 export const isAndroid = Platform.OS === 'android'
@@ -10,3 +12,7 @@ export const isMobileWeb =
   // @ts-ignore we know window exists -prf
   global.window.matchMedia(isMobileWebMediaQuery)?.matches
 export const isDesktopWeb = isWeb && !isMobileWeb
+
+export const deviceLocales = dedupArray(
+  getLocales?.().map?.(locale => locale.languageCode),
+)
diff --git a/src/state/models/ui/preferences.ts b/src/state/models/ui/preferences.ts
index 6c9dc756e..28c7c5666 100644
--- a/src/state/models/ui/preferences.ts
+++ b/src/state/models/ui/preferences.ts
@@ -1,5 +1,4 @@
 import {makeAutoObservable, runInAction} from 'mobx'
-import {getLocales} from 'expo-localization'
 import AwaitLock from 'await-lock'
 import isEqual from 'lodash.isequal'
 import {isObj, hasProp} from 'lib/type-guards'
@@ -14,9 +13,8 @@ import {
   ALWAYS_WARN_LABEL_GROUP,
 } from 'lib/labeling/const'
 import {DEFAULT_FEEDS} from 'lib/constants'
-import {isIOS} from 'platform/detection'
-
-const deviceLocales = getLocales()
+import {isIOS, deviceLocales} from 'platform/detection'
+import {LANGUAGES} from '../../../locale/languages'
 
 export type LabelPreference = 'show' | 'warn' | 'hide'
 const LABEL_GROUPS = [
@@ -46,8 +44,8 @@ export class LabelPreferencesModel {
 
 export class PreferencesModel {
   adultContentEnabled = !isIOS
-  contentLanguages: string[] =
-    deviceLocales?.map?.(locale => locale.languageCode) || []
+  contentLanguages: string[] = deviceLocales || []
+  postLanguages: string[] = deviceLocales || []
   contentLabels = new LabelPreferencesModel()
   savedFeeds: string[] = []
   pinnedFeeds: string[] = []
@@ -66,6 +64,7 @@ export class PreferencesModel {
   serialize() {
     return {
       contentLanguages: this.contentLanguages,
+      postLanguages: this.postLanguages,
       contentLabels: this.contentLabels,
       savedFeeds: this.savedFeeds,
       pinnedFeeds: this.pinnedFeeds,
@@ -83,19 +82,33 @@ export class PreferencesModel {
    */
   hydrate(v: unknown) {
     if (isObj(v)) {
+      // 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, 'contentLabels') && typeof v.contentLabels === 'object') {
-        Object.assign(this.contentLabels, v.contentLabels)
+      // check if post languages in preferences exist, otherwise default to device languages
+      if (
+        hasProp(v, 'postLanguages') &&
+        Array.isArray(v.postLanguages) &&
+        typeof v.postLanguages.every(item => typeof item === 'string')
+      ) {
+        this.postLanguages = v.postLanguages
       } else {
         // default to the device languages
-        this.contentLanguages = deviceLocales.map(locale => locale.languageCode)
+        this.postLanguages = deviceLocales
+      }
+      // check if content labels in preferences exist, then hydrate
+      if (hasProp(v, 'contentLabels') && typeof v.contentLabels === 'object') {
+        Object.assign(this.contentLabels, v.contentLabels)
       }
+      // check if saved feeds in preferences, then hydrate
       if (
         hasProp(v, 'savedFeeds') &&
         Array.isArray(v.savedFeeds) &&
@@ -103,6 +116,7 @@ export class PreferencesModel {
       ) {
         this.savedFeeds = v.savedFeeds
       }
+      // check if pinned feeds in preferences exist, then hydrate
       if (
         hasProp(v, 'pinnedFeeds') &&
         Array.isArray(v.pinnedFeeds) &&
@@ -110,24 +124,28 @@ export class PreferencesModel {
       ) {
         this.pinnedFeeds = v.pinnedFeeds
       }
+      // check if home feed replies are enabled in preferences, then hydrate
       if (
         hasProp(v, 'homeFeedRepliesEnabled') &&
         typeof v.homeFeedRepliesEnabled === 'boolean'
       ) {
         this.homeFeedRepliesEnabled = v.homeFeedRepliesEnabled
       }
+      // check if home feed replies threshold is enabled in preferences, then hydrate
       if (
         hasProp(v, 'homeFeedRepliesThreshold') &&
         typeof v.homeFeedRepliesThreshold === 'number'
       ) {
         this.homeFeedRepliesThreshold = v.homeFeedRepliesThreshold
       }
+      // check if home feed reposts are enabled in preferences, then hydrate
       if (
         hasProp(v, 'homeFeedRepostsEnabled') &&
         typeof v.homeFeedRepostsEnabled === 'boolean'
       ) {
         this.homeFeedRepostsEnabled = v.homeFeedRepostsEnabled
       }
+      // check if home feed quote posts are enabled in preferences, then hydrate
       if (
         hasProp(v, 'homeFeedQuotePostsEnabled') &&
         typeof v.homeFeedQuotePostsEnabled === 'boolean'
@@ -245,7 +263,8 @@ export class PreferencesModel {
     try {
       runInAction(() => {
         this.contentLabels = new LabelPreferencesModel()
-        this.contentLanguages = deviceLocales.map(locale => locale.languageCode)
+        this.contentLanguages = deviceLocales
+        this.postLanguages = deviceLocales
         this.savedFeeds = []
         this.pinnedFeeds = []
       })
@@ -271,6 +290,26 @@ export class PreferencesModel {
     }
   }
 
+  hasPostLanguage(code2: string) {
+    return this.postLanguages.includes(code2)
+  }
+
+  togglePostLanguage(code2: string) {
+    if (this.hasPostLanguage(code2)) {
+      this.postLanguages = this.postLanguages.filter(lang => lang !== code2)
+    } else {
+      this.postLanguages = this.postLanguages.concat([code2])
+    }
+  }
+
+  getReadablePostLanguages() {
+    const all = this.postLanguages.map(code2 => {
+      const lang = LANGUAGES.find(l => l.code2 === code2)
+      return lang ? lang.name : code2
+    })
+    return all.join(', ')
+  }
+
   async setContentLabelPref(
     key: keyof LabelPreferencesModel,
     value: LabelPreference,
diff --git a/src/state/models/ui/shell.ts b/src/state/models/ui/shell.ts
index c7e72e695..d6ece48aa 100644
--- a/src/state/models/ui/shell.ts
+++ b/src/state/models/ui/shell.ts
@@ -111,6 +111,10 @@ export interface ContentLanguagesSettingsModal {
   name: 'content-languages-settings'
 }
 
+export interface PostLanguagesSettingsModal {
+  name: 'post-languages-settings'
+}
+
 export interface PreferencesHomeFeed {
   name: 'preferences-home-feed'
 }
@@ -125,6 +129,7 @@ export type Modal =
   // Curation
   | ContentFilteringSettingsModal
   | ContentLanguagesSettingsModal
+  | PostLanguagesSettingsModal
   | PreferencesHomeFeed
 
   // Moderation
diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx
index f88cf4bf0..abac291a2 100644
--- a/src/view/com/composer/Composer.tsx
+++ b/src/view/com/composer/Composer.tsx
@@ -38,6 +38,7 @@ import {isDesktopWeb, isAndroid} from 'platform/detection'
 import {GalleryModel} from 'state/models/media/gallery'
 import {Gallery} from './photos/Gallery'
 import {MAX_GRAPHEME_LENGTH} from 'lib/constants'
+import {SelectLangBtn} from './select-language/SelectLangBtn'
 
 type Props = ComposerOpts & {
   onClose: () => void
@@ -71,6 +72,13 @@ export const ComposePost = observer(function ComposePost({
   )
 
   const insets = useSafeAreaInsets()
+  const viewStyles = useMemo(
+    () => ({
+      paddingBottom: isAndroid ? insets.bottom : 0,
+      paddingTop: isAndroid ? insets.top : isDesktopWeb ? 0 : 15,
+    }),
+    [insets],
+  )
 
   // HACK
   // there's a bug with @mattermost/react-native-paste-input where if the input
@@ -87,6 +95,7 @@ export const ComposePost = observer(function ComposePost({
     autocompleteView.setup()
   }, [autocompleteView])
 
+  // listen to escape key on desktop web
   const onEscape = useCallback(
     (e: KeyboardEvent) => {
       if (e.key === 'Escape') {
@@ -109,7 +118,6 @@ export const ComposePost = observer(function ComposePost({
     },
     [store, onClose],
   )
-
   useEffect(() => {
     if (isDesktopWeb) {
       window.addEventListener('keydown', onEscape)
@@ -157,6 +165,7 @@ export const ComposePost = observer(function ComposePost({
           extLink: extLink,
           onStateChange: setProcessingState,
           knownHandles: autocompleteView.knownHandles,
+          langs: store.preferences.postLanguages,
         })
         track('Create Post', {
           imageCount: gallery.size,
@@ -197,15 +206,13 @@ export const ComposePost = observer(function ComposePost({
     ],
   )
 
-  const canPost = graphemeLength <= MAX_GRAPHEME_LENGTH
-
-  const selectTextInputPlaceholder = replyTo ? 'Write your reply' : "What's up?"
+  const canPost = useMemo(
+    () => graphemeLength <= MAX_GRAPHEME_LENGTH,
+    [graphemeLength],
+  )
+  const selectTextInputPlaceholder = replyTo ? 'Write your reply' : `What's up?`
 
-  const canSelectImages = gallery.size < 4
-  const viewStyles = {
-    paddingBottom: isAndroid ? insets.bottom : 0,
-    paddingTop: isAndroid ? insets.top : isDesktopWeb ? 0 : 15,
-  }
+  const canSelectImages = useMemo(() => gallery.size < 4, [gallery.size])
 
   return (
     <KeyboardAvoidingView
@@ -352,6 +359,7 @@ export const ComposePost = observer(function ComposePost({
             </>
           ) : null}
           <View style={s.flex1} />
+          <SelectLangBtn />
           <CharProgress count={graphemeLength} />
         </View>
       </View>
diff --git a/src/view/com/composer/select-language/SelectLangBtn.tsx b/src/view/com/composer/select-language/SelectLangBtn.tsx
new file mode 100644
index 000000000..8c55e1c91
--- /dev/null
+++ b/src/view/com/composer/select-language/SelectLangBtn.tsx
@@ -0,0 +1,56 @@
+import React, {useCallback} from 'react'
+import {TouchableOpacity, StyleSheet, Keyboard} from 'react-native'
+import {observer} from 'mobx-react-lite'
+import {
+  FontAwesomeIcon,
+  FontAwesomeIconStyle,
+} from '@fortawesome/react-native-fontawesome'
+import {Text} from 'view/com/util/text/Text'
+import {usePalette} from 'lib/hooks/usePalette'
+import {useStores} from 'state/index'
+import {isNative} from 'platform/detection'
+
+const HITSLOP = {left: 10, top: 10, right: 10, bottom: 10}
+
+export const SelectLangBtn = observer(function SelectLangBtn() {
+  const pal = usePalette('default')
+  const store = useStores()
+
+  const onPress = useCallback(async () => {
+    if (isNative) {
+      if (Keyboard.isVisible()) {
+        Keyboard.dismiss()
+      }
+    }
+    store.shell.openModal({name: 'post-languages-settings'})
+  }, [store])
+
+  return (
+    <TouchableOpacity
+      testID="selectLangBtn"
+      onPress={onPress}
+      style={styles.button}
+      hitSlop={HITSLOP}
+      accessibilityRole="button"
+      accessibilityLabel="Language selection"
+      accessibilityHint="Opens screen or modal to select language of post">
+      {store.preferences.postLanguages.length > 0 ? (
+        <Text type="lg-bold" style={pal.link}>
+          {store.preferences.postLanguages.join(', ')}
+        </Text>
+      ) : (
+        <FontAwesomeIcon
+          icon="language"
+          style={pal.link as FontAwesomeIconStyle}
+          size={26}
+        />
+      )}
+    </TouchableOpacity>
+  )
+})
+
+const styles = StyleSheet.create({
+  button: {
+    paddingHorizontal: 15,
+  },
+})
diff --git a/src/view/com/modals/ContentLanguagesSettings.tsx b/src/view/com/modals/ContentLanguagesSettings.tsx
deleted file mode 100644
index 700f1cbcb..000000000
--- a/src/view/com/modals/ContentLanguagesSettings.tsx
+++ /dev/null
@@ -1,143 +0,0 @@
-import React from 'react'
-import {StyleSheet, Pressable, View} from 'react-native'
-import LinearGradient from 'react-native-linear-gradient'
-import {observer} from 'mobx-react-lite'
-import {ScrollView} from './util'
-import {useStores} from 'state/index'
-import {ToggleButton} from '../util/forms/ToggleButton'
-import {s, colors, gradients} from 'lib/styles'
-import {Text} from '../util/text/Text'
-import {usePalette} from 'lib/hooks/usePalette'
-import {isDesktopWeb} from 'platform/detection'
-import {LANGUAGES, LANGUAGES_MAP_CODE2} from '../../../locale/languages'
-
-export const snapPoints = ['100%']
-
-export function Component({}: {}) {
-  const store = useStores()
-  const pal = usePalette('default')
-  const onPressDone = React.useCallback(() => {
-    store.shell.closeModal()
-  }, [store])
-
-  const languages = React.useMemo(() => {
-    const langs = LANGUAGES.filter(
-      lang =>
-        !!lang.code2.trim() &&
-        LANGUAGES_MAP_CODE2[lang.code2].code3 === lang.code3,
-    )
-    // sort so that selected languages are on top, then alphabetically
-    langs.sort((a, b) => {
-      const hasA = store.preferences.hasContentLanguage(a.code2)
-      const hasB = store.preferences.hasContentLanguage(b.code2)
-      if (hasA === hasB) return a.name.localeCompare(b.name)
-      if (hasA) return -1
-      return 1
-    })
-    return langs
-  }, [store])
-
-  return (
-    <View testID="contentLanguagesModal" style={[pal.view, styles.container]}>
-      <Text style={[pal.text, styles.title]}>Content Languages</Text>
-      <Text style={[pal.text, styles.description]}>
-        Which languages would you like to see in the your feed? (Leave them all
-        unchecked to see any language.)
-      </Text>
-      <ScrollView style={styles.scrollContainer}>
-        {languages.map(lang => (
-          <LanguageToggle
-            key={lang.code2}
-            code2={lang.code2}
-            name={lang.name}
-          />
-        ))}
-        <View style={styles.bottomSpacer} />
-      </ScrollView>
-      <View style={[styles.btnContainer, pal.borderDark]}>
-        <Pressable
-          testID="sendReportBtn"
-          onPress={onPressDone}
-          accessibilityRole="button"
-          accessibilityLabel="Confirm content language settings"
-          accessibilityHint="">
-          <LinearGradient
-            colors={[gradients.blueLight.start, gradients.blueLight.end]}
-            start={{x: 0, y: 0}}
-            end={{x: 1, y: 1}}
-            style={[styles.btn]}>
-            <Text style={[s.white, s.bold, s.f18]}>Done</Text>
-          </LinearGradient>
-        </Pressable>
-      </View>
-    </View>
-  )
-}
-
-const LanguageToggle = observer(
-  ({code2, name}: {code2: string; name: string}) => {
-    const store = useStores()
-    const pal = usePalette('default')
-
-    const onPress = React.useCallback(() => {
-      store.preferences.toggleContentLanguage(code2)
-    }, [store, code2])
-
-    return (
-      <ToggleButton
-        label={name}
-        isSelected={store.preferences.contentLanguages.includes(code2)}
-        onPress={onPress}
-        style={[pal.border, styles.languageToggle]}
-      />
-    )
-  },
-)
-
-const styles = StyleSheet.create({
-  container: {
-    flex: 1,
-    paddingTop: 20,
-  },
-  title: {
-    textAlign: 'center',
-    fontWeight: 'bold',
-    fontSize: 24,
-    marginBottom: 12,
-  },
-  description: {
-    textAlign: 'center',
-    paddingHorizontal: 16,
-    marginBottom: 10,
-  },
-  scrollContainer: {
-    flex: 1,
-    paddingHorizontal: 10,
-  },
-  bottomSpacer: {
-    height: isDesktopWeb ? 0 : 60,
-  },
-  btnContainer: {
-    paddingTop: 10,
-    paddingHorizontal: 10,
-    paddingBottom: isDesktopWeb ? 0 : 40,
-    borderTopWidth: isDesktopWeb ? 0 : 1,
-  },
-
-  languageToggle: {
-    borderTopWidth: 1,
-    borderRadius: 0,
-    paddingHorizontal: 0,
-    paddingVertical: 12,
-  },
-
-  btn: {
-    flexDirection: 'row',
-    alignItems: 'center',
-    justifyContent: 'center',
-    width: '100%',
-    borderRadius: 32,
-    padding: 14,
-    backgroundColor: colors.gray1,
-  },
-})
diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx
index 5989d9ff9..b276dabc0 100644
--- a/src/view/com/modals/Modal.tsx
+++ b/src/view/com/modals/Modal.tsx
@@ -23,7 +23,8 @@ import * as WaitlistModal from './Waitlist'
 import * as InviteCodesModal from './InviteCodes'
 import * as AddAppPassword from './AddAppPasswords'
 import * as ContentFilteringSettingsModal from './ContentFilteringSettings'
-import * as ContentLanguagesSettingsModal from './ContentLanguagesSettings'
+import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings'
+import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings'
 import * as PreferencesHomeFeed from './PreferencesHomeFeed'
 
 const DEFAULT_SNAPPOINTS = ['90%']
@@ -106,6 +107,9 @@ export const ModalsContainer = observer(function ModalsContainer() {
   } else if (activeModal?.name === 'content-languages-settings') {
     snapPoints = ContentLanguagesSettingsModal.snapPoints
     element = <ContentLanguagesSettingsModal.Component />
+  } else if (activeModal?.name === 'post-languages-settings') {
+    snapPoints = PostLanguagesSettingsModal.snapPoints
+    element = <PostLanguagesSettingsModal.Component />
   } else if (activeModal?.name === 'preferences-home-feed') {
     snapPoints = PreferencesHomeFeed.snapPoints
     element = <PreferencesHomeFeed.Component />
diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx
index 3895d47ac..77842d3e1 100644
--- a/src/view/com/modals/Modal.web.tsx
+++ b/src/view/com/modals/Modal.web.tsx
@@ -23,7 +23,9 @@ import * as WaitlistModal from './Waitlist'
 import * as InviteCodesModal from './InviteCodes'
 import * as AddAppPassword from './AddAppPasswords'
 import * as ContentFilteringSettingsModal from './ContentFilteringSettings'
-import * as ContentLanguagesSettingsModal from './ContentLanguagesSettings'
+import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings'
+import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings'
+
 import * as PreferencesHomeFeed from './PreferencesHomeFeed'
 
 export const ModalsContainer = observer(function ModalsContainer() {
@@ -94,6 +96,8 @@ function Modal({modal}: {modal: ModalIface}) {
     element = <ContentFilteringSettingsModal.Component />
   } else if (modal.name === 'content-languages-settings') {
     element = <ContentLanguagesSettingsModal.Component />
+  } else if (modal.name === 'post-languages-settings') {
+    element = <PostLanguagesSettingsModal.Component />
   } else if (modal.name === 'alt-text-image') {
     element = <AltTextImageModal.Component {...modal} />
   } else if (modal.name === 'edit-image') {
diff --git a/src/view/com/modals/lang-settings/ConfirmLanguagesButton.tsx b/src/view/com/modals/lang-settings/ConfirmLanguagesButton.tsx
new file mode 100644
index 000000000..e1ecce589
--- /dev/null
+++ b/src/view/com/modals/lang-settings/ConfirmLanguagesButton.tsx
@@ -0,0 +1,52 @@
+import React from 'react'
+import {StyleSheet, Text, View, Pressable} from 'react-native'
+import LinearGradient from 'react-native-linear-gradient'
+import {s, colors, gradients} from 'lib/styles'
+import {isDesktopWeb} from 'platform/detection'
+import {usePalette} from 'lib/hooks/usePalette'
+
+export const ConfirmLanguagesButton = ({
+  onPress,
+  extraText,
+}: {
+  onPress: () => void
+  extraText?: string
+}) => {
+  const pal = usePalette('default')
+  return (
+    <View style={[styles.btnContainer, pal.borderDark]}>
+      <Pressable
+        testID="confirmContentLanguagesBtn"
+        onPress={onPress}
+        accessibilityRole="button"
+        accessibilityLabel="Confirm content language settings"
+        accessibilityHint="">
+        <LinearGradient
+          colors={[gradients.blueLight.start, gradients.blueLight.end]}
+          start={{x: 0, y: 0}}
+          end={{x: 1, y: 1}}
+          style={[styles.btn]}>
+          <Text style={[s.white, s.bold, s.f18]}>Done{extraText}</Text>
+        </LinearGradient>
+      </Pressable>
+    </View>
+  )
+}
+
+const styles = StyleSheet.create({
+  btnContainer: {
+    paddingTop: 10,
+    paddingHorizontal: 10,
+    paddingBottom: isDesktopWeb ? 0 : 40,
+    borderTopWidth: isDesktopWeb ? 0 : 1,
+  },
+  btn: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    justifyContent: 'center',
+    width: '100%',
+    borderRadius: 32,
+    padding: 14,
+    backgroundColor: colors.gray1,
+  },
+})
diff --git a/src/view/com/modals/lang-settings/ContentLanguagesSettings.tsx b/src/view/com/modals/lang-settings/ContentLanguagesSettings.tsx
new file mode 100644
index 000000000..4f7bbc9c7
--- /dev/null
+++ b/src/view/com/modals/lang-settings/ContentLanguagesSettings.tsx
@@ -0,0 +1,100 @@
+import React from 'react'
+import {StyleSheet, View} from 'react-native'
+import {ScrollView} from '../util'
+import {useStores} from 'state/index'
+import {Text} from '../../util/text/Text'
+import {usePalette} from 'lib/hooks/usePalette'
+import {isDesktopWeb, deviceLocales} from 'platform/detection'
+import {LANGUAGES, LANGUAGES_MAP_CODE2} from '../../../../locale/languages'
+import {LanguageToggle} from './LanguageToggle'
+import {ConfirmLanguagesButton} from './ConfirmLanguagesButton'
+
+export const snapPoints = ['100%']
+
+export function Component({}: {}) {
+  const store = useStores()
+  const pal = usePalette('default')
+  const onPressDone = React.useCallback(() => {
+    store.shell.closeModal()
+  }, [store])
+
+  const languages = React.useMemo(() => {
+    const langs = LANGUAGES.filter(
+      lang =>
+        !!lang.code2.trim() &&
+        LANGUAGES_MAP_CODE2[lang.code2].code3 === lang.code3,
+    )
+    // sort so that device & selected languages are on top, then alphabetically
+    langs.sort((a, b) => {
+      const hasA =
+        store.preferences.hasContentLanguage(a.code2) ||
+        deviceLocales.includes(a.code2)
+      const hasB =
+        store.preferences.hasContentLanguage(b.code2) ||
+        deviceLocales.includes(b.code2)
+      if (hasA === hasB) return a.name.localeCompare(b.name)
+      if (hasA) return -1
+      return 1
+    })
+    return langs
+  }, [store])
+
+  const onPress = React.useCallback(
+    (code2: string) => {
+      store.preferences.toggleContentLanguage(code2)
+    },
+    [store],
+  )
+
+  return (
+    <View testID="contentLanguagesModal" style={[pal.view, styles.container]}>
+      <Text style={[pal.text, styles.title]}>Content Languages</Text>
+      <Text style={[pal.text, styles.description]}>
+        Which languages would you like to see in your algorithmic feeds?
+      </Text>
+      <Text style={[pal.textLight, styles.description]}>
+        Leave them all unchecked to see any language.
+      </Text>
+      <ScrollView style={styles.scrollContainer}>
+        {languages.map(lang => (
+          <LanguageToggle
+            key={lang.code2}
+            code2={lang.code2}
+            langType="contentLanguages"
+            name={lang.name}
+            onPress={() => {
+              onPress(lang.code2)
+            }}
+          />
+        ))}
+        <View style={styles.bottomSpacer} />
+      </ScrollView>
+      <ConfirmLanguagesButton onPress={onPressDone} />
+    </View>
+  )
+}
+
+const styles = StyleSheet.create({
+  container: {
+    flex: 1,
+    paddingTop: 20,
+  },
+  title: {
+    textAlign: 'center',
+    fontWeight: 'bold',
+    fontSize: 24,
+    marginBottom: 12,
+  },
+  description: {
+    textAlign: 'center',
+    paddingHorizontal: 16,
+    marginBottom: 10,
+  },
+  scrollContainer: {
+    flex: 1,
+    paddingHorizontal: 10,
+  },
+  bottomSpacer: {
+    height: isDesktopWeb ? 0 : 60,
+  },
+})
diff --git a/src/view/com/modals/lang-settings/LanguageToggle.tsx b/src/view/com/modals/lang-settings/LanguageToggle.tsx
new file mode 100644
index 000000000..df1b405ca
--- /dev/null
+++ b/src/view/com/modals/lang-settings/LanguageToggle.tsx
@@ -0,0 +1,56 @@
+import React from 'react'
+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'
+
+export const LanguageToggle = observer(
+  ({
+    code2,
+    name,
+    onPress,
+    langType,
+  }: {
+    code2: string
+    name: string
+    onPress: () => void
+    langType: 'contentLanguages' | 'postLanguages'
+  }) => {
+    const pal = usePalette('default')
+    const store = useStores()
+
+    const isSelected = store.preferences[langType].includes(code2)
+
+    // enforce a max of 3 selections for post languages
+    let isDisabled = false
+    if (
+      langType === 'postLanguages' &&
+      store.preferences[langType].length >= 3 &&
+      !isSelected
+    ) {
+      isDisabled = true
+    }
+
+    return (
+      <ToggleButton
+        label={name}
+        isSelected={isSelected}
+        onPress={isDisabled ? undefined : onPress}
+        style={[pal.border, styles.languageToggle, isDisabled && styles.dimmed]}
+      />
+    )
+  },
+)
+
+const styles = StyleSheet.create({
+  languageToggle: {
+    borderTopWidth: 1,
+    borderRadius: 0,
+    paddingHorizontal: 6,
+    paddingVertical: 12,
+  },
+  dimmed: {
+    opacity: 0.5,
+  },
+})
diff --git a/src/view/com/modals/lang-settings/PostLanguagesSettings.tsx b/src/view/com/modals/lang-settings/PostLanguagesSettings.tsx
new file mode 100644
index 000000000..3dc35e9ed
--- /dev/null
+++ b/src/view/com/modals/lang-settings/PostLanguagesSettings.tsx
@@ -0,0 +1,97 @@
+import React from 'react'
+import {StyleSheet, View} from 'react-native'
+import {ScrollView} from '../util'
+import {useStores} from 'state/index'
+import {Text} from '../../util/text/Text'
+import {usePalette} from 'lib/hooks/usePalette'
+import {isDesktopWeb, deviceLocales} from 'platform/detection'
+import {LANGUAGES, LANGUAGES_MAP_CODE2} from '../../../../locale/languages'
+import {LanguageToggle} from './LanguageToggle'
+import {ConfirmLanguagesButton} from './ConfirmLanguagesButton'
+
+export const snapPoints = ['100%']
+
+export function Component({}: {}) {
+  const store = useStores()
+  const pal = usePalette('default')
+  const onPressDone = React.useCallback(() => {
+    store.shell.closeModal()
+  }, [store])
+
+  const languages = React.useMemo(() => {
+    const langs = LANGUAGES.filter(
+      lang =>
+        !!lang.code2.trim() &&
+        LANGUAGES_MAP_CODE2[lang.code2].code3 === lang.code3,
+    )
+    // sort so that device & selected languages are on top, then alphabetically
+    langs.sort((a, b) => {
+      const hasA =
+        store.preferences.hasPostLanguage(a.code2) ||
+        deviceLocales.includes(a.code2)
+      const hasB =
+        store.preferences.hasPostLanguage(b.code2) ||
+        deviceLocales.includes(b.code2)
+      if (hasA === hasB) return a.name.localeCompare(b.name)
+      if (hasA) return -1
+      return 1
+    })
+    return langs
+  }, [store])
+
+  const onPress = React.useCallback(
+    (code2: string) => {
+      store.preferences.togglePostLanguage(code2)
+    },
+    [store],
+  )
+
+  return (
+    <View testID="postLanguagesModal" style={[pal.view, styles.container]}>
+      <Text style={[pal.text, styles.title]}>Post Languages</Text>
+      <Text style={[pal.text, styles.description]}>
+        Which languages are used in this post?
+      </Text>
+      <ScrollView style={styles.scrollContainer}>
+        {languages.map(lang => (
+          <LanguageToggle
+            key={lang.code2}
+            code2={lang.code2}
+            langType="postLanguages"
+            name={lang.name}
+            onPress={() => {
+              onPress(lang.code2)
+            }}
+          />
+        ))}
+        <View style={styles.bottomSpacer} />
+      </ScrollView>
+      <ConfirmLanguagesButton onPress={onPressDone} />
+    </View>
+  )
+}
+
+const styles = StyleSheet.create({
+  container: {
+    flex: 1,
+    paddingTop: 20,
+  },
+  title: {
+    textAlign: 'center',
+    fontWeight: 'bold',
+    fontSize: 24,
+    marginBottom: 12,
+  },
+  description: {
+    textAlign: 'center',
+    paddingHorizontal: 16,
+    marginBottom: 10,
+  },
+  scrollContainer: {
+    flex: 1,
+    paddingHorizontal: 10,
+  },
+  bottomSpacer: {
+    height: isDesktopWeb ? 0 : 60,
+  },
+})
diff --git a/src/view/com/util/forms/ToggleButton.tsx b/src/view/com/util/forms/ToggleButton.tsx
index 804d414b3..47620d0a6 100644
--- a/src/view/com/util/forms/ToggleButton.tsx
+++ b/src/view/com/util/forms/ToggleButton.tsx
@@ -17,7 +17,7 @@ export function ToggleButton({
   label: string
   isSelected: boolean
   style?: StyleProp<ViewStyle>
-  onPress: () => void
+  onPress?: () => void
 }) {
   const theme = useTheme()
   const circleStyle = choose<TextStyle, Record<ButtonType, TextStyle>>(type, {