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,98 +0,0 @@
import React from 'react'
import {StyleSheet, View} from 'react-native'
import {Button} from '../util/forms/Button'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {usePalette} from 'lib/hooks/usePalette'
export const ListActions = ({
muted,
onToggleSubscribed,
onPressEditList,
isOwner,
onPressDeleteList,
onPressShareList,
onPressReportList,
reversed = false, // Default value of reversed is false
}: {
isOwner: boolean
muted?: boolean
onToggleSubscribed?: () => void
onPressEditList?: () => void
onPressDeleteList?: () => void
onPressShareList?: () => void
onPressReportList?: () => void
reversed?: boolean // New optional prop
}) => {
const pal = usePalette('default')
let buttons = [
<Button
key="subscribeListBtn"
testID={muted ? 'unsubscribeListBtn' : 'subscribeListBtn'}
type={muted ? 'inverted' : 'primary'}
label={muted ? 'Unsubscribe' : 'Subscribe & Mute'}
accessibilityLabel={muted ? 'Unsubscribe' : 'Subscribe and mute'}
accessibilityHint=""
onPress={onToggleSubscribed}
/>,
isOwner && (
<Button
key="editListBtn"
testID="editListBtn"
type="default"
label="Edit List"
accessibilityLabel="Edit list"
accessibilityHint=""
onPress={onPressEditList}
/>
),
isOwner && (
<Button
key="deleteListBtn"
testID="deleteListBtn"
type="default"
accessibilityLabel="Delete list"
accessibilityHint=""
onPress={onPressDeleteList}>
<FontAwesomeIcon icon={['far', 'trash-can']} style={[pal.text]} />
</Button>
),
<Button
key="shareListBtn"
testID="shareListBtn"
type="default"
accessibilityLabel="Share list"
accessibilityHint=""
onPress={onPressShareList}>
<FontAwesomeIcon icon={'share'} style={[pal.text]} />
</Button>,
!isOwner && (
<Button
key="reportListBtn"
testID="reportListBtn"
type="default"
accessibilityLabel="Report list"
accessibilityHint=""
onPress={onPressReportList}>
<FontAwesomeIcon icon={'circle-exclamation'} style={[pal.text]} />
</Button>
),
]
// If reversed is true, reverse the array to reverse the order of the buttons
if (reversed) {
buttons = buttons.filter(Boolean).reverse() // filterting out any falsey values and reversing the array
} else {
buttons = buttons.filter(Boolean) // filterting out any falsey values
}
return <View style={styles.headerBtns}>{buttons}</View>
}
const styles = StyleSheet.create({
headerBtns: {
flexDirection: 'row',
gap: 8,
marginTop: 12,
},
})

View file

