about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
authorPaul Frazee <pfrazee@gmail.com>2022-07-26 12:02:34 -0500
committerPaul Frazee <pfrazee@gmail.com>2022-07-26 12:02:34 -0500
commit62eb9f3c937028b6a6a8a3af40a03978fda5fef4 (patch)
tree4b027aea02c4e521632a54a9104fdffe665b2108 /src
parent1504d144d9bbb90e4e048702f1e1b3db9d7ce17e (diff)
downloadvoidsky-62eb9f3c937028b6a6a8a3af40a03978fda5fef4.tar.zst
Add followers and follows list
Diffstat (limited to 'src')
-rw-r--r--src/state/models/liked-by-view.ts17
-rw-r--r--src/state/models/reposted-by-view.ts10
-rw-r--r--src/state/models/user-followers-view.ts111
-rw-r--r--src/state/models/user-follows-view.ts112
-rw-r--r--src/view/com/profile/ProfileFollowers.tsx141
-rw-r--r--src/view/com/profile/ProfileFollows.tsx141
-rw-r--r--src/view/com/profile/ProfileHeader.tsx21
-rw-r--r--src/view/routes/index.tsx22
-rw-r--r--src/view/routes/types.ts2
-rw-r--r--src/view/screens/stacks/ProfileFollowers.tsx39
-rw-r--r--src/view/screens/stacks/ProfileFollows.tsx39
11 files changed, 636 insertions, 19 deletions
diff --git a/src/state/models/liked-by-view.ts b/src/state/models/liked-by-view.ts
index e9548f27b..91c94093d 100644
--- a/src/state/models/liked-by-view.ts
+++ b/src/state/models/liked-by-view.ts
@@ -73,7 +73,11 @@ export class LikedByViewModel implements bsky.LikedByView.Response {
   }
 
   async refresh() {
-    await this._refresh()
+    await this._fetch(true)
+  }
+
+  async loadMore() {
+    // TODO
   }
 
   // state transitions
@@ -105,8 +109,8 @@ export class LikedByViewModel implements bsky.LikedByView.Response {
     })
   }
 
