about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/App.native.tsx28
-rw-r--r--src/App.web.tsx28
-rw-r--r--src/assets/alice.jpgbin0 -> 25330 bytes
-rw-r--r--src/assets/bob.jpgbin0 -> 45720 bytes
-rw-r--r--src/assets/carla.jpgbin0 -> 12259 bytes
-rw-r--r--src/state/index.ts33
-rw-r--r--src/state/lib/api.ts (renamed from src/state/env.ts)61
-rw-r--r--src/state/lib/type-guards.ts10
-rw-r--r--src/state/models/feed-view.ts98
-rw-r--r--src/state/models/me.ts79
-rw-r--r--src/state/models/root-store.ts46
-rw-r--r--src/state/models/session.ts199
-rw-r--r--src/view/com/Feed.tsx17
-rw-r--r--src/view/com/FeedItem.tsx104
-rw-r--r--src/view/screens/Home.tsx25
-rw-r--r--src/view/screens/Login.tsx32
-rw-r--r--src/view/screens/Signup.tsx14
17 files changed, 520 insertions, 254 deletions
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<RootStore | undefined>(undefined)
+  const [rootStore, setRootStore] = useState<RootStoreModel | undefined>(
+    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<RootStore | undefined>(undefined)
+  const [rootStore, setRootStore] = useState<RootStoreModel | undefined>(
+    undefined,
+  )
 
   // init
   useEffect(() => {
diff --git a/src/assets/alice.jpg b/src/assets/alice.jpg
new file mode 100644
index 000000000..83555d962
--- /dev/null
+++ b/src/assets/alice.jpg
Binary files differdiff --git a/src/assets/bob.jpg b/src/assets/bob.jpg
new file mode 100644
index 000000000..0567772b6
--- /dev/null
+++ b/src/assets/bob.jpg
Binary files differdiff --git a/src/assets/carla.jpg b/src/assets/carla.jpg
new file mode 100644
index 000000000..f26734280
--- /dev/null
+++ b/src/assets/carla.jpg
Binary files differdiff --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/env.ts b/src/state/lib/api.ts
index a9e9bf244..19c0c2d8b 100644
--- a/src/state/env.ts
+++ b/src/state/lib/api.ts
@@ -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'
-
-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)
+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
@@ -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',
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<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
+}
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<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))
+    }
+  }
+}
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<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
+    }
+  }
 }
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<typeof RootStoreModel> {}
-export interface RootStoreSnapshot extends SnapshotOut<typeof RootStoreModel> {}
+  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<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)
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<typeof SessionModel> {}
-export interface SessionSnapshot extends SnapshotOut<typeof SessionModel> {}
+  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 (
+    <View>
+      {feed.isLoading && <Text>Loading...</Text>}
+      {feed.hasError && <Text>{feed.error}</Text>}
+      {feed.hasContent &&
+        feed.feed.map(item => <FeedItem key={item.key} item={item} />)}
+      {feed.isEmpty && <Text>This feed is empty!</Text>}
+    </View>
+  )
+})
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<string, ImageSourcePropType> = {
+  '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 (
+    <View style={styles.outer}>
+      {item.repostedBy && (
+        <Text style={styles.repostedBy}>
+          Reposted by {item.repostedBy.displayName}
+        </Text>
+      )}
+      <View style={styles.layout}>
+        <View style={styles.layoutAvi}>
+          <Image
+            style={styles.avi}
+            source={IMAGES[item.author.name] || IMAGES['alice.com']}
+          />
+        </View>
+        <View style={styles.layoutContent}>
+          <View style={styles.meta}>
+            <Text style={[styles.metaItem, styles.metaDisplayName]}>
+              {item.author.displayName}
+            </Text>
+            <Text style={[styles.metaItem, styles.metaName]}>
+              @{item.author.name}
+            </Text>
+            <Text style={[styles.metaItem, styles.metaDate]}>
+              &middot; {moment(item.indexedAt).fromNow(true)}
+            </Text>
+          </View>
+          <Text style={styles.postText}>{record.text}</Text>
+        </View>
+      </View>
+    </View>
+  )
+})
+
+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 (
     <Shell>
-      <View style={{alignItems: 'center'}}>
-        <Text style={{fontSize: 20, fontWeight: 'bold'}}>Home</Text>
-        <Button
-          title="Go to Jane's profile"
-          onPress={() => navigation.navigate('Profile', {name: 'Jane'})}
-        />
-        <Button title="Logout" onPress={() => store.session.logout()} />
+      <View>
+        <Text style={{fontSize: 20, fontWeight: 'bold'}}>
+          Hello, {store.me.displayName} ({store.me.name})
+        </Text>
+        <Feed feed={store.homeFeed} />
       </View>
     </Shell>
   )
