diff options
Diffstat (limited to 'src/state/models')
-rw-r--r-- | src/state/models/content/list-membership.ts | 112 | ||||
-rw-r--r-- | src/state/models/content/list.ts | 257 | ||||
-rw-r--r-- | src/state/models/content/post-thread.ts | 4 | ||||
-rw-r--r-- | src/state/models/content/profile.ts | 4 | ||||
-rw-r--r-- | src/state/models/feeds/notifications.ts | 1 | ||||
-rw-r--r-- | src/state/models/feeds/posts.ts | 4 | ||||
-rw-r--r-- | src/state/models/lists/lists-list.ts | 214 | ||||
-rw-r--r-- | src/state/models/media/gallery.ts | 18 | ||||
-rw-r--r-- | src/state/models/media/image.ts | 94 | ||||
-rw-r--r-- | src/state/models/root-store.ts | 4 | ||||
-rw-r--r-- | src/state/models/ui/preferences.ts | 84 | ||||
-rw-r--r-- | src/state/models/ui/profile.ts | 24 | ||||
-rw-r--r-- | src/state/models/ui/shell.ts | 18 |
13 files changed, 775 insertions, 63 deletions
diff --git a/src/state/models/content/list-membership.ts b/src/state/models/content/list-membership.ts new file mode 100644 index 000000000..b4af4472b --- /dev/null +++ b/src/state/models/content/list-membership.ts @@ -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) + } + } + } +} diff --git a/src/state/models/content/list.ts b/src/state/models/content/list.ts new file mode 100644 index 000000000..673ee9430 --- /dev/null +++ b/src/state/models/content/list.ts @@ -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})), + ) + } +} diff --git a/src/state/models/content/post-thread.ts b/src/state/models/content/post-thread.ts index a0f75493a..74a75d803 100644 --- a/src/state/models/content/post-thread.ts +++ b/src/state/models/content/post-thread.ts @@ -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) || diff --git a/src/state/models/content/profile.ts b/src/state/models/content/profile.ts index dddf488a3..9d8378f79 100644 --- a/src/state/models/content/profile.ts +++ b/src/state/models/content/profile.ts @@ -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 diff --git a/src/state/models/feeds/notifications.ts b/src/state/models/feeds/notifications.ts index 3ffd10b99..73424f03e 100644 --- a/src/state/models/feeds/notifications.ts +++ b/src/state/models/feeds/notifications.ts @@ -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: diff --git a/src/state/models/feeds/posts.ts b/src/state/models/feeds/posts.ts index 7adc1cb1c..dfd92b35c 100644 --- a/src/state/models/feeds/posts.ts +++ b/src/state/models/feeds/posts.ts @@ -25,6 +25,7 @@ import {PostLabelInfo, PostModeration} from 'lib/labeling/types' import { getEmbedLabels, getEmbedMuted, + getEmbedMutedByList, getEmbedBlocking, getEmbedBlockedBy, getPostModeration, @@ -106,6 +107,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) || diff --git a/src/state/models/lists/lists-list.ts b/src/state/models/lists/lists-list.ts new file mode 100644 index 000000000..309ab0e03 --- /dev/null +++ b/src/state/models/lists/lists-list.ts @@ -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 +} diff --git a/src/state/models/media/gallery.ts b/src/state/models/media/gallery.ts index 86bf8a314..67f8d2ea1 100644 --- a/src/state/models/media/gallery.ts +++ b/src/state/models/media/gallery.ts @@ -52,16 +52,14 @@ export class GalleryModel { } async edit(image: ImageModel) { - if (!isNative) { + if (isNative) { + this.crop(image) + } else { this.rootStore.shell.openModal({ name: 'edit-image', image, gallery: this, }) - - return - } else { - this.crop(image) } } @@ -104,10 +102,14 @@ export class GalleryModel { async pick() { const images = await openPicker(this.rootStore, { - multiple: true, - maxFiles: 4 - this.images.length, + selectionLimit: 4 - this.size, + allowsMultipleSelection: true, }) - await Promise.all(images.map(image => this.add(image))) + return await Promise.all( + images.map(image => { + this.add(image) + }), + ) } } diff --git a/src/state/models/media/image.ts b/src/state/models/media/image.ts index ff464a5a9..ec93bf5b6 100644 --- a/src/state/models/media/image.ts +++ b/src/state/models/media/image.ts @@ -13,12 +13,12 @@ import {compressAndResizeImageForPost} from 'lib/media/manip' // Cases to consider: ExternalEmbed export interface ImageManipulationAttributes { + aspectRatio?: '4:3' | '1:1' | '3:4' | 'None' rotate?: number scale?: number position?: Position flipHorizontal?: boolean flipVertical?: boolean - aspectRatio?: '4:3' | '1:1' | '3:4' | 'None' } export class ImageModel implements RNImage { @@ -34,14 +34,14 @@ export class ImageModel implements RNImage { scaledHeight: number = POST_IMG_MAX.height // Web manipulation - aspectRatio?: ImageManipulationAttributes['aspectRatio'] - position?: Position = undefined - prev?: RNImage = undefined - rotation?: number = 0 - scale?: number = 1 - flipHorizontal?: boolean = false - flipVertical?: boolean = false - + prev?: RNImage + attributes: ImageManipulationAttributes = { + aspectRatio: '1:1', + scale: 1, + flipHorizontal: false, + flipVertical: false, + rotate: 0, + } prevAttributes: ImageManipulationAttributes = {} constructor(public rootStore: RootStoreModel, image: RNImage) { @@ -65,6 +65,25 @@ export class ImageModel implements RNImage { // : MAX_IMAGE_SIZE_IN_BYTES / this.size // } + setRatio(aspectRatio: ImageManipulationAttributes['aspectRatio']) { + this.attributes.aspectRatio = aspectRatio + } + + setRotate(degrees: number) { + this.attributes.rotate = degrees + this.manipulate({}) + } + + flipVertical() { + this.attributes.flipVertical = !this.attributes.flipVertical + this.manipulate({}) + } + + flipHorizontal() { + this.attributes.flipHorizontal = !this.attributes.flipHorizontal + this.manipulate({}) + } + get ratioMultipliers() { return { '4:3': 4 / 3, @@ -116,7 +135,7 @@ export class ImageModel implements RNImage { // Only for mobile async crop() { try { - const cropped = await openCropper(this.rootStore, { + const cropped = await openCropper({ mediaType: 'photo', path: this.path, freeStyleCropEnabled: true, @@ -162,33 +181,19 @@ export class ImageModel implements RNImage { crop?: ActionCrop['crop'] } & ImageManipulationAttributes, ) { - const {aspectRatio, crop, flipHorizontal, flipVertical, rotate, scale} = - attributes + const {aspectRatio, crop, position, scale} = attributes const modifiers = [] - if (flipHorizontal !== undefined) { - this.flipHorizontal = flipHorizontal - } - - if (flipVertical !== undefined) { - this.flipVertical = flipVertical - } - - if (this.flipHorizontal) { + if (this.attributes.flipHorizontal) { modifiers.push({flip: FlipType.Horizontal}) } - if (this.flipVertical) { + if (this.attributes.flipVertical) { modifiers.push({flip: FlipType.Vertical}) } - // TODO: Fix rotation -- currently not functional - if (rotate !== undefined) { - this.rotation = rotate - } - - if (this.rotation !== undefined) { - modifiers.push({rotate: this.rotation}) + if (this.attributes.rotate !== undefined) { + modifiers.push({rotate: this.attributes.rotate}) } if (crop !== undefined) { @@ -203,18 +208,21 @@ export class ImageModel implements RNImage { } if (scale !== undefined) { - this.scale = scale + this.attributes.scale = scale + } + + if (position !== undefined) { + this.attributes.position = position } if (aspectRatio !== undefined) { - this.aspectRatio = aspectRatio + this.attributes.aspectRatio = aspectRatio } - const ratioMultiplier = this.ratioMultipliers[this.aspectRatio ?? '1:1'] + const ratioMultiplier = + this.ratioMultipliers[this.attributes.aspectRatio ?? '1:1'] - // TODO: Ollie - should support up to 2000 but smaller images that scale - // up need an updated compression factor calculation. Use 1000 for now. - const MAX_SIDE = 1000 + const MAX_SIDE = 2000 const result = await ImageManipulator.manipulateAsync( this.path, @@ -223,7 +231,7 @@ export class ImageModel implements RNImage { {resize: ratioMultiplier > 1 ? {width: MAX_SIDE} : {height: MAX_SIDE}}, ], { - compress: 0.7, // TODO: revisit compression calculation + compress: 0.9, format: SaveFormat.JPEG, }, ) @@ -238,16 +246,12 @@ export class ImageModel implements RNImage { }) } + resetCompressed() { + this.manipulate({}) + } + previous() { this.compressed = this.prev - - const {flipHorizontal, flipVertical, rotate, position, scale} = - this.prevAttributes - - this.scale = scale - this.rotation = rotate - this.flipHorizontal = flipHorizontal - this.flipVertical = flipVertical - this.position = position + this.attributes = this.prevAttributes } } diff --git a/src/state/models/root-store.ts b/src/state/models/root-store.ts index 8cd23efcd..f2a352a79 100644 --- a/src/state/models/root-store.ts +++ b/src/state/models/root-store.ts @@ -37,7 +37,7 @@ export class RootStoreModel { log = new LogModel() session = new SessionModel(this) shell = new ShellUiModel(this) - preferences = new PreferencesModel() + preferences = new PreferencesModel(this) me = new MeModel(this) invitedUsers = new InvitedUsers(this) profiles = new ProfilesCache(this) @@ -126,6 +126,7 @@ export class RootStoreModel { this.log.debug('RootStoreModel:handleSessionChange') this.agent = agent this.me.clear() + /* dont await */ this.preferences.sync() await this.me.load() if (!hadSession) { resetNavigation() @@ -161,6 +162,7 @@ export class RootStoreModel { } try { await this.me.updateIfNeeded() + await this.preferences.sync() } catch (e: any) { this.log.error('Failed to fetch latest state', e) } diff --git a/src/state/models/ui/preferences.ts b/src/state/models/ui/preferences.ts index fcd33af8e..1471420fc 100644 --- a/src/state/models/ui/preferences.ts +++ b/src/state/models/ui/preferences.ts @@ -1,7 +1,8 @@ -import {makeAutoObservable} from 'mobx' +import {makeAutoObservable, runInAction} from 'mobx' import {getLocales} from 'expo-localization' import {isObj, hasProp} from 'lib/type-guards' -import {ComAtprotoLabelDefs} from '@atproto/api' +import {RootStoreModel} from '../root-store' +import {ComAtprotoLabelDefs, AppBskyActorDefs} from '@atproto/api' import {LabelValGroup} from 'lib/labeling/types' import {getLabelValueGroup} from 'lib/labeling/helpers' import { @@ -15,6 +16,15 @@ import {isIOS} from 'platform/detection' const deviceLocales = getLocales() export type LabelPreference = 'show' | 'warn' | 'hide' +const LABEL_GROUPS = [ + 'nsfw', + 'nudity', + 'suggestive', + 'gore', + 'hate', + 'spam', + 'impersonation', +] export class LabelPreferencesModel { nsfw: LabelPreference = 'hide' @@ -36,7 +46,7 @@ export class PreferencesModel { deviceLocales?.map?.(locale => locale.languageCode) || [] contentLabels = new LabelPreferencesModel() - constructor() { + constructor(public rootStore: RootStoreModel) { makeAutoObservable(this, {}, {autoBind: true}) } @@ -65,6 +75,35 @@ export class PreferencesModel { } } + async sync() { + const res = await this.rootStore.agent.app.bsky.actor.getPreferences({}) + runInAction(() => { + for (const pref of res.data.preferences) { + if ( + AppBskyActorDefs.isAdultContentPref(pref) && + AppBskyActorDefs.validateAdultContentPref(pref).success + ) { + this.adultContentEnabled = pref.enabled + } else if ( + AppBskyActorDefs.isContentLabelPref(pref) && + AppBskyActorDefs.validateAdultContentPref(pref).success + ) { + if (LABEL_GROUPS.includes(pref.label)) { + this.contentLabels[pref.label] = pref.visibility + } + } + } + }) + } + + async update(cb: (prefs: AppBskyActorDefs.Preferences) => void) { + const res = await this.rootStore.agent.app.bsky.actor.getPreferences({}) + cb(res.data.preferences) + await this.rootStore.agent.app.bsky.actor.putPreferences({ + preferences: res.data.preferences, + }) + } + hasContentLanguage(code2: string) { return this.contentLanguages.includes(code2) } @@ -79,11 +118,48 @@ export class PreferencesModel { } } - setContentLabelPref( + async setContentLabelPref( key: keyof LabelPreferencesModel, value: LabelPreference, ) { this.contentLabels[key] = value + + await this.update((prefs: AppBskyActorDefs.Preferences) => { + const existing = prefs.find( + pref => + AppBskyActorDefs.isContentLabelPref(pref) && + AppBskyActorDefs.validateAdultContentPref(pref).success && + pref.label === key, + ) + if (existing) { + existing.visibility = value + } else { + prefs.push({ + $type: 'app.bsky.actor.defs#contentLabelPref', + label: key, + visibility: value, + }) + } + }) + } + + async setAdultContentEnabled(v: boolean) { + this.adultContentEnabled = v + await this.update((prefs: AppBskyActorDefs.Preferences) => { + const existing = prefs.find( + pref => + AppBskyActorDefs.isAdultContentPref(pref) && + AppBskyActorDefs.validateAdultContentPref(pref).success, + ) + if (existing) { + existing.enabled = v + } else { + prefs.push({ + $type: 'app.bsky.actor.defs#adultContentPref', + enabled: v, + }) + } + }) } getLabelPreference(labels: ComAtprotoLabelDefs.Label[] | undefined): { diff --git a/src/state/models/ui/profile.ts b/src/state/models/ui/profile.ts index 855955d12..4f604bfc0 100644 --- a/src/state/models/ui/profile.ts +++ b/src/state/models/ui/profile.ts @@ -1,20 +1,23 @@ import {makeAutoObservable} from 'mobx' +import {AppBskyFeedDefs} from '@atproto/api' import {RootStoreModel} from '../root-store' import {ProfileModel} from '../content/profile' import {PostsFeedModel} from '../feeds/posts' import {ActorFeedsModel} from '../feeds/algo/actor' -import {AppBskyFeedDefs} from '@atproto/api' +import {ListsListModel} from '../lists/lists-list' export enum Sections { Posts = 'Posts', PostsWithReplies = 'Posts & replies', CustomAlgorithms = 'Algos', + Lists = 'Lists', } const USER_SELECTOR_ITEMS = [ Sections.Posts, Sections.PostsWithReplies, Sections.CustomAlgorithms, + Sections.Lists, ] export interface ProfileUiParams { @@ -30,6 +33,7 @@ export class ProfileUiModel { profile: ProfileModel feed: PostsFeedModel algos: ActorFeedsModel + lists: ListsListModel // ui state selectedViewIndex = 0 @@ -52,14 +56,17 @@ export class ProfileUiModel { limit: 10, }) this.algos = new ActorFeedsModel(rootStore, {actor: params.user}) + this.lists = new ListsListModel(rootStore, params.user) } - get currentView(): PostsFeedModel | ActorFeedsModel { + get currentView(): PostsFeedModel | ActorFeedsModel | ListsListModel { if ( this.selectedView === Sections.Posts || this.selectedView === Sections.PostsWithReplies ) { return this.feed + } else if (this.selectedView === Sections.Lists) { + return this.lists } if (this.selectedView === Sections.CustomAlgorithms) { return this.algos @@ -121,6 +128,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 { // fallback, add empty item, to show empty message arr = arr.concat([ProfileUiModel.EMPTY_ITEM]) @@ -135,6 +148,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 } @@ -155,6 +170,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() { diff --git a/src/state/models/ui/shell.ts b/src/state/models/ui/shell.ts index 67f8e16d4..9b9a176be 100644 --- a/src/state/models/ui/shell.ts +++ b/src/state/models/ui/shell.ts @@ -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 |