about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/Navigation.tsx14
-rw-r--r--src/lib/routes/types.ts1
-rw-r--r--src/routes.ts1
-rw-r--r--src/state/models/content/post-thread.ts37
-rw-r--r--src/state/models/ui/preferences.ts30
-rw-r--r--src/view/index.ts2
-rw-r--r--src/view/screens/PreferencesHomeFeed.tsx7
-rw-r--r--src/view/screens/PreferencesThreads.tsx155
-rw-r--r--src/view/screens/Settings.tsx22
9 files changed, 256 insertions, 13 deletions
diff --git a/src/Navigation.tsx b/src/Navigation.tsx
index 9bf6ba981..604fca2b9 100644
--- a/src/Navigation.tsx
+++ b/src/Navigation.tsx
@@ -33,6 +33,10 @@ import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle'
 import {router} from './routes'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useStores} from './state'
+import {getRoutingInstrumentation} from 'lib/sentry'
+import {bskyTitle} from 'lib/strings/headings'
+import {JSX} from 'react/jsx-runtime'
+import {timeout} from 'lib/async/timeout'
 
 import {HomeScreen} from './view/screens/Home'
 import {SearchScreen} from './view/screens/Search'
@@ -62,11 +66,8 @@ import {AppPasswords} from 'view/screens/AppPasswords'
 import {ModerationMutedAccounts} from 'view/screens/ModerationMutedAccounts'
 import {ModerationBlockedAccounts} from 'view/screens/ModerationBlockedAccounts'
 import {SavedFeeds} from 'view/screens/SavedFeeds'
-import {getRoutingInstrumentation} from 'lib/sentry'
-import {bskyTitle} from 'lib/strings/headings'
-import {JSX} from 'react/jsx-runtime'
-import {timeout} from 'lib/async/timeout'
 import {PreferencesHomeFeed} from 'view/screens/PreferencesHomeFeed'
+import {PreferencesThreads} from 'view/screens/PreferencesThreads'
 
 const navigationRef = createNavigationContainerRef<AllNavigatorParams>()
 
@@ -219,6 +220,11 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
         component={PreferencesHomeFeed}
         options={{title: title('Home Feed Preferences')}}
       />
+      <Stack.Screen
+        name="PreferencesThreads"
+        component={PreferencesThreads}
+        options={{title: title('Threads Preferences')}}
+      />
     </>
   )
 }
diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts
index cc7a468e9..e2867a707 100644
--- a/src/lib/routes/types.ts
+++ b/src/lib/routes/types.ts
@@ -29,6 +29,7 @@ export type CommonNavigatorParams = {
   AppPasswords: undefined
   SavedFeeds: undefined
   PreferencesHomeFeed: undefined
+  PreferencesThreads: undefined
 }
 
 export type BottomTabNavigatorParams = CommonNavigatorParams & {
diff --git a/src/routes.ts b/src/routes.ts
index 7c356eb1b..35266d85b 100644
--- a/src/routes.ts
+++ b/src/routes.ts
@@ -23,6 +23,7 @@ export const router = new Router({
   Log: '/sys/log',
   AppPasswords: '/settings/app-passwords',
   PreferencesHomeFeed: '/settings/home-feed',
+  PreferencesThreads: '/settings/threads',
   SavedFeeds: '/settings/saved-feeds',
   Support: '/support',
   PrivacyPolicy: '/support/privacy',
diff --git a/src/state/models/content/post-thread.ts b/src/state/models/content/post-thread.ts
index 7e3650948..2d3a3d64a 100644
--- a/src/state/models/content/post-thread.ts
+++ b/src/state/models/content/post-thread.ts
@@ -241,7 +241,7 @@ export class PostThreadModel {
       res.data.thread as AppBskyFeedDefs.ThreadViewPost,
       thread.uri,
     )
-    sortThread(thread)
+    sortThread(thread, this.rootStore.preferences)
     this.thread = thread
   }
 }
@@ -263,11 +263,16 @@ function pruneReplies(post: MaybePost) {
   }
 }
 
+interface SortSettings {
+  threadDefaultSort: string
+  threadFollowedUsersFirst: boolean
+}
+
 type MaybeThreadItem =
   | PostThreadItemModel
   | AppBskyFeedDefs.NotFoundPost
   | AppBskyFeedDefs.BlockedPost
