[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

@ -21,8 +21,8 @@ export function Component({}: {}) {
}, [store])
return (
<View testID="contentModerationModal" style={[pal.view, styles.container]}>
<Text style={[pal.text, styles.title]}>Content Moderation</Text>
<View testID="contentFilteringModal" style={[pal.view, styles.container]}>
<Text style={[pal.text, styles.title]}>Content Filtering</Text>
<ScrollView style={styles.scrollContainer}>
<ContentLabelPref
group="nsfw"
@ -50,7 +50,7 @@ export function Component({}: {}) {
testID="sendReportBtn"
onPress={onPressDone}
accessibilityRole="button"
accessibilityLabel="Confirm content moderation settings"
accessibilityLabel="Confirm content filtering settings"
accessibilityHint="">
<LinearGradient
colors={[gradients.blueLight.start, gradients.blueLight.end]}

View file

@ -0,0 +1,273 @@
import React, {useState, useCallback} from 'react'
import * as Toast from '../util/Toast'
import {
ActivityIndicator,
KeyboardAvoidingView,
ScrollView,
StyleSheet,
TextInput,
TouchableOpacity,
View,
} from 'react-native'
import LinearGradient from 'react-native-linear-gradient'
import {Image as RNImage} from 'react-native-image-crop-picker'
import {Text} from '../util/text/Text'
import {ErrorMessage} from '../util/error/ErrorMessage'
import {useStores} from 'state/index'
import {ListModel} from 'state/models/content/list'
import {s, colors, gradients} from 'lib/styles'
import {enforceLen} from 'lib/strings/helpers'
import {compressIfNeeded} from 'lib/media/manip'
import {UserAvatar} from '../util/UserAvatar'
import {usePalette} from 'lib/hooks/usePalette'
import {useTheme} from 'lib/ThemeContext'
import {useAnalytics} from 'lib/analytics'
import {cleanError, isNetworkError} from 'lib/strings/errors'
import {isDesktopWeb} from 'platform/detection'
const MAX_NAME = 64 // todo
const MAX_DESCRIPTION = 300 // todo
export const snapPoints = ['fullscreen']
export function Component({
onSave,
list,
}: {
onSave?: (uri: string) => void
list?: ListModel
}) {
const store = useStores()
const [error, setError] = useState<string>('')
const pal = usePalette('default')
const theme = useTheme()
const {track} = useAnalytics()
const [isProcessing, setProcessing] = useState<boolean>(false)
const [name, setName] = useState<string>(list?.list.name || '')
const [description, setDescription] = useState<string>(
list?.list.description || '',
)
const [avatar, setAvatar] = useState<string | undefined>(list?.list.avatar)
const [newAvatar, setNewAvatar] = useState<RNImage | undefined | null>()
const onPressCancel = useCallback(() => {
store.shell.closeModal()
}, [store])
const onSelectNewAvatar = useCallback(
async (img: RNImage | null) => {
if (!img) {
setNewAvatar(null)
setAvatar(null)
return
}
track('CreateMuteList:AvatarSelected')
try {
const finalImg = await compressIfNeeded(img, 1000000)
setNewAvatar(finalImg)
setAvatar(finalImg.path)
} catch (e: any) {
setError(cleanError(e))
}
},
[track, setNewAvatar, setAvatar, setError],
)
const onPressSave = useCallback(async () => {
track('CreateMuteList:Save')
const nameTrimmed = name.trim()
if (!nameTrimmed) {
setError('Name is required')
return
}
setProcessing(true)
if (error) {
setError('')
}
try {
if (list) {
await list.updateMetadata({
name: nameTrimmed,
description: description.trim(),
avatar: newAvatar,
})
Toast.show('Mute list updated')
onSave?.(list.uri)
} else {
const res = await ListModel.createModList(store, {
name,
description,
avatar: newAvatar,
})
Toast.show('Mute 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.',
)
} else {
setError(cleanError(e))
}
}
setProcessing(false)
}, [
track,
setProcessing,
setError,
error,
onSave,
store,
name,
description,
newAvatar,
list,
])
return (
<KeyboardAvoidingView behavior="height">
<ScrollView
style={[pal.view, styles.container]}
testID="createOrEditMuteListModal">
<Text style={[styles.title, pal.text]}>
{list ? 'Edit Mute List' : 'New Mute List'}
</Text>
{error !== '' && (
<View style={styles.errorContainer}>
<ErrorMessage message={error} />
</View>
)}
<Text style={[styles.label, pal.text]}>List Avatar</Text>
<View style={[styles.avi, {borderColor: pal.colors.background}]}>
<UserAvatar
size={80}
avatar={avatar}
onSelectNewAvatar={onSelectNewAvatar}
/>
</View>
<View style={styles.form}>
<View>
<Text style={[styles.label, pal.text]}>List Name</Text>
<TextInput
testID="editNameInput"
style={[styles.textInput, pal.border, pal.text]}
placeholder="e.g. Spammers"
placeholderTextColor={colors.gray4}
value={name}
onChangeText={v => setName(enforceLen(v, MAX_NAME))}
accessible={true}
accessibilityLabel="Name"
accessibilityHint="Set the list's name"
/>
</View>
<View style={s.pb10}>
<Text style={[styles.label, pal.text]}>Description</Text>
<TextInput
testID="editDescriptionInput"
style={[styles.textArea, pal.border, pal.text]}
placeholder="e.g. Users that repeatedly reply with ads."
placeholderTextColor={colors.gray4}
keyboardAppearance={theme.colorScheme}
multiline
value={description}
onChangeText={v => setDescription(enforceLen(v, MAX_DESCRIPTION))}
accessible={true}
accessibilityLabel="Description"
accessibilityHint="Edit your list's description"
/>
</View>
{isProcessing ? (
<View style={[styles.btn, s.mt10, {backgroundColor: colors.gray2}]}>
<ActivityIndicator />
</View>
) : (
<TouchableOpacity
testID="saveBtn"
style={s.mt10}
onPress={onPressSave}
accessibilityRole="button"
accessibilityLabel="Save"
accessibilityHint="Creates the mute list">
<LinearGradient
colors={[gradients.blueLight.start, gradients.blueLight.end]}
start={{x: 0, y: 0}}
end={{x: 1, y: 1}}
style={[styles.btn]}>
<Text style={[s.white, s.bold]}>Save</Text>
</LinearGradient>
</TouchableOpacity>
)}
<TouchableOpacity
testID="cancelBtn"
style={s.mt5}
onPress={onPressCancel}
accessibilityRole="button"
accessibilityLabel="Cancel creating the mute list"
accessibilityHint=""
onAccessibilityEscape={onPressCancel}>
<View style={[styles.btn]}>
<Text style={[s.black, s.bold, pal.text]}>Cancel</Text>
</View>
</TouchableOpacity>
</View>
</ScrollView>
</KeyboardAvoidingView>
)
}
const styles = StyleSheet.create({
container: {
paddingHorizontal: isDesktopWeb ? 0 : 16,
},
title: {
textAlign: 'center',
fontWeight: 'bold',
fontSize: 24,
marginBottom: 18,
},
label: {
fontWeight: 'bold',
paddingHorizontal: 4,
paddingBottom: 4,
marginTop: 20,
},
form: {
paddingHorizontal: 6,
},
textInput: {
borderWidth: 1,
borderRadius: 6,
paddingHorizontal: 14,
paddingVertical: 10,
fontSize: 16,
},
textArea: {
borderWidth: 1,
borderRadius: 6,
paddingHorizontal: 12,
paddingTop: 10,
fontSize: 16,
height: 100,
textAlignVertical: 'top',
},
btn: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
borderRadius: 32,
padding: 10,
marginBottom: 10,
},
avi: {
width: 84,
height: 84,
borderWidth: 2,
borderRadius: 42,
marginTop: 4,
},
errorContainer: {marginTop: 20},
})

View file

@ -0,0 +1,255 @@
import React, {useCallback} from 'react'
import {observer} from 'mobx-react-lite'
import {Pressable, StyleSheet, View} from 'react-native'
import {AppBskyGraphDefs as GraphDefs} from '@atproto/api'
import {
FontAwesomeIcon,
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import {Text} from '../util/text/Text'
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'
import {sanitizeDisplayName} from 'lib/strings/display-names'
import {s} from 'lib/styles'
import {usePalette} from 'lib/hooks/usePalette'
import {isDesktopWeb, isAndroid} from 'platform/detection'
export const snapPoints = ['fullscreen']
export const Component = observer(
({
subject,
displayName,
onUpdate,
}: {
subject: string
displayName: string
onUpdate?: () => void
}) => {
const store = useStores()
const pal = usePalette('default')
const palPrimary = usePalette('primary')
const palInverted = usePalette('inverted')
const [selected, setSelected] = React.useState([])
const listsList: ListsListModel = React.useMemo(
() => new ListsListModel(store, store.me.did),
[store],
)
const memberships: ListMembershipModel = React.useMemo(
() => new ListMembershipModel(store, subject),
[store, subject],
)
React.useEffect(() => {
listsList.refresh()
memberships.fetch().then(
() => {
setSelected(memberships.memberships.map(m => m.value.list))
},
err => {
store.log.error('Failed to fetch memberships', {err})
},
)
}, [memberships, listsList, store, setSelected])
const onPressCancel = useCallback(() => {
store.shell.closeModal()
}, [store])
const onPressSave = useCallback(async () => {
try {
await memberships.updateTo(selected)
} catch (err) {
store.log.error('Failed to update memberships', {err})
return
}
Toast.show('Lists updated')
onUpdate?.()
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])
const onToggleSelected = useCallback(
(uri: string) => {
if (selected.includes(uri)) {
setSelected(selected.filter(uri2 => uri2 !== uri))
} else {
setSelected([...selected, uri])
}
},
[selected, setSelected],
)
const renderItem = useCallback(
(list: GraphDefs.ListView) => {
const isSelected = selected.includes(list.uri)
return (
<Pressable
testID={`toggleBtn-${list.name}`}
style={[styles.listItem, pal.border]}
accessibilityLabel={`${isSelected ? 'Remove from' : 'Add to'} ${
list.name
}`}
accessibilityHint="Toggle their inclusion in this list"
onPress={() => onToggleSelected(list.uri)}>
<View style={styles.listItemAvi}>
<UserAvatar size={40} avatar={list.avatar} />
</View>
<View style={styles.listItemContent}>
<Text
type="lg"
style={[s.bold, pal.text]}
numberOfLines={1}
lineHeight={1.2}>
{sanitizeDisplayName(list.name)}
</Text>
<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'
: `@${list.creator.handle}`}
</Text>
</View>
<View
style={
isSelected
? [styles.checkbox, palPrimary.border, palPrimary.view]
: [styles.checkbox, pal.borderDark]
}>
{isSelected && (
<FontAwesomeIcon
icon="check"
style={palInverted.text as FontAwesomeIconStyle}
/>
)}
</View>
</Pressable>
)
},
[pal, palPrimary, palInverted, onToggleSelected, selected, store.me.did],
)
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])
return (
<View testID="listAddRemoveUserModal" style={s.hContentRegion}>
<Text style={[styles.title, pal.text]}>Add {displayName} to lists</Text>
<ListsList
listsList={listsList}
showAddBtns
onPressCreateNew={onPressNewMuteList}
renderItem={renderItem}
renderEmptyState={renderEmptyState}
style={[styles.list, pal.border]}
/>
<View style={[styles.btns, pal.border]}>
<Button
testID="cancelBtn"
type="default"
onPress={onPressCancel}
style={styles.footerBtn}
accessibilityRole="button"
accessibilityLabel="Cancel this modal"
accessibilityHint=""
onAccessibilityEscape={onPressCancel}
label="Cancel"
/>
<Button
testID="saveBtn"
type="primary"
onPress={onPressSave}
style={styles.footerBtn}
accessibilityRole="button"
accessibilityLabel="Save these changes"
accessibilityHint=""
onAccessibilityEscape={onPressSave}
label="Save Changes"
/>
</View>
</View>
)
},
)
const styles = StyleSheet.create({
container: {
paddingHorizontal: isDesktopWeb ? 0 : 16,
},
title: {
textAlign: 'center',
fontWeight: 'bold',
fontSize: 24,
marginBottom: 10,
},
list: {
flex: 1,
borderTopWidth: 1,
},
btns: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 10,
paddingTop: 10,
paddingBottom: isAndroid ? 10 : 0,
borderTopWidth: 1,
},
footerBtn: {
paddingHorizontal: 24,
paddingVertical: 12,
},
listItem: {
flexDirection: 'row',
alignItems: 'center',
borderTopWidth: 1,
paddingHorizontal: 14,
paddingVertical: 10,
},
listItemAvi: {
width: 54,
paddingLeft: 4,
paddingTop: 8,
paddingBottom: 10,
},
listItemContent: {
flex: 1,
paddingRight: 10,
paddingTop: 10,
paddingBottom: 10,
},
checkbox: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
borderWidth: 1,
width: 24,
height: 24,
borderRadius: 6,
marginRight: 8,
},
})

