about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/components/Menu/index.tsx49
-rw-r--r--src/components/Menu/index.web.tsx51
-rw-r--r--src/screens/Settings/ThreadPreferences.tsx2
-rw-r--r--src/view/com/post-thread/PostThread.tsx192
4 files changed, 281 insertions, 13 deletions
diff --git a/src/components/Menu/index.tsx b/src/components/Menu/index.tsx
index 73eb9da52..d79b0ff90 100644
--- a/src/components/Menu/index.tsx
+++ b/src/components/Menu/index.tsx
@@ -190,6 +190,55 @@ export function ItemIcon({icon: Comp}: ItemIconProps) {
   )
 }
 
+export function ItemRadio({selected}: {selected: boolean}) {
+  const t = useTheme()
+  return (
+    <View
+      style={[
+        a.justify_center,
+        a.align_center,
+        a.rounded_full,
+        t.atoms.border_contrast_high,
+        {
+          borderWidth: 1,
+          height: 24,
+          width: 24,
+        },
+      ]}>
+      {selected ? (
+        <View
+          style={[
+            a.absolute,
+            a.rounded_full,
+            {height: 16, width: 16},
+            selected
+              ? {
+                  backgroundColor: t.palette.primary_500,
+                }
+              : {},
+          ]}
+        />
+      ) : null}
+    </View>
+  )
+}
+
+export function LabelText({children}: {children: React.ReactNode}) {
+  const t = useTheme()
+  return (
+    <Text
+      style={[
+        a.font_bold,
+        t.atoms.text_contrast_medium,
+        {
+          marginBottom: -8,
+        },
+      ]}>
+      {children}
+    </Text>
+  )
+}
+
 export function Group({children, style}: GroupProps) {
   const t = useTheme()
   return (
diff --git a/src/components/Menu/index.web.tsx b/src/components/Menu/index.web.tsx
index ab0c9d20a..bc8596218 100644
--- a/src/components/Menu/index.web.tsx
+++ b/src/components/Menu/index.web.tsx
@@ -304,6 +304,57 @@ export function ItemIcon({icon: Comp, position = 'left'}: ItemIconProps) {
   )
 }
 
+export function ItemRadio({selected}: {selected: boolean}) {
+  const t = useTheme()
+  return (
+    <View
+      style={[
+        a.justify_center,
+        a.align_center,
+        a.rounded_full,
+        t.atoms.border_contrast_high,
+        {
+          borderWidth: 1,
+          height: 24,
+          width: 24,
+        },
+      ]}>
+      {selected ? (
+        <View
+          style={[
+            a.absolute,
+            a.rounded_full,
+            {height: 16, width: 16},
+            selected
+              ? {
+                  backgroundColor: t.palette.primary_500,
+                }
+              : {},
+          ]}
+        />
+      ) : null}
+    </View>
+  )
+}
+
+export function LabelText({children}: {children: React.ReactNode}) {
+  const t = useTheme()
+  return (
+    <Text
+      style={[
+        a.font_bold,
+        a.pt_lg,
+        a.pb_sm,
+        t.atoms.text_contrast_low,
+        {
+          paddingHorizontal: 10,
+        },
+      ]}>
+      {children}
+    </Text>
+  )
+}
+
 export function Group({children}: GroupProps) {
   return children
 }
diff --git a/src/screens/Settings/ThreadPreferences.tsx b/src/screens/Settings/ThreadPreferences.tsx
index b1547e495..701d3d9e5 100644
--- a/src/screens/Settings/ThreadPreferences.tsx
+++ b/src/screens/Settings/ThreadPreferences.tsx
@@ -148,7 +148,7 @@ export function ThreadPreferencesScreen({}: Props) {
               }
               style={[a.w_full, a.gap_md]}>
               <Toggle.LabelText style={[a.flex_1]}>
-                <Trans>Show replies in a threaded view</Trans>
+                <Trans>Show replies as threaded</Trans>
               </Toggle.LabelText>
               <Toggle.Platform />
             </Toggle.Item>
diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx
index af58edcbf..a0073b02f 100644
--- a/src/view/com/post-thread/PostThread.tsx
+++ b/src/view/com/post-thread/PostThread.tsx
@@ -1,4 +1,4 @@
-import React, {useRef} from 'react'
+import React, {memo, useRef, useState} from 'react'
 import {StyleSheet, useWindowDimensions, View} from 'react-native'
 import {runOnJS} from 'react-native-reanimated'
 import Animated from 'react-native-reanimated'
@@ -7,6 +7,7 @@ import {AppBskyFeedDefs, AppBskyFeedThreadgate} from '@atproto/api'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
+import {HITSLOP_10} from '#/lib/constants'
 import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
 import {useMinimalShellFabTransform} from '#/lib/hooks/useMinimalShellTransform'
 import {useSetTitle} from '#/lib/hooks/useSetTitle'
@@ -28,14 +29,18 @@ import {
   ThreadPost,
   usePostThreadQuery,
 } from '#/state/queries/post-thread'
+import {useSetThreadViewPreferencesMutation} from '#/state/queries/preferences'
 import {usePreferencesQuery} from '#/state/queries/preferences'
 import {useSession} from '#/state/session'
 import {useComposerControls} from '#/state/shell'
 import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
 import {List, ListMethods} from '#/view/com/util/List'
 import {atoms as a, useTheme} from '#/alf'
+import {Button, ButtonIcon} from '#/components/Button'
+import {SettingsSliderVertical_Stroke2_Corner0_Rounded as SettingsSlider} from '#/components/icons/SettingsSlider'
 import {Header} from '#/components/Layout'
 import {ListFooter, ListMaybePlaceholder} from '#/components/Lists'
+import * as Menu from '#/components/Menu'
 import {Text} from '#/components/Typography'
 import {PostThreadComposePrompt} from './PostThreadComposePrompt'
 import {PostThreadItem} from './PostThreadItem'
@@ -107,12 +112,47 @@ export function PostThread({uri}: {uri: string | undefined}) {
     dataUpdatedAt: fetchedAt,
   } = usePostThreadQuery(uri)
 
+  // The original source of truth for these are the server settings.
+  const serverPrefs = preferences?.threadViewPrefs
+  const serverPrioritizeFollowedUsers =
+    serverPrefs?.prioritizeFollowedUsers ?? true
+  const serverTreeViewEnabled = serverPrefs?.lab_treeViewEnabled ?? false
+  const serverSortReplies = serverPrefs?.sort ?? 'hotness'
+
+  // However, we also need these to work locally for PWI (without persistance).
+  // So we're mirroring them locally.
+  const prioritizeFollowedUsers = serverPrioritizeFollowedUsers
+  const [treeViewEnabled, setTreeViewEnabled] = useState(serverTreeViewEnabled)
+  const [sortReplies, setSortReplies] = useState(serverSortReplies)
+
+  // We'll reset the local state if new server state flows down to us.
+  const [prevServerPrefs, setPrevServerPrefs] = useState(serverPrefs)
+  if (prevServerPrefs !== serverPrefs) {
+    setPrevServerPrefs(serverPrefs)
+    setTreeViewEnabled(serverTreeViewEnabled)
+    setSortReplies(serverSortReplies)
+  }
+
+  // And we'll update the local state when mutating the server prefs.
+  const {mutate: mutateThreadViewPrefs} = useSetThreadViewPreferencesMutation()
+  function updateTreeViewEnabled(newTreeViewEnabled: boolean) {
+    setTreeViewEnabled(newTreeViewEnabled)
+    if (hasSession) {
+      mutateThreadViewPrefs({lab_treeViewEnabled: newTreeViewEnabled})
+    }
+  }
+  function updateSortReplies(newSortReplies: string) {
+    setSortReplies(newSortReplies)
+    if (hasSession) {
+      mutateThreadViewPrefs({sort: newSortReplies})
+    }
+  }
+
   const treeView = React.useMemo(
-    () =>
-      !!preferences?.threadViewPrefs?.lab_treeViewEnabled &&
-      hasBranchingReplies(thread),
-    [preferences?.threadViewPrefs, thread],
+    () => treeViewEnabled && hasBranchingReplies(thread),
+    [treeViewEnabled, thread],
   )
+
   const rootPost = thread?.type === 'post' ? thread.post : undefined
   const rootPostRecord = thread?.type === 'post' ? thread.record : undefined
   const threadgateRecord = threadgate?.record as
@@ -175,13 +215,16 @@ export function PostThread({uri}: {uri: string | undefined}) {
   const [fetchedAtCache] = React.useState(() => new Map<string, number>())
   const [randomCache] = React.useState(() => new Map<string, number>())
   const skeleton = React.useMemo(() => {
-    const threadViewPrefs = preferences?.threadViewPrefs
-    if (!threadViewPrefs || !thread) return null
-
+    if (!thread) return null
     return createThreadSkeleton(
       sortThread(
         thread,
-        threadViewPrefs,
+        {
+          // Prefer local state as the source of truth.
+          sort: sortReplies,
+          lab_treeViewEnabled: treeViewEnabled,
+          prioritizeFollowedUsers,
+        },
         threadModerationCache,
         currentDid,
         justPostedUris,
@@ -198,7 +241,9 @@ export function PostThread({uri}: {uri: string | undefined}) {
     )
   }, [
     thread,
-    preferences?.threadViewPrefs,
+    prioritizeFollowedUsers,
+    sortReplies,
+    treeViewEnabled,
     currentDid,
     treeView,
     threadModerationCache,
@@ -484,14 +529,21 @@ export function PostThread({uri}: {uri: string | undefined}) {
 
   return (
     <>
-      <Header.Outer sticky={true} headerRef={headerRef}>
+      <Header.Outer headerRef={headerRef}>
         <Header.BackButton />
         <Header.Content>
           <Header.TitleText>
             <Trans context="description">Post</Trans>
           </Header.TitleText>
         </Header.Content>
-        <Header.Slot />
+        <Header.Slot>
+          <ThreadMenu
+            sortReplies={sortReplies}
+            treeViewEnabled={treeViewEnabled}
+            setSortReplies={updateSortReplies}
+            setTreeViewEnabled={updateTreeViewEnabled}
+          />
+        </Header.Slot>
       </Header.Outer>
 
       <ScrollProvider onMomentumEnd={onMomentumEnd}>
@@ -537,6 +589,122 @@ export function PostThread({uri}: {uri: string | undefined}) {
   )
 }
 
+let ThreadMenu = ({
+  sortReplies,
+  treeViewEnabled,
+  setSortReplies,
+  setTreeViewEnabled,
+}: {
+  sortReplies: string
+  treeViewEnabled: boolean
+  setSortReplies: (newValue: string) => void
+  setTreeViewEnabled: (newValue: boolean) => void
+}): React.ReactNode => {
+  const {_} = useLingui()
+  return (
+    <Menu.Root>
+      <Menu.Trigger label={_(msg`Thread options`)}>
+        {({props}) => (
+          <Button
+            label={_(msg`Thread options`)}
+            size="small"
+            variant="ghost"
+            color="secondary"
+            shape="round"
+            hitSlop={HITSLOP_10}
+            {...props}>
+            <ButtonIcon icon={SettingsSlider} size="md" />
+          </Button>
+        )}
+      </Menu.Trigger>
+      <Menu.Outer>
+        <Menu.LabelText>
+          <Trans>Show replies as</Trans>
+        </Menu.LabelText>
+        <Menu.Group>
+          <Menu.Item
+            label={_(msg`Linear`)}
+            onPress={() => {
+              setTreeViewEnabled(false)
+            }}>
+            <Menu.ItemText>
+              <Trans>Linear</Trans>
+            </Menu.ItemText>
+            <Menu.ItemRadio selected={!treeViewEnabled} />
+          </Menu.Item>
+          <Menu.Item
+            label={_(msg`Threaded`)}
+            onPress={() => {
+              setTreeViewEnabled(true)
+            }}>
+            <Menu.ItemText>
+              <Trans>Threaded</Trans>
+            </Menu.ItemText>
+            <Menu.ItemRadio selected={treeViewEnabled} />
+          </Menu.Item>
+        </Menu.Group>
+        <Menu.Divider />
+        <Menu.LabelText>
+          <Trans>Reply sorting</Trans>
+        </Menu.LabelText>
+        <Menu.Group>
+          <Menu.Item
+            label={_(msg`Hot replies first`)}
+            onPress={() => {
+              setSortReplies('hotness')
+            }}>
+            <Menu.ItemText>
+              <Trans>Hot replies first</Trans>
+            </Menu.ItemText>
+            <Menu.ItemRadio selected={sortReplies === 'hotness'} />
+          </Menu.Item>
+          <Menu.Item
+            label={_(msg`Oldest replies first`)}
+            onPress={() => {
+              setSortReplies('oldest')
+            }}>
+            <Menu.ItemText>
+              <Trans>Oldest replies first</Trans>
+            </Menu.ItemText>
+            <Menu.ItemRadio selected={sortReplies === 'oldest'} />
+          </Menu.Item>
+          <Menu.Item
+            label={_(msg`Newest replies first`)}
+            onPress={() => {
+              setSortReplies('newest')
+            }}>
+            <Menu.ItemText>
+              <Trans>Newest replies first</Trans>
+            </Menu.ItemText>
+            <Menu.ItemRadio selected={sortReplies === 'newest'} />
+          </Menu.Item>
+          <Menu.Item
+            label={_(msg`Most-liked replies first`)}
+            onPress={() => {
+              setSortReplies('most-likes')
+            }}>
+            <Menu.ItemText>
+              <Trans>Most-liked replies first</Trans>
+            </Menu.ItemText>
+            <Menu.ItemRadio selected={sortReplies === 'most-likes'} />
+          </Menu.Item>
+          <Menu.Item
+            label={_(msg`Random (aka "Poster's Roulette")`)}
+            onPress={() => {
+              setSortReplies('random')
+            }}>
+            <Menu.ItemText>
+              <Trans>Random (aka "Poster's Roulette")</Trans>
+            </Menu.ItemText>
+            <Menu.ItemRadio selected={sortReplies === 'random'} />
+          </Menu.Item>
+        </Menu.Group>
+      </Menu.Outer>
+    </Menu.Root>
+  )
+}
+ThreadMenu = memo(ThreadMenu)
+
 function MobileComposePrompt({onPressReply}: {onPressReply: () => unknown}) {
   const safeAreaInsets = useSafeAreaInsets()
   const fabMinimalShellTransform = useMinimalShellFabTransform()