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',
|
backgroundColor: '#ffffff',
|
||||||
},
|
},
|
||||||
ios: {
|
ios: {
|
||||||
buildNumber: '1',
|
buildNumber: '2',
|
||||||
supportsTablet: false,
|
supportsTablet: false,
|
||||||
bundleIdentifier: 'xyz.blueskyweb.app',
|
bundleIdentifier: 'xyz.blueskyweb.app',
|
||||||
config: {
|
config: {
|
||||||
|
|
|
@ -33,6 +33,10 @@ import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle'
|
||||||
import {router} from './routes'
|
import {router} from './routes'
|
||||||
import {usePalette} from 'lib/hooks/usePalette'
|
import {usePalette} from 'lib/hooks/usePalette'
|
||||||
import {useStores} from './state'
|
import {useStores} from './state'
|
||||||
|
import {getRoutingInstrumentation} from 'lib/sentry'
|
||||||
|
import {bskyTitle} from 'lib/strings/headings'
|
||||||
|
import {JSX} from 'react/jsx-runtime'
|
||||||
|
import {timeout} from 'lib/async/timeout'
|
||||||
|
|
||||||
import {HomeScreen} from './view/screens/Home'
|
import {HomeScreen} from './view/screens/Home'
|
||||||
import {SearchScreen} from './view/screens/Search'
|
import {SearchScreen} from './view/screens/Search'
|
||||||
|
@ -62,11 +66,8 @@ import {AppPasswords} from 'view/screens/AppPasswords'
|
||||||
import {ModerationMutedAccounts} from 'view/screens/ModerationMutedAccounts'
|
import {ModerationMutedAccounts} from 'view/screens/ModerationMutedAccounts'
|
||||||
import {ModerationBlockedAccounts} from 'view/screens/ModerationBlockedAccounts'
|
import {ModerationBlockedAccounts} from 'view/screens/ModerationBlockedAccounts'
|
||||||
import {SavedFeeds} from 'view/screens/SavedFeeds'
|
import {SavedFeeds} from 'view/screens/SavedFeeds'
|
||||||
import {getRoutingInstrumentation} from 'lib/sentry'
|
|
||||||
import {bskyTitle} from 'lib/strings/headings'
|
|
||||||
import {JSX} from 'react/jsx-runtime'
|
|
||||||
import {timeout} from 'lib/async/timeout'
|
|
||||||
import {PreferencesHomeFeed} from 'view/screens/PreferencesHomeFeed'
|
import {PreferencesHomeFeed} from 'view/screens/PreferencesHomeFeed'
|
||||||
|
import {PreferencesThreads} from 'view/screens/PreferencesThreads'
|
||||||
|
|
||||||
const navigationRef = createNavigationContainerRef<AllNavigatorParams>()
|
const navigationRef = createNavigationContainerRef<AllNavigatorParams>()
|
||||||
|
|
||||||
|
@ -219,6 +220,11 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
|
||||||
component={PreferencesHomeFeed}
|
component={PreferencesHomeFeed}
|
||||||
options={{title: title('Home Feed Preferences')}}
|
options={{title: title('Home Feed Preferences')}}
|
||||||
/>
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="PreferencesThreads"
|
||||||
|
component={PreferencesThreads}
|
||||||
|
options={{title: title('Threads Preferences')}}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -29,6 +29,7 @@ export type CommonNavigatorParams = {
|
||||||
AppPasswords: undefined
|
AppPasswords: undefined
|
||||||
SavedFeeds: undefined
|
SavedFeeds: undefined
|
||||||
PreferencesHomeFeed: undefined
|
PreferencesHomeFeed: undefined
|
||||||
|
PreferencesThreads: undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BottomTabNavigatorParams = CommonNavigatorParams & {
|
export type BottomTabNavigatorParams = CommonNavigatorParams & {
|
||||||
|
|
|
@ -23,6 +23,7 @@ export const router = new Router({
|
||||||
Log: '/sys/log',
|
Log: '/sys/log',
|
||||||
AppPasswords: '/settings/app-passwords',
|
AppPasswords: '/settings/app-passwords',
|
||||||
PreferencesHomeFeed: '/settings/home-feed',
|
PreferencesHomeFeed: '/settings/home-feed',
|
||||||
|
PreferencesThreads: '/settings/threads',
|
||||||
SavedFeeds: '/settings/saved-feeds',
|
SavedFeeds: '/settings/saved-feeds',
|
||||||
Support: '/support',
|
Support: '/support',
|
||||||
PrivacyPolicy: '/support/privacy',
|
PrivacyPolicy: '/support/privacy',
|
||||||
|
|
|
@ -241,7 +241,7 @@ export class PostThreadModel {
|
||||||
res.data.thread as AppBskyFeedDefs.ThreadViewPost,
|
res.data.thread as AppBskyFeedDefs.ThreadViewPost,
|
||||||
thread.uri,
|
thread.uri,
|
||||||
)
|
)
|
||||||
sortThread(thread)
|
sortThread(thread, this.rootStore.preferences)
|
||||||
this.thread = thread
|
this.thread = thread
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -263,11 +263,16 @@ function pruneReplies(post: MaybePost) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SortSettings {
|
||||||
|
threadDefaultSort: string
|
||||||
|
threadFollowedUsersFirst: boolean
|
||||||
|
}
|
||||||
|
|
||||||
type MaybeThreadItem =
|
type MaybeThreadItem =
|
||||||
| PostThreadItemModel
|
| PostThreadItemModel
|
||||||
| AppBskyFeedDefs.NotFoundPost
|
| AppBskyFeedDefs.NotFoundPost
|
||||||
| AppBskyFeedDefs.BlockedPost
|
| AppBskyFeedDefs.BlockedPost
|
||||||
function sortThread(item: MaybeThreadItem) {
|
function sortThread(item: MaybeThreadItem, opts: SortSettings) {
|
||||||
if ('notFound' in item) {
|
if ('notFound' in item) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -296,13 +301,31 @@ function sortThread(item: MaybeThreadItem) {
|
||||||
if (modScore(a.moderation) !== modScore(b.moderation)) {
|
if (modScore(a.moderation) !== modScore(b.moderation)) {
|
||||||
return modScore(a.moderation) - modScore(b.moderation)
|
return modScore(a.moderation) - modScore(b.moderation)
|
||||||
}
|
}
|
||||||
|
if (opts.threadFollowedUsersFirst) {
|
||||||
|
const af = a.post.author.viewer?.following
|
||||||
|
const bf = b.post.author.viewer?.following
|
||||||
|
if (af && !bf) {
|
||||||
|
return -1
|
||||||
|
} else if (!af && bf) {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (opts.threadDefaultSort === 'oldest') {
|
||||||
|
return a.post.indexedAt.localeCompare(b.post.indexedAt)
|
||||||
|
} else if (opts.threadDefaultSort === 'newest') {
|
||||||
|
return b.post.indexedAt.localeCompare(a.post.indexedAt)
|
||||||
|
} else if (opts.threadDefaultSort === 'most-likes') {
|
||||||
if (a.post.likeCount === b.post.likeCount) {
|
if (a.post.likeCount === b.post.likeCount) {
|
||||||
return b.post.indexedAt.localeCompare(a.post.indexedAt) // newest
|
return b.post.indexedAt.localeCompare(a.post.indexedAt) // newest
|
||||||
} else {
|
} else {
|
||||||
return (b.post.likeCount || 0) - (a.post.likeCount || 0) // most likes
|
return (b.post.likeCount || 0) - (a.post.likeCount || 0) // most likes
|
||||||
}
|
}
|
||||||
|
} else if (opts.threadDefaultSort === 'random') {
|
||||||
|
return 0.5 - Math.random() // this is vaguely criminal but we can get away with it
|
||||||
|
}
|
||||||
|
return b.post.indexedAt.localeCompare(a.post.indexedAt)
|
||||||
})
|
})
|
||||||
item.replies.forEach(reply => sortThread(reply))
|
item.replies.forEach(reply => sortThread(reply, opts))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -2,10 +2,12 @@ import {makeAutoObservable} from 'mobx'
|
||||||
import {RootStoreModel} from '../root-store'
|
import {RootStoreModel} from '../root-store'
|
||||||
import {hasProp} from 'lib/type-guards'
|
import {hasProp} from 'lib/type-guards'
|
||||||
import {track} from 'lib/analytics/analytics'
|
import {track} from 'lib/analytics/analytics'
|
||||||
|
import {SuggestedActorsModel} from './suggested-actors'
|
||||||
|
|
||||||
export const OnboardingScreenSteps = {
|
export const OnboardingScreenSteps = {
|
||||||
Welcome: 'Welcome',
|
Welcome: 'Welcome',
|
||||||
RecommendedFeeds: 'RecommendedFeeds',
|
RecommendedFeeds: 'RecommendedFeeds',
|
||||||
|
RecommendedFollows: 'RecommendedFollows',
|
||||||
Home: 'Home',
|
Home: 'Home',
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
|
@ -16,7 +18,11 @@ export class OnboardingModel {
|
||||||
// state
|
// state
|
||||||
step: OnboardingStep = 'Home' // default state to skip onboarding, only enabled for new users by calling start()
|
step: OnboardingStep = 'Home' // default state to skip onboarding, only enabled for new users by calling start()
|
||||||
|
|
||||||
|
// data
|
||||||
|
suggestedActors: SuggestedActorsModel
|
||||||
|
|
||||||
constructor(public rootStore: RootStoreModel) {
|
constructor(public rootStore: RootStoreModel) {
|
||||||
|
this.suggestedActors = new SuggestedActorsModel(this.rootStore)
|
||||||
makeAutoObservable(this, {
|
makeAutoObservable(this, {
|
||||||
rootStore: false,
|
rootStore: false,
|
||||||
hydrate: false,
|
hydrate: false,
|
||||||
|
@ -56,6 +62,11 @@ export class OnboardingModel {
|
||||||
this.step = 'RecommendedFeeds'
|
this.step = 'RecommendedFeeds'
|
||||||
return this.step
|
return this.step
|
||||||
} else if (this.step === 'RecommendedFeeds') {
|
} else if (this.step === 'RecommendedFeeds') {
|
||||||
|
this.step = 'RecommendedFollows'
|
||||||
|
// prefetch recommended follows
|
||||||
|
this.suggestedActors.loadMore(true)
|
||||||
|
return this.step
|
||||||
|
} else if (this.step === 'RecommendedFollows') {
|
||||||
this.finish()
|
this.finish()
|
||||||
return this.step
|
return this.step
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -19,6 +19,7 @@ export class SuggestedActorsModel {
|
||||||
loadMoreCursor: string | undefined = undefined
|
loadMoreCursor: string | undefined = undefined
|
||||||
error = ''
|
error = ''
|
||||||
hasMore = false
|
hasMore = false
|
||||||
|
lastInsertedAtIndex = -1
|
||||||
|
|
||||||
// data
|
// data
|
||||||
suggestions: SuggestedActor[] = []
|
suggestions: SuggestedActor[] = []
|
||||||
|
@ -110,6 +111,24 @@ export class SuggestedActorsModel {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
async insertSuggestionsByActor(actor: string, indexToInsertAt: number) {
|
||||||
|
// fetch suggestions
|
||||||
|
const res =
|
||||||
|
await this.rootStore.agent.app.bsky.graph.getSuggestedFollowsByActor({
|
||||||
|
actor: actor,
|
||||||
|
})
|
||||||
|
const {suggestions: moreSuggestions} = res.data
|
||||||
|
this.rootStore.me.follows.hydrateProfiles(moreSuggestions)
|
||||||
|
// dedupe
|
||||||
|
const toInsert = moreSuggestions.filter(
|
||||||
|
s => !this.suggestions.find(s2 => s2.did === s.did),
|
||||||
|
)
|
||||||
|
// insert
|
||||||
|
this.suggestions.splice(indexToInsertAt + 1, 0, ...toInsert)
|
||||||
|
// update index
|
||||||
|
this.lastInsertedAtIndex = indexToInsertAt
|
||||||
|
}
|
||||||
|
|
||||||
// state transitions
|
// state transitions
|
||||||
// =
|
// =
|
||||||
|
|
||||||
|
|
|
@ -8,6 +8,11 @@ export type MyFeedsItem =
|
||||||
_reactKey: string
|
_reactKey: string
|
||||||
type: 'spinner'
|
type: 'spinner'
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
_reactKey: string
|
||||||
|
type: 'saved-feeds-loading'
|
||||||
|
numItems: number
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
_reactKey: string
|
_reactKey: string
|
||||||
type: 'discover-feeds-loading'
|
type: 'discover-feeds-loading'
|
||||||
|
@ -91,7 +96,8 @@ export class MyFeedsUIModel {
|
||||||
if (this.saved.isLoading) {
|
if (this.saved.isLoading) {
|
||||||
items.push({
|
items.push({
|
||||||
_reactKey: '__saved_feeds_loading__',
|
_reactKey: '__saved_feeds_loading__',
|
||||||
type: 'spinner',
|
type: 'saved-feeds-loading',
|
||||||
|
numItems: this.rootStore.preferences.savedFeeds.length || 3,
|
||||||
})
|
})
|
||||||
} else if (this.saved.hasError) {
|
} else if (this.saved.hasError) {
|
||||||
items.push({
|
items.push({
|
||||||
|
|
|
@ -25,6 +25,7 @@ const VISIBILITY_VALUES = ['ignore', 'warn', 'hide']
|
||||||
const DEFAULT_LANG_CODES = (deviceLocales || [])
|
const DEFAULT_LANG_CODES = (deviceLocales || [])
|
||||||
.concat(['en', 'ja', 'pt', 'de'])
|
.concat(['en', 'ja', 'pt', 'de'])
|
||||||
.slice(0, 6)
|
.slice(0, 6)
|
||||||
|
const THREAD_SORT_VALUES = ['oldest', 'newest', 'most-likes', 'random']
|
||||||
|
|
||||||
export class LabelPreferencesModel {
|
export class LabelPreferencesModel {
|
||||||
nsfw: LabelPreference = 'hide'
|
nsfw: LabelPreference = 'hide'
|
||||||
|
@ -55,6 +56,9 @@ export class PreferencesModel {
|
||||||
homeFeedRepostsEnabled: boolean = true
|
homeFeedRepostsEnabled: boolean = true
|
||||||
homeFeedQuotePostsEnabled: boolean = true
|
homeFeedQuotePostsEnabled: boolean = true
|
||||||
homeFeedMergeFeedEnabled: boolean = false
|
homeFeedMergeFeedEnabled: boolean = false
|
||||||
|
threadDefaultSort: string = 'oldest'
|
||||||
|
threadFollowedUsersFirst: boolean = true
|
||||||
|
threadTreeViewEnabled: boolean = false
|
||||||
requireAltTextEnabled: boolean = false
|
requireAltTextEnabled: boolean = false
|
||||||
|
|
||||||
// used to linearize async modifications to state
|
// used to linearize async modifications to state
|
||||||
|
@ -86,6 +90,9 @@ export class PreferencesModel {
|
||||||
homeFeedRepostsEnabled: this.homeFeedRepostsEnabled,
|
homeFeedRepostsEnabled: this.homeFeedRepostsEnabled,
|
||||||
homeFeedQuotePostsEnabled: this.homeFeedQuotePostsEnabled,
|
homeFeedQuotePostsEnabled: this.homeFeedQuotePostsEnabled,
|
||||||
homeFeedMergeFeedEnabled: this.homeFeedMergeFeedEnabled,
|
homeFeedMergeFeedEnabled: this.homeFeedMergeFeedEnabled,
|
||||||
|
threadDefaultSort: this.threadDefaultSort,
|
||||||
|
threadFollowedUsersFirst: this.threadFollowedUsersFirst,
|
||||||
|
threadTreeViewEnabled: this.threadTreeViewEnabled,
|
||||||
requireAltTextEnabled: this.requireAltTextEnabled,
|
requireAltTextEnabled: this.requireAltTextEnabled,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -189,6 +196,28 @@ export class PreferencesModel {
|
||||||
) {
|
) {
|
||||||
this.homeFeedMergeFeedEnabled = v.homeFeedMergeFeedEnabled
|
this.homeFeedMergeFeedEnabled = v.homeFeedMergeFeedEnabled
|
||||||
}
|
}
|
||||||
|
// check if thread sort order is set in preferences, then hydrate
|
||||||
|
if (
|
||||||
|
hasProp(v, 'threadDefaultSort') &&
|
||||||
|
typeof v.threadDefaultSort === 'string' &&
|
||||||
|
THREAD_SORT_VALUES.includes(v.threadDefaultSort)
|
||||||
|
) {
|
||||||
|
this.threadDefaultSort = v.threadDefaultSort
|
||||||
|
}
|
||||||
|
// check if thread followed-users-first is enabled in preferences, then hydrate
|
||||||
|
if (
|
||||||
|
hasProp(v, 'threadFollowedUsersFirst') &&
|
||||||
|
typeof v.threadFollowedUsersFirst === 'boolean'
|
||||||
|
) {
|
||||||
|
this.threadFollowedUsersFirst = v.threadFollowedUsersFirst
|
||||||
|
}
|
||||||
|
// check if thread treeview is enabled in preferences, then hydrate
|
||||||
|
if (
|
||||||
|
hasProp(v, 'threadTreeViewEnabled') &&
|
||||||
|
typeof v.threadTreeViewEnabled === 'boolean'
|
||||||
|
) {
|
||||||
|
this.threadTreeViewEnabled = v.threadTreeViewEnabled
|
||||||
|
}
|
||||||
// check if requiring alt text is enabled in preferences, then hydrate
|
// check if requiring alt text is enabled in preferences, then hydrate
|
||||||
if (
|
if (
|
||||||
hasProp(v, 'requireAltTextEnabled') &&
|
hasProp(v, 'requireAltTextEnabled') &&
|
||||||
|
@ -494,6 +523,20 @@ export class PreferencesModel {
|
||||||
this.homeFeedMergeFeedEnabled = !this.homeFeedMergeFeedEnabled
|
this.homeFeedMergeFeedEnabled = !this.homeFeedMergeFeedEnabled
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setThreadDefaultSort(v: string) {
|
||||||
|
if (THREAD_SORT_VALUES.includes(v)) {
|
||||||
|
this.threadDefaultSort = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleThreadFollowedUsersFirst() {
|
||||||
|
this.threadFollowedUsersFirst = !this.threadFollowedUsersFirst
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleThreadTreeViewEnabled() {
|
||||||
|
this.threadTreeViewEnabled = !this.threadTreeViewEnabled
|
||||||
|
}
|
||||||
|
|
||||||
toggleRequireAltTextEnabled() {
|
toggleRequireAltTextEnabled() {
|
||||||
this.requireAltTextEnabled = !this.requireAltTextEnabled
|
this.requireAltTextEnabled = !this.requireAltTextEnabled
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,6 +7,7 @@ import {usePalette} from 'lib/hooks/usePalette'
|
||||||
import {useStores} from 'state/index'
|
import {useStores} from 'state/index'
|
||||||
import {Welcome} from './onboarding/Welcome'
|
import {Welcome} from './onboarding/Welcome'
|
||||||
import {RecommendedFeeds} from './onboarding/RecommendedFeeds'
|
import {RecommendedFeeds} from './onboarding/RecommendedFeeds'
|
||||||
|
import {RecommendedFollows} from './onboarding/RecommendedFollows'
|
||||||
|
|
||||||
export const Onboarding = observer(function OnboardingImpl() {
|
export const Onboarding = observer(function OnboardingImpl() {
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
|
@ -28,6 +29,9 @@ export const Onboarding = observer(function OnboardingImpl() {
|
||||||
{store.onboarding.step === 'RecommendedFeeds' && (
|
{store.onboarding.step === 'RecommendedFeeds' && (
|
||||||
<RecommendedFeeds next={next} />
|
<RecommendedFeeds next={next} />
|
||||||
)}
|
)}
|
||||||
|
{store.onboarding.step === 'RecommendedFollows' && (
|
||||||
|
<RecommendedFollows next={next} />
|
||||||
|
)}
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
)
|
)
|
||||||
|
|
|
@ -96,7 +96,7 @@ export const RecommendedFeeds = observer(function RecommendedFeedsImpl({
|
||||||
<Text
|
<Text
|
||||||
type="2xl-medium"
|
type="2xl-medium"
|
||||||
style={{color: '#fff', position: 'relative', top: -1}}>
|
style={{color: '#fff', position: 'relative', top: -1}}>
|
||||||
Done
|
Next
|
||||||
</Text>
|
</Text>
|
||||||
<FontAwesomeIcon icon="angle-right" color="#fff" size={14} />
|
<FontAwesomeIcon icon="angle-right" color="#fff" size={14} />
|
||||||
</View>
|
</View>
|
||||||
|
@ -215,6 +215,7 @@ const mStyles = StyleSheet.create({
|
||||||
marginBottom: 16,
|
marginBottom: 16,
|
||||||
marginHorizontal: 16,
|
marginHorizontal: 16,
|
||||||
marginTop: 16,
|
marginTop: 16,
|
||||||
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
buttonText: {
|
buttonText: {
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
|
|
|
@ -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}
|
onPress={next}
|
||||||
label="Continue"
|
label="Continue"
|
||||||
testID="continueBtn"
|
testID="continueBtn"
|
||||||
|
style={[styles.buttonContainer]}
|
||||||
labelStyle={styles.buttonText}
|
labelStyle={styles.buttonText}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
@ -117,6 +118,9 @@ const styles = StyleSheet.create({
|
||||||
spacer: {
|
spacer: {
|
||||||
height: 20,
|
height: 20,
|
||||||
},
|
},
|
||||||
|
buttonContainer: {
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
buttonText: {
|
buttonText: {
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
|
|
|
@ -285,11 +285,6 @@ export const ComposePost = observer(function ComposePost({
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
{isProcessing ? (
|
|
||||||
<View style={[pal.btn, styles.processingLine]}>
|
|
||||||
<Text style={pal.text}>{processingState}</Text>
|
|
||||||
</View>
|
|
||||||
) : undefined}
|
|
||||||
{store.preferences.requireAltTextEnabled && gallery.needsAltText && (
|
{store.preferences.requireAltTextEnabled && gallery.needsAltText && (
|
||||||
<View style={[styles.reminderLine, pal.viewLight]}>
|
<View style={[styles.reminderLine, pal.viewLight]}>
|
||||||
<View style={styles.errorIcon}>
|
<View style={styles.errorIcon}>
|
||||||
|
@ -374,6 +369,12 @@ export const ComposePost = observer(function ComposePost({
|
||||||
</View>
|
</View>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
{isProcessing ? (
|
||||||
|
<View style={[pal.viewLight, styles.processingLine]}>
|
||||||
|
<ActivityIndicator />
|
||||||
|
<Text style={pal.textLight}>{processingState}</Text>
|
||||||
|
</View>
|
||||||
|
) : undefined}
|
||||||
{!extLink && suggestedLinks.size > 0 ? (
|
{!extLink && suggestedLinks.size > 0 ? (
|
||||||
<View style={s.mb5}>
|
<View style={s.mb5}>
|
||||||
{Array.from(suggestedLinks)
|
{Array.from(suggestedLinks)
|
||||||
|
@ -435,11 +436,11 @@ const styles = StyleSheet.create({
|
||||||
paddingVertical: 6,
|
paddingVertical: 6,
|
||||||
},
|
},
|
||||||
processingLine: {
|
processingLine: {
|
||||||
borderRadius: 6,
|
flexDirection: 'row',
|
||||||
paddingHorizontal: 8,
|
alignItems: 'center',
|
||||||
paddingVertical: 6,
|
gap: 8,
|
||||||
marginHorizontal: 15,
|
paddingHorizontal: 26,
|
||||||
marginBottom: 6,
|
paddingVertical: 12,
|
||||||
},
|
},
|
||||||
errorLine: {
|
errorLine: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
|
|
|
@ -34,6 +34,7 @@ const ImageDefaultHeader = ({onRequestClose}: Props) => (
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
root: {
|
root: {
|
||||||
alignItems: 'flex-end',
|
alignItems: 'flex-end',
|
||||||
|
pointerEvents: 'box-none',
|
||||||
},
|
},
|
||||||
closeButton: {
|
closeButton: {
|
||||||
marginRight: 8,
|
marginRight: 8,
|
||||||
|
|
|
@ -32,6 +32,7 @@ const SWIPE_CLOSE_VELOCITY = 1
|
||||||
const SCREEN = Dimensions.get('screen')
|
const SCREEN = Dimensions.get('screen')
|
||||||
const SCREEN_WIDTH = SCREEN.width
|
const SCREEN_WIDTH = SCREEN.width
|
||||||
const SCREEN_HEIGHT = SCREEN.height
|
const SCREEN_HEIGHT = SCREEN.height
|
||||||
|
const MAX_SCALE = 2
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
imageSrc: ImageSource
|
imageSrc: ImageSource
|
||||||
|
@ -58,13 +59,18 @@ const ImageItem = ({
|
||||||
const [loaded, setLoaded] = useState(false)
|
const [loaded, setLoaded] = useState(false)
|
||||||
const [scaled, setScaled] = useState(false)
|
const [scaled, setScaled] = useState(false)
|
||||||
const imageDimensions = useImageDimensions(imageSrc)
|
const imageDimensions = useImageDimensions(imageSrc)
|
||||||
const handleDoubleTap = useDoubleTapToZoom(scrollViewRef, scaled, SCREEN)
|
const handleDoubleTap = useDoubleTapToZoom(
|
||||||
|
scrollViewRef,
|
||||||
|
scaled,
|
||||||
|
SCREEN,
|
||||||
|
imageDimensions,
|
||||||
|
)
|
||||||
|
|
||||||
const [translate, scale] = getImageTransform(imageDimensions, SCREEN)
|
const [translate, scale] = getImageTransform(imageDimensions, SCREEN)
|
||||||
const scrollValueY = new Animated.Value(0)
|
const scrollValueY = new Animated.Value(0)
|
||||||
const scaleValue = new Animated.Value(scale || 1)
|
const scaleValue = new Animated.Value(scale || 1)
|
||||||
const translateValue = new Animated.ValueXY(translate)
|
const translateValue = new Animated.ValueXY(translate)
|
||||||
const maxScale = scale && scale > 0 ? Math.max(1 / scale, 1) : 1
|
const maxScrollViewZoom = MAX_SCALE / (scale || 1)
|
||||||
|
|
||||||
const imageOpacity = scrollValueY.interpolate({
|
const imageOpacity = scrollValueY.interpolate({
|
||||||
inputRange: [-SWIPE_CLOSE_OFFSET, 0, SWIPE_CLOSE_OFFSET],
|
inputRange: [-SWIPE_CLOSE_OFFSET, 0, SWIPE_CLOSE_OFFSET],
|
||||||
|
@ -118,7 +124,7 @@ const ImageItem = ({
|
||||||
pinchGestureEnabled
|
pinchGestureEnabled
|
||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
maximumZoomScale={maxScale}
|
maximumZoomScale={maxScrollViewZoom}
|
||||||
contentContainerStyle={styles.imageScrollContainer}
|
contentContainerStyle={styles.imageScrollContainer}
|
||||||
scrollEnabled={swipeToCloseEnabled}
|
scrollEnabled={swipeToCloseEnabled}
|
||||||
onScrollEndDrag={onScrollEndDrag}
|
onScrollEndDrag={onScrollEndDrag}
|
||||||
|
|
|
@ -12,6 +12,8 @@ import {ScrollView, NativeTouchEvent, NativeSyntheticEvent} from 'react-native'
|
||||||
import {Dimensions} from '../@types'
|
import {Dimensions} from '../@types'
|
||||||
|
|
||||||
const DOUBLE_TAP_DELAY = 300
|
const DOUBLE_TAP_DELAY = 300
|
||||||
|
const MIN_ZOOM = 2
|
||||||
|
|
||||||
let lastTapTS: number | null = null
|
let lastTapTS: number | null = null
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -22,41 +24,124 @@ function useDoubleTapToZoom(
|
||||||
scrollViewRef: React.RefObject<ScrollView>,
|
scrollViewRef: React.RefObject<ScrollView>,
|
||||||
scaled: boolean,
|
scaled: boolean,
|
||||||
screen: Dimensions,
|
screen: Dimensions,
|
||||||
|
imageDimensions: Dimensions | null,
|
||||||
) {
|
) {
|
||||||
const handleDoubleTap = useCallback(
|
const handleDoubleTap = useCallback(
|
||||||
(event: NativeSyntheticEvent<NativeTouchEvent>) => {
|
(event: NativeSyntheticEvent<NativeTouchEvent>) => {
|
||||||
const nowTS = new Date().getTime()
|
const nowTS = new Date().getTime()
|
||||||
const scrollResponderRef = scrollViewRef?.current?.getScrollResponder()
|
const scrollResponderRef = scrollViewRef?.current?.getScrollResponder()
|
||||||
|
|
||||||
if (lastTapTS && nowTS - lastTapTS < DOUBLE_TAP_DELAY) {
|
const getZoomRectAfterDoubleTap = (
|
||||||
const {pageX, pageY} = event.nativeEvent
|
touchX: number,
|
||||||
let targetX = 0
|
touchY: number,
|
||||||
let targetY = 0
|
): {
|
||||||
let targetWidth = screen.width
|
x: number
|
||||||
let targetHeight = screen.height
|
y: number
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
} => {
|
||||||
|
if (!imageDimensions) {
|
||||||
|
return {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: screen.width,
|
||||||
|
height: screen.height,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Zooming in
|
// First, let's figure out how much we want to zoom in.
|
||||||
// TODO: Add more precise calculation of targetX, targetY based on touch
|
// We want to try to zoom in at least close enough to get rid of black bars.
|
||||||
if (!scaled) {
|
const imageAspect = imageDimensions.width / imageDimensions.height
|
||||||
targetX = pageX / 2
|
const screenAspect = screen.width / screen.height
|
||||||
targetY = pageY / 2
|
const zoom = Math.max(
|
||||||
targetWidth = screen.width / 2
|
imageAspect / screenAspect,
|
||||||
targetHeight = screen.height / 2
|
screenAspect / imageAspect,
|
||||||
|
MIN_ZOOM,
|
||||||
|
)
|
||||||
|
// Unlike in the Android version, we don't constrain the *max* zoom level here.
|
||||||
|
// Instead, this is done in the ScrollView props so that it constraints pinch too.
|
||||||
|
|
||||||
|
// Next, we'll be calculating the rectangle to "zoom into" in screen coordinates.
|
||||||
|
// We already know the zoom level, so this gives us the rectangle size.
|
||||||
|
let rectWidth = screen.width / zoom
|
||||||
|
let rectHeight = screen.height / zoom
|
||||||
|
|
||||||
|
// Before we settle on the zoomed rect, figure out the safe area it has to be inside.
|
||||||
|
// We don't want to introduce new black bars or make existing black bars unbalanced.
|
||||||
|
let minX = 0
|
||||||
|
let minY = 0
|
||||||
|
let maxX = screen.width - rectWidth
|
||||||
|
let maxY = screen.height - rectHeight
|
||||||
|
if (imageAspect >= screenAspect) {
|
||||||
|
// The image has horizontal black bars. Exclude them from the safe area.
|
||||||
|
const renderedHeight = screen.width / imageAspect
|
||||||
|
const horizontalBarHeight = (screen.height - renderedHeight) / 2
|
||||||
|
minY += horizontalBarHeight
|
||||||
|
maxY -= horizontalBarHeight
|
||||||
|
} else {
|
||||||
|
// The image has vertical black bars. Exclude them from the safe area.
|
||||||
|
const renderedWidth = screen.height * imageAspect
|
||||||
|
const verticalBarWidth = (screen.width - renderedWidth) / 2
|
||||||
|
minX += verticalBarWidth
|
||||||
|
maxX -= verticalBarWidth
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finally, we can position the rect according to its size and the safe area.
|
||||||
|
let rectX
|
||||||
|
if (maxX >= minX) {
|
||||||
|
// Content fills the screen horizontally so we have horizontal wiggle room.
|
||||||
|
// Try to keep the tapped point under the finger after zoom.
|
||||||
|
rectX = touchX - touchX / zoom
|
||||||
|
rectX = Math.min(rectX, maxX)
|
||||||
|
rectX = Math.max(rectX, minX)
|
||||||
|
} else {
|
||||||
|
// Keep the rect centered on the screen so that black bars are balanced.
|
||||||
|
rectX = screen.width / 2 - rectWidth / 2
|
||||||
|
}
|
||||||
|
let rectY
|
||||||
|
if (maxY >= minY) {
|
||||||
|
// Content fills the screen vertically so we have vertical wiggle room.
|
||||||
|
// Try to keep the tapped point under the finger after zoom.
|
||||||
|
rectY = touchY - touchY / zoom
|
||||||
|
rectY = Math.min(rectY, maxY)
|
||||||
|
rectY = Math.max(rectY, minY)
|
||||||
|
} else {
|
||||||
|
// Keep the rect centered on the screen so that black bars are balanced.
|
||||||
|
rectY = screen.height / 2 - rectHeight / 2
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: rectX,
|
||||||
|
y: rectY,
|
||||||
|
height: rectHeight,
|
||||||
|
width: rectWidth,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastTapTS && nowTS - lastTapTS < DOUBLE_TAP_DELAY) {
|
||||||
|
let nextZoomRect = {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: screen.width,
|
||||||
|
height: screen.height,
|
||||||
|
}
|
||||||
|
|
||||||
|
const willZoom = !scaled
|
||||||
|
if (willZoom) {
|
||||||
|
const {pageX, pageY} = event.nativeEvent
|
||||||
|
nextZoomRect = getZoomRectAfterDoubleTap(pageX, pageY)
|
||||||
}
|
}
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
scrollResponderRef?.scrollResponderZoomTo({
|
scrollResponderRef?.scrollResponderZoomTo({
|
||||||
x: targetX,
|
...nextZoomRect, // This rect is in screen coordinates
|
||||||
y: targetY,
|
|
||||||
width: targetWidth,
|
|
||||||
height: targetHeight,
|
|
||||||
animated: true,
|
animated: true,
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
lastTapTS = nowTS
|
lastTapTS = nowTS
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[scaled, screen.height, screen.width, scrollViewRef],
|
[imageDimensions, scaled, screen.height, screen.width, scrollViewRef],
|
||||||
)
|
)
|
||||||
|
|
||||||
return handleDoubleTap
|
return handleDoubleTap
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
/* eslint-disable react-hooks/exhaustive-deps */
|
|
||||||
/**
|
/**
|
||||||
* Copyright (c) JOB TODAY S.A. and its affiliates.
|
* Copyright (c) JOB TODAY S.A. and its affiliates.
|
||||||
*
|
*
|
||||||
|
@ -7,19 +6,19 @@
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {useMemo, useEffect} from 'react'
|
import {useEffect} from 'react'
|
||||||
import {
|
import {
|
||||||
Animated,
|
Animated,
|
||||||
Dimensions,
|
Dimensions,
|
||||||
GestureResponderEvent,
|
GestureResponderEvent,
|
||||||
GestureResponderHandlers,
|
GestureResponderHandlers,
|
||||||
NativeTouchEvent,
|
NativeTouchEvent,
|
||||||
|
PanResponder,
|
||||||
PanResponderGestureState,
|
PanResponderGestureState,
|
||||||
} from 'react-native'
|
} from 'react-native'
|
||||||
|
|
||||||
import {Position} from '../@types'
|
import {Position} from '../@types'
|
||||||
import {
|
import {
|
||||||
createPanResponder,
|
|
||||||
getDistanceBetweenTouches,
|
getDistanceBetweenTouches,
|
||||||
getImageTranslate,
|
getImageTranslate,
|
||||||
getImageDimensionsByTranslate,
|
getImageDimensionsByTranslate,
|
||||||
|
@ -29,8 +28,10 @@ const SCREEN = Dimensions.get('window')
|
||||||
const SCREEN_WIDTH = SCREEN.width
|
const SCREEN_WIDTH = SCREEN.width
|
||||||
const SCREEN_HEIGHT = SCREEN.height
|
const SCREEN_HEIGHT = SCREEN.height
|
||||||
const MIN_DIMENSION = Math.min(SCREEN_WIDTH, SCREEN_HEIGHT)
|
const MIN_DIMENSION = Math.min(SCREEN_WIDTH, SCREEN_HEIGHT)
|
||||||
|
const ANDROID_BAR_HEIGHT = 24
|
||||||
|
|
||||||
const SCALE_MAX = 2
|
const MIN_ZOOM = 2
|
||||||
|
const MAX_SCALE = 2
|
||||||
const DOUBLE_TAP_DELAY = 300
|
const DOUBLE_TAP_DELAY = 300
|
||||||
const OUT_BOUND_MULTIPLIER = 0.75
|
const OUT_BOUND_MULTIPLIER = 0.75
|
||||||
|
|
||||||
|
@ -87,23 +88,56 @@ const usePanResponder = ({
|
||||||
return [top, left, bottom, right]
|
return [top, left, bottom, right]
|
||||||
}
|
}
|
||||||
|
|
||||||
const getTranslateInBounds = (translate: Position, scale: number) => {
|
const getTransformAfterDoubleTap = (
|
||||||
const inBoundTranslate = {x: translate.x, y: translate.y}
|
touchX: number,
|
||||||
const [topBound, leftBound, bottomBound, rightBound] = getBounds(scale)
|
touchY: number,
|
||||||
|
): [number, Position] => {
|
||||||
|
let nextScale = initialScale
|
||||||
|
let nextTranslateX = initialTranslate.x
|
||||||
|
let nextTranslateY = initialTranslate.y
|
||||||
|
|
||||||
if (translate.x > leftBound) {
|
// First, let's figure out how much we want to zoom in.
|
||||||
inBoundTranslate.x = leftBound
|
// We want to try to zoom in at least close enough to get rid of black bars.
|
||||||
} else if (translate.x < rightBound) {
|
const imageAspect = imageDimensions.width / imageDimensions.height
|
||||||
inBoundTranslate.x = rightBound
|
const screenAspect = SCREEN.width / SCREEN.height
|
||||||
|
let zoom = Math.max(
|
||||||
|
imageAspect / screenAspect,
|
||||||
|
screenAspect / imageAspect,
|
||||||
|
MIN_ZOOM,
|
||||||
|
)
|
||||||
|
// Don't zoom so hard that the original image's pixels become blurry.
|
||||||
|
zoom = Math.min(zoom, MAX_SCALE / initialScale)
|
||||||
|
nextScale = initialScale * zoom
|
||||||
|
|
||||||
|
// Next, let's see if we need to adjust the scaled image translation.
|
||||||
|
// Ideally, we want the tapped point to stay under the finger after the scaling.
|
||||||
|
const dx = SCREEN.width / 2 - touchX
|
||||||
|
const dy = SCREEN.height / 2 - (touchY - ANDROID_BAR_HEIGHT)
|
||||||
|
// Before we try to adjust the translation, check how much wiggle room we have.
|
||||||
|
// We don't want to introduce new black bars or make existing black bars unbalanced.
|
||||||
|
const [topBound, leftBound, bottomBound, rightBound] = getBounds(nextScale)
|
||||||
|
if (leftBound > rightBound) {
|
||||||
|
// Content fills the screen horizontally so we have horizontal wiggle room.
|
||||||
|
// Try to keep the tapped point under the finger after zoom.
|
||||||
|
nextTranslateX += dx * zoom - dx
|
||||||
|
nextTranslateX = Math.min(nextTranslateX, leftBound)
|
||||||
|
nextTranslateX = Math.max(nextTranslateX, rightBound)
|
||||||
|
}
|
||||||
|
if (topBound > bottomBound) {
|
||||||
|
// Content fills the screen vertically so we have vertical wiggle room.
|
||||||
|
// Try to keep the tapped point under the finger after zoom.
|
||||||
|
nextTranslateY += dy * zoom - dy
|
||||||
|
nextTranslateY = Math.min(nextTranslateY, topBound)
|
||||||
|
nextTranslateY = Math.max(nextTranslateY, bottomBound)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (translate.y > topBound) {
|
return [
|
||||||
inBoundTranslate.y = topBound
|
nextScale,
|
||||||
} else if (translate.y < bottomBound) {
|
{
|
||||||
inBoundTranslate.y = bottomBound
|
x: nextTranslateX,
|
||||||
}
|
y: nextTranslateY,
|
||||||
|
},
|
||||||
return inBoundTranslate
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
const fitsScreenByWidth = () =>
|
const fitsScreenByWidth = () =>
|
||||||
|
@ -125,8 +159,12 @@ const usePanResponder = ({
|
||||||
longPressHandlerRef && clearTimeout(longPressHandlerRef)
|
longPressHandlerRef && clearTimeout(longPressHandlerRef)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handlers = {
|
const panResponder = PanResponder.create({
|
||||||
onGrant: (
|
onStartShouldSetPanResponder: () => true,
|
||||||
|
onStartShouldSetPanResponderCapture: () => true,
|
||||||
|
onMoveShouldSetPanResponder: () => true,
|
||||||
|
onMoveShouldSetPanResponderCapture: () => true,
|
||||||
|
onPanResponderGrant: (
|
||||||
_: GestureResponderEvent,
|
_: GestureResponderEvent,
|
||||||
gestureState: PanResponderGestureState,
|
gestureState: PanResponderGestureState,
|
||||||
) => {
|
) => {
|
||||||
|
@ -138,7 +176,7 @@ const usePanResponder = ({
|
||||||
|
|
||||||
longPressHandlerRef = setTimeout(onLongPress, delayLongPress)
|
longPressHandlerRef = setTimeout(onLongPress, delayLongPress)
|
||||||
},
|
},
|
||||||
onStart: (
|
onPanResponderStart: (
|
||||||
event: GestureResponderEvent,
|
event: GestureResponderEvent,
|
||||||
gestureState: PanResponderGestureState,
|
gestureState: PanResponderGestureState,
|
||||||
) => {
|
) => {
|
||||||
|
@ -157,25 +195,18 @@ const usePanResponder = ({
|
||||||
)
|
)
|
||||||
|
|
||||||
if (doubleTapToZoomEnabled && isDoubleTapPerformed) {
|
if (doubleTapToZoomEnabled && isDoubleTapPerformed) {
|
||||||
const isScaled = currentTranslate.x !== initialTranslate.x // currentScale !== initialScale;
|
let nextScale = initialScale
|
||||||
const {pageX: touchX, pageY: touchY} = event.nativeEvent.touches[0]
|
let nextTranslate = initialTranslate
|
||||||
const targetScale = SCALE_MAX
|
|
||||||
const nextScale = isScaled ? initialScale : targetScale
|
|
||||||
const nextTranslate = isScaled
|
|
||||||
? initialTranslate
|
|
||||||
: getTranslateInBounds(
|
|
||||||
{
|
|
||||||
x:
|
|
||||||
initialTranslate.x +
|
|
||||||
(SCREEN_WIDTH / 2 - touchX) * (targetScale / currentScale),
|
|
||||||
y:
|
|
||||||
initialTranslate.y +
|
|
||||||
(SCREEN_HEIGHT / 2 - touchY) * (targetScale / currentScale),
|
|
||||||
},
|
|
||||||
targetScale,
|
|
||||||
)
|
|
||||||
|
|
||||||
onZoom(!isScaled)
|
const willZoom = currentScale === initialScale
|
||||||
|
if (willZoom) {
|
||||||
|
const {pageX: touchX, pageY: touchY} = event.nativeEvent.touches[0]
|
||||||
|
;[nextScale, nextTranslate] = getTransformAfterDoubleTap(
|
||||||
|
touchX,
|
||||||
|
touchY,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onZoom(willZoom)
|
||||||
|
|
||||||
Animated.parallel(
|
Animated.parallel(
|
||||||
[
|
[
|
||||||
|
@ -206,7 +237,7 @@ const usePanResponder = ({
|
||||||
lastTapTS = Date.now()
|
lastTapTS = Date.now()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onMove: (
|
onPanResponderMove: (
|
||||||
event: GestureResponderEvent,
|
event: GestureResponderEvent,
|
||||||
gestureState: PanResponderGestureState,
|
gestureState: PanResponderGestureState,
|
||||||
) => {
|
) => {
|
||||||
|
@ -328,7 +359,7 @@ const usePanResponder = ({
|
||||||
tmpTranslate = {x: nextTranslateX, y: nextTranslateY}
|
tmpTranslate = {x: nextTranslateX, y: nextTranslateY}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onRelease: () => {
|
onPanResponderRelease: () => {
|
||||||
cancelLongPressHandle()
|
cancelLongPressHandle()
|
||||||
|
|
||||||
if (isDoubleTapPerformed) {
|
if (isDoubleTapPerformed) {
|
||||||
|
@ -336,8 +367,8 @@ const usePanResponder = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tmpScale > 0) {
|
if (tmpScale > 0) {
|
||||||
if (tmpScale < initialScale || tmpScale > SCALE_MAX) {
|
if (tmpScale < initialScale || tmpScale > MAX_SCALE) {
|
||||||
tmpScale = tmpScale < initialScale ? initialScale : SCALE_MAX
|
tmpScale = tmpScale < initialScale ? initialScale : MAX_SCALE
|
||||||
Animated.timing(scaleValue, {
|
Animated.timing(scaleValue, {
|
||||||
toValue: tmpScale,
|
toValue: tmpScale,
|
||||||
duration: 100,
|
duration: 100,
|
||||||
|
@ -390,9 +421,9 @@ const usePanResponder = ({
|
||||||
tmpTranslate = null
|
tmpTranslate = null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
onPanResponderTerminationRequest: () => false,
|
||||||
|
onShouldBlockNativeResponder: () => false,
|
||||||
const panResponder = useMemo(() => createPanResponder(handlers), [handlers])
|
})
|
||||||
|
|
||||||
return [panResponder.panHandlers, scaleValue, translateValue]
|
return [panResponder.panHandlers, scaleValue, translateValue]
|
||||||
}
|
}
|
||||||
|
|
|
@ -189,6 +189,7 @@ const styles = StyleSheet.create({
|
||||||
width: '100%',
|
width: '100%',
|
||||||
zIndex: 1,
|
zIndex: 1,
|
||||||
top: 0,
|
top: 0,
|
||||||
|
pointerEvents: 'box-none',
|
||||||
},
|
},
|
||||||
footer: {
|
footer: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
|
|
|
@ -6,14 +6,7 @@
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {Animated, NativeTouchEvent} from 'react-native'
|
||||||
Animated,
|
|
||||||
GestureResponderEvent,
|
|
||||||
PanResponder,
|
|
||||||
PanResponderGestureState,
|
|
||||||
PanResponderInstance,
|
|
||||||
NativeTouchEvent,
|
|
||||||
} from 'react-native'
|
|
||||||
import {Dimensions, Position} from './@types'
|
import {Dimensions, Position} from './@types'
|
||||||
|
|
||||||
type CacheStorageItem = {key: string; value: any}
|
type CacheStorageItem = {key: string; value: any}
|
||||||
|
@ -131,40 +124,6 @@ export const getImageTranslateForScale = (
|
||||||
return getImageTranslate(targetImageDimensions, screen)
|
return getImageTranslate(targetImageDimensions, screen)
|
||||||
}
|
}
|
||||||
|
|
||||||
type HandlerType = (
|
|
||||||
event: GestureResponderEvent,
|
|
||||||
state: PanResponderGestureState,
|
|
||||||
) => void
|
|
||||||
|
|
||||||
type PanResponderProps = {
|
|
||||||
onGrant: HandlerType
|
|
||||||
onStart?: HandlerType
|
|
||||||
onMove: HandlerType
|
|
||||||
onRelease?: HandlerType
|
|
||||||
onTerminate?: HandlerType
|
|
||||||
}
|
|
||||||
|
|
||||||
export const createPanResponder = ({
|
|
||||||
onGrant,
|
|
||||||
onStart,
|
|
||||||
onMove,
|
|
||||||
onRelease,
|
|
||||||
onTerminate,
|
|
||||||
}: PanResponderProps): PanResponderInstance =>
|
|
||||||
PanResponder.create({
|
|
||||||
onStartShouldSetPanResponder: () => true,
|
|
||||||
onStartShouldSetPanResponderCapture: () => true,
|
|
||||||
onMoveShouldSetPanResponder: () => true,
|
|
||||||
onMoveShouldSetPanResponderCapture: () => true,
|
|
||||||
onPanResponderGrant: onGrant,
|
|
||||||
onPanResponderStart: onStart,
|
|
||||||
onPanResponderMove: onMove,
|
|
||||||
onPanResponderRelease: onRelease,
|
|
||||||
onPanResponderTerminate: onTerminate,
|
|
||||||
onPanResponderTerminationRequest: () => false,
|
|
||||||
onShouldBlockNativeResponder: () => false,
|
|
||||||
})
|
|
||||||
|
|
||||||
export const getDistanceBetweenTouches = (
|
export const getDistanceBetweenTouches = (
|
||||||
touches: NativeTouchEvent[],
|
touches: NativeTouchEvent[],
|
||||||
): number => {
|
): number => {
|
||||||
|
|
|
@ -55,6 +55,7 @@ const LOAD_MORE = {
|
||||||
const BOTTOM_COMPONENT = {
|
const BOTTOM_COMPONENT = {
|
||||||
_reactKey: '__bottom_component__',
|
_reactKey: '__bottom_component__',
|
||||||
_isHighlightedPost: false,
|
_isHighlightedPost: false,
|
||||||
|
_showBorder: true,
|
||||||
}
|
}
|
||||||
type YieldedItem =
|
type YieldedItem =
|
||||||
| PostThreadItemModel
|
| PostThreadItemModel
|
||||||
|
@ -69,10 +70,12 @@ export const PostThread = observer(function PostThread({
|
||||||
uri,
|
uri,
|
||||||
view,
|
view,
|
||||||
onPressReply,
|
onPressReply,
|
||||||
|
treeView,
|
||||||
}: {
|
}: {
|
||||||
uri: string
|
uri: string
|
||||||
view: PostThreadModel
|
view: PostThreadModel
|
||||||
onPressReply: () => void
|
onPressReply: () => void
|
||||||
|
treeView: boolean
|
||||||
}) {
|
}) {
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
const {isTablet} = useWebMediaQueries()
|
const {isTablet} = useWebMediaQueries()
|
||||||
|
@ -99,6 +102,13 @@ export const PostThread = observer(function PostThread({
|
||||||
}
|
}
|
||||||
return []
|
return []
|
||||||
}, [view.isLoadingFromCache, view.thread, maxVisible])
|
}, [view.isLoadingFromCache, view.thread, maxVisible])
|
||||||
|
const highlightedPostIndex = posts.findIndex(post => post._isHighlightedPost)
|
||||||
|
const showBottomBorder =
|
||||||
|
!treeView ||
|
||||||
|
// in the treeview, only show the bottom border
|
||||||
|
// if there are replies under the highlighted posts
|
||||||
|
posts.findLast(v => v instanceof PostThreadItemModel) !==
|
||||||
|
posts[highlightedPostIndex]
|
||||||
useSetTitle(
|
useSetTitle(
|
||||||
view.thread?.postRecord &&
|
view.thread?.postRecord &&
|
||||||
`${sanitizeDisplayName(
|
`${sanitizeDisplayName(
|
||||||
|
@ -135,17 +145,16 @@ export const PostThread = observer(function PostThread({
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const index = posts.findIndex(post => post._isHighlightedPost)
|
if (highlightedPostIndex !== -1) {
|
||||||
if (index !== -1) {
|
|
||||||
ref.current?.scrollToIndex({
|
ref.current?.scrollToIndex({
|
||||||
index,
|
index: highlightedPostIndex,
|
||||||
animated: false,
|
animated: false,
|
||||||
viewPosition: 0,
|
viewPosition: 0,
|
||||||
})
|
})
|
||||||
hasScrolledIntoView.current = true
|
hasScrolledIntoView.current = true
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
posts,
|
highlightedPostIndex,
|
||||||
view.hasContent,
|
view.hasContent,
|
||||||
view.isFromCache,
|
view.isFromCache,
|
||||||
view.isLoadingFromCache,
|
view.isLoadingFromCache,
|
||||||
|
@ -184,7 +193,14 @@ export const PostThread = observer(function PostThread({
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
} else if (item === REPLY_PROMPT) {
|
} else if (item === REPLY_PROMPT) {
|
||||||
return <ComposePrompt onPressCompose={onPressReply} />
|
return (
|
||||||
|
<View
|
||||||
|
style={
|
||||||
|
treeView && [pal.border, {borderBottomWidth: 1, marginBottom: 6}]
|
||||||
|
}>
|
||||||
|
{isDesktopWeb && <ComposePrompt onPressCompose={onPressReply} />}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
} else if (item === DELETED) {
|
} else if (item === DELETED) {
|
||||||
return (
|
return (
|
||||||
<View style={[pal.border, pal.viewLight, styles.itemContainer]}>
|
<View style={[pal.border, pal.viewLight, styles.itemContainer]}>
|
||||||
|
@ -224,7 +240,18 @@ export const PostThread = observer(function PostThread({
|
||||||
// due to some complexities with how flatlist works, this is the easiest way
|
// due to some complexities with how flatlist works, this is the easiest way
|
||||||
// I could find to get a border positioned directly under the last item
|
// I could find to get a border positioned directly under the last item
|
||||||
// -prf
|
// -prf
|
||||||
return <View style={[pal.border, styles.bottomSpacer]} />
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
{height: 400},
|
||||||
|
showBottomBorder && {
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderColor: pal.colors.border,
|
||||||
|
},
|
||||||
|
treeView && {marginTop: 10},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)
|
||||||
} else if (item === CHILD_SPINNER) {
|
} else if (item === CHILD_SPINNER) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.childSpinner}>
|
<View style={styles.childSpinner}>
|
||||||
|
@ -240,12 +267,13 @@ export const PostThread = observer(function PostThread({
|
||||||
item={item}
|
item={item}
|
||||||
onPostReply={onRefresh}
|
onPostReply={onRefresh}
|
||||||
hasPrecedingItem={prev?._showChildReplyLine}
|
hasPrecedingItem={prev?._showChildReplyLine}
|
||||||
|
treeView={treeView}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return <></>
|
return <></>
|
||||||
},
|
},
|
||||||
[onRefresh, onPressReply, pal, posts, isTablet],
|
[onRefresh, onPressReply, pal, posts, isTablet, treeView, showBottomBorder],
|
||||||
)
|
)
|
||||||
|
|
||||||
// loading
|
// loading
|
||||||
|
@ -377,7 +405,7 @@ function* flattenThread(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
yield post
|
yield post
|
||||||
if (isDesktopWeb && post._isHighlightedPost) {
|
if (post._isHighlightedPost) {
|
||||||
yield REPLY_PROMPT
|
yield REPLY_PROMPT
|
||||||
}
|
}
|
||||||
if (post.replies?.length) {
|
if (post.replies?.length) {
|
||||||
|
@ -411,8 +439,4 @@ const styles = StyleSheet.create({
|
||||||
paddingVertical: 10,
|
paddingVertical: 10,
|
||||||
},
|
},
|
||||||
childSpinner: {},
|
childSpinner: {},
|
||||||
bottomSpacer: {
|
|
||||||
height: 400,
|
|
||||||
borderTopWidth: 1,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
|
@ -35,15 +35,18 @@ import {formatCount} from '../util/numeric/format'
|
||||||
import {TimeElapsed} from 'view/com/util/TimeElapsed'
|
import {TimeElapsed} from 'view/com/util/TimeElapsed'
|
||||||
import {makeProfileLink} from 'lib/routes/links'
|
import {makeProfileLink} from 'lib/routes/links'
|
||||||
import {isDesktopWeb} from 'platform/detection'
|
import {isDesktopWeb} from 'platform/detection'
|
||||||
|
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||||
|
|
||||||
export const PostThreadItem = observer(function PostThreadItem({
|
export const PostThreadItem = observer(function PostThreadItem({
|
||||||
item,
|
item,
|
||||||
onPostReply,
|
onPostReply,
|
||||||
hasPrecedingItem,
|
hasPrecedingItem,
|
||||||
|
treeView,
|
||||||
}: {
|
}: {
|
||||||
item: PostThreadItemModel
|
item: PostThreadItemModel
|
||||||
onPostReply: () => void
|
onPostReply: () => void
|
||||||
hasPrecedingItem: boolean
|
hasPrecedingItem: boolean
|
||||||
|
treeView: boolean
|
||||||
}) {
|
}) {
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
const store = useStores()
|
const store = useStores()
|
||||||
|
@ -389,25 +392,28 @@ export const PostThreadItem = observer(function PostThreadItem({
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
const isThreadedChild = treeView && item._depth > 0
|
||||||
return (
|
return (
|
||||||
<>
|
<PostOuterWrapper
|
||||||
|
item={item}
|
||||||
|
hasPrecedingItem={hasPrecedingItem}
|
||||||
|
treeView={treeView}>
|
||||||
<PostHider
|
<PostHider
|
||||||
testID={`postThreadItem-by-${item.post.author.handle}`}
|
testID={`postThreadItem-by-${item.post.author.handle}`}
|
||||||
href={itemHref}
|
href={itemHref}
|
||||||
style={[
|
style={[pal.view]}
|
||||||
styles.outer,
|
|
||||||
pal.border,
|
|
||||||
pal.view,
|
|
||||||
item._showParentReplyLine && hasPrecedingItem && styles.noTopBorder,
|
|
||||||
styles.cursor,
|
|
||||||
]}
|
|
||||||
moderation={item.moderation.content}>
|
moderation={item.moderation.content}>
|
||||||
<PostSandboxWarning />
|
<PostSandboxWarning />
|
||||||
|
|
||||||
<View
|
<View
|
||||||
style={{flexDirection: 'row', gap: 10, paddingLeft: 8, height: 16}}>
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 10,
|
||||||
|
paddingLeft: 8,
|
||||||
|
height: isThreadedChild ? 8 : 16,
|
||||||
|
}}>
|
||||||
<View style={{width: 52}}>
|
<View style={{width: 52}}>
|
||||||
{item._showParentReplyLine && (
|
{!isThreadedChild && item._showParentReplyLine && (
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
styles.replyLine,
|
styles.replyLine,
|
||||||
|
@ -431,7 +437,7 @@ export const PostThreadItem = observer(function PostThreadItem({
|
||||||
]}>
|
]}>
|
||||||
<View style={styles.layoutAvi}>
|
<View style={styles.layoutAvi}>
|
||||||
<PreviewableUserAvatar
|
<PreviewableUserAvatar
|
||||||
size={52}
|
size={isThreadedChild ? 24 : 52}
|
||||||
did={item.post.author.did}
|
did={item.post.author.did}
|
||||||
handle={item.post.author.handle}
|
handle={item.post.author.handle}
|
||||||
avatar={item.post.author.avatar}
|
avatar={item.post.author.avatar}
|
||||||
|
@ -444,7 +450,9 @@ export const PostThreadItem = observer(function PostThreadItem({
|
||||||
styles.replyLine,
|
styles.replyLine,
|
||||||
{
|
{
|
||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
backgroundColor: pal.colors.replyLine,
|
backgroundColor: isThreadedChild
|
||||||
|
? pal.colors.border
|
||||||
|
: pal.colors.replyLine,
|
||||||
marginTop: 4,
|
marginTop: 4,
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
|
@ -464,7 +472,11 @@ export const PostThreadItem = observer(function PostThreadItem({
|
||||||
style={styles.alert}
|
style={styles.alert}
|
||||||
/>
|
/>
|
||||||
{item.richText?.text ? (
|
{item.richText?.text ? (
|
||||||
<View style={styles.postTextContainer}>
|
<View
|
||||||
|
style={[
|
||||||
|
styles.postTextContainer,
|
||||||
|
isThreadedChild && {paddingTop: 2},
|
||||||
|
]}>
|
||||||
<RichText
|
<RichText
|
||||||
type="post-text"
|
type="post-text"
|
||||||
richText={item.richText}
|
richText={item.richText}
|
||||||
|
@ -508,30 +520,84 @@ export const PostThreadItem = observer(function PostThreadItem({
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</PostHider>
|
|
||||||
{item._hasMore ? (
|
{item._hasMore ? (
|
||||||
<Link
|
<Link
|
||||||
style={[
|
style={[
|
||||||
styles.loadMore,
|
styles.loadMore,
|
||||||
{borderTopColor: pal.colors.border},
|
{
|
||||||
pal.view,
|
paddingLeft: treeView ? 44 : 70,
|
||||||
|
paddingTop: 0,
|
||||||
|
paddingBottom: treeView ? 4 : 12,
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
href={itemHref}
|
href={itemHref}
|
||||||
title={itemTitle}
|
title={itemTitle}
|
||||||
noFeedback>
|
noFeedback>
|
||||||
<Text style={pal.link}>Continue thread...</Text>
|
<Text type="sm-medium" style={pal.textLight}>
|
||||||
|
More
|
||||||
|
</Text>
|
||||||
<FontAwesomeIcon
|
<FontAwesomeIcon
|
||||||
icon="angle-right"
|
icon="angle-right"
|
||||||
style={pal.link as FontAwesomeIconStyle}
|
color={pal.colors.textLight}
|
||||||
size={18}
|
size={14}
|
||||||
/>
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
</>
|
</PostHider>
|
||||||
|
</PostOuterWrapper>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function PostOuterWrapper({
|
||||||
|
item,
|
||||||
|
hasPrecedingItem,
|
||||||
|
treeView,
|
||||||
|
children,
|
||||||
|
}: React.PropsWithChildren<{
|
||||||
|
item: PostThreadItemModel
|
||||||
|
hasPrecedingItem: boolean
|
||||||
|
treeView: boolean
|
||||||
|
}>) {
|
||||||
|
const {isMobile} = useWebMediaQueries()
|
||||||
|
const pal = usePalette('default')
|
||||||
|
if (treeView && item._depth > 0) {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
pal.view,
|
||||||
|
styles.cursor,
|
||||||
|
{flexDirection: 'row', paddingLeft: 10},
|
||||||
|
]}>
|
||||||
|
{Array.from(Array(item._depth - 1)).map((_, n: number) => (
|
||||||
|
<View
|
||||||
|
key={`${item.uri}-padding-${n}`}
|
||||||
|
style={{
|
||||||
|
borderLeftWidth: 2,
|
||||||
|
borderLeftColor: pal.colors.border,
|
||||||
|
marginLeft: 19,
|
||||||
|
paddingLeft: isMobile ? 0 : 4,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<View style={{flex: 1}}>{children}</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.outer,
|
||||||
|
pal.view,
|
||||||
|
pal.border,
|
||||||
|
item._showParentReplyLine && hasPrecedingItem && styles.noTopBorder,
|
||||||
|
styles.cursor,
|
||||||
|
]}>
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function ExpandedPostDetails({
|
function ExpandedPostDetails({
|
||||||
post,
|
post,
|
||||||
needsTranslation,
|
needsTranslation,
|
||||||
|
@ -600,7 +666,7 @@ const styles = StyleSheet.create({
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
flexWrap: 'wrap',
|
flexWrap: 'wrap',
|
||||||
paddingBottom: 8,
|
paddingBottom: 4,
|
||||||
paddingRight: 10,
|
paddingRight: 10,
|
||||||
},
|
},
|
||||||
postTextLargeContainer: {
|
postTextLargeContainer: {
|
||||||
|
@ -629,11 +695,10 @@ const styles = StyleSheet.create({
|
||||||
},
|
},
|
||||||
loadMore: {
|
loadMore: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
justifyContent: 'space-between',
|
alignItems: 'center',
|
||||||
borderTopWidth: 1,
|
justifyContent: 'flex-start',
|
||||||
paddingLeft: 80,
|
gap: 4,
|
||||||
paddingRight: 20,
|
paddingHorizontal: 20,
|
||||||
paddingVertical: 12,
|
|
||||||
},
|
},
|
||||||
replyLine: {
|
replyLine: {
|
||||||
width: 2,
|
width: 2,
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {View} from 'react-native'
|
import {StyleProp, TextStyle, View} from 'react-native'
|
||||||
import {observer} from 'mobx-react-lite'
|
import {observer} from 'mobx-react-lite'
|
||||||
import {Button, ButtonType} from '../util/forms/Button'
|
import {Button, ButtonType} from '../util/forms/Button'
|
||||||
import {useStores} from 'state/index'
|
import {useStores} from 'state/index'
|
||||||
|
@ -11,11 +11,13 @@ export const FollowButton = observer(function FollowButtonImpl({
|
||||||
followedType = 'default',
|
followedType = 'default',
|
||||||
did,
|
did,
|
||||||
onToggleFollow,
|
onToggleFollow,
|
||||||
|
labelStyle,
|
||||||
}: {
|
}: {
|
||||||
unfollowedType?: ButtonType
|
unfollowedType?: ButtonType
|
||||||
followedType?: ButtonType
|
followedType?: ButtonType
|
||||||
did: string
|
did: string
|
||||||
onToggleFollow?: (v: boolean) => void
|
onToggleFollow?: (v: boolean) => void
|
||||||
|
labelStyle?: StyleProp<TextStyle>
|
||||||
}) {
|
}) {
|
||||||
const store = useStores()
|
const store = useStores()
|
||||||
const followState = store.me.follows.getFollowState(did)
|
const followState = store.me.follows.getFollowState(did)
|
||||||
|
@ -28,18 +30,18 @@ export const FollowButton = observer(function FollowButtonImpl({
|
||||||
const updatedFollowState = await store.me.follows.fetchFollowState(did)
|
const updatedFollowState = await store.me.follows.fetchFollowState(did)
|
||||||
if (updatedFollowState === FollowState.Following) {
|
if (updatedFollowState === FollowState.Following) {
|
||||||
try {
|
try {
|
||||||
|
onToggleFollow?.(false)
|
||||||
await store.agent.deleteFollow(store.me.follows.getFollowUri(did))
|
await store.agent.deleteFollow(store.me.follows.getFollowUri(did))
|
||||||
store.me.follows.removeFollow(did)
|
store.me.follows.removeFollow(did)
|
||||||
onToggleFollow?.(false)
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
store.log.error('Failed to delete follow', e)
|
store.log.error('Failed to delete follow', e)
|
||||||
Toast.show('An issue occurred, please try again.')
|
Toast.show('An issue occurred, please try again.')
|
||||||
}
|
}
|
||||||
} else if (updatedFollowState === FollowState.NotFollowing) {
|
} else if (updatedFollowState === FollowState.NotFollowing) {
|
||||||
try {
|
try {
|
||||||
|
onToggleFollow?.(true)
|
||||||
const res = await store.agent.follow(did)
|
const res = await store.agent.follow(did)
|
||||||
store.me.follows.addFollow(did, res.uri)
|
store.me.follows.addFollow(did, res.uri)
|
||||||
onToggleFollow?.(true)
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
store.log.error('Failed to create follow', e)
|
store.log.error('Failed to create follow', e)
|
||||||
Toast.show('An issue occurred, please try again.')
|
Toast.show('An issue occurred, please try again.')
|
||||||
|
@ -52,8 +54,10 @@ export const FollowButton = observer(function FollowButtonImpl({
|
||||||
type={
|
type={
|
||||||
followState === FollowState.Following ? followedType : unfollowedType
|
followState === FollowState.Following ? followedType : unfollowedType
|
||||||
}
|
}
|
||||||
|
labelStyle={labelStyle}
|
||||||
onPress={onToggleFollowInner}
|
onPress={onToggleFollowInner}
|
||||||
label={followState === FollowState.Following ? 'Unfollow' : 'Follow'}
|
label={followState === FollowState.Following ? 'Unfollow' : 'Follow'}
|
||||||
|
withLoading={true}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
|
@ -7,6 +7,8 @@ import {
|
||||||
Pressable,
|
Pressable,
|
||||||
ViewStyle,
|
ViewStyle,
|
||||||
PressableStateCallbackType,
|
PressableStateCallbackType,
|
||||||
|
ActivityIndicator,
|
||||||
|
View,
|
||||||
} from 'react-native'
|
} from 'react-native'
|
||||||
import {Text} from '../text/Text'
|
import {Text} from '../text/Text'
|
||||||
import {useTheme} from 'lib/ThemeContext'
|
import {useTheme} from 'lib/ThemeContext'
|
||||||
|
@ -48,17 +50,19 @@ export function Button({
|
||||||
accessibilityHint,
|
accessibilityHint,
|
||||||
accessibilityLabelledBy,
|
accessibilityLabelledBy,
|
||||||
onAccessibilityEscape,
|
onAccessibilityEscape,
|
||||||
|
withLoading = false,
|
||||||
}: React.PropsWithChildren<{
|
}: React.PropsWithChildren<{
|
||||||
type?: ButtonType
|
type?: ButtonType
|
||||||
label?: string
|
label?: string
|
||||||
style?: StyleProp<ViewStyle>
|
style?: StyleProp<ViewStyle>
|
||||||
labelStyle?: StyleProp<TextStyle>
|
labelStyle?: StyleProp<TextStyle>
|
||||||
onPress?: () => void
|
onPress?: () => void | Promise<void>
|
||||||
testID?: string
|
testID?: string
|
||||||
accessibilityLabel?: string
|
accessibilityLabel?: string
|
||||||
accessibilityHint?: string
|
accessibilityHint?: string
|
||||||
accessibilityLabelledBy?: string
|
accessibilityLabelledBy?: string
|
||||||
onAccessibilityEscape?: () => void
|
onAccessibilityEscape?: () => void
|
||||||
|
withLoading?: boolean
|
||||||
}>) {
|
}>) {
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
const typeOuterStyle = choose<ViewStyle, Record<ButtonType, ViewStyle>>(
|
const typeOuterStyle = choose<ViewStyle, Record<ButtonType, ViewStyle>>(
|
||||||
|
@ -138,13 +142,16 @@ export function Button({
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const [isLoading, setIsLoading] = React.useState(false)
|
||||||
const onPressWrapped = React.useCallback(
|
const onPressWrapped = React.useCallback(
|
||||||
(event: Event) => {
|
async (event: Event) => {
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
onPress?.()
|
withLoading && setIsLoading(true)
|
||||||
|
await onPress?.()
|
||||||
|
withLoading && setIsLoading(false)
|
||||||
},
|
},
|
||||||
[onPress],
|
[onPress, withLoading],
|
||||||
)
|
)
|
||||||
|
|
||||||
const getStyle = React.useCallback(
|
const getStyle = React.useCallback(
|
||||||
|
@ -160,23 +167,35 @@ export function Button({
|
||||||
[typeOuterStyle, style],
|
[typeOuterStyle, style],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const renderChildern = React.useCallback(() => {
|
||||||
|
if (!label) {
|
||||||
|
return children
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.labelContainer}>
|
||||||
|
{label && withLoading && isLoading ? (
|
||||||
|
<ActivityIndicator size={12} color={typeLabelStyle.color} />
|
||||||
|
) : null}
|
||||||
|
<Text type="button" style={[typeLabelStyle, labelStyle]}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}, [children, label, withLoading, isLoading, typeLabelStyle, labelStyle])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
style={getStyle}
|
style={getStyle}
|
||||||
onPress={onPressWrapped}
|
onPress={onPressWrapped}
|
||||||
|
disabled={isLoading}
|
||||||
testID={testID}
|
testID={testID}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel={accessibilityLabel}
|
accessibilityLabel={accessibilityLabel}
|
||||||
accessibilityHint={accessibilityHint}
|
accessibilityHint={accessibilityHint}
|
||||||
accessibilityLabelledBy={accessibilityLabelledBy}
|
accessibilityLabelledBy={accessibilityLabelledBy}
|
||||||
onAccessibilityEscape={onAccessibilityEscape}>
|
onAccessibilityEscape={onAccessibilityEscape}>
|
||||||
{label ? (
|
{renderChildern}
|
||||||
<Text type="button" style={[typeLabelStyle, labelStyle]}>
|
|
||||||
{label}
|
|
||||||
</Text>
|
|
||||||
) : (
|
|
||||||
children
|
|
||||||
)}
|
|
||||||
</Pressable>
|
</Pressable>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -187,4 +206,8 @@ const styles = StyleSheet.create({
|
||||||
paddingVertical: 8,
|
paddingVertical: 8,
|
||||||
borderRadius: 24,
|
borderRadius: 24,
|
||||||
},
|
},
|
||||||
|
labelContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
|
@ -36,6 +36,7 @@ import {faClone} from '@fortawesome/free-solid-svg-icons/faClone'
|
||||||
import {faClone as farClone} from '@fortawesome/free-regular-svg-icons/faClone'
|
import {faClone as farClone} from '@fortawesome/free-regular-svg-icons/faClone'
|
||||||
import {faComment} from '@fortawesome/free-regular-svg-icons/faComment'
|
import {faComment} from '@fortawesome/free-regular-svg-icons/faComment'
|
||||||
import {faCommentSlash} from '@fortawesome/free-solid-svg-icons/faCommentSlash'
|
import {faCommentSlash} from '@fortawesome/free-solid-svg-icons/faCommentSlash'
|
||||||
|
import {faComments} from '@fortawesome/free-regular-svg-icons/faComments'
|
||||||
import {faCompass} from '@fortawesome/free-regular-svg-icons/faCompass'
|
import {faCompass} from '@fortawesome/free-regular-svg-icons/faCompass'
|
||||||
import {faEllipsis} from '@fortawesome/free-solid-svg-icons/faEllipsis'
|
import {faEllipsis} from '@fortawesome/free-solid-svg-icons/faEllipsis'
|
||||||
import {faEnvelope} from '@fortawesome/free-solid-svg-icons/faEnvelope'
|
import {faEnvelope} from '@fortawesome/free-solid-svg-icons/faEnvelope'
|
||||||
|
@ -44,6 +45,7 @@ import {faEye} from '@fortawesome/free-solid-svg-icons/faEye'
|
||||||
import {faEyeSlash as farEyeSlash} from '@fortawesome/free-regular-svg-icons/faEyeSlash'
|
import {faEyeSlash as farEyeSlash} from '@fortawesome/free-regular-svg-icons/faEyeSlash'
|
||||||
import {faFaceSmile} from '@fortawesome/free-regular-svg-icons/faFaceSmile'
|
import {faFaceSmile} from '@fortawesome/free-regular-svg-icons/faFaceSmile'
|
||||||
import {faFire} from '@fortawesome/free-solid-svg-icons/faFire'
|
import {faFire} from '@fortawesome/free-solid-svg-icons/faFire'
|
||||||
|
import {faFlask} from '@fortawesome/free-solid-svg-icons'
|
||||||
import {faFloppyDisk} from '@fortawesome/free-regular-svg-icons/faFloppyDisk'
|
import {faFloppyDisk} from '@fortawesome/free-regular-svg-icons/faFloppyDisk'
|
||||||
import {faGear} from '@fortawesome/free-solid-svg-icons/faGear'
|
import {faGear} from '@fortawesome/free-solid-svg-icons/faGear'
|
||||||
import {faGlobe} from '@fortawesome/free-solid-svg-icons/faGlobe'
|
import {faGlobe} from '@fortawesome/free-solid-svg-icons/faGlobe'
|
||||||
|
@ -134,6 +136,7 @@ export function setup() {
|
||||||
farClone,
|
farClone,
|
||||||
faComment,
|
faComment,
|
||||||
faCommentSlash,
|
faCommentSlash,
|
||||||
|
faComments,
|
||||||
faCompass,
|
faCompass,
|
||||||
faEllipsis,
|
faEllipsis,
|
||||||
faEnvelope,
|
faEnvelope,
|
||||||
|
@ -142,6 +145,7 @@ export function setup() {
|
||||||
farEyeSlash,
|
farEyeSlash,
|
||||||
faFaceSmile,
|
faFaceSmile,
|
||||||
faFire,
|
faFire,
|
||||||
|
faFlask,
|
||||||
faFloppyDisk,
|
faFloppyDisk,
|
||||||
faGear,
|
faGear,
|
||||||
faGlobe,
|
faGlobe,
|
||||||
|
|
|
@ -185,6 +185,17 @@ export const CustomFeedScreenInner = observer(
|
||||||
})
|
})
|
||||||
}, [store, currentFeed])
|
}, [store, currentFeed])
|
||||||
|
|
||||||
|
const onPressAbout = React.useCallback(() => {
|
||||||
|
store.shell.openModal({
|
||||||
|
name: 'confirm',
|
||||||
|
title: currentFeed?.displayName || '',
|
||||||
|
message:
|
||||||
|
currentFeed?.data.description || 'This feed has no description.',
|
||||||
|
confirmBtnText: 'Close',
|
||||||
|
onPressConfirm() {},
|
||||||
|
})
|
||||||
|
}, [store, currentFeed])
|
||||||
|
|
||||||
const onPressViewAuthor = React.useCallback(() => {
|
const onPressViewAuthor = React.useCallback(() => {
|
||||||
navigation.navigate('Profile', {name: handleOrDid})
|
navigation.navigate('Profile', {name: handleOrDid})
|
||||||
}, [handleOrDid, navigation])
|
}, [handleOrDid, navigation])
|
||||||
|
@ -233,7 +244,21 @@ export const CustomFeedScreenInner = observer(
|
||||||
}, [store, onSoftReset, isScreenFocused])
|
}, [store, onSoftReset, isScreenFocused])
|
||||||
|
|
||||||
const dropdownItems: DropdownItem[] = React.useMemo(() => {
|
const dropdownItems: DropdownItem[] = React.useMemo(() => {
|
||||||
let items: DropdownItem[] = [
|
return [
|
||||||
|
currentFeed
|
||||||
|
? {
|
||||||
|
testID: 'feedHeaderDropdownAboutBtn',
|
||||||
|
label: 'About this feed',
|
||||||
|
onPress: onPressAbout,
|
||||||
|
icon: {
|
||||||
|
ios: {
|
||||||
|
name: 'info.circle',
|
||||||
|
},
|
||||||
|
android: '',
|
||||||
|
web: 'info',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
{
|
{
|
||||||
testID: 'feedHeaderDropdownViewAuthorBtn',
|
testID: 'feedHeaderDropdownViewAuthorBtn',
|
||||||
label: 'View author',
|
label: 'View author',
|
||||||
|
@ -292,10 +317,10 @@ export const CustomFeedScreenInner = observer(
|
||||||
web: 'share',
|
web: 'share',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]
|
].filter(Boolean) as DropdownItem[]
|
||||||
return items
|
|
||||||
}, [
|
}, [
|
||||||
currentFeed?.isSaved,
|
currentFeed,
|
||||||
|
onPressAbout,
|
||||||
onToggleSaved,
|
onToggleSaved,
|
||||||
onPressReport,
|
onPressReport,
|
||||||
onPressShare,
|
onPressShare,
|
||||||
|
|
|
@ -16,7 +16,10 @@ import {ComposeIcon2, CogIcon} from 'lib/icons'
|
||||||
import {s} from 'lib/styles'
|
import {s} from 'lib/styles'
|
||||||
import {SearchInput} from 'view/com/util/forms/SearchInput'
|
import {SearchInput} from 'view/com/util/forms/SearchInput'
|
||||||
import {UserAvatar} from 'view/com/util/UserAvatar'
|
import {UserAvatar} from 'view/com/util/UserAvatar'
|
||||||
import {FeedFeedLoadingPlaceholder} from 'view/com/util/LoadingPlaceholder'
|
import {
|
||||||
|
LoadingPlaceholder,
|
||||||
|
FeedFeedLoadingPlaceholder,
|
||||||
|
} from 'view/com/util/LoadingPlaceholder'
|
||||||
import {ErrorMessage} from 'view/com/util/error/ErrorMessage'
|
import {ErrorMessage} from 'view/com/util/error/ErrorMessage'
|
||||||
import debounce from 'lodash.debounce'
|
import debounce from 'lodash.debounce'
|
||||||
import {Text} from 'view/com/util/text/Text'
|
import {Text} from 'view/com/util/text/Text'
|
||||||
|
@ -42,7 +45,12 @@ export const FeedsScreen = withAuthRequired(
|
||||||
React.useCallback(() => {
|
React.useCallback(() => {
|
||||||
store.shell.setMinimalShellMode(false)
|
store.shell.setMinimalShellMode(false)
|
||||||
myFeeds.setup()
|
myFeeds.setup()
|
||||||
}, [store.shell, myFeeds]),
|
|
||||||
|
const softResetSub = store.onScreenSoftReset(() => myFeeds.refresh())
|
||||||
|
return () => {
|
||||||
|
softResetSub.remove()
|
||||||
|
}
|
||||||
|
}, [store, myFeeds]),
|
||||||
)
|
)
|
||||||
|
|
||||||
const onPressCompose = React.useCallback(() => {
|
const onPressCompose = React.useCallback(() => {
|
||||||
|
@ -119,6 +127,14 @@ export const FeedsScreen = withAuthRequired(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return <View />
|
return <View />
|
||||||
|
} else if (item.type === 'saved-feeds-loading') {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{Array.from(Array(item.numItems)).map((_, i) => (
|
||||||
|
<SavedFeedLoadingPlaceholder key={`placeholder-${i}`} />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)
|
||||||
} else if (item.type === 'saved-feed') {
|
} else if (item.type === 'saved-feed') {
|
||||||
return (
|
return (
|
||||||
<SavedFeed
|
<SavedFeed
|
||||||
|
@ -262,10 +278,7 @@ function SavedFeed({
|
||||||
asAnchor
|
asAnchor
|
||||||
anchorNoUnderline>
|
anchorNoUnderline>
|
||||||
<UserAvatar type="algo" size={28} avatar={avatar} />
|
<UserAvatar type="algo" size={28} avatar={avatar} />
|
||||||
<Text
|
<Text type="lg-medium" style={[pal.text, s.flex1]} numberOfLines={1}>
|
||||||
type={isMobile ? 'lg' : 'lg-medium'}
|
|
||||||
style={[pal.text, s.flex1]}
|
|
||||||
numberOfLines={1}>
|
|
||||||
{displayName}
|
{displayName}
|
||||||
</Text>
|
</Text>
|
||||||
{isMobile && (
|
{isMobile && (
|
||||||
|
@ -279,6 +292,22 @@ function SavedFeed({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SavedFeedLoadingPlaceholder() {
|
||||||
|
const pal = usePalette('default')
|
||||||
|
const {isMobile} = useWebMediaQueries()
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
pal.border,
|
||||||
|
styles.savedFeed,
|
||||||
|
isMobile && styles.savedFeedMobile,
|
||||||
|
]}>
|
||||||
|
<LoadingPlaceholder width={28} height={28} style={{borderRadius: 4}} />
|
||||||
|
<LoadingPlaceholder width={140} height={12} />
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
|
|
@ -74,6 +74,7 @@ export const PostThreadScreen = withAuthRequired(({route}: Props) => {
|
||||||
uri={uri}
|
uri={uri}
|
||||||
view={view}
|
view={view}
|
||||||
onPressReply={onPressReply}
|
onPressReply={onPressReply}
|
||||||
|
treeView={store.preferences.threadTreeViewEnabled}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
{isMobile && (
|
{isMobile && (
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import React, {useState} from 'react'
|
import React, {useState} from 'react'
|
||||||
import {ScrollView, StyleSheet, TouchableOpacity, View} from 'react-native'
|
import {ScrollView, StyleSheet, TouchableOpacity, View} from 'react-native'
|
||||||
import {observer} from 'mobx-react-lite'
|
import {observer} from 'mobx-react-lite'
|
||||||
|
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||||
import {Slider} from '@miblanchard/react-native-slider'
|
import {Slider} from '@miblanchard/react-native-slider'
|
||||||
import {Text} from '../com/util/text/Text'
|
import {Text} from '../com/util/text/Text'
|
||||||
import {useStores} from 'state/index'
|
import {useStores} from 'state/index'
|
||||||
|
@ -66,7 +67,10 @@ export const PreferencesHomeFeed = observer(function PreferencesHomeFeedImpl({
|
||||||
]}>
|
]}>
|
||||||
<ViewHeader title="Home Feed Preferences" showOnDesktop />
|
<ViewHeader title="Home Feed Preferences" showOnDesktop />
|
||||||
<View
|
<View
|
||||||
style={[styles.titleSection, isTabletOrDesktop && {paddingTop: 20}]}>
|
style={[
|
||||||
|
styles.titleSection,
|
||||||
|
isTabletOrDesktop && {paddingTop: 20, paddingBottom: 20},
|
||||||
|
]}>
|
||||||
<Text type="xl" style={[pal.textLight, styles.description]}>
|
<Text type="xl" style={[pal.textLight, styles.description]}>
|
||||||
Fine-tune the content you see on your home screen.
|
Fine-tune the content you see on your home screen.
|
||||||
</Text>
|
</Text>
|
||||||
|
@ -155,11 +159,12 @@ export const PreferencesHomeFeed = observer(function PreferencesHomeFeedImpl({
|
||||||
|
|
||||||
<View style={[pal.viewLight, styles.card]}>
|
<View style={[pal.viewLight, styles.card]}>
|
||||||
<Text type="title-sm" style={[pal.text, s.pb5]}>
|
<Text type="title-sm" style={[pal.text, s.pb5]}>
|
||||||
Show Posts from My Feeds (Experimental)
|
<FontAwesomeIcon icon="flask" color={pal.colors.text} /> Show
|
||||||
|
Posts from My Feeds
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={[pal.text, s.pb10]}>
|
<Text style={[pal.text, s.pb10]}>
|
||||||
Set this setting to "Yes" to show samples of your saved feeds in
|
Set this setting to "Yes" to show samples of your saved feeds in
|
||||||
your following feed.
|
your following feed. This is an experimental feature.
|
||||||
</Text>
|
</Text>
|
||||||
<ToggleButton
|
<ToggleButton
|
||||||
type="default-light"
|
type="default-light"
|
||||||
|
@ -175,7 +180,7 @@ export const PreferencesHomeFeed = observer(function PreferencesHomeFeedImpl({
|
||||||
style={[
|
style={[
|
||||||
styles.btnContainer,
|
styles.btnContainer,
|
||||||
!isTabletOrDesktop && {borderTopWidth: 1, paddingHorizontal: 20},
|
!isTabletOrDesktop && {borderTopWidth: 1, paddingHorizontal: 20},
|
||||||
pal.borderDark,
|
pal.border,
|
||||||
]}>
|
]}>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
testID="confirmBtn"
|
testID="confirmBtn"
|
||||||
|
|
|
@ -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) {
|
} else if (item instanceof CustomFeedModel) {
|
||||||
return <CustomFeed item={item} showSaveBtn showLikes />
|
return (
|
||||||
|
<CustomFeed item={item} showSaveBtn showLikes showDescription />
|
||||||
|
)
|
||||||
}
|
}
|
||||||
// if section is posts or posts & replies
|
// if section is posts or posts & replies
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -180,6 +180,10 @@ export const SettingsScreen = withAuthRequired(
|
||||||
navigation.navigate('PreferencesHomeFeed')
|
navigation.navigate('PreferencesHomeFeed')
|
||||||
}, [navigation])
|
}, [navigation])
|
||||||
|
|
||||||
|
const openThreadsPreferences = React.useCallback(() => {
|
||||||
|
navigation.navigate('PreferencesThreads')
|
||||||
|
}, [navigation])
|
||||||
|
|
||||||
const onPressAppPasswords = React.useCallback(() => {
|
const onPressAppPasswords = React.useCallback(() => {
|
||||||
navigation.navigate('AppPasswords')
|
navigation.navigate('AppPasswords')
|
||||||
}, [navigation])
|
}, [navigation])
|
||||||
|
@ -420,6 +424,24 @@ export const SettingsScreen = withAuthRequired(
|
||||||
Home Feed Preferences
|
Home Feed Preferences
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
testID="preferencesThreadsButton"
|
||||||
|
style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]}
|
||||||
|
onPress={openThreadsPreferences}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityHint=""
|
||||||
|
accessibilityLabel="Opens the threads preferences">
|
||||||
|
<View style={[styles.iconContainer, pal.btn]}>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={['far', 'comments']}
|
||||||
|
style={pal.text as FontAwesomeIconStyle}
|
||||||
|
size={18}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Text type="lg" style={pal.text}>
|
||||||
|
Thread Preferences
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
testID="savedFeedsBtn"
|
testID="savedFeedsBtn"
|
||||||
style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]}
|
style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]}
|
||||||
|
|
Loading…
Reference in New Issue