Add actor types to the profiles and clean up the UI

zio/stable
Paul Frazee 2022-11-05 11:58:48 -05:00
parent 3f730f1173
commit 60b1c53d85
4 changed files with 217 additions and 140 deletions

View File

@ -3,19 +3,21 @@ import {RootStoreModel} from './root-store'
import {ProfileViewModel} from './profile-view' import {ProfileViewModel} from './profile-view'
import {FeedModel} from './feed-view' import {FeedModel} from './feed-view'
export const SECTION_IDS = { export enum Sections {
POSTS: 0, Posts = 'Posts',
BADGES: 1, Scenes = 'Scenes',
Trending = 'Trending',
Members = 'Members',
} }
const USER_SELECTOR_ITEMS = [Sections.Posts, Sections.Scenes]
const SCENE_SELECTOR_ITEMS = [Sections.Trending, Sections.Members]
export interface ProfileUiParams { export interface ProfileUiParams {
user: string user: string
} }
export class ProfileUiModel { export class ProfileUiModel {
// constants
static SELECTOR_ITEMS = ['Posts', 'Scenes']
// data // data
profile: ProfileViewModel profile: ProfileViewModel
feed: FeedModel feed: FeedModel
@ -43,7 +45,10 @@ export class ProfileUiModel {
} }
get currentView(): FeedModel { get currentView(): FeedModel {
if (this.selectedViewIndex === SECTION_IDS.POSTS) { if (
this.selectedView === Sections.Posts ||
this.selectedView === Sections.Trending
) {
return this.feed return this.feed
} }
throw new Error(`Invalid selector value: ${this.selectedViewIndex}`) throw new Error(`Invalid selector value: ${this.selectedViewIndex}`)
@ -58,6 +63,28 @@ export class ProfileUiModel {
return this.profile.isRefreshing || this.currentView.isRefreshing return this.profile.isRefreshing || this.currentView.isRefreshing
} }
get isUser() {
return this.profile.isUser
}
get isScene() {
return this.profile.isScene
}
get selectorItems() {
if (this.isUser) {
return USER_SELECTOR_ITEMS
} else if (this.isScene) {
return SCENE_SELECTOR_ITEMS
} else {
return USER_SELECTOR_ITEMS
}
}
get selectedView() {
return this.selectorItems[this.selectedViewIndex]
}
// public api // public api
// = // =

View File

@ -4,6 +4,9 @@ import * as Profile from '../../third-party/api/src/client/types/app/bsky/actor/
import {RootStoreModel} from './root-store' import {RootStoreModel} from './root-store'
import * as apilib from '../lib/api' import * as apilib from '../lib/api'
export const ACTOR_TYPE_USER = 'app.bsky.system.actorUser'
export const ACTOR_TYPE_SCENE = 'app.bsky.system.actorScene'
export class ProfileViewMyStateModel { export class ProfileViewMyStateModel {
follow?: string follow?: string
@ -23,6 +26,7 @@ export class ProfileViewModel {
// data // data
did: string = '' did: string = ''
handle: string = '' handle: string = ''
actorType = ACTOR_TYPE_USER
displayName?: string displayName?: string
description?: string description?: string
followersCount: number = 0 followersCount: number = 0
@ -57,6 +61,14 @@ export class ProfileViewModel {
return this.hasLoaded && !this.hasContent return this.hasLoaded && !this.hasContent
} }
get isUser() {
return this.actorType === ACTOR_TYPE_USER
}
get isScene() {
return this.actorType === ACTOR_TYPE_SCENE
}
// public api // public api
// = // =

View File

@ -102,9 +102,88 @@ export const ProfileHeader = observer(function ProfileHeader({
/> />
</View> </View>
<View style={styles.content}> <View style={styles.content}>
<View style={[styles.displayNameLine]}> <View style={[styles.buttonsLine]}>
{isMe ? (
<TouchableOpacity
onPress={onPressEditProfile}
style={[styles.btn, styles.mainBtn]}>
<Text style={[s.fw400, s.f14]}>Edit Profile</Text>
</TouchableOpacity>
) : (
<>
{view.myState.follow ? (
<TouchableOpacity
onPress={onPressToggleFollow}
style={[styles.btn, styles.mainBtn]}>
<FontAwesomeIcon icon="check" style={[s.mr5]} size={14} />
<Text style={[s.fw400, s.f14]}>Following</Text>
</TouchableOpacity>
) : (
<TouchableOpacity onPress={onPressToggleFollow}>
<LinearGradient
colors={[gradients.primary.start, gradients.primary.end]}
start={{x: 0, y: 0}}
end={{x: 1, y: 1}}
style={[styles.btn, styles.gradientBtn]}>
<FontAwesomeIcon icon="plus" style={[s.white, s.mr5]} />
<Text style={[s.white, s.fw600, s.f16]}>Follow</Text>
</LinearGradient>
</TouchableOpacity>
)}
</>
)}
<TouchableOpacity
onPress={onPressMenu}
style={[styles.btn, styles.secondaryBtn]}>
<FontAwesomeIcon icon="ellipsis" style={[s.gray5]} />
</TouchableOpacity>
</View>
<View style={styles.displayNameLine}>
<Text style={styles.displayName}>{view.displayName}</Text> <Text style={styles.displayName}>{view.displayName}</Text>
</View> </View>
<View style={styles.handleLine}>
{view.isScene ? (
<View style={styles.typeLabelWrapper}>
<Text style={styles.typeLabel}>Scene</Text>
</View>
) : undefined}
<Text style={styles.handle}>@{view.handle}</Text>
</View>
<View style={styles.metricsLine}>
<TouchableOpacity
style={[s.flexRow, s.mr10]}
onPress={onPressFollowers}>
<Text style={[s.bold, s.mr2]}>{view.followersCount}</Text>
<Text style={s.gray5}>
{pluralize(view.followersCount, 'follower')}
</Text>
</TouchableOpacity>
{view.isUser ? (
<TouchableOpacity
style={[s.flexRow, s.mr10]}
onPress={onPressFollows}>
<Text style={[s.bold, s.mr2]}>{view.followsCount}</Text>
<Text style={s.gray5}>following</Text>
</TouchableOpacity>
) : undefined}
{view.isScene ? (
<TouchableOpacity
style={[s.flexRow, s.mr10]}
onPress={onPressFollows}>
<Text style={[s.bold, s.mr2]}>{view.followsCount}</Text>
<Text style={s.gray5}>
{pluralize(view.followsCount, 'member')}
</Text>
</TouchableOpacity>
) : undefined}
<View style={[s.flexRow, s.mr10]}>
<Text style={[s.bold, s.mr2]}>{view.postsCount}</Text>
<Text style={s.gray5}>{pluralize(view.postsCount, 'post')}</Text>
</View>
</View>
{view.description && (
<Text style={[s.mb5, s.f15, s['lh15-1.3']]}>{view.description}</Text>
)}
{ {
undefined /*<View style={styles.badgesLine}> undefined /*<View style={styles.badgesLine}>
<FontAwesomeIcon icon="shield" style={s.mr5} size={12} /> <FontAwesomeIcon icon="shield" style={s.mr5} size={12} />
@ -115,71 +194,6 @@ export const ProfileHeader = observer(function ProfileHeader({
</Link> </Link>
</View>*/ </View>*/
} }
<View style={[styles.buttonsLine]}>
{isMe ? (
<TouchableOpacity
onPress={onPressEditProfile}
style={[styles.mainBtn, styles.btn]}>
<Text style={[s.fw400, s.f14]}>Edit Profile</Text>
</TouchableOpacity>
) : (
<>
{view.myState.follow ? (
<TouchableOpacity
onPress={onPressToggleFollow}
style={[styles.mainBtn, styles.btn]}>
<FontAwesomeIcon icon="check" style={[s.mr5]} size={14} />
<Text style={[s.fw400, s.f14]}>Following</Text>
</TouchableOpacity>
) : (
<TouchableOpacity
onPress={onPressToggleFollow}
style={[styles.mainBtn, styles.btn]}>
<FontAwesomeIcon icon="rss" style={[s.mr5]} size={13} />
<Text style={[s.fw400, s.f14]}>Follow</Text>
</TouchableOpacity>
)}
<TouchableOpacity
onPress={onPressMenu}
style={[styles.btn, styles.secondaryBtn, s.mr5]}>
<FontAwesomeIcon icon="user-plus" style={[s.gray5]} />
</TouchableOpacity>
<TouchableOpacity
onPress={onPressMenu}
style={[styles.btn, styles.secondaryBtn, s.mr5]}>
<FontAwesomeIcon icon="note-sticky" style={[s.gray5]} />
</TouchableOpacity>
</>
)}
<TouchableOpacity
onPress={onPressMenu}
style={[styles.btn, styles.secondaryBtn]}>
<FontAwesomeIcon icon="ellipsis" style={[s.gray5]} />
</TouchableOpacity>
</View>
<View style={[s.flexRow]}>
<TouchableOpacity
style={[s.flexRow, s.mr10]}
onPress={onPressFollowers}>
<Text style={[s.bold, s.mr2]}>{view.followersCount}</Text>
<Text style={s.gray5}>
{pluralize(view.followersCount, 'follower')}
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[s.flexRow, s.mr10]}
onPress={onPressFollows}>
<Text style={[s.bold, s.mr2]}>{view.followsCount}</Text>
<Text style={s.gray5}>following</Text>
</TouchableOpacity>
<View style={[s.flexRow, s.mr10]}>
<Text style={[s.bold, s.mr2]}>{view.postsCount}</Text>
<Text style={s.gray5}>{pluralize(view.postsCount, 'post')}</Text>
</View>
</View>
{view.description && (
<Text style={[s.mt10, s.f15, s['lh15-1.3']]}>{view.description}</Text>
)}
</View> </View>
</View> </View>
) )
@ -222,46 +236,70 @@ const styles = StyleSheet.create({
paddingHorizontal: 14, paddingHorizontal: 14,
paddingBottom: 4, paddingBottom: 4,
}, },
buttonsLine: {
flexDirection: 'row',
marginLeft: 'auto',
marginBottom: 12,
},
gradientBtn: {
paddingHorizontal: 24,
paddingVertical: 6,
},
mainBtn: {
paddingHorizontal: 24,
},
secondaryBtn: {
paddingHorizontal: 14,
},
btn: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 7,
borderRadius: 50,
backgroundColor: colors.gray1,
marginLeft: 6,
},
displayNameLine: { displayNameLine: {
paddingLeft: 86, // paddingLeft: 86,
marginBottom: 14, // marginBottom: 14,
}, },
displayName: { displayName: {
fontSize: 24, fontSize: 24,
fontWeight: 'bold', fontWeight: 'bold',
}, },
handleLine: {
flexDirection: 'row',
marginBottom: 8,
},
handle: {
fontSize: 14,
fontWeight: 'bold',
color: colors.gray5,
},
typeLabelWrapper: {
backgroundColor: colors.gray1,
paddingHorizontal: 4,
borderRadius: 4,
marginRight: 5,
},
typeLabel: {
fontSize: 14,
fontWeight: 'bold',
color: colors.gray5,
},
metricsLine: {
flexDirection: 'row',
marginBottom: 8,
},
badgesLine: { badgesLine: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
marginBottom: 10, marginBottom: 10,
}, },
buttonsLine: {
flexDirection: 'row',
marginBottom: 12,
},
followBtn: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 6,
borderRadius: 6,
marginRight: 6,
},
btn: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 7,
borderRadius: 4,
backgroundColor: colors.gray1,
marginRight: 6,
},
mainBtn: {
flexDirection: 'row',
},
secondaryBtn: {
flex: 0,
paddingHorizontal: 14,
marginRight: 0,
},
}) })