diff --git a/src/view/screens/Login.tsx b/src/view/screens/Login.tsx
index 207557369..d08a5a256 100644
--- a/src/view/screens/Login.tsx
+++ b/src/view/screens/Login.tsx
@@ -1,17 +1,18 @@
 import React from 'react'
-import {Text, Button, View, ActivityIndicator} from 'react-native'
+import {Text, View} from 'react-native'
 import {observer} from 'mobx-react-lite'
 import {Shell} from '../shell'
-import type {RootTabsScreenProps} from '../routes/types'
-import {useStores} from '../../state'
+// import type {RootTabsScreenProps} from '../routes/types'
+// import {useStores} from '../../state'
 
-export const Login = observer(({navigation}: RootTabsScreenProps<'Login'>) => {
-  const store = useStores()
-  return (
-    <Shell>
-      <View style={{justifyContent: 'center', alignItems: 'center'}}>
-        <Text style={{fontSize: 20, fontWeight: 'bold'}}>Sign In</Text>
-        {store.session.uiError ?? <Text>{store.session.uiError}</Text>}
+export const Login = observer(
+  (/*{navigation}: RootTabsScreenProps<'Login'>*/) => {
+    // const store = useStores()
+    return (
+      <Shell>
+        <View style={{justifyContent: 'center', alignItems: 'center'}}>
+          <Text style={{fontSize: 20, fontWeight: 'bold'}}>Sign In</Text>
+          {/*store.session.uiError && <Text>{store.session.uiError}</Text>}
         {!store.session.uiIsProcessing ? (
           <>
             <Button title="Login" onPress={() => store.session.login()} />
@@ -22,8 +23,9 @@ export const Login = observer(({navigation}: RootTabsScreenProps<'Login'>) => {
           </>
         ) : (
           <ActivityIndicator />
-        )}
-      </View>
-    </Shell>
-  )
-})
+        )*/}
+        </View>
+      </Shell>
+    )
+  },
+)
diff --git a/src/view/screens/Signup.tsx b/src/view/screens/Signup.tsx
index 8ca47e3ef..4a8c5df2d 100644
--- a/src/view/screens/Signup.tsx
+++ b/src/view/screens/Signup.tsx
@@ -1,18 +1,18 @@
 import React from 'react'
-import {Text, Button, View, ActivityIndicator} from 'react-native'
+import {Text, View} from 'react-native'
 import {observer} from 'mobx-react-lite'
 import {Shell} from '../shell'
-import type {RootTabsScreenProps} from '../routes/types'
-import {useStores} from '../../state'
+// import type {RootTabsScreenProps} from '../routes/types'
+// import {useStores} from '../../state'
 
 export const Signup = observer(
-  ({navigation}: RootTabsScreenProps<'Signup'>) => {
-    const store = useStores()
+  (/*{navigation}: RootTabsScreenProps<'Signup'>*/) => {
+    // const store = useStores()
     return (
       <Shell>
         <View style={{justifyContent: 'center', alignItems: 'center'}}>
           <Text style={{fontSize: 20, fontWeight: 'bold'}}>Create Account</Text>
-          {store.session.uiError ?? <Text>{store.session.uiError}</Text>}
+          {/*store.session.uiError ?? <Text>{store.session.uiError}</Text>}
           {!store.session.uiIsProcessing ? (
             <>
               <Button
@@ -26,7 +26,7 @@ export const Signup = observer(
             </>
           ) : (
             <ActivityIndicator />
-          )}
+          )*/}
         </View>
       </Shell>
     )