about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/state/models/members-view.ts110
-rw-r--r--src/state/models/profile-ui.ts31
-rw-r--r--src/state/models/profile-view.ts4
-rw-r--r--src/view/com/profile/ProfileCard.tsx59
-rw-r--r--src/view/com/profile/ProfileHeader.tsx9
-rw-r--r--src/view/com/profile/ProfileMembers.tsx69
-rw-r--r--src/view/com/util/Selector.tsx2
-rw-r--r--src/view/routes.ts2
-rw-r--r--src/view/screens/Profile.tsx105
-rw-r--r--src/view/screens/ProfileMembers.tsx24
10 files changed, 375 insertions, 40 deletions
diff --git a/src/state/models/members-view.ts b/src/state/models/members-view.ts
new file mode 100644
index 000000000..b96d4cd01
--- /dev/null
+++ b/src/state/models/members-view.ts
@@ -0,0 +1,110 @@
+import {makeAutoObservable} from 'mobx'
+import * as GetMembers from '../../third-party/api/src/client/types/app/bsky/graph/getMembers'
+import {RootStoreModel} from './root-store'
+
+type Subject = GetMembers.OutputSchema['subject']
+export type MemberItem = GetMembers.OutputSchema['members'][number] & {
+  _reactKey: string
+}
+
+export class MembersViewModel {
+  // state
+  isLoading = false
+  isRefreshing = false
+  hasLoaded = false
+  error = ''
+  params: GetMembers.QueryParams
+
+  // data
+  subject: Subject = {did: '', handle: '', displayName: ''}
+  members: MemberItem[] = []
+
+  constructor(
+    public rootStore: RootStoreModel,
+    params: GetMembers.QueryParams,
+  ) {
+    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)
+    try {
+      const res = await this.rootStore.api.app.bsky.graph.getMembers(
+        this.params,
+      )
+      this._replaceAll(res)
+      this._xIdle()
+    } catch (e: any) {
+      this._xIdle(`Failed to load feed: ${e.toString()}`)
+    }
+  }
+
+  private _replaceAll(res: GetMembers.Response) {
+    this.subject.did = res.data.subject.did
+    this.subject.handle = res.data.subject.handle
+    this.subject.displayName = res.data.subject.displayName
+    this.members.length = 0
+    let counter = 0
+    for (const item of res.data.members) {
+      this._append({_reactKey: `item-${counter++}`, ...item})
+    }
+  }
+
+  private _append(item: MemberItem) {
+    this.members.push(item)
+  }
+}
diff --git a/src/state/models/profile-ui.ts b/src/state/models/profile-ui.ts
index 2ec615a9c..a9062bf92 100644
--- a/src/state/models/profile-ui.ts
+++ b/src/state/models/profile-ui.ts
@@ -1,6 +1,8 @@
 import {makeAutoObservable} from 'mobx'
 import {RootStoreModel} from './root-store'
 import {ProfileViewModel} from './profile-view'
+import {MembersViewModel} from './members-view'
+import {MembershipsViewModel} from './memberships-view'
 import {FeedModel} from './feed-view'
 
 export enum Sections {
@@ -21,6 +23,8 @@ export class ProfileUiModel {
   // data
   profile: ProfileViewModel
   feed: FeedModel
+  memberships: MembershipsViewModel
+  members: MembersViewModel
 
   // ui state
   selectedViewIndex = 0
@@ -42,15 +46,23 @@ export class ProfileUiModel {
       author: params.user,
       limit: 10,
     })
+    this.memberships = new MembershipsViewModel(rootStore, {actor: params.user})
+    this.members = new MembersViewModel(rootStore, {actor: params.user})
   }
 
-  get currentView(): FeedModel {
+  get currentView(): FeedModel | MembershipsViewModel | MembersViewModel {
     if (
       this.selectedView === Sections.Posts ||
       this.selectedView === Sections.Trending
     ) {
       return this.feed
     }
+    if (this.selectedView === Sections.Scenes) {
+      return this.memberships
+    }
+    if (this.selectedView === Sections.Members) {
+      return this.members
+    }
     throw new Error(`Invalid selector value: ${this.selectedViewIndex}`)
   }
 
@@ -101,10 +113,25 @@ export class ProfileUiModel {
         .setup()
         .catch(err => console.error('Failed to fetch feed', err)),
     ])
