Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add thread sort settings (#1475)

* Add thread sorting preferences

* UI tweaks

* Tweak settings

* Tune the copy

authored by

Paul Frazee and committed by
GitHub
da8499c8 9c4374f6

+256 -13
+10 -4
src/Navigation.tsx
··· 33 33 import {router} from './routes' 34 34 import {usePalette} from 'lib/hooks/usePalette' 35 35 import {useStores} from './state' 36 + import {getRoutingInstrumentation} from 'lib/sentry' 37 + import {bskyTitle} from 'lib/strings/headings' 38 + import {JSX} from 'react/jsx-runtime' 39 + import {timeout} from 'lib/async/timeout' 36 40 37 41 import {HomeScreen} from './view/screens/Home' 38 42 import {SearchScreen} from './view/screens/Search' ··· 62 66 import {ModerationMutedAccounts} from 'view/screens/ModerationMutedAccounts' 63 67 import {ModerationBlockedAccounts} from 'view/screens/ModerationBlockedAccounts' 64 68 import {SavedFeeds} from 'view/screens/SavedFeeds' 65 - import {getRoutingInstrumentation} from 'lib/sentry' 66 - import {bskyTitle} from 'lib/strings/headings' 67 - import {JSX} from 'react/jsx-runtime' 68 - import {timeout} from 'lib/async/timeout' 69 69 import {PreferencesHomeFeed} from 'view/screens/PreferencesHomeFeed' 70 + import {PreferencesThreads} from 'view/screens/PreferencesThreads' 70 71 71 72 const navigationRef = createNavigationContainerRef<AllNavigatorParams>() 72 73 ··· 218 219 name="PreferencesHomeFeed" 219 220 component={PreferencesHomeFeed} 220 221 options={{title: title('Home Feed Preferences')}} 222 + /> 223 + <Stack.Screen 224 + name="PreferencesThreads" 225 + component={PreferencesThreads} 226 + options={{title: title('Threads Preferences')}} 221 227 /> 222 228 </> 223 229 )
+1
src/lib/routes/types.ts
··· 29 29 AppPasswords: undefined 30 30 SavedFeeds: undefined 31 31 PreferencesHomeFeed: undefined 32 + PreferencesThreads: undefined 32 33 } 33 34 34 35 export type BottomTabNavigatorParams = CommonNavigatorParams & {
+1
src/routes.ts
··· 23 23 Log: '/sys/log', 24 24 AppPasswords: '/settings/app-passwords', 25 25 PreferencesHomeFeed: '/settings/home-feed', 26 + PreferencesThreads: '/settings/threads', 26 27 SavedFeeds: '/settings/saved-feeds', 27 28 Support: '/support', 28 29 PrivacyPolicy: '/support/privacy',
+30 -7
src/state/models/content/post-thread.ts
··· 241 241 res.data.thread as AppBskyFeedDefs.ThreadViewPost, 242 242 thread.uri, 243 243 ) 244 - sortThread(thread) 244 + sortThread(thread, this.rootStore.preferences) 245 245 this.thread = thread 246 246 } 247 247 } ··· 263 263 } 264 264 } 265 265 266 + interface SortSettings { 267 + threadDefaultSort: string 268 + threadFollowedUsersFirst: boolean 269 + } 270 + 266 271 type MaybeThreadItem = 267 272 | PostThreadItemModel 268 273 | AppBskyFeedDefs.NotFoundPost 269 274 | AppBskyFeedDefs.BlockedPost 270 - function sortThread(item: MaybeThreadItem) { 275 + function sortThread(item: MaybeThreadItem, opts: SortSettings) { 271 276 if ('notFound' in item) { 272 277 return 273 278 } ··· 296 301 if (modScore(a.moderation) !== modScore(b.moderation)) { 297 302 return modScore(a.moderation) - modScore(b.moderation) 298 303 } 299 - if (a.post.likeCount === b.post.likeCount) { 300 - return b.post.indexedAt.localeCompare(a.post.indexedAt) // newest 301 - } else { 302 - return (b.post.likeCount || 0) - (a.post.likeCount || 0) // most likes 304 + if (opts.threadFollowedUsersFirst) { 305 + const af = a.post.author.viewer?.following 306 + const bf = b.post.author.viewer?.following 307 + if (af && !bf) { 308 + return -1 309 + } else if (!af && bf) { 310 + return 1 311 + } 312 + } 313 + if (opts.threadDefaultSort === 'oldest') { 314 + return a.post.indexedAt.localeCompare(b.post.indexedAt) 315 + } else if (opts.threadDefaultSort === 'newest') { 316 + return b.post.indexedAt.localeCompare(a.post.indexedAt) 317 + } else if (opts.threadDefaultSort === 'most-likes') { 318 + if (a.post.likeCount === b.post.likeCount) { 319 + return b.post.indexedAt.localeCompare(a.post.indexedAt) // newest 320 + } else { 321 + return (b.post.likeCount || 0) - (a.post.likeCount || 0) // most likes 322 + } 323 + } else if (opts.threadDefaultSort === 'random') { 324 + return 0.5 - Math.random() // this is vaguely criminal but we can get away with it 303 325 } 326 + return b.post.indexedAt.localeCompare(a.post.indexedAt) 304 327 }) 305 - item.replies.forEach(reply => sortThread(reply)) 328 + item.replies.forEach(reply => sortThread(reply, opts)) 306 329 } 307 330 } 308 331
+30
src/state/models/ui/preferences.ts
··· 25 25 const DEFAULT_LANG_CODES = (deviceLocales || []) 26 26 .concat(['en', 'ja', 'pt', 'de']) 27 27 .slice(0, 6) 28 + const THREAD_SORT_VALUES = ['oldest', 'newest', 'most-likes', 'random'] 28 29 29 30 export class LabelPreferencesModel { 30 31 nsfw: LabelPreference = 'hide' ··· 55 56 homeFeedRepostsEnabled: boolean = true 56 57 homeFeedQuotePostsEnabled: boolean = true 57 58 homeFeedMergeFeedEnabled: boolean = false 59 + threadDefaultSort: string = 'oldest' 60 + threadFollowedUsersFirst: boolean = true 58 61 requireAltTextEnabled: boolean = false 59 62 60 63 // used to linearize async modifications to state ··· 86 89 homeFeedRepostsEnabled: this.homeFeedRepostsEnabled, 87 90 homeFeedQuotePostsEnabled: this.homeFeedQuotePostsEnabled, 88 91 homeFeedMergeFeedEnabled: this.homeFeedMergeFeedEnabled, 92 + threadDefaultSort: this.threadDefaultSort, 93 + threadFollowedUsersFirst: this.threadFollowedUsersFirst, 89 94 requireAltTextEnabled: this.requireAltTextEnabled, 90 95 } 91 96 } ··· 188 193 typeof v.homeFeedMergeFeedEnabled === 'boolean' 189 194 ) { 190 195 this.homeFeedMergeFeedEnabled = v.homeFeedMergeFeedEnabled 196 + } 197 + // check if thread sort order is set in preferences, then hydrate 198 + if ( 199 + hasProp(v, 'threadDefaultSort') && 200 + typeof v.threadDefaultSort === 'string' && 201 + THREAD_SORT_VALUES.includes(v.threadDefaultSort) 202 + ) { 203 + this.threadDefaultSort = v.threadDefaultSort 204 + } 205 + // check if tread followed-users-first is enabled in preferences, then hydrate 206 + if ( 207 + hasProp(v, 'threadFollowedUsersFirst') && 208 + typeof v.threadFollowedUsersFirst === 'boolean' 209 + ) { 210 + this.threadFollowedUsersFirst = v.threadFollowedUsersFirst 191 211 } 192 212 // check if requiring alt text is enabled in preferences, then hydrate 193 213 if ( ··· 492 512 493 513 toggleHomeFeedMergeFeedEnabled() { 494 514 this.homeFeedMergeFeedEnabled = !this.homeFeedMergeFeedEnabled 515 + } 516 + 517 + setThreadDefaultSort(v: string) { 518 + if (THREAD_SORT_VALUES.includes(v)) { 519 + this.threadDefaultSort = v 520 + } 521 + } 522 + 523 + toggleThreadFollowedUsersFirst() { 524 + this.threadFollowedUsersFirst = !this.threadFollowedUsersFirst 495 525 } 496 526 497 527 toggleRequireAltTextEnabled() {
+2
src/view/index.ts
··· 36 36 import {faClone as farClone} from '@fortawesome/free-regular-svg-icons/faClone' 37 37 import {faComment} from '@fortawesome/free-regular-svg-icons/faComment' 38 38 import {faCommentSlash} from '@fortawesome/free-solid-svg-icons/faCommentSlash' 39 + import {faComments} from '@fortawesome/free-regular-svg-icons/faComments' 39 40 import {faCompass} from '@fortawesome/free-regular-svg-icons/faCompass' 40 41 import {faEllipsis} from '@fortawesome/free-solid-svg-icons/faEllipsis' 41 42 import {faEnvelope} from '@fortawesome/free-solid-svg-icons/faEnvelope' ··· 134 135 farClone, 135 136 faComment, 136 137 faCommentSlash, 138 + faComments, 137 139 faCompass, 138 140 faEllipsis, 139 141 faEnvelope,
+5 -2
src/view/screens/PreferencesHomeFeed.tsx
··· 66 66 ]}> 67 67 <ViewHeader title="Home Feed Preferences" showOnDesktop /> 68 68 <View 69 - style={[styles.titleSection, isTabletOrDesktop && {paddingTop: 20}]}> 69 + style={[ 70 + styles.titleSection, 71 + isTabletOrDesktop && {paddingTop: 20, paddingBottom: 20}, 72 + ]}> 70 73 <Text type="xl" style={[pal.textLight, styles.description]}> 71 74 Fine-tune the content you see on your home screen. 72 75 </Text> ··· 175 178 style={[ 176 179 styles.btnContainer, 177 180 !isTabletOrDesktop && {borderTopWidth: 1, paddingHorizontal: 20}, 178 - pal.borderDark, 181 + pal.border, 179 182 ]}> 180 183 <TouchableOpacity 181 184 testID="confirmBtn"
+155
src/view/screens/PreferencesThreads.tsx
··· 1 + import React from 'react' 2 + import {ScrollView, StyleSheet, TouchableOpacity, View} from 'react-native' 3 + import {observer} from 'mobx-react-lite' 4 + import {Text} from '../com/util/text/Text' 5 + import {useStores} from 'state/index' 6 + import {s, colors} from 'lib/styles' 7 + import {usePalette} from 'lib/hooks/usePalette' 8 + import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' 9 + import {ToggleButton} from 'view/com/util/forms/ToggleButton' 10 + import {RadioGroup} from 'view/com/util/forms/RadioGroup' 11 + import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types' 12 + import {ViewHeader} from 'view/com/util/ViewHeader' 13 + import {CenteredView} from 'view/com/util/Views' 14 + 15 + type Props = NativeStackScreenProps<CommonNavigatorParams, 'PreferencesThreads'> 16 + export const PreferencesThreads = observer(function PreferencesThreadsImpl({ 17 + navigation, 18 + }: Props) { 19 + const pal = usePalette('default') 20 + const store = useStores() 21 + const {isTabletOrDesktop} = useWebMediaQueries() 22 + 23 + return ( 24 + <CenteredView 25 + testID="preferencesThreadsScreen" 26 + style={[ 27 + pal.view, 28 + pal.border, 29 + styles.container, 30 + isTabletOrDesktop && styles.desktopContainer, 31 + ]}> 32 + <ViewHeader title="Thread Preferences" showOnDesktop /> 33 + <View 34 + style={[ 35 + styles.titleSection, 36 + isTabletOrDesktop && {paddingTop: 20, paddingBottom: 20}, 37 + ]}> 38 + <Text type="xl" style={[pal.textLight, styles.description]}> 39 + Fine-tune the discussion threads. 40 + </Text> 41 + </View> 42 + 43 + <ScrollView> 44 + <View style={styles.cardsContainer}> 45 + <View style={[pal.viewLight, styles.card]}> 46 + <Text type="title-sm" style={[pal.text, s.pb5]}> 47 + Sort Replies 48 + </Text> 49 + <Text style={[pal.text, s.pb10]}> 50 + Sort replies to the same post by: 51 + </Text> 52 + <View style={[pal.view, {borderRadius: 8, paddingVertical: 6}]}> 53 + <RadioGroup 54 + type="default-light" 55 + items={[ 56 + {key: 'oldest', label: 'Oldest replies first'}, 57 + {key: 'newest', label: 'Newest replies first'}, 58 + {key: 'most-likes', label: 'Most-liked replies first'}, 59 + {key: 'random', label: 'Random (aka "Poster\'s Roulette")'}, 60 + ]} 61 + onSelect={store.preferences.setThreadDefaultSort} 62 + initialSelection={store.preferences.threadDefaultSort} 63 + /> 64 + </View> 65 + </View> 66 + 67 + <View style={[pal.viewLight, styles.card]}> 68 + <Text type="title-sm" style={[pal.text, s.pb5]}> 69 + Prioritize Your Follows 70 + </Text> 71 + <Text style={[pal.text, s.pb10]}> 72 + Show replies by people you follow before all other replies. 73 + </Text> 74 + <ToggleButton 75 + type="default-light" 76 + label={store.preferences.threadFollowedUsersFirst ? 'Yes' : 'No'} 77 + isSelected={store.preferences.threadFollowedUsersFirst} 78 + onPress={store.preferences.toggleThreadFollowedUsersFirst} 79 + /> 80 + </View> 81 + </View> 82 + </ScrollView> 83 + 84 + <View 85 + style={[ 86 + styles.btnContainer, 87 + !isTabletOrDesktop && {borderTopWidth: 1, paddingHorizontal: 20}, 88 + pal.border, 89 + ]}> 90 + <TouchableOpacity 91 + testID="confirmBtn" 92 + onPress={() => { 93 + navigation.canGoBack() 94 + ? navigation.goBack() 95 + : navigation.navigate('Settings') 96 + }} 97 + style={[styles.btn, isTabletOrDesktop && styles.btnDesktop]} 98 + accessibilityRole="button" 99 + accessibilityLabel="Confirm" 100 + accessibilityHint=""> 101 + <Text style={[s.white, s.bold, s.f18]}>Done</Text> 102 + </TouchableOpacity> 103 + </View> 104 + </CenteredView> 105 + ) 106 + }) 107 + 108 + const styles = StyleSheet.create({ 109 + container: { 110 + flex: 1, 111 + paddingBottom: 90, 112 + }, 113 + desktopContainer: { 114 + borderLeftWidth: 1, 115 + borderRightWidth: 1, 116 + paddingBottom: 40, 117 + }, 118 + titleSection: { 119 + paddingBottom: 30, 120 + }, 121 + title: { 122 + textAlign: 'center', 123 + marginBottom: 5, 124 + }, 125 + description: { 126 + textAlign: 'center', 127 + paddingHorizontal: 32, 128 + }, 129 + cardsContainer: { 130 + paddingHorizontal: 20, 131 + }, 132 + card: { 133 + padding: 16, 134 + borderRadius: 10, 135 + marginBottom: 20, 136 + }, 137 + btn: { 138 + flexDirection: 'row', 139 + alignItems: 'center', 140 + justifyContent: 'center', 141 + borderRadius: 32, 142 + padding: 14, 143 + backgroundColor: colors.blue3, 144 + }, 145 + btnDesktop: { 146 + marginHorizontal: 'auto', 147 + paddingHorizontal: 80, 148 + }, 149 + btnContainer: { 150 + paddingTop: 20, 151 + }, 152 + dimmed: { 153 + opacity: 0.3, 154 + }, 155 + })
+22
src/view/screens/Settings.tsx
··· 180 180 navigation.navigate('PreferencesHomeFeed') 181 181 }, [navigation]) 182 182 183 + const openThreadsPreferences = React.useCallback(() => { 184 + navigation.navigate('PreferencesThreads') 185 + }, [navigation]) 186 + 183 187 const onPressAppPasswords = React.useCallback(() => { 184 188 navigation.navigate('AppPasswords') 185 189 }, [navigation]) ··· 418 422 </View> 419 423 <Text type="lg" style={pal.text}> 420 424 Home Feed Preferences 425 + </Text> 426 + </TouchableOpacity> 427 + <TouchableOpacity 428 + testID="preferencesThreadsButton" 429 + style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]} 430 + onPress={openThreadsPreferences} 431 + accessibilityRole="button" 432 + accessibilityHint="" 433 + accessibilityLabel="Opens the threads preferences"> 434 + <View style={[styles.iconContainer, pal.btn]}> 435 + <FontAwesomeIcon 436 + icon={['far', 'comments']} 437 + style={pal.text as FontAwesomeIconStyle} 438 + size={18} 439 + /> 440 + </View> 441 + <Text type="lg" style={pal.text}> 442 + Thread Preferences 421 443 </Text> 422 444 </TouchableOpacity> 423 445 <TouchableOpacity