From ce83648f9da3a93018fc7845bec1d35c1519028d Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Fri, 22 Jul 2022 12:32:52 -0500 Subject: [PATCH] Add liked-by and reposted-by views --- src/state/models/liked-by-view.ts | 141 ++++++++++++++++++++ src/state/models/reposted-by-view.ts | 141 ++++++++++++++++++++ src/view/com/post-thread/PostLikedBy.tsx | 139 +++++++++++++++++++ src/view/com/post-thread/PostRepostedBy.tsx | 141 ++++++++++++++++++++ src/view/com/post-thread/PostThreadItem.tsx | 22 ++- src/view/lib/strings.ts | 14 ++ src/view/routes/index.tsx | 16 +++ src/view/routes/types.ts | 2 + src/view/screens/content/PostLikedBy.tsx | 38 ++++++ src/view/screens/content/PostRepostedBy.tsx | 41 ++++++ src/view/screens/content/PostThread.tsx | 9 +- todos.txt | 12 +- 12 files changed, 704 insertions(+), 12 deletions(-) create mode 100644 src/state/models/liked-by-view.ts create mode 100644 src/state/models/reposted-by-view.ts create mode 100644 src/view/com/post-thread/PostLikedBy.tsx create mode 100644 src/view/com/post-thread/PostRepostedBy.tsx create mode 100644 src/view/screens/content/PostLikedBy.tsx create mode 100644 src/view/screens/content/PostRepostedBy.tsx diff --git a/src/state/models/liked-by-view.ts b/src/state/models/liked-by-view.ts new file mode 100644 index 00000000..e9548f27 --- /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 00000000..a5f6cce2 --- /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 00000000..678e069f --- /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() + + 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 ( + + + + ) + } + + // error + // = + if (view.hasError) { + return ( + + {view.error} + + ) + } + + // loaded + // = + const renderItem = ({item}: {item: LikedByViewItemModel}) => ( + + ) + return ( + + item._reactKey} + renderItem={renderItem} + /> + + ) +}) + +const LikedByItem = ({ + item, + onNavigateContent, +}: { + item: LikedByViewItemModel + onNavigateContent: OnNavigateContent +}) => { + const onPressOuter = () => { + onNavigateContent('Profile', { + name: item.name, + }) + } + return ( + + + + + + + {item.displayName} + @{item.name} + + + + ) +} + +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 00000000..98c24ef8 --- /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() + + 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 ( + + + + ) + } + + // error + // = + if (view.hasError) { + return ( + + {view.error} + + ) + } + + // loaded + // = + const renderItem = ({item}: {item: RepostedByViewItemModel}) => ( + + ) + return ( + + item._reactKey} + renderItem={renderItem} + /> + + ) +}) + +const RepostedByItem = ({ + item, + onNavigateContent, +}: { + item: RepostedByViewItemModel + onNavigateContent: OnNavigateContent +}) => { + const onPressOuter = () => { + onNavigateContent('Profile', { + name: item.name, + }) + } + return ( + + + + + + + {item.displayName} + @{item.name} + + + + ) +} + +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 896eab89..7263c61b 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 ? ( {item.repostCount ? ( - + {item.repostCount}{' '} {pluralize(item.repostCount, 'repost')} @@ -99,7 +115,9 @@ export const PostThreadItem = observer(function PostThreadItem({ <> )} {item.likeCount ? ( - + {item.likeCount}{' '} {pluralize(item.likeCount, 'like')} diff --git a/src/view/lib/strings.ts b/src/view/lib/strings.ts index 1be1112b..3ef707dd 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 c44076a6..989fda47 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 = { 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() { + + ) } @@ -101,6 +107,8 @@ function SearchStackCom() { /> + + ) } @@ -114,6 +122,14 @@ function NotificationsStackCom() { /> + + ) } diff --git a/src/view/routes/types.ts b/src/view/routes/types.ts index 0b4bbc5d..fd58a766 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 00000000..f1299014 --- /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: () => ( + navigation.goBack()}> + + + ), + }) + }, [navigation]) + + const onNavigateContent = (screen: string, props: Record) => { + // @ts-ignore it's up to the callers to supply correct params -prf + navigation.push(screen, props) + } + + return ( + + + + ) +} diff --git a/src/view/screens/content/PostRepostedBy.tsx b/src/view/screens/content/PostRepostedBy.tsx new file mode 100644 index 00000000..000c1a7f --- /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: () => ( + navigation.goBack()}> + + + ), + }) + }, [navigation]) + + const onNavigateContent = (screen: string, props: Record) => { + // @ts-ignore it's up to the callers to supply correct params -prf + navigation.push(screen, props) + } + + return ( + + + + ) +} diff --git a/src/view/screens/content/PostThread.tsx b/src/view/screens/content/PostThread.tsx index fde74e77..485a2e49 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({ diff --git a/todos.txt b/todos.txt index 3a346498..7e38d530 100644 --- a/todos.txt +++ b/todos.txt @@ -1,15 +1,21 @@ Paul's todo list -- Posts in Thread and Feed view +- Feed view + - Refresh - Share btn - Thread view - - View likes list - - View reposts list + - Refresh + - Share btn - Reply control - Profile view + - Refresh - Follow / Unfollow - Badges - Compose post control +- Search view + - * +- Notifications view + - * - Linking - Web linking - App linking \ No newline at end of file