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
commit
5665968f72
|
@ -19,7 +19,7 @@ module.exports = function () {
|
|||
backgroundColor: '#ffffff',
|
||||
},
|
||||
ios: {
|
||||
buildNumber: '1',
|
||||
buildNumber: '2',
|
||||
supportsTablet: false,
|
||||
bundleIdentifier: 'xyz.blueskyweb.app',
|
||||
config: {
|
||||
|
|
|
@ -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')}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -29,6 +29,7 @@ export type CommonNavigatorParams = {
|
|||
AppPasswords: undefined
|
||||
SavedFeeds: undefined
|
||||
PreferencesHomeFeed: undefined
|
||||
PreferencesThreads: undefined
|
||||
}
|
||||
|
||||
export type BottomTabNavigatorParams = CommonNavigatorParams & {
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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 (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))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
// =
|
||||
|
||||
|
|
|
@ -8,6 +8,11 @@ export type MyFeedsItem =
|
|||
_reactKey: string
|
||||
type: 'spinner'
|
||||
}
|
||||
| {
|
||||
_reactKey: string
|
||||
type: 'saved-feeds-loading'
|
||||
numItems: number
|
||||
}
|
||||
| {
|
||||
_reactKey: string
|
||||
type: 'discover-feeds-loading'
|
||||
|
@ -91,7 +96,8 @@ export class MyFeedsUIModel {
|
|||
if (this.saved.isLoading) {
|
||||
items.push({
|
||||
_reactKey: '__saved_feeds_loading__',
|
||||
type: 'spinner',
|
||||
type: 'saved-feeds-loading',
|
||||
numItems: this.rootStore.preferences.savedFeeds.length || 3,
|
||||
})
|
||||
} else if (this.saved.hasError) {
|
||||
items.push({
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
})
|
|
@ -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,
|
||||
},
|
||||
})
|
|
@ -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,
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -34,6 +34,7 @@ const ImageDefaultHeader = ({onRequestClose}: Props) => (
|
|||
const styles = StyleSheet.create({
|
||||
root: {
|
||||
alignItems: 'flex-end',
|
||||
pointerEvents: 'box-none',
|
||||
},
|
||||
closeButton: {
|
||||
marginRight: 8,
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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()
|
||||
|
||||
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
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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) {
|
||||
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
|
||||
|
|
|
@ -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)
|
||||
const getTransformAfterDoubleTap = (
|
||||
touchX: number,
|
||||
touchY: number,
|
||||
): [number, Position] => {
|
||||
let nextScale = initialScale
|
||||
let nextTranslateX = initialTranslate.x
|
||||
let nextTranslateY = initialTranslate.y
|
||||
|
||||
if (translate.x > leftBound) {
|
||||
inBoundTranslate.x = leftBound
|
||||
} else if (translate.x < rightBound) {
|
||||
inBoundTranslate.x = rightBound
|
||||
// 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 (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) {
|
||||
inBoundTranslate.y = topBound
|
||||
} else if (translate.y < bottomBound) {
|
||||
inBoundTranslate.y = 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,
|
||||
)
|
||||
let nextScale = initialScale
|
||||
let nextTranslate = initialTranslate
|
||||
|
||||
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(
|
||||
[
|
||||
|
@ -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]
|
||||
}
|
||||
|
|
|
@ -189,6 +189,7 @@ const styles = StyleSheet.create({
|
|||
width: '100%',
|
||||
zIndex: 1,
|
||||
top: 0,
|
||||
pointerEvents: 'box-none',
|
||||
},
|
||||
footer: {
|
||||
position: 'absolute',
|
||||
|
|
|
@ -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 => {
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
})
|
||||
|
|
|
@ -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>
|
||||
</PostHider>
|
||||
{item._hasMore ? (
|
||||
<Link
|
||||
style={[
|
||||
styles.loadMore,
|
||||
{borderTopColor: pal.colors.border},
|
||||
pal.view,
|
||||
{
|
||||
paddingLeft: treeView ? 44 : 70,
|
||||
paddingTop: 0,
|
||||
paddingBottom: treeView ? 4 : 12,
|
||||
},
|
||||
]}
|
||||
href={itemHref}
|
||||
title={itemTitle}
|
||||
noFeedback>
|
||||
<Text style={pal.link}>Continue thread...</Text>
|
||||
<Text type="sm-medium" style={pal.textLight}>
|
||||
More
|
||||
</Text>
|
||||
<FontAwesomeIcon
|
||||
icon="angle-right"
|
||||
style={pal.link as FontAwesomeIconStyle}
|
||||
size={18}
|
||||
color={pal.colors.textLight}
|
||||
size={14}
|
||||
/>
|
||||
</Link>
|
||||
) : 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({
|
||||
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,
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
})
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -74,6 +74,7 @@ export const PostThreadScreen = withAuthRequired(({route}: Props) => {
|
|||
uri={uri}
|
||||
view={view}
|
||||
onPressReply={onPressReply}
|
||||
treeView={store.preferences.threadTreeViewEnabled}
|
||||
/>
|
||||
</View>
|
||||
{isMobile && (
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
})
|
|
@ -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 {
|
||||
|
|
|
@ -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])
|
||||
|
@ -420,6 +424,24 @@ export const SettingsScreen = withAuthRequired(
|
|||
Home Feed Preferences
|
||||
</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]}
|
||||
|
|
Loading…
Reference in New Issue