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

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