diff options
Diffstat (limited to 'src/view')
-rw-r--r-- | src/view/com/notifications/Feed.tsx | 50 | ||||
-rw-r--r-- | src/view/com/notifications/FeedItem.tsx | 136 | ||||
-rw-r--r-- | src/view/com/post/Post.tsx | 225 | ||||
-rw-r--r-- | src/view/com/post/PostText.tsx | 53 | ||||
-rw-r--r-- | src/view/screens/tabroots/Notifications.tsx | 69 |
5 files changed, 526 insertions, 7 deletions
diff --git a/src/view/com/notifications/Feed.tsx b/src/view/com/notifications/Feed.tsx new file mode 100644 index 000000000..7c95003c7 --- /dev/null +++ b/src/view/com/notifications/Feed.tsx @@ -0,0 +1,50 @@ +import React from 'react' +import {observer} from 'mobx-react-lite' +import {Text, View, FlatList} from 'react-native' +import {OnNavigateContent} from '../../routes/types' +import { + NotificationsViewModel, + NotificationsViewItemModel, +} from '../../../state/models/notifications-view' +import {FeedItem} from './FeedItem' + +export const Feed = observer(function Feed({ + view, + onNavigateContent, +}: { + view: NotificationsViewModel + onNavigateContent: OnNavigateContent +}) { + // 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: NotificationsViewItemModel}) => ( + <FeedItem item={item} onNavigateContent={onNavigateContent} /> + ) + const onRefresh = () => { + view.refresh().catch(err => console.error('Failed to refresh', err)) + } + const onEndReached = () => { + view.loadMore().catch(err => console.error('Failed to load more', err)) + } + return ( + <View> + {view.isLoading && !view.isRefreshing && !view.hasContent && ( + <Text>Loading...</Text> + )} + {view.hasError && <Text>{view.error}</Text>} + {view.hasContent && ( + <FlatList + data={view.notifications} + keyExtractor={item => item._reactKey} + renderItem={renderItem} + refreshing={view.isRefreshing} + onRefresh={onRefresh} + onEndReached={onEndReached} + /> + )} + {view.isEmpty && <Text>This feed is empty!</Text>} + </View> + ) +}) diff --git a/src/view/com/notifications/FeedItem.tsx b/src/view/com/notifications/FeedItem.tsx new file mode 100644 index 000000000..1e0e47811 --- /dev/null +++ b/src/view/com/notifications/FeedItem.tsx @@ -0,0 +1,136 @@ +import React from 'react' +import {observer} from 'mobx-react-lite' +import {Image, StyleSheet, Text, TouchableOpacity, View} from 'react-native' +import {AdxUri} from '@adxp/mock-api' +import {FontAwesomeIcon, Props} from '@fortawesome/react-native-fontawesome' +import {OnNavigateContent} from '../../routes/types' +import {NotificationsViewItemModel} from '../../../state/models/notifications-view' +import {s} from '../../lib/styles' +import {ago} from '../../lib/strings' +import {AVIS} from '../../lib/assets' +import {PostText} from '../post/PostText' +import {Post} from '../post/Post' + +export const FeedItem = observer(function FeedItem({ + item, + onNavigateContent, +}: { + item: NotificationsViewItemModel + onNavigateContent: OnNavigateContent +}) { + const onPressOuter = () => { + if (item.isLike || item.isRepost) { + const urip = new AdxUri(item.subjectUri) + onNavigateContent('PostThread', { + name: urip.host, + recordKey: urip.recordKey, + }) + } else if (item.isFollow) { + onNavigateContent('Profile', { + name: item.author.name, + }) + } else if (item.isReply) { + const urip = new AdxUri(item.uri) + onNavigateContent('PostThread', { + name: urip.host, + recordKey: urip.recordKey, + }) + } + } + const onPressAuthor = () => { + onNavigateContent('Profile', { + name: item.author.name, + }) + } + + let action = '' + let icon: Props['icon'] + if (item.isLike) { + action = 'liked your post' + icon = ['far', 'heart'] + } else if (item.isRepost) { + action = 'reposted your post' + icon = 'retweet' + } else if (item.isReply) { + action = 'replied to your post' + icon = ['far', 'comment'] + } else if (item.isFollow) { + action = 'followed you' + icon = 'plus' + } else { + return <></> + } + + return ( + <TouchableOpacity style={styles.outer} onPress={onPressOuter}> + <View style={styles.layout}> + <TouchableOpacity style={styles.layoutAvi} onPress={onPressAuthor}> + <Image + style={styles.avi} + source={AVIS[item.author.name] || AVIS['alice.com']} + /> + </TouchableOpacity> + <View style={styles.layoutContent}> + <View style={styles.meta}> + <FontAwesomeIcon icon={icon} size={14} style={[s.mt2, s.mr5]} /> + <Text + style={[styles.metaItem, s.f14, s.bold]} + onPress={onPressAuthor}> + {item.author.displayName} + </Text> + <Text style={[styles.metaItem, s.f14]}>{action}</Text> + <Text style={[styles.metaItem, s.f14, s.gray]}> + {ago(item.indexedAt)} + </Text> + </View> + {item.isLike || item.isRepost ? ( + <PostText uri={item.subjectUri} style={[s.gray]} /> + ) : ( + <></> + )} + </View> + </View> + {item.isReply ? ( + <View style={s.pt5}> + <Post uri={item.uri} onNavigateContent={onNavigateContent} /> + </View> + ) : ( + <></> + )} + </TouchableOpacity> + ) +}) + +const styles = StyleSheet.create({ + outer: { + backgroundColor: '#fff', + padding: 10, + paddingBottom: 0, + }, + layout: { + flexDirection: 'row', + }, + layoutAvi: { + width: 40, + }, + avi: { + width: 30, + height: 30, + borderRadius: 15, + resizeMode: 'cover', + }, + layoutContent: { + flex: 1, + }, + meta: { + flexDirection: 'row', + paddingTop: 6, + paddingBottom: 4, + }, + metaItem: { + paddingRight: 3, + }, + postText: { + paddingBottom: 5, + }, +}) diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx new file mode 100644 index 000000000..3cfb6a1a1 --- /dev/null +++ b/src/view/com/post/Post.tsx @@ -0,0 +1,225 @@ +import React, {useState, useEffect} from 'react' +import {observer} from 'mobx-react-lite' +import {bsky, AdxUri} from '@adxp/mock-api' +import { + ActivityIndicator, + Image, + StyleSheet, + Text, + TouchableOpacity, + View, +} from 'react-native' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {OnNavigateContent} from '../../routes/types' +import {PostThreadViewModel} from '../../../state/models/post-thread-view' +import {useStores} from '../../../state' +import {s} from '../../lib/styles' +import {ago} from '../../lib/strings' +import {AVIS} from '../../lib/assets' + +export const Post = observer(function Post({ + uri, + onNavigateContent, +}: { + uri: string + onNavigateContent: OnNavigateContent +}) { + const store = useStores() + const [view, setView] = useState<PostThreadViewModel | undefined>() + + useEffect(() => { + if (view?.params.uri === uri) { + return // no change needed? or trigger refresh? + } + const newView = new PostThreadViewModel(store, {uri, depth: 0}) + setView(newView) + newView.setup().catch(err => console.error('Failed to fetch post', err)) + }, [uri, view?.params.uri, store]) + + // loading + // = + if (!view || view.isLoading || view.params.uri !== uri) { + return ( + <View> + <ActivityIndicator /> + </View> + ) + } + + // error + // = + if (view.hasError || !view.thread) { + return ( + <View> + <Text>{view.error || 'Thread not found'}</Text> + </View> + ) + } + + // loaded + // = + const item = view.thread + const record = view.thread?.record as unknown as bsky.Post.Record + + const onPressOuter = () => { + const urip = new AdxUri(item.uri) + onNavigateContent('PostThread', { + name: item.author.name, + recordKey: urip.recordKey, + }) + } + const onPressAuthor = () => { + onNavigateContent('Profile', { + name: item.author.name, + }) + } + const onPressReply = () => { + onNavigateContent('Composer', { + replyTo: 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 ( + <TouchableOpacity style={styles.outer} onPress={onPressOuter}> + <View style={styles.layout}> + <TouchableOpacity style={styles.layoutAvi} onPress={onPressAuthor}> + <Image + style={styles.avi} + source={AVIS[item.author.name] || AVIS['alice.com']} + /> + </TouchableOpacity> + <View style={styles.layoutContent}> + <View style={styles.meta}> + <Text + style={[styles.metaItem, s.f15, s.bold]} + onPress={onPressAuthor}> + {item.author.displayName} + </Text> + <Text + style={[styles.metaItem, s.f14, s.gray]} + onPress={onPressAuthor}> + @{item.author.name} + </Text> + <Text style={[styles.metaItem, s.f14, s.gray]}> + · {ago(item.indexedAt)} + </Text> + </View> + <Text style={[styles.postText, s.f15, s['lh15-1.3']]}> + {record.text} + </Text> + <View style={styles.ctrls}> + <TouchableOpacity style={styles.ctrl} onPress={onPressReply}> + <FontAwesomeIcon + style={styles.ctrlIcon} + icon={['far', 'comment']} + /> + <Text>{item.replyCount}</Text> + </TouchableOpacity> + <TouchableOpacity style={styles.ctrl} onPress={onPressToggleRepost}> + <FontAwesomeIcon + style={ + item.myState.hasReposted + ? styles.ctrlIconReposted + : styles.ctrlIcon + } + icon="retweet" + size={22} + /> + <Text + style={ + item.myState.hasReposted ? [s.bold, s.green] : undefined + }> + {item.repostCount} + </Text> + </TouchableOpacity> + <TouchableOpacity style={styles.ctrl} onPress={onPressToggleLike}> + <FontAwesomeIcon + style={ + item.myState.hasLiked ? styles.ctrlIconLiked : styles.ctrlIcon + } + icon={[item.myState.hasLiked ? 'fas' : 'far', 'heart']} + /> + <Text style={item.myState.hasLiked ? [s.bold, s.red] : undefined}> + {item.likeCount} + </Text> + </TouchableOpacity> + <View style={styles.ctrl}> + <FontAwesomeIcon + style={styles.ctrlIcon} + icon="share-from-square" + /> + </View> + </View> + </View> + </View> + </TouchableOpacity> + ) +}) + +const styles = StyleSheet.create({ + outer: { + borderWidth: 1, + borderColor: '#e8e8e8', + borderRadius: 4, + backgroundColor: '#fff', + padding: 10, + }, + layout: { + flexDirection: 'row', + }, + layoutAvi: { + width: 70, + }, + avi: { + width: 60, + height: 60, + borderRadius: 30, + resizeMode: 'cover', + }, + layoutContent: { + flex: 1, + }, + meta: { + flexDirection: 'row', + paddingTop: 2, + paddingBottom: 4, + }, + metaItem: { + paddingRight: 5, + }, + postText: { + paddingBottom: 5, + }, + ctrls: { + flexDirection: 'row', + }, + ctrl: { + flexDirection: 'row', + alignItems: 'center', + flex: 1, + paddingLeft: 4, + paddingRight: 4, + }, + ctrlIcon: { + marginRight: 5, + color: 'gray', + }, + ctrlIconReposted: { + marginRight: 5, + color: 'green', + }, + ctrlIconLiked: { + marginRight: 5, + color: 'red', + }, +}) diff --git a/src/view/com/post/PostText.tsx b/src/view/com/post/PostText.tsx new file mode 100644 index 000000000..541f2fc16 --- /dev/null +++ b/src/view/com/post/PostText.tsx @@ -0,0 +1,53 @@ +import React, {useState, useEffect} from 'react' +import {observer} from 'mobx-react-lite' +import {ActivityIndicator, Text, View} from 'react-native' +import {PostModel} from '../../../state/models/post' +import {useStores} from '../../../state' + +export const PostText = observer(function PostText({ + uri, + style, +}: { + uri: string + style?: StyleProp +}) { + const store = useStores() + const [model, setModel] = useState<PostModel | undefined>() + + useEffect(() => { + if (model?.uri === uri) { + return // no change needed? or trigger refresh? + } + const newModel = new PostModel(store, uri) + setModel(newModel) + newModel.setup().catch(err => console.error('Failed to fetch post', err)) + }, [uri, model?.uri, store]) + + // loading + // = + if (!model || model.isLoading || model.uri !== uri) { + return ( + <View> + <ActivityIndicator /> + </View> + ) + } + + // error + // = + if (model.hasError) { + return ( + <View> + <Text style={style}>{model.error}</Text> + </View> + ) + } + + // loaded + // = + return ( + <View> + <Text style={style}>{model.text}</Text> + </View> + ) +}) diff --git a/src/view/screens/tabroots/Notifications.tsx b/src/view/screens/tabroots/Notifications.tsx index 091410ad8..ea7576799 100644 --- a/src/view/screens/tabroots/Notifications.tsx +++ b/src/view/screens/tabroots/Notifications.tsx @@ -1,16 +1,71 @@ -import React from 'react' +import React, {useState, useEffect, useLayoutEffect} from 'react' +import {Image, StyleSheet, TouchableOpacity, View} from 'react-native' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {Shell} from '../../shell' -import {Text, View} from 'react-native' +import {Feed} from '../../com/notifications/Feed' import type {RootTabsScreenProps} from '../../routes/types' +import {useStores} from '../../../state' +import {AVIS} from '../../lib/assets' + +export const Notifications = ({ + navigation, +}: RootTabsScreenProps<'NotificationsTab'>) => { + const [hasSetup, setHasSetup] = useState<boolean>(false) + const store = useStores() + useEffect(() => { + console.log('Fetching home feed') + store.notesFeed.setup().then(() => setHasSetup(true)) + }, [store.notesFeed]) + + const onNavigateContent = (screen: string, props: Record<string, string>) => { + // @ts-ignore it's up to the callers to supply correct params -prf + navigation.navigate(screen, props) + } + + useEffect(() => { + return navigation.addListener('focus', () => { + if (hasSetup) { + console.log('Updating home feed') + store.notesFeed.update() + } + }) + }, [navigation, store.notesFeed, hasSetup]) + + useLayoutEffect(() => { + navigation.setOptions({ + headerShown: true, + headerTitle: 'Notifications', + headerLeft: () => ( + <TouchableOpacity + onPress={() => navigation.push('Profile', {name: 'alice.com'})}> + <Image source={AVIS['alice.com']} style={styles.avi} /> + </TouchableOpacity> + ), + headerRight: () => ( + <TouchableOpacity + onPress={() => { + navigation.push('Composer', {}) + }}> + <FontAwesomeIcon icon="plus" style={{color: '#006bf7'}} /> + </TouchableOpacity> + ), + }) + }, [navigation]) -export const Notifications = ( - _props: RootTabsScreenProps<'NotificationsTab'>, -) => { return ( <Shell> - <View style={{justifyContent: 'center', alignItems: 'center'}}> - <Text style={{fontSize: 20, fontWeight: 'bold'}}>Notifications</Text> + <View> + <Feed view={store.notesFeed} onNavigateContent={onNavigateContent} /> </View> </Shell> ) } + +const styles = StyleSheet.create({ + avi: { + width: 20, + height: 20, + borderRadius: 10, + resizeMode: 'cover', + }, +}) |