about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
authorPaul Frazee <pfrazee@gmail.com>2022-07-26 15:45:46 -0500
committerPaul Frazee <pfrazee@gmail.com>2022-07-26 15:45:46 -0500
commitd1470bad6628022eda66c658d228cc7646abc746 (patch)
tree82acecf9397b28e1d07d2a0e9c2460db545eb40d /src
parent62eb9f3c937028b6a6a8a3af40a03978fda5fef4 (diff)
downloadvoidsky-d1470bad6628022eda66c658d228cc7646abc746.tar.zst
Add notifications view
Diffstat (limited to 'src')
-rw-r--r--src/state/models/feed-view.ts1
-rw-r--r--src/state/models/notifications-view.ts304
-rw-r--r--src/state/models/post.ts93
-rw-r--r--src/state/models/root-store.ts2
-rw-r--r--src/view/com/notifications/Feed.tsx50
-rw-r--r--src/view/com/notifications/FeedItem.tsx136
-rw-r--r--src/view/com/post/Post.tsx225
-rw-r--r--src/view/com/post/PostText.tsx53
-rw-r--r--src/view/screens/tabroots/Notifications.tsx69
9 files changed, 926 insertions, 7 deletions
diff --git a/src/state/models/feed-view.ts b/src/state/models/feed-view.ts
index 5264aa27e..e9405773c 100644
--- a/src/state/models/feed-view.ts
+++ b/src/state/models/feed-view.ts
@@ -110,6 +110,7 @@ export class FeedViewModel implements bsky.FeedView.Response {
       {
         rootStore: false,
         params: false,
+        _loadPromise: false,
         _loadMorePromise: false,
         _updatePromise: false,
       },
diff --git a/src/state/models/notifications-view.ts b/src/state/models/notifications-view.ts
new file mode 100644
index 000000000..ef8f14996
--- /dev/null
+++ b/src/state/models/notifications-view.ts
@@ -0,0 +1,304 @@
+import {makeAutoObservable, runInAction} from 'mobx'
+import {bsky} from '@adxp/mock-api'
+import {RootStoreModel} from './root-store'
+import {hasProp} from '../lib/type-guards'
+
+export class NotificationsViewItemModel
+  implements bsky.NotificationsView.Notification
+{
+  // ui state
+  _reactKey: string = ''
+
+  // data
+  uri: string = ''
+  author: {
+    did: string
+    name: string
+    displayName: string
+  } = {did: '', name: '', displayName: ''}
+  record: any = {}
+  isRead: boolean = false
+  indexedAt: string = ''
+
+  constructor(
+    public rootStore: RootStoreModel,
+    reactKey: string,
+    v: bsky.NotificationsView.Notification,
+  ) {
+    makeAutoObservable(this, {rootStore: false})
+    this._reactKey = reactKey
+    this.copy(v)
+  }
+
+  copy(v: bsky.NotificationsView.Notification) {
+    this.uri = v.uri
+    this.author = v.author
+    this.record = v.record
+    this.isRead = v.isRead
+    this.indexedAt = v.indexedAt
+  }
+
+  get isLike() {
+    return (
+      hasProp(this.record, '$type') &&
+      this.record.$type === 'blueskyweb.xyz:Like'
+    )
+  }
+
+  get isRepost() {
+    return (
+      hasProp(this.record, '$type') &&
+      this.record.$type === 'blueskyweb.xyz:Repost'
+    )
+  }
+
+  get isReply() {
+    return (
+      hasProp(this.record, '$type') &&
+      this.record.$type === 'blueskyweb.xyz:Post'
+    )
+  }
+
+  get isFollow() {
+    return (
+      hasProp(this.record, '$type') &&
+      this.record.$type === 'blueskyweb.xyz:Follow'
+    )
+  }
+
+  get subjectUri() {
+    if (
+      hasProp(this.record, 'subject') &&
+      typeof this.record.subject === 'string'
+    ) {
+      return this.record.subject
+    }
+    return ''
+  }
+}
+
+export class NotificationsViewModel implements bsky.NotificationsView.Response {
+  // state
+  isLoading = false
+  isRefreshing = false
+  hasLoaded = false
+  error = ''
+  params: bsky.NotificationsView.Params
+  _loadPromise: Promise<void> | undefined
+  _loadMorePromise: Promise<void> | undefined
+  _updatePromise: Promise<void> | undefined
+
+  // data
+  notifications: NotificationsViewItemModel[] = []
+
+  constructor(
+    public rootStore: RootStoreModel,
+    params: bsky.NotificationsView.Params,
+  ) {
+    makeAutoObservable(
+      this,
+      {
+        rootStore: false,
+        params: false,
+        _loadPromise: false,
+        _loadMorePromise: false,
+        _updatePromise: false,
+      },
+      {autoBind: true},
+    )
+    this.params = params
+  }
+
+  get hasContent() {
+    return this.notifications.length !== 0
+  }
+
+  get hasError() {
+    return this.error !== ''
+  }
+
+  get isEmpty() {
+    return this.hasLoaded && !this.hasContent
+  }
+
+  get loadMoreCursor() {
+    if (this.hasContent) {
+      return this.notifications[this.notifications.length - 1].indexedAt
+    }
+    return undefined
+  }
+
+  // public api
+  // =
+
+  /**
+   * Load for first render
+   */
+  async setup(isRefreshing = false) {
+    if (this._loadPromise) {
+      return this._loadPromise
+    }
+    await this._pendingWork()
+    this._loadPromise = this._initialLoad(isRefreshing)
+    await this._loadPromise
+    this._loadPromise = undefined
+  }
+
+  /**
+   * Reset and load
+   */
+  async refresh() {
+    return this.setup(true)
+  }
+
+  /**
+   * Load more posts to the end of the notifications
+   */
+  async loadMore() {
+    if (this._loadMorePromise) {
+      return this._loadMorePromise
+    }
+    await this._pendingWork()
+    this._loadMorePromise = this._loadMore()
+    await this._loadMorePromise
+    this._loadMorePromise = undefined
+  }
+
+  /**
+   * Update content in-place
+   */
+  async update() {
+    if (this._updatePromise) {
+      return this._updatePromise
+    }
+    await this._pendingWork()
+    this._updatePromise = this._update()
+    await this._updatePromise
+    this._updatePromise = undefined
+  }
+
+  // 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 _pendingWork() {
+    if (this._loadPromise) {
+      await this._loadPromise
+    }
+    if (this._loadMorePromise) {
+      await this._loadMorePromise
+    }
+    if (this._updatePromise) {
+      await this._updatePromise
+    }
+  }
+
+  private async _initialLoad(isRefreshing = false) {
+    this._xLoading(isRefreshing)
+    await new Promise(r => setTimeout(r, 250)) // DEBUG
+    try {
+      const res = (await this.rootStore.api.mainPds.view(
+        'blueskyweb.xyz:NotificationsView',
+        this.params,
+      )) as bsky.NotificationsView.Response
+      this._replaceAll(res)
+      this._xIdle()
+    } catch (e: any) {
+      this._xIdle(`Failed to load notifications: ${e.toString()}`)
+    }
+  }
+
+  private async _loadMore() {
+    this._xLoading()
+    await new Promise(r => setTimeout(r, 250)) // DEBUG
+    try {
+      const params = Object.assign({}, this.params, {
+        before: this.loadMoreCursor,
+      })
+      const res = (await this.rootStore.api.mainPds.view(
+        'blueskyweb.xyz:NotificationsView',
+        params,
+      )) as bsky.NotificationsView.Response
+      this._appendAll(res)
+      this._xIdle()
+    } catch (e: any) {
+      this._xIdle(`Failed to load notifications: ${e.toString()}`)
+    }
+  }
+
+  private async _update() {
+    this._xLoading()
+    await new Promise(r => setTimeout(r, 250)) // DEBUG
+    let numToFetch = this.notifications.length
+    let cursor = undefined
+    try {
+      do {
+        const res = (await this.rootStore.api.mainPds.view(
+          'blueskyweb.xyz:NotificationsView',
+          {
+            before: cursor,
+            limit: Math.min(numToFetch, 100),
+          },
+        )) as bsky.NotificationsView.Response
+        if (res.notifications.length === 0) {
+          break // sanity check
+        }
+        this._updateAll(res)
+        numToFetch -= res.notifications.length
+        cursor = this.notifications[res.notifications.length - 1].indexedAt
+        console.log(numToFetch, cursor, res.notifications.length)
+      } while (numToFetch > 0)
+      this._xIdle()
+    } catch (e: any) {
+      this._xIdle(`Failed to update notifications: ${e.toString()}`)
+    }
+  }
+
+  private _replaceAll(res: bsky.NotificationsView.Response) {
+    this.notifications.length = 0
+    this._appendAll(res)
+  }
+
+  private _appendAll(res: bsky.NotificationsView.Response) {
+    let counter = this.notifications.length
+    for (const item of res.notifications) {
+      this._append(counter++, item)
+    }
+  }
+
+  private _append(keyId: number, item: bsky.NotificationsView.Notification) {
+    // TODO: validate .record
+    this.notifications.push(
+      new NotificationsViewItemModel(this.rootStore, `item-${keyId}`, item),
+    )
+  }
+
+  private _updateAll(res: bsky.NotificationsView.Response) {
+    for (const item of res.notifications) {
+      const existingItem = this.notifications.find(
+        // this find function has a key subtley- the indexedAt comparison
+        // the reason for this is reposts: they set the URI of the original post, not of the repost record
+        // the indexedAt time will be for the repost however, so we use that to help us
+        item2 => item.uri === item2.uri && item.indexedAt === item2.indexedAt,
+      )
+      if (existingItem) {
+        existingItem.copy(item)
+      }
+    }
+  }
+}
diff --git a/src/state/models/post.ts b/src/state/models/post.ts
new file mode 100644
index 000000000..463230101
--- /dev/null
+++ b/src/state/models/post.ts
@@ -0,0 +1,93 @@
+import {makeAutoObservable} from 'mobx'
+import {bsky, AdxUri} from '@adxp/mock-api'
+import {RootStoreModel} from './root-store'
+
+export type PostEntities = bsky.Post.Record['entities']
+export type PostReply = bsky.Post.Record['reply']
+export class PostModel implements bsky.Post.Record {
+  // state
+  isLoading = false
+  hasLoaded = false
+  error = ''
+  uri: string = ''
+
+  // data
+  text: string = ''
+  entities?: PostEntities
+  reply?: PostReply
+  createdAt: string = ''
+
+  constructor(public rootStore: RootStoreModel, uri: string) {
+    makeAutoObservable(
+      this,
+      {
+        rootStore: false,
+        uri: false,
+      },
+      {autoBind: true},
+    )
+    this.uri = uri
+  }
+
+  get hasContent() {
+    return this.createdAt !== ''
+  }
+
+  get hasError() {
+    return this.error !== ''
+  }
+
+  get isEmpty() {
+    return this.hasLoaded && !this.hasContent
+  }
+
+  // public api
+  // =
+
+  async setup() {
+    await this._load()
+  }
+
+  // state transitions
+  // =
+
+  private _xLoading() {
+    this.isLoading = true
+    this.error = ''
+  }
+
+  private _xIdle(err: string = '') {
+    this.isLoading = false
+    this.hasLoaded = true
+    this.error = err
+  }
+
+  // loader functions
+  // =
+
+  private async _load() {
+    this._xLoading()
+    await new Promise(r => setTimeout(r, 250)) // DEBUG
+    try {
+      const urip = new AdxUri(this.uri)
+      const res = await this.rootStore.api.mainPds
+        .repo(urip.host, false)
+        .collection(urip.collection)
+        .get('Post', urip.recordKey)
+      if (!res.valid) {
+        throw new Error(res.error)
+      }
+      this._replaceAll(res.value)
+      this._xIdle()
+    } catch (e: any) {
+      this._xIdle(`Failed to load post: ${e.toString()}`)
+    }
+  }
+
+  private _replaceAll(res: bsky.Post.Record) {
+    this.text = res.text
+    this.entities = res.entities
+    this.reply = res.reply
+    this.createdAt = res.createdAt
+  }
+}
diff --git a/src/state/models/root-store.ts b/src/state/models/root-store.ts
index 7391a82bd..e05c86389 100644
--- a/src/state/models/root-store.ts
+++ b/src/state/models/root-store.ts
@@ -9,11 +9,13 @@ import {isObj, hasProp} from '../lib/type-guards'
 import {SessionModel} from './session'
 import {MeModel} from './me'
 import {FeedViewModel} from './feed-view'
