diff --git a/src/state/models/members-view.ts b/src/state/models/members-view.ts
new file mode 100644
index 0000000000..b96d4cd01c
--- /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 2ec615a9c9..a9062bf929 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 09f1991e1e..a2919e2e72 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 0000000000..cb58aec3fd
--- /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 (
+
+
+
+
+
+
+ {displayName || handle}
+ @{handle}
+
+
+
+ )
+}
+
+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 9841902838..d1dcd05256 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 ? (
+ onPress={onPressMembers}>
- {view.followsCount}
+ {view.membersCount}
- {pluralize(view.followsCount, 'member')}
+ {pluralize(view.membersCount, 'member')}
) : undefined}
diff --git a/src/view/com/profile/ProfileMembers.tsx b/src/view/com/profile/ProfileMembers.tsx
new file mode 100644
index 0000000000..11db020546
--- /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()
+
+ 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 (
+
+
+
+ )
+ }
+
+ // error
+ // =
+ if (view.hasError) {
+ return (
+
+ {view.error}
+
+ )
+ }
+
+ // loaded
+ // =
+ const renderItem = ({item}: {item: MemberItem}) => (
+
+ )
+ return (
+
+ item._reactKey}
+ renderItem={renderItem}
+ />
+
+ )
+})
diff --git a/src/view/com/util/Selector.tsx b/src/view/com/util/Selector.tsx
index e683106825..06e8cda803 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 a72afe5929..a1f8ab289f 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/(?[^/]+)')],
[ProfileFollowers, 'users', r('/profile/(?[^/]+)/followers')],
[ProfileFollows, 'users', r('/profile/(?[^/]+)/follows')],
+ [ProfileMembers, 'users', r('/profile/(?[^/]+)/members')],
[
PostThread,
['far', 'message'],
diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx
index 6f7281bd90..fce77aac3f 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 = () => Loading...
- } else if (uiState.feed.hasError) {
- items.push({
- _reactKey: '__error__',
- error: uiState.feed.error,
- })
- renderItem = (item: any) => (
-
-
-
- )
- } else if (uiState.currentView.hasContent) {
- items = uiState.feed.feed.slice()
- if (uiState.feed.hasReachedEnd) {
- items.push(END_ITEM)
- }
- renderItem = (item: any) => {
- if (item === END_ITEM) {
- return - end of feed -
- }
- return
- }
- } else if (uiState.currentView.isEmpty) {
- items.push(EMPTY_ITEM)
- renderItem = () => No posts yet!
- }
+ if (uiState.isInitialLoading) {
+ items.push(LOADING_ITEM)
+ renderItem = () => Loading...
+ } else if (uiState.currentView.hasError) {
+ items.push({
+ _reactKey: '__error__',
+ error: uiState.currentView.error,
+ })
+ renderItem = (item: any) => (
+
+
+
+ )
} else {
- items.push(EMPTY_ITEM)
- renderItem = () => TODO
+ 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 - end of feed -
+ }
+ return
+ }
+ } else if (uiState.feed.isEmpty) {
+ items.push(EMPTY_ITEM)
+ renderItem = () => No posts yet!
+ }
+ } else if (uiState.selectedView === Sections.Scenes) {
+ if (uiState.memberships.hasContent) {
+ items = uiState.memberships.memberships.slice()
+ renderItem = (item: any) => {
+ return (
+
+ )
+ }
+ } else if (uiState.memberships.isEmpty) {
+ items.push(EMPTY_ITEM)
+ renderItem = () => No scenes yet!
+ }
+ } else if (uiState.selectedView === Sections.Members) {
+ if (uiState.members.hasContent) {
+ items = uiState.members.members.slice()
+ renderItem = (item: any) => {
+ return (
+
+ )
+ }
+ } else if (uiState.members.isEmpty) {
+ items.push(EMPTY_ITEM)
+ renderItem = () => No members yet!
+ }
+ } else {
+ items.push(EMPTY_ITEM)
+ renderItem = () => TODO
+ }
}
}
if (!renderItem) {
@@ -129,7 +164,7 @@ export const Profile = observer(({visible, params}: ScreenParams) => {
details={uiState.profile.error}
onPressTryAgain={onPressTryAgain}
/>
- ) : (
+ ) : uiState.profile.hasLoaded ? (
{
onRefresh={onRefresh}
onEndReached={onEndReached}
/>
+ ) : (
+ renderHeader()
)}
)
diff --git a/src/view/screens/ProfileMembers.tsx b/src/view/screens/ProfileMembers.tsx
new file mode 100644
index 0000000000..dd2221091c
--- /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 (
+
+
+
+
+ )
+}