diff options
author | Paul Frazee <pfrazee@gmail.com> | 2022-07-22 12:32:52 -0500 |
---|---|---|
committer | Paul Frazee <pfrazee@gmail.com> | 2022-07-22 12:32:52 -0500 |
commit | ce83648f9da3a93018fc7845bec1d35c1519028d (patch) | |
tree | 6b10c7536abca76abe90d2d2adc42ede8a705bf4 /src | |
parent | 0ec0ba996f05876d78039509e0ea61528c5faeec (diff) | |
download | voidsky-ce83648f9da3a93018fc7845bec1d35c1519028d.tar.zst |
Add liked-by and reposted-by views
Diffstat (limited to 'src')
-rw-r--r-- | src/state/models/liked-by-view.ts | 141 | ||||
-rw-r--r-- | src/state/models/reposted-by-view.ts | 141 | ||||
-rw-r--r-- | src/view/com/post-thread/PostLikedBy.tsx | 139 | ||||
-rw-r--r-- | src/view/com/post-thread/PostRepostedBy.tsx | 141 | ||||
-rw-r--r-- | src/view/com/post-thread/PostThreadItem.tsx | 22 | ||||
-rw-r--r-- | src/view/lib/strings.ts | 14 | ||||
-rw-r--r-- | src/view/routes/index.tsx | 16 | ||||
-rw-r--r-- | src/view/routes/types.ts | 2 | ||||
-rw-r--r-- | src/view/screens/content/PostLikedBy.tsx | 38 | ||||
-rw-r--r-- | src/view/screens/content/PostRepostedBy.tsx | 41 | ||||
-rw-r--r-- | src/view/screens/content/PostThread.tsx | 9 |
11 files changed, 695 insertions, 9 deletions
diff --git a/src/state/models/liked-by-view.ts b/src/state/models/liked-by-view.ts new file mode 100644 index 000000000..e9548f27b --- /dev/null +++ b/src/state/models/liked-by-view.ts @@ -0,0 +1,141 @@ +import {makeAutoObservable, runInAction} from 'mobx' +import {bsky, AdxUri} from '@adxp/mock-api' +import {RootStoreModel} from './root-store' + +type LikedByItem = bsky.LikedByView.Response['likedBy'][number] + +export class LikedByViewItemModel implements LikedByItem { + // ui state + _reactKey: string = '' + + // data + did: string = '' + name: string = '' + displayName: string = '' + createdAt?: string + indexedAt: string = '' + + constructor(reactKey: string, v: LikedByItem) { + makeAutoObservable(this) + this._reactKey = reactKey + Object.assign(this, v) + } +} + +export class LikedByViewModel implements bsky.LikedByView.Response { + // state + isLoading = false + isRefreshing = false + hasLoaded = false + error = '' + resolvedUri = '' + params: bsky.LikedByView.Params + + // data + uri: string = '' + likedBy: LikedByViewItemModel[] = [] + + constructor( + public rootStore: RootStoreModel, + params: bsky.LikedByView.Params, + ) { + makeAutoObservable( + this, + { + rootStore: false, + params: false, + }, + {autoBind: true}, + ) + this.params = params + } + + get hasContent() { + return this.uri !== '' + } + + get hasError() { + return this.error !== '' + } + + get isEmpty() { + return this.hasLoaded && !this.hasContent + } + + // public api + // = + + async setup() { + if (!this.resolvedUri) { + await this._resolveUri() + } + await this._fetch() + } + + async refresh() { + await this._refresh() + } + + // state transitions + // = + + private _xLoading(isRefreshing = false) { + this.isLoading = true + this.isRefreshing = isRefreshing + this.error = '' + } + + private _xIdle(err: string = '') { + this.isLoading = false + this.isRefreshing = false + this.hasLoaded = true + this.error = err + } + + // loader functions + // = + + private async _resolveUri() { + const urip = new AdxUri(this.params.uri) + if (!urip.host.startsWith('did:')) { + urip.host = await this.rootStore.resolveName(urip.host) + } + runInAction(() => { + this.resolvedUri = urip.toString() + }) + } + + private async _fetch() { + this._xLoading() + await new Promise(r => setTimeout(r, 250)) // DEBUG + try { + const res = (await this.rootStore.api.mainPds.view( + 'blueskyweb.xyz:LikedByView', + Object.assign({}, this.params, {uri: this.resolvedUri}), + )) as bsky.LikedByView.Response + this._replaceAll(res) + this._xIdle() + } catch (e: any) { + this._xIdle(`Failed to load feed: ${e.toString()}`) + } + } + + private async _refresh() { + this._xLoading(true) + // TODO: refetch and update items + await new Promise(r => setTimeout(r, 250)) // DEBUG + this._xIdle() + } + + private _replaceAll(res: bsky.LikedByView.Response) { + this.likedBy.length = 0 + let counter = 0 + for (const item of res.likedBy) { + this._append(counter++, item) + } + } + + private _append(keyId: number, item: LikedByItem) { + this.likedBy.push(new LikedByViewItemModel(`item-${keyId}`, item)) + } +} diff --git a/src/state/models/reposted-by-view.ts b/src/state/models/reposted-by-view.ts new file mode 100644 index 000000000..a5f6cce25 --- /dev/null +++ b/src/state/models/reposted-by-view.ts @@ -0,0 +1,141 @@ +import {makeAutoObservable, runInAction} from 'mobx' +import {bsky, AdxUri} from '@adxp/mock-api' +import {RootStoreModel} from './root-store' + +type RepostedByItem = bsky.RepostedByView.Response['repostedBy'][number] + +export class RepostedByViewItemModel implements RepostedByItem { + // ui state + _reactKey: string = '' + + // data + did: string = '' + name: string = '' + displayName: string = '' + createdAt?: string + indexedAt: string = '' + + constructor(reactKey: string, v: RepostedByItem) { + makeAutoObservable(this) + this._reactKey = reactKey + Object.assign(this, v) + } +} + +export class RepostedByViewModel implements bsky.RepostedByView.Response { + // state + isLoading = false + isRefreshing = false + hasLoaded = false + error = '' + resolvedUri = '' + params: bsky.RepostedByView.Params + + // data + uri: string = '' + repostedBy: RepostedByViewItemModel[] = [] + + constructor( + public rootStore: RootStoreModel, + params: bsky.RepostedByView.Params, + ) { + makeAutoObservable( + this, + { + rootStore: false, + params: false, + }, + {autoBind: true}, + ) + this.params = params + } + + get hasContent() { + return this.uri !== '' + } + + get hasError() { + return this.error !== '' + } + + get isEmpty() { + return this.hasLoaded && !this.hasContent + } + + // public api + // = + + async setup() { + if (!this.resolvedUri) { + await this._resolveUri() + } + await this._fetch() + } + + async refresh() { + await this._refresh() + } + + // state transitions + // = + + private _xLoading(isRefreshing = false) { + this.isLoading = true + this.isRefreshing = isRefreshing + this.error = '' + } + + private _xIdle(err: string = '') { + this.isLoading = false + this.isRefreshing = false + this.hasLoaded = true + this.error = err + } + + // loader functions + // = + + private async _resolveUri() { + const urip = new AdxUri(this.params.uri) + if (!urip.host.startsWith('did:')) { + urip.host = await this.rootStore.resolveName(urip.host) + } + runInAction(() => { + this.resolvedUri = urip.toString() + }) + } + + private async _fetch() { + this._xLoading() + await new Promise(r => setTimeout(r, 250)) // DEBUG + try { + const res = (await this.rootStore.api.mainPds.view( + 'blueskyweb.xyz:RepostedByView', + Object.assign({}, this.params, {uri: this.resolvedUri}), + )) as bsky.RepostedByView.Response + this._replaceAll(res) + this._xIdle() + } catch (e: any) { + this._xIdle(`Failed to load feed: ${e.toString()}`) + } + } + + private async _refresh() { + this._xLoading(true) + // TODO: refetch and update items + await new Promise(r => setTimeout(r, 250)) // DEBUG + this._xIdle() + } + + private _replaceAll(res: bsky.RepostedByView.Response) { + this.repostedBy.length = 0 + let counter = 0 + for (const item of res.repostedBy) { + this._append(counter++, item) + } + } + + private _append(keyId: number, item: RepostedByItem) { + this.repostedBy.push(new RepostedByViewItemModel(`item-${keyId}`, item)) + } +} diff --git a/src/view/com/post-thread/PostLikedBy.tsx b/src/view/com/post-thread/PostLikedBy.tsx new file mode 100644 index 000000000..678e069f6 --- /dev/null +++ b/src/view/com/post-thread/PostLikedBy.tsx @@ -0,0 +1,139 @@ +import React, {useState, useEffect} from 'react' +import {observer} from 'mobx-react-lite' +import { + ActivityIndicator, + FlatList, + Image, + StyleSheet, + Text, + TouchableOpacity, + View, +} from 'react-native' +import {OnNavigateContent} from '../../routes/types' +import { + LikedByViewModel, + LikedByViewItemModel, +} from '../../../state/models/liked-by-view' +import {useStores} from '../../../state' +import {s} from '../../lib/styles' +import {AVIS} from '../../lib/assets' + +export const PostLikedBy = observer(function PostLikedBy({ + uri, + onNavigateContent, +}: { + uri: string + onNavigateContent: OnNavigateContent +}) { + const store = useStores() + const [view, setView] = useState<LikedByViewModel | undefined>() + + useEffect(() => { + if (view?.params.uri === uri) { + console.log('Liked by doing nothing') + return // no change needed? or trigger refresh? + } + console.log('Fetching Liked by', uri) + const newView = new LikedByViewModel(store, {uri}) + setView(newView) + newView.setup().catch(err => console.error('Failed to fetch liked by', err)) + }, [uri, view?.params.uri, store]) + + // loading + // = + if ( + !view || + (view.isLoading && !view.isRefreshing) || + view.params.uri !== uri + ) { + return ( + <View> + <ActivityIndicator /> + </View> + ) + } + + // error + // = + if (view.hasError) { + return ( + <View> + <Text>{view.error}</Text> + </View> + ) + } + + // loaded + // = + const renderItem = ({item}: {item: LikedByViewItemModel}) => ( + <LikedByItem item={item} onNavigateContent={onNavigateContent} /> + ) + return ( + <View> + <FlatList + data={view.likedBy} + keyExtractor={item => item._reactKey} + renderItem={renderItem} + /> + </View> + ) +}) + +const LikedByItem = ({ + item, + onNavigateContent, +}: { + item: LikedByViewItemModel + onNavigateContent: OnNavigateContent +}) => { + const onPressOuter = () => { + onNavigateContent('Profile', { + name: item.name, + }) + } + return ( + <TouchableOpacity style={styles.outer} onPress={onPressOuter}> + <View style={styles.layout}> + <View style={styles.layoutAvi}> + <Image + style={styles.avi} + source={AVIS[item.name] || AVIS['alice.com']} + /> + </View> + <View style={styles.layoutContent}> + <Text style={[s.f15, s.bold]}>{item.displayName}</Text> + <Text style={[s.f14, s.gray]}>@{item.name}</Text> + </View> + </View> + </TouchableOpacity> + ) +} + +const styles = StyleSheet.create({ + outer: { + borderTopWidth: 1, + borderTopColor: '#e8e8e8', + backgroundColor: '#fff', + }, + layout: { + flexDirection: 'row', + }, + layoutAvi: { + width: 60, + paddingLeft: 10, + paddingTop: 10, + paddingBottom: 10, + }, + avi: { + width: 40, + height: 40, + borderRadius: 30, + resizeMode: 'cover', + }, + layoutContent: { + flex: 1, + paddingRight: 10, + paddingTop: 10, + paddingBottom: 10, + }, +}) diff --git a/src/view/com/post-thread/PostRepostedBy.tsx b/src/view/com/post-thread/PostRepostedBy.tsx new file mode 100644 index 000000000..98c24ef86 --- /dev/null +++ b/src/view/com/post-thread/PostRepostedBy.tsx @@ -0,0 +1,141 @@ +import React, {useState, useEffect} from 'react' +import {observer} from 'mobx-react-lite' +import { + ActivityIndicator, + FlatList, + Image, + StyleSheet, + Text, + TouchableOpacity, + View, +} from 'react-native' +import {OnNavigateContent} from '../../routes/types' +import { + RepostedByViewModel, + RepostedByViewItemModel, +} from '../../../state/models/reposted-by-view' +import {useStores} from '../../../state' +import {s} from '../../lib/styles' +import {AVIS} from '../../lib/assets' + +export const PostRepostedBy = observer(function PostRepostedBy({ + uri, + onNavigateContent, +}: { + uri: string + onNavigateContent: OnNavigateContent +}) { + const store = useStores() + const [view, setView] = useState<RepostedByViewModel | undefined>() + + useEffect(() => { + if (view?.params.uri === uri) { + console.log('Reposted by doing nothing') + return // no change needed? or trigger refresh? + } + console.log('Fetching Reposted by', uri) + const newView = new RepostedByViewModel(store, {uri}) + setView(newView) + newView + .setup() + .catch(err => console.error('Failed to fetch reposted by', err)) + }, [uri, view?.params.uri, store]) + + // loading + // = + if ( + !view || + (view.isLoading && !view.isRefreshing) || + view.params.uri !== uri + ) { + return ( + <View> + <ActivityIndicator /> + </View> + ) + } + + // error + // = + if (view.hasError) { + return ( + <View> + <Text>{view.error}</Text> + </View> + ) + } + + // loaded + // = + const renderItem = ({item}: {item: RepostedByViewItemModel}) => ( + <RepostedByItem item={item} onNavigateContent={onNavigateContent} /> + ) + return ( + <View> + <FlatList + data={view.repostedBy} + keyExtractor={item => item._reactKey} + renderItem={renderItem} + /> + </View> + ) +}) + +const RepostedByItem = ({ + item, + onNavigateContent, +}: { + item: RepostedByViewItemModel + onNavigateContent: OnNavigateContent +}) => { + const onPressOuter = () => { + onNavigateContent('Profile', { + name: item.name, + }) + } + return ( + <TouchableOpacity style={styles.outer} onPress={onPressOuter}> + <View style={styles.layout}> + <View style={styles.layoutAvi}> + <Image + style={styles.avi} + source={AVIS[item.name] || AVIS['alice.com']} + /> + </View> + <View style={styles.layoutContent}> + <Text style={[s.f15, s.bold]}>{item.displayName}</Text> + <Text style={[s.f14, s.gray]}>@{item.name}</Text> + </View> + </View> + </TouchableOpacity> + ) +} + +const styles = StyleSheet.create({ + outer: { + borderTopWidth: 1, + borderTopColor: '#e8e8e8', + backgroundColor: '#fff', + }, + layout: { + flexDirection: 'row', + }, + layoutAvi: { + width: 60, + paddingLeft: 10, + paddingTop: 10, + paddingBottom: 10, + }, + avi: { + width: 40, + height: 40, + borderRadius: 30, + resizeMode: 'cover', + }, + layoutContent: { + flex: 1, + paddingRight: 10, + paddingTop: 10, + paddingBottom: 10, + }, +}) diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx index 896eab89f..7263c61b3 100644 --- a/src/view/com/post-thread/PostThreadItem.tsx +++ b/src/view/com/post-thread/PostThreadItem.tsx @@ -40,6 +40,20 @@ export const PostThreadItem = observer(function PostThreadItem({ name: item.author.name, }) } + const onPressLikes = () => { + const urip = new AdxUri(item.uri) + onNavigateContent('PostLikedBy', { + name: item.author.name, + recordKey: urip.recordKey, + }) + } + const onPressReposts = () => { + const urip = new AdxUri(item.uri) + onNavigateContent('PostRepostedBy', { + name: item.author.name, + recordKey: urip.recordKey, + }) + } const onPressToggleRepost = () => { item .toggleRepost() @@ -91,7 +105,9 @@ export const PostThreadItem = observer(function PostThreadItem({ {item._isHighlightedPost && hasEngagement ? ( <View style={styles.expandedInfo}> {item.repostCount ? ( - <Text style={[styles.expandedInfoItem, s.gray, s.semiBold]}> + <Text + style={[styles.expandedInfoItem, s.gray, s.semiBold]} + onPress={onPressReposts}> <Text style={[s.bold, s.black]}>{item.repostCount}</Text>{' '} {pluralize(item.repostCount, 'repost')} </Text> @@ -99,7 +115,9 @@ export const PostThreadItem = observer(function PostThreadItem({ <></> )} {item.likeCount ? ( - <Text style={[styles.expandedInfoItem, s.gray, s.semiBold]}> + <Text + style={[styles.expandedInfoItem, s.gray, s.semiBold]} + onPress={onPressLikes}> <Text style={[s.bold, s.black]}>{item.likeCount}</Text>{' '} {pluralize(item.likeCount, 'like')} </Text> diff --git a/src/view/lib/strings.ts b/src/view/lib/strings.ts index 1be1112b1..3ef707dd8 100644 --- a/src/view/lib/strings.ts +++ b/src/view/lib/strings.ts @@ -1,3 +1,5 @@ +import {AdxUri} from '@adxp/mock-api' + export function pluralize(n: number, base: string, plural?: string): string { if (n === 1) { return base @@ -7,3 +9,15 @@ export function pluralize(n: number, base: string, plural?: string): string { } return base + 's' } + +export function makeRecordUri( + didOrName: string, + collection: string, + recordKey: string, +) { + const urip = new AdxUri(`adx://host/`) + urip.host = didOrName + urip.collection = collection + urip.recordKey = recordKey + return urip.toString() +} diff --git a/src/view/routes/index.tsx b/src/view/routes/index.tsx index c44076a6e..989fda470 100644 --- a/src/view/routes/index.tsx +++ b/src/view/routes/index.tsx @@ -19,6 +19,8 @@ import {Notifications} from '../screens/Notifications' import {Menu} from '../screens/Menu' import {Profile} from '../screens/content/Profile' import {PostThread} from '../screens/content/PostThread' +import {PostLikedBy} from '../screens/content/PostLikedBy' +import {PostRepostedBy} from '../screens/content/PostRepostedBy' import {Login} from '../screens/Login' import {Signup} from '../screens/Signup' import {NotFound} from '../screens/NotFound' @@ -38,6 +40,8 @@ const linking: LinkingOptions<RootTabsParamList> = { MenuTab: 'menu', Profile: 'profile/:name', PostThread: 'profile/:name/post/:recordKey', + PostLikedBy: 'profile/:name/post/:recordKey/liked-by', + PostRepostedBy: 'profile/:name/post/:recordKey/reposted-by', Login: 'login', Signup: 'signup', NotFound: '*', @@ -87,6 +91,8 @@ function HomeStackCom() { <HomeTabStack.Screen name="Home" component={Home} options={HIDE_HEADER} /> <HomeTabStack.Screen name="Profile" component={Profile} /> <HomeTabStack.Screen name="PostThread" component={PostThread} /> + <HomeTabStack.Screen name="PostLikedBy" component={PostLikedBy} /> + <HomeTabStack.Screen name="PostRepostedBy" component={PostRepostedBy} /> </HomeTabStack.Navigator> ) } @@ -101,6 +107,8 @@ function SearchStackCom() { /> <SearchTabStack.Screen name="Profile" component={Profile} /> <SearchTabStack.Screen name="PostThread" component={PostThread} /> + <SearchTabStack.Screen name="PostLikedBy" component={PostLikedBy} /> + <SearchTabStack.Screen name="PostRepostedBy" component={PostRepostedBy} /> </SearchTabStack.Navigator> ) } @@ -114,6 +122,14 @@ function NotificationsStackCom() { /> <NotificationsTabStack.Screen name="Profile" component={Profile} /> <NotificationsTabStack.Screen name="PostThread" component={PostThread} /> + <NotificationsTabStack.Screen + name="PostLikedBy" + component={PostLikedBy} + /> + <NotificationsTabStack.Screen + name="PostRepostedBy" + component={PostRepostedBy} + /> </NotificationsTabStack.Navigator> ) } diff --git a/src/view/routes/types.ts b/src/view/routes/types.ts index 0b4bbc5d7..fd58a7666 100644 --- a/src/view/routes/types.ts +++ b/src/view/routes/types.ts @@ -7,6 +7,8 @@ export type RootTabsParamList = { MenuTab: undefined Profile: {name: string} PostThread: {name: string; recordKey: string} + PostLikedBy: {name: string; recordKey: string} + PostRepostedBy: {name: string; recordKey: string} Login: undefined Signup: undefined NotFound: undefined diff --git a/src/view/screens/content/PostLikedBy.tsx b/src/view/screens/content/PostLikedBy.tsx new file mode 100644 index 000000000..f12990141 --- /dev/null +++ b/src/view/screens/content/PostLikedBy.tsx @@ -0,0 +1,38 @@ +import React, {useLayoutEffect} from 'react' +import {TouchableOpacity} from 'react-native' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {makeRecordUri} from '../../lib/strings' +import {Shell} from '../../shell' +import type {RootTabsScreenProps} from '../../routes/types' +import {PostLikedBy as PostLikedByComponent} from '../../com/post-thread/PostLikedBy' + +export const PostLikedBy = ({ + navigation, + route, +}: RootTabsScreenProps<'PostLikedBy'>) => { + const {name, recordKey} = route.params + const uri = makeRecordUri(name, 'blueskyweb.xyz:Posts', recordKey) + + useLayoutEffect(() => { + navigation.setOptions({ + headerShown: true, + headerTitle: 'Liked By', + headerLeft: () => ( + <TouchableOpacity onPress={() => navigation.goBack()}> + <FontAwesomeIcon icon="arrow-left" /> + </TouchableOpacity> + ), + }) + }, [navigation]) + + const onNavigateContent = (screen: string, props: Record<string, string>) => { + // @ts-ignore it's up to the callers to supply correct params -prf + navigation.push(screen, props) + } + + return ( + <Shell> + <PostLikedByComponent uri={uri} onNavigateContent={onNavigateContent} /> + </Shell> + ) +} diff --git a/src/view/screens/content/PostRepostedBy.tsx b/src/view/screens/content/PostRepostedBy.tsx new file mode 100644 index 000000000..000c1a7fc --- /dev/null +++ b/src/view/screens/content/PostRepostedBy.tsx @@ -0,0 +1,41 @@ +import React, {useLayoutEffect} from 'react' +import {TouchableOpacity} from 'react-native' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {makeRecordUri} from '../../lib/strings' +import {Shell} from '../../shell' +import type {RootTabsScreenProps} from '../../routes/types' +import {PostRepostedBy as PostRepostedByComponent} from '../../com/post-thread/PostRepostedBy' + +export const PostRepostedBy = ({ + navigation, + route, +}: RootTabsScreenProps<'PostRepostedBy'>) => { + const {name, recordKey} = route.params + const uri = makeRecordUri(name, 'blueskyweb.xyz:Posts', recordKey) + + useLayoutEffect(() => { + navigation.setOptions({ + headerShown: true, + headerTitle: 'Reposted By', + headerLeft: () => ( + <TouchableOpacity onPress={() => navigation.goBack()}> + <FontAwesomeIcon icon="arrow-left" /> + </TouchableOpacity> + ), + }) + }, [navigation]) + + const onNavigateContent = (screen: string, props: Record<string, string>) => { + // @ts-ignore it's up to the callers to supply correct params -prf + navigation.push(screen, props) + } + + return ( + <Shell> + <PostRepostedByComponent + uri={uri} + onNavigateContent={onNavigateContent} + /> + </Shell> + ) +} diff --git a/src/view/screens/content/PostThread.tsx b/src/view/screens/content/PostThread.tsx index fde74e778..485a2e49a 100644 --- a/src/view/screens/content/PostThread.tsx +++ b/src/view/screens/content/PostThread.tsx @@ -1,7 +1,7 @@ import React, {useLayoutEffect} from 'react' import {TouchableOpacity} from 'react-native' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {AdxUri} from '@adxp/mock-api' +import {makeRecordUri} from '../../lib/strings' import {Shell} from '../../shell' import type {RootTabsScreenProps} from '../../routes/types' import {PostThread as PostThreadComponent} from '../../com/post-thread/PostThread' @@ -11,12 +11,7 @@ export const PostThread = ({ route, }: RootTabsScreenProps<'PostThread'>) => { const {name, recordKey} = route.params - - const urip = new AdxUri(`adx://todo/`) - urip.host = name - urip.collection = 'blueskyweb.xyz:Posts' - urip.recordKey = recordKey - const uri = urip.toString() + const uri = makeRecordUri(name, 'blueskyweb.xyz:Posts', recordKey) useLayoutEffect(() => { navigation.setOptions({ |