diff --git a/app.config.js b/app.config.js index dd9e0548..a7614e8e 100644 --- a/app.config.js +++ b/app.config.js @@ -19,7 +19,7 @@ module.exports = function () { backgroundColor: '#ffffff', }, ios: { - buildNumber: '1', + buildNumber: '2', supportsTablet: false, bundleIdentifier: 'xyz.blueskyweb.app', config: { diff --git a/src/Navigation.tsx b/src/Navigation.tsx index 9bf6ba98..604fca2b 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() @@ -219,6 +220,11 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) { component={PreferencesHomeFeed} options={{title: title('Home Feed Preferences')}} /> + ) } diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts index cc7a468e..e2867a70 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 7c356eb1..35266d85 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 7e365094..2d3a3d64 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/discovery/onboarding.ts b/src/state/models/discovery/onboarding.ts index 09c9eac0..8ad321ed 100644 --- a/src/state/models/discovery/onboarding.ts +++ b/src/state/models/discovery/onboarding.ts @@ -2,10 +2,12 @@ import {makeAutoObservable} from 'mobx' import {RootStoreModel} from '../root-store' import {hasProp} from 'lib/type-guards' import {track} from 'lib/analytics/analytics' +import {SuggestedActorsModel} from './suggested-actors' export const OnboardingScreenSteps = { Welcome: 'Welcome', RecommendedFeeds: 'RecommendedFeeds', + RecommendedFollows: 'RecommendedFollows', Home: 'Home', } as const @@ -16,7 +18,11 @@ export class OnboardingModel { // state step: OnboardingStep = 'Home' // default state to skip onboarding, only enabled for new users by calling start() + // data + suggestedActors: SuggestedActorsModel + constructor(public rootStore: RootStoreModel) { + this.suggestedActors = new SuggestedActorsModel(this.rootStore) makeAutoObservable(this, { rootStore: false, hydrate: false, @@ -56,6 +62,11 @@ export class OnboardingModel { this.step = 'RecommendedFeeds' return this.step } else if (this.step === 'RecommendedFeeds') { + this.step = 'RecommendedFollows' + // prefetch recommended follows + this.suggestedActors.loadMore(true) + return this.step + } else if (this.step === 'RecommendedFollows') { this.finish() return this.step } else { diff --git a/src/state/models/discovery/suggested-actors.ts b/src/state/models/discovery/suggested-actors.ts index 0b3d3695..afa5e74e 100644 --- a/src/state/models/discovery/suggested-actors.ts +++ b/src/state/models/discovery/suggested-actors.ts @@ -19,6 +19,7 @@ export class SuggestedActorsModel { loadMoreCursor: string | undefined = undefined error = '' hasMore = false + lastInsertedAtIndex = -1 // data suggestions: SuggestedActor[] = [] @@ -110,6 +111,24 @@ export class SuggestedActorsModel { } }) + async insertSuggestionsByActor(actor: string, indexToInsertAt: number) { + // fetch suggestions + const res = + await this.rootStore.agent.app.bsky.graph.getSuggestedFollowsByActor({ + actor: actor, + }) + const {suggestions: moreSuggestions} = res.data + this.rootStore.me.follows.hydrateProfiles(moreSuggestions) + // dedupe + const toInsert = moreSuggestions.filter( + s => !this.suggestions.find(s2 => s2.did === s.did), + ) + // insert + this.suggestions.splice(indexToInsertAt + 1, 0, ...toInsert) + // update index + this.lastInsertedAtIndex = indexToInsertAt + } + // state transitions // = diff --git a/src/state/models/ui/my-feeds.ts b/src/state/models/ui/my-feeds.ts index f9ad06f7..6b017709 100644 --- a/src/state/models/ui/my-feeds.ts +++ b/src/state/models/ui/my-feeds.ts @@ -8,6 +8,11 @@ export type MyFeedsItem = _reactKey: string type: 'spinner' } + | { + _reactKey: string + type: 'saved-feeds-loading' + numItems: number + } | { _reactKey: string type: 'discover-feeds-loading' @@ -91,7 +96,8 @@ export class MyFeedsUIModel { if (this.saved.isLoading) { items.push({ _reactKey: '__saved_feeds_loading__', - type: 'spinner', + type: 'saved-feeds-loading', + numItems: this.rootStore.preferences.savedFeeds.length || 3, }) } else if (this.saved.hasError) { items.push({ diff --git a/src/state/models/ui/preferences.ts b/src/state/models/ui/preferences.ts index 3790b3a9..5c6ea230 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,9 @@ export class PreferencesModel { homeFeedRepostsEnabled: boolean = true homeFeedQuotePostsEnabled: boolean = true homeFeedMergeFeedEnabled: boolean = false + threadDefaultSort: string = 'oldest' + threadFollowedUsersFirst: boolean = true + threadTreeViewEnabled: boolean = false requireAltTextEnabled: boolean = false // used to linearize async modifications to state @@ -86,6 +90,9 @@ export class PreferencesModel { homeFeedRepostsEnabled: this.homeFeedRepostsEnabled, homeFeedQuotePostsEnabled: this.homeFeedQuotePostsEnabled, homeFeedMergeFeedEnabled: this.homeFeedMergeFeedEnabled, + threadDefaultSort: this.threadDefaultSort, + threadFollowedUsersFirst: this.threadFollowedUsersFirst, + threadTreeViewEnabled: this.threadTreeViewEnabled, requireAltTextEnabled: this.requireAltTextEnabled, } } @@ -189,6 +196,28 @@ 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 thread followed-users-first is enabled in preferences, then hydrate + if ( + hasProp(v, 'threadFollowedUsersFirst') && + typeof v.threadFollowedUsersFirst === 'boolean' + ) { + this.threadFollowedUsersFirst = v.threadFollowedUsersFirst + } + // check if thread treeview is enabled in preferences, then hydrate + if ( + hasProp(v, 'threadTreeViewEnabled') && + typeof v.threadTreeViewEnabled === 'boolean' + ) { + this.threadTreeViewEnabled = v.threadTreeViewEnabled + } // check if requiring alt text is enabled in preferences, then hydrate if ( hasProp(v, 'requireAltTextEnabled') && @@ -494,6 +523,20 @@ export class PreferencesModel { this.homeFeedMergeFeedEnabled = !this.homeFeedMergeFeedEnabled } + setThreadDefaultSort(v: string) { + if (THREAD_SORT_VALUES.includes(v)) { + this.threadDefaultSort = v + } + } + + toggleThreadFollowedUsersFirst() { + this.threadFollowedUsersFirst = !this.threadFollowedUsersFirst + } + + toggleThreadTreeViewEnabled() { + this.threadTreeViewEnabled = !this.threadTreeViewEnabled + } + toggleRequireAltTextEnabled() { this.requireAltTextEnabled = !this.requireAltTextEnabled } diff --git a/src/view/com/auth/Onboarding.tsx b/src/view/com/auth/Onboarding.tsx index 6ea8cd79..a36544a0 100644 --- a/src/view/com/auth/Onboarding.tsx +++ b/src/view/com/auth/Onboarding.tsx @@ -7,6 +7,7 @@ import {usePalette} from 'lib/hooks/usePalette' import {useStores} from 'state/index' import {Welcome} from './onboarding/Welcome' import {RecommendedFeeds} from './onboarding/RecommendedFeeds' +import {RecommendedFollows} from './onboarding/RecommendedFollows' export const Onboarding = observer(function OnboardingImpl() { const pal = usePalette('default') @@ -28,6 +29,9 @@ export const Onboarding = observer(function OnboardingImpl() { {store.onboarding.step === 'RecommendedFeeds' && ( )} + {store.onboarding.step === 'RecommendedFollows' && ( + + )} ) diff --git a/src/view/com/auth/onboarding/RecommendedFeeds.tsx b/src/view/com/auth/onboarding/RecommendedFeeds.tsx index b39714ef..24fc9eef 100644 --- a/src/view/com/auth/onboarding/RecommendedFeeds.tsx +++ b/src/view/com/auth/onboarding/RecommendedFeeds.tsx @@ -96,7 +96,7 @@ export const RecommendedFeeds = observer(function RecommendedFeedsImpl({ - Done + Next @@ -215,6 +215,7 @@ const mStyles = StyleSheet.create({ marginBottom: 16, marginHorizontal: 16, marginTop: 16, + alignItems: 'center', }, buttonText: { textAlign: 'center', diff --git a/src/view/com/auth/onboarding/RecommendedFollows.tsx b/src/view/com/auth/onboarding/RecommendedFollows.tsx new file mode 100644 index 00000000..f2710d2a --- /dev/null +++ b/src/view/com/auth/onboarding/RecommendedFollows.tsx @@ -0,0 +1,204 @@ +import React from 'react' +import {ActivityIndicator, FlatList, StyleSheet, View} from 'react-native' +import {observer} from 'mobx-react-lite' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {TabletOrDesktop, Mobile} from 'view/com/util/layouts/Breakpoints' +import {Text} from 'view/com/util/text/Text' +import {ViewHeader} from 'view/com/util/ViewHeader' +import {TitleColumnLayout} from 'view/com/util/layouts/TitleColumnLayout' +import {Button} from 'view/com/util/forms/Button' +import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' +import {usePalette} from 'lib/hooks/usePalette' +import {useStores} from 'state/index' +import {RecommendedFollowsItem} from './RecommendedFollowsItem' + +type Props = { + next: () => void +} +export const RecommendedFollows = observer(function RecommendedFollowsImpl({ + next, +}: Props) { + const store = useStores() + const pal = usePalette('default') + const {isTabletOrMobile} = useWebMediaQueries() + + React.useEffect(() => { + // Load suggested actors if not already loaded + // prefetch should happen in the onboarding model + if ( + !store.onboarding.suggestedActors.hasLoaded || + store.onboarding.suggestedActors.isEmpty + ) { + store.onboarding.suggestedActors.loadMore(true) + } + }, [store]) + + const title = ( + <> + + Follow some + + + Recommended + + + Users + + + Follow some users to get started. We can recommend you more users based + on who you find interesting. + + + + + + ) + + return ( + <> + + + {store.onboarding.suggestedActors.isLoading ? ( + + ) : ( + ( + + )} + keyExtractor={(item, index) => item.did + index.toString()} + style={{flex: 1}} + /> + )} + + + + + + + + + Check out some recommended users. Follow them to see similar + users. + + + {store.onboarding.suggestedActors.isLoading ? ( + + ) : ( + ( + + )} + keyExtractor={(item, index) => item.did + index.toString()} + style={{flex: 1}} + /> + )} +