diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/state/models/members-view.ts | 110 | ||||
-rw-r--r-- | src/state/models/profile-ui.ts | 31 | ||||
-rw-r--r-- | src/state/models/profile-view.ts | 4 | ||||
-rw-r--r-- | src/view/com/profile/ProfileCard.tsx | 59 | ||||
-rw-r--r-- | src/view/com/profile/ProfileHeader.tsx | 9 | ||||
-rw-r--r-- | src/view/com/profile/ProfileMembers.tsx | 69 | ||||
-rw-r--r-- | src/view/com/util/Selector.tsx | 2 | ||||
-rw-r--r-- | src/view/routes.ts | 2 | ||||
-rw-r--r-- | src/view/screens/Profile.tsx | 105 | ||||
-rw-r--r-- | src/view/screens/ProfileMembers.tsx | 24 |
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> + ) +} |