[APP-635] Mutelists (#601)

* Add lists and profilelist screens

* Implement lists screen and lists-list in profiles

* Add empty states to the lists screen

* Switch (mostly) from blocklists to mutelists

* Rework: create a new moderation screen and move everything related under it

* Fix moderation screen on desktop web

* Tune the empty state code

* Change content moderation modal to content filtering

* Add CreateMuteList modal

* Implement mutelist creation

* Add lists listings

* Add the ability to create new mutelists

* Add 'add to list' tool

* Satisfy the hashtag hyphen haters

* Add update/delete/subscribe/unsubscribe to lists

* Show which list caused a mute

* Add list un/subscribe

* Add the mute override when viewing a profile's posts

* Update to latest backend

* Add simulation tests and tune some behaviors

* Fix lint

* Bump deps

* Fix list refresh after creation

* Mute list subscriptions -> Mute lists
This commit is contained in:
Paul Frazee 2023-05-11 16:08:21 -05:00 committed by GitHub
parent 34d8fa5991
commit ebcd633386
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
48 changed files with 2984 additions and 151 deletions

View file

@ -0,0 +1,387 @@
import React, {MutableRefObject} from 'react'
import {
ActivityIndicator,
RefreshControl,
StyleProp,
StyleSheet,
View,
ViewStyle,
} from 'react-native'
import {AppBskyActorDefs, AppBskyGraphDefs, RichText} from '@atproto/api'
import {observer} from 'mobx-react-lite'
import {FlatList} from '../util/Views'
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'
import {usePalette} from 'lib/hooks/usePalette'
import {useStores} from 'state/index'
import {s} from 'lib/styles'
import {isDesktopWeb} from 'platform/detection'
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__'}
export const ListItems = observer(
({
list,
style,
scrollElRef,
onPressTryAgain,
onToggleSubscribed,
onPressEditList,
onPressDeleteList,
renderEmptyState,
testID,
headerOffset = 0,
}: {
list: ListModel
style?: StyleProp<ViewStyle>
scrollElRef?: MutableRefObject<FlatList<any> | null>
onPressTryAgain?: () => void
onToggleSubscribed?: () => void
onPressEditList?: () => void
onPressDeleteList?: () => void
renderEmptyState?: () => JSX.Element
testID?: string
headerOffset?: number
}) => {
const pal = usePalette('default')
const store = useStores()
const {track} = useAnalytics()
const [isRefreshing, setIsRefreshing] = React.useState(false)
const data = React.useMemo(() => {
let items: any[] = [HEADER_ITEM]
if (list.hasLoaded) {
if (list.hasError) {
items = items.concat([ERROR_ITEM])
}
if (list.isEmpty) {
items = items.concat([EMPTY_ITEM])
} else {
items = items.concat(list.items)
}
if (list.loadMoreError) {
items = items.concat([LOAD_MORE_ERROR_ITEM])
}
} else if (list.isLoading) {
items = items.concat([LOADING_ITEM])
}
return items
}, [
list.hasError,
list.hasLoaded,
list.isLoading,
list.isEmpty,
list.items,
list.loadMoreError,
])
// events
// =
const onRefresh = React.useCallback(async () => {
track('Lists:onRefresh')
setIsRefreshing(true)
try {
await list.refresh()
} catch (err) {
list.rootStore.log.error('Failed to refresh lists', err)
}
setIsRefreshing(false)
}, [list, track, setIsRefreshing])
const onEndReached = React.useCallback(async () => {
track('Lists:onEndReached')
try {
await list.loadMore()
} catch (err) {
list.rootStore.log.error('Failed to load more lists', err)
}
}, [list, track])
const onPressRetryLoadMore = React.useCallback(() => {
list.retryLoadMore()
}, [list])
const onPressEditMembership = React.useCallback(
(profile: AppBskyActorDefs.ProfileViewBasic) => {
store.shell.openModal({
name: 'list-add-remove-user',
subject: profile.did,
displayName: profile.displayName || profile.handle,
onUpdate() {
list.refresh()
},
})
},
[store, list],
)
// rendering
// =
const renderMemberButton = React.useCallback(
(profile: AppBskyActorDefs.ProfileViewBasic) => {
if (!list.isOwner) {
return null
}
return (
<Button
type="default"
label="Edit"
onPress={() => onPressEditMembership(profile)}
/>
)
},
[list, onPressEditMembership],
)
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}
/>
) : null
} else if (item === ERROR_ITEM) {
return (
<ErrorMessage
message={list.error}
onPressTryAgain={onPressTryAgain}
/>
)
} else if (item === LOAD_MORE_ERROR_ITEM) {
return (
<LoadMoreRetryBtn
label="There was an issue fetching the list. Tap here to try again."
onPress={onPressRetryLoadMore}
/>
)
} else if (item === LOADING_ITEM) {
return <ProfileCardFeedLoadingPlaceholder />
}
return (
<ProfileCard
testID={`user-${
(item as AppBskyGraphDefs.ListItemView).subject.handle
}`}
profile={(item as AppBskyGraphDefs.ListItemView).subject}
renderButton={renderMemberButton}
/>
)
},
[
list,
onPressTryAgain,
onPressRetryLoadMore,
renderMemberButton,
onPressEditList,
onPressDeleteList,
onToggleSubscribed,
renderEmptyState,
],
)
const Footer = React.useCallback(
() =>
list.isLoading ? (
<View style={styles.feedFooter}>
<ActivityIndicator />
</View>
) : (
<View />
),
[list],
)
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
/>
)}
</View>
)
},
)
const ListHeader = observer(
({
list,
isOwner,
onToggleSubscribed,
onPressEditList,
onPressDeleteList,
}: {
list: AppBskyGraphDefs.ListView
isOwner: boolean
onToggleSubscribed?: () => void
onPressEditList?: () => void
onPressDeleteList?: () => void
}) => {
const pal = usePalette('default')
const store = useStores()
const descriptionRT = React.useMemo(
() =>
list?.description &&
new RichText({text: list.description, facets: list.descriptionFacets}),
[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={`@${list.creator.handle}`}
href={`/profile/${list.creator.did}`}
/>
)}
</Text>
)}
{descriptionRT && (
<RichTextCom
testID="listDescription"
style={[pal.text, styles.headerDescription]}
richText={descriptionRT}
/>
)}
{isDesktopWeb && (
<View style={styles.headerBtns}>
{list.viewer?.muted ? (
<Button
type="inverted"
label="Unsubscribe"
accessibilityLabel="Unsubscribe from this list"
accessibilityHint="Stops muting the users included in this list"
onPress={onToggleSubscribed}
/>
) : (
<Button
type="primary"
label="Subscribe & Mute"
accessibilityLabel="Subscribe to this list"
accessibilityHint="Mutes the users included in this list"
onPress={onToggleSubscribed}
/>
)}
{isOwner && (
<Button
type="default"
label="Edit List"
accessibilityLabel="Edit list"
accessibilityHint="Opens a modal to edit the mutelist"
onPress={onPressEditList}
/>
)}
{isOwner && (
<Button
type="default"
label="Delete List"
accessibilityLabel="Delete list"
accessibilityHint="Deletes the mutelist"
onPress={onPressDeleteList}
/>
)}
</View>
)}
</View>
<View>
<UserAvatar avatar={list.avatar} size={64} />
</View>
</View>
<View style={[styles.fakeSelector, pal.border]}>
<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: {
marginTop: 8,
},
headerBtns: {
flexDirection: 'row',
gap: 8,
marginTop: 12,
},
fakeSelector: {
flexDirection: 'row',
paddingHorizontal: isDesktopWeb ? 16 : 6,
},
fakeSelectorItem: {
paddingHorizontal: 12,
paddingBottom: 8,
borderBottomWidth: 3,
},
feedFooter: {paddingTop: 20},
})