[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,112 @@
import {makeAutoObservable} from 'mobx'
import {AtUri, AppBskyGraphListitem} from '@atproto/api'
import {runInAction} from 'mobx'
import {RootStoreModel} from '../root-store'
const PAGE_SIZE = 100
interface Membership {
uri: string
value: AppBskyGraphListitem.Record
}
export class ListMembershipModel {
// data
memberships: Membership[] = []
constructor(public rootStore: RootStoreModel, public subject: string) {
makeAutoObservable(
this,
{
rootStore: false,
},
{autoBind: true},
)
}
// public api
// =
async fetch() {
// NOTE
// this approach to determining list membership is too inefficient to work at any scale
// it needs to be replaced with server side list membership queries
// -prf
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.subject === this.subject),
)
cursor = res.cursor
if (!cursor) {
break
}
}
runInAction(() => {
this.memberships = records
})
}
getMembership(listUri: string) {
return this.memberships.find(m => m.value.list === listUri)
}
isMember(listUri: string) {
return !!this.getMembership(listUri)
}
async add(listUri: string) {
if (this.isMember(listUri)) {
return
}
const res = await this.rootStore.agent.app.bsky.graph.listitem.create(
{
repo: this.rootStore.me.did,
},
{
subject: this.subject,
list: listUri,
createdAt: new Date().toISOString(),
},
)
const {rkey} = new AtUri(res.uri)
const record = await this.rootStore.agent.app.bsky.graph.listitem.get({
repo: this.rootStore.me.did,
rkey,
})
runInAction(() => {
this.memberships = this.memberships.concat([record])
})
}
async remove(listUri: string) {
const membership = this.getMembership(listUri)
if (!membership) {
return
}
const {rkey} = new AtUri(membership.uri)
await this.rootStore.agent.app.bsky.graph.listitem.delete({
repo: this.rootStore.me.did,
rkey,
})
runInAction(() => {
this.memberships = this.memberships.filter(m => m.value.list !== listUri)
})
}
async updateTo(uris: string) {
for (const uri of uris) {
await this.add(uri)
}
for (const membership of this.memberships) {
if (!uris.includes(membership.value.list)) {
await this.remove(membership.value.list)
}
}
}
}

View file

