about summary refs log tree commit diff
diff options
context:
space:
mode:
authorPaul Frazee <pfrazee@gmail.com>2022-07-20 15:00:37 -0500
committerPaul Frazee <pfrazee@gmail.com>2022-07-20 15:00:37 -0500
commitc712cbbfe27cca5db5d87abd8d7fd3b749492fcc (patch)
tree6ba411c9d9ab7a63b4578071752fdbd9c6a9cec3
parent19c694bc601c2b5d494d635134ffe9ca3fdc7774 (diff)
downloadvoidsky-c712cbbfe27cca5db5d87abd8d7fd3b749492fcc.tar.zst
Add WIP post-thread view
-rw-r--r--package.json2
-rw-r--r--src/state/models/feed-view.ts11
-rw-r--r--src/state/models/post-thread-view.ts153
-rw-r--r--src/state/models/root-store.ts7
-rw-r--r--src/view/com/feed/Feed.tsx (renamed from src/view/com/Feed.tsx)20
-rw-r--r--src/view/com/feed/FeedItem.tsx (renamed from src/view/com/FeedItem.tsx)27
-rw-r--r--src/view/com/post-thread/PostThread.tsx88
-rw-r--r--src/view/com/post-thread/PostThreadItem.tsx176
-rw-r--r--src/view/routes/index.tsx11
-rw-r--r--src/view/routes/types.ts3
-rw-r--r--src/view/screens/Home.tsx13
-rw-r--r--src/view/screens/content/PostThread.tsx27
-rw-r--r--src/view/screens/content/Profile.tsx (renamed from src/view/screens/Profile.tsx)4
-rw-r--r--yarn.lock17
14 files changed, 534 insertions, 25 deletions
diff --git a/package.json b/package.json
index 7c7fed2ed..0963c46ea 100644
--- a/package.json
+++ b/package.json
@@ -27,6 +27,7 @@
     "@react-navigation/stack": "^6.2.1",
     "@zxing/text-encoding": "^0.9.0",
     "base64-js": "^1.5.1",
+    "lodash.omit": "^4.5.0",
     "mobx": "^6.6.1",
     "mobx-react-lite": "^3.4.0",
     "moment": "^2.29.4",
@@ -49,6 +50,7 @@
     "@babel/runtime": "^7.12.5",
     "@react-native-community/eslint-config": "^2.0.0",
     "@types/jest": "^26.0.23",
+    "@types/lodash.omit": "^4.5.7",
     "@types/react-native": "^0.67.3",
     "@types/react-test-renderer": "^17.0.1",
     "@typescript-eslint/eslint-plugin": "^5.17.0",
