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',
},
ios: {
buildNumber: '1',
buildNumber: '2',
supportsTablet: false,
bundleIdentifier: 'xyz.blueskyweb.app',
config: {

View File

@ -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')}}
/>
</>
)
}

View File

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

View File

@ -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',

View File

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

View File

@ -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 {

View File

@ -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
// =

View File

@ -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({

View File

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

View File

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

View File

@ -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',

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

View File

@ -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',

View File

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

View File

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

View File

@ -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

View File

@ -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]
}

View File

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

View File

@ -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 => {

View File

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

View File

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

View File

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

View File

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

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 {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,

View File

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

View File

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

View File

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

View File

@ -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"

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) {
return <CustomFeed item={item} showSaveBtn showLikes />
return (
<CustomFeed item={item} showSaveBtn showLikes showDescription />
)
}
// if section is posts or posts & replies
} else {

View File

@ -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]}