View file

@ -12,6 +12,8 @@ import * as EditProfileModal from './EditProfile'
import * as ServerInputModal from './ServerInput'
import * as ReportPostModal from './ReportPost'
import * as RepostModal from './Repost'
import * as CreateOrEditMuteListModal from './CreateOrEditMuteList'
import * as ListAddRemoveUserModal from './ListAddRemoveUser'
import * as AltImageModal from './AltImage'
import * as ReportAccountModal from './ReportAccount'
import * as DeleteAccountModal from './DeleteAccount'
@ -66,6 +68,12 @@ export const ModalsContainer = observer(function ModalsContainer() {
} else if (activeModal?.name === 'report-account') {
snapPoints = ReportAccountModal.snapPoints
element = <ReportAccountModal.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 === 'delete-account') {
snapPoints = DeleteAccountModal.snapPoints
element = <DeleteAccountModal.Component />

View file

@ -11,6 +11,8 @@ import * as EditProfileModal from './EditProfile'
import * as ServerInputModal from './ServerInput'
import * as ReportPostModal from './ReportPost'
import * as ReportAccountModal from './ReportAccount'
import * as CreateOrEditMuteListModal from './CreateOrEditMuteList'
import * as ListAddRemoveUserModal from './ListAddRemoveUser'
import * as DeleteAccountModal from './DeleteAccount'
import * as RepostModal from './Repost'
import * as CropImageModal from './crop-image/CropImage.web'
@ -69,6 +71,10 @@ function Modal({modal}: {modal: ModalIface}) {
element = <ReportPostModal.Component {...modal} />
} else if (modal.name === 'report-account') {
element = <ReportAccountModal.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 === 'crop-image') {
element = <CropImageModal.Component {...modal} />
} else if (modal.name === 'delete-account') {