diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/state/models/badges-view.ts | 49 | ||||
-rw-r--r-- | src/state/models/feed-view.ts | 10 | ||||
-rw-r--r-- | src/state/models/profile-ui.ts | 98 | ||||
-rw-r--r-- | src/state/models/profile-view.ts | 8 | ||||
-rw-r--r-- | src/view/com/modals/ComposePost.tsx | 9 | ||||
-rw-r--r-- | src/view/com/post-thread/PostThreadItem.tsx | 2 | ||||
-rw-r--r-- | src/view/com/post/Post.tsx | 2 | ||||
-rw-r--r-- | src/view/com/posts/Feed.tsx (renamed from src/view/com/feed/Feed.tsx) | 8 | ||||
-rw-r--r-- | src/view/com/posts/FeedItem.tsx (renamed from src/view/com/feed/FeedItem.tsx) | 9 | ||||
-rw-r--r-- | src/view/com/profile/ProfileHeader.tsx | 52 | ||||
-rw-r--r-- | src/view/com/util/ErrorMessage.tsx | 66 | ||||
-rw-r--r-- | src/view/com/util/ErrorScreen.tsx | 111 | ||||
-rw-r--r-- | src/view/com/util/Selector.tsx | 8 | ||||
-rw-r--r-- | src/view/index.ts | 2 | ||||
-rw-r--r-- | src/view/screens/Home.tsx | 2 | ||||
-rw-r--r-- | src/view/screens/Profile.tsx | 203 | ||||
-rw-r--r-- | src/view/shell/mobile/accounts-menu.tsx | 7 | ||||
-rw-r--r-- | src/view/shell/mobile/index.tsx | 5 |
18 files changed, 559 insertions, 92 deletions
diff --git a/src/state/models/badges-view.ts b/src/state/models/badges-view.ts new file mode 100644 index 000000000..644ec7d9e --- /dev/null +++ b/src/state/models/badges-view.ts @@ -0,0 +1,49 @@ +import {makeAutoObservable} from 'mobx' +import {RootStoreModel} from './root-store' + +// TODO / DEBUG +// this is a temporary fake for the model until the view actually gets implemented in the bsky api +// -prf + +export class BadgesViewModel { + // state + isLoading = false + isRefreshing = false + hasLoaded = false + error = '' + + constructor(public rootStore: RootStoreModel) { + makeAutoObservable( + this, + { + rootStore: false, + }, + {autoBind: true}, + ) + } + + get hasContent() { + return false + } + + get hasError() { + return this.error !== '' + } + + get isEmpty() { + return this.hasLoaded && !this.hasContent + } + + // public api + // = + + async setup() { + this.hasLoaded = true + } + + async refresh() {} + + async loadMore() {} + + async update() {} +} diff --git a/src/state/models/feed-view.ts b/src/state/models/feed-view.ts index e9405773c..9ba96764b 100644 --- a/src/state/models/feed-view.ts +++ b/src/state/models/feed-view.ts @@ -95,6 +95,7 @@ export class FeedViewModel implements bsky.FeedView.Response { isLoading = false isRefreshing = false hasLoaded = false + hasReachedEnd = false error = '' params: bsky.FeedView.Params _loadPromise: Promise<void> | undefined @@ -244,7 +245,13 @@ export class FeedViewModel implements bsky.FeedView.Response { 'blueskyweb.xyz:FeedView', params, )) as bsky.FeedView.Response - this._appendAll(res) + if (res.feed.length === 0) { + runInAction(() => { + this.hasReachedEnd = true + }) + } else { + this._appendAll(res) + } this._xIdle() } catch (e: any) { this._xIdle(`Failed to load feed: ${e.toString()}`) @@ -281,6 +288,7 @@ export class FeedViewModel implements bsky.FeedView.Response { private _replaceAll(res: bsky.FeedView.Response) { this.feed.length = 0 + this.hasReachedEnd = false this._appendAll(res) } diff --git a/src/state/models/profile-ui.ts b/src/state/models/profile-ui.ts new file mode 100644 index 000000000..98a087aeb --- /dev/null +++ b/src/state/models/profile-ui.ts @@ -0,0 +1,98 @@ +import {makeAutoObservable} from 'mobx' +import {RootStoreModel} from './root-store' +import {ProfileViewModel} from './profile-view' +import {FeedViewModel} from './feed-view' +import {BadgesViewModel} from './badges-view' + +export const SECTION_IDS = { + POSTS: 0, + BADGES: 1, +} + +export interface ProfileUiParams { + user: string +} + +export class ProfileUiModel { + // constants + static SELECTOR_ITEMS = ['Posts', 'Badges'] + + // data + profile: ProfileViewModel + feed: FeedViewModel + badges: BadgesViewModel + + // ui state + selectedViewIndex = 0 + + constructor( + public rootStore: RootStoreModel, + public params: ProfileUiParams, + ) { + makeAutoObservable( + this, + { + rootStore: false, + params: false, + }, + {autoBind: true}, + ) + this.profile = new ProfileViewModel(rootStore, {user: params.user}) + this.feed = new FeedViewModel(rootStore, {author: params.user, limit: 10}) + this.badges = new BadgesViewModel(rootStore) + } + + get currentView(): FeedViewModel | BadgesViewModel { + if (this.selectedViewIndex === SECTION_IDS.POSTS) { + return this.feed + } + if (this.selectedViewIndex === SECTION_IDS.BADGES) { + return this.badges + } + throw new Error(`Invalid selector value: ${this.selectedViewIndex}`) + } + + get isInitialLoading() { + const view = this.currentView + return view.isLoading && !view.isRefreshing && !view.hasContent + } + + get isRefreshing() { + return this.profile.isRefreshing || this.currentView.isRefreshing + } + + // public api + // = + + setSelectedViewIndex(index: number) { + this.selectedViewIndex = index + } + + async setup() { + await Promise.all([ + this.profile + .setup() + .catch(err => console.error('Failed to fetch profile', err)), + this.feed + .setup() + .catch(err => console.error('Failed to fetch feed', err)), + this.badges + .setup() + .catch(err => console.error('Failed to fetch badges', err)), + ]) + } + + async update() { + await this.currentView.update() + } + + async refresh() { + await Promise.all([this.profile.refresh(), this.currentView.refresh()]) + } + + async loadMore() { + if (!this.currentView.isLoading && !this.currentView.hasError) { + await this.currentView.loadMore() + } + } +} diff --git a/src/state/models/profile-view.ts b/src/state/models/profile-view.ts index b245335f1..89c8a75d0 100644 --- a/src/state/models/profile-view.ts +++ b/src/state/models/profile-view.ts @@ -65,7 +65,7 @@ export class ProfileViewModel implements bsky.ProfileView.Response { } async refresh() { - await this._load() + await this._load(true) } async toggleFollowing() { @@ -108,8 +108,8 @@ export class ProfileViewModel implements bsky.ProfileView.Response { // loader functions // = - private async _load() { - this._xLoading() + private async _load(isRefreshing = false) { + this._xLoading(isRefreshing) await new Promise(r => setTimeout(r, 250)) // DEBUG try { const res = (await this.rootStore.api.mainPds.view( @@ -119,7 +119,7 @@ export class ProfileViewModel implements bsky.ProfileView.Response { this._replaceAll(res) this._xIdle() } catch (e: any) { - this._xIdle(`Failed to load feed: ${e.toString()}`) + this._xIdle(e.toString()) } } diff --git a/src/view/com/modals/ComposePost.tsx b/src/view/com/modals/ComposePost.tsx index 253db3771..22b6b14bb 100644 --- a/src/view/com/modals/ComposePost.tsx +++ b/src/view/com/modals/ComposePost.tsx @@ -1,12 +1,5 @@ import React, {useState} from 'react' -import { - KeyboardAvoidingView, - StyleSheet, - Text, - TextInput, - TouchableOpacity, - View, -} from 'react-native' +import {StyleSheet, Text, TouchableOpacity, View} from 'react-native' import {BottomSheetTextInput} from '@gorhom/bottom-sheet' import LinearGradient from 'react-native-linear-gradient' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx index d500514ef..8752ee7f9 100644 --- a/src/view/com/post-thread/PostThreadItem.tsx +++ b/src/view/com/post-thread/PostThreadItem.tsx @@ -238,7 +238,7 @@ export const PostThreadItem = observer(function PostThreadItem({ const styles = StyleSheet.create({ outer: { backgroundColor: colors.white, - borderRadius: 10, + borderRadius: 6, margin: 2, marginBottom: 0, }, diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx index a6580fa5a..3dd5c0047 100644 --- a/src/view/com/post/Post.tsx +++ b/src/view/com/post/Post.tsx @@ -154,7 +154,7 @@ export const Post = observer(function Post({uri}: {uri: string}) { const styles = StyleSheet.create({ outer: { marginTop: 1, - borderRadius: 4, + borderRadius: 6, backgroundColor: colors.white, padding: 10, }, diff --git a/src/view/com/feed/Feed.tsx b/src/view/com/posts/Feed.tsx index 4a2ecb612..370a72e69 100644 --- a/src/view/com/feed/Feed.tsx +++ b/src/view/com/posts/Feed.tsx @@ -3,21 +3,17 @@ import {observer} from 'mobx-react-lite' import {Text, View, FlatList} from 'react-native' import {FeedViewModel, FeedViewItemModel} from '../../../state/models/feed-view' import {FeedItem} from './FeedItem' -import {SharePostModel} from '../../../state/models/shell' import {useStores} from '../../../state' export const Feed = observer(function Feed({feed}: {feed: FeedViewModel}) { const store = useStores() - const onPressShare = (uri: string) => { - store.shell.openModal(new SharePostModel(uri)) - } // TODO optimize renderItem or FeedItem, we're getting this notice from RN: -prf // VirtualizedList: You have a large list that is slow to update - make sure your // renderItem function renders components that follow React performance best practices // like PureComponent, shouldComponentUpdate, etc const renderItem = ({item}: {item: FeedViewItemModel}) => ( - <FeedItem item={item} onPressShare={onPressShare} /> + <FeedItem item={item} /> ) const onRefresh = () => { feed.refresh().catch(err => console.error('Failed to refresh', err)) @@ -33,7 +29,7 @@ export const Feed = observer(function Feed({feed}: {feed: FeedViewModel}) { {feed.hasError && <Text>{feed.error}</Text>} {feed.hasContent && ( <FlatList - data={feed.feed} + data={feed.feed.slice()} keyExtractor={item => item._reactKey} renderItem={renderItem} refreshing={feed.isRefreshing} diff --git a/src/view/com/feed/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx index e9cf83346..2376686df 100644 --- a/src/view/com/feed/FeedItem.tsx +++ b/src/view/com/posts/FeedItem.tsx @@ -4,7 +4,7 @@ import {Image, StyleSheet, Text, TouchableOpacity, View} from 'react-native' import {bsky, AdxUri} from '@adxp/mock-api' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {FeedViewItemModel} from '../../../state/models/feed-view' -import {ComposePostModel} from '../../../state/models/shell' +import {ComposePostModel, SharePostModel} from '../../../state/models/shell' import {Link} from '../util/Link' import {PostDropdownBtn} from '../util/DropdownBtn' import {s, colors} from '../../lib/styles' @@ -14,10 +14,8 @@ import {useStores} from '../../../state' export const FeedItem = observer(function FeedItem({ item, - onPressShare, }: { item: FeedViewItemModel - onPressShare: (_uri: string) => void }) { const store = useStores() const record = item.record as unknown as bsky.Post.Record @@ -41,6 +39,9 @@ export const FeedItem = observer(function FeedItem({ .toggleLike() .catch(e => console.error('Failed to toggle like', record, e)) } + const onPressShare = (uri: string) => { + store.shell.openModal(new SharePostModel(uri)) + } return ( <Link style={styles.outer} href={itemHref} title={itemTitle}> @@ -151,7 +152,7 @@ export const FeedItem = observer(function FeedItem({ const styles = StyleSheet.create({ outer: { - borderRadius: 10, + borderRadius: 6, margin: 2, marginBottom: 0, backgroundColor: colors.white, diff --git a/src/view/com/profile/ProfileHeader.tsx b/src/view/com/profile/ProfileHeader.tsx index 08d895554..59af6b200 100644 --- a/src/view/com/profile/ProfileHeader.tsx +++ b/src/view/com/profile/ProfileHeader.tsx @@ -17,31 +17,13 @@ import {s, gradients, colors} from '../../lib/styles' import {AVIS, BANNER} from '../../lib/assets' import Toast from '../util/Toast' import {Link} from '../util/Link' -import {Selector, SelectorItem} from '../util/Selector' export const ProfileHeader = observer(function ProfileHeader({ - user, + view, }: { - user: string + view: ProfileViewModel }) { const store = useStores() - const [view, setView] = useState<ProfileViewModel | undefined>() - - useEffect(() => { - if (view?.params.user === user) { - console.log('Profile header doing nothing') - return // no change needed? or trigger refresh? - } - console.log('Fetching profile', user) - const newView = new ProfileViewModel(store, {user: user}) - setView(newView) - newView.setup().catch(err => console.error('Failed to fetch profile', err)) - }, [user, view?.params.user, store]) - - const selectorItems: SelectorItem[] = [ - {label: 'Posts', onSelect() {}}, - {label: 'Badges', onSelect() {}}, - ] const onPressToggleFollow = () => { view?.toggleFollowing().then( @@ -66,19 +48,15 @@ export const ProfileHeader = observer(function ProfileHeader({ // TODO } const onPressFollowers = () => { - store.nav.navigate(`/profile/${user}/followers`) + store.nav.navigate(`/profile/${view.name}/followers`) } const onPressFollows = () => { - store.nav.navigate(`/profile/${user}/follows`) + store.nav.navigate(`/profile/${view.name}/follows`) } // loading // = - if ( - !view || - (view.isLoading && !view.isRefreshing) || - view.params.user !== user - ) { + if (!view || (view.isLoading && !view.isRefreshing)) { return ( <View> <ActivityIndicator /> @@ -120,13 +98,13 @@ export const ProfileHeader = observer(function ProfileHeader({ <TouchableOpacity onPress={onPressEditProfile} style={[styles.mainBtn, styles.btn]}> - <Text style={[s.fw600, s.f16]}>Edit Profile</Text> + <Text style={[s.fw400, s.f14]}>Edit Profile</Text> </TouchableOpacity> ) : view.myState.hasFollowed ? ( <TouchableOpacity onPress={onPressToggleFollow} style={[styles.mainBtn, styles.btn]}> - <Text style={[s.fw600, s.f16]}>Following</Text> + <Text style={[s.fw400, s.f14]}>Following</Text> </TouchableOpacity> ) : ( <TouchableOpacity onPress={onPressToggleFollow}> @@ -146,7 +124,7 @@ export const ProfileHeader = observer(function ProfileHeader({ <FontAwesomeIcon icon="ellipsis" style={[s.gray5]} /> </TouchableOpacity> </View> - <View style={[s.flexRow, s.mb10]}> + <View style={[s.flexRow]}> <TouchableOpacity style={[s.flexRow, s.mr10]} onPress={onPressFollowers}> @@ -167,10 +145,9 @@ export const ProfileHeader = observer(function ProfileHeader({ </View> </View> {view.description && ( - <Text style={[s.mb10, s.f15, s['lh15-1.3']]}>{view.description}</Text> + <Text style={[s.mt10, s.f15, s['lh15-1.3']]}>{view.description}</Text> )} </View> - <Selector items={selectorItems} /> </View> ) }) @@ -178,8 +155,6 @@ export const ProfileHeader = observer(function ProfileHeader({ const styles = StyleSheet.create({ outer: { backgroundColor: colors.white, - borderBottomWidth: 1, - borderColor: colors.gray2, }, banner: { width: '100%', @@ -222,14 +197,17 @@ const styles = StyleSheet.create({ flexDirection: 'row', alignItems: 'center', justifyContent: 'center', - paddingVertical: 8, - paddingHorizontal: 60, + paddingVertical: 6, + paddingLeft: 55, + paddingRight: 60, borderRadius: 30, + borderWidth: 1, + borderColor: 'transparent', }, btn: { alignItems: 'center', justifyContent: 'center', - paddingVertical: 8, + paddingVertical: 7, borderRadius: 30, borderWidth: 1, borderColor: colors.gray2, diff --git a/src/view/com/util/ErrorMessage.tsx b/src/view/com/util/ErrorMessage.tsx new file mode 100644 index 000000000..7c8670da3 --- /dev/null +++ b/src/view/com/util/ErrorMessage.tsx @@ -0,0 +1,66 @@ +import React from 'react' +import {StyleSheet, Text, TouchableOpacity, View} from 'react-native' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {colors} from '../../lib/styles' + +export function ErrorMessage({ + message, + onPressTryAgain, +}: { + message: string + onPressTryAgain?: () => void +}) { + return ( + <View style={styles.outer}> + <View style={styles.errorIcon}> + <FontAwesomeIcon + icon="exclamation" + style={{color: colors.white}} + size={16} + /> + </View> + <Text style={styles.message}>{message}</Text> + {onPressTryAgain && ( + <TouchableOpacity style={styles.btn} onPress={onPressTryAgain}> + <FontAwesomeIcon + icon="arrows-rotate" + style={{color: colors.red4}} + size={16} + /> + </TouchableOpacity> + )} + </View> + ) +} + +const styles = StyleSheet.create({ + outer: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + backgroundColor: colors.red1, + borderWidth: 1, + borderColor: colors.red3, + borderRadius: 6, + paddingVertical: 8, + paddingHorizontal: 8, + }, + errorIcon: { + backgroundColor: colors.red4, + borderRadius: 12, + width: 24, + height: 24, + alignItems: 'center', + justifyContent: 'center', + marginRight: 8, + }, + message: { + flex: 1, + color: colors.red4, + paddingRight: 10, + }, + btn: { + paddingHorizontal: 4, + paddingVertical: 4, + }, +}) diff --git a/src/view/com/util/ErrorScreen.tsx b/src/view/com/util/ErrorScreen.tsx new file mode 100644 index 000000000..4a3e41dc9 --- /dev/null +++ b/src/view/com/util/ErrorScreen.tsx @@ -0,0 +1,111 @@ +import React from 'react' +import {StyleSheet, Text, TouchableOpacity, View} from 'react-native' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {colors} from '../../lib/styles' + +export function ErrorScreen({ + title, + message, + details, + onPressTryAgain, +}: { + title: string + message: string + details?: string + onPressTryAgain?: () => void +}) { + return ( + <View style={styles.outer}> + <View style={styles.errorIconContainer}> + <View style={styles.errorIcon}> + <FontAwesomeIcon + icon="exclamation" + style={{color: colors.white}} + size={24} + /> + </View> + </View> + <Text style={styles.title}>{title}</Text> + <Text style={styles.message}>{message}</Text> + {details && <Text style={styles.details}>{details}</Text>} + {onPressTryAgain && ( + <View style={styles.btnContainer}> + <TouchableOpacity style={styles.btn} onPress={onPressTryAgain}> + <FontAwesomeIcon + icon="arrows-rotate" + style={{color: colors.white}} + size={16} + /> + <Text style={styles.btnText}>Try again</Text> + </TouchableOpacity> + </View> + )} + </View> + ) +} + +const styles = StyleSheet.create({ + outer: { + flex: 1, + backgroundColor: colors.red1, + borderWidth: 1, + borderColor: colors.red3, + borderRadius: 6, + paddingVertical: 30, + paddingHorizontal: 14, + margin: 10, + }, + title: { + textAlign: 'center', + color: colors.red4, + fontSize: 24, + marginBottom: 10, + }, + message: { + textAlign: 'center', + color: colors.red4, + marginBottom: 20, + }, + details: { + textAlign: 'center', + color: colors.black, + backgroundColor: colors.white, + borderWidth: 1, + borderColor: colors.gray5, + borderRadius: 6, + paddingVertical: 10, + paddingHorizontal: 14, + overflow: 'hidden', + marginBottom: 20, + }, + btnContainer: { + alignItems: 'center', + }, + btn: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: colors.red4, + borderRadius: 6, + paddingHorizontal: 16, + paddingVertical: 10, + }, + btnText: { + marginLeft: 5, + color: colors.white, + fontSize: 16, + fontWeight: 'bold', + }, + errorIconContainer: { + alignItems: 'center', + marginBottom: 10, + }, + errorIcon: { + backgroundColor: colors.red4, + borderRadius: 30, + width: 50, + height: 50, + alignItems: 'center', + justifyContent: 'center', + marginRight: 5, + }, +}) diff --git a/src/view/com/util/Selector.tsx b/src/view/com/util/Selector.tsx index ef7c65d59..adc393d89 100644 --- a/src/view/com/util/Selector.tsx +++ b/src/view/com/util/Selector.tsx @@ -9,17 +9,13 @@ import { } from 'react-native' import {colors} from '../../lib/styles' -export interface SelectorItem { - label: string -} - export function Selector({ style, items, onSelect, }: { style?: StyleProp<ViewStyle> - items: SelectorItem[] + items: string[] onSelect?: (index: number) => void }) { const [selectedIndex, setSelectedIndex] = useState<number>(0) @@ -36,7 +32,7 @@ export function Selector({ <TouchableWithoutFeedback key={i} onPress={() => onPressItem(i)}> <View style={selected ? styles.itemSelected : styles.item}> <Text style={selected ? styles.labelSelected : styles.label}> - {item.label} + {item} </Text> </View> </TouchableWithoutFeedback> diff --git a/src/view/index.ts b/src/view/index.ts index c4b8fa9f9..d15fc6c25 100644 --- a/src/view/index.ts +++ b/src/view/index.ts @@ -5,6 +5,7 @@ import {faAngleRight} from '@fortawesome/free-solid-svg-icons/faAngleRight' import {faArrowLeft} from '@fortawesome/free-solid-svg-icons/faArrowLeft' import {faArrowUpFromBracket} from '@fortawesome/free-solid-svg-icons/faArrowUpFromBracket' import {faArrowUpRightFromSquare} from '@fortawesome/free-solid-svg-icons/faArrowUpRightFromSquare' +import {faArrowsRotate} from '@fortawesome/free-solid-svg-icons/faArrowsRotate' import {faBars} from '@fortawesome/free-solid-svg-icons/faBars' import {faBell} from '@fortawesome/free-solid-svg-icons/faBell' import {faBell as farBell} from '@fortawesome/free-regular-svg-icons/faBell' @@ -39,6 +40,7 @@ export function setup() { faArrowLeft, faArrowUpFromBracket, faArrowUpRightFromSquare, + faArrowsRotate, faBars, faBell, farBell, diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx index 27a17d0e9..5b4f1011d 100644 --- a/src/view/screens/Home.tsx +++ b/src/view/screens/Home.tsx @@ -1,7 +1,7 @@ import React, {useState, useEffect} from 'react' import {View} from 'react-native' import {observer} from 'mobx-react-lite' -import {Feed} from '../com/feed/Feed' +import {Feed} from '../com/posts/Feed' import {FAB} from '../com/util/FloatingActionButton' import {useStores} from '../../state' import {FeedViewModel} from '../../state/models/feed-view' diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx index 236f8f908..6711f7e04 100644 --- a/src/view/screens/Profile.tsx +++ b/src/view/screens/Profile.tsx @@ -1,52 +1,213 @@ import React, {useState, useEffect} from 'react' -import {View, StyleSheet} from 'react-native' -import {FeedViewModel} from '../../state/models/feed-view' +import {SectionList, StyleSheet, Text, View} from 'react-native' +import {observer} from 'mobx-react-lite' +import {ProfileUiModel, SECTION_IDS} from '../../state/models/profile-ui' +import {FeedViewItemModel} from '../../state/models/feed-view' import {useStores} from '../../state' import {ProfileHeader} from '../com/profile/ProfileHeader' -import {Feed} from '../com/feed/Feed' +import {FeedItem} from '../com/posts/FeedItem' +import {Selector} from '../com/util/Selector' +import {ErrorScreen} from '../com/util/ErrorScreen' +import {ErrorMessage} from '../com/util/ErrorMessage' +import {s, colors} from '../lib/styles' import {ScreenParams} from '../routes' -export const Profile = ({visible, params}: ScreenParams) => { +const SECTION_HEADER_ITEM = Symbol('SectionHeaderItem') +const LOADING_ITEM = Symbol('LoadingItem') +const EMPTY_ITEM = Symbol('EmptyItem') +const END_ITEM = Symbol('EndItem') + +interface RenderItemParams { + item: any + index: number + section: Section +} + +interface ErrorItem { + error: string +} + +interface Section { + data: any[] + keyExtractor?: (v: any) => string + renderItem: (params: RenderItemParams) => JSX.Element +} + +export const Profile = observer(({visible, params}: ScreenParams) => { const store = useStores() const [hasSetup, setHasSetup] = useState<boolean>(false) - const [feedView, setFeedView] = useState<FeedViewModel | undefined>() + const [profileUiState, setProfileUiState] = useState< + ProfileUiModel | undefined + >() useEffect(() => { if (!visible) { return } - const author = params.name + const user = params.name if (hasSetup) { - console.log('Updating profile feed for', author) - feedView?.update() + console.log('Updating profile for', user) + profileUiState?.update() } else { - console.log('Fetching profile feed for', author) - const newFeedView = new FeedViewModel(store, {author}) - setFeedView(newFeedView) - newFeedView - .setup() - .catch(err => console.error('Failed to fetch feed', err)) - .then(() => { - setHasSetup(true) - store.nav.setTitle(author) - }) + console.log('Fetching profile for', user) + store.nav.setTitle(user) + const newProfileUiState = new ProfileUiModel(store, {user}) + setProfileUiState(newProfileUiState) + newProfileUiState.setup().then(() => { + setHasSetup(true) + }) } }, [visible, params.name, store]) + // events + // = + + const onSelectViewSelector = (index: number) => + profileUiState?.setSelectedViewIndex(index) + const onRefresh = () => { + profileUiState + ?.refresh() + .catch((err: any) => console.error('Failed to refresh', err)) + } + const onEndReached = () => { + profileUiState + ?.loadMore() + .catch((err: any) => console.error('Failed to load more', err)) + } + const onPressTryAgain = () => { + profileUiState?.setup() + } + + // rendering + // = + + const renderItem = (_params: RenderItemParams) => <View /> + const renderLoadingItem = (_params: RenderItemParams) => ( + <Text style={styles.loading}>Loading...</Text> + ) + const renderErrorItem = ({item}: {item: ErrorItem}) => ( + <View style={s.p5}> + <ErrorMessage message={item.error} onPressTryAgain={onPressTryAgain} /> + </View> + ) + const renderEmptyItem = (_params: RenderItemParams) => ( + <Text style={styles.loading}>No posts yet!</Text> + ) + const renderProfileItem = (_params: RenderItemParams) => { + if (!profileUiState) { + return <View /> + } + return <ProfileHeader view={profileUiState.profile} /> + } + const renderSectionHeader = ({section}: {section: Section}) => { + if (section?.data?.[0] !== SECTION_HEADER_ITEM) { + return ( + <Selector + items={ProfileUiModel.SELECTOR_ITEMS} + style={styles.selector} + onSelect={onSelectViewSelector} + /> + ) + } + return <View /> + } + const renderPostsItem = ({item}: {item: FeedViewItemModel | Symbol}) => { + if (item === END_ITEM || item instanceof Symbol) { + return <Text style={styles.endItem}>- end of feed -</Text> + } + return <FeedItem item={item} /> + } + const renderBadgesItem = ({item}: {item: any}) => <Text>todo</Text> + + const sections = [ + {data: [SECTION_HEADER_ITEM], renderItem: renderProfileItem}, + ] + if (profileUiState) { + if (profileUiState.selectedViewIndex === SECTION_IDS.POSTS) { + if (profileUiState.isInitialLoading) { + sections.push({ + data: [LOADING_ITEM], + renderItem: renderLoadingItem, + } as Section) + } else if (profileUiState.feed.hasError) { + sections.push({ + data: [{error: profileUiState.feed.error}], + renderItem: renderErrorItem, + } as Section) + } else if (profileUiState.currentView.hasContent) { + const items: (FeedViewItemModel | Symbol)[] = + profileUiState.feed.feed.slice() + if (profileUiState.feed.hasReachedEnd) { + items.push(END_ITEM) + } + sections.push({ + data: items, + renderItem: renderPostsItem, + keyExtractor: (item: FeedViewItemModel) => item._reactKey, + } as Section) + } else if (profileUiState.currentView.isEmpty) { + sections.push({ + data: [EMPTY_ITEM], + renderItem: renderEmptyItem, + }) + } + } + if (profileUiState.selectedViewIndex === SECTION_IDS.BADGES) { + sections.push({ + data: [{}], + renderItem: renderBadgesItem, + } as Section) + } + } + return ( <View style={styles.container}> - <ProfileHeader user={params.name} /> - <View style={styles.feed}>{feedView && <Feed feed={feedView} />}</View> + <View style={styles.feed}> + {profileUiState && + (profileUiState.profile.hasError ? ( + <ErrorScreen + title="Failed to load profile" + message={`There was an issue when attempting to load ${params.name}`} + details={profileUiState.profile.error} + onPressTryAgain={onPressTryAgain} + /> + ) : ( + <SectionList + sections={sections} + renderSectionHeader={renderSectionHeader} + renderItem={renderItem} + refreshing={profileUiState.isRefreshing} + onRefresh={onRefresh} + onEndReached={onEndReached} + /> + ))} + </View> </View> ) -} +}) const styles = StyleSheet.create({ container: { flexDirection: 'column', height: '100%', }, + selector: { + paddingTop: 8, + backgroundColor: colors.white, + borderBottomWidth: 1, + borderColor: colors.gray2, + }, feed: { flex: 1, }, + loading: { + paddingVertical: 10, + paddingHorizontal: 14, + }, + endItem: { + paddingTop: 20, + paddingBottom: 30, + color: colors.gray5, + textAlign: 'center', + }, }) diff --git a/src/view/shell/mobile/accounts-menu.tsx b/src/view/shell/mobile/accounts-menu.tsx index e3b61ce42..24b614cec 100644 --- a/src/view/shell/mobile/accounts-menu.tsx +++ b/src/view/shell/mobile/accounts-menu.tsx @@ -12,9 +12,14 @@ import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {AVIS} from '../../lib/assets' import {s, colors} from '../../lib/styles' -export function createAccountsMenu(): RootSiblings { +export function createAccountsMenu({ + debug_onPressItem, +}: { + debug_onPressItem: () => void +}): RootSiblings { const onPressItem = (_index: number) => { sibling.destroy() + debug_onPressItem() // TODO } const onOuterPress = () => sibling.destroy() const sibling = new RootSiblings( diff --git a/src/view/shell/mobile/index.tsx b/src/view/shell/mobile/index.tsx index 35a1b3957..f049211e4 100644 --- a/src/view/shell/mobile/index.tsx +++ b/src/view/shell/mobile/index.tsx @@ -99,7 +99,10 @@ export const MobileShell: React.FC = observer(() => { const [isLocationMenuActive, setLocationMenuActive] = useState(false) const screenRenderDesc = constructScreenRenderDesc(store.nav) - const onPressAvi = () => createAccountsMenu() + const onPressAvi = () => + createAccountsMenu({ + debug_onPressItem: () => store.nav.navigate('/profile/alice.com'), + }) const onPressLocation = () => setLocationMenuActive(true) const onPressEllipsis = () => createLocationMenu() |