@ -76,7 +76,10 @@ export const ListCard = ({
{sanitizeDisplayName(list.name)}
</Text>
<Text type="md" style={[pal.textLight]} numberOfLines={1}>
{list.purpose === 'app.bsky.graph.defs#modlist' && 'Mute list'} by{' '}
{list.purpose === 'app.bsky.graph.defs#curatelist' && 'User list '}
{list.purpose === 'app.bsky.graph.defs#modlist' &&
'Moderation list '}
by{' '}
{list.creator.did === store.me.did
? 'you'
: sanitizeHandle(list.creator.handle, '@')}

View file

@ -3,34 +3,26 @@ import {
ActivityIndicator,
RefreshControl,
StyleProp,
StyleSheet,
View,
ViewStyle,
FlatList,
} from 'react-native'
import {AppBskyActorDefs, AppBskyGraphDefs, RichText} from '@atproto/api'
import {AppBskyActorDefs, AppBskyGraphDefs} from '@atproto/api'
import {FlatList} from '../util/Views'
import {observer} from 'mobx-react-lite'
import {ProfileCardFeedLoadingPlaceholder} from '../util/LoadingPlaceholder'
import {ErrorMessage} from '../util/error/ErrorMessage'
import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn'
import {ProfileCard} from '../profile/ProfileCard'
import {Button} from '../util/forms/Button'
import {Text} from '../util/text/Text'
import {RichText as RichTextCom} from '../util/text/RichText'
import {UserAvatar} from '../util/UserAvatar'
import {TextLink} from '../util/Link'
import {ListModel} from 'state/models/content/list'
import {useAnalytics} from 'lib/analytics/analytics'
import {usePalette} from 'lib/hooks/usePalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {useStores} from 'state/index'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {s} from 'lib/styles'
import {ListActions} from './ListActions'
import {makeProfileLink} from 'lib/routes/links'
import {sanitizeHandle} from 'lib/strings/handles'
import {OnScrollCb} from 'lib/hooks/useOnMainScroll'
const LOADING_ITEM = {_reactKey: '__loading__'}
const HEADER_ITEM = {_reactKey: '__header__'}
const EMPTY_ITEM = {_reactKey: '__empty__'}
const ERROR_ITEM = {_reactKey: '__error__'}
const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'}
@ -39,36 +31,35 @@ export const ListItems = observer(function ListItemsImpl({
list,
style,
scrollElRef,
onScroll,
onPressTryAgain,
onToggleSubscribed,
onPressEditList,
onPressDeleteList,
onPressShareList,
onPressReportList,
renderHeader,
renderEmptyState,
testID,
scrollEventThrottle,
headerOffset = 0,
desktopFixedHeightOffset,
}: {
list: ListModel
style?: StyleProp<ViewStyle>
scrollElRef?: MutableRefObject<FlatList<any> | null>
onScroll?: OnScrollCb
onPressTryAgain?: () => void
onToggleSubscribed: () => void
onPressEditList: () => void
onPressDeleteList: () => void
onPressShareList: () => void
onPressReportList: () => void
renderEmptyState?: () => JSX.Element
renderHeader: () => JSX.Element
renderEmptyState: () => JSX.Element
testID?: string
scrollEventThrottle?: number
headerOffset?: number
desktopFixedHeightOffset?: number
}) {
const pal = usePalette('default')
const store = useStores()
const {track} = useAnalytics()
const [isRefreshing, setIsRefreshing] = React.useState(false)
const {isMobile} = useWebMediaQueries()
const data = React.useMemo(() => {
let items: any[] = [HEADER_ITEM]
let items: any[] = []
if (list.hasLoaded) {
if (list.hasError) {
items = items.concat([ERROR_ITEM])
@ -124,11 +115,18 @@ export const ListItems = observer(function ListItemsImpl({
const onPressEditMembership = React.useCallback(
(profile: AppBskyActorDefs.ProfileViewBasic) => {
store.shell.openModal({
name: 'list-add-remove-user',
name: 'user-add-remove-lists',
subject: profile.did,
displayName: profile.displayName || profile.handle,
onUpdate() {
list.refresh()
onAdd(listUri: string) {
if (listUri === list.uri) {
list.cacheAddMember(profile)
}
},
onRemove(listUri: string) {
if (listUri === list.uri) {
list.cacheRemoveMember(profile)
}
},
})
},
@ -145,6 +143,7 @@ export const ListItems = observer(function ListItemsImpl({
}
return (
<Button
testID={`user-${profile.handle}-editBtn`}
type="default"
label="Edit"
onPress={() => onPressEditMembership(profile)}
@ -157,22 +156,7 @@ export const ListItems = observer(function ListItemsImpl({
const renderItem = React.useCallback(
({item}: {item: any}) => {
if (item === EMPTY_ITEM) {
if (renderEmptyState) {
return renderEmptyState()
}
return <View />
} else if (item === HEADER_ITEM) {
return list.list ? (
<ListHeader
list={list.list}
isOwner={list.isOwner}
onToggleSubscribed={onToggleSubscribed}
onPressEditList={onPressEditList}
onPressDeleteList={onPressDeleteList}
onPressShareList={onPressShareList}
onPressReportList={onPressReportList}
/>
) : null
return renderEmptyState()
} else if (item === ERROR_ITEM) {
return (
<ErrorMessage
@ -197,178 +181,59 @@ export const ListItems = observer(function ListItemsImpl({
}`}
profile={(item as AppBskyGraphDefs.ListItemView).subject}
renderButton={renderMemberButton}
style={{paddingHorizontal: isMobile ? 8 : 14, paddingVertical: 4}}
/>
)
},
[
renderMemberButton,
renderEmptyState,
list.list,
list.isOwner,
list.error,
onToggleSubscribed,
onPressEditList,
onPressDeleteList,
onPressShareList,
onPressReportList,
onPressTryAgain,
onPressRetryLoadMore,
isMobile,
],
)
const Footer = React.useCallback(
() =>
list.isLoading ? (
<View style={styles.feedFooter}>
<ActivityIndicator />
</View>
) : (
<View />
),
[list],
() => (
<View style={{paddingTop: 20, paddingBottom: 200}}>
{list.isLoading && <ActivityIndicator />}
</View>
),
[list.isLoading],
)
return (
<View testID={testID} style={style}>
{data.length > 0 && (
<FlatList
testID={testID ? `${testID}-flatlist` : undefined}
ref={scrollElRef}
data={data}
keyExtractor={item => item._reactKey}
renderItem={renderItem}
ListFooterComponent={Footer}
refreshControl={
<RefreshControl
refreshing={isRefreshing}
onRefresh={onRefresh}
tintColor={pal.colors.text}
titleColor={pal.colors.text}
progressViewOffset={headerOffset}
/>
}
contentContainerStyle={s.contentContainer}
style={{paddingTop: headerOffset}}
onEndReached={onEndReached}
onEndReachedThreshold={0.6}
removeClippedSubviews={true}
contentOffset={{x: 0, y: headerOffset * -1}}
// @ts-ignore our .web version only -prf
desktopFixedHeight
/>
)}
<FlatList
testID={testID ? `${testID}-flatlist` : undefined}
ref={scrollElRef}
data={data}
keyExtractor={(item: any) => item._reactKey}
renderItem={renderItem}
ListHeaderComponent={renderHeader}
ListFooterComponent={Footer}
refreshControl={
<RefreshControl
refreshing={isRefreshing}
onRefresh={onRefresh}
tintColor={pal.colors.text}
titleColor={pal.colors.text}
progressViewOffset={headerOffset}
/>
}
contentContainerStyle={s.contentContainer}
style={{paddingTop: headerOffset}}
onScroll={onScroll}
onEndReached={onEndReached}
onEndReachedThreshold={0.6}
scrollEventThrottle={scrollEventThrottle}
removeClippedSubviews={true}
contentOffset={{x: 0, y: headerOffset * -1}}
// @ts-ignore our .web version only -prf
desktopFixedHeight={desktopFixedHeightOffset || true}
/>
</View>
)
})
const ListHeader = observer(function ListHeaderImpl({
list,
isOwner,
onToggleSubscribed,
onPressEditList,
onPressDeleteList,
onPressShareList,
onPressReportList,
}: {
list: AppBskyGraphDefs.ListView
isOwner: boolean
onToggleSubscribed: () => void
onPressEditList: () => void
onPressDeleteList: () => void
onPressShareList: () => void
onPressReportList: () => void
}) {
const pal = usePalette('default')
const store = useStores()
const {isDesktop} = useWebMediaQueries()
const descriptionRT = React.useMemo(
() =>
list?.description &&
new RichText({
text: list.description,
facets: (list.descriptionFacets || [])?.slice(),
}),
[list],
)
return (
<>
<View style={[styles.header, pal.border]}>
<View style={s.flex1}>
<Text testID="listName" type="title-xl" style={[pal.text, s.bold]}>
{list.name}
</Text>
{list && (
<Text type="md" style={[pal.textLight]} numberOfLines={1}>
{list.purpose === 'app.bsky.graph.defs#modlist' && 'Mute list '}
by{' '}
{list.creator.did === store.me.did ? (
'you'
) : (
<TextLink
text={sanitizeHandle(list.creator.handle, '@')}
href={makeProfileLink(list.creator)}
style={pal.textLight}
/>
)}
</Text>
)}
{descriptionRT && (
<RichTextCom
testID="listDescription"
style={[pal.text, styles.headerDescription]}
richText={descriptionRT}
/>
)}
{isDesktop && (
<ListActions
isOwner={isOwner}
muted={list.viewer?.muted}
onPressDeleteList={onPressDeleteList}
onPressEditList={onPressEditList}
onToggleSubscribed={onToggleSubscribed}
onPressShareList={onPressShareList}
onPressReportList={onPressReportList}
/>
)}
</View>
<View>
<UserAvatar type="list" avatar={list.avatar} size={64} />
</View>
</View>
<View
style={{flexDirection: 'row', paddingHorizontal: isDesktop ? 16 : 6}}>
<View style={[styles.fakeSelectorItem, {borderColor: pal.colors.link}]}>
<Text type="md-medium" style={[pal.text]}>
Muted users
</Text>
</View>
</View>
</>
)
})
const styles = StyleSheet.create({
header: {
flexDirection: 'row',
gap: 12,
paddingHorizontal: 16,
paddingTop: 12,
paddingBottom: 16,
borderTopWidth: 1,
},
headerDescription: {
flex: 1,
marginTop: 8,
},
headerBtns: {
flexDirection: 'row',
gap: 8,
marginTop: 12,
},
fakeSelectorItem: {
paddingHorizontal: 12,
paddingBottom: 8,
borderBottomWidth: 3,
},
feedFooter: {paddingTop: 20},
})

View file

@ -1,57 +1,44 @@
import React, {MutableRefObject} from 'react'
import React from 'react'
import {
ActivityIndicator,
FlatList as RNFlatList,
RefreshControl,
StyleProp,
StyleSheet,
View,
ViewStyle,
FlatList,
} from 'react-native'
import {observer} from 'mobx-react-lite'
import {
FontAwesomeIcon,
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import {AppBskyGraphDefs as GraphDefs} from '@atproto/api'
import {ListCard} from './ListCard'
import {ProfileCardFeedLoadingPlaceholder} from '../util/LoadingPlaceholder'
import {ErrorMessage} from '../util/error/ErrorMessage'
import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn'
import {Button} from '../util/forms/Button'
import {Text} from '../util/text/Text'
import {ListsListModel} from 'state/models/lists/lists-list'
import {useAnalytics} from 'lib/analytics/analytics'
import {usePalette} from 'lib/hooks/usePalette'
import {FlatList} from '../util/Views.web'
import {s} from 'lib/styles'
const LOADING_ITEM = {_reactKey: '__loading__'}
const CREATENEW_ITEM = {_reactKey: '__loading__'}
const EMPTY_ITEM = {_reactKey: '__empty__'}
const LOADING = {_reactKey: '__loading__'}
const EMPTY = {_reactKey: '__empty__'}
const ERROR_ITEM = {_reactKey: '__error__'}
const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'}
export const ListsList = observer(function ListsListImpl({
listsList,
showAddBtns,
inline,
style,
scrollElRef,
onPressTryAgain,
onPressCreateNew,
renderItem,
renderEmptyState,
testID,
headerOffset = 0,
}: {
listsList: ListsListModel
showAddBtns?: boolean
inline?: boolean
style?: StyleProp<ViewStyle>
scrollElRef?: MutableRefObject<FlatList<any> | null>
onPressCreateNew: () => void
onPressTryAgain?: () => void
renderItem?: (list: GraphDefs.ListView) => JSX.Element
renderEmptyState?: () => JSX.Element
renderItem?: (list: GraphDefs.ListView, index: number) => JSX.Element
testID?: string
headerOffset?: number
}) {
const pal = usePalette('default')
const {track} = useAnalytics()
@ -59,33 +46,27 @@ export const ListsList = observer(function ListsListImpl({
const data = React.useMemo(() => {
let items: any[] = []
if (listsList.hasLoaded) {
if (listsList.hasError) {
items = items.concat([ERROR_ITEM])
}
if (listsList.isEmpty) {
items = items.concat([EMPTY_ITEM])
} else {
if (showAddBtns) {
items = items.concat([CREATENEW_ITEM])
}
items = items.concat(listsList.lists)
}
if (listsList.loadMoreError) {
items = items.concat([LOAD_MORE_ERROR_ITEM])
}
} else if (listsList.isLoading) {
items = items.concat([LOADING_ITEM])
if (listsList.hasError) {
items = items.concat([ERROR_ITEM])
}
if (!listsList.hasLoaded && listsList.isLoading) {
items = items.concat([LOADING])
} else if (listsList.isEmpty) {
items = items.concat([EMPTY])
} else {
items = items.concat(listsList.lists)
}
if (listsList.loadMoreError) {
items = items.concat([LOAD_MORE_ERROR_ITEM])
}
return items
}, [
listsList.hasError,
listsList.hasLoaded,
listsList.isLoading,
listsList.isEmpty,
listsList.lists,
listsList.isEmpty,
listsList.loadMoreError,
showAddBtns,
])
// events
@ -119,14 +100,15 @@ export const ListsList = observer(function ListsListImpl({
// =
const renderItemInner = React.useCallback(
({item}: {item: any}) => {
if (item === EMPTY_ITEM) {
if (renderEmptyState) {
return renderEmptyState()
}
return <View />
} else if (item === CREATENEW_ITEM) {
return <CreateNewItem onPress={onPressCreateNew} />
({item, index}: {item: any; index: number}) => {
if (item === EMPTY) {
return (
<View
testID="listsEmpty"
style={[{padding: 18, borderTopWidth: 1}, pal.border]}>
<Text style={pal.textLight}>You have no lists.</Text>
</View>
)
} else if (item === ERROR_ITEM) {
return (
<ErrorMessage
@ -141,11 +123,15 @@ export const ListsList = observer(function ListsListImpl({
onPress={onPressRetryLoadMore}
/>
)
} else if (item === LOADING_ITEM) {
return <ProfileCardFeedLoadingPlaceholder />
} else if (item === LOADING) {
return (
<View style={{padding: 20}}>
<ActivityIndicator />
</View>
)
}
return renderItem ? (
renderItem(item)
renderItem(item, index)
) : (
<ListCard
list={item}
@ -154,24 +140,17 @@ export const ListsList = observer(function ListsListImpl({
/>
)
},
[
listsList,
onPressTryAgain,
onPressRetryLoadMore,
onPressCreateNew,
renderItem,
renderEmptyState,
],
[listsList, onPressTryAgain, onPressRetryLoadMore, renderItem, pal],
)
const FlatListCom = inline ? RNFlatList : FlatList
return (
<View testID={testID} style={style}>
{data.length > 0 && (
<FlatList
<FlatListCom
testID={testID ? `${testID}-flatlist` : undefined}
ref={scrollElRef}
data={data}
keyExtractor={item => item._reactKey}
keyExtractor={(item: any) => item._reactKey}
renderItem={renderItemInner}
refreshControl={
<RefreshControl
@ -179,15 +158,12 @@ export const ListsList = observer(function ListsListImpl({
onRefresh={onRefresh}
tintColor={pal.colors.text}
titleColor={pal.colors.text}
progressViewOffset={headerOffset}
/>
}
contentContainerStyle={[s.contentContainer]}
style={{paddingTop: headerOffset}}
onEndReached={onEndReached}
onEndReachedThreshold={0.6}
removeClippedSubviews={true}
contentOffset={{x: 0, y: headerOffset * -1}}
// @ts-ignore our .web version only -prf
desktopFixedHeight
/>
@ -196,36 +172,9 @@ export const ListsList = observer(function ListsListImpl({
)
})
function CreateNewItem({onPress}: {onPress: () => void}) {
const pal = usePalette('default')
return (
<View style={[styles.createNewContainer]}>
<Button type="default" onPress={onPress} style={styles.createNewButton}>
<FontAwesomeIcon icon="plus" style={pal.text as FontAwesomeIconStyle} />
<Text type="button" style={pal.text}>
New Mute List
</Text>
</Button>
</View>
)
}
const styles = StyleSheet.create({
createNewContainer: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 18,
paddingTop: 18,
paddingBottom: 16,
},
createNewButton: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
feedFooter: {paddingTop: 20},
item: {
paddingHorizontal: 18,
paddingVertical: 4,
},
})