+import {NotificationsViewModel} from './notifications-view'
 
 export class RootStoreModel {
   session = new SessionModel()
   me = new MeModel(this)
   homeFeed = new FeedViewModel(this, {})
+  notesFeed = new NotificationsViewModel(this, {})
 
   constructor(public api: AdxClient) {
     makeAutoObservable(this, {
diff --git a/src/view/com/notifications/Feed.tsx b/src/view/com/notifications/Feed.tsx
new file mode 100644
index 000000000..7c95003c7
--- /dev/null
+++ b/src/view/com/notifications/Feed.tsx
@@ -0,0 +1,50 @@
+import React from 'react'
+import {observer} from 'mobx-react-lite'
+import {Text, View, FlatList} from 'react-native'
+import {OnNavigateContent} from '../../routes/types'
+import {
+  NotificationsViewModel,
+  NotificationsViewItemModel,
+} from '../../../state/models/notifications-view'
+import {FeedItem} from './FeedItem'
+
+export const Feed = observer(function Feed({
+  view,
+  onNavigateContent,
+}: {
+  view: NotificationsViewModel
+  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: NotificationsViewItemModel}) => (
+    <FeedItem item={item} onNavigateContent={onNavigateContent} />
+  )
+  const onRefresh = () => {
+    view.refresh().catch(err => console.error('Failed to refresh', err))
+  }
+  const onEndReached = () => {
+    view.loadMore().catch(err => console.error('Failed to load more', err))
+  }
+  return (
+    <View>
+      {view.isLoading && !view.isRefreshing && !view.hasContent && (
+        <Text>Loading...</Text>
+      )}
+      {view.hasError && <Text>{view.error}</Text>}
+      {view.hasContent && (
+        <FlatList
+          data={view.notifications}
+          keyExtractor={item => item._reactKey}
+          renderItem={renderItem}
+          refreshing={view.isRefreshing}
+          onRefresh={onRefresh}
+          onEndReached={onEndReached}
+        />
+      )}
+      {view.isEmpty && <Text>This feed is empty!</Text>}
+    </View>
+  )
+})
diff --git a/src/view/com/notifications/FeedItem.tsx b/src/view/com/notifications/FeedItem.tsx
new file mode 100644
index 000000000..1e0e47811
--- /dev/null
+++ b/src/view/com/notifications/FeedItem.tsx
@@ -0,0 +1,136 @@
+import React from 'react'
+import {observer} from 'mobx-react-lite'
+import {Image, StyleSheet, Text, TouchableOpacity, View} from 'react-native'
+import {AdxUri} from '@adxp/mock-api'
+import {FontAwesomeIcon, Props} from '@fortawesome/react-native-fontawesome'
+import {OnNavigateContent} from '../../routes/types'
+import {NotificationsViewItemModel} from '../../../state/models/notifications-view'
+import {s} from '../../lib/styles'
+import {ago} from '../../lib/strings'
+import {AVIS} from '../../lib/assets'
+import {PostText} from '../post/PostText'
+import {Post} from '../post/Post'
+
+export const FeedItem = observer(function FeedItem({
+  item,
+  onNavigateContent,
+}: {
+  item: NotificationsViewItemModel
+  onNavigateContent: OnNavigateContent
+}) {
+  const onPressOuter = () => {
+    if (item.isLike || item.isRepost) {
+      const urip = new AdxUri(item.subjectUri)
+      onNavigateContent('PostThread', {
+        name: urip.host,
+        recordKey: urip.recordKey,
+      })
+    } else if (item.isFollow) {
+      onNavigateContent('Profile', {
+        name: item.author.name,
+      })
+    } else if (item.isReply) {
+      const urip = new AdxUri(item.uri)
+      onNavigateContent('PostThread', {
+        name: urip.host,
+        recordKey: urip.recordKey,
+      })
+    }
+  }
+  const onPressAuthor = () => {
+    onNavigateContent('Profile', {
+      name: item.author.name,
+    })
+  }
+
+  let action = ''
+  let icon: Props['icon']
+  if (item.isLike) {
+    action = 'liked your post'
+    icon = ['far', 'heart']
+  } else if (item.isRepost) {
+    action = 'reposted your post'
+    icon = 'retweet'
+  } else if (item.isReply) {
+    action = 'replied to your post'
+    icon = ['far', 'comment']
+  } else if (item.isFollow) {
+    action = 'followed you'
+    icon = 'plus'
+  } else {
+    return <></>
+  }
+
+  return (
+    <TouchableOpacity style={styles.outer} onPress={onPressOuter}>
+      <View style={styles.layout}>
+        <TouchableOpacity style={styles.layoutAvi} onPress={onPressAuthor}>
+          <Image
+            style={styles.avi}
+            source={AVIS[item.author.name] || AVIS['alice.com']}
+          />
+        </TouchableOpacity>
+        <View style={styles.layoutContent}>
+          <View style={styles.meta}>
+            <FontAwesomeIcon icon={icon} size={14} style={[s.mt2, s.mr5]} />
+            <Text
+              style={[styles.metaItem, s.f14, s.bold]}
+              onPress={onPressAuthor}>
+              {item.author.displayName}
+            </Text>
+            <Text style={[styles.metaItem, s.f14]}>{action}</Text>
+            <Text style={[styles.metaItem, s.f14, s.gray]}>
+              {ago(item.indexedAt)}
+            </Text>
+          </View>
+          {item.isLike || item.isRepost ? (
+            <PostText uri={item.subjectUri} style={[s.gray]} />
+          ) : (
+            <></>
+          )}
+        </View>
+      </View>
+      {item.isReply ? (
+        <View style={s.pt5}>
+          <Post uri={item.uri} onNavigateContent={onNavigateContent} />
+        </View>
+      ) : (
+        <></>
+      )}
+    </TouchableOpacity>
+  )
+})
+
+const styles = StyleSheet.create({
+  outer: {
+    backgroundColor: '#fff',
+    padding: 10,
+    paddingBottom: 0,
+  },
+  layout: {
+    flexDirection: 'row',
+  },
+  layoutAvi: {
+    width: 40,
+  },
+  avi: {
+    width: 30,
+    height: 30,
+    borderRadius: 15,
+    resizeMode: 'cover',
+  },
+  layoutContent: {
+    flex: 1,
+  },
+  meta: {
+    flexDirection: 'row',
+    paddingTop: 6,
+    paddingBottom: 4,
+  },
+  metaItem: {
+    paddingRight: 3,
+  },
+  postText: {
+    paddingBottom: 5,
+  },
+})
diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx
new file mode 100644
index 000000000..3cfb6a1a1
--- /dev/null
+++ b/src/view/com/post/Post.tsx
@@ -0,0 +1,225 @@
+import React, {useState, useEffect} from 'react'
+import {observer} from 'mobx-react-lite'
+import {bsky, AdxUri} from '@adxp/mock-api'
+import {
+  ActivityIndicator,
+  Image,
+  StyleSheet,
+  Text,
+  TouchableOpacity,
+  View,
+} from 'react-native'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {OnNavigateContent} from '../../routes/types'
+import {PostThreadViewModel} from '../../../state/models/post-thread-view'
+import {useStores} from '../../../state'
+import {s} from '../../lib/styles'
+import {ago} from '../../lib/strings'
+import {AVIS} from '../../lib/assets'
+
+export const Post = observer(function Post({
+  uri,
+  onNavigateContent,
+}: {
+  uri: string
+  onNavigateContent: OnNavigateContent
+}) {
+  const store = useStores()
+  const [view, setView] = useState<PostThreadViewModel | undefined>()
+
+  useEffect(() => {
+    if (view?.params.uri === uri) {
+      return // no change needed? or trigger refresh?
+    }
+    const newView = new PostThreadViewModel(store, {uri, depth: 0})
+    setView(newView)
+    newView.setup().catch(err => console.error('Failed to fetch post', err))
+  }, [uri, view?.params.uri, store])
+
+  // loading
+  // =
+  if (!view || view.isLoading || view.params.uri !== uri) {
+    return (
+      <View>
+        <ActivityIndicator />
+      </View>
+    )
+  }
+
+  // error
+  // =
+  if (view.hasError || !view.thread) {
+    return (
+      <View>
+        <Text>{view.error || 'Thread not found'}</Text>
+      </View>
+    )
+  }
+
+  // loaded
+  // =
+  const item = view.thread
+  const record = view.thread?.record as unknown as bsky.Post.Record
+
+  const onPressOuter = () => {
+    const urip = new AdxUri(item.uri)
+    onNavigateContent('PostThread', {
+      name: item.author.name,
+      recordKey: urip.recordKey,
+    })
+  }
+  const onPressAuthor = () => {
+    onNavigateContent('Profile', {
+      name: item.author.name,
+    })
+  }
+  const onPressReply = () => {
+    onNavigateContent('Composer', {
+      replyTo: item.uri,
+    })
+  }
+  const onPressToggleRepost = () => {
+    item
+      .toggleRepost()
+      .catch(e => console.error('Failed to toggle repost', record, e))
+  }
+  const onPressToggleLike = () => {
+    item
+      .toggleLike()
+      .catch(e => console.error('Failed to toggle like', record, e))
+  }
+
+  return (
+    <TouchableOpacity style={styles.outer} onPress={onPressOuter}>
+      <View style={styles.layout}>
+        <TouchableOpacity style={styles.layoutAvi} onPress={onPressAuthor}>
+          <Image
+            style={styles.avi}
+            source={AVIS[item.author.name] || AVIS['alice.com']}
+          />
+        </TouchableOpacity>
+        <View style={styles.layoutContent}>
+          <View style={styles.meta}>
+            <Text
+              style={[styles.metaItem, s.f15, s.bold]}
+              onPress={onPressAuthor}>
+              {item.author.displayName}
+            </Text>
+            <Text
+              style={[styles.metaItem, s.f14, s.gray]}
+              onPress={onPressAuthor}>
+              @{item.author.name}
+            </Text>
+            <Text style={[styles.metaItem, s.f14, s.gray]}>
+              &middot; {ago(item.indexedAt)}
+            </Text>
+          </View>
+          <Text style={[styles.postText, s.f15, s['lh15-1.3']]}>
+            {record.text}
+          </Text>
+          <View style={styles.ctrls}>
+            <TouchableOpacity style={styles.ctrl} onPress={onPressReply}>
+              <FontAwesomeIcon
+                style={styles.ctrlIcon}
+                icon={['far', 'comment']}
+              />
+              <Text>{item.replyCount}</Text>
+            </TouchableOpacity>
+            <TouchableOpacity style={styles.ctrl} onPress={onPressToggleRepost}>
+              <FontAwesomeIcon
+                style={
+                  item.myState.hasReposted
+                    ? styles.ctrlIconReposted
+                    : styles.ctrlIcon
+                }
+                icon="retweet"
+                size={22}
+              />
+              <Text
+                style={
+                  item.myState.hasReposted ? [s.bold, s.green] : undefined
+                }>
+                {item.repostCount}
+              </Text>
+            </TouchableOpacity>
+            <TouchableOpacity style={styles.ctrl} onPress={onPressToggleLike}>
+              <FontAwesomeIcon
+                style={
+                  item.myState.hasLiked ? styles.ctrlIconLiked : styles.ctrlIcon
+                }
+                icon={[item.myState.hasLiked ? 'fas' : 'far', 'heart']}
+              />
+              <Text style={item.myState.hasLiked ? [s.bold, s.red] : undefined}>
+                {item.likeCount}
+              </Text>
+            </TouchableOpacity>
+            <View style={styles.ctrl}>
+              <FontAwesomeIcon
+                style={styles.ctrlIcon}
+                icon="share-from-square"
+              />
+            </View>
+          </View>
+        </View>
+      </View>
+    </TouchableOpacity>
+  )
+})
+
+const styles = StyleSheet.create({
+  outer: {
+    borderWidth: 1,
+    borderColor: '#e8e8e8',
+    borderRadius: 4,
+    backgroundColor: '#fff',
+    padding: 10,
+  },
+  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,
+  },
+  postText: {
+    paddingBottom: 5,
+  },
+  ctrls: {
+    flexDirection: 'row',
+  },
+  ctrl: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    flex: 1,
+    paddingLeft: 4,
+    paddingRight: 4,
+  },
+  ctrlIcon: {
+    marginRight: 5,
+    color: 'gray',
+  },
+  ctrlIconReposted: {
+    marginRight: 5,
+    color: 'green',
+  },
+  ctrlIconLiked: {
+    marginRight: 5,
+    color: 'red',
+  },
+})
diff --git a/src/view/com/post/PostText.tsx b/src/view/com/post/PostText.tsx
new file mode 100644
index 000000000..541f2fc16
--- /dev/null
+++ b/src/view/com/post/PostText.tsx
@@ -0,0 +1,53 @@
+import React, {useState, useEffect} from 'react'
+import {observer} from 'mobx-react-lite'
+import {ActivityIndicator, Text, View} from 'react-native'
+import {PostModel} from '../../../state/models/post'
+import {useStores} from '../../../state'
+
+export const PostText = observer(function PostText({
+  uri,
+  style,
+}: {
+  uri: string
+  style?: StyleProp
+}) {
+  const store = useStores()
+  const [model, setModel] = useState<PostModel | undefined>()
+
+  useEffect(() => {
+    if (model?.uri === uri) {
+      return // no change needed? or trigger refresh?
+    }
+    const newModel = new PostModel(store, uri)
+    setModel(newModel)
+    newModel.setup().catch(err => console.error('Failed to fetch post', err))
+  }, [uri, model?.uri, store])
+
+  // loading
+  // =
+  if (!model || model.isLoading || model.uri !== uri) {
+    return (
+      <View>
+        <ActivityIndicator />
+      </View>
+    )
+  }
+
+  // error
+  // =
+  if (model.hasError) {
+    return (
+      <View>
+        <Text style={style}>{model.error}</Text>
+      </View>
+    )
+  }
+
+  // loaded
+  // =
+  return (
+    <View>
+      <Text style={style}>{model.text}</Text>
+    </View>
+  )
+})
diff --git a/src/view/screens/tabroots/Notifications.tsx b/src/view/screens/tabroots/Notifications.tsx
index 091410ad8..ea7576799 100644
--- a/src/view/screens/tabroots/Notifications.tsx
+++ b/src/view/screens/tabroots/Notifications.tsx
@@ -1,16 +1,71 @@
-import React from 'react'
+import React, {useState, useEffect, useLayoutEffect} from 'react'
+import {Image, StyleSheet, TouchableOpacity, View} from 'react-native'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {Shell} from '../../shell'
-import {Text, View} from 'react-native'
+import {Feed} from '../../com/notifications/Feed'
 import type {RootTabsScreenProps} from '../../routes/types'
+import {useStores} from '../../../state'
+import {AVIS} from '../../lib/assets'
+
+export const Notifications = ({
+  navigation,
+}: RootTabsScreenProps<'NotificationsTab'>) => {
+  const [hasSetup, setHasSetup] = useState<boolean>(false)
+  const store = useStores()
+  useEffect(() => {
+    console.log('Fetching home feed')
+    store.notesFeed.setup().then(() => setHasSetup(true))
+  }, [store.notesFeed])
+
+  const onNavigateContent = (screen: string, props: Record<string, string>) => {
+    // @ts-ignore it's up to the callers to supply correct params -prf
+    navigation.navigate(screen, props)
+  }
+
+  useEffect(() => {
+    return navigation.addListener('focus', () => {
+      if (hasSetup) {
+        console.log('Updating home feed')
+        store.notesFeed.update()
+      }
+    })
+  }, [navigation, store.notesFeed, hasSetup])
+
+  useLayoutEffect(() => {
+    navigation.setOptions({
+      headerShown: true,
+      headerTitle: 'Notifications',
+      headerLeft: () => (
+        <TouchableOpacity
+          onPress={() => navigation.push('Profile', {name: 'alice.com'})}>
+          <Image source={AVIS['alice.com']} style={styles.avi} />
+        </TouchableOpacity>
+      ),
+      headerRight: () => (
+        <TouchableOpacity
+          onPress={() => {
+            navigation.push('Composer', {})
+          }}>
+          <FontAwesomeIcon icon="plus" style={{color: '#006bf7'}} />
+        </TouchableOpacity>
+      ),
+    })
+  }, [navigation])
 
-export const Notifications = (
-  _props: RootTabsScreenProps<'NotificationsTab'>,
-) => {
   return (
     <Shell>
-      <View style={{justifyContent: 'center', alignItems: 'center'}}>
-        <Text style={{fontSize: 20, fontWeight: 'bold'}}>Notifications</Text>
+      <View>
+        <Feed view={store.notesFeed} onNavigateContent={onNavigateContent} />
       </View>
     </Shell>
   )
 }
+
+const styles = StyleSheet.create({
+  avi: {
+    width: 20,
+    height: 20,
+    borderRadius: 10,
+    resizeMode: 'cover',
+  },
+})