Merge branch 'main' into custom-algos
This commit is contained in:
commit
7aa1d9010e
99 changed files with 4234 additions and 716 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
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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) ||
|
||||
|
|
214
src/state/models/lists/lists-list.ts
Normal file
214
src/state/models/lists/lists-list.ts
Normal 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
|
||||
}
|
|
@ -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)
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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): {
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue