about summary refs log tree commit diff
diff options
context:
space:
mode:
authorPaul Frazee <pfrazee@gmail.com>2022-09-06 14:26:39 -0500
committerPaul Frazee <pfrazee@gmail.com>2022-09-06 14:26:39 -0500
commitbb06ef4f6e7ac7889b3112285d0cf3445b8eb766 (patch)
treee820f23a49c83a3f0aba83b62f4e006307b62504
parent2ec09ba54574f5e05f0bbec9c864dacd2092edd4 (diff)
downloadvoidsky-bb06ef4f6e7ac7889b3112285d0cf3445b8eb766.tar.zst
Rework profile page to include working view selector
-rw-r--r--src/state/models/badges-view.ts49
-rw-r--r--src/state/models/feed-view.ts10
-rw-r--r--src/state/models/profile-ui.ts98
-rw-r--r--src/state/models/profile-view.ts8
-rw-r--r--src/view/com/modals/ComposePost.tsx9
-rw-r--r--src/view/com/post-thread/PostThreadItem.tsx2
-rw-r--r--src/view/com/post/Post.tsx2
-rw-r--r--src/view/com/posts/Feed.tsx (renamed from src/view/com/feed/Feed.tsx)8
-rw-r--r--src/view/com/posts/FeedItem.tsx (renamed from src/view/com/feed/FeedItem.tsx)9
-rw-r--r--src/view/com/profile/ProfileHeader.tsx52
-rw-r--r--src/view/com/util/ErrorMessage.tsx66
-rw-r--r--src/view/com/util/ErrorScreen.tsx111
-rw-r--r--src/view/com/util/Selector.tsx8
-rw-r--r--src/view/index.ts2
-rw-r--r--src/view/screens/Home.tsx2
-rw-r--r--src/view/screens/Profile.tsx203
-rw-r--r--src/view/shell/mobile/accounts-menu.tsx7
-rw-r--r--src/view/shell/mobile/index.tsx5
-rw-r--r--todos.txt8
19 files changed, 567 insertions, 92 deletions
diff --git a/src/state/models/badges-view.ts b/src/state/models/badges-view.ts
new file mode 100644
index 000000000..644ec7d9e
--- /dev/null
+++ b/src/state/models/badges-view.ts
@@ -0,0 +1,49 @@
+import {makeAutoObservable} from 'mobx'
+import {RootStoreModel} from './root-store'
+
+// TODO / DEBUG
+// this is a temporary fake for the model until the view actually gets implemented in the bsky api
+// -prf
+
+export class BadgesViewModel {
+  // state
+  isLoading = false
+  isRefreshing = false
+  hasLoaded = false
+  error = ''
+
+  constructor(public rootStore: RootStoreModel) {
+    makeAutoObservable(
+      this,
+      {
+        rootStore: false,
+      },
+      {autoBind: true},
+    )
+  }
+
+  get hasContent() {
+    return false
+  }
+
+  get hasError() {
+    return this.error !== ''
+  }
+
+  get isEmpty() {
+    return this.hasLoaded && !this.hasContent
+  }
+
+  // public api
+  // =
+
+  async setup() {
+    this.hasLoaded = true
+  }
+
+  async refresh() {}
+
+  async loadMore() {}
+
+  async update() {}
+}
diff --git a/src/state/models/feed-view.ts b/src/state/models/feed-view.ts
index e9405773c..9ba96764b 100644
--- a/src/state/models/feed-view.ts
+++ b/src/state/models/feed-view.ts
@@ -95,6 +95,7 @@ export class FeedViewModel implements bsky.FeedView.Response {
   isLoading = false
   isRefreshing = false
   hasLoaded = false
+  hasReachedEnd = false
   error = ''
   params: bsky.FeedView.Params
   _loadPromise: Promise<void> | undefined
@@ -244,7 +245,13 @@ export class FeedViewModel implements bsky.FeedView.Response {
         'blueskyweb.xyz:FeedView',
         params,
       )) as bsky.FeedView.Response
