diff options
author | Eric Bailey <git@esb.lol> | 2023-09-20 11:03:57 -0500 |
---|---|---|
committer | Eric Bailey <git@esb.lol> | 2023-09-20 11:03:57 -0500 |
commit | 5665968f729b99509d54769f494bbbfc59b4b630 (patch) | |
tree | bfad6f82b613699ba3f206d460f0eac50dee6bd4 | |
parent | 63527493fd8dfb72d21bd50cd2404a5cf2c6e274 (diff) | |
parent | cd96f8dcc8692aec4b9b165cc9f47d7e0b6257df (diff) | |
download | voidsky-5665968f729b99509d54769f494bbbfc59b4b630.tar.zst |
Merge remote-tracking branch 'origin' into bnewbold/bump-api-dep
* origin: Allow touch at the top of the lightbox (#1489) Bump ios build number Feeds tab fixes (#1486) Nicer 'post processing status' in the composer (#1472) Inline createPanResponder (#1483) Tree view threads experiment (#1480) Make "double tap to zoom" precise across platforms (#1482) Onboarding recommended follows (#1457) Add thread sort settings (#1475)
33 files changed, 1157 insertions, 213 deletions
diff --git a/app.config.js b/app.config.js index dd9e05486..a7614e8e5 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 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/discovery/onboarding.ts b/src/state/models/discovery/onboarding.ts index 09c9eac04..8ad321ed9 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 0b3d36952..afa5e74e3 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 f9ad06f77..6b017709e 100644 --- a/src/state/models/ui/my-feeds.ts +++ b/src/state/models/ui/my-feeds.ts @@ -10,6 +10,11 @@ export type MyFeedsItem = } | { _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 3790b3a92..5c6ea230b 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 6ea8cd79e..a36544a03 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' && ( <RecommendedFeeds next={next} /> )} + {store.onboarding.step === 'RecommendedFollows' && ( + <RecommendedFollows next={next} /> + )} </ErrorBoundary> </SafeAreaView> ) diff --git a/src/view/com/auth/onboarding/RecommendedFeeds.tsx b/src/view/com/auth/onboarding/RecommendedFeeds.tsx index b39714ef2..24fc9eef1 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({ <Text type="2xl-medium" style={{color: '#fff', position: 'relative', top: -1}}> - Done + Next </Text> <FontAwesomeIcon icon="angle-right" color="#fff" size={14} /> </View> @@ -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 000000000..f2710d2ac --- /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 = ( + <> + <Text + style={[ + pal.textLight, + tdStyles.title1, + isTabletOrMobile && tdStyles.title1Small, + ]}> + Follow some + </Text> + <Text + style={[ + pal.link, + tdStyles.title2, + isTabletOrMobile && tdStyles.title2Small, + ]}> + Recommended + </Text> + <Text + style={[ + pal.link, + tdStyles.title2, + isTabletOrMobile && tdStyles.title2Small, + ]}> + Users + </Text> + <Text type="2xl-medium" style={[pal.textLight, tdStyles.description]}> + Follow some users to get started. We can recommend you more users based + on who you find interesting. + </Text> + <View + style={{ + flexDirection: 'row', + justifyContent: 'flex-end', + marginTop: 20, + }}> + <Button onPress={next} testID="continueBtn"> + <View + style={{ + flexDirection: 'row', + alignItems: 'center', + paddingLeft: 2, + gap: 6, + }}> + <Text + type="2xl-medium" + style={{color: '#fff', position: 'relative', top: -1}}> + Done + </Text> + <FontAwesomeIcon icon="angle-right" color="#fff" size={14} /> + </View> + </Button> + </View> + </> + ) + + return ( + <> + <TabletOrDesktop> + <TitleColumnLayout + testID="recommendedFollowsOnboarding" + title={title} + horizontal + titleStyle={isTabletOrMobile ? undefined : {minWidth: 470}} + contentStyle={{paddingHorizontal: 0}}> + {store.onboarding.suggestedActors.isLoading ? ( + <ActivityIndicator size="large" /> + ) : ( + <FlatList + data={store.onboarding.suggestedActors.suggestions} + renderItem={({item, index}) => ( + <RecommendedFollowsItem item={item} index={index} /> + )} + keyExtractor={(item, index) => item.did + index.toString()} + style={{flex: 1}} + /> + )} + </TitleColumnLayout> + </TabletOrDesktop> + + <Mobile> + <View style={[mStyles.container]} testID="recommendedFollowsOnboarding"> + <View> + <ViewHeader + title="Recommended Follows" + showBackButton={false} + showOnDesktop + /> + <Text type="lg-medium" style={[pal.text, mStyles.header]}> + Check out some recommended users. Follow them to see similar + users. + </Text> + </View> + {store.onboarding.suggestedActors.isLoading ? ( + <ActivityIndicator size="large" /> + ) : ( + <FlatList + data={store.onboarding.suggestedActors.suggestions} + renderItem={({item, index}) => ( + <RecommendedFollowsItem item={item} index={index} /> + )} + keyExtractor={(item, index) => item.did + index.toString()} + style={{flex: 1}} + /> + )} + <Button + onPress={next} + label="Continue" + testID="continueBtn" + style={mStyles.button} + labelStyle={mStyles.buttonText} + /> + </View> + </Mobile> + </> + ) +}) + +const tdStyles = StyleSheet.create({ + container: { + flex: 1, + marginHorizontal: 16, + justifyContent: 'space-between', + }, + title1: { + fontSize: 36, + fontWeight: '800', + textAlign: 'right', + }, + title1Small: { + fontSize: 24, + }, + title2: { + fontSize: 58, + fontWeight: '800', + textAlign: 'right', + }, + title2Small: { + fontSize: 36, + }, + description: { + maxWidth: 400, + marginTop: 10, + marginLeft: 'auto', + textAlign: 'right', + }, +}) + +const mStyles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'space-between', + }, + header: { + marginBottom: 16, + marginHorizontal: 16, + }, + button: { + marginBottom: 16, + marginHorizontal: 16, + marginTop: 16, + alignItems: 'center', + }, + buttonText: { + textAlign: 'center', + fontSize: 18, + paddingVertical: 4, + }, +}) diff --git a/src/view/com/auth/onboarding/RecommendedFollowsItem.tsx b/src/view/com/auth/onboarding/RecommendedFollowsItem.tsx new file mode 100644 index 000000000..144fdc2e9 --- /dev/null +++ b/src/view/com/auth/onboarding/RecommendedFollowsItem.tsx @@ -0,0 +1,160 @@ +import React, {useMemo} from 'react' +import {View, StyleSheet, ActivityIndicator} from 'react-native' +import {AppBskyActorDefs, moderateProfile} from '@atproto/api' +import {observer} from 'mobx-react-lite' +import {useStores} from 'state/index' +import {FollowButton} from 'view/com/profile/FollowButton' +import {usePalette} from 'lib/hooks/usePalette' +import {SuggestedActor} from 'state/models/discovery/suggested-actors' +import {sanitizeDisplayName} from 'lib/strings/display-names' +import {sanitizeHandle} from 'lib/strings/handles' +import {s} from 'lib/styles' +import {UserAvatar} from 'view/com/util/UserAvatar' +import {Text} from 'view/com/util/text/Text' +import Animated, {FadeInRight} from 'react-native-reanimated' +import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' + +type Props = { + item: SuggestedActor + index: number +} +export const RecommendedFollowsItem: React.FC<Props> = ({item, index}) => { + const pal = usePalette('default') + const store = useStores() + const {isMobile} = useWebMediaQueries() + const delay = useMemo(() => { + return ( + 50 * + (Math.abs(store.onboarding.suggestedActors.lastInsertedAtIndex - index) % + 5) + ) + }, [index, store.onboarding.suggestedActors.lastInsertedAtIndex]) + + return ( + <Animated.View + entering={FadeInRight.delay(delay).springify()} + style={[ + styles.cardContainer, + pal.view, + pal.border, + { + maxWidth: isMobile ? undefined : 670, + borderRightWidth: isMobile ? undefined : 1, + }, + ]}> + <ProfileCard key={item.did} profile={item} index={index} /> + </Animated.View> + ) +} + +export const ProfileCard = observer(function ProfileCardImpl({ + profile, + index, +}: { + profile: AppBskyActorDefs.ProfileViewBasic + index: number +}) { + const store = useStores() + const pal = usePalette('default') + const moderation = moderateProfile(profile, store.preferences.moderationOpts) + const [addingMoreSuggestions, setAddingMoreSuggestions] = + React.useState(false) + + return ( + <View style={styles.card}> + <View style={styles.layout}> + <View style={styles.layoutAvi}> + <UserAvatar + size={40} + avatar={profile.avatar} + moderation={moderation.avatar} + /> + </View> + <View style={styles.layoutContent}> + <Text + type="2xl-bold" + style={[s.bold, pal.text]} + numberOfLines={1} + lineHeight={1.2}> + {sanitizeDisplayName( + profile.displayName || sanitizeHandle(profile.handle), + moderation.profile, + )} + </Text> + <Text type="xl" style={[pal.textLight]} numberOfLines={1}> + {sanitizeHandle(profile.handle, '@')} + </Text> + </View> + + <FollowButton + did={profile.did} + labelStyle={styles.followButton} + onToggleFollow={async isFollow => { + if (isFollow) { + setAddingMoreSuggestions(true) + await store.onboarding.suggestedActors.insertSuggestionsByActor( + profile.did, + index, + ) + setAddingMoreSuggestions(false) + } + }} + /> + </View> + {profile.description ? ( + <View style={styles.details}> + <Text type="lg" style={pal.text} numberOfLines={4}> + {profile.description as string} + </Text> + </View> + ) : undefined} + {addingMoreSuggestions ? ( + <View style={styles.addingMoreContainer}> + <ActivityIndicator size="small" color={pal.colors.text} /> + <Text style={[pal.text]}>Finding similar accounts...</Text> + </View> + ) : null} + </View> + ) +}) + +const styles = StyleSheet.create({ + cardContainer: { + borderTopWidth: 1, + }, + card: { + paddingHorizontal: 10, + }, + layout: { + flexDirection: 'row', + alignItems: 'center', + }, + layoutAvi: { + width: 54, + paddingLeft: 4, + paddingTop: 8, + paddingBottom: 10, + }, + layoutContent: { + flex: 1, + paddingRight: 10, + paddingTop: 10, + paddingBottom: 10, + }, + details: { + paddingLeft: 54, + paddingRight: 10, + paddingBottom: 10, + }, + addingMoreContainer: { + flexDirection: 'row', + alignItems: 'center', + paddingLeft: 54, + paddingTop: 4, + paddingBottom: 12, + gap: 4, + }, + followButton: { + fontSize: 16, + }, +}) diff --git a/src/view/com/auth/onboarding/WelcomeMobile.tsx b/src/view/com/auth/onboarding/WelcomeMobile.tsx index 19c8d52d0..1f0a64370 100644 --- a/src/view/com/auth/onboarding/WelcomeMobile.tsx +++ b/src/view/com/auth/onboarding/WelcomeMobile.tsx @@ -88,6 +88,7 @@ export const WelcomeMobile = observer(function WelcomeMobileImpl({ onPress={next} label="Continue" testID="continueBtn" + style={[styles.buttonContainer]} labelStyle={styles.buttonText} /> </View> @@ -117,6 +118,9 @@ const styles = StyleSheet.create({ spacer: { height: 20, }, + buttonContainer: { + alignItems: 'center', + }, buttonText: { textAlign: 'center', fontSize: 18, diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 8ed0bb378..6a4215b9b 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -285,11 +285,6 @@ export const ComposePost = observer(function ComposePost({ </View> )} </View> - {isProcessing ? ( - <View style={[pal.btn, styles.processingLine]}> - <Text style={pal.text}>{processingState}</Text> - </View> - ) : undefined} {store.preferences.requireAltTextEnabled && gallery.needsAltText && ( <View style={[styles.reminderLine, pal.viewLight]}> <View style={styles.errorIcon}> @@ -374,6 +369,12 @@ export const ComposePost = observer(function ComposePost({ </View> ) : undefined} </ScrollView> + {isProcessing ? ( + <View style={[pal.viewLight, styles.processingLine]}> + <ActivityIndicator /> + <Text style={pal.textLight}>{processingState}</Text> + </View> + ) : undefined} {!extLink && suggestedLinks.size > 0 ? ( <View style={s.mb5}> {Array.from(suggestedLinks) @@ -435,11 +436,11 @@ const styles = StyleSheet.create({ paddingVertical: 6, }, processingLine: { - borderRadius: 6, - paddingHorizontal: 8, - paddingVertical: 6, - marginHorizontal: 15, - marginBottom: 6, + flexDirection: 'row', + alignItems: 'center', + gap: 8, + paddingHorizontal: 26, + paddingVertical: 12, }, errorLine: { flexDirection: 'row', diff --git a/src/view/com/lightbox/ImageViewing/components/ImageDefaultHeader.tsx b/src/view/com/lightbox/ImageViewing/components/ImageDefaultHeader.tsx index c95538c55..bb006d506 100644 --- a/src/view/com/lightbox/ImageViewing/components/ImageDefaultHeader.tsx +++ b/src/view/com/lightbox/ImageViewing/components/ImageDefaultHeader.tsx @@ -34,6 +34,7 @@ const ImageDefaultHeader = ({onRequestClose}: Props) => ( const styles = StyleSheet.create({ root: { alignItems: 'flex-end', + pointerEvents: 'box-none', }, closeButton: { marginRight: 8, diff --git a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx index a6b98009a..03bf45af1 100644 --- a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx +++ b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx @@ -32,6 +32,7 @@ const SWIPE_CLOSE_VELOCITY = 1 const SCREEN = Dimensions.get('screen') const SCREEN_WIDTH = SCREEN.width const SCREEN_HEIGHT = SCREEN.height +const MAX_SCALE = 2 type Props = { imageSrc: ImageSource @@ -58,13 +59,18 @@ const ImageItem = ({ const [loaded, setLoaded] = useState(false) const [scaled, setScaled] = useState(false) const imageDimensions = useImageDimensions(imageSrc) - const handleDoubleTap = useDoubleTapToZoom(scrollViewRef, scaled, SCREEN) + const handleDoubleTap = useDoubleTapToZoom( + scrollViewRef, + scaled, + SCREEN, + imageDimensions, + ) const [translate, scale] = getImageTransform(imageDimensions, SCREEN) const scrollValueY = new Animated.Value(0) const scaleValue = new Animated.Value(scale || 1) const translateValue = new Animated.ValueXY(translate) - const maxScale = scale && scale > 0 ? Math.max(1 / scale, 1) : 1 + const maxScrollViewZoom = MAX_SCALE / (scale || 1) const imageOpacity = scrollValueY.interpolate({ inputRange: [-SWIPE_CLOSE_OFFSET, 0, SWIPE_CLOSE_OFFSET], @@ -118,7 +124,7 @@ const ImageItem = ({ pinchGestureEnabled showsHorizontalScrollIndicator={false} showsVerticalScrollIndicator={false} - maximumZoomScale={maxScale} + maximumZoomScale={maxScrollViewZoom} contentContainerStyle={styles.imageScrollContainer} scrollEnabled={swipeToCloseEnabled} onScrollEndDrag={onScrollEndDrag} diff --git a/src/view/com/lightbox/ImageViewing/hooks/useDoubleTapToZoom.ts b/src/view/com/lightbox/ImageViewing/hooks/useDoubleTapToZoom.ts index 92746e951..ea81d9f1c 100644 --- a/src/view/com/lightbox/ImageViewing/hooks/useDoubleTapToZoom.ts +++ b/src/view/com/lightbox/ImageViewing/hooks/useDoubleTapToZoom.ts @@ -12,6 +12,8 @@ import {ScrollView, NativeTouchEvent, NativeSyntheticEvent} from 'react-native' import {Dimensions} from '../@types' const DOUBLE_TAP_DELAY = 300 +const MIN_ZOOM = 2 + let lastTapTS: number | null = null /** @@ -22,41 +24,124 @@ function useDoubleTapToZoom( scrollViewRef: React.RefObject<ScrollView>, scaled: boolean, screen: Dimensions, + imageDimensions: Dimensions | null, ) { const handleDoubleTap = useCallback( (event: NativeSyntheticEvent<NativeTouchEvent>) => { const nowTS = new Date().getTime() const scrollResponderRef = scrollViewRef?.current?.getScrollResponder() + const getZoomRectAfterDoubleTap = ( + touchX: number, + touchY: number, + ): { + x: number + y: number + width: number + height: number + } => { + if (!imageDimensions) { + return { + x: 0, + y: 0, + width: screen.width, + height: screen.height, + } + } + + // First, let's figure out how much we want to zoom in. + // We want to try to zoom in at least close enough to get rid of black bars. + const imageAspect = imageDimensions.width / imageDimensions.height + const screenAspect = screen.width / screen.height + const zoom = Math.max( + imageAspect / screenAspect, + screenAspect / imageAspect, + MIN_ZOOM, + ) + // Unlike in the Android version, we don't constrain the *max* zoom level here. + // Instead, this is done in the ScrollView props so that it constraints pinch too. + + // Next, we'll be calculating the rectangle to "zoom into" in screen coordinates. + // We already know the zoom level, so this gives us the rectangle size. + let rectWidth = screen.width / zoom + let rectHeight = screen.height / zoom + + // Before we settle on the zoomed rect, figure out the safe area it has to be inside. + // We don't want to introduce new black bars or make existing black bars unbalanced. + let minX = 0 + let minY = 0 + let maxX = screen.width - rectWidth + let maxY = screen.height - rectHeight + if (imageAspect >= screenAspect) { + // The image has horizontal black bars. Exclude them from the safe area. + const renderedHeight = screen.width / imageAspect + const horizontalBarHeight = (screen.height - renderedHeight) / 2 + minY += horizontalBarHeight + maxY -= horizontalBarHeight + } else { + // The image has vertical black bars. Exclude them from the safe area. + const renderedWidth = screen.height * imageAspect + const verticalBarWidth = (screen.width - renderedWidth) / 2 + minX += verticalBarWidth + maxX -= verticalBarWidth + } + + // Finally, we can position the rect according to its size and the safe area. + let rectX + if (maxX >= minX) { + // Content fills the screen horizontally so we have horizontal wiggle room. + // Try to keep the tapped point under the finger after zoom. + rectX = touchX - touchX / zoom + rectX = Math.min(rectX, maxX) + rectX = Math.max(rectX, minX) + } else { + // Keep the rect centered on the screen so that black bars are balanced. + rectX = screen.width / 2 - rectWidth / 2 + } + let rectY + if (maxY >= minY) { + // Content fills the screen vertically so we have vertical wiggle room. + // Try to keep the tapped point under the finger after zoom. + rectY = touchY - touchY / zoom + rectY = Math.min(rectY, maxY) + rectY = Math.max(rectY, minY) + } else { + // Keep the rect centered on the screen so that black bars are balanced. + rectY = screen.height / 2 - rectHeight / 2 + } + + return { + x: rectX, + y: rectY, + height: rectHeight, + width: rectWidth, + } + } + if (lastTapTS && nowTS - lastTapTS < DOUBLE_TAP_DELAY) { - const {pageX, pageY} = event.nativeEvent - let targetX = 0 - let targetY = 0 - let targetWidth = screen.width - let targetHeight = screen.height - - // Zooming in - // TODO: Add more precise calculation of targetX, targetY based on touch - if (!scaled) { - targetX = pageX / 2 - targetY = pageY / 2 - targetWidth = screen.width / 2 - targetHeight = screen.height / 2 + let nextZoomRect = { + x: 0, + y: 0, + width: screen.width, + height: screen.height, + } + + const willZoom = !scaled + if (willZoom) { + const {pageX, pageY} = event.nativeEvent + nextZoomRect = getZoomRectAfterDoubleTap(pageX, pageY) } // @ts-ignore scrollResponderRef?.scrollResponderZoomTo({ - x: targetX, - y: targetY, - width: targetWidth, - height: targetHeight, + ...nextZoomRect, // This rect is in screen coordinates animated: true, }) } else { lastTapTS = nowTS } }, - [scaled, screen.height, screen.width, scrollViewRef], + [imageDimensions, scaled, screen.height, screen.width, scrollViewRef], ) return handleDoubleTap diff --git a/src/view/com/lightbox/ImageViewing/hooks/usePanResponder.ts b/src/view/com/lightbox/ImageViewing/hooks/usePanResponder.ts index 036e7246f..7908504ea 100644 --- a/src/view/com/lightbox/ImageViewing/hooks/usePanResponder.ts +++ b/src/view/com/lightbox/ImageViewing/hooks/usePanResponder.ts @@ -1,4 +1,3 @@ -/* eslint-disable react-hooks/exhaustive-deps */ /** * Copyright (c) JOB TODAY S.A. and its affiliates. * @@ -7,19 +6,19 @@ * */ -import {useMemo, useEffect} from 'react' +import {useEffect} from 'react' import { Animated, Dimensions, GestureResponderEvent, GestureResponderHandlers, NativeTouchEvent, + PanResponder, PanResponderGestureState, } from 'react-native' import {Position} from '../@types' import { - createPanResponder, getDistanceBetweenTouches, getImageTranslate, getImageDimensionsByTranslate, @@ -29,8 +28,10 @@ const SCREEN = Dimensions.get('window') const SCREEN_WIDTH = SCREEN.width const SCREEN_HEIGHT = SCREEN.height const MIN_DIMENSION = Math.min(SCREEN_WIDTH, SCREEN_HEIGHT) +const ANDROID_BAR_HEIGHT = 24 -const SCALE_MAX = 2 +const MIN_ZOOM = 2 +const MAX_SCALE = 2 const DOUBLE_TAP_DELAY = 300 const OUT_BOUND_MULTIPLIER = 0.75 @@ -87,23 +88,56 @@ const usePanResponder = ({ return [top, left, bottom, right] } - const getTranslateInBounds = (translate: Position, scale: number) => { - const inBoundTranslate = {x: translate.x, y: translate.y} - const [topBound, leftBound, bottomBound, rightBound] = getBounds(scale) - - if (translate.x > leftBound) { - inBoundTranslate.x = leftBound - } else if (translate.x < rightBound) { - inBoundTranslate.x = rightBound + const getTransformAfterDoubleTap = ( + touchX: number, + touchY: number, + ): [number, Position] => { + let nextScale = initialScale + let nextTranslateX = initialTranslate.x + let nextTranslateY = initialTranslate.y + + // First, let's figure out how much we want to zoom in. + // We want to try to zoom in at least close enough to get rid of black bars. + const imageAspect = imageDimensions.width / imageDimensions.height + const screenAspect = SCREEN.width / SCREEN.height + let zoom = Math.max( + imageAspect / screenAspect, + screenAspect / imageAspect, + MIN_ZOOM, + ) + // Don't zoom so hard that the original image's pixels become blurry. + zoom = Math.min(zoom, MAX_SCALE / initialScale) + nextScale = initialScale * zoom + + // Next, let's see if we need to adjust the scaled image translation. + // Ideally, we want the tapped point to stay under the finger after the scaling. + const dx = SCREEN.width / 2 - touchX + const dy = SCREEN.height / 2 - (touchY - ANDROID_BAR_HEIGHT) + // Before we try to adjust the translation, check how much wiggle room we have. + // We don't want to introduce new black bars or make existing black bars unbalanced. + const [topBound, leftBound, bottomBound, rightBound] = getBounds(nextScale) + if (leftBound > rightBound) { + // Content fills the screen horizontally so we have horizontal wiggle room. + // Try to keep the tapped point under the finger after zoom. + nextTranslateX += dx * zoom - dx + nextTranslateX = Math.min(nextTranslateX, leftBound) + nextTranslateX = Math.max(nextTranslateX, rightBound) } - - if (translate.y > topBound) { - inBoundTranslate.y = topBound - } else if (translate.y < bottomBound) { - inBoundTranslate.y = bottomBound + if (topBound > bottomBound) { + // Content fills the screen vertically so we have vertical wiggle room. + // Try to keep the tapped point under the finger after zoom. + nextTranslateY += dy * zoom - dy + nextTranslateY = Math.min(nextTranslateY, topBound) + nextTranslateY = Math.max(nextTranslateY, bottomBound) } - return inBoundTranslate + return [ + nextScale, + { + x: nextTranslateX, + y: nextTranslateY, + }, + ] } const fitsScreenByWidth = () => @@ -125,8 +159,12 @@ const usePanResponder = ({ longPressHandlerRef && clearTimeout(longPressHandlerRef) } - const handlers = { - onGrant: ( + const panResponder = PanResponder.create({ + onStartShouldSetPanResponder: () => true, + onStartShouldSetPanResponderCapture: () => true, + onMoveShouldSetPanResponder: () => true, + onMoveShouldSetPanResponderCapture: () => true, + onPanResponderGrant: ( _: GestureResponderEvent, gestureState: PanResponderGestureState, ) => { @@ -138,7 +176,7 @@ const usePanResponder = ({ longPressHandlerRef = setTimeout(onLongPress, delayLongPress) }, - onStart: ( + onPanResponderStart: ( event: GestureResponderEvent, gestureState: PanResponderGestureState, ) => { @@ -157,25 +195,18 @@ const usePanResponder = ({ ) if (doubleTapToZoomEnabled && isDoubleTapPerformed) { - const isScaled = currentTranslate.x !== initialTranslate.x // currentScale !== initialScale; - const {pageX: touchX, pageY: touchY} = event.nativeEvent.touches[0] - const targetScale = SCALE_MAX - const nextScale = isScaled ? initialScale : targetScale - const nextTranslate = isScaled - ? initialTranslate - : getTranslateInBounds( - { - x: - initialTranslate.x + - (SCREEN_WIDTH / 2 - touchX) * (targetScale / currentScale), - y: - initialTranslate.y + - (SCREEN_HEIGHT / 2 - touchY) * (targetScale / currentScale), - }, - targetScale, - ) - - onZoom(!isScaled) + let nextScale = initialScale + let nextTranslate = initialTranslate + + const willZoom = currentScale === initialScale + if (willZoom) { + const {pageX: touchX, pageY: touchY} = event.nativeEvent.touches[0] + ;[nextScale, nextTranslate] = getTransformAfterDoubleTap( + touchX, + touchY, + ) + } + onZoom(willZoom) Animated.parallel( [ @@ -206,7 +237,7 @@ const usePanResponder = ({ lastTapTS = Date.now() } }, - onMove: ( + onPanResponderMove: ( event: GestureResponderEvent, gestureState: PanResponderGestureState, ) => { @@ -328,7 +359,7 @@ const usePanResponder = ({ tmpTranslate = {x: nextTranslateX, y: nextTranslateY} } }, - onRelease: () => { + onPanResponderRelease: () => { cancelLongPressHandle() if (isDoubleTapPerformed) { @@ -336,8 +367,8 @@ const usePanResponder = ({ } if (tmpScale > 0) { - if (tmpScale < initialScale || tmpScale > SCALE_MAX) { - tmpScale = tmpScale < initialScale ? initialScale : SCALE_MAX + if (tmpScale < initialScale || tmpScale > MAX_SCALE) { + tmpScale = tmpScale < initialScale ? initialScale : MAX_SCALE Animated.timing(scaleValue, { toValue: tmpScale, duration: 100, @@ -390,9 +421,9 @@ const usePanResponder = ({ tmpTranslate = null } }, - } - - const panResponder = useMemo(() => createPanResponder(handlers), [handlers]) + onPanResponderTerminationRequest: () => false, + onShouldBlockNativeResponder: () => false, + }) return [panResponder.panHandlers, scaleValue, translateValue] } diff --git a/src/view/com/lightbox/ImageViewing/index.tsx b/src/view/com/lightbox/ImageViewing/index.tsx index 531df129e..1a64fb3af 100644 --- a/src/view/com/lightbox/ImageViewing/index.tsx +++ b/src/view/com/lightbox/ImageViewing/index.tsx @@ -189,6 +189,7 @@ const styles = StyleSheet.create({ width: '100%', zIndex: 1, top: 0, + pointerEvents: 'box-none', }, footer: { position: 'absolute', diff --git a/src/view/com/lightbox/ImageViewing/utils.ts b/src/view/com/lightbox/ImageViewing/utils.ts index 8c9c1b34c..d56eea4f4 100644 --- a/src/view/com/lightbox/ImageViewing/utils.ts +++ b/src/view/com/lightbox/ImageViewing/utils.ts @@ -6,14 +6,7 @@ * */ -import { - Animated, - GestureResponderEvent, - PanResponder, - PanResponderGestureState, - PanResponderInstance, - NativeTouchEvent, -} from 'react-native' +import {Animated, NativeTouchEvent} from 'react-native' import {Dimensions, Position} from './@types' type CacheStorageItem = {key: string; value: any} @@ -131,40 +124,6 @@ export const getImageTranslateForScale = ( return getImageTranslate(targetImageDimensions, screen) } -type HandlerType = ( - event: GestureResponderEvent, - state: PanResponderGestureState, -) => void - -type PanResponderProps = { - onGrant: HandlerType - onStart?: HandlerType - onMove: HandlerType - onRelease?: HandlerType - onTerminate?: HandlerType -} - -export const createPanResponder = ({ - onGrant, - onStart, - onMove, - onRelease, - onTerminate, -}: PanResponderProps): PanResponderInstance => - PanResponder.create({ - onStartShouldSetPanResponder: () => true, - onStartShouldSetPanResponderCapture: () => true, - onMoveShouldSetPanResponder: () => true, - onMoveShouldSetPanResponderCapture: () => true, - onPanResponderGrant: onGrant, - onPanResponderStart: onStart, - onPanResponderMove: onMove, - onPanResponderRelease: onRelease, - onPanResponderTerminate: onTerminate, - onPanResponderTerminationRequest: () => false, - onShouldBlockNativeResponder: () => false, - }) - export const getDistanceBetweenTouches = ( touches: NativeTouchEvent[], ): number => { diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx index 1cc177d17..373b4499d 100644 --- a/src/view/com/post-thread/PostThread.tsx +++ b/src/view/com/post-thread/PostThread.tsx @@ -55,6 +55,7 @@ const LOAD_MORE = { const BOTTOM_COMPONENT = { _reactKey: '__bottom_component__', _isHighlightedPost: false, + _showBorder: true, } type YieldedItem = | PostThreadItemModel @@ -69,10 +70,12 @@ export const PostThread = observer(function PostThread({ uri, view, onPressReply, + treeView, }: { uri: string view: PostThreadModel onPressReply: () => void + treeView: boolean }) { const pal = usePalette('default') const {isTablet} = useWebMediaQueries() @@ -99,6 +102,13 @@ export const PostThread = observer(function PostThread({ } return [] }, [view.isLoadingFromCache, view.thread, maxVisible]) + const highlightedPostIndex = posts.findIndex(post => post._isHighlightedPost) + const showBottomBorder = + !treeView || + // in the treeview, only show the bottom border + // if there are replies under the highlighted posts + posts.findLast(v => v instanceof PostThreadItemModel) !== + posts[highlightedPostIndex] useSetTitle( view.thread?.postRecord && `${sanitizeDisplayName( @@ -135,17 +145,16 @@ export const PostThread = observer(function PostThread({ return } - const index = posts.findIndex(post => post._isHighlightedPost) - if (index !== -1) { + if (highlightedPostIndex !== -1) { ref.current?.scrollToIndex({ - index, + index: highlightedPostIndex, animated: false, viewPosition: 0, }) hasScrolledIntoView.current = true } }, [ - posts, + highlightedPostIndex, view.hasContent, view.isFromCache, view.isLoadingFromCache, @@ -184,7 +193,14 @@ export const PostThread = observer(function PostThread({ </View> ) } else if (item === REPLY_PROMPT) { - return <ComposePrompt onPressCompose={onPressReply} /> + return ( + <View + style={ + treeView && [pal.border, {borderBottomWidth: 1, marginBottom: 6}] + }> + {isDesktopWeb && <ComposePrompt onPressCompose={onPressReply} />} + </View> + ) } else if (item === DELETED) { return ( <View style={[pal.border, pal.viewLight, styles.itemContainer]}> @@ -224,7 +240,18 @@ export const PostThread = observer(function PostThread({ // due to some complexities with how flatlist works, this is the easiest way // I could find to get a border positioned directly under the last item // -prf - return <View style={[pal.border, styles.bottomSpacer]} /> + return ( + <View + style={[ + {height: 400}, + showBottomBorder && { + borderTopWidth: 1, + borderColor: pal.colors.border, + }, + treeView && {marginTop: 10}, + ]} + /> + ) } else if (item === CHILD_SPINNER) { return ( <View style={styles.childSpinner}> @@ -240,12 +267,13 @@ export const PostThread = observer(function PostThread({ item={item} onPostReply={onRefresh} hasPrecedingItem={prev?._showChildReplyLine} + treeView={treeView} /> ) } return <></> }, - [onRefresh, onPressReply, pal, posts, isTablet], + [onRefresh, onPressReply, pal, posts, isTablet, treeView, showBottomBorder], ) // loading @@ -377,7 +405,7 @@ function* flattenThread( } } yield post - if (isDesktopWeb && post._isHighlightedPost) { + if (post._isHighlightedPost) { yield REPLY_PROMPT } if (post.replies?.length) { @@ -411,8 +439,4 @@ const styles = StyleSheet.create({ paddingVertical: 10, }, childSpinner: {}, - bottomSpacer: { - height: 400, - borderTopWidth: 1, - }, }) diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx index 37c7ece47..1089bfabf 100644 --- a/src/view/com/post-thread/PostThreadItem.tsx +++ b/src/view/com/post-thread/PostThreadItem.tsx @@ -35,15 +35,18 @@ import {formatCount} from '../util/numeric/format' import {TimeElapsed} from 'view/com/util/TimeElapsed' import {makeProfileLink} from 'lib/routes/links' import {isDesktopWeb} from 'platform/detection' +import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' export const PostThreadItem = observer(function PostThreadItem({ item, onPostReply, hasPrecedingItem, + treeView, }: { item: PostThreadItemModel onPostReply: () => void hasPrecedingItem: boolean + treeView: boolean }) { const pal = usePalette('default') const store = useStores() @@ -389,25 +392,28 @@ export const PostThreadItem = observer(function PostThreadItem({ </> ) } else { + const isThreadedChild = treeView && item._depth > 0 return ( - <> + <PostOuterWrapper + item={item} + hasPrecedingItem={hasPrecedingItem} + treeView={treeView}> <PostHider testID={`postThreadItem-by-${item.post.author.handle}`} href={itemHref} - style={[ - styles.outer, - pal.border, - pal.view, - item._showParentReplyLine && hasPrecedingItem && styles.noTopBorder, - styles.cursor, - ]} + style={[pal.view]} moderation={item.moderation.content}> <PostSandboxWarning /> <View - style={{flexDirection: 'row', gap: 10, paddingLeft: 8, height: 16}}> + style={{ + flexDirection: 'row', + gap: 10, + paddingLeft: 8, + height: isThreadedChild ? 8 : 16, + }}> <View style={{width: 52}}> - {item._showParentReplyLine && ( + {!isThreadedChild && item._showParentReplyLine && ( <View style={[ styles.replyLine, @@ -431,7 +437,7 @@ export const PostThreadItem = observer(function PostThreadItem({ ]}> <View style={styles.layoutAvi}> <PreviewableUserAvatar - size={52} + size={isThreadedChild ? 24 : 52} did={item.post.author.did} handle={item.post.author.handle} avatar={item.post.author.avatar} @@ -444,7 +450,9 @@ export const PostThreadItem = observer(function PostThreadItem({ styles.replyLine, { flexGrow: 1, - backgroundColor: pal.colors.replyLine, + backgroundColor: isThreadedChild + ? pal.colors.border + : pal.colors.replyLine, marginTop: 4, }, ]} @@ -464,7 +472,11 @@ export const PostThreadItem = observer(function PostThreadItem({ style={styles.alert} /> {item.richText?.text ? ( - <View style={styles.postTextContainer}> + <View + style={[ + styles.postTextContainer, + isThreadedChild && {paddingTop: 2}, + ]}> <RichText type="post-text" richText={item.richText} @@ -508,30 +520,84 @@ export const PostThreadItem = observer(function PostThreadItem({ /> </View> </View> + {item._hasMore ? ( + <Link + style={[ + styles.loadMore, + { + paddingLeft: treeView ? 44 : 70, + paddingTop: 0, + paddingBottom: treeView ? 4 : 12, + }, + ]} + href={itemHref} + title={itemTitle} + noFeedback> + <Text type="sm-medium" style={pal.textLight}> + More + </Text> + <FontAwesomeIcon + icon="angle-right" + color={pal.colors.textLight} + size={14} + /> + </Link> + ) : undefined} </PostHider> - {item._hasMore ? ( - <Link - style={[ - styles.loadMore, - {borderTopColor: pal.colors.border}, - pal.view, - ]} - href={itemHref} - title={itemTitle} - noFeedback> - <Text style={pal.link}>Continue thread...</Text> - <FontAwesomeIcon - icon="angle-right" - style={pal.link as FontAwesomeIconStyle} - size={18} - /> - </Link> - ) : undefined} - </> + </PostOuterWrapper> ) } }) +function PostOuterWrapper({ + item, + hasPrecedingItem, + treeView, + children, +}: React.PropsWithChildren<{ + item: PostThreadItemModel + hasPrecedingItem: boolean + treeView: boolean +}>) { + const {isMobile} = useWebMediaQueries() + const pal = usePalette('default') + if (treeView && item._depth > 0) { + return ( + <View + style={[ + pal.view, + styles.cursor, + {flexDirection: 'row', paddingLeft: 10}, + ]}> + {Array.from(Array(item._depth - 1)).map((_, n: number) => ( + <View + key={`${item.uri}-padding-${n}`} + style={{ + borderLeftWidth: 2, + borderLeftColor: pal.colors.border, + marginLeft: 19, + paddingLeft: isMobile ? 0 : 4, + }} + /> + ))} + <View style={{flex: 1}}>{children}</View> + </View> + ) + } + return ( + <View + style={[ + styles.outer, + pal.view, + pal.border, + item._showParentReplyLine && hasPrecedingItem && styles.noTopBorder, + styles.cursor, + ]}> + {children} + </View> + ) +} + function ExpandedPostDetails({ post, needsTranslation, @@ -600,7 +666,7 @@ const styles = StyleSheet.create({ flexDirection: 'row', alignItems: 'center', flexWrap: 'wrap', - paddingBottom: 8, + paddingBottom: 4, paddingRight: 10, }, postTextLargeContainer: { @@ -629,11 +695,10 @@ const styles = StyleSheet.create({ }, loadMore: { flexDirection: 'row', - justifyContent: 'space-between', - borderTopWidth: 1, - paddingLeft: 80, - paddingRight: 20, - paddingVertical: 12, + alignItems: 'center', + justifyContent: 'flex-start', + gap: 4, + paddingHorizontal: 20, }, replyLine: { width: 2, diff --git a/src/view/com/profile/FollowButton.tsx b/src/view/com/profile/FollowButton.tsx index 6f6286e69..4b2b944f7 100644 --- a/src/view/com/profile/FollowButton.tsx +++ b/src/view/com/profile/FollowButton.tsx @@ -1,5 +1,5 @@ import React from 'react' -import {View} from 'react-native' +import {StyleProp, TextStyle, View} from 'react-native' import {observer} from 'mobx-react-lite' import {Button, ButtonType} from '../util/forms/Button' import {useStores} from 'state/index' @@ -11,11 +11,13 @@ export const FollowButton = observer(function FollowButtonImpl({ followedType = 'default', did, onToggleFollow, + labelStyle, }: { unfollowedType?: ButtonType followedType?: ButtonType did: string onToggleFollow?: (v: boolean) => void + labelStyle?: StyleProp<TextStyle> }) { const store = useStores() const followState = store.me.follows.getFollowState(did) @@ -28,18 +30,18 @@ export const FollowButton = observer(function FollowButtonImpl({ const updatedFollowState = await store.me.follows.fetchFollowState(did) if (updatedFollowState === FollowState.Following) { try { + onToggleFollow?.(false) await store.agent.deleteFollow(store.me.follows.getFollowUri(did)) store.me.follows.removeFollow(did) - onToggleFollow?.(false) } catch (e: any) { store.log.error('Failed to delete follow', e) Toast.show('An issue occurred, please try again.') } } else if (updatedFollowState === FollowState.NotFollowing) { try { + onToggleFollow?.(true) const res = await store.agent.follow(did) store.me.follows.addFollow(did, res.uri) - onToggleFollow?.(true) } catch (e: any) { store.log.error('Failed to create follow', e) Toast.show('An issue occurred, please try again.') @@ -52,8 +54,10 @@ export const FollowButton = observer(function FollowButtonImpl({ type={ followState === FollowState.Following ? followedType : unfollowedType } + labelStyle={labelStyle} onPress={onToggleFollowInner} label={followState === FollowState.Following ? 'Unfollow' : 'Follow'} + withLoading={true} /> ) }) diff --git a/src/view/com/util/forms/Button.tsx b/src/view/com/util/forms/Button.tsx index 8049d2243..076fa1baa 100644 --- a/src/view/com/util/forms/Button.tsx +++ b/src/view/com/util/forms/Button.tsx @@ -7,6 +7,8 @@ import { Pressable, ViewStyle, PressableStateCallbackType, + ActivityIndicator, + View, } from 'react-native' import {Text} from '../text/Text' import {useTheme} from 'lib/ThemeContext' @@ -48,17 +50,19 @@ export function Button({ accessibilityHint, accessibilityLabelledBy, onAccessibilityEscape, + withLoading = false, }: React.PropsWithChildren<{ type?: ButtonType label?: string style?: StyleProp<ViewStyle> labelStyle?: StyleProp<TextStyle> - onPress?: () => void + onPress?: () => void | Promise<void> testID?: string accessibilityLabel?: string accessibilityHint?: string accessibilityLabelledBy?: string onAccessibilityEscape?: () => void + withLoading?: boolean }>) { const theme = useTheme() const typeOuterStyle = choose<ViewStyle, Record<ButtonType, ViewStyle>>( @@ -138,13 +142,16 @@ export function Button({ }, ) + const [isLoading, setIsLoading] = React.useState(false) const onPressWrapped = React.useCallback( - (event: Event) => { + async (event: Event) => { event.stopPropagation() event.preventDefault() - onPress?.() + withLoading && setIsLoading(true) + await onPress?.() + withLoading && setIsLoading(false) }, - [onPress], + [onPress, withLoading], ) const getStyle = React.useCallback( @@ -160,23 +167,35 @@ export function Button({ [typeOuterStyle, style], ) + const renderChildern = React.useCallback(() => { + if (!label) { + return children + } + + return ( + <View style={styles.labelContainer}> + {label && withLoading && isLoading ? ( + <ActivityIndicator size={12} color={typeLabelStyle.color} /> + ) : null} + <Text type="button" style={[typeLabelStyle, labelStyle]}> + {label} + </Text> + </View> + ) + }, [children, label, withLoading, isLoading, typeLabelStyle, labelStyle]) + return ( <Pressable style={getStyle} onPress={onPressWrapped} + disabled={isLoading} testID={testID} accessibilityRole="button" accessibilityLabel={accessibilityLabel} accessibilityHint={accessibilityHint} accessibilityLabelledBy={accessibilityLabelledBy} onAccessibilityEscape={onAccessibilityEscape}> - {label ? ( - <Text type="button" style={[typeLabelStyle, labelStyle]}> - {label} - </Text> - ) : ( - children - )} + {renderChildern} </Pressable> ) } @@ -187,4 +206,8 @@ const styles = StyleSheet.create({ paddingVertical: 8, borderRadius: 24, }, + labelContainer: { + flexDirection: 'row', + gap: 8, + }, }) diff --git a/src/view/index.ts b/src/view/index.ts index 2fdc34e7b..07848aa8f 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' @@ -44,6 +45,7 @@ import {faEye} from '@fortawesome/free-solid-svg-icons/faEye' import {faEyeSlash as farEyeSlash} from '@fortawesome/free-regular-svg-icons/faEyeSlash' import {faFaceSmile} from '@fortawesome/free-regular-svg-icons/faFaceSmile' import {faFire} from '@fortawesome/free-solid-svg-icons/faFire' +import {faFlask} from '@fortawesome/free-solid-svg-icons' import {faFloppyDisk} from '@fortawesome/free-regular-svg-icons/faFloppyDisk' import {faGear} from '@fortawesome/free-solid-svg-icons/faGear' import {faGlobe} from '@fortawesome/free-solid-svg-icons/faGlobe' @@ -134,6 +136,7 @@ export function setup() { farClone, faComment, faCommentSlash, + faComments, faCompass, faEllipsis, faEnvelope, @@ -142,6 +145,7 @@ export function setup() { farEyeSlash, faFaceSmile, faFire, + faFlask, faFloppyDisk, faGear, faGlobe, diff --git a/src/view/screens/CustomFeed.tsx b/src/view/screens/CustomFeed.tsx index eaa21f292..f8ceda940 100644 --- a/src/view/screens/CustomFeed.tsx +++ b/src/view/screens/CustomFeed.tsx @@ -185,6 +185,17 @@ export const CustomFeedScreenInner = observer( }) }, [store, currentFeed]) + const onPressAbout = React.useCallback(() => { + store.shell.openModal({ + name: 'confirm', + title: currentFeed?.displayName || '', + message: + currentFeed?.data.description || 'This feed has no description.', + confirmBtnText: 'Close', + onPressConfirm() {}, + }) + }, [store, currentFeed]) + const onPressViewAuthor = React.useCallback(() => { navigation.navigate('Profile', {name: handleOrDid}) }, [handleOrDid, navigation]) @@ -233,7 +244,21 @@ export const CustomFeedScreenInner = observer( }, [store, onSoftReset, isScreenFocused]) const dropdownItems: DropdownItem[] = React.useMemo(() => { - let items: DropdownItem[] = [ + return [ + currentFeed + ? { + testID: 'feedHeaderDropdownAboutBtn', + label: 'About this feed', + onPress: onPressAbout, + icon: { + ios: { + name: 'info.circle', + }, + android: '', + web: 'info', + }, + } + : undefined, { testID: 'feedHeaderDropdownViewAuthorBtn', label: 'View author', @@ -292,10 +317,10 @@ export const CustomFeedScreenInner = observer( web: 'share', }, }, - ] - return items + ].filter(Boolean) as DropdownItem[] }, [ - currentFeed?.isSaved, + currentFeed, + onPressAbout, onToggleSaved, onPressReport, onPressShare, diff --git a/src/view/screens/Feeds.tsx b/src/view/screens/Feeds.tsx index d2c4a6d2d..6ca24bae9 100644 --- a/src/view/screens/Feeds.tsx +++ b/src/view/screens/Feeds.tsx @@ -16,7 +16,10 @@ import {ComposeIcon2, CogIcon} from 'lib/icons' import {s} from 'lib/styles' import {SearchInput} from 'view/com/util/forms/SearchInput' import {UserAvatar} from 'view/com/util/UserAvatar' -import {FeedFeedLoadingPlaceholder} from 'view/com/util/LoadingPlaceholder' +import { + LoadingPlaceholder, + FeedFeedLoadingPlaceholder, +} from 'view/com/util/LoadingPlaceholder' import {ErrorMessage} from 'view/com/util/error/ErrorMessage' import debounce from 'lodash.debounce' import {Text} from 'view/com/util/text/Text' @@ -42,7 +45,12 @@ export const FeedsScreen = withAuthRequired( React.useCallback(() => { store.shell.setMinimalShellMode(false) myFeeds.setup() - }, [store.shell, myFeeds]), + + const softResetSub = store.onScreenSoftReset(() => myFeeds.refresh()) + return () => { + softResetSub.remove() + } + }, [store, myFeeds]), ) const onPressCompose = React.useCallback(() => { @@ -119,6 +127,14 @@ export const FeedsScreen = withAuthRequired( ) } return <View /> + } else if (item.type === 'saved-feeds-loading') { + return ( + <> + {Array.from(Array(item.numItems)).map((_, i) => ( + <SavedFeedLoadingPlaceholder key={`placeholder-${i}`} /> + ))} + </> + ) } else if (item.type === 'saved-feed') { return ( <SavedFeed @@ -262,10 +278,7 @@ function SavedFeed({ asAnchor anchorNoUnderline> <UserAvatar type="algo" size={28} avatar={avatar} /> - <Text - type={isMobile ? 'lg' : 'lg-medium'} - style={[pal.text, s.flex1]} - numberOfLines={1}> + <Text type="lg-medium" style={[pal.text, s.flex1]} numberOfLines={1}> {displayName} </Text> {isMobile && ( @@ -279,6 +292,22 @@ function SavedFeed({ ) } +function SavedFeedLoadingPlaceholder() { + const pal = usePalette('default') + const {isMobile} = useWebMediaQueries() + return ( + <View + style={[ + pal.border, + styles.savedFeed, + isMobile && styles.savedFeedMobile, + ]}> + <LoadingPlaceholder width={28} height={28} style={{borderRadius: 4}} /> + <LoadingPlaceholder width={140} height={12} /> + </View> + ) +} + const styles = StyleSheet.create({ container: { flex: 1, diff --git a/src/view/screens/PostThread.tsx b/src/view/screens/PostThread.tsx index a6aafa530..90b98d052 100644 --- a/src/view/screens/PostThread.tsx +++ b/src/view/screens/PostThread.tsx @@ -74,6 +74,7 @@ export const PostThreadScreen = withAuthRequired(({route}: Props) => { uri={uri} view={view} onPressReply={onPressReply} + treeView={store.preferences.threadTreeViewEnabled} /> </View> {isMobile && ( diff --git a/src/view/screens/PreferencesHomeFeed.tsx b/src/view/screens/PreferencesHomeFeed.tsx index 81bdfc95e..404d006f8 100644 --- a/src/view/screens/PreferencesHomeFeed.tsx +++ b/src/view/screens/PreferencesHomeFeed.tsx @@ -1,6 +1,7 @@ import React, {useState} from 'react' import {ScrollView, StyleSheet, TouchableOpacity, View} from 'react-native' import {observer} from 'mobx-react-lite' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {Slider} from '@miblanchard/react-native-slider' import {Text} from '../com/util/text/Text' import {useStores} from 'state/index' @@ -66,7 +67,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> @@ -155,11 +159,12 @@ export const PreferencesHomeFeed = observer(function PreferencesHomeFeedImpl({ <View style={[pal.viewLight, styles.card]}> <Text type="title-sm" style={[pal.text, s.pb5]}> - Show Posts from My Feeds (Experimental) + <FontAwesomeIcon icon="flask" color={pal.colors.text} /> Show + Posts from My Feeds </Text> <Text style={[pal.text, s.pb10]}> Set this setting to "Yes" to show samples of your saved feeds in - your following feed. + your following feed. This is an experimental feature. </Text> <ToggleButton type="default-light" @@ -175,7 +180,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..74b28267d --- /dev/null +++ b/src/view/screens/PreferencesThreads.tsx @@ -0,0 +1,173 @@ +import React from 'react' +import {ScrollView, StyleSheet, TouchableOpacity, View} from 'react-native' +import {observer} from 'mobx-react-lite' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +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 style={[pal.viewLight, styles.card]}> + <Text type="title-sm" style={[pal.text, s.pb5]}> + <FontAwesomeIcon icon="flask" color={pal.colors.text} /> Threaded + Mode + </Text> + <Text style={[pal.text, s.pb10]}> + Set this setting to "Yes" to show replies in a threaded view. This + is an experimental feature. + </Text> + <ToggleButton + type="default-light" + label={store.preferences.threadTreeViewEnabled ? 'Yes' : 'No'} + isSelected={store.preferences.threadTreeViewEnabled} + onPress={store.preferences.toggleThreadTreeViewEnabled} + /> + </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/Profile.tsx b/src/view/screens/Profile.tsx index 241bae1ed..efcb588f6 100644 --- a/src/view/screens/Profile.tsx +++ b/src/view/screens/Profile.tsx @@ -187,7 +187,9 @@ export const ProfileScreen = withAuthRequired( /> ) } else if (item instanceof CustomFeedModel) { - return <CustomFeed item={item} showSaveBtn showLikes /> + return ( + <CustomFeed item={item} showSaveBtn showLikes showDescription /> + ) } // if section is posts or posts & replies } else { 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" |