diff --git a/src/state/models/feed-view.ts b/src/state/models/feed-view.ts
index 4b915a766..51379b5cc 100644
--- a/src/state/models/feed-view.ts
+++ b/src/state/models/feed-view.ts
@@ -1,9 +1,9 @@
-import {makeAutoObservable, runInAction} from 'mobx'
+import {makeAutoObservable} from 'mobx'
 import {bsky} from '@adxp/mock-api'
 import {RootStoreModel} from './root-store'
 
 export class FeedViewItemModel implements bsky.FeedView.FeedItem {
-  key: string = ''
+  _reactKey: string = ''
   uri: string = ''
   author: bsky.FeedView.User = {did: '', name: '', displayName: ''}
   repostedBy?: bsky.FeedView.User
@@ -17,9 +17,9 @@ export class FeedViewItemModel implements bsky.FeedView.FeedItem {
   likeCount: number = 0
   indexedAt: string = ''
 
-  constructor(key: string, v: bsky.FeedView.FeedItem) {
+  constructor(reactKey: string, v: bsky.FeedView.FeedItem) {
     makeAutoObservable(this)
-    this.key = key
+    this._reactKey = reactKey
     Object.assign(this, v)
   }
 }
@@ -115,7 +115,6 @@ export class FeedViewModel implements bsky.FeedView.Response {
   // =
 
   private async _initialLoad() {
-    console.log('_initialLoad()')
     this._xLoading()
     await new Promise(r => setTimeout(r, 1e3)) // DEBUG
     try {
@@ -131,7 +130,6 @@ export class FeedViewModel implements bsky.FeedView.Response {
   }
 
   private async _loadMore() {
-    console.log('_loadMore()')
     this._xLoading()
     await new Promise(r => setTimeout(r, 1e3)) // DEBUG
     try {
@@ -150,7 +148,6 @@ export class FeedViewModel implements bsky.FeedView.Response {
   }
 
   private async _refresh() {
-    console.log('_refresh()')
     this._xLoading(true)
     // TODO: refetch and update items
     await new Promise(r => setTimeout(r, 1e3)) // DEBUG
diff --git a/src/state/models/post-thread-view.ts b/src/state/models/post-thread-view.ts
new file mode 100644
index 000000000..e2e0f7462
--- /dev/null
+++ b/src/state/models/post-thread-view.ts
@@ -0,0 +1,153 @@
+import {makeAutoObservable, runInAction} from 'mobx'
+import {bsky, AdxUri} from '@adxp/mock-api'
+import _omit from 'lodash.omit'
+import {RootStoreModel} from './root-store'
+
+export class PostThreadViewPostModel implements bsky.PostThreadView.Post {
+  _reactKey: string = ''
+  uri: string = ''
+  author: bsky.PostThreadView.User = {did: '', name: '', displayName: ''}
+  record: Record<string, unknown> = {}
+  embed?:
+    | bsky.PostThreadView.RecordEmbed
+    | bsky.PostThreadView.ExternalEmbed
+    | bsky.PostThreadView.UnknownEmbed
+  replyCount: number = 0
+  replies?: PostThreadViewPostModel[]
+  repostCount: number = 0
+  likeCount: number = 0
+  indexedAt: string = ''
+
+  constructor(reactKey: string, v?: bsky.PostThreadView.Post) {
+    makeAutoObservable(this)
+    this._reactKey = reactKey
+    if (v) {
+      Object.assign(this, _omit(v, 'replies')) // copy everything but the replies
+    }
+  }
+
+  setReplies(v: bsky.PostThreadView.Post) {
+    if (v.replies) {
+      const replies = []
+      let counter = 0
+      for (const item of v.replies) {
+        // TODO: validate .record
+        const itemModel = new PostThreadViewPostModel(`item-${counter++}`, item)
+        if (item.replies) {
+          itemModel.setReplies(item)
+        }
+        replies.push(itemModel)
+      }
+      this.replies = replies
+    }
+  }
+}
+const UNLOADED_THREAD = new PostThreadViewPostModel('')
+
+export class PostThreadViewModel implements bsky.PostThreadView.Response {
+  isLoading = false
+  isRefreshing = false
+  hasLoaded = false
+  error = ''
+  resolvedUri = ''
+  params: bsky.PostThreadView.Params
+  thread: PostThreadViewPostModel = UNLOADED_THREAD
+
+  constructor(
+    public rootStore: RootStoreModel,
+    params: bsky.PostThreadView.Params,
+  ) {
+    makeAutoObservable(
+      this,
+      {
+        rootStore: false,
+        params: false,
+      },
+      {autoBind: true},
+    )
+    this.params = params
+  }
+
+  get hasContent() {
+    return this.thread !== UNLOADED_THREAD
+  }
+
+  get hasError() {
+    return this.error !== ''
+  }
+
+  // public api
+  // =
+
+  async setup() {
+    if (!this.resolvedUri) {
+      await this._resolveUri()
+    }
+    if (this.hasContent) {
+      await this._refresh()
+    } else {
+      await this._initialLoad()
+    }
+  }
+
+  async refresh() {
+    await this._refresh()
+  }
+
+  // state transitions
+  // =
+
+  private _xLoading(isRefreshing = false) {
+    this.isLoading = true
+    this.isRefreshing = isRefreshing
+    this.error = ''
+  }
+
+  private _xIdle(err: string = '') {
+    this.isLoading = false
+    this.isRefreshing = false
+    this.hasLoaded = true
+    this.error = err
+  }
+
+  // loader functions
+  // =
+
+  private async _resolveUri() {
+    const urip = new AdxUri(this.params.uri)
+    if (!urip.host.startsWith('did:')) {
+      urip.host = await this.rootStore.resolveName(urip.host)
+    }
+    runInAction(() => {
+      this.resolvedUri = urip.toString()
+    })
+  }
+
+  private async _initialLoad() {
+    this._xLoading()
+    try {
+      const res = (await this.rootStore.api.mainPds.view(
+        'blueskyweb.xyz:PostThreadView',
+        Object.assign({}, this.params, {uri: this.resolvedUri}),
+      )) as bsky.PostThreadView.Response
+      this._replaceAll(res)
+      this._xIdle()
+    } catch (e: any) {
+      this._xIdle(`Failed to load thread: ${e.toString()}`)
+    }
+  }
+
+  private async _refresh() {
+    this._xLoading(true)
+    // TODO: refetch and update items
+    await new Promise(r => setTimeout(r, 1e3)) // DEBUG
+    this._xIdle()
+  }
+
+  private _replaceAll(res: bsky.PostThreadView.Response) {
+    // TODO: validate .record
+    const thread = new PostThreadViewPostModel('item-0', res.thread)
+    thread.setReplies(res.thread)
+    this.thread = thread
+  }
+}
diff --git a/src/state/models/root-store.ts b/src/state/models/root-store.ts
index a5d356066..7391a82bd 100644
--- a/src/state/models/root-store.ts
+++ b/src/state/models/root-store.ts
@@ -18,11 +18,18 @@ export class RootStoreModel {
   constructor(public api: AdxClient) {
     makeAutoObservable(this, {
       api: false,
+      resolveName: false,
       serialize: false,
       hydrate: false,
     })
   }
 
+  async resolveName(didOrName: string) {
+    const userDb = this.api.mockDb.getUser(didOrName)
+    if (!userDb) throw new Error(`User not found: ${didOrName}`)
+    return userDb.did
+  }
+
   serialize(): unknown {
     return {
       session: this.session.serialize(),
diff --git a/src/view/com/Feed.tsx b/src/view/com/feed/Feed.tsx
index 507f0edde..fe9d350d1 100644
--- a/src/view/com/Feed.tsx
+++ b/src/view/com/feed/Feed.tsx
@@ -1,12 +1,23 @@
 import React from 'react'
-import {observer, Observer} from 'mobx-react-lite'
+import {observer} from 'mobx-react-lite'
 import {Text, View, FlatList} from 'react-native'
-import {FeedViewModel, FeedViewItemModel} from '../../state/models/feed-view'
+import {OnNavigateContent} from '../../routes/types'
+import {FeedViewModel, FeedViewItemModel} from '../../../state/models/feed-view'
 import {FeedItem} from './FeedItem'
 
-export const Feed = observer(function Feed({feed}: {feed: FeedViewModel}) {
+export const Feed = observer(function Feed({
+  feed,
+  onNavigateContent,
+}: {
+  feed: FeedViewModel
+  onNavigateContent: OnNavigateContent
+}) {
+  // TODO optimize renderItem or FeedItem, we're getting this notice from RN: -prf
+  //   VirtualizedList: You have a large list that is slow to update - make sure your
+  //   renderItem function renders components that follow React performance best practices
+  //   like PureComponent, shouldComponentUpdate, etc
   const renderItem = ({item}: {item: FeedViewItemModel}) => (
-    <Observer>{() => <FeedItem item={item} />}</Observer>
+    <FeedItem item={item} onNavigateContent={onNavigateContent} />
   )
   const onRefresh = () => {
     feed.refresh().catch(err => console.error('Failed to refresh', err))
@@ -21,6 +32,7 @@ export const Feed = observer(function Feed({feed}: {feed: FeedViewModel}) {
       {feed.hasContent && (
         <FlatList
           data={feed.feed}
+          keyExtractor={item => item._reactKey}
           renderItem={renderItem}
           refreshing={feed.isRefreshing}
           onRefresh={onRefresh}
diff --git a/src/view/com/FeedItem.tsx b/src/view/com/feed/FeedItem.tsx
index 262104bf3..7a57326f6 100644
--- a/src/view/com/FeedItem.tsx
+++ b/src/view/com/feed/FeedItem.tsx
@@ -1,10 +1,18 @@
 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 {
+  Image,
+  ImageSourcePropType,
+  StyleSheet,
+  Text,
+  TouchableOpacity,
+  View,
+} from 'react-native'
+import {bsky, AdxUri} from '@adxp/mock-api'
 import moment from 'moment'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
-import {FeedViewItemModel} from '../../state/models/feed-view'
+import {OnNavigateContent} from '../../routes/types'
+import {FeedViewItemModel} from '../../../state/models/feed-view'
 
 const IMAGES: Record<string, ImageSourcePropType> = {
   'alice.com': require('../../assets/alice.jpg'),
@@ -14,12 +22,21 @@ const IMAGES: Record<string, ImageSourcePropType> = {
 
 export const FeedItem = observer(function FeedItem({
   item,
+  onNavigateContent,
 }: {
   item: FeedViewItemModel
+  onNavigateContent: OnNavigateContent
 }) {
   const record = item.record as unknown as bsky.Post.Record
+  const onPressOuter = () => {
+    const urip = new AdxUri(item.uri)
+    onNavigateContent('PostThread', {
+      name: item.author.name,
+      recordKey: urip.recordKey,
+    })
+  }
   return (
-    <View style={styles.outer}>
+    <TouchableOpacity style={styles.outer} onPress={onPressOuter}>
       {item.repostedBy && (
         <View style={styles.repostedBy}>
           <FontAwesomeIcon icon="retweet" style={styles.repostedByIcon} />
@@ -80,7 +97,7 @@ export const FeedItem = observer(function FeedItem({
           </View>
         </View>
       </View>
-    </View>
+    </TouchableOpacity>
   )
 })
 
diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx
new file mode 100644
index 000000000..bc6642e07
--- /dev/null
+++ b/src/view/com/post-thread/PostThread.tsx
@@ -0,0 +1,88 @@
+import React, {useState, useEffect} from 'react'
+import {observer} from 'mobx-react-lite'
+import {ActivityIndicator, FlatList, Text, View} from 'react-native'
+import {OnNavigateContent} from '../../routes/types'
+import {
+  PostThreadViewModel,
+  PostThreadViewPostModel,
+} from '../../../state/models/post-thread-view'
+import {useStores} from '../../../state'
+import {PostThreadItem} from './PostThreadItem'
+
+export const PostThread = observer(function PostThread({
+  uri,
+  onNavigateContent,
+}: {
+  uri: string
+  onNavigateContent: OnNavigateContent
+}) {
+  const store = useStores()
+  const [view, setView] = useState<PostThreadViewModel | undefined>()
+
+  useEffect(() => {
+    if (view?.params.uri === uri) {
+      console.log('Post thread doing nothing')
+      return // no change needed? or trigger refresh?
+    }
+    console.log('Fetching post thread', uri)
+    const newView = new PostThreadViewModel(store, {uri})
+    setView(newView)
+    newView.setup().catch(err => console.error('Failed to fetch thread', err))
+  }, [uri, view?.params.uri, store])
+
+  // not yet setup
+  if (
+    !view ||
+    (view.isLoading && !view.isRefreshing) ||
+    view.params.uri !== uri
+  ) {
+    return (
+      <View>
+        <ActivityIndicator />
+      </View>
+    )
+  }
+
+  // error
+  if (view.hasError) {
+    return (
+      <View>
+        <Text>{view.error}</Text>
+      </View>
+    )
+  }
+
+  // rendering
+  const posts = Array.from(flattenThread(view.thread))
+  const renderItem = ({item}: {item: PostThreadViewPostModel}) => (
+    <PostThreadItem item={item} onNavigateContent={onNavigateContent} />
+  )
+  const onRefresh = () => {
+    view.refresh().catch(err => console.error('Failed to refresh', err))
+  }
+  return (
+    <View>
+      {view.isRefreshing && <ActivityIndicator />}
+      {view.hasContent && (
+        <FlatList
+          data={posts}
+          keyExtractor={item => item._reactKey}
+          renderItem={renderItem}
+          refreshing={view.isRefreshing}
+          onRefresh={onRefresh}
+        />
+      )}
+    </View>
+  )
+})
+
+function* flattenThread(
+  post: PostThreadViewPostModel,
+): Generator<PostThreadViewPostModel, void> {
+  yield post
+  if (post.replies?.length) {
+    for (const reply of post.replies) {
+      yield* flattenThread(reply)
+    }
+  }
+}
diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx
new file mode 100644
index 000000000..33857f48a
--- /dev/null
+++ b/src/view/com/post-thread/PostThreadItem.tsx
@@ -0,0 +1,176 @@
+import React from 'react'
+import {observer} from 'mobx-react-lite'
+import {
+  Image,
+  ImageSourcePropType,
+  StyleSheet,
+  Text,
+  TouchableOpacity,
+  View,
+} from 'react-native'
+import {bsky} from '@adxp/mock-api'
+import moment from 'moment'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {OnNavigateContent} from '../../routes/types'
+import {PostThreadViewPostModel} from '../../../state/models/post-thread-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 PostThreadItem = observer(function PostThreadItem({
+  item, // onNavigateContent,
+}: {
+  item: PostThreadViewPostModel
+  onNavigateContent: OnNavigateContent
+}) {
+  const record = item.record as unknown as bsky.Post.Record
+  const onPressOuter = () => {
+    // TODO onNavigateContent
+  }
+  return (
+    <TouchableOpacity style={styles.outer} onPress={onPressOuter}>
+      <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 style={styles.ctrls}>
+            <View style={styles.ctrl}>
+              <FontAwesomeIcon
+                style={styles.ctrlReplyIcon}
+                icon={['far', 'comment']}
+              />
+              <Text>{item.replyCount}</Text>
+            </View>
+            <View style={styles.ctrl}>
+              <FontAwesomeIcon
+                style={styles.ctrlRepostIcon}
+                icon="retweet"
+                size={22}
+              />
+              <Text>{item.repostCount}</Text>
+            </View>
+            <View style={styles.ctrl}>
+              <FontAwesomeIcon
+                style={styles.ctrlLikeIcon}
+                icon={['far', 'heart']}
+              />
+              <Text>{item.likeCount}</Text>
+            </View>
+            <View style={styles.ctrl}>
+              <FontAwesomeIcon
+                style={styles.ctrlShareIcon}
+                icon="share-from-square"
+              />
+            </View>
+          </View>
+        </View>
+      </View>
+    </TouchableOpacity>
+  )
+})
+
+const styles = StyleSheet.create({
+  outer: {
+    borderTopWidth: 1,
+    borderTopColor: '#e8e8e8',
+    backgroundColor: '#fff',
+    padding: 10,
+  },
+  repostedBy: {
+    flexDirection: 'row',
+    paddingLeft: 70,
+  },
+  repostedByIcon: {
+    marginRight: 2,
+    color: 'gray',
+  },
+  repostedByText: {
+    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,
+    paddingBottom: 5,
+  },
+  ctrls: {
+    flexDirection: 'row',
+  },
+  ctrl: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    flex: 1,
+    paddingLeft: 4,
+    paddingRight: 4,
+  },
+  ctrlReplyIcon: {
+    marginRight: 5,
+    color: 'gray',
+  },
+  ctrlRepostIcon: {
+    marginRight: 5,
+    color: 'gray',
+  },
+  ctrlLikeIcon: {
+    marginRight: 5,
+    color: 'gray',
+  },
+  ctrlShareIcon: {
+    marginRight: 5,
+    color: 'gray',
+  },
+})
diff --git a/src/view/routes/index.tsx b/src/view/routes/index.tsx
index 6351dea6a..2d47a0470 100644
--- a/src/view/routes/index.tsx
+++ b/src/view/routes/index.tsx
@@ -16,7 +16,8 @@ import {Home} from '../screens/Home'
 import {Search} from '../screens/Search'
 import {Notifications} from '../screens/Notifications'
 import {Menu} from '../screens/Menu'
-import {Profile} from '../screens/Profile'
+import {Profile} from '../screens/content/Profile'
+import {PostThread} from '../screens/content/PostThread'
 import {Login} from '../screens/Login'
 import {Signup} from '../screens/Signup'
 import {NotFound} from '../screens/NotFound'
@@ -32,6 +33,7 @@ const linking: LinkingOptions<RootTabsParamList> = {
     screens: {
       Home: '',
       Profile: 'profile/:name',
+      PostThread: 'profile/:name/post/:recordKey',
       Search: 'search',
       Notifications: 'notifications',
       Menu: 'menu',
@@ -42,7 +44,7 @@ const linking: LinkingOptions<RootTabsParamList> = {
   },
 }
 
-export const RootTabs = createBottomTabNavigator()
+export const RootTabs = createBottomTabNavigator<RootTabsParamList>()
 export const PrimaryStack = createNativeStackNavigator()
 
 const tabBarScreenOptions = ({
@@ -92,6 +94,11 @@ export const Root = observer(() => {
               component={Profile}
               options={HIDE_TAB}
             />
+            <RootTabs.Screen
+              name="PostThread"
+              component={PostThread}
+              options={HIDE_TAB}
+            />
           </>
         ) : (
           <>
diff --git a/src/view/routes/types.ts b/src/view/routes/types.ts
index d92594bbe..bca6f196a 100644
--- a/src/view/routes/types.ts
+++ b/src/view/routes/types.ts
@@ -6,6 +6,7 @@ export type RootTabsParamList = {
   Notifications: undefined
   Menu: undefined
   Profile: {name: string}
+  PostThread: {name: string; recordKey: string}
   Login: undefined
   Signup: undefined
   NotFound: undefined
@@ -13,6 +14,8 @@ export type RootTabsParamList = {
 export type RootTabsScreenProps<T extends keyof RootTabsParamList> =
   StackScreenProps<RootTabsParamList, T>
 
+export type OnNavigateContent = (screen: string, params: Record<string, string>): void
+
 /*
 NOTE
 this is leftover from a nested nav implementation
diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx
index 6685eb794..4a3e41a75 100644
--- a/src/view/screens/Home.tsx
+++ b/src/view/screens/Home.tsx
@@ -1,20 +1,23 @@
 import React, {useEffect} from 'react'
-import {Text, View} from 'react-native'
+import {View} from 'react-native'
 import {Shell} from '../shell'
-import {Feed} from '../com/Feed'
-// import type {RootTabsScreenProps} from '../routes/types'
+import {Feed} from '../com/feed/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.setup()
   }, [store.homeFeed])
+  const onNavigateContent = (screen: string, props: Record<string, string>) => {
+    navigation.navigate(screen, props)
+  }
   return (
     <Shell>
       <View>
-        <Feed feed={store.homeFeed} />
+        <Feed feed={store.homeFeed} onNavigateContent={onNavigateContent} />
       </View>
     </Shell>
   )
diff --git a/src/view/screens/content/PostThread.tsx b/src/view/screens/content/PostThread.tsx
new file mode 100644
index 000000000..5b8fa951c
--- /dev/null
+++ b/src/view/screens/content/PostThread.tsx
@@ -0,0 +1,27 @@
+import React from 'react'
+import {AdxUri} from '@adxp/mock-api'
+import {Shell} from '../../shell'
+import type {RootTabsScreenProps} from '../../routes/types'
+import {PostThread as PostThreadComponent} from '../../com/post-thread/PostThread'
+
+export const PostThread = ({
+  navigation,
+  route,
+}: RootTabsScreenProps<'PostThread'>) => {
+  const {name, recordKey} = route.params
+
+  const urip = new AdxUri(`adx://todo/`)
+  urip.host = name
+  urip.collection = 'blueskyweb.xyz:Posts'
+  urip.recordKey = recordKey
+  const uri = urip.toString()
+
+  const onNavigateContent = (screen: string, props: Record<string, string>) => {
+    navigation.navigate(screen, props)
+  }
+  return (
+    <Shell>
+      <PostThreadComponent uri={uri} onNavigateContent={onNavigateContent} />
+    </Shell>
+  )
+}
diff --git a/src/view/screens/Profile.tsx b/src/view/screens/content/Profile.tsx
index 2c93f4bf9..cfbf840f3 100644
--- a/src/view/screens/Profile.tsx
+++ b/src/view/screens/content/Profile.tsx
@@ -1,7 +1,7 @@
 import React from 'react'
-import {Shell} from '../shell'
+import {Shell} from '../../shell'
 import {View, Text} from 'react-native'
-import type {RootTabsScreenProps} from '../routes/types'
+import type {RootTabsScreenProps} from '../../routes/types'
 
 export const Profile = ({route}: RootTabsScreenProps<'Profile'>) => {
   return (
diff --git a/yarn.lock b/yarn.lock
index 0382f8ec6..eaefbcc7a 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2662,6 +2662,18 @@
   resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee"
   integrity sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==
 
+"@types/lodash.omit@^4.5.7":
+  version "4.5.7"
+  resolved "https://registry.yarnpkg.com/@types/lodash.omit/-/lodash.omit-4.5.7.tgz#2357ed2412b4164344e8ee41f85bb0b2920304ba"
+  integrity sha512-6q6cNg0tQ6oTWjSM+BcYMBhan54P/gLqBldG4AuXd3nKr0oeVekWNS4VrNEu3BhCSDXtGapi7zjhnna0s03KpA==
+  dependencies:
+    "@types/lodash" "*"
+
+"@types/lodash@*":
+  version "4.14.182"
+  resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.182.tgz#05301a4d5e62963227eaafe0ce04dd77c54ea5c2"
+  integrity sha512-/THyiqyQAP9AfARo4pF+aCGcyiQ94tX/Is2I7HofNRqoYLgN1PBoOWu2/zTA5zMxzP5EFutMtWtGAFRKUe961Q==
+
 "@types/mime@^1":
   version "1.3.2"
   resolved "https://registry.yarnpkg.com/@types/mime/-/mime-1.3.2.tgz#93e25bf9ee75fe0fd80b594bc4feb0e862111b5a"
@@ -8912,6 +8924,11 @@ lodash.merge@^4.6.2:
   resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a"
   integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==
 
+lodash.omit@^4.5.0:
+  version "4.5.0"
+  resolved "https://registry.yarnpkg.com/lodash.omit/-/lodash.omit-4.5.0.tgz#6eb19ae5a1ee1dd9df0b969e66ce0b7fa30b5e60"
+  integrity sha512-XeqSp49hNGmlkj2EJlfrQFIzQ6lXdNro9sddtQzcJY8QaoC2GO0DT7xaIokHeyM+mIT0mPMlPvkYzg2xCuHdZg==
+
 lodash.sortby@^4.7.0:
   version "4.7.0"
   resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438"