-function sortThread(item: MaybeThreadItem) {
+function sortThread(item: MaybeThreadItem, opts: SortSettings) {
   if ('notFound' in item) {
     return
   }
@@ -296,13 +301,31 @@ function sortThread(item: MaybeThreadItem) {
       if (modScore(a.moderation) !== modScore(b.moderation)) {
         return modScore(a.moderation) - modScore(b.moderation)
       }
-      if (a.post.likeCount === b.post.likeCount) {
-        return b.post.indexedAt.localeCompare(a.post.indexedAt) // newest
-      } else {
-        return (b.post.likeCount || 0) - (a.post.likeCount || 0) // most likes
+      if (opts.threadFollowedUsersFirst) {
+        const af = a.post.author.viewer?.following
+        const bf = b.post.author.viewer?.following
+        if (af && !bf) {
+          return -1
+        } else if (!af && bf) {
+          return 1
+        }
+      }
+      if (opts.threadDefaultSort === 'oldest') {
+        return a.post.indexedAt.localeCompare(b.post.indexedAt)
+      } else if (opts.threadDefaultSort === 'newest') {
+        return b.post.indexedAt.localeCompare(a.post.indexedAt)
+      } else if (opts.threadDefaultSort === 'most-likes') {
+        if (a.post.likeCount === b.post.likeCount) {
+          return b.post.indexedAt.localeCompare(a.post.indexedAt) // newest
+        } else {
+          return (b.post.likeCount || 0) - (a.post.likeCount || 0) // most likes
+        }
+      } else if (opts.threadDefaultSort === 'random') {
+        return 0.5 - Math.random() // this is vaguely criminal but we can get away with it
       }
+      return b.post.indexedAt.localeCompare(a.post.indexedAt)
     })
-    item.replies.forEach(reply => sortThread(reply))
+    item.replies.forEach(reply => sortThread(reply, opts))
   }
 }
 