+    if (this.isUser) {
+      await this.memberships
+        .setup()
+        .catch(err => console.error('Failed to fetch members', err))
+    }
+    if (this.isScene) {
+      await this.members
+        .setup()
+        .catch(err => console.error('Failed to fetch members', err))
+    }
   }
 
   async update() {
-    await this.currentView.update()
+    const view = this.currentView
+    if (view instanceof FeedModel) {
+      await view.update()
+    } else {
+      await view.refresh()
+    }
   }
 
   async refresh() {
diff --git a/src/state/models/profile-view.ts b/src/state/models/profile-view.ts
index 09f1991e1..a2919e2e7 100644
--- a/src/state/models/profile-view.ts
+++ b/src/state/models/profile-view.ts
@@ -31,6 +31,7 @@ export class ProfileViewModel {
   description?: string
   followersCount: number = 0
   followsCount: number = 0
+  membersCount: number = 0
   postsCount: number = 0
   myState = new ProfileViewMyStateModel()
 
@@ -140,12 +141,15 @@ export class ProfileViewModel {
   }
 
   private _replaceAll(res: GetProfile.Response) {
+    console.log(res.data)
     this.did = res.data.did
     this.handle = res.data.handle
+    this.actorType = res.data.actorType
     this.displayName = res.data.displayName
     this.description = res.data.description
     this.followersCount = res.data.followersCount
     this.followsCount = res.data.followsCount
+    this.membersCount = res.data.membersCount
     this.postsCount = res.data.postsCount
     if (res.data.myState) {
       Object.assign(this.myState, res.data.myState)
diff --git a/src/view/com/profile/ProfileCard.tsx b/src/view/com/profile/ProfileCard.tsx
new file mode 100644
index 000000000..cb58aec3f
--- /dev/null
+++ b/src/view/com/profile/ProfileCard.tsx
@@ -0,0 +1,59 @@
+import React from 'react'
+import {StyleSheet, Text, View} from 'react-native'
+import {Link} from '../util/Link'
+import {UserAvatar} from '../util/UserAvatar'
+import {s, colors} from '../../lib/styles'
+
+export function ProfileCard({
+  did,
+  handle,
+  displayName,
+  description,
+}: {
+  did: string
+  handle: string
+  displayName?: string
+  description?: string
+}) {
+  return (
+    <Link style={styles.outer} href={`/profile/${handle}`} title={handle}>
+      <View style={styles.layout}>
+        <View style={styles.layoutAvi}>
+          <UserAvatar size={40} displayName={displayName} handle={handle} />
+        </View>
+        <View style={styles.layoutContent}>
+          <Text style={[s.f16, s.bold]}>{displayName || handle}</Text>
+          <Text style={[s.f15, s.gray5]}>@{handle}</Text>
+        </View>
+      </View>
+    </Link>
+  )
+}
+
+const styles = StyleSheet.create({
+  outer: {
+    marginTop: 1,
+    backgroundColor: colors.white,
+  },
+  layout: {
+    flexDirection: 'row',
+  },
+  layoutAvi: {
+    width: 60,
+    paddingLeft: 10,
+    paddingTop: 10,
+    paddingBottom: 10,
+  },
+  avi: {
+    width: 40,
+    height: 40,
+    borderRadius: 20,
+    resizeMode: 'cover',
+  },
+  layoutContent: {
+    flex: 1,
+    paddingRight: 10,
+    paddingTop: 12,
+    paddingBottom: 10,
+  },
+})
diff --git a/src/view/com/profile/ProfileHeader.tsx b/src/view/com/profile/ProfileHeader.tsx
index 984190283..d1dcd0525 100644
--- a/src/view/com/profile/ProfileHeader.tsx
+++ b/src/view/com/profile/ProfileHeader.tsx
@@ -57,6 +57,9 @@ export const ProfileHeader = observer(function ProfileHeader({
   const onPressFollows = () => {
     store.nav.navigate(`/profile/${view.handle}/follows`)
   }
+  const onPressMembers = () => {
+    store.nav.navigate(`/profile/${view.handle}/members`)
+  }
 
   // loading
   // =
@@ -173,12 +176,12 @@ export const ProfileHeader = observer(function ProfileHeader({
           {view.isScene ? (
             <TouchableOpacity
               style={[s.flexRow, s.mr10]}
-              onPress={onPressFollows}>
+              onPress={onPressMembers}>
               <Text style={[s.bold, s.mr2, styles.metricsText]}>
-                {view.followsCount}
+                {view.membersCount}
               </Text>
               <Text style={[s.gray5, styles.metricsText]}>
-                {pluralize(view.followsCount, 'member')}
+                {pluralize(view.membersCount, 'member')}
               </Text>
             </TouchableOpacity>
           ) : undefined}
diff --git a/src/view/com/profile/ProfileMembers.tsx b/src/view/com/profile/ProfileMembers.tsx
new file mode 100644
index 000000000..11db02054
--- /dev/null
+++ b/src/view/com/profile/ProfileMembers.tsx
@@ -0,0 +1,69 @@
+import React, {useState, useEffect} from 'react'
+import {observer} from 'mobx-react-lite'
+import {ActivityIndicator, FlatList, Text, View} from 'react-native'
+import {MembersViewModel, MemberItem} from '../../../state/models/members-view'
+import {ProfileCard} from './ProfileCard'
+import {useStores} from '../../../state'
+
+export const ProfileMembers = observer(function ProfileMembers({
+  name,
+}: {
+  name: string
+}) {
+  const store = useStores()
+  const [view, setView] = useState<MembersViewModel | undefined>()
+
+  useEffect(() => {
+    if (view?.params.actor === name) {
+      console.log('Members doing nothing')
+      return // no change needed? or trigger refresh?
+    }
+    console.log('Fetching members', name)
+    const newView = new MembersViewModel(store, {actor: name})
+    setView(newView)
+    newView.setup().catch(err => console.error('Failed to fetch members', err))
+  }, [name, view?.params.actor, store])
+
+  // loading
+  // =
+  if (
+    !view ||
+    (view.isLoading && !view.isRefreshing) ||
+    view.params.actor !== name
+  ) {
+    return (
+      <View>
+        <ActivityIndicator />
+      </View>
+    )
+  }
+
+  // error
+  // =
+  if (view.hasError) {
+    return (
+      <View>
+        <Text>{view.error}</Text>
+      </View>
+    )
+  }
+
+  // loaded
+  // =
+  const renderItem = ({item}: {item: MemberItem}) => (
+    <ProfileCard
+      did={item.did}
+      handle={item.handle}
+      displayName={item.displayName}
+    />
+  )
+  return (
+    <View>
+      <FlatList
+        data={view.members}
+        keyExtractor={item => item._reactKey}
+        renderItem={renderItem}
+      />
+    </View>
+  )
+})
diff --git a/src/view/com/util/Selector.tsx b/src/view/com/util/Selector.tsx
index e68310682..06e8cda80 100644
--- a/src/view/com/util/Selector.tsx
+++ b/src/view/com/util/Selector.tsx
@@ -41,7 +41,7 @@ export function Selector({
       width: middle.width,
     }
     return [left, middle, right]
-  }, [selectedIndex, itemLayouts])
+  }, [selectedIndex, items, itemLayouts])
 
   const interp = swipeGestureInterp || DEFAULT_SWIPE_GESTURE_INTERP
   const underlinePos = useAnimatedStyle(() => {
diff --git a/src/view/routes.ts b/src/view/routes.ts
index a72afe592..a1f8ab289 100644
--- a/src/view/routes.ts
+++ b/src/view/routes.ts
@@ -13,6 +13,7 @@ import {PostRepostedBy} from './screens/PostRepostedBy'
 import {Profile} from './screens/Profile'
 import {ProfileFollowers} from './screens/ProfileFollowers'
 import {ProfileFollows} from './screens/ProfileFollows'
+import {ProfileMembers} from './screens/ProfileMembers'
 import {Settings} from './screens/Settings'
 
 export type ScreenParams = {
@@ -37,6 +38,7 @@ export const routes: Route[] = [
   [Profile, ['far', 'user'], r('/profile/(?<name>[^/]+)')],
   [ProfileFollowers, 'users', r('/profile/(?<name>[^/]+)/followers')],
   [ProfileFollows, 'users', r('/profile/(?<name>[^/]+)/follows')],
+  [ProfileMembers, 'users', r('/profile/(?<name>[^/]+)/members')],
   [
     PostThread,
     ['far', 'message'],
diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx
index 6f7281bd9..fce77aac3 100644
--- a/src/view/screens/Profile.tsx
+++ b/src/view/screens/Profile.tsx
@@ -7,6 +7,7 @@ import {ProfileUiModel, Sections} from '../../state/models/profile-ui'
 import {useStores} from '../../state'
 import {ProfileHeader} from '../com/profile/ProfileHeader'
 import {FeedItem} from '../com/posts/FeedItem'
+import {ProfileCard} from '../com/profile/ProfileCard'
 import {ErrorScreen} from '../com/util/ErrorScreen'
 import {ErrorMessage} from '../com/util/ErrorMessage'
 import {s, colors} from '../lib/styles'
@@ -76,44 +77,78 @@ export const Profile = observer(({visible, params}: ScreenParams) => {
   let renderItem
   let items: any[] = []
   if (uiState) {
-    if (
-      uiState.selectedView === Sections.Posts ||
-      uiState.selectedView === Sections.Trending
-    ) {
-      if (uiState.isInitialLoading) {
-        items.push(LOADING_ITEM)
-        renderItem = () => <Text style={styles.loading}>Loading...</Text>
-      } else if (uiState.feed.hasError) {
-        items.push({
-          _reactKey: '__error__',
-          error: uiState.feed.error,
-        })
-        renderItem = (item: any) => (
-          <View style={s.p5}>
-            <ErrorMessage
-              message={item.error}
-              onPressTryAgain={onPressTryAgain}
-            />
-          </View>
-        )
-      } else if (uiState.currentView.hasContent) {
-        items = uiState.feed.feed.slice()
-        if (uiState.feed.hasReachedEnd) {
-          items.push(END_ITEM)
+    if (uiState.isInitialLoading) {
+      items.push(LOADING_ITEM)
+      renderItem = () => <Text style={styles.loading}>Loading...</Text>
+    } else if (uiState.currentView.hasError) {
+      items.push({
+        _reactKey: '__error__',
+        error: uiState.currentView.error,
+      })
+      renderItem = (item: any) => (
+        <View style={s.p5}>
+          <ErrorMessage
+            message={item.error}
+            onPressTryAgain={onPressTryAgain}
+          />
+        </View>
+      )
+    } else {
+      if (
+        uiState.selectedView === Sections.Posts ||
+        uiState.selectedView === Sections.Trending
+      ) {
+        if (uiState.feed.hasContent) {
+          items = uiState.feed.feed.slice()
+          if (uiState.feed.hasReachedEnd) {
+            items.push(END_ITEM)
+          }
+          renderItem = (item: any) => {
+            if (item === END_ITEM) {
+              return <Text style={styles.endItem}>- end of feed -</Text>
+            }
+            return <FeedItem item={item} />
+          }
+        } else if (uiState.feed.isEmpty) {
+          items.push(EMPTY_ITEM)
+          renderItem = () => <Text style={styles.loading}>No posts yet!</Text>
         }
-        renderItem = (item: any) => {
-          if (item === END_ITEM) {
-            return <Text style={styles.endItem}>- end of feed -</Text>
+      } else if (uiState.selectedView === Sections.Scenes) {
+        if (uiState.memberships.hasContent) {
+          items = uiState.memberships.memberships.slice()
+          renderItem = (item: any) => {
+            return (
+              <ProfileCard
+                did={item.did}
+                handle={item.handle}
+                displayName={item.displayName}
+              />
+            )
           }
-          return <FeedItem item={item} />
+        } else if (uiState.memberships.isEmpty) {
+          items.push(EMPTY_ITEM)
+          renderItem = () => <Text style={styles.loading}>No scenes yet!</Text>
         }
-      } else if (uiState.currentView.isEmpty) {
+      } else if (uiState.selectedView === Sections.Members) {
+        if (uiState.members.hasContent) {
+          items = uiState.members.members.slice()
+          renderItem = (item: any) => {
+            return (
+              <ProfileCard
+                did={item.did}
+                handle={item.handle}
+                displayName={item.displayName}
+              />
+            )
+          }
+        } else if (uiState.members.isEmpty) {
+          items.push(EMPTY_ITEM)
+          renderItem = () => <Text style={styles.loading}>No members yet!</Text>
+        }
+      } else {
         items.push(EMPTY_ITEM)
-        renderItem = () => <Text style={styles.loading}>No posts yet!</Text>
+        renderItem = () => <Text>TODO</Text>
       }
-    } else {
-      items.push(EMPTY_ITEM)
-      renderItem = () => <Text>TODO</Text>
     }
   }
   if (!renderItem) {
@@ -129,7 +164,7 @@ export const Profile = observer(({visible, params}: ScreenParams) => {
           details={uiState.profile.error}
           onPressTryAgain={onPressTryAgain}
         />
-      ) : (
+      ) : uiState.profile.hasLoaded ? (
         <ViewSelector
           sections={uiState.selectorItems}
           items={items}
@@ -140,6 +175,8 @@ export const Profile = observer(({visible, params}: ScreenParams) => {
           onRefresh={onRefresh}
           onEndReached={onEndReached}
         />
+      ) : (
+        renderHeader()
       )}
     </View>
   )
diff --git a/src/view/screens/ProfileMembers.tsx b/src/view/screens/ProfileMembers.tsx
new file mode 100644
index 000000000..dd2221091
--- /dev/null
+++ b/src/view/screens/ProfileMembers.tsx
@@ -0,0 +1,24 @@
+import React, {useEffect} from 'react'
+import {View} from 'react-native'
+import {ViewHeader} from '../com/util/ViewHeader'
+import {ProfileMembers as ProfileMembersComponent} from '../com/profile/ProfileMembers'
+import {ScreenParams} from '../routes'
+import {useStores} from '../../state'
+
+export const ProfileMembers = ({visible, params}: ScreenParams) => {
+  const store = useStores()
+  const {name} = params
+
+  useEffect(() => {
+    if (visible) {
+      store.nav.setTitle(`Members of ${name}`)
+    }
+  }, [store, visible, name])
+
+  return (
+    <View>
+      <ViewHeader title="Members" subtitle={`of ${name}`} />
+      <ProfileMembersComponent name={name} />
+    </View>
+  )
+}