View File

@ -1,9 +1,9 @@
import React, {useEffect, useState} from 'react' import React, {useEffect, useState, useMemo} from 'react'
import {StyleSheet, Text, View} from 'react-native' import {StyleSheet, Text, View} from 'react-native'
import {observer} from 'mobx-react-lite' import {observer} from 'mobx-react-lite'
import {ViewSelector} from '../com/util/ViewSelector' import {ViewSelector} from '../com/util/ViewSelector'
import {ScreenParams} from '../routes' import {ScreenParams} from '../routes'
import {ProfileUiModel, SECTION_IDS} from '../../state/models/profile-ui' import {ProfileUiModel, Sections} from '../../state/models/profile-ui'
import {useStores} from '../../state' import {useStores} from '../../state'
import {ProfileHeader} from '../com/profile/ProfileHeader' import {ProfileHeader} from '../com/profile/ProfileHeader'
import {FeedItem} from '../com/posts/FeedItem' import {FeedItem} from '../com/posts/FeedItem'
@ -18,25 +18,23 @@ const EMPTY_ITEM = {_reactKey: '__empty__'}
export const Profile = observer(({visible, params}: ScreenParams) => { export const Profile = observer(({visible, params}: ScreenParams) => {
const store = useStores() const store = useStores()
const [hasSetup, setHasSetup] = useState<boolean>(false) const [hasSetup, setHasSetup] = useState<boolean>(false)
const [profileUiState, setProfileUiState] = useState< const uiState = useMemo(
ProfileUiModel | undefined () => new ProfileUiModel(store, {user: params.name}),
>() [params.user],
)
useEffect(() => { useEffect(() => {
let aborted = false let aborted = false
if (!visible) { if (!visible) {
return return
} }
const user = params.name
if (hasSetup) { if (hasSetup) {
console.log('Updating profile for', user) console.log('Updating profile for', params.name)
profileUiState?.update() uiState.update()
} else { } else {
console.log('Fetching profile for', user) console.log('Fetching profile for', params.name)
store.nav.setTitle(user) store.nav.setTitle(params.name)
const newProfileUiState = new ProfileUiModel(store, {user}) uiState.setup().then(() => {
setProfileUiState(newProfileUiState)
newProfileUiState.setup().then(() => {
if (aborted) return if (aborted) return
setHasSetup(true) setHasSetup(true)
}) })
@ -50,42 +48,45 @@ export const Profile = observer(({visible, params}: ScreenParams) => {
// = // =
const onSelectView = (index: number) => { const onSelectView = (index: number) => {
profileUiState?.setSelectedViewIndex(index) uiState.setSelectedViewIndex(index)
} }
const onRefresh = () => { const onRefresh = () => {
profileUiState uiState
?.refresh() .refresh()
.catch((err: any) => console.error('Failed to refresh', err)) .catch((err: any) => console.error('Failed to refresh', err))
} }
const onEndReached = () => { const onEndReached = () => {
profileUiState uiState
?.loadMore() .loadMore()
.catch((err: any) => console.error('Failed to load more', err)) .catch((err: any) => console.error('Failed to load more', err))
} }
const onPressTryAgain = () => { const onPressTryAgain = () => {
profileUiState?.setup() uiState.setup()
} }
// rendering // rendering
// = // =
const renderHeader = () => { const renderHeader = () => {
if (!profileUiState) { if (!uiState) {
return <View /> return <View />
} }
return <ProfileHeader view={profileUiState.profile} /> return <ProfileHeader view={uiState.profile} />
} }
let renderItem let renderItem
let items: any[] = [] let items: any[] = []
if (profileUiState) { if (uiState) {
if (profileUiState.selectedViewIndex === SECTION_IDS.POSTS) { if (
if (profileUiState.isInitialLoading) { uiState.selectedView === Sections.Posts ||
uiState.selectedView === Sections.Trending
) {
if (uiState.isInitialLoading) {
items.push(LOADING_ITEM) items.push(LOADING_ITEM)
renderItem = () => <Text style={styles.loading}>Loading...</Text> renderItem = () => <Text style={styles.loading}>Loading...</Text>
} else if (profileUiState.feed.hasError) { } else if (uiState.feed.hasError) {
items.push({ items.push({
_reactKey: '__error__', _reactKey: '__error__',
error: profileUiState.feed.error, error: uiState.feed.error,
}) })
renderItem = (item: any) => ( renderItem = (item: any) => (
<View style={s.p5}> <View style={s.p5}>
@ -95,9 +96,9 @@ export const Profile = observer(({visible, params}: ScreenParams) => {
/> />
</View> </View>
) )
} else if (profileUiState.currentView.hasContent) { } else if (uiState.currentView.hasContent) {
items = profileUiState.feed.feed.slice() items = uiState.feed.feed.slice()
if (profileUiState.feed.hasReachedEnd) { if (uiState.feed.hasReachedEnd) {
items.push(END_ITEM) items.push(END_ITEM)
} }
renderItem = (item: any) => { renderItem = (item: any) => {
@ -106,12 +107,11 @@ export const Profile = observer(({visible, params}: ScreenParams) => {
} }
return <FeedItem item={item} /> return <FeedItem item={item} />
} }
} else if (profileUiState.currentView.isEmpty) { } else if (uiState.currentView.isEmpty) {
items.push(EMPTY_ITEM) items.push(EMPTY_ITEM)
renderItem = () => <Text style={styles.loading}>No posts yet!</Text> renderItem = () => <Text style={styles.loading}>No posts yet!</Text>
} }
} } else {
if (profileUiState.selectedViewIndex === SECTION_IDS.BADGES) {
items.push(EMPTY_ITEM) items.push(EMPTY_ITEM)
renderItem = () => <Text>TODO</Text> renderItem = () => <Text>TODO</Text>
} }
@ -122,20 +122,20 @@ export const Profile = observer(({visible, params}: ScreenParams) => {
return ( return (
<View style={styles.container}> <View style={styles.container}>
{profileUiState?.profile.hasError ? ( {uiState.profile.hasError ? (
<ErrorScreen <ErrorScreen
title="Failed to load profile" title="Failed to load profile"
message={`There was an issue when attempting to load ${params.name}`} message={`There was an issue when attempting to load ${params.name}`}
details={profileUiState.profile.error} details={uiState.profile.error}
onPressTryAgain={onPressTryAgain} onPressTryAgain={onPressTryAgain}
/> />
) : ( ) : (
<ViewSelector <ViewSelector
sections={ProfileUiModel.SELECTOR_ITEMS} sections={uiState.selectorItems}
items={items} items={items}
renderHeader={renderHeader} renderHeader={renderHeader}
renderItem={renderItem} renderItem={renderItem}
refreshing={profileUiState?.isRefreshing || false} refreshing={uiState.isRefreshing || false}
onSelectView={onSelectView} onSelectView={onSelectView}
onRefresh={onRefresh} onRefresh={onRefresh}
onEndReached={onEndReached} onEndReached={onEndReached}