diff --git a/src/state/models/ui/preferences.ts b/src/state/models/ui/preferences.ts
index 3790b3a92..03f08bc1b 100644
--- a/src/state/models/ui/preferences.ts
+++ b/src/state/models/ui/preferences.ts
@@ -25,6 +25,7 @@ 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']
 
 export class LabelPreferencesModel {
   nsfw: LabelPreference = 'hide'
@@ -55,6 +56,8 @@ export class PreferencesModel {
   homeFeedRepostsEnabled: boolean = true
   homeFeedQuotePostsEnabled: boolean = true
   homeFeedMergeFeedEnabled: boolean = false
+  threadDefaultSort: string = 'oldest'
+  threadFollowedUsersFirst: boolean = true
   requireAltTextEnabled: boolean = false
 
   // used to linearize async modifications to state
@@ -86,6 +89,8 @@ export class PreferencesModel {
       homeFeedRepostsEnabled: this.homeFeedRepostsEnabled,
       homeFeedQuotePostsEnabled: this.homeFeedQuotePostsEnabled,
       homeFeedMergeFeedEnabled: this.homeFeedMergeFeedEnabled,
+      threadDefaultSort: this.threadDefaultSort,
+      threadFollowedUsersFirst: this.threadFollowedUsersFirst,
       requireAltTextEnabled: this.requireAltTextEnabled,
     }
   }
@@ -189,6 +194,21 @@ export class PreferencesModel {
       ) {
         this.homeFeedMergeFeedEnabled = v.homeFeedMergeFeedEnabled
       }
+      // check if thread sort order is set in preferences, then hydrate
+      if (
+        hasProp(v, 'threadDefaultSort') &&
+        typeof v.threadDefaultSort === 'string' &&
+        THREAD_SORT_VALUES.includes(v.threadDefaultSort)
+      ) {
+        this.threadDefaultSort = v.threadDefaultSort
+      }
+      // check if tread followed-users-first is enabled in preferences, then hydrate
+      if (
+        hasProp(v, 'threadFollowedUsersFirst') &&
+        typeof v.threadFollowedUsersFirst === 'boolean'
+      ) {
+        this.threadFollowedUsersFirst = v.threadFollowedUsersFirst
+      }
       // check if requiring alt text is enabled in preferences, then hydrate
       if (
         hasProp(v, 'requireAltTextEnabled') &&
@@ -494,6 +514,16 @@ export class PreferencesModel {
     this.homeFeedMergeFeedEnabled = !this.homeFeedMergeFeedEnabled
   }
 
+  setThreadDefaultSort(v: string) {
+    if (THREAD_SORT_VALUES.includes(v)) {
+      this.threadDefaultSort = v
+    }
+  }
+
+  toggleThreadFollowedUsersFirst() {
+    this.threadFollowedUsersFirst = !this.threadFollowedUsersFirst
+  }
+
   toggleRequireAltTextEnabled() {
     this.requireAltTextEnabled = !this.requireAltTextEnabled
   }
diff --git a/src/view/index.ts b/src/view/index.ts
index 2fdc34e7b..da1b78146 100644
--- a/src/view/index.ts
+++ b/src/view/index.ts
@@ -36,6 +36,7 @@ import {faClone} from '@fortawesome/free-solid-svg-icons/faClone'
 import {faClone as farClone} from '@fortawesome/free-regular-svg-icons/faClone'
 import {faComment} from '@fortawesome/free-regular-svg-icons/faComment'
 import {faCommentSlash} from '@fortawesome/free-solid-svg-icons/faCommentSlash'
+import {faComments} from '@fortawesome/free-regular-svg-icons/faComments'
 import {faCompass} from '@fortawesome/free-regular-svg-icons/faCompass'
 import {faEllipsis} from '@fortawesome/free-solid-svg-icons/faEllipsis'
 import {faEnvelope} from '@fortawesome/free-solid-svg-icons/faEnvelope'
@@ -134,6 +135,7 @@ export function setup() {
     farClone,
     faComment,
     faCommentSlash,
+    faComments,
     faCompass,
     faEllipsis,
     faEnvelope,
diff --git a/src/view/screens/PreferencesHomeFeed.tsx b/src/view/screens/PreferencesHomeFeed.tsx
index 81bdfc95e..34139bec1 100644
--- a/src/view/screens/PreferencesHomeFeed.tsx
+++ b/src/view/screens/PreferencesHomeFeed.tsx
@@ -66,7 +66,10 @@ export const PreferencesHomeFeed = observer(function PreferencesHomeFeedImpl({
       ]}>
       <ViewHeader title="Home Feed Preferences" showOnDesktop />
       <View
-        style={[styles.titleSection, isTabletOrDesktop && {paddingTop: 20}]}>
+        style={[
+          styles.titleSection,
+          isTabletOrDesktop && {paddingTop: 20, paddingBottom: 20},
+        ]}>
         <Text type="xl" style={[pal.textLight, styles.description]}>
           Fine-tune the content you see on your home screen.
         </Text>
@@ -175,7 +178,7 @@ export const PreferencesHomeFeed = observer(function PreferencesHomeFeedImpl({
         style={[
           styles.btnContainer,
           !isTabletOrDesktop && {borderTopWidth: 1, paddingHorizontal: 20},
-          pal.borderDark,
+          pal.border,
         ]}>
         <TouchableOpacity
           testID="confirmBtn"
diff --git a/src/view/screens/PreferencesThreads.tsx b/src/view/screens/PreferencesThreads.tsx
new file mode 100644
index 000000000..731a98d71
--- /dev/null
+++ b/src/view/screens/PreferencesThreads.tsx
@@ -0,0 +1,155 @@
+import React from 'react'
+import {ScrollView, StyleSheet, TouchableOpacity, View} from 'react-native'
+import {observer} from 'mobx-react-lite'
+import {Text} from '../com/util/text/Text'
+import {useStores} from 'state/index'
+import {s, colors} from 'lib/styles'
+import {usePalette} from 'lib/hooks/usePalette'
+import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
+import {ToggleButton} from 'view/com/util/forms/ToggleButton'
+import {RadioGroup} from 'view/com/util/forms/RadioGroup'
+import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types'
+import {ViewHeader} from 'view/com/util/ViewHeader'
+import {CenteredView} from 'view/com/util/Views'
+
+type Props = NativeStackScreenProps<CommonNavigatorParams, 'PreferencesThreads'>
+export const PreferencesThreads = observer(function PreferencesThreadsImpl({
+  navigation,
+}: Props) {
+  const pal = usePalette('default')
+  const store = useStores()
+  const {isTabletOrDesktop} = useWebMediaQueries()
+
+  return (
+    <CenteredView
+      testID="preferencesThreadsScreen"
+      style={[
+        pal.view,
+        pal.border,
+        styles.container,
+        isTabletOrDesktop && styles.desktopContainer,
+      ]}>
+      <ViewHeader title="Thread Preferences" showOnDesktop />
+      <View
+        style={[
+          styles.titleSection,
+          isTabletOrDesktop && {paddingTop: 20, paddingBottom: 20},
+        ]}>
+        <Text type="xl" style={[pal.textLight, styles.description]}>
+          Fine-tune the discussion threads.
+        </Text>
+      </View>
+
+      <ScrollView>
+        <View style={styles.cardsContainer}>
+          <View style={[pal.viewLight, styles.card]}>
+            <Text type="title-sm" style={[pal.text, s.pb5]}>
+              Sort Replies
+            </Text>
+            <Text style={[pal.text, s.pb10]}>
+              Sort replies to the same post by:
+            </Text>
+            <View style={[pal.view, {borderRadius: 8, paddingVertical: 6}]}>
+              <RadioGroup
+                type="default-light"
+                items={[
+                  {key: 'oldest', label: 'Oldest replies first'},
+                  {key: 'newest', label: 'Newest replies first'},
+                  {key: 'most-likes', label: 'Most-liked replies first'},
+                  {key: 'random', label: 'Random (aka "Poster\'s Roulette")'},
+                ]}
+                onSelect={store.preferences.setThreadDefaultSort}
+                initialSelection={store.preferences.threadDefaultSort}
+              />
+            </View>
+          </View>
+
+          <View style={[pal.viewLight, styles.card]}>
+            <Text type="title-sm" style={[pal.text, s.pb5]}>
+              Prioritize Your Follows
+            </Text>
+            <Text style={[pal.text, s.pb10]}>
+              Show replies by people you follow before all other replies.
+            </Text>
+            <ToggleButton
+              type="default-light"
+              label={store.preferences.threadFollowedUsersFirst ? 'Yes' : 'No'}
+              isSelected={store.preferences.threadFollowedUsersFirst}
+              onPress={store.preferences.toggleThreadFollowedUsersFirst}
+            />
+          </View>
+        </View>
+      </ScrollView>
+
+      <View
+        style={[
+          styles.btnContainer,
+          !isTabletOrDesktop && {borderTopWidth: 1, paddingHorizontal: 20},
+          pal.border,
+        ]}>
+        <TouchableOpacity
+          testID="confirmBtn"
+          onPress={() => {
+            navigation.canGoBack()
+              ? navigation.goBack()
+              : navigation.navigate('Settings')
+          }}
+          style={[styles.btn, isTabletOrDesktop && styles.btnDesktop]}
+          accessibilityRole="button"
+          accessibilityLabel="Confirm"
+          accessibilityHint="">
+          <Text style={[s.white, s.bold, s.f18]}>Done</Text>
+        </TouchableOpacity>
+      </View>
+    </CenteredView>
+  )
+})
+
+const styles = StyleSheet.create({
+  container: {
+    flex: 1,
+    paddingBottom: 90,
+  },
+  desktopContainer: {
+    borderLeftWidth: 1,
+    borderRightWidth: 1,
+    paddingBottom: 40,
+  },
+  titleSection: {
+    paddingBottom: 30,
+  },
+  title: {
+    textAlign: 'center',
+    marginBottom: 5,
+  },
+  description: {
+    textAlign: 'center',
+    paddingHorizontal: 32,
+  },
+  cardsContainer: {
+    paddingHorizontal: 20,
+  },
+  card: {
+    padding: 16,
+    borderRadius: 10,
+    marginBottom: 20,
+  },
+  btn: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    justifyContent: 'center',
+    borderRadius: 32,
+    padding: 14,
+    backgroundColor: colors.blue3,
+  },
+  btnDesktop: {
+    marginHorizontal: 'auto',
+    paddingHorizontal: 80,
+  },
+  btnContainer: {
+    paddingTop: 20,
+  },
+  dimmed: {
+    opacity: 0.3,
+  },
+})
diff --git a/src/view/screens/Settings.tsx b/src/view/screens/Settings.tsx
index 761f50d0a..1ff5f58ff 100644
--- a/src/view/screens/Settings.tsx
+++ b/src/view/screens/Settings.tsx
@@ -180,6 +180,10 @@ export const SettingsScreen = withAuthRequired(
       navigation.navigate('PreferencesHomeFeed')
     }, [navigation])
 
+    const openThreadsPreferences = React.useCallback(() => {
+      navigation.navigate('PreferencesThreads')
+    }, [navigation])
+
     const onPressAppPasswords = React.useCallback(() => {
       navigation.navigate('AppPasswords')
     }, [navigation])
@@ -421,6 +425,24 @@ export const SettingsScreen = withAuthRequired(
             </Text>
           </TouchableOpacity>
           <TouchableOpacity
+            testID="preferencesThreadsButton"
+            style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]}
+            onPress={openThreadsPreferences}
+            accessibilityRole="button"
+            accessibilityHint=""
+            accessibilityLabel="Opens the threads preferences">
+            <View style={[styles.iconContainer, pal.btn]}>
+              <FontAwesomeIcon
+                icon={['far', 'comments']}
+                style={pal.text as FontAwesomeIconStyle}
+                size={18}
+              />
+            </View>
+            <Text type="lg" style={pal.text}>
+              Thread Preferences
+            </Text>
+          </TouchableOpacity>
+          <TouchableOpacity
             testID="savedFeedsBtn"
             style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]}
             accessibilityHint="My Saved Feeds"