From bb06ef4f6e7ac7889b3112285d0cf3445b8eb766 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Tue, 6 Sep 2022 14:26:39 -0500 Subject: Rework profile page to include working view selector --- src/state/models/badges-view.ts | 49 +++++++ src/state/models/feed-view.ts | 10 +- src/state/models/profile-ui.ts | 98 +++++++++++++ src/state/models/profile-view.ts | 8 +- src/view/com/feed/Feed.tsx | 47 ------ src/view/com/feed/FeedItem.tsx | 218 --------------------------- src/view/com/modals/ComposePost.tsx | 9 +- src/view/com/post-thread/PostThreadItem.tsx | 2 +- src/view/com/post/Post.tsx | 2 +- src/view/com/posts/Feed.tsx | 43 ++++++ src/view/com/posts/FeedItem.tsx | 219 ++++++++++++++++++++++++++++ src/view/com/profile/ProfileHeader.tsx | 52 ++----- src/view/com/util/ErrorMessage.tsx | 66 +++++++++ src/view/com/util/ErrorScreen.tsx | 111 ++++++++++++++ src/view/com/util/Selector.tsx | 8 +- src/view/index.ts | 2 + src/view/screens/Home.tsx | 2 +- src/view/screens/Profile.tsx | 203 +++++++++++++++++++++++--- src/view/shell/mobile/accounts-menu.tsx | 7 +- src/view/shell/mobile/index.tsx | 5 +- 20 files changed, 814 insertions(+), 347 deletions(-) create mode 100644 src/state/models/badges-view.ts create mode 100644 src/state/models/profile-ui.ts delete mode 100644 src/view/com/feed/Feed.tsx delete mode 100644 src/view/com/feed/FeedItem.tsx create mode 100644 src/view/com/posts/Feed.tsx create mode 100644 src/view/com/posts/FeedItem.tsx create mode 100644 src/view/com/util/ErrorMessage.tsx create mode 100644 src/view/com/util/ErrorScreen.tsx (limited to 'src') 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 | 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/feed/Feed.tsx b/src/view/com/feed/Feed.tsx deleted file mode 100644 index 4a2ecb612..000000000 --- a/src/view/com/feed/Feed.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import React from 'react' -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}) => ( - - ) - const onRefresh = () => { - feed.refresh().catch(err => console.error('Failed to refresh', err)) - } - const onEndReached = () => { - feed.loadMore().catch(err => console.error('Failed to load more', err)) - } - return ( - - {feed.isLoading && !feed.isRefreshing && !feed.hasContent && ( - Loading... - )} - {feed.hasError && {feed.error}} - {feed.hasContent && ( - item._reactKey} - renderItem={renderItem} - refreshing={feed.isRefreshing} - onRefresh={onRefresh} - onEndReached={onEndReached} - /> - )} - {feed.isEmpty && This feed is empty!} - - ) -}) diff --git a/src/view/com/feed/FeedItem.tsx b/src/view/com/feed/FeedItem.tsx deleted file mode 100644 index e9cf83346..000000000 --- a/src/view/com/feed/FeedItem.tsx +++ /dev/null @@ -1,218 +0,0 @@ -import React, {useMemo} from 'react' -import {observer} from 'mobx-react-lite' -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 {Link} from '../util/Link' -import {PostDropdownBtn} from '../util/DropdownBtn' -import {s, colors} from '../../lib/styles' -import {ago} from '../../lib/strings' -import {AVIS} from '../../lib/assets' -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 - const itemHref = useMemo(() => { - const urip = new AdxUri(item.uri) - return `/profile/${item.author.name}/post/${urip.recordKey}` - }, [item.uri, item.author.name]) - const itemTitle = `Post by ${item.author.name}` - const authorHref = `/profile/${item.author.name}` - - const onPressReply = () => { - store.shell.openModal(new ComposePostModel(item.uri)) - } - const onPressToggleRepost = () => { - item - .toggleRepost() - .catch(e => console.error('Failed to toggle repost', record, e)) - } - const onPressToggleLike = () => { - item - .toggleLike() - .catch(e => console.error('Failed to toggle like', record, e)) - } - - return ( - - {item.repostedBy && ( - - - - Reposted by {item.repostedBy.displayName} - - - )} - - - - - - - - {item.author.displayName} - - - @{item.author.name} - - - · {ago(item.indexedAt)} - - - - - - - - {record.text} - - - - - {item.replyCount} - - - - - {item.repostCount} - - - - - - {item.likeCount} - - - onPressShare(item.uri)}> - - - - - - - ) -}) - -const styles = StyleSheet.create({ - outer: { - borderRadius: 10, - margin: 2, - marginBottom: 0, - backgroundColor: colors.white, - padding: 10, - }, - repostedBy: { - flexDirection: 'row', - paddingLeft: 60, - }, - repostedByIcon: { - marginRight: 2, - color: colors.gray4, - }, - layout: { - flexDirection: 'row', - }, - layoutAvi: { - width: 60, - paddingTop: 5, - }, - avi: { - width: 50, - height: 50, - borderRadius: 25, - resizeMode: 'cover', - }, - layoutContent: { - flex: 1, - }, - meta: { - flexDirection: 'row', - paddingTop: 2, - paddingBottom: 2, - }, - metaItem: { - paddingRight: 5, - }, - postText: { - paddingBottom: 5, - fontFamily: 'Helvetica Neue', - }, - ctrls: { - flexDirection: 'row', - }, - ctrl: { - flexDirection: 'row', - alignItems: 'center', - flex: 1, - paddingLeft: 4, - paddingRight: 4, - }, - ctrlIcon: { - marginRight: 5, - color: colors.gray5, - }, - ctrlIconReposted: { - marginRight: 5, - color: colors.green3, - }, - ctrlIconLiked: { - marginRight: 5, - color: colors.red3, - }, -}) 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/posts/Feed.tsx b/src/view/com/posts/Feed.tsx new file mode 100644 index 000000000..370a72e69 --- /dev/null +++ b/src/view/com/posts/Feed.tsx @@ -0,0 +1,43 @@ +import React from 'react' +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 {useStores} from '../../../state' + +export const Feed = observer(function Feed({feed}: {feed: FeedViewModel}) { + const store = useStores() + + // 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}) => ( + + ) + const onRefresh = () => { + feed.refresh().catch(err => console.error('Failed to refresh', err)) + } + const onEndReached = () => { + feed.loadMore().catch(err => console.error('Failed to load more', err)) + } + return ( + + {feed.isLoading && !feed.isRefreshing && !feed.hasContent && ( + Loading... + )} + {feed.hasError && {feed.error}} + {feed.hasContent && ( + item._reactKey} + renderItem={renderItem} + refreshing={feed.isRefreshing} + onRefresh={onRefresh} + onEndReached={onEndReached} + /> + )} + {feed.isEmpty && This feed is empty!} + + ) +}) diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx new file mode 100644 index 000000000..2376686df --- /dev/null +++ b/src/view/com/posts/FeedItem.tsx @@ -0,0 +1,219 @@ +import React, {useMemo} from 'react' +import {observer} from 'mobx-react-lite' +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, SharePostModel} from '../../../state/models/shell' +import {Link} from '../util/Link' +import {PostDropdownBtn} from '../util/DropdownBtn' +import {s, colors} from '../../lib/styles' +import {ago} from '../../lib/strings' +import {AVIS} from '../../lib/assets' +import {useStores} from '../../../state' + +export const FeedItem = observer(function FeedItem({ + item, +}: { + item: FeedViewItemModel +}) { + const store = useStores() + const record = item.record as unknown as bsky.Post.Record + const itemHref = useMemo(() => { + const urip = new AdxUri(item.uri) + return `/profile/${item.author.name}/post/${urip.recordKey}` + }, [item.uri, item.author.name]) + const itemTitle = `Post by ${item.author.name}` + const authorHref = `/profile/${item.author.name}` + + const onPressReply = () => { + store.shell.openModal(new ComposePostModel(item.uri)) + } + const onPressToggleRepost = () => { + item + .toggleRepost() + .catch(e => console.error('Failed to toggle repost', record, e)) + } + const onPressToggleLike = () => { + item + .toggleLike() + .catch(e => console.error('Failed to toggle like', record, e)) + } + const onPressShare = (uri: string) => { + store.shell.openModal(new SharePostModel(uri)) + } + + return ( + + {item.repostedBy && ( + + + + Reposted by {item.repostedBy.displayName} + + + )} + + + + + + + + {item.author.displayName} + + + @{item.author.name} + + + · {ago(item.indexedAt)} + + + + + + + + {record.text} + + + + + {item.replyCount} + + + + + {item.repostCount} + + + + + + {item.likeCount} + + + onPressShare(item.uri)}> + + + + + + + ) +}) + +const styles = StyleSheet.create({ + outer: { + borderRadius: 6, + margin: 2, + marginBottom: 0, + backgroundColor: colors.white, + padding: 10, + }, + repostedBy: { + flexDirection: 'row', + paddingLeft: 60, + }, + repostedByIcon: { + marginRight: 2, + color: colors.gray4, + }, + layout: { + flexDirection: 'row', + }, + layoutAvi: { + width: 60, + paddingTop: 5, + }, + avi: { + width: 50, + height: 50, + borderRadius: 25, + resizeMode: 'cover', + }, + layoutContent: { + flex: 1, + }, + meta: { + flexDirection: 'row', + paddingTop: 2, + paddingBottom: 2, + }, + metaItem: { + paddingRight: 5, + }, + postText: { + paddingBottom: 5, + fontFamily: 'Helvetica Neue', + }, + ctrls: { + flexDirection: 'row', + }, + ctrl: { + flexDirection: 'row', + alignItems: 'center', + flex: 1, + paddingLeft: 4, + paddingRight: 4, + }, + ctrlIcon: { + marginRight: 5, + color: colors.gray5, + }, + ctrlIconReposted: { + marginRight: 5, + color: colors.green3, + }, + ctrlIconLiked: { + marginRight: 5, + color: colors.red3, + }, +}) 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() - - 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 ( @@ -120,13 +98,13 @@ export const ProfileHeader = observer(function ProfileHeader({ - Edit Profile + Edit Profile ) : view.myState.hasFollowed ? ( - Following + Following ) : ( @@ -146,7 +124,7 @@ export const ProfileHeader = observer(function ProfileHeader({ - + @@ -167,10 +145,9 @@ export const ProfileHeader = observer(function ProfileHeader({ {view.description && ( - {view.description} + {view.description} )} - ) }) @@ -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 ( + + + + + {message} + {onPressTryAgain && ( + + + + )} + + ) +} + +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 ( + + + + + + + {title} + {message} + {details && {details}} + {onPressTryAgain && ( + + + + Try again + + + )} + + ) +} + +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 - items: SelectorItem[] + items: string[] onSelect?: (index: number) => void }) { const [selectedIndex, setSelectedIndex] = useState(0) @@ -36,7 +32,7 @@ export function Selector({ onPressItem(i)}> - {item.label} + {item} 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(false) - const [feedView, setFeedView] = useState() + 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) => + const renderLoadingItem = (_params: RenderItemParams) => ( + Loading... + ) + const renderErrorItem = ({item}: {item: ErrorItem}) => ( + + + + ) + const renderEmptyItem = (_params: RenderItemParams) => ( + No posts yet! + ) + const renderProfileItem = (_params: RenderItemParams) => { + if (!profileUiState) { + return + } + return + } + const renderSectionHeader = ({section}: {section: Section}) => { + if (section?.data?.[0] !== SECTION_HEADER_ITEM) { + return ( + + ) + } + return + } + const renderPostsItem = ({item}: {item: FeedViewItemModel | Symbol}) => { + if (item === END_ITEM || item instanceof Symbol) { + return - end of feed - + } + return + } + const renderBadgesItem = ({item}: {item: any}) => todo + + 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 ( - - {feedView && } + + {profileUiState && + (profileUiState.profile.hasError ? ( + + ) : ( + + ))} + ) -} +}) 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() -- cgit 1.4.1