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,4 +1,4 @@
import React, {useState, useCallback} from 'react'
import React, {useState, useCallback, useMemo} from 'react'
import * as Toast from '../util/Toast'
import {
ActivityIndicator,
@ -31,9 +31,11 @@ const MAX_DESCRIPTION = 300 // todo
export const snapPoints = ['fullscreen']
export function Component({
purpose,
onSave,
list,
}: {
purpose?: string
onSave?: (uri: string) => void
list?: ListModel
}) {
@ -44,12 +46,24 @@ export function Component({
const theme = useTheme()
const {track} = useAnalytics()
const activePurpose = useMemo(() => {
if (list?.data?.purpose) {
return list.data.purpose
}
if (purpose) {
return purpose
}
return 'app.bsky.graph.defs#curatelist'
}, [list, purpose])
const isCurateList = activePurpose === 'app.bsky.graph.defs#curatelist'
const purposeLabel = isCurateList ? 'User' : 'Moderation'
const [isProcessing, setProcessing] = useState<boolean>(false)
const [name, setName] = useState<string>(list?.list?.name || '')
const [name, setName] = useState<string>(list?.data?.name || '')
const [description, setDescription] = useState<string>(
list?.list?.description || '',
list?.data?.description || '',
)
const [avatar, setAvatar] = useState<string | undefined>(list?.list?.avatar)
const [avatar, setAvatar] = useState<string | undefined>(list?.data?.avatar)
const [newAvatar, setNewAvatar] = useState<RNImage | undefined | null>()
const onPressCancel = useCallback(() => {
@ -63,7 +77,7 @@ export function Component({
setAvatar(undefined)
return
}
track('CreateMuteList:AvatarSelected')
track('CreateList:AvatarSelected')
try {
const finalImg = await compressIfNeeded(img, 1000000)
setNewAvatar(finalImg)
@ -76,7 +90,11 @@ export function Component({
)
const onPressSave = useCallback(async () => {
track('CreateMuteList:Save')
if (isCurateList) {
track('CreateList:SaveCurateList')
} else {
track('CreateList:SaveModList')
}
const nameTrimmed = name.trim()
if (!nameTrimmed) {
setError('Name is required')
@ -93,22 +111,23 @@ export function Component({
description: description.trim(),
avatar: newAvatar,
})
Toast.show('Mute list updated')
Toast.show(`${purposeLabel} list updated`)
onSave?.(list.uri)
} else {
const res = await ListModel.createModList(store, {
const res = await ListModel.createList(store, {
purpose: activePurpose,
name,
description,
avatar: newAvatar,
})
Toast.show('Mute list created')
Toast.show(`${purposeLabel} list created`)
onSave?.(res.uri)
}
store.shell.closeModal()
} catch (e: any) {
if (isNetworkError(e)) {
setError(
'Failed to create the mute list. Check your internet connection and try again.',
'Failed to create the list. Check your internet connection and try again.',
)
} else {
setError(cleanError(e))
@ -122,6 +141,9 @@ export function Component({
error,
onSave,
store,
activePurpose,
isCurateList,
purposeLabel,
name,
description,
newAvatar,
@ -137,9 +159,9 @@ export function Component({
paddingHorizontal: isMobile ? 16 : 0,
},
]}
testID="createOrEditMuteListModal">
testID="createOrEditListModal">
<Text style={[styles.title, pal.text]}>
{list ? 'Edit Mute List' : 'New Mute List'}
{list ? 'Edit' : 'New'} {purposeLabel} List
</Text>
{error !== '' && (
<View style={styles.errorContainer}>
@ -163,7 +185,9 @@ export function Component({
<TextInput
testID="editNameInput"
style={[styles.textInput, pal.border, pal.text]}
placeholder="e.g. spammers"
placeholder={
isCurateList ? 'e.g. Great Posters' : 'e.g. Spammers'
}
placeholderTextColor={colors.gray4}
value={name}
onChangeText={v => setName(enforceLen(v, MAX_NAME))}
@ -180,7 +204,11 @@ export function Component({
<TextInput
testID="editDescriptionInput"
style={[styles.textArea, pal.border, pal.text]}
placeholder="e.g. users that repeatedly reply with ads."
placeholder={
isCurateList
? 'e.g. The posters who never miss.'
: 'e.g. Users that repeatedly reply with ads.'
}
placeholderTextColor={colors.gray4}
keyboardAppearance={theme.colorScheme}
multiline
@ -203,7 +231,7 @@ export function Component({
onPress={onPressSave}
accessibilityRole="button"
accessibilityLabel="Save"
accessibilityHint="Creates the mute list">
accessibilityHint="">
<LinearGradient
colors={[gradients.blueLight.start, gradients.blueLight.end]}
start={{x: 0, y: 0}}

View file

@ -0,0 +1,281 @@
import React, {useEffect, useCallback, useState, useMemo} from 'react'
import {
ActivityIndicator,
Pressable,
SafeAreaView,
StyleSheet,
View,
} from 'react-native'
import {AppBskyActorDefs} from '@atproto/api'
import {ScrollView, TextInput} from './util'
import {observer} from 'mobx-react-lite'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {Text} from '../util/text/Text'
import {Button} from '../util/forms/Button'
import {UserAvatar} from '../util/UserAvatar'
import * as Toast from '../util/Toast'
import {useStores} from 'state/index'
import {ListModel} from 'state/models/content/list'
import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete'
import {s, colors} from 'lib/styles'
import {usePalette} from 'lib/hooks/usePalette'
import {isWeb} from 'platform/detection'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {cleanError} from 'lib/strings/errors'
import {sanitizeDisplayName} from 'lib/strings/display-names'
import {sanitizeHandle} from 'lib/strings/handles'
export const snapPoints = ['90%']
export const Component = observer(function Component({
list,
onAdd,
}: {
list: ListModel
onAdd?: (profile: AppBskyActorDefs.ProfileViewBasic) => void
}) {
const pal = usePalette('default')
const store = useStores()
const {isMobile} = useWebMediaQueries()
const [query, setQuery] = useState('')
const autocompleteView = useMemo<UserAutocompleteModel>(
() => new UserAutocompleteModel(store),
[store],
)
// initial setup
useEffect(() => {
autocompleteView.setup().then(() => {
autocompleteView.setPrefix('')
})
autocompleteView.setActive(true)
list.loadAll()
}, [autocompleteView, list])
const onChangeQuery = useCallback(
(text: string) => {
setQuery(text)
autocompleteView.setPrefix(text)
},
[setQuery, autocompleteView],
)
const onPressCancelSearch = useCallback(
() => onChangeQuery(''),
[onChangeQuery],
)
return (
<SafeAreaView
testID="listAddUserModal"
style={[pal.view, isWeb ? styles.fixedHeight : s.flex1]}>
<View
style={[
s.flex1,
isMobile && {paddingHorizontal: 18, paddingBottom: 40},
]}>
<View style={styles.titleSection}>
<Text type="title-lg" style={[pal.text, styles.title]}>
Add User to List
</Text>
</View>
<View style={[styles.searchContainer, pal.border]}>
<FontAwesomeIcon icon="search" size={16} />
<TextInput
testID="searchInput"
style={[styles.searchInput, pal.border, pal.text]}
placeholder="Search for users"
placeholderTextColor={pal.colors.textLight}
value={query}
onChangeText={onChangeQuery}
accessible={true}
accessibilityLabel="Search"
accessibilityHint=""
autoCapitalize="none"
autoComplete="off"
autoCorrect={false}
/>
{query ? (
<Pressable
onPress={onPressCancelSearch}
accessibilityRole="button"
accessibilityLabel="Cancel search"
accessibilityHint="Exits inputting search query"
onAccessibilityEscape={onPressCancelSearch}>
<FontAwesomeIcon
icon="xmark"
size={16}
color={pal.colors.textLight}
/>
</Pressable>
) : undefined}
</View>
<ScrollView style={[s.flex1]}>
{autocompleteView.suggestions.length ? (
<>
{autocompleteView.suggestions.slice(0, 40).map((item, i) => (
<UserResult
key={item.did}
list={list}
profile={item}
noBorder={i === 0}
onAdd={onAdd}
/>
))}
</>
) : (
<Text
type="xl"
style={[
pal.textLight,
{paddingHorizontal: 12, paddingVertical: 16},
]}>
No results found for {autocompleteView.prefix}
</Text>
)}
</ScrollView>
<View style={[styles.btnContainer]}>
<Button
testID="doneBtn"
type="primary"
onPress={() => store.shell.closeModal()}
accessibilityLabel="Done"
accessibilityHint=""
label="Done"
labelContainerStyle={{justifyContent: 'center', padding: 4}}
labelStyle={[s.f18]}
/>
</View>
</View>
</SafeAreaView>
)
})
function UserResult({
profile,
list,
noBorder,
onAdd,
}: {
profile: AppBskyActorDefs.ProfileViewBasic
list: ListModel
noBorder: boolean
onAdd?: (profile: AppBskyActorDefs.ProfileViewBasic) => void | undefined
}) {
const pal = usePalette('default')
const [isProcessing, setIsProcessing] = useState(false)
const [isAdded, setIsAdded] = useState(list.isMember(profile.did))
const onPressAdd = useCallback(async () => {
setIsProcessing(true)
try {
await list.addMember(profile)
Toast.show('Added to list')
setIsAdded(true)
onAdd?.(profile)
} catch (e) {
Toast.show(cleanError(e))
} finally {
setIsProcessing(false)
}
}, [list, profile, setIsProcessing, setIsAdded, onAdd])
return (
<View
style={[
pal.border,
{
flexDirection: 'row',
alignItems: 'center',
borderTopWidth: noBorder ? 0 : 1,
paddingVertical: 8,
paddingHorizontal: 8,
},
]}>
<View
style={{
alignSelf: 'baseline',
width: 54,
paddingLeft: 4,
paddingTop: 10,
}}>
<UserAvatar size={40} avatar={profile.avatar} />
</View>
<View
style={{
flex: 1,
paddingRight: 10,
paddingTop: 10,
paddingBottom: 10,
}}>
<Text
type="lg"
style={[s.bold, pal.text]}
numberOfLines={1}
lineHeight={1.2}>
{sanitizeDisplayName(
profile.displayName || sanitizeHandle(profile.handle),
)}
</Text>
<Text type="md" style={[pal.textLight]} numberOfLines={1}>
{sanitizeHandle(profile.handle, '@')}
</Text>
{!!profile.viewer?.followedBy && <View style={s.flexRow} />}
</View>
<View>
{isAdded ? (
<FontAwesomeIcon icon="check" />
) : isProcessing ? (
<ActivityIndicator />
) : (
<Button
testID={`user-${profile.handle}-addBtn`}
type="default"
label="Add"
onPress={onPressAdd}
/>
)}
</View>
</View>
)
}
const styles = StyleSheet.create({
fixedHeight: {
// @ts-ignore web only -prf
height: '80vh',
},
titleSection: {
paddingTop: isWeb ? 0 : 4,
paddingBottom: isWeb ? 14 : 10,
},
title: {
textAlign: 'center',
fontWeight: '600',
marginBottom: 5,
},
searchContainer: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
borderWidth: 1,
borderRadius: 24,
paddingHorizontal: 16,
paddingVertical: 10,
},
searchInput: {
fontSize: 16,
flex: 1,
},
btn: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 32,
padding: 14,
backgroundColor: colors.blue3,
},
btnContainer: {
paddingTop: 20,
},
})

View file

@ -16,8 +16,9 @@ import * as ProfilePreviewModal from './ProfilePreview'
import * as ServerInputModal from './ServerInput'
import * as RepostModal from './Repost'
import * as SelfLabelModal from './SelfLabel'
import * as CreateOrEditMuteListModal from './CreateOrEditMuteList'
import * as ListAddRemoveUserModal from './ListAddRemoveUser'
import * as CreateOrEditListModal from './CreateOrEditList'
import * as UserAddRemoveListsModal from './UserAddRemoveLists'
import * as ListAddUserModal from './ListAddUser'
import * as AltImageModal from './AltImage'
import * as EditImageModal from './AltImage'
import * as ReportModal from './report/Modal'
@ -101,12 +102,15 @@ export const ModalsContainer = observer(function ModalsContainer() {
} else if (activeModal?.name === 'report') {
snapPoints = ReportModal.snapPoints
element = <ReportModal.Component {...activeModal} />
} else if (activeModal?.name === 'create-or-edit-mute-list') {
snapPoints = CreateOrEditMuteListModal.snapPoints
element = <CreateOrEditMuteListModal.Component {...activeModal} />
} else if (activeModal?.name === 'list-add-remove-user') {
snapPoints = ListAddRemoveUserModal.snapPoints
element = <ListAddRemoveUserModal.Component {...activeModal} />
} else if (activeModal?.name === 'create-or-edit-list') {
snapPoints = CreateOrEditListModal.snapPoints
element = <CreateOrEditListModal.Component {...activeModal} />
} else if (activeModal?.name === 'user-add-remove-lists') {
snapPoints = UserAddRemoveListsModal.snapPoints
element = <UserAddRemoveListsModal.Component {...activeModal} />
} else if (activeModal?.name === 'list-add-user') {
snapPoints = ListAddUserModal.snapPoints
element = <ListAddUserModal.Component {...activeModal} />
} else if (activeModal?.name === 'delete-account') {
snapPoints = DeleteAccountModal.snapPoints
element = <DeleteAccountModal.Component />

View file

@ -11,8 +11,9 @@ import * as EditProfileModal from './EditProfile'
import * as ProfilePreviewModal from './ProfilePreview'
import * as ServerInputModal from './ServerInput'
import * as ReportModal from './report/Modal'
import * as CreateOrEditMuteListModal from './CreateOrEditMuteList'
import * as ListAddRemoveUserModal from './ListAddRemoveUser'
import * as CreateOrEditListModal from './CreateOrEditList'
import * as UserAddRemoveLists from './UserAddRemoveLists'
import * as ListAddUserModal from './ListAddUser'
import * as DeleteAccountModal from './DeleteAccount'
import * as RepostModal from './Repost'
import * as SelfLabelModal from './SelfLabel'
@ -79,10 +80,12 @@ function Modal({modal}: {modal: ModalIface}) {
element = <ServerInputModal.Component {...modal} />
} else if (modal.name === 'report') {
element = <ReportModal.Component {...modal} />
} else if (modal.name === 'create-or-edit-mute-list') {
element = <CreateOrEditMuteListModal.Component {...modal} />
} else if (modal.name === 'list-add-remove-user') {
element = <ListAddRemoveUserModal.Component {...modal} />
} else if (modal.name === 'create-or-edit-list') {
element = <CreateOrEditListModal.Component {...modal} />
} else if (modal.name === 'user-add-remove-lists') {
element = <UserAddRemoveLists.Component {...modal} />
} else if (modal.name === 'list-add-user') {
element = <ListAddUserModal.Component {...modal} />
} else if (modal.name === 'crop-image') {
element = <CropImageModal.Component {...modal} />
} else if (modal.name === 'delete-account') {

View file

@ -31,8 +31,25 @@ export function Component({
description =
'Moderator has chosen to set a general warning on the content.'
} else if (moderation.cause.type === 'blocking') {
name = 'User Blocked'
description = 'You have blocked this user. You cannot view their content.'
if (moderation.cause.source.type === 'list') {
const list = moderation.cause.source.list
name = 'User Blocked by List'
description = (
<>
This user is included in the{' '}
<TextLink
type="2xl"
href={listUriToHref(list.uri)}
text={list.name}
style={pal.link}
/>{' '}
list which you have blocked.
</>
)
} else {
name = 'User Blocked'
description = 'You have blocked this user. You cannot view their content.'
}
} else if (moderation.cause.type === 'blocked-by') {
name = 'User Blocks You'
description = 'This user has blocked you. You cannot view their content.'

View file

@ -1,6 +1,6 @@
import React, {useCallback} from 'react'
import {observer} from 'mobx-react-lite'
import {Pressable, StyleSheet, View, ActivityIndicator} from 'react-native'
import {ActivityIndicator, Pressable, StyleSheet, View} from 'react-native'
import {AppBskyGraphDefs as GraphDefs} from '@atproto/api'
import {
FontAwesomeIcon,
@ -11,7 +11,6 @@ import {UserAvatar} from '../util/UserAvatar'
import {ListsList} from '../lists/ListsList'
import {ListsListModel} from 'state/models/lists/lists-list'
import {ListMembershipModel} from 'state/models/content/list-membership'
import {EmptyStateWithButton} from '../util/EmptyStateWithButton'
import {Button} from '../util/forms/Button'
import * as Toast from '../util/Toast'
import {useStores} from 'state/index'
@ -24,14 +23,16 @@ import isEqual from 'lodash.isequal'
export const snapPoints = ['fullscreen']
export const Component = observer(function ListAddRemoveUserImpl({
export const Component = observer(function UserAddRemoveListsImpl({
subject,
displayName,
onUpdate,
onAdd,
onRemove,
}: {
subject: string
displayName: string
onUpdate?: () => void
onAdd?: (listUri: string) => void
onRemove?: (listUri: string) => void
}) {
const store = useStores()
const pal = usePalette('default')
@ -71,25 +72,22 @@ export const Component = observer(function ListAddRemoveUserImpl({
}, [store])
const onPressSave = useCallback(async () => {
let changes
try {
await memberships.updateTo(selected)
changes = await memberships.updateTo(selected)
} catch (err) {
store.log.error('Failed to update memberships', {err})
return
}
Toast.show('Lists updated')
onUpdate?.()
for (const uri of changes.added) {
onAdd?.(uri)
}
for (const uri of changes.removed) {
onRemove?.(uri)
}
store.shell.closeModal()
}, [store, selected, memberships, onUpdate])
const onPressNewMuteList = useCallback(() => {
store.shell.openModal({
name: 'create-or-edit-mute-list',
onSave: (_uri: string) => {
listsList.refresh()
},
})
}, [store, listsList])
}, [store, selected, memberships, onAdd, onRemove])
const onToggleSelected = useCallback(
(uri: string) => {
@ -103,7 +101,7 @@ export const Component = observer(function ListAddRemoveUserImpl({
)
const renderItem = useCallback(
(list: GraphDefs.ListView) => {
(list: GraphDefs.ListView, index: number) => {
const isSelected = selected.includes(list.uri)
return (
<Pressable
@ -111,7 +109,10 @@ export const Component = observer(function ListAddRemoveUserImpl({
style={[
styles.listItem,
pal.border,
{opacity: membershipsLoaded ? 1 : 0.5},
{
opacity: membershipsLoaded ? 1 : 0.5,
borderTopWidth: index === 0 ? 0 : 1,
},
]}
accessibilityLabel={`${isSelected ? 'Remove from' : 'Add to'} ${
list.name
@ -131,7 +132,11 @@ export const Component = observer(function ListAddRemoveUserImpl({
{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, '@')}
@ -166,30 +171,19 @@ export const Component = observer(function ListAddRemoveUserImpl({
],
)
const renderEmptyState = React.useCallback(() => {
return (
<EmptyStateWithButton
icon="users-slash"
message="You can subscribe to mute lists to automatically mute all of the users they include. Mute lists are public but your subscription to a mute list is private."
buttonLabel="New Mute List"
onPress={onPressNewMuteList}
/>
)
}, [onPressNewMuteList])
// Only show changes button if there are some items on the list to choose from AND user has made changes in selection
const canSaveChanges =
!listsList.isEmpty && !isEqual(selected, originalSelections)
return (
<View testID="listAddRemoveUserModal" style={s.hContentRegion}>
<Text style={[styles.title, pal.text]}>Add {displayName} to Lists</Text>
<View testID="userAddRemoveListsModal" style={s.hContentRegion}>
<Text style={[styles.title, pal.text]}>
Update {displayName} in Lists
</Text>
<ListsList
listsList={listsList}
showAddBtns
onPressCreateNew={onPressNewMuteList}
inline
renderItem={renderItem}
renderEmptyState={renderEmptyState}
style={[styles.list, pal.border]}
/>
<View style={[styles.btns, pal.border]}>
@ -258,7 +252,6 @@ const styles = StyleSheet.create({
listItem: {
flexDirection: 'row',
alignItems: 'center',
borderTopWidth: 1,
paddingHorizontal: 14,
paddingVertical: 10,
},