[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:
parent
34d8fa5991
commit
ebcd633386
48 changed files with 2984 additions and 151 deletions
112
src/state/models/content/list-membership.ts
Normal file
112
src/state/models/content/list-membership.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
257
src/state/models/content/list.ts
Normal file
257
src/state/models/content/list.ts
Normal 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})),
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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) ||
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue