Lists updates: curate lists and blocklists (#1689)

* Add lists screen

* Update Lists screen and List create/edit modal to support curate lists

* Rework the ProfileList screen and add curatelist support

* More ProfileList progress

* Update list modals

* Rename mutelists to modlists

* Layout updates/fixes

* More layout fixes

* Modal fixes

* List list screen updates

* Update feed page to give more info

* Layout fixes to ListAddUser modal

* Layout fixes to FlatList and Feed on desktop

* Layout fix to LoadLatestBtn on Web

* Handle did resolution before showing the ProfileList screen

* Rename the CustomFeed routes to ProfileFeed for consistency

* Fix layout issues with the pager and feeds

* Factor out some common code

* Fix UIs for mobile

* Fix user list rendering

* Fix: dont bubble custom feed errors in the merge feed

* Refactor feed models to reduce usage of the SavedFeeds model

* Replace CustomFeedModel with FeedSourceModel which abstracts feed-generators and lists

* Add the ability to pin lists

* Add pinned lists to mobile

* Remove dead code

* Rework the ProfileScreenHeader to create more real-estate for action buttons

* Improve layout behavior on web mobile breakpoints

* Refactor feed & list pages to use new Tabs layout component

* Refactor to ProfileSubpageHeader

* Implement modlist block and mute

* Switch to new api and just modify state on modlist actions

* Fix some UI overflows

* Fix: dont show edit buttons on lists you dont own

* Fix alignment issue on long titles

* Improve loading and error states for feeds & lists

* Update list dropdown icons for ios

* Fetch feed display names in the mergefeed

* Improve rendering off offline feeds in the feed-listing page

* Update Feeds listing UI to react to changes in saved/pinned state

* Refresh list and feed on posts tab press

* Fix pinned feed ordering UI

* Fixes to list pinning

* Remove view=simple qp

* Add list to feed tuners

* Render richtext

* Add list href

* Add 'view avatar'

* Remove unused import

* Fix missing import

* Correctly reflect block by list state

* Replace the <Tabs> component with the more effective <PagerWithHeader> component

* Improve the responsiveness of the PagerWithHeader

* Fix visual jank in the feed loading state

* Improve performance of the PagerWithHeader

* Fix a case that would cause the header to animate too aggressively

* Add the ability to scroll to top by tapping the selected tab

* Fix unit test runner

* Update modlists test

* Add curatelist tests

* Fix: remove link behavior in ListAddUser modal

* Fix some layout jank in the PagerWithHeader on iOS

* Simplify ListItems header rendering

* Wait for the appview to recognize the list before proceeding with list creation

* Fix glitch in the onPageSelecting index of the Pager

* Fix until()

* Copy fix

Co-authored-by: Eric Bailey <git@esb.lol>

---------

Co-authored-by: Eric Bailey <git@esb.lol>
This commit is contained in:
Paul Frazee 2023-11-01 16:15:40 -07:00 committed by GitHub
parent f9944b55e2
commit f57a8cf8ba
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
87 changed files with 4090 additions and 1988 deletions

View file

@ -1,5 +1,5 @@
import * as React from 'react'
import {StyleSheet, View} from 'react-native'
import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
import {observer} from 'mobx-react-lite'
import {
AppBskyActorDefs,
@ -29,6 +29,7 @@ export const ProfileCard = observer(function ProfileCardImpl({
noBorder,
followers,
renderButton,
style,
}: {
testID?: string
profile: AppBskyActorDefs.ProfileViewBasic
@ -36,6 +37,7 @@ export const ProfileCard = observer(function ProfileCardImpl({
noBorder?: boolean
followers?: AppBskyActorDefs.ProfileView[] | undefined
renderButton?: (profile: AppBskyActorDefs.ProfileViewBasic) => React.ReactNode
style?: StyleProp<ViewStyle>
}) {
const store = useStores()
const pal = usePalette('default')
@ -50,6 +52,7 @@ export const ProfileCard = observer(function ProfileCardImpl({
pal.border,
noBorder && styles.outerNoBorder,
!noBg && pal.view,
style,
]}
href={makeProfileLink(profile)}
title={profile.handle}
@ -93,7 +96,7 @@ export const ProfileCard = observer(function ProfileCardImpl({
{profile.description as string}
</Text>
</View>
) : undefined}
) : null}
<FollowersList followers={followers} />
</Link>
)
@ -220,10 +223,10 @@ const styles = StyleSheet.create({
alignItems: 'center',
},
layoutAvi: {
alignSelf: 'baseline',
width: 54,
paddingLeft: 4,
paddingTop: 8,
paddingBottom: 10,
paddingTop: 10,
},
avi: {
width: 40,

View file

@ -181,7 +181,7 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({
const onPressAddRemoveLists = React.useCallback(() => {
track('ProfileHeader:AddToListsButtonClicked')
store.shell.openModal({
name: 'list-add-remove-user',
name: 'user-add-remove-lists',
subject: view.did,
displayName: view.displayName || view.handle,
})
@ -276,21 +276,20 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({
},
},
]
if (!isMe) {
items.push({label: 'separator'})
// Only add "Add to Lists" on other user's profiles, doesn't make sense to mute my own self!
items.push({
testID: 'profileHeaderDropdownListAddRemoveBtn',
label: 'Add to Lists',
onPress: onPressAddRemoveLists,
icon: {
ios: {
name: 'list.bullet',
},
android: 'ic_menu_add',
web: 'list',
items.push({label: 'separator'})
items.push({
testID: 'profileHeaderDropdownListAddRemoveBtn',
label: 'Add to Lists',
onPress: onPressAddRemoveLists,
icon: {
ios: {
name: 'list.bullet',
},
})
android: 'ic_menu_add',
web: 'list',
},
})
if (!isMe) {
if (!view.viewer.blocking) {
items.push({
testID: 'profileHeaderDropdownMuteBtn',
@ -307,20 +306,22 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({
},
})
}
items.push({
testID: 'profileHeaderDropdownBlockBtn',
label: view.viewer.blocking ? 'Unblock Account' : 'Block Account',
onPress: view.viewer.blocking
? onPressUnblockAccount
: onPressBlockAccount,
icon: {
ios: {
name: 'person.fill.xmark',
if (!view.viewer.blockingByList) {
items.push({
testID: 'profileHeaderDropdownBlockBtn',
label: view.viewer.blocking ? 'Unblock Account' : 'Block Account',
onPress: view.viewer.blocking
? onPressUnblockAccount
: onPressBlockAccount,
icon: {
ios: {
name: 'person.fill.xmark',
},
android: 'ic_menu_close_clear_cancel',
web: 'user-slash',
},
android: 'ic_menu_close_clear_cancel',
web: 'user-slash',
},
})
})
}
items.push({
testID: 'profileHeaderDropdownReportBtn',
label: 'Report Account',
@ -339,6 +340,7 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({
isMe,
view.viewer.muted,
view.viewer.blocking,
view.viewer.blockingByList,
onPressShare,
onPressUnmuteAccount,
onPressMuteAccount,
@ -371,17 +373,19 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({
</Text>
</TouchableOpacity>
) : view.viewer.blocking ? (
<TouchableOpacity
testID="unblockBtn"
onPress={onPressUnblockAccount}
style={[styles.btn, styles.mainBtn, pal.btn]}
accessibilityRole="button"
accessibilityLabel="Unblock"
accessibilityHint="">
<Text type="button" style={[pal.text, s.bold]}>
Unblock
</Text>
</TouchableOpacity>
view.viewer.blockingByList ? null : (
<TouchableOpacity
testID="unblockBtn"
onPress={onPressUnblockAccount}
style={[styles.btn, styles.mainBtn, pal.btn]}
accessibilityRole="button"
accessibilityLabel="Unblock"
accessibilityHint="">
<Text type="button" style={[pal.text, s.bold]}>
Unblock
</Text>
</TouchableOpacity>
)
) : !view.viewer.blockedBy ? (
<>
{!isProfilePreview && (

View file

@ -0,0 +1,194 @@
import React from 'react'
import {Pressable, StyleSheet, View} from 'react-native'
import {observer} from 'mobx-react-lite'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {useNavigation} from '@react-navigation/native'
import {usePalette} from 'lib/hooks/usePalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {Text} from '../util/text/Text'
import {TextLink} from '../util/Link'
import {UserAvatar, UserAvatarType} from '../util/UserAvatar'
import {LoadingPlaceholder} from '../util/LoadingPlaceholder'
import {CenteredView} from '../util/Views'
import {sanitizeHandle} from 'lib/strings/handles'
import {makeProfileLink} from 'lib/routes/links'
import {useStores} from 'state/index'
import {NavigationProp} from 'lib/routes/types'
import {BACK_HITSLOP} from 'lib/constants'
import {isNative} from 'platform/detection'
import {ImagesLightbox} from 'state/models/ui/shell'
export const ProfileSubpageHeader = observer(function HeaderImpl({
isLoading,
href,
title,
avatar,
isOwner,
creator,
avatarType,
children,
}: React.PropsWithChildren<{
isLoading?: boolean
href: string
title: string | undefined
avatar: string | undefined
isOwner: boolean | undefined
creator:
| {
did: string
handle: string
}
| undefined
avatarType: UserAvatarType
}>) {
const store = useStores()
const navigation = useNavigation<NavigationProp>()
const {isMobile} = useWebMediaQueries()
const pal = usePalette('default')
const canGoBack = navigation.canGoBack()
const onPressBack = React.useCallback(() => {
if (navigation.canGoBack()) {
navigation.goBack()
} else {
navigation.navigate('Home')
}
}, [navigation])
const onPressMenu = React.useCallback(() => {
store.shell.openDrawer()
}, [store])
const onPressAvi = React.useCallback(() => {
if (
avatar // TODO && !(view.moderation.avatar.blur && view.moderation.avatar.noOverride)
) {
store.shell.openLightbox(new ImagesLightbox([{uri: avatar}], 0))
}
}, [store, avatar])
return (
<CenteredView style={pal.view}>
{isMobile && (
<View
style={[
{
flexDirection: 'row',
alignItems: 'center',
borderBottomWidth: 1,
paddingTop: isNative ? 0 : 8,
paddingBottom: 8,
paddingHorizontal: isMobile ? 12 : 14,
},
pal.border,
]}>
<Pressable
testID="headerDrawerBtn"
onPress={canGoBack ? onPressBack : onPressMenu}
hitSlop={BACK_HITSLOP}
style={canGoBack ? styles.backBtn : styles.backBtnWide}
accessibilityRole="button"
accessibilityLabel={canGoBack ? 'Back' : 'Menu'}
accessibilityHint="">
{canGoBack ? (
<FontAwesomeIcon
size={18}
icon="angle-left"
style={[styles.backIcon, pal.text]}
/>
) : (
<FontAwesomeIcon
size={18}
icon="bars"
style={[styles.backIcon, pal.textLight]}
/>
)}
</Pressable>
<View style={{flex: 1}} />
{children}
</View>
)}
<View
style={{
flexDirection: 'row',
alignItems: 'flex-start',
gap: 10,
paddingTop: 14,
paddingBottom: 6,
paddingHorizontal: isMobile ? 12 : 14,
}}>
<Pressable
testID="headerAviButton"
onPress={onPressAvi}
accessibilityRole="image"
accessibilityLabel="View the avatar"
accessibilityHint=""
style={{width: 58}}>
<UserAvatar type={avatarType} size={58} avatar={avatar} />
</Pressable>
<View style={{flex: 1}}>
{isLoading ? (
<LoadingPlaceholder
width={200}
height={32}
style={{marginVertical: 6}}
/>
) : (
<TextLink
testID="headerTitle"
type="title-xl"
href={href}
style={[pal.text, {fontWeight: 'bold'}]}
text={title || ''}
onPress={() => store.emitScreenSoftReset()}
numberOfLines={4}
/>
)}
{isLoading ? (
<LoadingPlaceholder width={50} height={8} />
) : (
<Text type="xl" style={[pal.textLight]} numberOfLines={1}>
by{' '}
{!creator ? (
'—'
) : isOwner ? (
'you'
) : (
<TextLink
text={sanitizeHandle(creator.handle, '@')}
href={makeProfileLink(creator)}
style={pal.textLight}
/>
)}
</Text>
)}
</View>
{!isMobile && (
<View
style={{
flexDirection: 'row',
alignItems: 'center',
}}>
{children}
</View>
)}
</View>
</CenteredView>
)
})
const styles = StyleSheet.create({
backBtn: {
width: 20,
height: 30,
},
backBtnWide: {
width: 20,
height: 30,
paddingHorizontal: 6,
},
backIcon: {
marginTop: 6,
},
})