-  private async _fetch() {
-    this._xLoading()
+  private async _fetch(isRefreshing = false) {
+    this._xLoading(isRefreshing)
     await new Promise(r => setTimeout(r, 250)) // DEBUG
     try {
       const res = (await this.rootStore.api.mainPds.view(
@@ -120,13 +124,6 @@ export class LikedByViewModel implements bsky.LikedByView.Response {
     }
   }
 
-  private async _refresh() {
-    this._xLoading(true)
-    // TODO: refetch and update items
-    await new Promise(r => setTimeout(r, 250)) // DEBUG
-    this._xIdle()
-  }
-
   private _replaceAll(res: bsky.LikedByView.Response) {
     this.likedBy.length = 0
     let counter = 0
diff --git a/src/state/models/reposted-by-view.ts b/src/state/models/reposted-by-view.ts
index a5f6cce25..74698767e 100644
--- a/src/state/models/reposted-by-view.ts
+++ b/src/state/models/reposted-by-view.ts
@@ -73,7 +73,11 @@ export class RepostedByViewModel implements bsky.RepostedByView.Response {
   }
 
   async refresh() {
-    await this._refresh()
+    await this._fetch(true)
+  }
+
+  async loadMore() {
+    // TODO
   }
 
   // state transitions
@@ -105,8 +109,8 @@ export class RepostedByViewModel implements bsky.RepostedByView.Response {
     })
   }
 
-  private async _fetch() {
-    this._xLoading()
+  private async _fetch(isRefreshing = false) {
+    this._xLoading(isRefreshing)
     await new Promise(r => setTimeout(r, 250)) // DEBUG
     try {
       const res = (await this.rootStore.api.mainPds.view(
diff --git a/src/state/models/user-followers-view.ts b/src/state/models/user-followers-view.ts
new file mode 100644
index 000000000..af10e57d5
--- /dev/null
+++ b/src/state/models/user-followers-view.ts
@@ -0,0 +1,111 @@
+import {makeAutoObservable} from 'mobx'
+import {bsky} from '@adxp/mock-api'
+import {RootStoreModel} from './root-store'
+
+type Subject = bsky.UserFollowersView.Response['subject']
+export type FollowerItem =
+  bsky.UserFollowersView.Response['followers'][number] & {_reactKey: string}
+
+export class UserFollowersViewModel implements bsky.UserFollowersView.Response {
+  // state
+  isLoading = false
+  isRefreshing = false
+  hasLoaded = false
+  error = ''
+  params: bsky.UserFollowersView.Params
+
+  // data
+  subject: Subject = {did: '', name: '', displayName: ''}
+  followers: FollowerItem[] = []
+
+  constructor(
+    public rootStore: RootStoreModel,
+    params: bsky.UserFollowersView.Params,
+  ) {
+    makeAutoObservable(
+      this,
+      {
+        rootStore: false,
+        params: false,
+      },
+      {autoBind: true},
+    )
+    this.params = params
+  }
+
+  get hasContent() {
+    return this.subject.did !== ''
+  }
+
+  get hasError() {
+    return this.error !== ''
+  }
+
+  get isEmpty() {
+    return this.hasLoaded && !this.hasContent
+  }
+
+  // public api
+  // =
+
+  async setup() {
+    await this._fetch()
+  }
+
+  async refresh() {
+    await this._fetch(true)
+  }
+
+  async loadMore() {
+    // TODO
+  }
+
+  // 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 _fetch(isRefreshing = false) {
+    this._xLoading(isRefreshing)
+    await new Promise(r => setTimeout(r, 250)) // DEBUG
+    try {
+      const res = (await this.rootStore.api.mainPds.view(
+        'blueskyweb.xyz:UserFollowersView',
+        this.params,
+      )) as bsky.UserFollowersView.Response
+      this._replaceAll(res)
+      this._xIdle()
+    } catch (e: any) {
+      this._xIdle(`Failed to load feed: ${e.toString()}`)
+    }
+  }
+
+  private _replaceAll(res: bsky.UserFollowersView.Response) {
+    this.subject.did = res.subject.did
+    this.subject.name = res.subject.name
+    this.subject.displayName = res.subject.displayName
+    this.followers.length = 0
+    let counter = 0
+    for (const item of res.followers) {
+      this._append({_reactKey: `item-${counter++}`, ...item})
+    }
+  }
+
+  private _append(item: FollowerItem) {
+    this.followers.push(item)
+  }
+}
diff --git a/src/state/models/user-follows-view.ts b/src/state/models/user-follows-view.ts
new file mode 100644
index 000000000..fd890a662
--- /dev/null
+++ b/src/state/models/user-follows-view.ts
@@ -0,0 +1,112 @@
+import {makeAutoObservable} from 'mobx'
+import {bsky} from '@adxp/mock-api'
+import {RootStoreModel} from './root-store'
+
+type Subject = bsky.UserFollowsView.Response['subject']
+export type FollowItem = bsky.UserFollowsView.Response['follows'][number] & {
+  _reactKey: string
+}
+
+export class UserFollowsViewModel implements bsky.UserFollowsView.Response {
+  // state
+  isLoading = false
+  isRefreshing = false
+  hasLoaded = false
+  error = ''
+  params: bsky.UserFollowsView.Params
+
+  // data
+  subject: Subject = {did: '', name: '', displayName: ''}
+  follows: FollowItem[] = []
+
+  constructor(
+    public rootStore: RootStoreModel,
+    params: bsky.UserFollowsView.Params,
+  ) {
+    makeAutoObservable(
+      this,
+      {
+        rootStore: false,
+        params: false,
+      },
+      {autoBind: true},
+    )
+    this.params = params
+  }
+
+  get hasContent() {
+    return this.subject.did !== ''
+  }
+
+  get hasError() {
+    return this.error !== ''
+  }
+
+  get isEmpty() {
+    return this.hasLoaded && !this.hasContent
+  }
+
+  // public api
+  // =
+
+  async setup() {
+    await this._fetch()
+  }
+
+  async refresh() {
+    await this._fetch(true)
+  }
+
+  async loadMore() {
+    // TODO
+  }
+
+  // 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 _fetch(isRefreshing = false) {
+    this._xLoading(isRefreshing)
+    await new Promise(r => setTimeout(r, 250)) // DEBUG
+    try {
+      const res = (await this.rootStore.api.mainPds.view(
+        'blueskyweb.xyz:UserFollowsView',
+        this.params,
+      )) as bsky.UserFollowsView.Response
+      this._replaceAll(res)
+      this._xIdle()
+    } catch (e: any) {
+      this._xIdle(`Failed to load feed: ${e.toString()}`)
+    }
+  }
+
+  private _replaceAll(res: bsky.UserFollowsView.Response) {
+    this.subject.did = res.subject.did
+    this.subject.name = res.subject.name
+    this.subject.displayName = res.subject.displayName
+    this.follows.length = 0
+    let counter = 0
+    for (const item of res.follows) {
+      this._append({_reactKey: `item-${counter++}`, ...item})
+    }
+  }
+
+  private _append(item: FollowItem) {
+    this.follows.push(item)
+  }
+}
diff --git a/src/view/com/profile/ProfileFollowers.tsx b/src/view/com/profile/ProfileFollowers.tsx
new file mode 100644
index 000000000..06cc0c14d
--- /dev/null
+++ b/src/view/com/profile/ProfileFollowers.tsx
@@ -0,0 +1,141 @@
+import React, {useState, useEffect} from 'react'
+import {observer} from 'mobx-react-lite'
+import {
+  ActivityIndicator,
+  FlatList,
+  Image,
+  StyleSheet,
+  Text,
+  TouchableOpacity,
+  View,
+} from 'react-native'
+import {OnNavigateContent} from '../../routes/types'
+import {
+  UserFollowersViewModel,
+  FollowerItem,
+} from '../../../state/models/user-followers-view'
+import {useStores} from '../../../state'
+import {s} from '../../lib/styles'
+import {AVIS} from '../../lib/assets'
+
+export const ProfileFollowers = observer(function ProfileFollowers({
+  name,
+  onNavigateContent,
+}: {
+  name: string
+  onNavigateContent: OnNavigateContent
+}) {
+  const store = useStores()
+  const [view, setView] = useState<UserFollowersViewModel | undefined>()
+
+  useEffect(() => {
+    if (view?.params.user === name) {
+      console.log('User followers doing nothing')
+      return // no change needed? or trigger refresh?
+    }
+    console.log('Fetching user followers', name)
+    const newView = new UserFollowersViewModel(store, {user: name})
+    setView(newView)
+    newView
+      .setup()
+      .catch(err => console.error('Failed to fetch user followers', err))
+  }, [name, view?.params.user, store])
+
+  // loading
+  // =
+  if (
+    !view ||
+    (view.isLoading && !view.isRefreshing) ||
+    view.params.user !== name
+  ) {
+    return (
+      <View>
+        <ActivityIndicator />
+      </View>
+    )
+  }
+
+  // error
+  // =
+  if (view.hasError) {
+    return (
+      <View>
+        <Text>{view.error}</Text>
+      </View>
+    )
+  }
+
+  // loaded
+  // =
+  const renderItem = ({item}: {item: FollowerItem}) => (
+    <User item={item} onNavigateContent={onNavigateContent} />
+  )
+  return (
+    <View>
+      <FlatList
+        data={view.followers}
+        keyExtractor={item => item._reactKey}
+        renderItem={renderItem}
+      />
+    </View>
+  )
+})
+
+const User = ({
+  item,
+  onNavigateContent,
+}: {
+  item: FollowerItem
+  onNavigateContent: OnNavigateContent
+}) => {
+  const onPressOuter = () => {
+    onNavigateContent('Profile', {
+      name: item.name,
+    })
+  }
+  return (
+    <TouchableOpacity style={styles.outer} onPress={onPressOuter}>
+      <View style={styles.layout}>
+        <View style={styles.layoutAvi}>
+          <Image
+            style={styles.avi}
+            source={AVIS[item.name] || AVIS['alice.com']}
+          />
+        </View>
+        <View style={styles.layoutContent}>
+          <Text style={[s.f15, s.bold]}>{item.displayName}</Text>
+          <Text style={[s.f14, s.gray]}>@{item.name}</Text>
+        </View>
+      </View>
+    </TouchableOpacity>
+  )
+}
+
+const styles = StyleSheet.create({
+  outer: {
+    borderTopWidth: 1,
+    borderTopColor: '#e8e8e8',
+    backgroundColor: '#fff',
+  },
+  layout: {
+    flexDirection: 'row',
+  },
+  layoutAvi: {
+    width: 60,
+    paddingLeft: 10,
+    paddingTop: 10,
+    paddingBottom: 10,
+  },
+  avi: {
+    width: 40,
+    height: 40,
+    borderRadius: 30,
+    resizeMode: 'cover',
+  },
+  layoutContent: {
+    flex: 1,
+    paddingRight: 10,
+    paddingTop: 10,
+    paddingBottom: 10,
+  },
+})
diff --git a/src/view/com/profile/ProfileFollows.tsx b/src/view/com/profile/ProfileFollows.tsx
new file mode 100644
index 000000000..bb5859852
--- /dev/null
+++ b/src/view/com/profile/ProfileFollows.tsx
@@ -0,0 +1,141 @@
+import React, {useState, useEffect} from 'react'
+import {observer} from 'mobx-react-lite'
+import {
+  ActivityIndicator,
+  FlatList,
+  Image,
+  StyleSheet,
+  Text,
+  TouchableOpacity,
+  View,
+} from 'react-native'
+import {OnNavigateContent} from '../../routes/types'
+import {
+  UserFollowsViewModel,
+  FollowItem,
+} from '../../../state/models/user-follows-view'
+import {useStores} from '../../../state'
+import {s} from '../../lib/styles'
+import {AVIS} from '../../lib/assets'
+
+export const ProfileFollows = observer(function ProfileFollows({
+  name,
+  onNavigateContent,
+}: {
+  name: string
+  onNavigateContent: OnNavigateContent
+}) {
+  const store = useStores()
+  const [view, setView] = useState<UserFollowsViewModel | undefined>()
+
+  useEffect(() => {
+    if (view?.params.user === name) {
+      console.log('User follows doing nothing')
+      return // no change needed? or trigger refresh?
+    }
+    console.log('Fetching user follows', name)
+    const newView = new UserFollowsViewModel(store, {user: name})
+    setView(newView)
+    newView
+      .setup()
+      .catch(err => console.error('Failed to fetch user follows', err))
+  }, [name, view?.params.user, store])
+
+  // loading
+  // =
+  if (
+    !view ||
+    (view.isLoading && !view.isRefreshing) ||
+    view.params.user !== name
+  ) {
+    return (
+      <View>
+        <ActivityIndicator />
+      </View>
+    )
+  }
+
+  // error
+  // =
+  if (view.hasError) {
+    return (
+      <View>
+        <Text>{view.error}</Text>
+      </View>
+    )
+  }
+
+  // loaded
+  // =
+  const renderItem = ({item}: {item: FollowItem}) => (
+    <User item={item} onNavigateContent={onNavigateContent} />
+  )
+  return (
+    <View>
+      <FlatList
+        data={view.follows}
+        keyExtractor={item => item._reactKey}
+        renderItem={renderItem}
+      />
+    </View>
+  )
+})
+
+const User = ({
+  item,
+  onNavigateContent,
+}: {
+  item: FollowItem
+  onNavigateContent: OnNavigateContent
+}) => {
+  const onPressOuter = () => {
+    onNavigateContent('Profile', {
+      name: item.name,
+    })
+  }
+  return (
+    <TouchableOpacity style={styles.outer} onPress={onPressOuter}>
+      <View style={styles.layout}>
+        <View style={styles.layoutAvi}>
+          <Image
+            style={styles.avi}
+            source={AVIS[item.name] || AVIS['alice.com']}
+          />
+        </View>
+        <View style={styles.layoutContent}>
+          <Text style={[s.f15, s.bold]}>{item.displayName}</Text>
+          <Text style={[s.f14, s.gray]}>@{item.name}</Text>
+        </View>
+      </View>
+    </TouchableOpacity>
+  )
+}
+
+const styles = StyleSheet.create({
+  outer: {
+    borderTopWidth: 1,
+    borderTopColor: '#e8e8e8',
+    backgroundColor: '#fff',
+  },
+  layout: {
+    flexDirection: 'row',
+  },
+  layoutAvi: {
+    width: 60,
+    paddingLeft: 10,
+    paddingTop: 10,
+    paddingBottom: 10,
+  },
+  avi: {
+    width: 40,
+    height: 40,
+    borderRadius: 30,
+    resizeMode: 'cover',
+  },
+  layoutContent: {
+    flex: 1,
+    paddingRight: 10,
+    paddingTop: 10,
+    paddingBottom: 10,
+  },
+})
diff --git a/src/view/com/profile/ProfileHeader.tsx b/src/view/com/profile/ProfileHeader.tsx
index 1eace94aa..6a6d04140 100644
--- a/src/view/com/profile/ProfileHeader.tsx
+++ b/src/view/com/profile/ProfileHeader.tsx
@@ -6,6 +6,7 @@ import {
   Image,
   StyleSheet,
   Text,
+  TouchableOpacity,
   View,
 } from 'react-native'
 import {OnNavigateContent} from '../../routes/types'
@@ -18,8 +19,8 @@ import Toast from '../util/Toast'
 
 export const ProfileHeader = observer(function ProfileHeader({
   user,
-}: // onNavigateContent,
-{
+  onNavigateContent,
+}: {
   user: string
   onNavigateContent: OnNavigateContent
 }) {
@@ -53,6 +54,12 @@ export const ProfileHeader = observer(function ProfileHeader({
       err => console.error('Failed to toggle follow', err),
     )
   }
+  const onPressFollowers = () => {
+    onNavigateContent('ProfileFollowers', {name: user})
+  }
+  const onPressFollows = () => {
+    onNavigateContent('ProfileFollows', {name: user})
+  }
 
   // loading
   // =
@@ -91,16 +98,18 @@ export const ProfileHeader = observer(function ProfileHeader({
         <Text style={[s.mb5, s.f15, s['lh15-1.3']]}>{view.description}</Text>
       )}
       <View style={s.flexRow}>
-        <View style={[s.flexRow, s.mr10]}>
+        <TouchableOpacity
+          style={[s.flexRow, s.mr10]}
+          onPress={onPressFollowers}>
           <Text style={[s.bold, s.mr2]}>{view.followersCount}</Text>
           <Text style={s.gray}>
             {pluralize(view.followersCount, 'follower')}
           </Text>
-        </View>
-        <View style={[s.flexRow, s.mr10]}>
+        </TouchableOpacity>
+        <TouchableOpacity style={[s.flexRow, s.mr10]} onPress={onPressFollows}>
           <Text style={[s.bold, s.mr2]}>{view.followsCount}</Text>
           <Text style={s.gray}>following</Text>
-        </View>
+        </TouchableOpacity>
         <View style={[s.flexRow, s.mr10]}>
           <Text style={[s.bold, s.mr2]}>{view.postsCount}</Text>
           <Text style={s.gray}>{pluralize(view.postsCount, 'post')}</Text>
diff --git a/src/view/routes/index.tsx b/src/view/routes/index.tsx
index 23340a7fe..675edb3e8 100644
--- a/src/view/routes/index.tsx
+++ b/src/view/routes/index.tsx
@@ -25,6 +25,8 @@ import {PostThread} from '../screens/stacks/PostThread'
 import {PostLikedBy} from '../screens/stacks/PostLikedBy'
 import {PostRepostedBy} from '../screens/stacks/PostRepostedBy'
 import {Profile} from '../screens/stacks/Profile'
+import {ProfileFollowers} from '../screens/stacks/ProfileFollowers'
+import {ProfileFollows} from '../screens/stacks/ProfileFollows'
 
 const linking: LinkingOptions<RootTabsParamList> = {
   prefixes: [
@@ -40,6 +42,8 @@ const linking: LinkingOptions<RootTabsParamList> = {
       NotificationsTab: 'notifications',
       MenuTab: 'menu',
       Profile: 'profile/:name',
+      ProfileFollowers: 'profile/:name/followers',
+      ProfileFollows: 'profile/:name/follows',
       PostThread: 'profile/:name/post/:recordKey',
       PostLikedBy: 'profile/:name/post/:recordKey/liked-by',
       PostRepostedBy: 'profile/:name/post/:recordKey/reposted-by',
@@ -93,6 +97,11 @@ function HomeStackCom() {
       <HomeTabStack.Screen name="Home" component={Home} />
       <HomeTabStack.Screen name="Composer" component={Composer} />
       <HomeTabStack.Screen name="Profile" component={Profile} />
+      <HomeTabStack.Screen
+        name="ProfileFollowers"
+        component={ProfileFollowers}
+      />
+      <HomeTabStack.Screen name="ProfileFollows" component={ProfileFollows} />
       <HomeTabStack.Screen name="PostThread" component={PostThread} />
       <HomeTabStack.Screen name="PostLikedBy" component={PostLikedBy} />
       <HomeTabStack.Screen name="PostRepostedBy" component={PostRepostedBy} />
@@ -109,6 +118,11 @@ function SearchStackCom() {
         options={HIDE_HEADER}
       />
       <SearchTabStack.Screen name="Profile" component={Profile} />
+      <SearchTabStack.Screen
+        name="ProfileFollowers"
+        component={ProfileFollowers}
+      />
+      <SearchTabStack.Screen name="ProfileFollows" component={ProfileFollows} />
       <SearchTabStack.Screen name="PostThread" component={PostThread} />
       <SearchTabStack.Screen name="PostLikedBy" component={PostLikedBy} />
       <SearchTabStack.Screen name="PostRepostedBy" component={PostRepostedBy} />
@@ -124,6 +138,14 @@ function NotificationsStackCom() {
         component={Notifications}
       />
       <NotificationsTabStack.Screen name="Profile" component={Profile} />
+      <NotificationsTabStack.Screen
+        name="ProfileFollowers"
+        component={ProfileFollowers}
+      />
+      <NotificationsTabStack.Screen
+        name="ProfileFollows"
+        component={ProfileFollows}
+      />
       <NotificationsTabStack.Screen name="PostThread" component={PostThread} />
       <NotificationsTabStack.Screen
         name="PostLikedBy"
diff --git a/src/view/routes/types.ts b/src/view/routes/types.ts
index 2a9e1046a..a67f282a6 100644
--- a/src/view/routes/types.ts
+++ b/src/view/routes/types.ts
@@ -6,6 +6,8 @@ export type RootTabsParamList = {
   NotificationsTab: undefined
   MenuTab: undefined
   Profile: {name: string}
+  ProfileFollowers: {name: string}
+  ProfileFollows: {name: string}
   PostThread: {name: string; recordKey: string}
   PostLikedBy: {name: string; recordKey: string}
   PostRepostedBy: {name: string; recordKey: string}
diff --git a/src/view/screens/stacks/ProfileFollowers.tsx b/src/view/screens/stacks/ProfileFollowers.tsx
new file mode 100644
index 000000000..48fbb4e13
--- /dev/null
+++ b/src/view/screens/stacks/ProfileFollowers.tsx
@@ -0,0 +1,39 @@
+import React, {useLayoutEffect} from 'react'
+import {TouchableOpacity} from 'react-native'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {Shell} from '../../shell'
+import type {RootTabsScreenProps} from '../../routes/types'
+import {ProfileFollowers as ProfileFollowersComponent} from '../../com/profile/ProfileFollowers'
+
+export const ProfileFollowers = ({
+  navigation,
+  route,
+}: RootTabsScreenProps<'ProfileFollowers'>) => {
+  const {name} = route.params
+
+  useLayoutEffect(() => {
+    navigation.setOptions({
+      headerShown: true,
+      headerTitle: 'Followers',
+      headerLeft: () => (
+        <TouchableOpacity onPress={() => navigation.goBack()}>
+          <FontAwesomeIcon icon="arrow-left" />
+        </TouchableOpacity>
+      ),
+    })
+  }, [navigation])
+
+  const onNavigateContent = (screen: string, props: Record<string, string>) => {
+    // @ts-ignore it's up to the callers to supply correct params -prf
+    navigation.push(screen, props)
+  }
+
+  return (
+    <Shell>
+      <ProfileFollowersComponent
+        name={name}
+        onNavigateContent={onNavigateContent}
+      />
+    </Shell>
+  )
+}
diff --git a/src/view/screens/stacks/ProfileFollows.tsx b/src/view/screens/stacks/ProfileFollows.tsx
new file mode 100644
index 000000000..6fce3d798
--- /dev/null
+++ b/src/view/screens/stacks/ProfileFollows.tsx
@@ -0,0 +1,39 @@
+import React, {useLayoutEffect} from 'react'
+import {TouchableOpacity} from 'react-native'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {Shell} from '../../shell'
+import type {RootTabsScreenProps} from '../../routes/types'
+import {ProfileFollows as ProfileFollowsComponent} from '../../com/profile/ProfileFollows'
+
+export const ProfileFollows = ({
+  navigation,
+  route,
+}: RootTabsScreenProps<'ProfileFollows'>) => {
+  const {name} = route.params
+
+  useLayoutEffect(() => {
+    navigation.setOptions({
+      headerShown: true,
+      headerTitle: 'Following',
+      headerLeft: () => (
+        <TouchableOpacity onPress={() => navigation.goBack()}>
+          <FontAwesomeIcon icon="arrow-left" />
+        </TouchableOpacity>
+      ),
+    })
+  }, [navigation])
+
+  const onNavigateContent = (screen: string, props: Record<string, string>) => {
+    // @ts-ignore it's up to the callers to supply correct params -prf
+    navigation.push(screen, props)
+  }
+
+  return (
+    <Shell>
+      <ProfileFollowsComponent
+        name={name}
+        onNavigateContent={onNavigateContent}
+      />
+    </Shell>
+  )
+}