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)
zio/stable
Eric Bailey 2023-09-20 11:03:57 -05:00
commit 5665968f72
33 changed files with 1156 additions and 212 deletions

View File

@ -19,7 +19,7 @@ module.exports = function () {
backgroundColor: '#ffffff', backgroundColor: '#ffffff',
}, },
ios: { ios: {
buildNumber: '1', buildNumber: '2',
supportsTablet: false, supportsTablet: false,
bundleIdentifier: 'xyz.blueskyweb.app', bundleIdentifier: 'xyz.blueskyweb.app',
config: { config: {

View File

@ -33,6 +33,10 @@ import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle'
import {router} from './routes' import {router} from './routes'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {useStores} from './state' 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 {HomeScreen} from './view/screens/Home'
import {SearchScreen} from './view/screens/Search' import {SearchScreen} from './view/screens/Search'
@ -62,11 +66,8 @@ import {AppPasswords} from 'view/screens/AppPasswords'
import {ModerationMutedAccounts} from 'view/screens/ModerationMutedAccounts' import {ModerationMutedAccounts} from 'view/screens/ModerationMutedAccounts'
import {ModerationBlockedAccounts} from 'view/screens/ModerationBlockedAccounts' import {ModerationBlockedAccounts} from 'view/screens/ModerationBlockedAccounts'
import {SavedFeeds} from 'view/screens/SavedFeeds' 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 {PreferencesHomeFeed} from 'view/screens/PreferencesHomeFeed'
import {PreferencesThreads} from 'view/screens/PreferencesThreads'
const navigationRef = createNavigationContainerRef<AllNavigatorParams>() const navigationRef = createNavigationContainerRef<AllNavigatorParams>()
@ -219,6 +220,11 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
component={PreferencesHomeFeed} component={PreferencesHomeFeed}
options={{title: title('Home Feed Preferences')}} options={{title: title('Home Feed Preferences')}}
/> />
<Stack.Screen
name="PreferencesThreads"
component={PreferencesThreads}
options={{title: title('Threads Preferences')}}
/>
</> </>
) )
} }

View File

@ -29,6 +29,7 @@ export type CommonNavigatorParams = {
AppPasswords: undefined AppPasswords: undefined
SavedFeeds: undefined SavedFeeds: undefined
PreferencesHomeFeed: undefined PreferencesHomeFeed: undefined
PreferencesThreads: undefined
} }
export type BottomTabNavigatorParams = CommonNavigatorParams & { export type BottomTabNavigatorParams = CommonNavigatorParams & {

View File

@ -23,6 +23,7 @@ export const router = new Router({
Log: '/sys/log', Log: '/sys/log',
AppPasswords: '/settings/app-passwords', AppPasswords: '/settings/app-passwords',
PreferencesHomeFeed: '/settings/home-feed', PreferencesHomeFeed: '/settings/home-feed',
PreferencesThreads: '/settings/threads',
SavedFeeds: '/settings/saved-feeds', SavedFeeds: '/settings/saved-feeds',
Support: '/support', Support: '/support',
PrivacyPolicy: '/support/privacy', PrivacyPolicy: '/support/privacy',

View File

@ -241,7 +241,7 @@ export class PostThreadModel {
res.data.thread as AppBskyFeedDefs.ThreadViewPost, res.data.thread as AppBskyFeedDefs.ThreadViewPost,
thread.uri, thread.uri,
) )
sortThread(thread) sortThread(thread, this.rootStore.preferences)
this.thread = thread this.thread = thread
} }
} }
@ -263,11 +263,16 @@ function pruneReplies(post: MaybePost) {
} }
} }
interface SortSettings {
threadDefaultSort: string
threadFollowedUsersFirst: boolean
}
type MaybeThreadItem = type MaybeThreadItem =
| PostThreadItemModel | PostThreadItemModel
| AppBskyFeedDefs.NotFoundPost | AppBskyFeedDefs.NotFoundPost
| AppBskyFeedDefs.BlockedPost | AppBskyFeedDefs.BlockedPost
function sortThread(item: MaybeThreadItem) { function sortThread(item: MaybeThreadItem, opts: SortSettings) {
if ('notFound' in item) { if ('notFound' in item) {
return return
} }
@ -296,13 +301,31 @@ function sortThread(item: MaybeThreadItem) {
if (modScore(a.moderation) !== modScore(b.moderation)) { if (modScore(a.moderation) !== modScore(b.moderation)) {
return modScore(a.moderation) - modScore(b.moderation) return modScore(a.moderation) - modScore(b.moderation)
} }
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) { if (a.post.likeCount === b.post.likeCount) {
return b.post.indexedAt.localeCompare(a.post.indexedAt) // newest return b.post.indexedAt.localeCompare(a.post.indexedAt) // newest
} else { } else {
return (b.post.likeCount || 0) - (a.post.likeCount || 0) // most likes 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))
} }
} }

View File

