From dc55f580049d284c6e01271e3885c4fa23a8f458 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Tue, 19 Jul 2022 15:37:24 -0500 Subject: Replace mobx-state-tree with mobx and get a basic home feed rendering --- README.md | 4 +- package.json | 6 +- src/App.native.tsx | 28 ++++- src/App.web.tsx | 28 ++++- src/assets/alice.jpg | Bin 0 -> 25330 bytes src/assets/bob.jpg | Bin 0 -> 45720 bytes src/assets/carla.jpg | Bin 0 -> 12259 bytes src/state/env.ts | 239 ----------------------------------------- src/state/index.ts | 33 +++--- src/state/lib/api.ts | 210 ++++++++++++++++++++++++++++++++++++ src/state/lib/type-guards.ts | 10 ++ src/state/models/feed-view.ts | 98 +++++++++++++++++ src/state/models/me.ts | 79 +++++++------- src/state/models/root-store.ts | 46 +++++--- src/state/models/session.ts | 199 +++++++++++++++++----------------- src/view/com/Feed.tsx | 17 +++ src/view/com/FeedItem.tsx | 104 ++++++++++++++++++ src/view/screens/Home.tsx | 25 +++-- src/view/screens/Login.tsx | 32 +++--- src/view/screens/Signup.tsx | 14 +-- yarn.lock | 25 ++--- 21 files changed, 729 insertions(+), 468 deletions(-) create mode 100644 src/assets/alice.jpg create mode 100644 src/assets/bob.jpg create mode 100644 src/assets/carla.jpg delete mode 100644 src/state/env.ts create mode 100644 src/state/lib/api.ts create mode 100644 src/state/lib/type-guards.ts create mode 100644 src/state/models/feed-view.ts create mode 100644 src/view/com/Feed.tsx create mode 100644 src/view/com/FeedItem.tsx diff --git a/README.md b/README.md index bbd390462..2133d9c6d 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Uses: - [React Native](https://reactnative.dev) - [React Native for Web](https://necolas.github.io/react-native-web/) - [React Navigation](https://reactnative.dev/docs/navigation#react-navigation) -- [MobX](https://mobx.js.org/README.html) and [MobX State Tree](https://mobx-state-tree.js.org/) +- [MobX](https://mobx.js.org/README.html) - [Async Storage](https://github.com/react-native-async-storage/async-storage) ## TODOs @@ -54,7 +54,7 @@ The `metro.config.js` file rewrites a couple of imports. This is partly to work ### Cryptography -For native builds, we must provide a polyfill of `webcrypto`. We use a custom native module AppSecureRandom (based on [react-native-securerandom](https://github.com/robhogan/react-native-securerandom)) for the CRNG and [msrcrypto](https://github.com/kevlened/msrCrypto) for the cryptography. +For native builds, we must provide a polyfill of `webcrypto`. We use a custom native module AppSecureRandom (based on [react-native-securerandom](https://github.com/robhogan/react-native-securerandom)) for the CRNG and [msrcrypto](https://github.com/microsoft/MSR-JavaScript-Crypto) for the cryptography. **NOTE** Keys are not currently stored securely. diff --git a/package.json b/package.json index ed5c20259..7c87b38ef 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "dependencies": { "@adxp/auth": "*", "@adxp/common": "*", - "@adxp/mock-api": "git+ssh://git@github.com:bluesky-social/adx-mock-api.git#0bccd04217c78a7c9786a45684ac2ffb9767429b", + "@adxp/mock-api": "git+ssh://git@github.com:bluesky-social/adx-mock-api.git#74a1f810a342aa4b58a54724e21c57d2faa5e72e", "@react-native-async-storage/async-storage": "^1.17.6", "@react-navigation/bottom-tabs": "^6.3.1", "@react-navigation/native": "^6.0.10", @@ -23,9 +23,9 @@ "@react-navigation/stack": "^6.2.1", "@zxing/text-encoding": "^0.9.0", "base64-js": "^1.5.1", - "mobx": "^6.6.0", + "mobx": "^6.6.1", "mobx-react-lite": "^3.4.0", - "mobx-state-tree": "^5.1.5", + "moment": "^2.29.4", "react": "17.0.2", "react-dom": "17.0.2", "react-native": "0.68.2", diff --git a/src/App.native.tsx b/src/App.native.tsx index 1326b184b..071b7457b 100644 --- a/src/App.native.tsx +++ b/src/App.native.tsx @@ -1,11 +1,35 @@ import 'react-native-url-polyfill/auto' import React, {useState, useEffect} from 'react' +import moment from 'moment' import {whenWebCrypto} from './platform/polyfills.native' -import {RootStore, setupState, RootStoreProvider} from './state' +import {RootStoreModel, setupState, RootStoreProvider} from './state' import * as Routes from './view/routes' +moment.updateLocale('en', { + relativeTime: { + future: 'in %s', + past: '%s ago', + s: 'a few seconds', + ss: '%ds', + m: 'a minute', + mm: '%dm', + h: 'an hour', + hh: '%dh', + d: 'a day', + dd: '%dd', + w: 'a week', + ww: '%dw', + M: 'a month', + MM: '%dmo', + y: 'a year', + yy: '%dy', + }, +}) + function App() { - const [rootStore, setRootStore] = useState(undefined) + const [rootStore, setRootStore] = useState( + undefined, + ) // init useEffect(() => { diff --git a/src/App.web.tsx b/src/App.web.tsx index 34b6ac6cb..a6f98487c 100644 --- a/src/App.web.tsx +++ b/src/App.web.tsx @@ -1,9 +1,33 @@ import React, {useState, useEffect} from 'react' -import {RootStore, setupState, RootStoreProvider} from './state' +import moment from 'moment' +import {RootStoreModel, setupState, RootStoreProvider} from './state' import * as Routes from './view/routes' +moment.updateLocale('en', { + relativeTime: { + future: 'in %s', + past: '%s ago', + s: 'a few seconds', + ss: '%ds', + m: 'a minute', + mm: '%dm', + h: 'an hour', + hh: '%dh', + d: 'a day', + dd: '%dd', + w: 'a week', + ww: '%dw', + M: 'a month', + MM: '%dmo', + y: 'a year', + yy: '%dy', + }, +}) + function App() { - const [rootStore, setRootStore] = useState(undefined) + const [rootStore, setRootStore] = useState( + undefined, + ) // init useEffect(() => { diff --git a/src/assets/alice.jpg b/src/assets/alice.jpg new file mode 100644 index 000000000..83555d962 Binary files /dev/null and b/src/assets/alice.jpg differ diff --git a/src/assets/bob.jpg b/src/assets/bob.jpg new file mode 100644 index 000000000..0567772b6 Binary files /dev/null and b/src/assets/bob.jpg differ diff --git a/src/assets/carla.jpg b/src/assets/carla.jpg new file mode 100644 index 000000000..f26734280 Binary files /dev/null and b/src/assets/carla.jpg differ diff --git a/src/state/env.ts b/src/state/env.ts deleted file mode 100644 index a9e9bf244..000000000 --- a/src/state/env.ts +++ /dev/null @@ -1,239 +0,0 @@ -/** - * The environment is a place where services and shared dependencies between - * 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' - -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(self) - }, - }, -}) - -// TEMPORARY -// mock api config -// ======= - -function* dateGen() { - let start = 1657846031914 - while (true) { - yield new Date(start).toISOString() - start += 1e3 - } -} -const date = dateGen() - -function repo(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() { - 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') - - await alice.collection('blueskyweb.xyz:Profiles').put('Profile', 'profile', { - $type: 'blueskyweb.xyz:Profile', - displayName: 'Alice', - description: 'Test user 1', - }) - await bob.collection('blueskyweb.xyz:Profiles').put('Profile', 'profile', { - $type: 'blueskyweb.xyz:Profile', - displayName: 'Bob', - description: 'Test user 2', - }) - await carla.collection('blueskyweb.xyz:Profiles').put('Profile', 'profile', { - $type: 'blueskyweb.xyz:Profile', - displayName: 'Carla', - description: 'Test user 3', - }) - - // everybody follows everybody - const follow = async (who: AdxRepoClient, subjectName: string) => { - const subjectDb = adx.mockDb.getUser(subjectName) - return who.collection('blueskyweb.xyz:Follows').create('Follow', { - $type: 'blueskyweb.xyz:Follow', - subject: { - did: subjectDb?.did, - name: subjectDb?.name, - }, - createdAt: date.next().value, - }) - } - await follow(alice, 'bob.com') - await follow(alice, 'carla.com') - await follow(bob, 'alice.com') - await follow(bob, 'carla.com') - await follow(carla, 'alice.com') - await follow(carla, 'bob.com') - - // 2 posts on each user - const alicePosts: {uri: string}[] = [] - for (let i = 0; i < 2; i++) { - alicePosts.push( - await alice.collection('blueskyweb.xyz:Posts').create('Post', { - $type: 'blueskyweb.xyz:Post', - text: `Alice post ${i + 1}`, - createdAt: date.next().value, - }), - ) - await bob.collection('blueskyweb.xyz:Posts').create('Post', { - $type: 'blueskyweb.xyz:Post', - text: `Bob post ${i + 1}`, - createdAt: date.next().value, - }) - await carla.collection('blueskyweb.xyz:Posts').create('Post', { - $type: 'blueskyweb.xyz:Post', - text: `Carla post ${i + 1}`, - createdAt: date.next().value, - }) - } - - // small thread of replies on alice's first post - const bobReply1 = await bob - .collection('blueskyweb.xyz:Posts') - .create('Post', { - $type: 'blueskyweb.xyz:Post', - text: 'Bob reply', - reply: {root: alicePosts[0].uri, parent: alicePosts[0].uri}, - createdAt: date.next().value, - }) - await carla.collection('blueskyweb.xyz:Posts').create('Post', { - $type: 'blueskyweb.xyz:Post', - text: 'Carla reply', - reply: {root: alicePosts[0].uri, parent: alicePosts[0].uri}, - createdAt: date.next().value, - }) - const aliceReply1 = await alice - .collection('blueskyweb.xyz:Posts') - .create('Post', { - $type: 'blueskyweb.xyz:Post', - text: 'Alice reply', - reply: {root: alicePosts[0].uri, parent: bobReply1.uri}, - createdAt: date.next().value, - }) - - // bob and carla repost alice's first post - await bob.collection('blueskyweb.xyz:Posts').create('Repost', { - $type: 'blueskyweb.xyz:Repost', - subject: alicePosts[0].uri, - createdAt: date.next().value, - }) - await carla.collection('blueskyweb.xyz:Posts').create('Repost', { - $type: 'blueskyweb.xyz:Repost', - subject: alicePosts[0].uri, - createdAt: date.next().value, - }) - - // bob likes all of alice's posts - for (let i = 0; i < 2; i++) { - await bob.collection('blueskyweb.xyz:Likes').create('Like', { - $type: 'blueskyweb.xyz:Like', - subject: alicePosts[i].uri, - createdAt: date.next().value, - }) - } - - // carla likes all of alice's posts and everybody's replies - for (let i = 0; i < 2; i++) { - await carla.collection('blueskyweb.xyz:Likes').create('Like', { - $type: 'blueskyweb.xyz:Like', - subject: alicePosts[i].uri, - createdAt: date.next().value, - }) - } - await carla.collection('blueskyweb.xyz:Likes').create('Like', { - $type: 'blueskyweb.xyz:Like', - subject: aliceReply1.uri, - createdAt: date.next().value, - }) - await carla.collection('blueskyweb.xyz:Likes').create('Like', { - $type: 'blueskyweb.xyz:Like', - subject: bobReply1.uri, - createdAt: date.next().value, - }) - - // give alice 3 badges, 2 from bob and 2 from carla, with one ignored - const inviteBadge = await bob - .collection('blueskyweb.xyz:Badges') - .create('Badge', { - $type: 'blueskyweb.xyz:Badge', - subject: {did: alice.did, name: 'alice.com'}, - assertion: {type: 'invite'}, - createdAt: date.next().value, - }) - const techTagBadge1 = await bob - .collection('blueskyweb.xyz:Badges') - .create('Badge', { - $type: 'blueskyweb.xyz:Badge', - subject: {did: alice.did, name: 'alice.com'}, - assertion: {type: 'tag', tag: 'tech'}, - createdAt: date.next().value, - }) - const techTagBadge2 = await carla - .collection('blueskyweb.xyz:Badges') - .create('Badge', { - $type: 'blueskyweb.xyz:Badge', - subject: {did: alice.did, name: 'alice.com'}, - assertion: {type: 'tag', tag: 'tech'}, - createdAt: date.next().value, - }) - await bob.collection('blueskyweb.xyz:Badges').create('Badge', { - $type: 'blueskyweb.xyz:Badge', - subject: {did: alice.did, name: 'alice.com'}, - assertion: {type: 'employee'}, - createdAt: date.next().value, - }) - await alice.collection('blueskyweb.xyz:Profiles').put('Profile', 'profile', { - $type: 'blueskyweb.xyz:Profile', - displayName: 'Alice', - description: 'Test user 1', - badges: [ - {uri: inviteBadge.uri}, - {uri: techTagBadge1.uri}, - {uri: techTagBadge2.uri}, - ], - }) -} diff --git a/src/state/index.ts b/src/state/index.ts index 24c3b9430..91726dc6e 100644 --- a/src/state/index.ts +++ b/src/state/index.ts @@ -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' diff --git a/src/state/lib/api.ts b/src/state/lib/api.ts new file mode 100644 index 000000000..19c0c2d8b --- /dev/null +++ b/src/state/lib/api.ts @@ -0,0 +1,210 @@ +/** + * The environment is a place where services and shared dependencies between + * models live. They are made available to every model via dependency injection. + */ + +// import {ReactNativeStore} from './auth' +import {AdxClient, AdxRepoClient} from '@adxp/mock-api' +import * as storage from './storage' + +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 +// ======= + +function* dateGen() { + let start = 1657846031914 + while (true) { + yield new Date(start).toISOString() + start += 1e3 + } +} +const date = dateGen() + +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(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(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', + displayName: 'Alice', + description: 'Test user 1', + }) + await bob.collection('blueskyweb.xyz:Profiles').put('Profile', 'profile', { + $type: 'blueskyweb.xyz:Profile', + displayName: 'Bob', + description: 'Test user 2', + }) + await carla.collection('blueskyweb.xyz:Profiles').put('Profile', 'profile', { + $type: 'blueskyweb.xyz:Profile', + displayName: 'Carla', + description: 'Test user 3', + }) + + // everybody follows everybody + const follow = async (who: AdxRepoClient, subjectName: string) => { + const subjectDb = adx.mockDb.getUser(subjectName) + return who.collection('blueskyweb.xyz:Follows').create('Follow', { + $type: 'blueskyweb.xyz:Follow', + subject: { + did: subjectDb?.did, + name: subjectDb?.name, + }, + createdAt: date.next().value, + }) + } + await follow(alice, 'bob.com') + await follow(alice, 'carla.com') + await follow(bob, 'alice.com') + await follow(bob, 'carla.com') + await follow(carla, 'alice.com') + await follow(carla, 'bob.com') + + // 2 posts on each user + const alicePosts: {uri: string}[] = [] + for (let i = 0; i < 2; i++) { + alicePosts.push( + await alice.collection('blueskyweb.xyz:Posts').create('Post', { + $type: 'blueskyweb.xyz:Post', + text: `Alice post ${i + 1}`, + createdAt: date.next().value, + }), + ) + await bob.collection('blueskyweb.xyz:Posts').create('Post', { + $type: 'blueskyweb.xyz:Post', + text: `Bob post ${i + 1}`, + createdAt: date.next().value, + }) + await carla.collection('blueskyweb.xyz:Posts').create('Post', { + $type: 'blueskyweb.xyz:Post', + text: `Carla post ${i + 1}`, + createdAt: date.next().value, + }) + } + + // small thread of replies on alice's first post + const bobReply1 = await bob + .collection('blueskyweb.xyz:Posts') + .create('Post', { + $type: 'blueskyweb.xyz:Post', + text: 'Bob reply', + reply: {root: alicePosts[0].uri, parent: alicePosts[0].uri}, + createdAt: date.next().value, + }) + await carla.collection('blueskyweb.xyz:Posts').create('Post', { + $type: 'blueskyweb.xyz:Post', + text: 'Carla reply', + reply: {root: alicePosts[0].uri, parent: alicePosts[0].uri}, + createdAt: date.next().value, + }) + const aliceReply1 = await alice + .collection('blueskyweb.xyz:Posts') + .create('Post', { + $type: 'blueskyweb.xyz:Post', + text: 'Alice reply', + reply: {root: alicePosts[0].uri, parent: bobReply1.uri}, + createdAt: date.next().value, + }) + + // bob and carla repost alice's first post + await bob.collection('blueskyweb.xyz:Posts').create('Repost', { + $type: 'blueskyweb.xyz:Repost', + subject: alicePosts[0].uri, + createdAt: date.next().value, + }) + await carla.collection('blueskyweb.xyz:Posts').create('Repost', { + $type: 'blueskyweb.xyz:Repost', + subject: alicePosts[0].uri, + createdAt: date.next().value, + }) + + // bob likes all of alice's posts + for (let i = 0; i < 2; i++) { + await bob.collection('blueskyweb.xyz:Likes').create('Like', { + $type: 'blueskyweb.xyz:Like', + subject: alicePosts[i].uri, + createdAt: date.next().value, + }) + } + + // carla likes all of alice's posts and everybody's replies + for (let i = 0; i < 2; i++) { + await carla.collection('blueskyweb.xyz:Likes').create('Like', { + $type: 'blueskyweb.xyz:Like', + subject: alicePosts[i].uri, + createdAt: date.next().value, + }) + } + await carla.collection('blueskyweb.xyz:Likes').create('Like', { + $type: 'blueskyweb.xyz:Like', + subject: aliceReply1.uri, + createdAt: date.next().value, + }) + await carla.collection('blueskyweb.xyz:Likes').create('Like', { + $type: 'blueskyweb.xyz:Like', + subject: bobReply1.uri, + createdAt: date.next().value, + }) + + // give alice 3 badges, 2 from bob and 2 from carla, with one ignored + const inviteBadge = await bob + .collection('blueskyweb.xyz:Badges') + .create('Badge', { + $type: 'blueskyweb.xyz:Badge', + subject: {did: alice.did, name: 'alice.com'}, + assertion: {type: 'invite'}, + createdAt: date.next().value, + }) + const techTagBadge1 = await bob + .collection('blueskyweb.xyz:Badges') + .create('Badge', { + $type: 'blueskyweb.xyz:Badge', + subject: {did: alice.did, name: 'alice.com'}, + assertion: {type: 'tag', tag: 'tech'}, + createdAt: date.next().value, + }) + const techTagBadge2 = await carla + .collection('blueskyweb.xyz:Badges') + .create('Badge', { + $type: 'blueskyweb.xyz:Badge', + subject: {did: alice.did, name: 'alice.com'}, + assertion: {type: 'tag', tag: 'tech'}, + createdAt: date.next().value, + }) + await bob.collection('blueskyweb.xyz:Badges').create('Badge', { + $type: 'blueskyweb.xyz:Badge', + subject: {did: alice.did, name: 'alice.com'}, + assertion: {type: 'employee'}, + createdAt: date.next().value, + }) + await alice.collection('blueskyweb.xyz:Profiles').put('Profile', 'profile', { + $type: 'blueskyweb.xyz:Profile', + displayName: 'Alice', + description: 'Test user 1', + badges: [ + {uri: inviteBadge.uri}, + {uri: techTagBadge1.uri}, + {uri: techTagBadge2.uri}, + ], + }) +} diff --git a/src/state/lib/type-guards.ts b/src/state/lib/type-guards.ts new file mode 100644 index 000000000..4ae31f3ac --- /dev/null +++ b/src/state/lib/type-guards.ts @@ -0,0 +1,10 @@ +export function isObj(v: unknown): v is Record { + return !!v && typeof v === 'object' +} + +export function hasProp( + data: object, + prop: K, +): data is Record { + return prop in data +} diff --git a/src/state/models/feed-view.ts b/src/state/models/feed-view.ts new file mode 100644 index 000000000..1fc507a70 --- /dev/null +++ b/src/state/models/feed-view.ts @@ -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 = {} + 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)) + } + } +} diff --git a/src/state/models/me.ts b/src/state/models/me.ts index bc4b13148..3fd5db9ac 100644 --- a/src/state/models/me.ts +++ b/src/state/models/me.ts @@ -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 {} -export interface MeSnapshot extends SnapshotOut {} - -export function createDefaultMe() { - return {} + }) + } else { + this.did = undefined + this.name = undefined + this.displayName = undefined + this.description = undefined + } + } } diff --git a/src/state/models/root-store.ts b/src/state/models/root-store.ts index b38b36e8a..a5d356066 100644 --- a/src/state/models/root-store.ts +++ b/src/state/models/root-store.ts @@ -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 {} -export interface RootStoreSnapshot extends SnapshotOut {} + constructor(public api: AdxClient) { + makeAutoObservable(this, { + api: false, + serialize: false, + hydrate: false, + }) + } + + serialize(): unknown { + return { + session: this.session.serialize(), + } + } -export function createDefaultRootStore() { - return { - session: createDefaultSession(), - me: createDefaultMe(), + hydrate(v: unknown) { + if (isObj(v)) { + if (hasProp(v, 'session')) { + this.session.hydrate(v.session) + } + } } } -// react context & hook utilities -const RootStoreContext = createContext({} as RootStore) +const throwawayInst = new RootStoreModel(adx) // this will be replaced by the loader +const RootStoreContext = createContext(throwawayInst) export const RootStoreProvider = RootStoreContext.Provider export const useStores = () => useContext(RootStoreContext) diff --git a/src/state/models/session.ts b/src/state/models/session.ts index 1a3fbad34..7c7602066 100644 --- a/src/state/models/session.ts +++ b/src/state/models/session.ts @@ -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 - } - }), - 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 - }),*/ - })) + constructor() { + makeAutoObservable(this, { + serialize: false, + hydrate: false, + }) + } -export interface Session extends Instance {} -export interface SessionSnapshot extends SnapshotOut {} + serialize(): unknown { + return { + isAuthed: this.isAuthed, + } + } + + hydrate(v: unknown) { + if (isObj(v)) { + if (hasProp(v, 'isAuthed') && typeof v.isAuthed === 'boolean') { + this.isAuthed = v.isAuthed + } + } + } -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 +}), +}))*/ diff --git a/src/view/com/Feed.tsx b/src/view/com/Feed.tsx new file mode 100644 index 000000000..2cba0610a --- /dev/null +++ b/src/view/com/Feed.tsx @@ -0,0 +1,17 @@ +import React from 'react' +import {observer} from 'mobx-react-lite' +import {Text, View} from 'react-native' +import {FeedViewModel} from '../../state/models/feed-view' +import {FeedItem} from './FeedItem' + +export const Feed = observer(function Feed({feed}: {feed: FeedViewModel}) { + return ( + + {feed.isLoading && Loading...} + {feed.hasError && {feed.error}} + {feed.hasContent && + feed.feed.map(item => )} + {feed.isEmpty && This feed is empty!} + + ) +}) diff --git a/src/view/com/FeedItem.tsx b/src/view/com/FeedItem.tsx new file mode 100644 index 000000000..8f838d5df --- /dev/null +++ b/src/view/com/FeedItem.tsx @@ -0,0 +1,104 @@ +import React from 'react' +import {observer} from 'mobx-react-lite' +import {Text, Image, ImageSourcePropType, StyleSheet, View} from 'react-native' +import {bsky} from '@adxp/mock-api' +import moment from 'moment' +import {FeedViewItemModel} from '../../state/models/feed-view' + +const IMAGES: Record = { + 'alice.com': require('../../assets/alice.jpg'), + 'bob.com': require('../../assets/bob.jpg'), + 'carla.com': require('../../assets/carla.jpg'), +} + +export const FeedItem = observer(function FeedItem({ + item, +}: { + item: FeedViewItemModel +}) { + const record = item.record as unknown as bsky.Post.Record + return ( + + {item.repostedBy && ( + + Reposted by {item.repostedBy.displayName} + + )} + + + + + + + + {item.author.displayName} + + + @{item.author.name} + + + · {moment(item.indexedAt).fromNow(true)} + + + {record.text} + + + + ) +}) + +const styles = StyleSheet.create({ + outer: { + borderTopWidth: 1, + borderTopColor: '#e8e8e8', + backgroundColor: '#fff', + padding: 10, + }, + repostedBy: { + paddingLeft: 70, + color: 'gray', + fontWeight: 'bold', + fontSize: 13, + }, + layout: { + flexDirection: 'row', + }, + layoutAvi: { + width: 70, + }, + avi: { + width: 60, + height: 60, + borderRadius: 30, + resizeMode: 'cover', + }, + layoutContent: { + flex: 1, + }, + meta: { + flexDirection: 'row', + paddingTop: 2, + paddingBottom: 4, + }, + metaItem: { + paddingRight: 5, + }, + metaDisplayName: { + fontSize: 15, + fontWeight: 'bold', + }, + metaName: { + fontSize: 14, + color: 'gray', + }, + metaDate: { + fontSize: 14, + color: 'gray', + }, + postText: { + fontSize: 15, + }, +}) diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx index 5210d9d40..53ec44eb9 100644 --- a/src/view/screens/Home.tsx +++ b/src/view/screens/Home.tsx @@ -1,20 +1,23 @@ -import React from 'react' -import {Text, Button, View} from 'react-native' +import React, {useEffect} from 'react' +import {Text, View} from 'react-native' import {Shell} from '../shell' -import type {RootTabsScreenProps} from '../routes/types' +import {Feed} from '../com/Feed' +// import type {RootTabsScreenProps} from '../routes/types' import {useStores} from '../../state' -export function Home({navigation}: RootTabsScreenProps<'Home'>) { +export function Home(/*{navigation}: RootTabsScreenProps<'Home'>*/) { const store = useStores() + useEffect(() => { + console.log('Fetching home feed') + store.homeFeed.fetch() + }, [store.homeFeed]) return ( - - Home -