-      this._appendAll(res)
+      if (res.feed.length === 0) {
+        runInAction(() => {
+          this.hasReachedEnd = true
+        })
+      } else {
+        this._appendAll(res)
+      }
       this._xIdle()
     } catch (e: any) {
       this._xIdle(`Failed to load feed: ${e.toString()}`)
@@ -281,6 +288,7 @@ export class FeedViewModel implements bsky.FeedView.Response {
 
   private _replaceAll(res: bsky.FeedView.Response) {
     this.feed.length = 0
+    this.hasReachedEnd = false
     this._appendAll(res)
   }
 
diff --git a/src/state/models/profile-ui.ts b/src/state/models/profile-ui.ts
new file mode 100644
index 000000000..98a087aeb
--- /dev/null
+++ b/src/state/models/profile-ui.ts
@@ -0,0 +1,98 @@
+import {makeAutoObservable} from 'mobx'
+import {RootStoreModel} from './root-store'
+import {ProfileViewModel} from './profile-view'
+import {FeedViewModel} from './feed-view'
+import {BadgesViewModel} from './badges-view'
+
+export const SECTION_IDS = {
+  POSTS: 0,
+  BADGES: 1,
+}
+
+export interface ProfileUiParams {
+  user: string
+}
+
+export class ProfileUiModel {
+  // constants
+  static SELECTOR_ITEMS = ['Posts', 'Badges']
+
+  // data
+  profile: ProfileViewModel
+  feed: FeedViewModel
+  badges: BadgesViewModel
+
+  // ui state
+  selectedViewIndex = 0
+
+  constructor(
+    public rootStore: RootStoreModel,
+    public params: ProfileUiParams,
+  ) {
+    makeAutoObservable(
+      this,
+      {
+        rootStore: false,
+        params: false,
+      },
+      {autoBind: true},
+    )
+    this.profile = new ProfileViewModel(rootStore, {user: params.user})
+    this.feed = new FeedViewModel(rootStore, {author: params.user, limit: 10})
+    this.badges = new BadgesViewModel(rootStore)
+  }
+
+  get currentView(): FeedViewModel | BadgesViewModel {
+    if (this.selectedViewIndex === SECTION_IDS.POSTS) {
+      return this.feed
+    }
+    if (this.selectedViewIndex === SECTION_IDS.BADGES) {
+      return this.badges
+    }
+    throw new Error(`Invalid selector value: ${this.selectedViewIndex}`)
+  }
+
+  get isInitialLoading() {
+    const view = this.currentView
+    return view.isLoading && !view.isRefreshing && !view.hasContent
+  }
+
+  get isRefreshing() {
+    return this.profile.isRefreshing || this.currentView.isRefreshing
+  }
+
+  // public api
+  // =
+
+  setSelectedViewIndex(index: number) {
+    this.selectedViewIndex = index
+  }
+
+  async setup() {
+    await Promise.all([
+      this.profile
+        .setup()
+        .catch(err => console.error('Failed to fetch profile', err)),
+      this.feed
+        .setup()
+        .catch(err => console.error('Failed to fetch feed', err)),
+      this.badges
+        .setup()
+        .catch(err => console.error('Failed to fetch badges', err)),
+    ])
+  }
+
+  async update() {
+    await this.currentView.update()
+  }
+
+  async refresh() {
+    await Promise.all([this.profile.refresh(), this.currentView.refresh()])
+  }
+
+  async loadMore() {
+    if (!this.currentView.isLoading && !this.currentView.hasError) {
+      await this.currentView.loadMore()
+    }
+  }
+}
diff --git a/src/state/models/profile-view.ts b/src/state/models/profile-view.ts
index b245335f1..89c8a75d0 100644
--- a/src/state/models/profile-view.ts
+++ b/src/state/models/profile-view.ts
@@ -65,7 +65,7 @@ export class ProfileViewModel implements bsky.ProfileView.Response {
   }
 
   async refresh() {
-    await this._load()
+    await this._load(true)
   }
 
   async toggleFollowing() {
@@ -108,8 +108,8 @@ export class ProfileViewModel implements bsky.ProfileView.Response {
   // loader functions
   // =
 
-  private async _load() {
-    this._xLoading()
+  private async _load(isRefreshing = false) {
+    this._xLoading(isRefreshing)
     await new Promise(r => setTimeout(r, 250)) // DEBUG
     try {
       const res = (await this.rootStore.api.mainPds.view(
@@ -119,7 +119,7 @@ export class ProfileViewModel implements bsky.ProfileView.Response {
       this._replaceAll(res)
       this._xIdle()
     } catch (e: any) {
-      this._xIdle(`Failed to load feed: ${e.toString()}`)
+      this._xIdle(e.toString())
     }
   }
 
diff --git a/src/view/com/modals/ComposePost.tsx b/src/view/com/modals/ComposePost.tsx
index 253db3771..22b6b14bb 100644
--- a/src/view/com/modals/ComposePost.tsx
+++ b/src/view/com/modals/ComposePost.tsx
@@ -1,12 +1,5 @@
 import React, {useState} from 'react'
-import {
-  KeyboardAvoidingView,
-  StyleSheet,
-  Text,
-  TextInput,
-  TouchableOpacity,
-  View,
-} from 'react-native'
+import {StyleSheet, Text, TouchableOpacity, View} from 'react-native'
 import {BottomSheetTextInput} from '@gorhom/bottom-sheet'
 import LinearGradient from 'react-native-linear-gradient'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx
index d500514ef..8752ee7f9 100644
--- a/src/view/com/post-thread/PostThreadItem.tsx
+++ b/src/view/com/post-thread/PostThreadItem.tsx
@@ -238,7 +238,7 @@ export const PostThreadItem = observer(function PostThreadItem({
 const styles = StyleSheet.create({
   outer: {
     backgroundColor: colors.white,
-    borderRadius: 10,
+    borderRadius: 6,
     margin: 2,
     marginBottom: 0,
   },
diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx
index a6580fa5a..3dd5c0047 100644
--- a/src/view/com/post/Post.tsx
+++ b/src/view/com/post/Post.tsx
@@ -154,7 +154,7 @@ export const Post = observer(function Post({uri}: {uri: string}) {
 const styles = StyleSheet.create({
   outer: {
     marginTop: 1,
-    borderRadius: 4,
+    borderRadius: 6,
     backgroundColor: colors.white,
     padding: 10,
   },
diff --git a/src/view/com/feed/Feed.tsx b/src/view/com/posts/Feed.tsx
index 4a2ecb612..370a72e69 100644
--- a/src/view/com/feed/Feed.tsx
+++ b/src/view/com/posts/Feed.tsx
@@ -3,21 +3,17 @@ import {observer} from 'mobx-react-lite'
 import {Text, View, FlatList} from 'react-native'
 import {FeedViewModel, FeedViewItemModel} from '../../../state/models/feed-view'
 import {FeedItem} from './FeedItem'
-import {SharePostModel} from '../../../state/models/shell'
 import {useStores} from '../../../state'
 
 export const Feed = observer(function Feed({feed}: {feed: FeedViewModel}) {
   const store = useStores()
 
-  const onPressShare = (uri: string) => {
-    store.shell.openModal(new SharePostModel(uri))
-  }
   // 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}) => (
-    <FeedItem item={item} onPressShare={onPressShare} />
+    <FeedItem item={item} />
   )
   const onRefresh = () => {
     feed.refresh().catch(err => console.error('Failed to refresh', err))
@@ -33,7 +29,7 @@ export const Feed = observer(function Feed({feed}: {feed: FeedViewModel}) {
       {feed.hasError && <Text>{feed.error}</Text>}
       {feed.hasContent && (
         <FlatList
-          data={feed.feed}
+          data={feed.feed.slice()}
           keyExtractor={item => item._reactKey}
           renderItem={renderItem}
           refreshing={feed.isRefreshing}
diff --git a/src/view/com/feed/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx
index e9cf83346..2376686df 100644
--- a/src/view/com/feed/FeedItem.tsx
+++ b/src/view/com/posts/FeedItem.tsx
@@ -4,7 +4,7 @@ import {Image, StyleSheet, Text, TouchableOpacity, View} from 'react-native'
 import {bsky, AdxUri} from '@adxp/mock-api'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {FeedViewItemModel} from '../../../state/models/feed-view'
-import {ComposePostModel} from '../../../state/models/shell'
+import {ComposePostModel, SharePostModel} from '../../../state/models/shell'
 import {Link} from '../util/Link'
 import {PostDropdownBtn} from '../util/DropdownBtn'
 import {s, colors} from '../../lib/styles'
@@ -14,10 +14,8 @@ import {useStores} from '../../../state'
 
 export const FeedItem = observer(function FeedItem({
   item,
-  onPressShare,
 }: {
   item: FeedViewItemModel
-  onPressShare: (_uri: string) => void
 }) {
   const store = useStores()
   const record = item.record as unknown as bsky.Post.Record
@@ -41,6 +39,9 @@ export const FeedItem = observer(function FeedItem({
       .toggleLike()
       .catch(e => console.error('Failed to toggle like', record, e))
   }
+  const onPressShare = (uri: string) => {
+    store.shell.openModal(new SharePostModel(uri))
+  }
 
   return (
     <Link style={styles.outer} href={itemHref} title={itemTitle}>
@@ -151,7 +152,7 @@ export const FeedItem = observer(function FeedItem({
 
 const styles = StyleSheet.create({
   outer: {
-    borderRadius: 10,
+    borderRadius: 6,
     margin: 2,
     marginBottom: 0,
     backgroundColor: colors.white,
diff --git a/src/view/com/profile/ProfileHeader.tsx b/src/view/com/profile/ProfileHeader.tsx
index 08d895554..59af6b200 100644
--- a/src/view/com/profile/ProfileHeader.tsx
+++ b/src/view/com/profile/ProfileHeader.tsx
@@ -17,31 +17,13 @@ import {s, gradients, colors} from '../../lib/styles'
 import {AVIS, BANNER} from '../../lib/assets'
 import Toast from '../util/Toast'
 import {Link} from '../util/Link'
-import {Selector, SelectorItem} from '../util/Selector'
 
 export const ProfileHeader = observer(function ProfileHeader({
-  user,
+  view,
 }: {
-  user: string
+  view: ProfileViewModel
 }) {
   const store = useStores()
-  const [view, setView] = useState<ProfileViewModel | undefined>()
-
-  useEffect(() => {
-    if (view?.params.user === user) {
-      console.log('Profile header doing nothing')
-      return // no change needed? or trigger refresh?
-    }
-    console.log('Fetching profile', user)
-    const newView = new ProfileViewModel(store, {user: user})
-    setView(newView)
-    newView.setup().catch(err => console.error('Failed to fetch profile', err))
-  }, [user, view?.params.user, store])
-
-  const selectorItems: SelectorItem[] = [
-    {label: 'Posts', onSelect() {}},
-    {label: 'Badges', onSelect() {}},
-  ]
 
   const onPressToggleFollow = () => {
     view?.toggleFollowing().then(
@@ -66,19 +48,15 @@ export const ProfileHeader = observer(function ProfileHeader({
     // TODO
   }
   const onPressFollowers = () => {
-    store.nav.navigate(`/profile/${user}/followers`)
+    store.nav.navigate(`/profile/${view.name}/followers`)
   }
   const onPressFollows = () => {
-    store.nav.navigate(`/profile/${user}/follows`)
+    store.nav.navigate(`/profile/${view.name}/follows`)
   }
 
   // loading
   // =
-  if (
-    !view ||
-    (view.isLoading && !view.isRefreshing) ||
-    view.params.user !== user
-  ) {
+  if (!view || (view.isLoading && !view.isRefreshing)) {
     return (
       <View>
         <ActivityIndicator />
@@ -120,13 +98,13 @@ export const ProfileHeader = observer(function ProfileHeader({
             <TouchableOpacity
               onPress={onPressEditProfile}
               style={[styles.mainBtn, styles.btn]}>
-              <Text style={[s.fw600, s.f16]}>Edit Profile</Text>
+              <Text style={[s.fw400, s.f14]}>Edit Profile</Text>
             </TouchableOpacity>
           ) : view.myState.hasFollowed ? (
             <TouchableOpacity
               onPress={onPressToggleFollow}
               style={[styles.mainBtn, styles.btn]}>
-              <Text style={[s.fw600, s.f16]}>Following</Text>
+              <Text style={[s.fw400, s.f14]}>Following</Text>
             </TouchableOpacity>
           ) : (
             <TouchableOpacity onPress={onPressToggleFollow}>
@@ -146,7 +124,7 @@ export const ProfileHeader = observer(function ProfileHeader({
             <FontAwesomeIcon icon="ellipsis" style={[s.gray5]} />
           </TouchableOpacity>
         </View>
-        <View style={[s.flexRow, s.mb10]}>
+        <View style={[s.flexRow]}>
           <TouchableOpacity
             style={[s.flexRow, s.mr10]}
             onPress={onPressFollowers}>
@@ -167,10 +145,9 @@ export const ProfileHeader = observer(function ProfileHeader({
           </View>
         </View>
         {view.description && (
-          <Text style={[s.mb10, s.f15, s['lh15-1.3']]}>{view.description}</Text>
+          <Text style={[s.mt10, s.f15, s['lh15-1.3']]}>{view.description}</Text>
         )}
       </View>
-      <Selector items={selectorItems} />
     </View>
   )
 })
@@ -178,8 +155,6 @@ export const ProfileHeader = observer(function ProfileHeader({
 const styles = StyleSheet.create({
   outer: {
     backgroundColor: colors.white,
-    borderBottomWidth: 1,
-    borderColor: colors.gray2,
   },
   banner: {
     width: '100%',
@@ -222,14 +197,17 @@ const styles = StyleSheet.create({
     flexDirection: 'row',
     alignItems: 'center',
     justifyContent: 'center',
-    paddingVertical: 8,
-    paddingHorizontal: 60,
+    paddingVertical: 6,
+    paddingLeft: 55,
+    paddingRight: 60,
     borderRadius: 30,
+    borderWidth: 1,
+    borderColor: 'transparent',
   },
   btn: {
     alignItems: 'center',
     justifyContent: 'center',
-    paddingVertical: 8,
+    paddingVertical: 7,
     borderRadius: 30,
     borderWidth: 1,
     borderColor: colors.gray2,
diff --git a/src/view/com/util/ErrorMessage.tsx b/src/view/com/util/ErrorMessage.tsx
new file mode 100644
index 000000000..7c8670da3
--- /dev/null
+++ b/src/view/com/util/ErrorMessage.tsx
@@ -0,0 +1,66 @@
+import React from 'react'
+import {StyleSheet, Text, TouchableOpacity, View} from 'react-native'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {colors} from '../../lib/styles'
+
+export function ErrorMessage({
+  message,
+  onPressTryAgain,
+}: {
+  message: string
+  onPressTryAgain?: () => void
+}) {
+  return (
+    <View style={styles.outer}>
+      <View style={styles.errorIcon}>
+        <FontAwesomeIcon
+          icon="exclamation"
+          style={{color: colors.white}}
+          size={16}
+        />
+      </View>
+      <Text style={styles.message}>{message}</Text>
+      {onPressTryAgain && (
+        <TouchableOpacity style={styles.btn} onPress={onPressTryAgain}>
+          <FontAwesomeIcon
+            icon="arrows-rotate"
+            style={{color: colors.red4}}
+            size={16}
+          />
+        </TouchableOpacity>
+      )}
+    </View>
+  )
+}
+
+const styles = StyleSheet.create({
+  outer: {
+    flex: 1,
+    flexDirection: 'row',
+    alignItems: 'center',
+    backgroundColor: colors.red1,
+    borderWidth: 1,
+    borderColor: colors.red3,
+    borderRadius: 6,
+    paddingVertical: 8,
+    paddingHorizontal: 8,
+  },
+  errorIcon: {
+    backgroundColor: colors.red4,
+    borderRadius: 12,
+    width: 24,
+    height: 24,
+    alignItems: 'center',
+    justifyContent: 'center',
+    marginRight: 8,
+  },
+  message: {
+    flex: 1,
+    color: colors.red4,
+    paddingRight: 10,
+  },
+  btn: {
+    paddingHorizontal: 4,
+    paddingVertical: 4,
+  },
+})
diff --git a/src/view/com/util/ErrorScreen.tsx b/src/view/com/util/ErrorScreen.tsx
new file mode 100644
index 000000000..4a3e41dc9
--- /dev/null
+++ b/src/view/com/util/ErrorScreen.tsx
@@ -0,0 +1,111 @@
+import React from 'react'
+import {StyleSheet, Text, TouchableOpacity, View} from 'react-native'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {colors} from '../../lib/styles'
+
+export function ErrorScreen({
+  title,
+  message,
+  details,
+  onPressTryAgain,
+}: {
+  title: string
+  message: string
+  details?: string
+  onPressTryAgain?: () => void
+}) {
+  return (
+    <View style={styles.outer}>
+      <View style={styles.errorIconContainer}>
+        <View style={styles.errorIcon}>
+          <FontAwesomeIcon
+            icon="exclamation"
+            style={{color: colors.white}}
+            size={24}
+          />
+        </View>
+      </View>
+      <Text style={styles.title}>{title}</Text>
+      <Text style={styles.message}>{message}</Text>
+      {details && <Text style={styles.details}>{details}</Text>}
+      {onPressTryAgain && (
+        <View style={styles.btnContainer}>
+          <TouchableOpacity style={styles.btn} onPress={onPressTryAgain}>
+            <FontAwesomeIcon
+              icon="arrows-rotate"
+              style={{color: colors.white}}
+              size={16}
+            />
+            <Text style={styles.btnText}>Try again</Text>
+          </TouchableOpacity>
+        </View>
+      )}
+    </View>
+  )
+}
+
+const styles = StyleSheet.create({
+  outer: {
+    flex: 1,
+    backgroundColor: colors.red1,
+    borderWidth: 1,
+    borderColor: colors.red3,
+    borderRadius: 6,
+    paddingVertical: 30,
+    paddingHorizontal: 14,
+    margin: 10,
+  },
+  title: {
+    textAlign: 'center',
+    color: colors.red4,
+    fontSize: 24,
+    marginBottom: 10,
+  },
+  message: {
+    textAlign: 'center',
+    color: colors.red4,
+    marginBottom: 20,
+  },
+  details: {
+    textAlign: 'center',
+    color: colors.black,
+    backgroundColor: colors.white,
+    borderWidth: 1,
+    borderColor: colors.gray5,
+    borderRadius: 6,
+    paddingVertical: 10,
+    paddingHorizontal: 14,
+    overflow: 'hidden',
+    marginBottom: 20,
+  },
+  btnContainer: {
+    alignItems: 'center',
+  },
+  btn: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    backgroundColor: colors.red4,
+    borderRadius: 6,
+    paddingHorizontal: 16,
+    paddingVertical: 10,
+  },
+  btnText: {
+    marginLeft: 5,
+    color: colors.white,
+    fontSize: 16,
+    fontWeight: 'bold',
+  },
+  errorIconContainer: {
+    alignItems: 'center',
+    marginBottom: 10,
+  },
+  errorIcon: {
+    backgroundColor: colors.red4,
+    borderRadius: 30,
+    width: 50,
+    height: 50,
+    alignItems: 'center',
+    justifyContent: 'center',
+    marginRight: 5,
+  },
+})
diff --git a/src/view/com/util/Selector.tsx b/src/view/com/util/Selector.tsx
index ef7c65d59..adc393d89 100644
--- a/src/view/com/util/Selector.tsx
+++ b/src/view/com/util/Selector.tsx
@@ -9,17 +9,13 @@ import {
 } from 'react-native'
 import {colors} from '../../lib/styles'
 
-export interface SelectorItem {
-  label: string
-}
-
 export function Selector({
   style,
   items,
   onSelect,
 }: {
   style?: StyleProp<ViewStyle>
-  items: SelectorItem[]
+  items: string[]
   onSelect?: (index: number) => void
 }) {
   const [selectedIndex, setSelectedIndex] = useState<number>(0)
@@ -36,7 +32,7 @@ export function Selector({
           <TouchableWithoutFeedback key={i} onPress={() => onPressItem(i)}>
             <View style={selected ? styles.itemSelected : styles.item}>
               <Text style={selected ? styles.labelSelected : styles.label}>
-                {item.label}
+                {item}
               </Text>
             </View>
           </TouchableWithoutFeedback>
diff --git a/src/view/index.ts b/src/view/index.ts
index c4b8fa9f9..d15fc6c25 100644
--- a/src/view/index.ts
+++ b/src/view/index.ts
@@ -5,6 +5,7 @@ import {faAngleRight} from '@fortawesome/free-solid-svg-icons/faAngleRight'
 import {faArrowLeft} from '@fortawesome/free-solid-svg-icons/faArrowLeft'
 import {faArrowUpFromBracket} from '@fortawesome/free-solid-svg-icons/faArrowUpFromBracket'
 import {faArrowUpRightFromSquare} from '@fortawesome/free-solid-svg-icons/faArrowUpRightFromSquare'
+import {faArrowsRotate} from '@fortawesome/free-solid-svg-icons/faArrowsRotate'
 import {faBars} from '@fortawesome/free-solid-svg-icons/faBars'
 import {faBell} from '@fortawesome/free-solid-svg-icons/faBell'
 import {faBell as farBell} from '@fortawesome/free-regular-svg-icons/faBell'
@@ -39,6 +40,7 @@ export function setup() {
     faArrowLeft,
     faArrowUpFromBracket,
     faArrowUpRightFromSquare,
+    faArrowsRotate,
     faBars,
     faBell,
     farBell,
diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx
index 27a17d0e9..5b4f1011d 100644
--- a/src/view/screens/Home.tsx
+++ b/src/view/screens/Home.tsx
@@ -1,7 +1,7 @@
 import React, {useState, useEffect} from 'react'
 import {View} from 'react-native'
 import {observer} from 'mobx-react-lite'
-import {Feed} from '../com/feed/Feed'
+import {Feed} from '../com/posts/Feed'
 import {FAB} from '../com/util/FloatingActionButton'
 import {useStores} from '../../state'
 import {FeedViewModel} from '../../state/models/feed-view'
diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx
index 236f8f908..6711f7e04 100644
--- a/src/view/screens/Profile.tsx
+++ b/src/view/screens/Profile.tsx
@@ -1,52 +1,213 @@
 import React, {useState, useEffect} from 'react'
-import {View, StyleSheet} from 'react-native'
-import {FeedViewModel} from '../../state/models/feed-view'
+import {SectionList, StyleSheet, Text, View} from 'react-native'
+import {observer} from 'mobx-react-lite'
+import {ProfileUiModel, SECTION_IDS} from '../../state/models/profile-ui'
+import {FeedViewItemModel} from '../../state/models/feed-view'
 import {useStores} from '../../state'
 import {ProfileHeader} from '../com/profile/ProfileHeader'
-import {Feed} from '../com/feed/Feed'
+import {FeedItem} from '../com/posts/FeedItem'
+import {Selector} from '../com/util/Selector'
+import {ErrorScreen} from '../com/util/ErrorScreen'
+import {ErrorMessage} from '../com/util/ErrorMessage'
+import {s, colors} from '../lib/styles'
 import {ScreenParams} from '../routes'
 
-export const Profile = ({visible, params}: ScreenParams) => {
+const SECTION_HEADER_ITEM = Symbol('SectionHeaderItem')
+const LOADING_ITEM = Symbol('LoadingItem')
+const EMPTY_ITEM = Symbol('EmptyItem')
+const END_ITEM = Symbol('EndItem')
+
+interface RenderItemParams {
+  item: any
+  index: number
+  section: Section
+}
+
+interface ErrorItem {
+  error: string
+}
+
+interface Section {
+  data: any[]
+  keyExtractor?: (v: any) => string
+  renderItem: (params: RenderItemParams) => JSX.Element
+}
+
+export const Profile = observer(({visible, params}: ScreenParams) => {
   const store = useStores()
   const [hasSetup, setHasSetup] = useState<boolean>(false)
-  const [feedView, setFeedView] = useState<FeedViewModel | undefined>()
+  const [profileUiState, setProfileUiState] = useState<
+    ProfileUiModel | undefined
+  >()
 
   useEffect(() => {
     if (!visible) {
       return
     }
-    const author = params.name
+    const user = params.name
     if (hasSetup) {
-      console.log('Updating profile feed for', author)
-      feedView?.update()
+      console.log('Updating profile for', user)
+      profileUiState?.update()
     } else {
-      console.log('Fetching profile feed for', author)
-      const newFeedView = new FeedViewModel(store, {author})
-      setFeedView(newFeedView)
-      newFeedView
-        .setup()
-        .catch(err => console.error('Failed to fetch feed', err))
-        .then(() => {
-          setHasSetup(true)
-          store.nav.setTitle(author)
-        })
+      console.log('Fetching profile for', user)
+      store.nav.setTitle(user)
+      const newProfileUiState = new ProfileUiModel(store, {user})
+      setProfileUiState(newProfileUiState)
+      newProfileUiState.setup().then(() => {
+        setHasSetup(true)
+      })
     }
   }, [visible, params.name, store])
 
+  // events
+  // =
+
+  const onSelectViewSelector = (index: number) =>
+    profileUiState?.setSelectedViewIndex(index)
+  const onRefresh = () => {
+    profileUiState
+      ?.refresh()
+      .catch((err: any) => console.error('Failed to refresh', err))
+  }
+  const onEndReached = () => {
+    profileUiState
+      ?.loadMore()
+      .catch((err: any) => console.error('Failed to load more', err))
+  }
+  const onPressTryAgain = () => {
+    profileUiState?.setup()
+  }
+
+  // rendering
+  // =
+
+  const renderItem = (_params: RenderItemParams) => <View />
+  const renderLoadingItem = (_params: RenderItemParams) => (
+    <Text style={styles.loading}>Loading...</Text>
+  )
+  const renderErrorItem = ({item}: {item: ErrorItem}) => (
+    <View style={s.p5}>
+      <ErrorMessage message={item.error} onPressTryAgain={onPressTryAgain} />
+    </View>
+  )
+  const renderEmptyItem = (_params: RenderItemParams) => (
+    <Text style={styles.loading}>No posts yet!</Text>
+  )
+  const renderProfileItem = (_params: RenderItemParams) => {
+    if (!profileUiState) {
+      return <View />
+    }
+    return <ProfileHeader view={profileUiState.profile} />
+  }
+  const renderSectionHeader = ({section}: {section: Section}) => {
+    if (section?.data?.[0] !== SECTION_HEADER_ITEM) {
+      return (
+        <Selector
+          items={ProfileUiModel.SELECTOR_ITEMS}
+          style={styles.selector}
+          onSelect={onSelectViewSelector}
+        />
+      )
+    }
+    return <View />
+  }
+  const renderPostsItem = ({item}: {item: FeedViewItemModel | Symbol}) => {
+    if (item === END_ITEM || item instanceof Symbol) {
+      return <Text style={styles.endItem}>- end of feed -</Text>
+    }
+    return <FeedItem item={item} />
+  }
+  const renderBadgesItem = ({item}: {item: any}) => <Text>todo</Text>
+
+  const sections = [
+    {data: [SECTION_HEADER_ITEM], renderItem: renderProfileItem},
+  ]
+  if (profileUiState) {
+    if (profileUiState.selectedViewIndex === SECTION_IDS.POSTS) {
+      if (profileUiState.isInitialLoading) {
+        sections.push({
+          data: [LOADING_ITEM],
+          renderItem: renderLoadingItem,
+        } as Section)
+      } else if (profileUiState.feed.hasError) {
+        sections.push({
+          data: [{error: profileUiState.feed.error}],
+          renderItem: renderErrorItem,
+        } as Section)
+      } else if (profileUiState.currentView.hasContent) {
+        const items: (FeedViewItemModel | Symbol)[] =
+          profileUiState.feed.feed.slice()
+        if (profileUiState.feed.hasReachedEnd) {
+          items.push(END_ITEM)
+        }
+        sections.push({
+          data: items,
+          renderItem: renderPostsItem,
+          keyExtractor: (item: FeedViewItemModel) => item._reactKey,
+        } as Section)
+      } else if (profileUiState.currentView.isEmpty) {
+        sections.push({
+          data: [EMPTY_ITEM],
+          renderItem: renderEmptyItem,
+        })
+      }
+    }
+    if (profileUiState.selectedViewIndex === SECTION_IDS.BADGES) {
+      sections.push({
+        data: [{}],
+        renderItem: renderBadgesItem,
+      } as Section)
+    }
+  }
+
   return (
     <View style={styles.container}>
-      <ProfileHeader user={params.name} />
-      <View style={styles.feed}>{feedView && <Feed feed={feedView} />}</View>
+      <View style={styles.feed}>
+        {profileUiState &&
+          (profileUiState.profile.hasError ? (
+            <ErrorScreen
+              title="Failed to load profile"
+              message={`There was an issue when attempting to load ${params.name}`}
+              details={profileUiState.profile.error}
+              onPressTryAgain={onPressTryAgain}
+            />
+          ) : (
+            <SectionList
+              sections={sections}
+              renderSectionHeader={renderSectionHeader}
+              renderItem={renderItem}
+              refreshing={profileUiState.isRefreshing}
+              onRefresh={onRefresh}
+              onEndReached={onEndReached}
+            />
+          ))}
+      </View>
     </View>
   )
-}
+})
 
 const styles = StyleSheet.create({
   container: {
     flexDirection: 'column',
     height: '100%',
   },
+  selector: {
+    paddingTop: 8,
+    backgroundColor: colors.white,
+    borderBottomWidth: 1,
+    borderColor: colors.gray2,
+  },
   feed: {
     flex: 1,
   },
+  loading: {
+    paddingVertical: 10,
+    paddingHorizontal: 14,
+  },
+  endItem: {
+    paddingTop: 20,
+    paddingBottom: 30,
+    color: colors.gray5,
+    textAlign: 'center',
+  },
 })
diff --git a/src/view/shell/mobile/accounts-menu.tsx b/src/view/shell/mobile/accounts-menu.tsx
index e3b61ce42..24b614cec 100644
--- a/src/view/shell/mobile/accounts-menu.tsx
+++ b/src/view/shell/mobile/accounts-menu.tsx
@@ -12,9 +12,14 @@ import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {AVIS} from '../../lib/assets'
 import {s, colors} from '../../lib/styles'
 
-export function createAccountsMenu(): RootSiblings {
+export function createAccountsMenu({
+  debug_onPressItem,
+}: {
+  debug_onPressItem: () => void
+}): RootSiblings {
   const onPressItem = (_index: number) => {
     sibling.destroy()
+    debug_onPressItem() // TODO
   }
   const onOuterPress = () => sibling.destroy()
   const sibling = new RootSiblings(
diff --git a/src/view/shell/mobile/index.tsx b/src/view/shell/mobile/index.tsx
index 35a1b3957..f049211e4 100644
--- a/src/view/shell/mobile/index.tsx
+++ b/src/view/shell/mobile/index.tsx
@@ -99,7 +99,10 @@ export const MobileShell: React.FC = observer(() => {
   const [isLocationMenuActive, setLocationMenuActive] = useState(false)
   const screenRenderDesc = constructScreenRenderDesc(store.nav)
 
-  const onPressAvi = () => createAccountsMenu()
+  const onPressAvi = () =>
+    createAccountsMenu({
+      debug_onPressItem: () => store.nav.navigate('/profile/alice.com'),
+    })
   const onPressLocation = () => setLocationMenuActive(true)
   const onPressEllipsis = () => createLocationMenu()
 
diff --git a/todos.txt b/todos.txt
index e9c96c808..0f453350c 100644
--- a/todos.txt
+++ b/todos.txt
@@ -1,7 +1,15 @@
 Paul's todo list
 
+- General
+  - Update to RN 0.70
+  - Selector swipe gesture
 - Composer
   - Update the view after creating a post
+- Profile
+  - Real badges
+  - Edit profile
+  - More button
+  - Followers & following as modal?
 - Search view
   - *
 - Linking