Replace mobx-state-tree with mobx and get a basic home feed rendering

This commit is contained in:
Paul Frazee 2022-07-19 15:37:24 -05:00
parent 6b32698b3e
commit dc55f58004
20 changed files with 534 additions and 273 deletions

View file

@ -1,33 +1,35 @@
import {onSnapshot} from 'mobx-state-tree'
import {
RootStoreModel,
RootStore,
createDefaultRootStore,
} from './models/root-store'
import {Environment} from './env'
import {autorun} from 'mobx'
import {AdxClient, blueskywebSchemas} from '@adxp/mock-api'
import {RootStoreModel} from './models/root-store'
import * as libapi from './lib/api'
import * as storage from './lib/storage'
// import * as auth from './auth' TODO
const ROOT_STATE_STORAGE_KEY = 'root'
export async function setupState() {
let rootStore: RootStore
let rootStore: RootStoreModel
let data: any
const env = new Environment()
await env.setup()
const api = new AdxClient({
pds: 'http://localhost',
schemas: blueskywebSchemas,
})
await libapi.setup(api)
rootStore = new RootStoreModel(api)
try {
data = (await storage.load(ROOT_STATE_STORAGE_KEY)) || {}
rootStore = RootStoreModel.create(data, env)
rootStore.hydrate(data)
} catch (e) {
console.error('Failed to load state from storage', e)
rootStore = RootStoreModel.create(createDefaultRootStore(), env)
}
// track changes & save to storage
onSnapshot(rootStore, snapshot =>
storage.save(ROOT_STATE_STORAGE_KEY, snapshot),
)
autorun(() => {
const snapshot = rootStore.serialize()
console.log('saving', snapshot)
storage.save(ROOT_STATE_STORAGE_KEY, snapshot)
})
// TODO
rootStore.session.setAuthed(true)
@ -47,4 +49,3 @@ export async function setupState() {
}
export {useStores, RootStoreModel, RootStoreProvider} from './models/root-store'
export type {RootStore} from './models/root-store'

View file

@ -3,48 +3,19 @@
* models live. They are made available to every model via dependency injection.
*/
import {getEnv, IStateTreeNode} from 'mobx-state-tree'
// import {ReactNativeStore} from './auth'
import {AdxClient, blueskywebSchemas, AdxRepoClient} from '@adxp/mock-api'
import * as storage from './lib/storage'
import {AdxClient, AdxRepoClient} from '@adxp/mock-api'
import * as storage from './storage'
export const adx = new AdxClient({
pds: 'http://localhost',
schemas: blueskywebSchemas,
})
export class Environment {
adx = adx
// authStore?: ReactNativeStore
constructor() {}
async setup() {
await adx.setupMock(
() => storage.load('mock-root'),
async root => {
await storage.save('mock-root', root)
},
generateMockData,
)
// this.authStore = await ReactNativeStore.load()
}
}
/**
* Extension to the MST models that adds the env property.
* Usage:
*
* .extend(withEnvironment)
*
*/
export const withEnvironment = (self: IStateTreeNode) => ({
views: {
get env() {
return getEnv<Environment>(self)
export async function setup(adx: AdxClient) {
await adx.setupMock(
() => storage.load('mock-root'),
async root => {
await storage.save('mock-root', root)
},
},
})
() => generateMockData(adx),
)
}
// TEMPORARY
// mock api config
@ -59,20 +30,20 @@ function* dateGen() {
}
const date = dateGen()
function repo(didOrName: string) {
function repo(adx: AdxClient, didOrName: string) {
const userDb = adx.mockDb.getUser(didOrName)
if (!userDb) throw new Error(`User not found: ${didOrName}`)
return adx.mainPds.repo(userDb.did, userDb.writable)
}
export async function generateMockData() {
export async function generateMockData(adx: AdxClient) {
await adx.mockDb.addUser({name: 'alice.com', writable: true})
await adx.mockDb.addUser({name: 'bob.com', writable: true})
await adx.mockDb.addUser({name: 'carla.com', writable: true})
const alice = repo('alice.com')
const bob = repo('bob.com')
const carla = repo('carla.com')
const alice = repo(adx, 'alice.com')
const bob = repo(adx, 'bob.com')
const carla = repo(adx, 'carla.com')
await alice.collection('blueskyweb.xyz:Profiles').put('Profile', 'profile', {
$type: 'blueskyweb.xyz:Profile',

View file

@ -0,0 +1,10 @@
export function isObj(v: unknown): v is Record<string, unknown> {
return !!v && typeof v === 'object'
}
export function hasProp<K extends PropertyKey>(
data: object,
prop: K,
): data is Record<K, unknown> {
return prop in data
}

View file

@ -0,0 +1,98 @@
import {makeAutoObservable, runInAction} from 'mobx'
import {bsky} from '@adxp/mock-api'
import {RootStoreModel} from './root-store'
export class FeedViewItemModel implements bsky.FeedView.FeedItem {
key: string = ''
uri: string = ''
author: bsky.FeedView.User = {did: '', name: '', displayName: ''}
repostedBy?: bsky.FeedView.User
record: Record<string, unknown> = {}
embed?:
| bsky.FeedView.RecordEmbed
| bsky.FeedView.ExternalEmbed
| bsky.FeedView.UnknownEmbed
replyCount: number = 0
repostCount: number = 0
likeCount: number = 0
indexedAt: string = ''
constructor(key: string, v: bsky.FeedView.FeedItem) {
makeAutoObservable(this)
this.key = key
Object.assign(this, v)
}
}
export class FeedViewModel implements bsky.FeedView.Response {
state = 'idle'
error = ''
params: bsky.FeedView.Params
feed: FeedViewItemModel[] = []
constructor(public rootStore: RootStoreModel, params: bsky.FeedView.Params) {
makeAutoObservable(
this,
{rootStore: false, params: false},
{autoBind: true},
)
this.params = params
}
get hasContent() {
return this.feed.length !== 0
}
get hasError() {
return this.error !== ''
}
get isLoading() {
return this.state === 'loading'
}
get isEmpty() {
return !this.hasContent && !this.hasError && !this.isLoading
}
async fetch() {
if (this.hasContent) {
await this.updateContent()
} else {
await this.initialLoad()
}
}
async initialLoad() {
this.state = 'loading'
this.error = ''
try {
const res = (await this.rootStore.api.mainPds.view(
'blueskyweb.xyz:FeedView',
this.params,
)) as bsky.FeedView.Response
this._replaceAll(res)
runInAction(() => {
this.state = 'idle'
})
} catch (e: any) {
runInAction(() => {
this.state = 'error'
this.error = `Failed to load feed: ${e.toString()}`
})
}
}
async updateContent() {
// TODO: refetch and update items
}
private _replaceAll(res: bsky.FeedView.Response) {
this.feed.length = 0
let counter = 0
for (const item of res.feed) {
// TODO: validate .record
this.feed.push(new FeedViewItemModel(`item-${counter++}`, item))
}
}
}

View file

@ -1,48 +1,41 @@
import {Instance, SnapshotOut, types, flow, getRoot} from 'mobx-state-tree'
import {RootStore} from './root-store'
import {withEnvironment} from '../env'
import {makeAutoObservable, runInAction} from 'mobx'
import {RootStoreModel} from './root-store'
export const MeModel = types
.model('Me')
.props({
did: types.maybe(types.string),
name: types.maybe(types.string),
displayName: types.maybe(types.string),
description: types.maybe(types.string),
})
.extend(withEnvironment)
.actions(self => ({
load: flow(function* () {
const sess = (getRoot(self) as RootStore).session
if (sess.isAuthed) {
// TODO temporary
const userDb = self.env.adx.mockDb.mainUser
self.did = userDb.did
self.name = userDb.name
const profile = yield self.env.adx
.repo(self.did, true)
.collection('blueskyweb.xyz:Profiles')
.get('Profile', 'profile')
.catch(_ => undefined)
export class MeModel {
did?: string
name?: string
displayName?: string
description?: string
constructor(public rootStore: RootStoreModel) {
makeAutoObservable(this, {rootStore: false}, {autoBind: true})
}
async load() {
const sess = this.rootStore.session
if (sess.isAuthed) {
const userDb = this.rootStore.api.mockDb.mainUser
this.did = userDb.did
this.name = userDb.name
const profile = await this.rootStore.api
.repo(this.did, true)
.collection('blueskyweb.xyz:Profiles')
.get('Profile', 'profile')
.catch(_ => undefined)
runInAction(() => {
if (profile?.valid) {
self.displayName = profile.value.displayName
self.description = profile.value.description
this.displayName = profile.value.displayName
this.description = profile.value.description
} else {
self.displayName = ''
self.description = ''
this.displayName = ''
this.description = ''
}
} else {
self.did = undefined
self.name = undefined
self.displayName = undefined
self.description = undefined
}
}),
}))
export interface Me extends Instance<typeof MeModel> {}
export interface MeSnapshot extends SnapshotOut<typeof MeModel> {}
export function createDefaultMe() {
return {}
})
} else {
this.did = undefined
this.name = undefined
this.displayName = undefined
this.description = undefined
}
}
}

View file

@ -2,27 +2,43 @@
* The root store is the base of all modeled state.
*/
import {Instance, SnapshotOut, types} from 'mobx-state-tree'
import {makeAutoObservable} from 'mobx'
import {adx, AdxClient} from '@adxp/mock-api'
import {createContext, useContext} from 'react'
import {SessionModel, createDefaultSession} from './session'
import {MeModel, createDefaultMe} from './me'
import {isObj, hasProp} from '../lib/type-guards'
import {SessionModel} from './session'
import {MeModel} from './me'
import {FeedViewModel} from './feed-view'
export const RootStoreModel = types.model('RootStore').props({
session: SessionModel,
me: MeModel,
})
export class RootStoreModel {
session = new SessionModel()
me = new MeModel(this)
homeFeed = new FeedViewModel(this, {})
export interface RootStore extends Instance<typeof RootStoreModel> {}
export interface RootStoreSnapshot extends SnapshotOut<typeof RootStoreModel> {}
constructor(public api: AdxClient) {
makeAutoObservable(this, {
api: false,
serialize: false,
hydrate: false,
})
}
export function createDefaultRootStore() {
return {
session: createDefaultSession(),
me: createDefaultMe(),
serialize(): unknown {
return {
session: this.session.serialize(),
}
}
hydrate(v: unknown) {
if (isObj(v)) {
if (hasProp(v, 'session')) {
this.session.hydrate(v.session)
}
}
}
}
// react context & hook utilities
const RootStoreContext = createContext<RootStore>({} as RootStore)
const throwawayInst = new RootStoreModel(adx) // this will be replaced by the loader
const RootStoreContext = createContext<RootStoreModel>(throwawayInst)
export const RootStoreProvider = RootStoreContext.Provider
export const useStores = () => useContext(RootStoreContext)

View file

@ -1,106 +1,109 @@
import {Instance, SnapshotOut, types, flow} from 'mobx-state-tree'
import {makeAutoObservable} from 'mobx'
import {isObj, hasProp} from '../lib/type-guards'
// import {UserConfig} from '../../api'
// import * as auth from '../lib/auth'
import {withEnvironment} from '../env'
export const SessionModel = types
.model('Session')
.props({
isAuthed: types.boolean,
uiIsProcessing: types.maybe(types.boolean),
uiError: types.maybe(types.string),
export class SessionModel {
isAuthed = false
// TODO: these should be stored somewhere secret
serverUrl: types.maybe(types.string),
secretKeyStr: types.maybe(types.string),
rootAuthToken: types.maybe(types.string),
})
.extend(withEnvironment)
.actions(self => ({
setAuthed: (v: boolean) => {
self.isAuthed = v
},
login: flow(function* () {
/*self.uiIsProcessing = true
self.uiError = undefined
try {
if (!self.env.authStore) {
throw new Error('Auth store not initialized')
}
const res = yield auth.requestAppUcan(self.env.authStore)
self.isAuthed = res
self.uiIsProcessing = false
return res
} catch (e: any) {
console.error('Failed to request app ucan', e)
self.uiError = e.toString()
self.uiIsProcessing = false
return false
}*/
}),
logout: flow(function* () {
/*self.uiIsProcessing = true
self.uiError = undefined
try {
if (!self.env.authStore) {
throw new Error('Auth store not initialized')
}
const res = yield auth.logout(self.env.authStore)
self.isAuthed = false
self.uiIsProcessing = false
return res
} catch (e: any) {
console.error('Failed to log out', e)
self.uiError = e.toString()
self.uiIsProcessing = false
return false
}*/
}),
/*loadAccount: flow(function* () {
self.uiIsProcessing = true
self.uiError = undefined
try {
// const cfg = yield UserConfig.hydrate({
// serverUrl: self.serverUrl,
// secretKeyStr: self.secretKeyStr,
// rootAuthToken: self.rootAuthToken,
// })
// self.env.api.setUserCfg(cfg)
self.isAuthed = true
self.uiIsProcessing = false
return true
} catch (e: any) {
console.error('Failed to create test account', e)
self.uiError = e.toString()
self.uiIsProcessing = false
return false
constructor() {
makeAutoObservable(this, {
serialize: false,
hydrate: false,
})
}
serialize(): unknown {
return {
isAuthed: this.isAuthed,
}
}
hydrate(v: unknown) {
if (isObj(v)) {
if (hasProp(v, 'isAuthed') && typeof v.isAuthed === 'boolean') {
this.isAuthed = v.isAuthed
}
}),
createTestAccount: flow(function* (_serverUrl: string) {
self.uiIsProcessing = true
self.uiError = undefined
try {
// const cfg = yield UserConfig.createTest(serverUrl)
// const state = yield cfg.serialize()
// self.serverUrl = state.serverUrl
// self.secretKeyStr = state.secretKeyStr
// self.rootAuthToken = state.rootAuthToken
self.isAuthed = true
// self.env.api.setUserCfg(cfg)
} catch (e: any) {
console.error('Failed to create test account', e)
self.uiError = e.toString()
}
self.uiIsProcessing = false
}),*/
}))
}
}
export interface Session extends Instance<typeof SessionModel> {}
export interface SessionSnapshot extends SnapshotOut<typeof SessionModel> {}
export function createDefaultSession() {
return {
isAuthed: false,
uiState: 'idle',
setAuthed(v: boolean) {
this.isAuthed = v
}
}
// TODO
/*login: flow(function* () {
/*self.uiIsProcessing = true
self.uiError = undefined
try {
if (!self.env.authStore) {
throw new Error('Auth store not initialized')
}
const res = yield auth.requestAppUcan(self.env.authStore)
self.isAuthed = res
self.uiIsProcessing = false
return res
} catch (e: any) {
console.error('Failed to request app ucan', e)
self.uiError = e.toString()
self.uiIsProcessing = false
return false
}
}),
logout: flow(function* () {
self.uiIsProcessing = true
self.uiError = undefined
try {
if (!self.env.authStore) {
throw new Error('Auth store not initialized')
}
const res = yield auth.logout(self.env.authStore)
self.isAuthed = false
self.uiIsProcessing = false
return res
} catch (e: any) {
console.error('Failed to log out', e)
self.uiError = e.toString()
self.uiIsProcessing = false
return false
}
}),
loadAccount: flow(function* () {
self.uiIsProcessing = true
self.uiError = undefined
try {
// const cfg = yield UserConfig.hydrate({
// serverUrl: self.serverUrl,
// secretKeyStr: self.secretKeyStr,
// rootAuthToken: self.rootAuthToken,
// })
// self.env.api.setUserCfg(cfg)
self.isAuthed = true
self.uiIsProcessing = false
return true
} catch (e: any) {
console.error('Failed to create test account', e)
self.uiError = e.toString()
self.uiIsProcessing = false
return false
}
}),
createTestAccount: flow(function* (_serverUrl: string) {
self.uiIsProcessing = true
self.uiError = undefined
try {
// const cfg = yield UserConfig.createTest(serverUrl)
// const state = yield cfg.serialize()
// self.serverUrl = state.serverUrl
// self.secretKeyStr = state.secretKeyStr
// self.rootAuthToken = state.rootAuthToken
self.isAuthed = true
// self.env.api.setUserCfg(cfg)
} catch (e: any) {
console.error('Failed to create test account', e)
self.uiError = e.toString()
}
self.uiIsProcessing = false
}),
}))*/