@ -0,0 +1,257 @@
import {makeAutoObservable} from 'mobx'
import {
AtUri,
AppBskyGraphGetList as GetList,
AppBskyGraphDefs as GraphDefs,
AppBskyGraphList,
} from '@atproto/api'
import {Image as RNImage} from 'react-native-image-crop-picker'
import {RootStoreModel} from '../root-store'
import * as apilib from 'lib/api/index'
import {cleanError} from 'lib/strings/errors'
import {bundleAsync} from 'lib/async/bundle'
const PAGE_SIZE = 30
export class ListModel {
// state
isLoading = false
isRefreshing = false
hasLoaded = false
error = ''
loadMoreError = ''
hasMore = true
loadMoreCursor?: string
// data
list: GraphDefs.ListView | null = null
items: GraphDefs.ListItemView[] = []
static async createModList(
rootStore: RootStoreModel,
{
name,
description,
avatar,
}: {name: string; description: string; avatar: RNImage | undefined},
) {
const record: AppBskyGraphList.Record = {
purpose: 'app.bsky.graph.defs#modlist',
name,
description,
avatar: undefined,
createdAt: new Date().toISOString(),
}
if (avatar) {
const blobRes = await apilib.uploadBlob(
rootStore,
avatar.path,
avatar.mime,
)
record.avatar = blobRes.data.blob
}
const res = await rootStore.agent.app.bsky.graph.list.create(
{
repo: rootStore.me.did,
},
record,
)
await rootStore.agent.app.bsky.graph.muteActorList({list: res.uri})
return res
}
constructor(public rootStore: RootStoreModel, public uri: string) {
makeAutoObservable(
this,
{
rootStore: false,
},
{autoBind: true},
)
}
get hasContent() {
return this.items.length > 0
}
get hasError() {
return this.error !== ''
}
get isEmpty() {
return this.hasLoaded && !this.hasContent
}
get isOwner() {
return this.list?.creator.did === this.rootStore.me.did
}
// public api
// =
async refresh() {
return this.loadMore(true)
}
loadMore = bundleAsync(async (replace: boolean = false) => {
if (!replace && !this.hasMore) {
return
}
this._xLoading(replace)
try {
const res = await this.rootStore.agent.app.bsky.graph.getList({
list: this.uri,
limit: PAGE_SIZE,
cursor: replace ? undefined : this.loadMoreCursor,
})
if (replace) {
this._replaceAll(res)
} else {
this._appendAll(res)
}
this._xIdle()
} catch (e: any) {
this._xIdle(replace ? e : undefined, !replace ? e : undefined)
}
})
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
}
return await this.rootStore.agent.com.atproto.repo.putRecord({
repo: this.rootStore.me.did,
collection: 'app.bsky.graph.list',
rkey,
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)),
),
})
}
async subscribe() {
await this.rootStore.agent.app.bsky.graph.muteActorList({
list: this.list.uri,
})
await this.refresh()
}
async unsubscribe() {
await this.rootStore.agent.app.bsky.graph.unmuteActorList({
list: this.list.uri,
})
await this.refresh()
}
/**
* Attempt to load more again after a failure
*/
async retryLoadMore() {
this.loadMoreError = ''
this.hasMore = true
return this.loadMore()
}
// state transitions
// =
_xLoading(isRefreshing = false) {
this.isLoading = true
this.isRefreshing = isRefreshing
this.error = ''
}
_xIdle(err?: any, loadMoreErr?: any) {
this.isLoading = false
this.isRefreshing = false
this.hasLoaded = true
this.error = cleanError(err)
this.loadMoreError = cleanError(loadMoreErr)
if (err) {
this.rootStore.log.error('Failed to fetch user items', err)
}
if (loadMoreErr) {
this.rootStore.log.error('Failed to fetch user items', loadMoreErr)
}
}
// helper functions
// =
_replaceAll(res: GetList.Response) {
this.items = []
this._appendAll(res)
}
_appendAll(res: GetList.Response) {
this.loadMoreCursor = res.data.cursor
this.hasMore = !!this.loadMoreCursor
this.list = res.data.list
this.items = this.items.concat(
res.data.items.map(item => ({...item, _reactKey: item.subject})),
)
}
}

View file

