From d1470bad6628022eda66c658d228cc7646abc746 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Tue, 26 Jul 2022 15:45:46 -0500 Subject: [PATCH] Add notifications view --- package.json | 2 +- src/state/models/feed-view.ts | 1 + src/state/models/notifications-view.ts | 304 ++++++++++++++++++++ src/state/models/post.ts | 93 ++++++ src/state/models/root-store.ts | 2 + src/view/com/notifications/Feed.tsx | 50 ++++ src/view/com/notifications/FeedItem.tsx | 136 +++++++++ src/view/com/post/Post.tsx | 225 +++++++++++++++ src/view/com/post/PostText.tsx | 53 ++++ src/view/screens/tabroots/Notifications.tsx | 69 ++++- todos.txt | 2 - yarn.lock | 4 +- 12 files changed, 929 insertions(+), 12 deletions(-) create mode 100644 src/state/models/notifications-view.ts create mode 100644 src/state/models/post.ts create mode 100644 src/view/com/notifications/Feed.tsx create mode 100644 src/view/com/notifications/FeedItem.tsx create mode 100644 src/view/com/post/Post.tsx create mode 100644 src/view/com/post/PostText.tsx diff --git a/package.json b/package.json index 622dcdaa..e27e0695 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "dependencies": { "@adxp/auth": "*", "@adxp/common": "*", - "@adxp/mock-api": "git+ssh://git@github.com:bluesky-social/adx-mock-api.git#6d700ac04affe31030120975c128f1849c8ae98e", + "@adxp/mock-api": "git+ssh://git@github.com:bluesky-social/adx-mock-api.git#e6f9ecd510fd54fbc5af32e319342634d9446a07", "@fortawesome/fontawesome-svg-core": "^6.1.1", "@fortawesome/free-regular-svg-icons": "^6.1.1", "@fortawesome/free-solid-svg-icons": "^6.1.1", diff --git a/src/state/models/feed-view.ts b/src/state/models/feed-view.ts index 5264aa27..e9405773 100644 --- a/src/state/models/feed-view.ts +++ b/src/state/models/feed-view.ts @@ -110,6 +110,7 @@ export class FeedViewModel implements bsky.FeedView.Response { { rootStore: false, params: false, + _loadPromise: false, _loadMorePromise: false, _updatePromise: false, }, diff --git a/src/state/models/notifications-view.ts b/src/state/models/notifications-view.ts new file mode 100644 index 00000000..ef8f1499 --- /dev/null +++ b/src/state/models/notifications-view.ts @@ -0,0 +1,304 @@ +import {makeAutoObservable, runInAction} from 'mobx' +import {bsky} from '@adxp/mock-api' +import {RootStoreModel} from './root-store' +import {hasProp} from '../lib/type-guards' + +export class NotificationsViewItemModel + implements bsky.NotificationsView.Notification +{ + // ui state + _reactKey: string = '' + + // data + uri: string = '' + author: { + did: string + name: string + displayName: string + } = {did: '', name: '', displayName: ''} + record: any = {} + isRead: boolean = false + indexedAt: string = '' + + constructor( + public rootStore: RootStoreModel, + reactKey: string, + v: bsky.NotificationsView.Notification, + ) { + makeAutoObservable(this, {rootStore: false}) + this._reactKey = reactKey + this.copy(v) + } + + copy(v: bsky.NotificationsView.Notification) { + this.uri = v.uri + this.author = v.author + this.record = v.record + this.isRead = v.isRead + this.indexedAt = v.indexedAt + } + + get isLike() { + return ( + hasProp(this.record, '$type') && + this.record.$type === 'blueskyweb.xyz:Like' + ) + } + + get isRepost() { + return ( + hasProp(this.record, '$type') && + this.record.$type === 'blueskyweb.xyz:Repost' + ) + } + + get isReply() { + return ( + hasProp(this.record, '$type') && + this.record.$type === 'blueskyweb.xyz:Post' + ) + } + + get isFollow() { + return ( + hasProp(this.record, '$type') && + this.record.$type === 'blueskyweb.xyz:Follow' + ) + } + + get subjectUri() { + if ( + hasProp(this.record, 'subject') && + typeof this.record.subject === 'string' + ) { + return this.record.subject + } + return '' + } +} + +export class NotificationsViewModel implements bsky.NotificationsView.Response { + // state + isLoading = false + isRefreshing = false + hasLoaded = false + error = '' + params: bsky.NotificationsView.Params + _loadPromise: Promise | undefined + _loadMorePromise: Promise | undefined + _updatePromise: Promise | undefined + + // data + notifications: NotificationsViewItemModel[] = [] + + constructor( + public rootStore: RootStoreModel, + params: bsky.NotificationsView.Params, + ) { + makeAutoObservable( + this, + { + rootStore: false, + params: false, + _loadPromise: false, + _loadMorePromise: false, + _updatePromise: false, + }, + {autoBind: true}, + ) + this.params = params + } + + get hasContent() { + return this.notifications.length !== 0 + } + + get hasError() { + return this.error !== '' + } + + get isEmpty() { + return this.hasLoaded && !this.hasContent + } + + get loadMoreCursor() { + if (this.hasContent) { + return this.notifications[this.notifications.length - 1].indexedAt + } + return undefined + } + + // public api + // = + + /** + * Load for first render + */ + async setup(isRefreshing = false) { + if (this._loadPromise) { + return this._loadPromise + } + await this._pendingWork() + this._loadPromise = this._initialLoad(isRefreshing) + await this._loadPromise + this._loadPromise = undefined + } + + /** + * Reset and load + */ + async refresh() { + return this.setup(true) + } + + /** + * Load more posts to the end of the notifications + */ + async loadMore() { + if (this._loadMorePromise) { + return this._loadMorePromise + } + await this._pendingWork() + this._loadMorePromise = this._loadMore() + await this._loadMorePromise + this._loadMorePromise = undefined + } + + /** + * Update content in-place + */ + async update() { + if (this._updatePromise) { + return this._updatePromise + } + await this._pendingWork() + this._updatePromise = this._update() + await this._updatePromise + this._updatePromise = undefined + } + + // 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 _pendingWork() { + if (this._loadPromise) { + await this._loadPromise + } + if (this._loadMorePromise) { + await this._loadMorePromise + } + if (this._updatePromise) { + await this._updatePromise + } + } + + private async _initialLoad(isRefreshing = false) { + this._xLoading(isRefreshing) + await new Promise(r => setTimeout(r, 250)) // DEBUG + try { + const res = (await this.rootStore.api.mainPds.view( + 'blueskyweb.xyz:NotificationsView', + this.params, + )) as bsky.NotificationsView.Response + this._replaceAll(res) + this._xIdle() + } catch (e: any) { + this._xIdle(`Failed to load notifications: ${e.toString()}`) + } + } + + private async _loadMore() { + this._xLoading() + await new Promise(r => setTimeout(r, 250)) // DEBUG + try { + const params = Object.assign({}, this.params, { + before: this.loadMoreCursor, + }) + const res = (await this.rootStore.api.mainPds.view( + 'blueskyweb.xyz:NotificationsView', + params, + )) as bsky.NotificationsView.Response + this._appendAll(res) + this._xIdle() + } catch (e: any) { + this._xIdle(`Failed to load notifications: ${e.toString()}`) + } + } + + private async _update() { + this._xLoading() + await new Promise(r => setTimeout(r, 250)) // DEBUG + let numToFetch = this.notifications.length + let cursor = undefined + try { + do { + const res = (await this.rootStore.api.mainPds.view( + 'blueskyweb.xyz:NotificationsView', + { + before: cursor, + limit: Math.min(numToFetch, 100), + }, + )) as bsky.NotificationsView.Response + if (res.notifications.length === 0) { + break // sanity check + } + this._updateAll(res) + numToFetch -= res.notifications.length + cursor = this.notifications[res.notifications.length - 1].indexedAt + console.log(numToFetch, cursor, res.notifications.length) + } while (numToFetch > 0) + this._xIdle() + } catch (e: any) { + this._xIdle(`Failed to update notifications: ${e.toString()}`) + } + } + + private _replaceAll(res: bsky.NotificationsView.Response) { + this.notifications.length = 0 + this._appendAll(res) + } + + private _appendAll(res: bsky.NotificationsView.Response) { + let counter = this.notifications.length + for (const item of res.notifications) { + this._append(counter++, item) + } + } + + private _append(keyId: number, item: bsky.NotificationsView.Notification) { + // TODO: validate .record + this.notifications.push( + new NotificationsViewItemModel(this.rootStore, `item-${keyId}`, item), + ) + } + + private _updateAll(res: bsky.NotificationsView.Response) { + for (const item of res.notifications) { + const existingItem = this.notifications.find( + // this find function has a key subtley- the indexedAt comparison + // the reason for this is reposts: they set the URI of the original post, not of the repost record + // the indexedAt time will be for the repost however, so we use that to help us + item2 => item.uri === item2.uri && item.indexedAt === item2.indexedAt, + ) + if (existingItem) { + existingItem.copy(item) + } + } + } +} diff --git a/src/state/models/post.ts b/src/state/models/post.ts new file mode 100644 index 00000000..46323010 --- /dev/null +++ b/src/state/models/post.ts @@ -0,0 +1,93 @@ +import {makeAutoObservable} from 'mobx' +import {bsky, AdxUri} from '@adxp/mock-api' +import {RootStoreModel} from './root-store' + +export type PostEntities = bsky.Post.Record['entities'] +export type PostReply = bsky.Post.Record['reply'] +export class PostModel implements bsky.Post.Record { + // state + isLoading = false + hasLoaded = false + error = '' + uri: string = '' + + // data + text: string = '' + entities?: PostEntities + reply?: PostReply + createdAt: string = '' + + constructor(public rootStore: RootStoreModel, uri: string) { + makeAutoObservable( + this, + { + rootStore: false, + uri: false, + }, + {autoBind: true}, + ) + this.uri = uri + } + + get hasContent() { + return this.createdAt !== '' + } + + get hasError() { + return this.error !== '' + } + + get isEmpty() { + return this.hasLoaded && !this.hasContent + } + + // public api + // = + + async setup() { + await this._load() + } + + // state transitions + // = + + private _xLoading() { + this.isLoading = true + this.error = '' + } + + private _xIdle(err: string = '') { + this.isLoading = false + this.hasLoaded = true + this.error = err + } + + // loader functions + // = + + private async _load() { + this._xLoading() + await new Promise(r => setTimeout(r, 250)) // DEBUG + try { + const urip = new AdxUri(this.uri) + const res = await this.rootStore.api.mainPds + .repo(urip.host, false) + .collection(urip.collection) + .get('Post', urip.recordKey) + if (!res.valid) { + throw new Error(res.error) + } + this._replaceAll(res.value) + this._xIdle() + } catch (e: any) { + this._xIdle(`Failed to load post: ${e.toString()}`) + } + } + + private _replaceAll(res: bsky.Post.Record) { + this.text = res.text + this.entities = res.entities + this.reply = res.reply + this.createdAt = res.createdAt + } +} diff --git a/src/state/models/root-store.ts b/src/state/models/root-store.ts index 7391a82b..e05c8638 100644 --- a/src/state/models/root-store.ts +++ b/src/state/models/root-store.ts @@ -9,11 +9,13 @@ import {isObj, hasProp} from '../lib/type-guards' import {SessionModel} from './session' import {MeModel} from './me' import {FeedViewModel} from './feed-view' +import {NotificationsViewModel} from './notifications-view' export class RootStoreModel { session = new SessionModel() me = new MeModel(this) homeFeed = new FeedViewModel(this, {}) + notesFeed = new NotificationsViewModel(this, {}) constructor(public api: AdxClient) { makeAutoObservable(this, { diff --git a/src/view/com/notifications/Feed.tsx b/src/view/com/notifications/Feed.tsx new file mode 100644 index 00000000..7c95003c --- /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}) => ( + + ) + 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.isLoading && !view.isRefreshing && !view.hasContent && ( + Loading... + )} + {view.hasError && {view.error}} + {view.hasContent && ( + item._reactKey} + renderItem={renderItem} + refreshing={view.isRefreshing} + onRefresh={onRefresh} + onEndReached={onEndReached} + /> + )} + {view.isEmpty && This feed is empty!} + + ) +}) diff --git a/src/view/com/notifications/FeedItem.tsx b/src/view/com/notifications/FeedItem.tsx new file mode 100644 index 00000000..1e0e4781 --- /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 ( + + + + + + + + + + {item.author.displayName} + + {action} + + {ago(item.indexedAt)} + + + {item.isLike || item.isRepost ? ( + + ) : ( + <> + )} + + + {item.isReply ? ( + + + + ) : ( + <> + )} + + ) +}) + +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 00000000..3cfb6a1a --- /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() + + 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 ( + + + + ) + } + + // error + // = + if (view.hasError || !view.thread) { + return ( + + {view.error || 'Thread not found'} + + ) + } + + // 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 ( + + + + + + + + + {item.author.displayName} + + + @{item.author.name} + + + · {ago(item.indexedAt)} + + + + {record.text} + + + + + {item.replyCount} + + + + + {item.repostCount} + + + + + + {item.likeCount} + + + + + + + + + + ) +}) + +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 00000000..541f2fc1 --- /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() + + 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 ( + + + + ) + } + + // error + // = + if (model.hasError) { + return ( + + {model.error} + + ) + } + + // loaded + // = + return ( + + {model.text} + + ) +}) diff --git a/src/view/screens/tabroots/Notifications.tsx b/src/view/screens/tabroots/Notifications.tsx index 091410ad..ea757679 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(false) + const store = useStores() + useEffect(() => { + console.log('Fetching home feed') + store.notesFeed.setup().then(() => setHasSetup(true)) + }, [store.notesFeed]) + + const onNavigateContent = (screen: string, props: Record) => { + // @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: () => ( + navigation.push('Profile', {name: 'alice.com'})}> + + + ), + headerRight: () => ( + { + navigation.push('Composer', {}) + }}> + + + ), + }) + }, [navigation]) -export const Notifications = ( - _props: RootTabsScreenProps<'NotificationsTab'>, -) => { return ( - - Notifications + + ) } + +const styles = StyleSheet.create({ + avi: { + width: 20, + height: 20, + borderRadius: 10, + resizeMode: 'cover', + }, +}) diff --git a/todos.txt b/todos.txt index a7153726..9a2d4115 100644 --- a/todos.txt +++ b/todos.txt @@ -4,8 +4,6 @@ Paul's todo list - Check on navigation stack during a bunch of replies - Search view - * -- Notifications view - - * - Linking - Web linking - App linking diff --git a/yarn.lock b/yarn.lock index 54d29f30..430fd7bf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -55,9 +55,9 @@ ucans "0.9.0-alpha3" uint8arrays "^3.0.0" -"@adxp/mock-api@git+ssh://git@github.com:bluesky-social/adx-mock-api.git#6d700ac04affe31030120975c128f1849c8ae98e": +"@adxp/mock-api@git+ssh://git@github.com:bluesky-social/adx-mock-api.git#e6f9ecd510fd54fbc5af32e319342634d9446a07": version "0.0.1" - resolved "git+ssh://git@github.com:bluesky-social/adx-mock-api.git#6d700ac04affe31030120975c128f1849c8ae98e" + resolved "git+ssh://git@github.com:bluesky-social/adx-mock-api.git#e6f9ecd510fd54fbc5af32e319342634d9446a07" dependencies: ajv "^8.11.0" ajv-formats "^2.1.1"