Add update/delete/subscribe/unsubscribe to lists

This commit is contained in:
Paul Frazee
2023-05-07 16:52:14 -05:00
parent 00a37ec6d9
commit 2482f41ffb
11 changed files with 441 additions and 46 deletions
+87 -1
View File
@@ -1,5 +1,6 @@
import {makeAutoObservable} from 'mobx'
import {
AtUri,
AppBskyGraphGetList as GetList,
AppBskyGraphDefs as GraphDefs,
AppBskyGraphList,
@@ -49,12 +50,14 @@ export class ListModel {
)
record.avatar = blobRes.data.blob
}
return await rootStore.agent.app.bsky.graph.list.create(
const res = await rootStore.agent.app.bsky.graph.list.create(
{
repo: rootStore.me.did,
},
record,
)
await rootStore.agent.app.bsky.graph.subscribeMuteList({list: res.uri})
return res
}
constructor(public rootStore: RootStoreModel, public uri: string) {
@@ -79,6 +82,10 @@ export class ListModel {
return this.hasLoaded && !this.hasContent
}
get isOwner() {
return this.list?.creator.did === this.rootStore.me.did
}
// public api
// =
@@ -108,6 +115,85 @@ export class ListModel {
}
})
async updateMetadata({
name,
description,
avatar,
}: {
name: string
description: string
avatar: RNImage | null | undefined
}) {
if (!this.isOwner) {
throw new Error('Cannot edit this list')
}
// get the current record
const {rkey} = new AtUri(this.uri)
const {value: record} = await this.rootStore.agent.app.bsky.graph.list.get({
repo: this.rootStore.me.did,
rkey,
})
// update the fields
record.name = name
record.description = description
if (avatar) {
const blobRes = await apilib.uploadBlob(
this.rootStore,
avatar.path,
avatar.mime,
)
record.avatar = blobRes.data.blob
} else if (avatar === null) {
record.avatar = undefined
}
// TODO: server doesn't accept puts for list records yet
throw new Error('TODO')
// return await this.rootStore.agent.app.bsky.graph.list.(
// {
// repo: this.rootStore.me.did,
// },
// record,
// )
}
async delete() {
// fetch all the listitem records that belong to this list
let cursor
let records = []
for (let i = 0; i < 100; i++) {
const res = await this.rootStore.agent.app.bsky.graph.listitem.list({
repo: this.rootStore.me.did,
cursor,
limit: PAGE_SIZE,
})
records = records.concat(
res.records.filter(record => record.value.list === this.uri),
)
cursor = res.cursor
if (!cursor) {
break
}
}
// batch delete the list and listitem records
const createDel = (uri: string) => {
const urip = new AtUri(uri)
return {
$type: 'com.atproto.repo.applyWrites#delete',
collection: urip.collection,
rkey: urip.rkey,
}
}
await this.rootStore.agent.com.atproto.repo.applyWrites({
repo: this.rootStore.me.did,
writes: [createDel(this.uri)].concat(
records.map(record => createDel(record.uri)),
),
})
}
/**
* Attempt to load more again after a failure
*/
+6 -3
View File
@@ -5,6 +5,7 @@ import {ProfileModel} from '../content/profile'
import {isObj, hasProp} from 'lib/type-guards'
import {Image as RNImage} from 'react-native-image-crop-picker'
import {ImageModel} from '../media/image'
import {ListModel} from '../content/list'
export interface ConfirmModal {
name: 'confirm'
@@ -37,15 +38,17 @@ export interface ReportAccountModal {
did: string
}
export interface CreateMuteListModal {
name: 'create-mute-list'
onCreate?: (uri: string) => void
export interface CreateOrEditMuteListModal {
name: 'create-or-edit-mute-list'
list?: ListModel
onSave?: (uri: string) => void
}
export interface ListAddUserModal {
name: 'list-add-user'
subject: string
displayName: string
onUpdate?: () => void
}
export interface CropImageModal {
+189 -3
View File
@@ -7,17 +7,27 @@ import {
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__'}
@@ -28,6 +38,9 @@ export const ListItems = observer(
style,
scrollElRef,
onPressTryAgain,
onToggleSubscribed,
onPressEditList,
onPressDeleteList,
renderEmptyState,
testID,
headerOffset = 0,
@@ -36,16 +49,20 @@ export const ListItems = observer(
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[] = []
let items: any[] = [HEADER_ITEM]
if (list.hasLoaded) {
if (list.hasError) {
items = items.concat([ERROR_ITEM])
@@ -98,9 +115,39 @@ export const ListItems = observer(
list.retryLoadMore()
}, [list])
const onPressEditMembership = React.useCallback(
(profile: AppBskyActorDefs.ProfileViewBasic) => {
store.shell.openModal({
name: 'list-add-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],
)
const renderItem = React.useCallback(
({item}: {item: any}) => {
if (item === EMPTY_ITEM) {
@@ -108,6 +155,16 @@ export const ListItems = observer(
return renderEmptyState()
}
return <View />
} else if (item === HEADER_ITEM) {
return list.hasLoaded ? (
<ListHeader
list={list.list}
isOwner={list.isOwner}
onToggleSubscribed={onToggleSubscribed}
onPressEditList={onPressEditList}
onPressDeleteList={onPressDeleteList}
/>
) : null
} else if (item === ERROR_ITEM) {
return (
<ErrorMessage
@@ -125,9 +182,14 @@ export const ListItems = observer(
} else if (item === LOADING_ITEM) {
return <ProfileCardFeedLoadingPlaceholder />
}
return <View /> // TODO
return (
<ProfileCard
profile={(item as AppBskyGraphDefs.ListItemView).subject}
renderButton={renderMemberButton}
/>
)
},
[list, onPressTryAgain, onPressRetryLoadMore],
[list, onPressTryAgain, onPressRetryLoadMore, renderMemberButton],
)
const Footer = React.useCallback(
@@ -176,6 +238,130 @@ export const ListItems = observer(
},
)
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 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
style={[pal.text, styles.headerDescription]}
richText={descriptionRT}
/>
)}
{isDesktopWeb && (
<View style={styles.headerBtns}>
{list.viewer?.muted ? (
<Button
type="inverted"
label="Unsubscribe"
onPress={onToggleSubscribed}
/>
) : (
<Button
type="primary"
label="Subscribe & Mute"
onPress={onToggleSubscribed}
/>
)}
{isOwner &&
false /*TODO disabled until we can edit records*/ && (
<Button
type="default"
label="Edit List"
onPress={onPressEditList}
/>
)}
{isOwner && (
<Button
type="default"
label="Delete List"
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},
})
@@ -30,7 +30,13 @@ const MAX_DESCRIPTION = 300 // todo
export const snapPoints = ['fullscreen']
export function Component({onCreate}: {onCreate?: (uri: string) => void}) {
export function Component({
onSave,
list,
}: {
onSave?: (uri: string) => void
list?: ListModel
}) {
const store = useStores()
const [error, setError] = useState<string>('')
const pal = usePalette('default')
@@ -38,9 +44,11 @@ export function Component({onCreate}: {onCreate?: (uri: string) => void}) {
const {track} = useAnalytics()
const [isProcessing, setProcessing] = useState<boolean>(false)
const [name, setName] = useState<string>('')
const [description, setDescription] = useState<string>('')
const [avatar, setAvatar] = useState<string | undefined>(undefined)
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(() => {
@@ -73,13 +81,23 @@ export function Component({onCreate}: {onCreate?: (uri: string) => void}) {
setError('')
}
try {
const res = await ListModel.createModList(store, {
name,
description,
avatar: newAvatar,
})
Toast.show('Mute list created')
onCreate?.(res.uri)
if (list) {
await list.updateMetadata({
name,
description,
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)) {
@@ -96,7 +114,7 @@ export function Component({onCreate}: {onCreate?: (uri: string) => void}) {
setProcessing,
setError,
error,
onCreate,
onSave,
store,
name,
description,
@@ -108,7 +126,9 @@ export function Component({onCreate}: {onCreate?: (uri: string) => void}) {
<ScrollView
style={[pal.view, styles.container]}
testID="createMuteListModal">
<Text style={[styles.title, pal.text]}>New Mute List</Text>
<Text style={[styles.title, pal.text]}>
{list ? 'Edit Mute List' : 'New Mute List'}
</Text>
{error !== '' && (
<View style={styles.errorContainer}>
<ErrorMessage message={error} />
+6 -2
View File
@@ -24,9 +24,11 @@ export const snapPoints = ['fullscreen']
export function Component({
subject,
displayName,
onUpdate,
}: {
subject: string
displayName: string
onUpdate?: () => void
}) {
const store = useStores()
const pal = usePalette('default')
@@ -61,15 +63,17 @@ export function Component({
const onPressSave = useCallback(async () => {
try {
await memberships.updateTo(selected)
store.shell.closeModal()
} catch (err) {
store.log.error('Failed to update memberships', {err})
return
}
onUpdate?.()
store.shell.closeModal()
}, [store, selected])
const onPressNewMuteList = useCallback(() => {
store.shell.openModal({
name: 'create-mute-list',
name: 'create-or-edit-mute-list',
onCreate: (uri: string) => {
listsList.refresh()
},
+4 -4
View File
@@ -12,7 +12,7 @@ import * as EditProfileModal from './EditProfile'
import * as ServerInputModal from './ServerInput'
import * as ReportPostModal from './ReportPost'
import * as RepostModal from './Repost'
import * as CreateMuteListModal from './CreateMuteList'
import * as CreateOrEditMuteListModal from './CreateOrEditMuteList'
import * as ListAddRemoveUserModal from './ListAddUser'
import * as AltImageModal from './AltImage'
import * as ReportAccountModal from './ReportAccount'
@@ -68,9 +68,9 @@ export const ModalsContainer = observer(function ModalsContainer() {
} else if (activeModal?.name === 'report-account') {
snapPoints = ReportAccountModal.snapPoints
element = <ReportAccountModal.Component {...activeModal} />
} else if (activeModal?.name === 'create-mute-list') {
snapPoints = CreateMuteListModal.snapPoints
element = <CreateMuteListModal.Component {...activeModal} />
} else if (activeModal?.name === 'create-or-edit-mute-list') {
snapPoints = CreateOrEditMuteListModal.snapPoints
element = <CreateOrEditMuteListModal.Component {...activeModal} />
} else if (activeModal?.name === 'list-add-user') {
snapPoints = ListAddRemoveUserModal.snapPoints
element = <ListAddRemoveUserModal.Component {...activeModal} />
+3 -3
View File
@@ -11,7 +11,7 @@ import * as EditProfileModal from './EditProfile'
import * as ServerInputModal from './ServerInput'
import * as ReportPostModal from './ReportPost'
import * as ReportAccountModal from './ReportAccount'
import * as CreateMuteListModal from './CreateMuteList'
import * as CreateOrEditMuteListModal from './CreateOrEditMuteList'
import * as ListAddRemoveUserModal from './ListAddUser'
import * as DeleteAccountModal from './DeleteAccount'
import * as RepostModal from './Repost'
@@ -70,8 +70,8 @@ function Modal({modal}: {modal: ModalIface}) {
element = <ReportPostModal.Component {...modal} />
} else if (modal.name === 'report-account') {
element = <ReportAccountModal.Component {...modal} />
} else if (modal.name === 'create-mute-list') {
element = <CreateMuteListModal.Component {...modal} />
} else if (modal.name === 'create-or-edit-mute-list') {
element = <CreateOrEditMuteListModal.Component {...modal} />
} else if (modal.name === 'list-add-user') {
element = <ListAddRemoveUserModal.Component {...modal} />
} else if (modal.name === 'crop-image') {
+2 -2
View File
@@ -32,7 +32,7 @@ export const ProfileCard = observer(
noBorder?: boolean
followers?: AppBskyActorDefs.ProfileView[] | undefined
overrideModeration?: boolean
renderButton?: () => JSX.Element
renderButton?: (profile: AppBskyActorDefs.ProfileViewBasic) => JSX.Element
}) => {
const store = useStores()
const pal = usePalette('default')
@@ -92,7 +92,7 @@ export const ProfileCard = observer(
)}
</View>
{renderButton ? (
<View style={styles.layoutButton}>{renderButton()}</View>
<View style={styles.layoutButton}>{renderButton(profile)}</View>
) : undefined}
</View>
{profile.description ? (
+9 -2
View File
@@ -48,7 +48,7 @@ export const ViewHeader = observer(function ({
if (isDesktopWeb) {
if (showOnDesktop) {
return <DesktopWebHeader title={title} />
return <DesktopWebHeader title={title} renderButton={renderButton} />
}
return null
} else {
@@ -91,7 +91,13 @@ export const ViewHeader = observer(function ({
}
})
function DesktopWebHeader({title}: {title: string}) {
function DesktopWebHeader({
title,
renderButton,
}: {
title: string
renderButton?: () => JSX.Element
}) {
const pal = usePalette('default')
return (
<CenteredView style={[styles.header, styles.desktopHeader, pal.border]}>
@@ -100,6 +106,7 @@ function DesktopWebHeader({title}: {title: string}) {
{title}
</Text>
</View>
{renderButton?.()}
</CenteredView>
)
}
+7 -7
View File
@@ -28,22 +28,22 @@ export const ModerationMuteListsScreen = withAuthRequired(({route}: Props) => {
const store = useStores()
const navigation = useNavigation<NavigationProp>()
const mutelists: ListsListModel = React.useMemo(() => {
const list = new ListsListModel(store, 'my-modlists')
list.loadMore()
return list
}, [store])
const mutelists: ListsListModel = React.useMemo(
() => new ListsListModel(store, 'my-modlists'),
[store],
)
useFocusEffect(
React.useCallback(() => {
store.shell.setMinimalShellMode(false)
mutelists.refresh()
}, [store]),
)
const onPressNewMuteList = React.useCallback(() => {
store.shell.openModal({
name: 'create-mute-list',
onCreate: (uri: string) => {
name: 'create-or-edit-mute-list',
onSave: (uri: string) => {
try {
const urip = new AtUri(uri)
navigation.navigate('ProfileList', {
+95 -6
View File
@@ -1,22 +1,27 @@
import React from 'react'
import {StyleSheet} from 'react-native'
import {StyleSheet, View} from 'react-native'
import {useFocusEffect} from '@react-navigation/native'
import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
import {useNavigation} from '@react-navigation/native'
import {observer} from 'mobx-react-lite'
import {withAuthRequired} from 'view/com/auth/withAuthRequired'
import {ViewHeader} from 'view/com/util/ViewHeader'
import {CenteredView} from 'view/com/util/Views'
import {ListItems} from 'view/com/lists/ListItems'
import {EmptyState} from 'view/com/util/EmptyState'
import {Button} from 'view/com/util/forms/Button'
import * as Toast from 'view/com/util/Toast'
import {ListModel} from 'state/models/content/list'
import {useStores} from 'state/index'
import {usePalette} from 'lib/hooks/usePalette'
import {NavigationProp} from 'lib/routes/types'
import {isDesktopWeb} from 'platform/detection'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'ProfileList'>
export const ProfileListScreen = withAuthRequired(
observer(({route}: Props) => {
const store = useStores()
const navigation = useNavigation<NavigationProp>()
const pal = usePalette('default')
const {name, rkey} = route.params
@@ -35,11 +40,88 @@ export const ProfileListScreen = withAuthRequired(
}, [store]),
)
const onToggleSubscribed = React.useCallback(async () => {
try {
if (list.list?.viewer?.muted) {
await store.agent.app.bsky.graph.unsubscribeMuteList({list: list.uri})
Toast.show('List unsubscribed')
} else {
await store.agent.app.bsky.graph.subscribeMuteList({list: list.uri})
Toast.show('List subscribed')
}
} catch (err) {
Toast.show(
'There was an an issue updating your subscription, please check your internet connection and try again.',
)
store.log.error('Failed up update subscription', {err})
}
}, [store, list])
const onPressEditList = React.useCallback(() => {
store.shell.openModal({
name: 'create-or-edit-mute-list',
list,
onSave() {
list.refresh()
},
})
}, [store, list])
const onPressDeleteList = React.useCallback(() => {
store.shell.openModal({
name: 'confirm',
title: 'Delete List',
message: 'Are you sure?',
async onPressConfirm() {
await list.delete()
if (navigation.canGoBack()) {
navigation.goBack()
} else {
navigation.navigate('Home')
}
},
})
}, [store, list])
const renderEmptyState = React.useCallback(() => {
return <EmptyState icon="users-slash" message="This list is empty!" />
}, [])
console.log('render')
const renderHeaderBtn = React.useCallback(() => {
return (
<View style={styles.headerBtns}>
{list?.isOwner &&
false /*TODO disabled until we can edit records*/ && (
<Button
type="default"
label="Edit List"
onPress={onPressEditList}
/>
)}
{list?.isOwner && (
<Button
type="default"
label="Delete List"
onPress={onPressDeleteList}
/>
)}
{list.list?.viewer?.muted ? (
<Button
type="inverted"
label="Unsubscribe"
onPress={onToggleSubscribed}
/>
) : (
<Button
type="primary"
label="Subscribe & Mute"
onPress={onToggleSubscribed}
/>
)}
</View>
)
}, [list?.isOwner, onPressDeleteList, onPressEditList, onToggleSubscribed])
return (
<CenteredView
style={[
@@ -49,17 +131,24 @@ export const ProfileListScreen = withAuthRequired(
pal.border,
]}
testID="moderationMutelistsScreen">
<ViewHeader
title={`List by ${list.list?.creator.handle}`}
showOnDesktop
<ViewHeader title="" renderButton={renderHeaderBtn} />
<ListItems
list={list}
renderEmptyState={renderEmptyState}
onToggleSubscribed={onToggleSubscribed}
onPressEditList={onPressEditList}
onPressDeleteList={onPressDeleteList}
/>
<ListItems list={list} renderEmptyState={renderEmptyState} />
</CenteredView>
)
}),
)
const styles = StyleSheet.create({
headerBtns: {
flexDirection: 'row',
gap: 8,
},
container: {
flex: 1,
paddingBottom: isDesktopWeb ? 0 : 100,