@ -14,6 +14,7 @@ import {PostLabelInfo, PostModeration} from 'lib/labeling/types'
import {
getEmbedLabels,
getEmbedMuted,
getEmbedMutedByList,
getEmbedBlocking,
getEmbedBlockedBy,
filterAccountLabels,
@ -70,6 +71,9 @@ export class PostThreadItemModel {
this.post.author.viewer?.muted ||
getEmbedMuted(this.post.embed) ||
false,
mutedByList:
this.post.author.viewer?.mutedByList ||
getEmbedMutedByList(this.post.embed),
isBlocking:
!!this.post.author.viewer?.blocking ||
getEmbedBlocking(this.post.embed) ||

View file

@ -2,6 +2,7 @@ import {makeAutoObservable, runInAction} from 'mobx'
import {
AtUri,
ComAtprotoLabelDefs,
AppBskyGraphDefs,
AppBskyActorGetProfile as GetProfile,
AppBskyActorProfile,
RichText,
@ -18,10 +19,9 @@ import {
filterProfileLabels,
} from 'lib/labeling/helpers'
export const ACTOR_TYPE_USER = 'app.bsky.system.actorUser'
export class ProfileViewerModel {
muted?: boolean
mutedByList?: AppBskyGraphDefs.ListViewBasic
following?: string
followedBy?: string
blockedBy?: boolean

View file

@ -111,6 +111,7 @@ export class NotificationsFeedItemModel {
addedInfo?.profileLabels || [],
),
isMuted: this.author.viewer?.muted || addedInfo?.isMuted || false,
mutedByList: this.author.viewer?.mutedByList || addedInfo?.mutedByList,
isBlocking:
!!this.author.viewer?.blocking || addedInfo?.isBlocking || false,
isBlockedBy:

View file

@ -24,6 +24,7 @@ import {PostLabelInfo, PostModeration} from 'lib/labeling/types'
import {
getEmbedLabels,
getEmbedMuted,
getEmbedMutedByList,
getEmbedBlocking,
getEmbedBlockedBy,
getPostModeration,
@ -105,6 +106,9 @@ export class PostsFeedItemModel {
this.post.author.viewer?.muted ||
getEmbedMuted(this.post.embed) ||
false,
mutedByList:
this.post.author.viewer?.mutedByList ||
getEmbedMutedByList(this.post.embed),
isBlocking:
!!this.post.author.viewer?.blocking ||
getEmbedBlocking(this.post.embed) ||

View file

@ -0,0 +1,214 @@
import {makeAutoObservable} from 'mobx'
import {
AppBskyGraphGetLists as GetLists,
AppBskyGraphGetListMutes as GetListMutes,
AppBskyGraphDefs as GraphDefs,
} from '@atproto/api'
import {RootStoreModel} from '../root-store'
import {cleanError} from 'lib/strings/errors'
import {bundleAsync} from 'lib/async/bundle'
const PAGE_SIZE = 30
export class ListsListModel {
// state
isLoading = false
isRefreshing = false
hasLoaded = false
error = ''
loadMoreError = ''
hasMore = true
loadMoreCursor?: string
// data
lists: GraphDefs.ListView[] = []
constructor(
public rootStore: RootStoreModel,
public source: 'my-modlists' | string,
) {
makeAutoObservable(
this,
{
rootStore: false,
},
{autoBind: true},
)
}
get hasContent() {
return this.lists.length > 0
}
get hasError() {
return this.error !== ''
}
get isEmpty() {
return this.hasLoaded && !this.hasContent
}
// public api
// =
async refresh() {
return this.loadMore(true)
}
loadMore = bundleAsync(async (replace: boolean = false) => {
if (!replace && !this.hasMore) {
return
}
this._xLoading(replace)
try {
let res
if (this.source === 'my-modlists') {
res = {
success: true,
headers: {},
data: {
subject: undefined,
lists: [],
},
}
const [res1, res2] = await Promise.all([
fetchAllUserLists(this.rootStore, this.rootStore.me.did),
fetchAllMyMuteLists(this.rootStore),
])
for (let list of res1.data.lists) {
if (list.purpose === 'app.bsky.graph.defs#modlist') {
res.data.lists.push(list)
}
}
for (let list of res2.data.lists) {
if (
list.purpose === 'app.bsky.graph.defs#modlist' &&
!res.data.lists.find(l => l.uri === list.uri)
) {
res.data.lists.push(list)
}
}
} else {
res = await this.rootStore.agent.app.bsky.graph.getLists({
actor: this.source,
limit: PAGE_SIZE,
cursor: replace ? undefined : this.loadMoreCursor,
})
}
if (replace) {
this._replaceAll(res)
} else {
this._appendAll(res)
}
this._xIdle()
} catch (e: any) {
this._xIdle(replace ? e : undefined, !replace ? e : undefined)
}
})
/**
* Attempt to load more again after a failure
*/
async retryLoadMore() {
this.loadMoreError = ''
this.hasMore = true
return this.loadMore()
}
// state transitions
// =
_xLoading(isRefreshing = false) {
this.isLoading = true
this.isRefreshing = isRefreshing
this.error = ''
}
_xIdle(err?: any, loadMoreErr?: any) {
this.isLoading = false
this.isRefreshing = false
this.hasLoaded = true
this.error = cleanError(err)
this.loadMoreError = cleanError(loadMoreErr)
if (err) {
this.rootStore.log.error('Failed to fetch user lists', err)
}
if (loadMoreErr) {
this.rootStore.log.error('Failed to fetch user lists', loadMoreErr)
}
}
// helper functions
// =
_replaceAll(res: GetLists.Response | GetListMutes.Response) {
this.lists = []
this._appendAll(res)
}
_appendAll(res: GetLists.Response | GetListMutes.Response) {
this.loadMoreCursor = res.data.cursor
this.hasMore = !!this.loadMoreCursor
this.lists = this.lists.concat(
res.data.lists.map(list => ({...list, _reactKey: list.uri})),
)
}
}
async function fetchAllUserLists(
store: RootStoreModel,
did: string,
): Promise<GetLists.Response> {
let acc: GetLists.Response = {
success: true,
headers: {},
data: {
subject: undefined,
lists: [],
},
}
let cursor
for (let i = 0; i < 100; i++) {
const res = await store.agent.app.bsky.graph.getLists({
actor: did,
cursor,
limit: 50,
})
cursor = res.data.cursor
acc.data.lists = acc.data.lists.concat(res.data.lists)
if (!cursor) {
break
}
}
return acc
}
async function fetchAllMyMuteLists(
store: RootStoreModel,
): Promise<GetListMutes.Response> {
let acc: GetListMutes.Response = {
success: true,
headers: {},
data: {
subject: undefined,
lists: [],
},
}
let cursor
for (let i = 0; i < 100; i++) {
const res = await store.agent.app.bsky.graph.getListMutes({
cursor,
limit: 50,
})
cursor = res.data.cursor
acc.data.lists = acc.data.lists.concat(res.data.lists)
if (!cursor) {
break
}
}
return acc
}

View file

@ -2,13 +2,19 @@ import {makeAutoObservable} from 'mobx'
import {RootStoreModel} from '../root-store'
import {ProfileModel} from '../content/profile'
import {PostsFeedModel} from '../feeds/posts'
import {ListsListModel} from '../lists/lists-list'
export enum Sections {
Posts = 'Posts',
PostsWithReplies = 'Posts & replies',
Lists = 'Lists',
}
const USER_SELECTOR_ITEMS = [Sections.Posts, Sections.PostsWithReplies]
const USER_SELECTOR_ITEMS = [
Sections.Posts,
Sections.PostsWithReplies,
Sections.Lists,
]
export interface ProfileUiParams {
user: string
@ -22,6 +28,7 @@ export class ProfileUiModel {
// data
profile: ProfileModel
feed: PostsFeedModel
lists: ListsListModel
// ui state
selectedViewIndex = 0
@ -43,14 +50,17 @@ export class ProfileUiModel {
actor: params.user,
limit: 10,
})
this.lists = new ListsListModel(rootStore, params.user)
}
get currentView(): PostsFeedModel {
get currentView(): PostsFeedModel | ListsListModel {
if (
this.selectedView === Sections.Posts ||
this.selectedView === Sections.PostsWithReplies
) {
return this.feed
} else if (this.selectedView === Sections.Lists) {
return this.lists
}
throw new Error(`Invalid selector value: ${this.selectedViewIndex}`)
}
@ -100,6 +110,12 @@ export class ProfileUiModel {
} else if (this.feed.isEmpty) {
arr = arr.concat([ProfileUiModel.EMPTY_ITEM])
}
} else if (this.selectedView === Sections.Lists) {
if (this.lists.hasContent) {
arr = this.lists.lists
} else if (this.lists.isEmpty) {
arr = arr.concat([ProfileUiModel.EMPTY_ITEM])
}
} else {
arr = arr.concat([ProfileUiModel.EMPTY_ITEM])
}
@ -113,6 +129,8 @@ export class ProfileUiModel {
this.selectedView === Sections.PostsWithReplies
) {
return this.feed.hasContent && this.feed.hasMore && this.feed.isLoading
} else if (this.selectedView === Sections.Lists) {
return this.lists.hasContent && this.lists.hasMore && this.lists.isLoading
}
return false
}
@ -133,6 +151,11 @@ export class ProfileUiModel {
.setup()
.catch(err => this.rootStore.log.error('Failed to fetch feed', err)),
])
// HACK: need to use the DID as a param, not the username -prf
this.lists.source = this.profile.did
this.lists
.loadMore()
.catch(err => this.rootStore.log.error('Failed to fetch lists', err))
}
async update() {

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'
import {GalleryModel} from '../media/gallery'
export interface ConfirmModal {
@ -38,6 +39,19 @@ export interface ReportAccountModal {
did: string
}
export interface CreateOrEditMuteListModal {
name: 'create-or-edit-mute-list'
list?: ListModel
onSave?: (uri: string) => void
}
export interface ListAddRemoveUserModal {
name: 'list-add-remove-user'
subject: string
displayName: string
onUpdate?: () => void
}
export interface EditImageModal {
name: 'edit-image'
image: ImageModel
@ -102,9 +116,11 @@ export type Modal =
| ContentFilteringSettingsModal
| ContentLanguagesSettingsModal
// Reporting
// Moderation
| ReportAccountModal
| ReportPostModal
| CreateMuteListModal
| ListAddRemoveUserModal
// Posts
| AltTextImageModal