diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/state/lib/api.ts | 83 | ||||
-rw-r--r-- | src/state/models/feed-view.ts | 63 | ||||
-rw-r--r-- | src/state/models/post-thread-view.ts | 76 | ||||
-rw-r--r-- | src/state/models/profile-view.ts | 12 | ||||
-rw-r--r-- | src/view/com/feed/FeedItem.tsx | 49 | ||||
-rw-r--r-- | src/view/com/post-thread/PostThread.tsx | 2 | ||||
-rw-r--r-- | src/view/com/post-thread/PostThreadItem.tsx | 49 | ||||
-rw-r--r-- | src/view/index.ts | 2 | ||||
-rw-r--r-- | src/view/lib/styles.ts | 3 |
9 files changed, 304 insertions, 35 deletions
diff --git a/src/state/lib/api.ts b/src/state/lib/api.ts index c2b992777..b3992544b 100644 --- a/src/state/lib/api.ts +++ b/src/state/lib/api.ts @@ -4,7 +4,16 @@ */ // import {ReactNativeStore} from './auth' -import {AdxClient, AdxRepoClient, AdxUri, bsky} from '@adxp/mock-api' +import { + AdxClient, + AdxRepoClient, + AdxRepoCollectionClient, + AdxUri, + bsky, + SchemaOpt, + ListRecordsResponseValidated, + GetRecordResponseValidated, +} from '@adxp/mock-api' import * as storage from './storage' import {postTexts} from './mock-data/post-texts' import {replyTexts} from './mock-data/reply-texts' @@ -19,6 +28,78 @@ export async function setup(adx: AdxClient) { ) } +export async function like(adx: AdxClient, user: string, uri: string) { + await adx.repo(user, true).collection('blueskyweb.xyz:Likes').create('Like', { + $type: 'blueskyweb.xyz:Like', + subject: uri, + createdAt: new Date().toISOString(), + }) +} + +export async function unlike(adx: AdxClient, user: string, uri: string) { + const coll = adx.repo(user, true).collection('blueskyweb.xyz:Likes') + const numDels = await deleteWhere(coll, 'Like', record => { + return record.value.subject === uri + }) + return numDels > 0 +} + +export async function repost(adx: AdxClient, user: string, uri: string) { + await adx + .repo(user, true) + .collection('blueskyweb.xyz:Posts') + .create('Repost', { + $type: 'blueskyweb.xyz:Repost', + subject: uri, + createdAt: new Date().toISOString(), + }) +} + +export async function unrepost(adx: AdxClient, user: string, uri: string) { + const coll = adx.repo(user, true).collection('blueskyweb.xyz:Posts') + const numDels = await deleteWhere(coll, 'Repost', record => { + return record.value.subject === uri + }) + return numDels > 0 +} + +type WherePred = (record: GetRecordResponseValidated) => Boolean +async function deleteWhere( + coll: AdxRepoCollectionClient, + schema: SchemaOpt, + cond: WherePred, +) { + const toDelete: string[] = [] + iterateAll(coll, schema, record => { + if (cond(record)) { + toDelete.push(record.key) + } + }) + for (const key of toDelete) { + await coll.del(key) + } + return toDelete.length +} + +type IterateAllCb = (record: GetRecordResponseValidated) => void +async function iterateAll( + coll: AdxRepoCollectionClient, + schema: SchemaOpt, + cb: IterateAllCb, +) { + let cursor + let res: ListRecordsResponseValidated + do { + res = await coll.list(schema, {after: cursor, limit: 100}) + for (const record of res.records) { + if (record.valid) { + cb(record) + cursor = record.key + } + } + } while (res.records.length === 100) +} + // TEMPORARY // mock api config // ======= diff --git a/src/state/models/feed-view.ts b/src/state/models/feed-view.ts index cdad67839..2eced3dc9 100644 --- a/src/state/models/feed-view.ts +++ b/src/state/models/feed-view.ts @@ -1,6 +1,17 @@ -import {makeAutoObservable} from 'mobx' +import {makeAutoObservable, runInAction} from 'mobx' import {bsky} from '@adxp/mock-api' +import _omit from 'lodash.omit' import {RootStoreModel} from './root-store' +import * as apilib from '../lib/api' + +export class FeedViewItemMyStateModel { + hasLiked: boolean = false + hasReposted: boolean = false + + constructor() { + makeAutoObservable(this) + } +} export class FeedViewItemModel implements bsky.FeedView.FeedItem { // ui state @@ -19,11 +30,51 @@ export class FeedViewItemModel implements bsky.FeedView.FeedItem { repostCount: number = 0 likeCount: number = 0 indexedAt: string = '' - - constructor(reactKey: string, v: bsky.FeedView.FeedItem) { - makeAutoObservable(this) + myState = new FeedViewItemMyStateModel() + + constructor( + public rootStore: RootStoreModel, + reactKey: string, + v: bsky.FeedView.FeedItem, + ) { + makeAutoObservable(this, {rootStore: false}) this._reactKey = reactKey - Object.assign(this, v) + Object.assign(this, _omit(v, 'myState')) + if (v.myState) { + Object.assign(this.myState, v.myState) + } + } + + async toggleLike() { + if (this.myState.hasLiked) { + await apilib.unlike(this.rootStore.api, 'alice.com', this.uri) + runInAction(() => { + this.likeCount-- + this.myState.hasLiked = false + }) + } else { + await apilib.like(this.rootStore.api, 'alice.com', this.uri) + runInAction(() => { + this.likeCount++ + this.myState.hasLiked = true + }) + } + } + + async toggleRepost() { + if (this.myState.hasReposted) { + await apilib.unrepost(this.rootStore.api, 'alice.com', this.uri) + runInAction(() => { + this.repostCount-- + this.myState.hasReposted = false + }) + } else { + await apilib.repost(this.rootStore.api, 'alice.com', this.uri) + runInAction(() => { + this.repostCount++ + this.myState.hasReposted = true + }) + } } } @@ -177,6 +228,6 @@ export class FeedViewModel implements bsky.FeedView.Response { private _append(keyId: number, item: bsky.FeedView.FeedItem) { // TODO: validate .record - this.feed.push(new FeedViewItemModel(`item-${keyId}`, item)) + this.feed.push(new FeedViewItemModel(this.rootStore, `item-${keyId}`, item)) } } diff --git a/src/state/models/post-thread-view.ts b/src/state/models/post-thread-view.ts index 3c3b8d92d..ef3a49e9e 100644 --- a/src/state/models/post-thread-view.ts +++ b/src/state/models/post-thread-view.ts @@ -2,6 +2,7 @@ import {makeAutoObservable, runInAction} from 'mobx' import {bsky, AdxUri} from '@adxp/mock-api' import _omit from 'lodash.omit' import {RootStoreModel} from './root-store' +import * as apilib from '../lib/api' function* reactKeyGenerator(): Generator<string> { let counter = 0 @@ -10,6 +11,15 @@ function* reactKeyGenerator(): Generator<string> { } } +export class PostThreadViewPostMyStateModel { + hasLiked: boolean = false + hasReposted: boolean = false + + constructor() { + makeAutoObservable(this) + } +} + export class PostThreadViewPostModel implements bsky.PostThreadView.Post { // ui state _reactKey: string = '' @@ -30,12 +40,20 @@ export class PostThreadViewPostModel implements bsky.PostThreadView.Post { repostCount: number = 0 likeCount: number = 0 indexedAt: string = '' + myState = new PostThreadViewPostMyStateModel() - constructor(reactKey: string, v?: bsky.PostThreadView.Post) { - makeAutoObservable(this) + constructor( + public rootStore: RootStoreModel, + reactKey: string, + v?: bsky.PostThreadView.Post, + ) { + makeAutoObservable(this, {rootStore: false}) this._reactKey = reactKey if (v) { - Object.assign(this, _omit(v, 'parent', 'replies')) // copy everything but the replies and the parent + Object.assign(this, _omit(v, 'parent', 'replies', 'myState')) // replies and parent are handled via assignTreeModels + if (v.myState) { + Object.assign(this.myState, v.myState) + } } } @@ -44,6 +62,7 @@ export class PostThreadViewPostModel implements bsky.PostThreadView.Post { if (v.parent) { // TODO: validate .record const parentModel = new PostThreadViewPostModel( + this.rootStore, keyGen.next().value, v.parent, ) @@ -58,7 +77,11 @@ export class PostThreadViewPostModel implements bsky.PostThreadView.Post { const replies = [] for (const item of v.replies) { // TODO: validate .record - const itemModel = new PostThreadViewPostModel(keyGen.next().value, item) + const itemModel = new PostThreadViewPostModel( + this.rootStore, + keyGen.next().value, + item, + ) itemModel._depth = this._depth + 1 if (item.replies) { itemModel.assignTreeModels(keyGen, item) @@ -68,10 +91,41 @@ export class PostThreadViewPostModel implements bsky.PostThreadView.Post { this.replies = replies } } + + async toggleLike() { + if (this.myState.hasLiked) { + await apilib.unlike(this.rootStore.api, 'alice.com', this.uri) + runInAction(() => { + this.likeCount-- + this.myState.hasLiked = false + }) + } else { + await apilib.like(this.rootStore.api, 'alice.com', this.uri) + runInAction(() => { + this.likeCount++ + this.myState.hasLiked = true + }) + } + } + + async toggleRepost() { + if (this.myState.hasReposted) { + await apilib.unrepost(this.rootStore.api, 'alice.com', this.uri) + runInAction(() => { + this.repostCount-- + this.myState.hasReposted = false + }) + } else { + await apilib.repost(this.rootStore.api, 'alice.com', this.uri) + runInAction(() => { + this.repostCount++ + this.myState.hasReposted = true + }) + } + } } -const UNLOADED_THREAD = new PostThreadViewPostModel('') -export class PostThreadViewModel implements bsky.PostThreadView.Response { +export class PostThreadViewModel { // state isLoading = false isRefreshing = false @@ -81,7 +135,7 @@ export class PostThreadViewModel implements bsky.PostThreadView.Response { params: bsky.PostThreadView.Params // data - thread: PostThreadViewPostModel = UNLOADED_THREAD + thread?: PostThreadViewPostModel constructor( public rootStore: RootStoreModel, @@ -99,7 +153,7 @@ export class PostThreadViewModel implements bsky.PostThreadView.Response { } get hasContent() { - return this.thread !== UNLOADED_THREAD + return typeof this.thread !== 'undefined' } get hasError() { @@ -177,7 +231,11 @@ export class PostThreadViewModel implements bsky.PostThreadView.Response { private _replaceAll(res: bsky.PostThreadView.Response) { // TODO: validate .record const keyGen = reactKeyGenerator() - const thread = new PostThreadViewPostModel(keyGen.next().value, res.thread) + const thread = new PostThreadViewPostModel( + this.rootStore, + keyGen.next().value, + res.thread, + ) thread._isHighlightedPost = true thread.assignTreeModels(keyGen, res.thread) this.thread = thread diff --git a/src/state/models/profile-view.ts b/src/state/models/profile-view.ts index 836cb3f75..bca4c6158 100644 --- a/src/state/models/profile-view.ts +++ b/src/state/models/profile-view.ts @@ -2,6 +2,14 @@ import {makeAutoObservable} from 'mobx' import {bsky} from '@adxp/mock-api' import {RootStoreModel} from './root-store' +export class ProfileViewMyStateModel { + hasFollowed: boolean = false + + constructor() { + makeAutoObservable(this) + } +} + export class ProfileViewModel implements bsky.ProfileView.Response { // state isLoading = false @@ -19,6 +27,7 @@ export class ProfileViewModel implements bsky.ProfileView.Response { followsCount: number = 0 postsCount: number = 0 badges: bsky.ProfileView.Badge[] = [] + myState = new ProfileViewMyStateModel() constructor( public rootStore: RootStoreModel, @@ -101,5 +110,8 @@ export class ProfileViewModel implements bsky.ProfileView.Response { this.followsCount = res.followsCount this.postsCount = res.postsCount this.badges = res.badges + if (res.myState) { + Object.assign(this.myState, res.myState) + } } } diff --git a/src/view/com/feed/FeedItem.tsx b/src/view/com/feed/FeedItem.tsx index 5e5a82a77..6ba1401c9 100644 --- a/src/view/com/feed/FeedItem.tsx +++ b/src/view/com/feed/FeedItem.tsx @@ -30,6 +30,16 @@ export const FeedItem = observer(function FeedItem({ name: item.author.name, }) } + 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}> @@ -75,21 +85,34 @@ export const FeedItem = observer(function FeedItem({ /> <Text>{item.replyCount}</Text> </View> - <View style={styles.ctrl}> + <TouchableOpacity style={styles.ctrl} onPress={onPressToggleRepost}> <FontAwesomeIcon - style={styles.ctrlIcon} + style={ + item.myState.hasReposted + ? styles.ctrlIconReposted + : styles.ctrlIcon + } icon="retweet" size={22} /> - <Text>{item.repostCount}</Text> - </View> - <View style={styles.ctrl}> + <Text + style={ + item.myState.hasReposted ? [s.bold, s.green] : undefined + }> + {item.repostCount} + </Text> + </TouchableOpacity> + <TouchableOpacity style={styles.ctrl} onPress={onPressToggleLike}> <FontAwesomeIcon - style={styles.ctrlIcon} - icon={['far', 'heart']} + style={ + item.myState.hasLiked ? styles.ctrlIconLiked : styles.ctrlIcon + } + icon={[item.myState.hasLiked ? 'fas' : 'far', 'heart']} /> - <Text>{item.likeCount}</Text> - </View> + <Text style={item.myState.hasLiked ? [s.bold, s.red] : undefined}> + {item.likeCount} + </Text> + </TouchableOpacity> <View style={styles.ctrl}> <FontAwesomeIcon style={styles.ctrlIcon} @@ -158,4 +181,12 @@ const styles = StyleSheet.create({ marginRight: 5, color: 'gray', }, + ctrlIconReposted: { + marginRight: 5, + color: 'green', + }, + ctrlIconLiked: { + marginRight: 5, + color: 'red', + }, }) diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx index 3623abde4..8f70e1493 100644 --- a/src/view/com/post-thread/PostThread.tsx +++ b/src/view/com/post-thread/PostThread.tsx @@ -56,7 +56,7 @@ export const PostThread = observer(function PostThread({ // loaded // = - const posts = Array.from(flattenThread(view.thread)) + const posts = view.thread ? Array.from(flattenThread(view.thread)) : [] const renderItem = ({item}: {item: PostThreadViewPostModel}) => ( <PostThreadItem item={item} onNavigateContent={onNavigateContent} /> ) diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx index 8628f67c1..896eab89f 100644 --- a/src/view/com/post-thread/PostThreadItem.tsx +++ b/src/view/com/post-thread/PostThreadItem.tsx @@ -40,6 +40,16 @@ export const PostThreadItem = observer(function PostThreadItem({ name: item.author.name, }) } + 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}> @@ -108,21 +118,34 @@ export const PostThreadItem = observer(function PostThreadItem({ /> <Text>{item.replyCount}</Text> </View> - <View style={styles.ctrl}> + <TouchableOpacity style={styles.ctrl} onPress={onPressToggleRepost}> <FontAwesomeIcon - style={styles.ctrlIcon} + style={ + item.myState.hasReposted + ? styles.ctrlIconReposted + : styles.ctrlIcon + } icon="retweet" size={22} /> - <Text>{item.repostCount}</Text> - </View> - <View style={styles.ctrl}> + <Text + style={ + item.myState.hasReposted ? [s.bold, s.green] : undefined + }> + {item.repostCount} + </Text> + </TouchableOpacity> + <TouchableOpacity style={styles.ctrl} onPress={onPressToggleLike}> <FontAwesomeIcon - style={styles.ctrlIcon} - icon={['far', 'heart']} + style={ + item.myState.hasLiked ? styles.ctrlIconLiked : styles.ctrlIcon + } + icon={[item.myState.hasLiked ? 'fas' : 'far', 'heart']} /> - <Text>{item.likeCount}</Text> - </View> + <Text style={item.myState.hasLiked ? [s.bold, s.red] : undefined}> + {item.likeCount} + </Text> + </TouchableOpacity> <View style={styles.ctrl}> <FontAwesomeIcon style={styles.ctrlIcon} @@ -205,4 +228,12 @@ const styles = StyleSheet.create({ marginRight: 5, color: 'gray', }, + ctrlIconReposted: { + marginRight: 5, + color: 'green', + }, + ctrlIconLiked: { + marginRight: 5, + color: 'red', + }, }) diff --git a/src/view/index.ts b/src/view/index.ts index 8e3b00798..c80e929c1 100644 --- a/src/view/index.ts +++ b/src/view/index.ts @@ -6,6 +6,7 @@ import {faBars} from '@fortawesome/free-solid-svg-icons/faBars' import {faBell} from '@fortawesome/free-solid-svg-icons/faBell' import {faComment} from '@fortawesome/free-regular-svg-icons/faComment' import {faHeart} from '@fortawesome/free-regular-svg-icons/faHeart' +import {faHeart as fasHeart} from '@fortawesome/free-solid-svg-icons/faHeart' import {faHouse} from '@fortawesome/free-solid-svg-icons/faHouse' import {faMagnifyingGlass} from '@fortawesome/free-solid-svg-icons/faMagnifyingGlass' import {faShareFromSquare} from '@fortawesome/free-solid-svg-icons/faShareFromSquare' @@ -38,6 +39,7 @@ export function setup() { faBell, faComment, faHeart, + fasHeart, faHouse, faMagnifyingGlass, faRetweet, diff --git a/src/view/lib/styles.ts b/src/view/lib/styles.ts index d80e2d030..f0796723c 100644 --- a/src/view/lib/styles.ts +++ b/src/view/lib/styles.ts @@ -34,6 +34,9 @@ export const s = StyleSheet.create({ // colors black: {color: 'black'}, gray: {color: 'gray'}, + blue: {color: 'blue'}, + green: {color: 'green'}, + red: {color: 'red'}, // margins mr2: {marginRight: 2}, |