@ -2,10 +2,12 @@ import {makeAutoObservable} from 'mobx'
import {RootStoreModel} from '../root-store' import {RootStoreModel} from '../root-store'
import {hasProp} from 'lib/type-guards' import {hasProp} from 'lib/type-guards'
import {track} from 'lib/analytics/analytics' import {track} from 'lib/analytics/analytics'
import {SuggestedActorsModel} from './suggested-actors'
export const OnboardingScreenSteps = { export const OnboardingScreenSteps = {
Welcome: 'Welcome', Welcome: 'Welcome',
RecommendedFeeds: 'RecommendedFeeds', RecommendedFeeds: 'RecommendedFeeds',
RecommendedFollows: 'RecommendedFollows',
Home: 'Home', Home: 'Home',
} as const } as const
@ -16,7 +18,11 @@ export class OnboardingModel {
// state // state
step: OnboardingStep = 'Home' // default state to skip onboarding, only enabled for new users by calling start() step: OnboardingStep = 'Home' // default state to skip onboarding, only enabled for new users by calling start()
// data
suggestedActors: SuggestedActorsModel
constructor(public rootStore: RootStoreModel) { constructor(public rootStore: RootStoreModel) {
this.suggestedActors = new SuggestedActorsModel(this.rootStore)
makeAutoObservable(this, { makeAutoObservable(this, {
rootStore: false, rootStore: false,
hydrate: false, hydrate: false,
@ -56,6 +62,11 @@ export class OnboardingModel {
this.step = 'RecommendedFeeds' this.step = 'RecommendedFeeds'
return this.step return this.step
} else if (this.step === 'RecommendedFeeds') { } 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() this.finish()
return this.step return this.step
} else { } else {

View File

@ -19,6 +19,7 @@ export class SuggestedActorsModel {
loadMoreCursor: string | undefined = undefined loadMoreCursor: string | undefined = undefined
error = '' error = ''
hasMore = false hasMore = false
lastInsertedAtIndex = -1
// data // data
suggestions: SuggestedActor[] = [] 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 // state transitions
// = // =

View File

@ -8,6 +8,11 @@ export type MyFeedsItem =
_reactKey: string _reactKey: string
type: 'spinner' type: 'spinner'
} }
| {
_reactKey: string
type: 'saved-feeds-loading'
numItems: number
}
| { | {
_reactKey: string _reactKey: string
type: 'discover-feeds-loading' type: 'discover-feeds-loading'
@ -91,7 +96,8 @@ export class MyFeedsUIModel {
if (this.saved.isLoading) { if (this.saved.isLoading) {
items.push({ items.push({
_reactKey: '__saved_feeds_loading__', _reactKey: '__saved_feeds_loading__',
type: 'spinner', type: 'saved-feeds-loading',
numItems: this.rootStore.preferences.savedFeeds.length || 3,
}) })
} else if (this.saved.hasError) { } else if (this.saved.hasError) {
items.push({ items.push({

View File

@ -25,6 +25,7 @@ const VISIBILITY_VALUES = ['ignore', 'warn', 'hide']
const DEFAULT_LANG_CODES = (deviceLocales || []) const DEFAULT_LANG_CODES = (deviceLocales || [])
.concat(['en', 'ja', 'pt', 'de']) .concat(['en', 'ja', 'pt', 'de'])
.slice(0, 6) .slice(0, 6)
const THREAD_SORT_VALUES = ['oldest', 'newest', 'most-likes', 'random']
export class LabelPreferencesModel { export class LabelPreferencesModel {
nsfw: LabelPreference = 'hide' nsfw: LabelPreference = 'hide'
@ -55,6 +56,9 @@ export class PreferencesModel {
homeFeedRepostsEnabled: boolean = true homeFeedRepostsEnabled: boolean = true
homeFeedQuotePostsEnabled: boolean = true homeFeedQuotePostsEnabled: boolean = true
homeFeedMergeFeedEnabled: boolean = false homeFeedMergeFeedEnabled: boolean = false
threadDefaultSort: string = 'oldest'
threadFollowedUsersFirst: boolean = true
threadTreeViewEnabled: boolean = false
requireAltTextEnabled: boolean = false requireAltTextEnabled: boolean = false
// used to linearize async modifications to state // used to linearize async modifications to state
@ -86,6 +90,9 @@ export class PreferencesModel {
homeFeedRepostsEnabled: this.homeFeedRepostsEnabled, homeFeedRepostsEnabled: this.homeFeedRepostsEnabled,
homeFeedQuotePostsEnabled: this.homeFeedQuotePostsEnabled, homeFeedQuotePostsEnabled: this.homeFeedQuotePostsEnabled,
homeFeedMergeFeedEnabled: this.homeFeedMergeFeedEnabled, homeFeedMergeFeedEnabled: this.homeFeedMergeFeedEnabled,
threadDefaultSort: this.threadDefaultSort,
threadFollowedUsersFirst: this.threadFollowedUsersFirst,
threadTreeViewEnabled: this.threadTreeViewEnabled,
requireAltTextEnabled: this.requireAltTextEnabled, requireAltTextEnabled: this.requireAltTextEnabled,
} }
} }
@ -189,6 +196,28 @@ export class PreferencesModel {
) { ) {
this.homeFeedMergeFeedEnabled = v.homeFeedMergeFeedEnabled 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 // check if requiring alt text is enabled in preferences, then hydrate
if ( if (
hasProp(v, 'requireAltTextEnabled') && hasProp(v, 'requireAltTextEnabled') &&
@ -494,6 +523,20 @@ export class PreferencesModel {
this.homeFeedMergeFeedEnabled = !this.homeFeedMergeFeedEnabled 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() { toggleRequireAltTextEnabled() {
this.requireAltTextEnabled = !this.requireAltTextEnabled this.requireAltTextEnabled = !this.requireAltTextEnabled
} }

View File

@ -7,6 +7,7 @@ import {usePalette} from 'lib/hooks/usePalette'
import {useStores} from 'state/index' import {useStores} from 'state/index'
import {Welcome} from './onboarding/Welcome' import {Welcome} from './onboarding/Welcome'
import {RecommendedFeeds} from './onboarding/RecommendedFeeds' import {RecommendedFeeds} from './onboarding/RecommendedFeeds'
import {RecommendedFollows} from './onboarding/RecommendedFollows'
export const Onboarding = observer(function OnboardingImpl() { export const Onboarding = observer(function OnboardingImpl() {
const pal = usePalette('default') const pal = usePalette('default')
@ -28,6 +29,9 @@ export const Onboarding = observer(function OnboardingImpl() {
{store.onboarding.step === 'RecommendedFeeds' && ( {store.onboarding.step === 'RecommendedFeeds' && (
<RecommendedFeeds next={next} /> <RecommendedFeeds next={next} />
)} )}
{store.onboarding.step === 'RecommendedFollows' && (
<RecommendedFollows next={next} />
)}
</ErrorBoundary> </ErrorBoundary>
</SafeAreaView> </SafeAreaView>
) )

View File

@ -96,7 +96,7 @@ export const RecommendedFeeds = observer(function RecommendedFeedsImpl({
<Text <Text
type="2xl-medium" type="2xl-medium"
style={{color: '#fff', position: 'relative', top: -1}}> style={{color: '#fff', position: 'relative', top: -1}}>
Done Next
</Text> </Text>
<FontAwesomeIcon icon="angle-right" color="#fff" size={14} /> <FontAwesomeIcon icon="angle-right" color="#fff" size={14} />
</View> </View>
@ -215,6 +215,7 @@ const mStyles = StyleSheet.create({
marginBottom: 16, marginBottom: 16,
marginHorizontal: 16, marginHorizontal: 16,
marginTop: 16, marginTop: 16,
alignItems: 'center',
}, },
buttonText: { buttonText: {
textAlign: 'center', textAlign: 'center',

View File

@ -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,
},
})

View File

@ -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,
},
})

View File

@ -88,6 +88,7 @@ export const WelcomeMobile = observer(function WelcomeMobileImpl({
onPress={next} onPress={next}
label="Continue" label="Continue"
testID="continueBtn" testID="continueBtn"
style={[styles.buttonContainer]}
labelStyle={styles.buttonText} labelStyle={styles.buttonText}
/> />
</View> </View>
@ -117,6 +118,9 @@ const styles = StyleSheet.create({
spacer: { spacer: {
height: 20, height: 20,
}, },
buttonContainer: {
alignItems: 'center',
},
buttonText: { buttonText: {
textAlign: 'center', textAlign: 'center',
fontSize: 18, fontSize: 18,

View File

@ -285,11 +285,6 @@ export const ComposePost = observer(function ComposePost({
</View> </View>
)} )}
</View> </View>
{isProcessing ? (
<View style={[pal.btn, styles.processingLine]}>
<Text style={pal.text}>{processingState}</Text>
</View>
) : undefined}
{store.preferences.requireAltTextEnabled && gallery.needsAltText && ( {store.preferences.requireAltTextEnabled && gallery.needsAltText && (
<View style={[styles.reminderLine, pal.viewLight]}> <View style={[styles.reminderLine, pal.viewLight]}>
<View style={styles.errorIcon}> <View style={styles.errorIcon}>
@ -374,6 +369,12 @@ export const ComposePost = observer(function ComposePost({
</View> </View>
) : undefined} ) : undefined}
</ScrollView> </ScrollView>
{isProcessing ? (
<View style={[pal.viewLight, styles.processingLine]}>
<ActivityIndicator />
<Text style={pal.textLight}>{processingState}</Text>
</View>
) : undefined}
{!extLink && suggestedLinks.size > 0 ? ( {!extLink && suggestedLinks.size > 0 ? (
<View style={s.mb5}> <View style={s.mb5}>
{Array.from(suggestedLinks) {Array.from(suggestedLinks)
@ -435,11 +436,11 @@ const styles = StyleSheet.create({
paddingVertical: 6, paddingVertical: 6,
}, },
processingLine: { processingLine: {
borderRadius: 6, flexDirection: 'row',
paddingHorizontal: 8, alignItems: 'center',
paddingVertical: 6, gap: 8,
marginHorizontal: 15, paddingHorizontal: 26,
marginBottom: 6, paddingVertical: 12,
}, },
errorLine: { errorLine: {
flexDirection: 'row', flexDirection: 'row',

View File

@ -34,6 +34,7 @@ const ImageDefaultHeader = ({onRequestClose}: Props) => (
const styles = StyleSheet.create({ const styles = StyleSheet.create({
root: { root: {
alignItems: 'flex-end', alignItems: 'flex-end',
pointerEvents: 'box-none',
}, },
closeButton: { closeButton: {
marginRight: 8, marginRight: 8,

View File

@ -32,6 +32,7 @@ const SWIPE_CLOSE_VELOCITY = 1
const SCREEN = Dimensions.get('screen') const SCREEN = Dimensions.get('screen')
const SCREEN_WIDTH = SCREEN.width const SCREEN_WIDTH = SCREEN.width
const SCREEN_HEIGHT = SCREEN.height const SCREEN_HEIGHT = SCREEN.height
const MAX_SCALE = 2
type Props = { type Props = {
imageSrc: ImageSource imageSrc: ImageSource
@ -58,13 +59,18 @@ const ImageItem = ({
const [loaded, setLoaded] = useState(false) const [loaded, setLoaded] = useState(false)
const [scaled, setScaled] = useState(false) const [scaled, setScaled] = useState(false)
const imageDimensions = useImageDimensions(imageSrc) const imageDimensions = useImageDimensions(imageSrc)
const handleDoubleTap = useDoubleTapToZoom(scrollViewRef, scaled, SCREEN) const handleDoubleTap = useDoubleTapToZoom(
scrollViewRef,
scaled,
SCREEN,
imageDimensions,
)
const [translate, scale] = getImageTransform(imageDimensions, SCREEN) const [translate, scale] = getImageTransform(imageDimensions, SCREEN)
const scrollValueY = new Animated.Value(0) const scrollValueY = new Animated.Value(0)
const scaleValue = new Animated.Value(scale || 1) const scaleValue = new Animated.Value(scale || 1)
const translateValue = new Animated.ValueXY(translate) 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({ const imageOpacity = scrollValueY.interpolate({
inputRange: [-SWIPE_CLOSE_OFFSET, 0, SWIPE_CLOSE_OFFSET], inputRange: [-SWIPE_CLOSE_OFFSET, 0, SWIPE_CLOSE_OFFSET],
@ -118,7 +124,7 @@ const ImageItem = ({
pinchGestureEnabled pinchGestureEnabled
showsHorizontalScrollIndicator={false} showsHorizontalScrollIndicator={false}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
maximumZoomScale={maxScale} maximumZoomScale={maxScrollViewZoom}
contentContainerStyle={styles.imageScrollContainer} contentContainerStyle={styles.imageScrollContainer}
scrollEnabled={swipeToCloseEnabled} scrollEnabled={swipeToCloseEnabled}
onScrollEndDrag={onScrollEndDrag} onScrollEndDrag={onScrollEndDrag}

View File

@ -12,6 +12,8 @@ import {ScrollView, NativeTouchEvent, NativeSyntheticEvent} from 'react-native'
import {Dimensions} from '../@types' import {Dimensions} from '../@types'
const DOUBLE_TAP_DELAY = 300 const DOUBLE_TAP_DELAY = 300
const MIN_ZOOM = 2
let lastTapTS: number | null = null let lastTapTS: number | null = null
/** /**
@ -22,41 +24,124 @@ function useDoubleTapToZoom(
scrollViewRef: React.RefObject<ScrollView>, scrollViewRef: React.RefObject<ScrollView>,
scaled: boolean, scaled: boolean,
screen: Dimensions, screen: Dimensions,
imageDimensions: Dimensions | null,
) { ) {
const handleDoubleTap = useCallback( const handleDoubleTap = useCallback(
(event: NativeSyntheticEvent<NativeTouchEvent>) => { (event: NativeSyntheticEvent<NativeTouchEvent>) => {
const nowTS = new Date().getTime() const nowTS = new Date().getTime()
const scrollResponderRef = scrollViewRef?.current?.getScrollResponder() const scrollResponderRef = scrollViewRef?.current?.getScrollResponder()
if (lastTapTS && nowTS - lastTapTS < DOUBLE_TAP_DELAY) { const getZoomRectAfterDoubleTap = (
const {pageX, pageY} = event.nativeEvent touchX: number,
let targetX = 0 touchY: number,
let targetY = 0 ): {
let targetWidth = screen.width x: number
let targetHeight = screen.height y: number
width: number
height: number
} => {
if (!imageDimensions) {
return {
x: 0,
y: 0,
width: screen.width,
height: screen.height,
}
}
// Zooming in // First, let's figure out how much we want to zoom in.
// TODO: Add more precise calculation of targetX, targetY based on touch // We want to try to zoom in at least close enough to get rid of black bars.
if (!scaled) { const imageAspect = imageDimensions.width / imageDimensions.height
targetX = pageX / 2 const screenAspect = screen.width / screen.height
targetY = pageY / 2 const zoom = Math.max(
targetWidth = screen.width / 2 imageAspect / screenAspect,
targetHeight = screen.height / 2 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) {
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 // @ts-ignore
scrollResponderRef?.scrollResponderZoomTo({ scrollResponderRef?.scrollResponderZoomTo({
x: targetX, ...nextZoomRect, // This rect is in screen coordinates
y: targetY,
width: targetWidth,
height: targetHeight,
animated: true, animated: true,
}) })
} else { } else {
lastTapTS = nowTS lastTapTS = nowTS
} }
}, },
[scaled, screen.height, screen.width, scrollViewRef], [imageDimensions, scaled, screen.height, screen.width, scrollViewRef],
) )
return handleDoubleTap return handleDoubleTap

View File

@ -1,4 +1,3 @@
/* eslint-disable react-hooks/exhaustive-deps */
/** /**
* Copyright (c) JOB TODAY S.A. and its affiliates. * Copyright (c) JOB TODAY S.A. and its affiliates.
* *
@ -7,19 +6,19 @@
* *
*/ */
import {useMemo, useEffect} from 'react' import {useEffect} from 'react'
import { import {
Animated, Animated,
Dimensions, Dimensions,
GestureResponderEvent, GestureResponderEvent,
GestureResponderHandlers, GestureResponderHandlers,
NativeTouchEvent, NativeTouchEvent,
PanResponder,
PanResponderGestureState, PanResponderGestureState,
} from 'react-native' } from 'react-native'
import {Position} from '../@types' import {Position} from '../@types'
import { import {
createPanResponder,
getDistanceBetweenTouches, getDistanceBetweenTouches,
getImageTranslate, getImageTranslate,
getImageDimensionsByTranslate, getImageDimensionsByTranslate,
@ -29,8 +28,10 @@ const SCREEN = Dimensions.get('window')
const SCREEN_WIDTH = SCREEN.width const SCREEN_WIDTH = SCREEN.width
const SCREEN_HEIGHT = SCREEN.height const SCREEN_HEIGHT = SCREEN.height
const MIN_DIMENSION = Math.min(SCREEN_WIDTH, 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 DOUBLE_TAP_DELAY = 300
const OUT_BOUND_MULTIPLIER = 0.75 const OUT_BOUND_MULTIPLIER = 0.75
@ -87,23 +88,56 @@ const usePanResponder = ({
return [top, left, bottom, right] return [top, left, bottom, right]
} }
const getTranslateInBounds = (translate: Position, scale: number) => { const getTransformAfterDoubleTap = (
const inBoundTranslate = {x: translate.x, y: translate.y} touchX: number,
const [topBound, leftBound, bottomBound, rightBound] = getBounds(scale) touchY: number,
): [number, Position] => {
let nextScale = initialScale
let nextTranslateX = initialTranslate.x
let nextTranslateY = initialTranslate.y
if (translate.x > leftBound) { // First, let's figure out how much we want to zoom in.
inBoundTranslate.x = leftBound // We want to try to zoom in at least close enough to get rid of black bars.
} else if (translate.x < rightBound) { const imageAspect = imageDimensions.width / imageDimensions.height
inBoundTranslate.x = rightBound 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 (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)
} }
if (translate.y > topBound) { return [
inBoundTranslate.y = topBound nextScale,
} else if (translate.y < bottomBound) { {
inBoundTranslate.y = bottomBound x: nextTranslateX,
} y: nextTranslateY,
},
return inBoundTranslate ]
} }
const fitsScreenByWidth = () => const fitsScreenByWidth = () =>
@ -125,8 +159,12 @@ const usePanResponder = ({
longPressHandlerRef && clearTimeout(longPressHandlerRef) longPressHandlerRef && clearTimeout(longPressHandlerRef)
} }
const handlers = { const panResponder = PanResponder.create({
onGrant: ( onStartShouldSetPanResponder: () => true,
onStartShouldSetPanResponderCapture: () => true,
onMoveShouldSetPanResponder: () => true,
onMoveShouldSetPanResponderCapture: () => true,
onPanResponderGrant: (
_: GestureResponderEvent, _: GestureResponderEvent,
gestureState: PanResponderGestureState, gestureState: PanResponderGestureState,
) => { ) => {
@ -138,7 +176,7 @@ const usePanResponder = ({
longPressHandlerRef = setTimeout(onLongPress, delayLongPress) longPressHandlerRef = setTimeout(onLongPress, delayLongPress)
}, },
onStart: ( onPanResponderStart: (
event: GestureResponderEvent, event: GestureResponderEvent,
gestureState: PanResponderGestureState, gestureState: PanResponderGestureState,
) => { ) => {
@ -157,25 +195,18 @@ const usePanResponder = ({
) )
if (doubleTapToZoomEnabled && isDoubleTapPerformed) { if (doubleTapToZoomEnabled && isDoubleTapPerformed) {
const isScaled = currentTranslate.x !== initialTranslate.x // currentScale !== initialScale; let nextScale = initialScale
const {pageX: touchX, pageY: touchY} = event.nativeEvent.touches[0] let nextTranslate = initialTranslate
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) const willZoom = currentScale === initialScale
if (willZoom) {
const {pageX: touchX, pageY: touchY} = event.nativeEvent.touches[0]
;[nextScale, nextTranslate] = getTransformAfterDoubleTap(
touchX,
touchY,
)
}
onZoom(willZoom)
Animated.parallel( Animated.parallel(
[ [
@ -206,7 +237,7 @@ const usePanResponder = ({
lastTapTS = Date.now() lastTapTS = Date.now()
} }
}, },
onMove: ( onPanResponderMove: (
event: GestureResponderEvent, event: GestureResponderEvent,
gestureState: PanResponderGestureState, gestureState: PanResponderGestureState,
) => { ) => {
@ -328,7 +359,7 @@ const usePanResponder = ({
tmpTranslate = {x: nextTranslateX, y: nextTranslateY} tmpTranslate = {x: nextTranslateX, y: nextTranslateY}
} }
}, },
onRelease: () => { onPanResponderRelease: () => {
cancelLongPressHandle() cancelLongPressHandle()
if (isDoubleTapPerformed) { if (isDoubleTapPerformed) {
@ -336,8 +367,8 @@ const usePanResponder = ({
} }
if (tmpScale > 0) { if (tmpScale > 0) {
if (tmpScale < initialScale || tmpScale > SCALE_MAX) { if (tmpScale < initialScale || tmpScale > MAX_SCALE) {
tmpScale = tmpScale < initialScale ? initialScale : SCALE_MAX tmpScale = tmpScale < initialScale ? initialScale : MAX_SCALE
Animated.timing(scaleValue, { Animated.timing(scaleValue, {
toValue: tmpScale, toValue: tmpScale,
duration: 100, duration: 100,
@ -390,9 +421,9 @@ const usePanResponder = ({
tmpTranslate = null tmpTranslate = null
} }
}, },
} onPanResponderTerminationRequest: () => false,
onShouldBlockNativeResponder: () => false,
const panResponder = useMemo(() => createPanResponder(handlers), [handlers]) })
return [panResponder.panHandlers, scaleValue, translateValue] return [panResponder.panHandlers, scaleValue, translateValue]
} }

View File

@ -189,6 +189,7 @@ const styles = StyleSheet.create({
width: '100%', width: '100%',
zIndex: 1, zIndex: 1,
top: 0, top: 0,
pointerEvents: 'box-none',
}, },
footer: { footer: {
position: 'absolute', position: 'absolute',

View File

@ -6,14 +6,7 @@
* *
*/ */
import { import {Animated, NativeTouchEvent} from 'react-native'
Animated,
GestureResponderEvent,
PanResponder,
PanResponderGestureState,
PanResponderInstance,
NativeTouchEvent,
} from 'react-native'
import {Dimensions, Position} from './@types' import {Dimensions, Position} from './@types'
type CacheStorageItem = {key: string; value: any} type CacheStorageItem = {key: string; value: any}
@ -131,40 +124,6 @@ export const getImageTranslateForScale = (
return getImageTranslate(targetImageDimensions, screen) 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 = ( export const getDistanceBetweenTouches = (
touches: NativeTouchEvent[], touches: NativeTouchEvent[],
): number => { ): number => {

View File

@ -55,6 +55,7 @@ const LOAD_MORE = {
const BOTTOM_COMPONENT = { const BOTTOM_COMPONENT = {
_reactKey: '__bottom_component__', _reactKey: '__bottom_component__',
_isHighlightedPost: false, _isHighlightedPost: false,
_showBorder: true,
} }
type YieldedItem = type YieldedItem =
| PostThreadItemModel | PostThreadItemModel
@ -69,10 +70,12 @@ export const PostThread = observer(function PostThread({
uri, uri,
view, view,
onPressReply, onPressReply,
treeView,
}: { }: {
uri: string uri: string
view: PostThreadModel view: PostThreadModel
onPressReply: () => void onPressReply: () => void
treeView: boolean
}) { }) {
const pal = usePalette('default') const pal = usePalette('default')
const {isTablet} = useWebMediaQueries() const {isTablet} = useWebMediaQueries()
@ -99,6 +102,13 @@ export const PostThread = observer(function PostThread({
} }
return [] return []
}, [view.isLoadingFromCache, view.thread, maxVisible]) }, [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( useSetTitle(
view.thread?.postRecord && view.thread?.postRecord &&
`${sanitizeDisplayName( `${sanitizeDisplayName(
@ -135,17 +145,16 @@ export const PostThread = observer(function PostThread({
return return
} }
const index = posts.findIndex(post => post._isHighlightedPost) if (highlightedPostIndex !== -1) {
if (index !== -1) {
ref.current?.scrollToIndex({ ref.current?.scrollToIndex({
index, index: highlightedPostIndex,
animated: false, animated: false,
viewPosition: 0, viewPosition: 0,
}) })
hasScrolledIntoView.current = true hasScrolledIntoView.current = true
} }
}, [ }, [
posts, highlightedPostIndex,
view.hasContent, view.hasContent,
view.isFromCache, view.isFromCache,
view.isLoadingFromCache, view.isLoadingFromCache,
@ -184,7 +193,14 @@ export const PostThread = observer(function PostThread({
</View> </View>
) )
} else if (item === REPLY_PROMPT) { } 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) { } else if (item === DELETED) {
return ( return (
<View style={[pal.border, pal.viewLight, styles.itemContainer]}> <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 // 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 // I could find to get a border positioned directly under the last item
// -prf // -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) { } else if (item === CHILD_SPINNER) {
return ( return (
<View style={styles.childSpinner}> <View style={styles.childSpinner}>
@ -240,12 +267,13 @@ export const PostThread = observer(function PostThread({
item={item} item={item}
onPostReply={onRefresh} onPostReply={onRefresh}
hasPrecedingItem={prev?._showChildReplyLine} hasPrecedingItem={prev?._showChildReplyLine}
treeView={treeView}
/> />
) )
} }
return <></> return <></>
}, },
[onRefresh, onPressReply, pal, posts, isTablet], [onRefresh, onPressReply, pal, posts, isTablet, treeView, showBottomBorder],
) )
// loading // loading
@ -377,7 +405,7 @@ function* flattenThread(
} }
} }
yield post yield post
if (isDesktopWeb && post._isHighlightedPost) { if (post._isHighlightedPost) {
yield REPLY_PROMPT yield REPLY_PROMPT
} }
if (post.replies?.length) { if (post.replies?.length) {
@ -411,8 +439,4 @@ const styles = StyleSheet.create({
paddingVertical: 10, paddingVertical: 10,
}, },
childSpinner: {}, childSpinner: {},
bottomSpacer: {
height: 400,
borderTopWidth: 1,
},
}) })

View File

@ -35,15 +35,18 @@ import {formatCount} from '../util/numeric/format'
import {TimeElapsed} from 'view/com/util/TimeElapsed' import {TimeElapsed} from 'view/com/util/TimeElapsed'
import {makeProfileLink} from 'lib/routes/links' import {makeProfileLink} from 'lib/routes/links'
import {isDesktopWeb} from 'platform/detection' import {isDesktopWeb} from 'platform/detection'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
export const PostThreadItem = observer(function PostThreadItem({ export const PostThreadItem = observer(function PostThreadItem({
item, item,
onPostReply, onPostReply,
hasPrecedingItem, hasPrecedingItem,
treeView,
}: { }: {
item: PostThreadItemModel item: PostThreadItemModel
onPostReply: () => void onPostReply: () => void
hasPrecedingItem: boolean hasPrecedingItem: boolean
treeView: boolean
}) { }) {
const pal = usePalette('default') const pal = usePalette('default')
const store = useStores() const store = useStores()
@ -389,25 +392,28 @@ export const PostThreadItem = observer(function PostThreadItem({
</> </>
) )
} else { } else {
const isThreadedChild = treeView && item._depth > 0
return ( return (
<> <PostOuterWrapper
item={item}
hasPrecedingItem={hasPrecedingItem}
treeView={treeView}>
<PostHider <PostHider
testID={`postThreadItem-by-${item.post.author.handle}`} testID={`postThreadItem-by-${item.post.author.handle}`}
href={itemHref} href={itemHref}
style={[ style={[pal.view]}
styles.outer,
pal.border,
pal.view,
item._showParentReplyLine && hasPrecedingItem && styles.noTopBorder,
styles.cursor,
]}
moderation={item.moderation.content}> moderation={item.moderation.content}>
<PostSandboxWarning /> <PostSandboxWarning />
<View <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}}> <View style={{width: 52}}>
{item._showParentReplyLine && ( {!isThreadedChild && item._showParentReplyLine && (
<View <View
style={[ style={[
styles.replyLine, styles.replyLine,
@ -431,7 +437,7 @@ export const PostThreadItem = observer(function PostThreadItem({
]}> ]}>
<View style={styles.layoutAvi}> <View style={styles.layoutAvi}>
<PreviewableUserAvatar <PreviewableUserAvatar
size={52} size={isThreadedChild ? 24 : 52}
did={item.post.author.did} did={item.post.author.did}
handle={item.post.author.handle} handle={item.post.author.handle}
avatar={item.post.author.avatar} avatar={item.post.author.avatar}
@ -444,7 +450,9 @@ export const PostThreadItem = observer(function PostThreadItem({
styles.replyLine, styles.replyLine,
{ {
flexGrow: 1, flexGrow: 1,
backgroundColor: pal.colors.replyLine, backgroundColor: isThreadedChild
? pal.colors.border
: pal.colors.replyLine,
marginTop: 4, marginTop: 4,
}, },
]} ]}
@ -464,7 +472,11 @@ export const PostThreadItem = observer(function PostThreadItem({
style={styles.alert} style={styles.alert}
/> />
{item.richText?.text ? ( {item.richText?.text ? (
<View style={styles.postTextContainer}> <View
style={[
styles.postTextContainer,
isThreadedChild && {paddingTop: 2},
]}>
<RichText <RichText
type="post-text" type="post-text"
richText={item.richText} richText={item.richText}
@ -508,30 +520,84 @@ export const PostThreadItem = observer(function PostThreadItem({
/> />
</View> </View>
</View> </View>
</PostHider>
{item._hasMore ? ( {item._hasMore ? (
<Link <Link
style={[ style={[
styles.loadMore, styles.loadMore,
{borderTopColor: pal.colors.border}, {
pal.view, paddingLeft: treeView ? 44 : 70,
paddingTop: 0,
paddingBottom: treeView ? 4 : 12,
},
]} ]}
href={itemHref} href={itemHref}
title={itemTitle} title={itemTitle}
noFeedback> noFeedback>
<Text style={pal.link}>Continue thread...</Text> <Text type="sm-medium" style={pal.textLight}>
More
</Text>
<FontAwesomeIcon <FontAwesomeIcon
icon="angle-right" icon="angle-right"
style={pal.link as FontAwesomeIconStyle} color={pal.colors.textLight}
size={18} size={14}
/> />
</Link> </Link>
) : undefined} ) : undefined}
</> </PostHider>
</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({ function ExpandedPostDetails({
post, post,
needsTranslation, needsTranslation,
@ -600,7 +666,7 @@ const styles = StyleSheet.create({
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
flexWrap: 'wrap', flexWrap: 'wrap',
paddingBottom: 8, paddingBottom: 4,
paddingRight: 10, paddingRight: 10,
}, },
postTextLargeContainer: { postTextLargeContainer: {
@ -629,11 +695,10 @@ const styles = StyleSheet.create({
}, },
loadMore: { loadMore: {
flexDirection: 'row', flexDirection: 'row',
justifyContent: 'space-between', alignItems: 'center',
borderTopWidth: 1, justifyContent: 'flex-start',
paddingLeft: 80, gap: 4,
paddingRight: 20, paddingHorizontal: 20,
paddingVertical: 12,
}, },
replyLine: { replyLine: {
width: 2, width: 2,

View File

@ -1,5 +1,5 @@
import React from 'react' import React from 'react'
import {View} from 'react-native' import {StyleProp, TextStyle, View} from 'react-native'
import {observer} from 'mobx-react-lite' import {observer} from 'mobx-react-lite'
import {Button, ButtonType} from '../util/forms/Button' import {Button, ButtonType} from '../util/forms/Button'
import {useStores} from 'state/index' import {useStores} from 'state/index'
@ -11,11 +11,13 @@ export const FollowButton = observer(function FollowButtonImpl({
followedType = 'default', followedType = 'default',
did, did,
onToggleFollow, onToggleFollow,
labelStyle,
}: { }: {
unfollowedType?: ButtonType unfollowedType?: ButtonType
followedType?: ButtonType followedType?: ButtonType
did: string did: string
onToggleFollow?: (v: boolean) => void onToggleFollow?: (v: boolean) => void
labelStyle?: StyleProp<TextStyle>
}) { }) {
const store = useStores() const store = useStores()
const followState = store.me.follows.getFollowState(did) 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) const updatedFollowState = await store.me.follows.fetchFollowState(did)
if (updatedFollowState === FollowState.Following) { if (updatedFollowState === FollowState.Following) {
try { try {
onToggleFollow?.(false)
await store.agent.deleteFollow(store.me.follows.getFollowUri(did)) await store.agent.deleteFollow(store.me.follows.getFollowUri(did))
store.me.follows.removeFollow(did) store.me.follows.removeFollow(did)
onToggleFollow?.(false)
} catch (e: any) { } catch (e: any) {
store.log.error('Failed to delete follow', e) store.log.error('Failed to delete follow', e)
Toast.show('An issue occurred, please try again.') Toast.show('An issue occurred, please try again.')
} }
} else if (updatedFollowState === FollowState.NotFollowing) { } else if (updatedFollowState === FollowState.NotFollowing) {
try { try {
onToggleFollow?.(true)
const res = await store.agent.follow(did) const res = await store.agent.follow(did)
store.me.follows.addFollow(did, res.uri) store.me.follows.addFollow(did, res.uri)
onToggleFollow?.(true)
} catch (e: any) { } catch (e: any) {
store.log.error('Failed to create follow', e) store.log.error('Failed to create follow', e)
Toast.show('An issue occurred, please try again.') Toast.show('An issue occurred, please try again.')
@ -52,8 +54,10 @@ export const FollowButton = observer(function FollowButtonImpl({
type={ type={
followState === FollowState.Following ? followedType : unfollowedType followState === FollowState.Following ? followedType : unfollowedType
} }
labelStyle={labelStyle}
onPress={onToggleFollowInner} onPress={onToggleFollowInner}
label={followState === FollowState.Following ? 'Unfollow' : 'Follow'} label={followState === FollowState.Following ? 'Unfollow' : 'Follow'}
withLoading={true}
/> />
) )
}) })

View File

@ -7,6 +7,8 @@ import {
Pressable, Pressable,
ViewStyle, ViewStyle,
PressableStateCallbackType, PressableStateCallbackType,
ActivityIndicator,
View,
} from 'react-native' } from 'react-native'
import {Text} from '../text/Text' import {Text} from '../text/Text'
import {useTheme} from 'lib/ThemeContext' import {useTheme} from 'lib/ThemeContext'
@ -48,17 +50,19 @@ export function Button({
accessibilityHint, accessibilityHint,
accessibilityLabelledBy, accessibilityLabelledBy,
onAccessibilityEscape, onAccessibilityEscape,
withLoading = false,
}: React.PropsWithChildren<{ }: React.PropsWithChildren<{
type?: ButtonType type?: ButtonType
label?: string label?: string
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
labelStyle?: StyleProp<TextStyle> labelStyle?: StyleProp<TextStyle>
onPress?: () => void onPress?: () => void | Promise<void>
testID?: string testID?: string
accessibilityLabel?: string accessibilityLabel?: string
accessibilityHint?: string accessibilityHint?: string
accessibilityLabelledBy?: string accessibilityLabelledBy?: string
onAccessibilityEscape?: () => void onAccessibilityEscape?: () => void
withLoading?: boolean
}>) { }>) {
const theme = useTheme() const theme = useTheme()
const typeOuterStyle = choose<ViewStyle, Record<ButtonType, ViewStyle>>( const typeOuterStyle = choose<ViewStyle, Record<ButtonType, ViewStyle>>(
@ -138,13 +142,16 @@ export function Button({
}, },
) )
const [isLoading, setIsLoading] = React.useState(false)
const onPressWrapped = React.useCallback( const onPressWrapped = React.useCallback(
(event: Event) => { async (event: Event) => {
event.stopPropagation() event.stopPropagation()
event.preventDefault() event.preventDefault()
onPress?.() withLoading && setIsLoading(true)
await onPress?.()
withLoading && setIsLoading(false)
}, },
[onPress], [onPress, withLoading],
) )
const getStyle = React.useCallback( const getStyle = React.useCallback(
@ -160,23 +167,35 @@ export function Button({
[typeOuterStyle, style], [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 ( return (
<Pressable <Pressable
style={getStyle} style={getStyle}
onPress={onPressWrapped} onPress={onPressWrapped}
disabled={isLoading}
testID={testID} testID={testID}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={accessibilityLabel} accessibilityLabel={accessibilityLabel}
accessibilityHint={accessibilityHint} accessibilityHint={accessibilityHint}
accessibilityLabelledBy={accessibilityLabelledBy} accessibilityLabelledBy={accessibilityLabelledBy}
onAccessibilityEscape={onAccessibilityEscape}> onAccessibilityEscape={onAccessibilityEscape}>
{label ? ( {renderChildern}
<Text type="button" style={[typeLabelStyle, labelStyle]}>
{label}
</Text>
) : (
children
)}
</Pressable> </Pressable>
) )
} }
@ -187,4 +206,8 @@ const styles = StyleSheet.create({
paddingVertical: 8, paddingVertical: 8,
borderRadius: 24, borderRadius: 24,
}, },
labelContainer: {
flexDirection: 'row',
gap: 8,
},
}) })

View File

@ -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 {faClone as farClone} from '@fortawesome/free-regular-svg-icons/faClone'
import {faComment} from '@fortawesome/free-regular-svg-icons/faComment' import {faComment} from '@fortawesome/free-regular-svg-icons/faComment'
import {faCommentSlash} from '@fortawesome/free-solid-svg-icons/faCommentSlash' 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 {faCompass} from '@fortawesome/free-regular-svg-icons/faCompass'
import {faEllipsis} from '@fortawesome/free-solid-svg-icons/faEllipsis' import {faEllipsis} from '@fortawesome/free-solid-svg-icons/faEllipsis'
import {faEnvelope} from '@fortawesome/free-solid-svg-icons/faEnvelope' 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 {faEyeSlash as farEyeSlash} from '@fortawesome/free-regular-svg-icons/faEyeSlash'
import {faFaceSmile} from '@fortawesome/free-regular-svg-icons/faFaceSmile' import {faFaceSmile} from '@fortawesome/free-regular-svg-icons/faFaceSmile'
import {faFire} from '@fortawesome/free-solid-svg-icons/faFire' 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 {faFloppyDisk} from '@fortawesome/free-regular-svg-icons/faFloppyDisk'
import {faGear} from '@fortawesome/free-solid-svg-icons/faGear' import {faGear} from '@fortawesome/free-solid-svg-icons/faGear'
import {faGlobe} from '@fortawesome/free-solid-svg-icons/faGlobe' import {faGlobe} from '@fortawesome/free-solid-svg-icons/faGlobe'
@ -134,6 +136,7 @@ export function setup() {
farClone, farClone,
faComment, faComment,
faCommentSlash, faCommentSlash,
faComments,
faCompass, faCompass,
faEllipsis, faEllipsis,
faEnvelope, faEnvelope,
@ -142,6 +145,7 @@ export function setup() {
farEyeSlash, farEyeSlash,
faFaceSmile, faFaceSmile,
faFire, faFire,
faFlask,
faFloppyDisk, faFloppyDisk,
faGear, faGear,
faGlobe, faGlobe,

View File

@ -185,6 +185,17 @@ export const CustomFeedScreenInner = observer(
}) })
}, [store, currentFeed]) }, [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(() => { const onPressViewAuthor = React.useCallback(() => {
navigation.navigate('Profile', {name: handleOrDid}) navigation.navigate('Profile', {name: handleOrDid})
}, [handleOrDid, navigation]) }, [handleOrDid, navigation])
@ -233,7 +244,21 @@ export const CustomFeedScreenInner = observer(
}, [store, onSoftReset, isScreenFocused]) }, [store, onSoftReset, isScreenFocused])
const dropdownItems: DropdownItem[] = React.useMemo(() => { 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', testID: 'feedHeaderDropdownViewAuthorBtn',
label: 'View author', label: 'View author',
@ -292,10 +317,10 @@ export const CustomFeedScreenInner = observer(
web: 'share', web: 'share',
}, },
}, },
] ].filter(Boolean) as DropdownItem[]
return items
}, [ }, [
currentFeed?.isSaved, currentFeed,
onPressAbout,
onToggleSaved, onToggleSaved,
onPressReport, onPressReport,
onPressShare, onPressShare,

View File

@ -16,7 +16,10 @@ import {ComposeIcon2, CogIcon} from 'lib/icons'
import {s} from 'lib/styles' import {s} from 'lib/styles'
import {SearchInput} from 'view/com/util/forms/SearchInput' import {SearchInput} from 'view/com/util/forms/SearchInput'
import {UserAvatar} from 'view/com/util/UserAvatar' 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 {ErrorMessage} from 'view/com/util/error/ErrorMessage'
import debounce from 'lodash.debounce' import debounce from 'lodash.debounce'
import {Text} from 'view/com/util/text/Text' import {Text} from 'view/com/util/text/Text'
@ -42,7 +45,12 @@ export const FeedsScreen = withAuthRequired(
React.useCallback(() => { React.useCallback(() => {
store.shell.setMinimalShellMode(false) store.shell.setMinimalShellMode(false)
myFeeds.setup() myFeeds.setup()
}, [store.shell, myFeeds]),
const softResetSub = store.onScreenSoftReset(() => myFeeds.refresh())
return () => {
softResetSub.remove()
}
}, [store, myFeeds]),
) )
const onPressCompose = React.useCallback(() => { const onPressCompose = React.useCallback(() => {
@ -119,6 +127,14 @@ export const FeedsScreen = withAuthRequired(
) )
} }
return <View /> 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') { } else if (item.type === 'saved-feed') {
return ( return (
<SavedFeed <SavedFeed
@ -262,10 +278,7 @@ function SavedFeed({
asAnchor asAnchor
anchorNoUnderline> anchorNoUnderline>
<UserAvatar type="algo" size={28} avatar={avatar} /> <UserAvatar type="algo" size={28} avatar={avatar} />
<Text <Text type="lg-medium" style={[pal.text, s.flex1]} numberOfLines={1}>
type={isMobile ? 'lg' : 'lg-medium'}
style={[pal.text, s.flex1]}
numberOfLines={1}>
{displayName} {displayName}
</Text> </Text>
{isMobile && ( {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({ const styles = StyleSheet.create({
container: { container: {
flex: 1, flex: 1,

View File

@ -74,6 +74,7 @@ export const PostThreadScreen = withAuthRequired(({route}: Props) => {
uri={uri} uri={uri}
view={view} view={view}
onPressReply={onPressReply} onPressReply={onPressReply}
treeView={store.preferences.threadTreeViewEnabled}
/> />
</View> </View>
{isMobile && ( {isMobile && (

View File

@ -1,6 +1,7 @@
import React, {useState} from 'react' import React, {useState} from 'react'
import {ScrollView, StyleSheet, TouchableOpacity, View} from 'react-native' import {ScrollView, StyleSheet, TouchableOpacity, View} from 'react-native'
import {observer} from 'mobx-react-lite' import {observer} from 'mobx-react-lite'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {Slider} from '@miblanchard/react-native-slider' import {Slider} from '@miblanchard/react-native-slider'
import {Text} from '../com/util/text/Text' import {Text} from '../com/util/text/Text'
import {useStores} from 'state/index' import {useStores} from 'state/index'
@ -66,7 +67,10 @@ export const PreferencesHomeFeed = observer(function PreferencesHomeFeedImpl({
]}> ]}>
<ViewHeader title="Home Feed Preferences" showOnDesktop /> <ViewHeader title="Home Feed Preferences" showOnDesktop />
<View <View
style={[styles.titleSection, isTabletOrDesktop && {paddingTop: 20}]}> style={[
styles.titleSection,
isTabletOrDesktop && {paddingTop: 20, paddingBottom: 20},
]}>
<Text type="xl" style={[pal.textLight, styles.description]}> <Text type="xl" style={[pal.textLight, styles.description]}>
Fine-tune the content you see on your home screen. Fine-tune the content you see on your home screen.
</Text> </Text>
@ -155,11 +159,12 @@ export const PreferencesHomeFeed = observer(function PreferencesHomeFeedImpl({
<View style={[pal.viewLight, styles.card]}> <View style={[pal.viewLight, styles.card]}>
<Text type="title-sm" style={[pal.text, s.pb5]}> <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>
<Text style={[pal.text, s.pb10]}> <Text style={[pal.text, s.pb10]}>
Set this setting to "Yes" to show samples of your saved feeds in 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> </Text>
<ToggleButton <ToggleButton
type="default-light" type="default-light"
@ -175,7 +180,7 @@ export const PreferencesHomeFeed = observer(function PreferencesHomeFeedImpl({
style={[ style={[
styles.btnContainer, styles.btnContainer,
!isTabletOrDesktop && {borderTopWidth: 1, paddingHorizontal: 20}, !isTabletOrDesktop && {borderTopWidth: 1, paddingHorizontal: 20},
pal.borderDark, pal.border,
]}> ]}>
<TouchableOpacity <TouchableOpacity
testID="confirmBtn" testID="confirmBtn"

View File

@ -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,
},
})

View File

@ -187,7 +187,9 @@ export const ProfileScreen = withAuthRequired(
/> />
) )
} else if (item instanceof CustomFeedModel) { } 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 // if section is posts or posts & replies
} else { } else {

View File

@ -180,6 +180,10 @@ export const SettingsScreen = withAuthRequired(
navigation.navigate('PreferencesHomeFeed') navigation.navigate('PreferencesHomeFeed')
}, [navigation]) }, [navigation])
const openThreadsPreferences = React.useCallback(() => {
navigation.navigate('PreferencesThreads')
}, [navigation])
const onPressAppPasswords = React.useCallback(() => { const onPressAppPasswords = React.useCallback(() => {
navigation.navigate('AppPasswords') navigation.navigate('AppPasswords')
}, [navigation]) }, [navigation])
@ -420,6 +424,24 @@ export const SettingsScreen = withAuthRequired(
Home Feed Preferences Home Feed Preferences
</Text> </Text>
</TouchableOpacity> </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 <TouchableOpacity
testID="savedFeedsBtn" testID="savedFeedsBtn"
style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]} style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]}