diff --git a/package.json b/package.json index e31d4544..e6c7fed0 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#19dc93e569fa71ae3de85876b3707afd47a6fe8c", + "@adxp/mock-api": "git+ssh://git@github.com:bluesky-social/adx-mock-api.git#0159e865560c12fb7004862c7d9d48420ed93878", "@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/lib/api.ts b/src/state/lib/api.ts index c2b99277..b3992544 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 cdad6783..2eced3dc 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 = '' + myState = new FeedViewItemMyStateModel() - constructor(reactKey: string, v: bsky.FeedView.FeedItem) { - makeAutoObservable(this) + 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 3c3b8d92..ef3a49e9 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 { let counter = 0 @@ -10,6 +11,15 @@ function* reactKeyGenerator(): Generator { } } +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 } } -} -const UNLOADED_THREAD = new PostThreadViewPostModel('') -export class PostThreadViewModel implements bsky.PostThreadView.Response { + 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 + }) + } + } +} + +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 836cb3f7..bca4c615 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 5e5a82a7..6ba1401c 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 ( @@ -75,21 +85,34 @@ export const FeedItem = observer(function FeedItem({ /> {item.replyCount} - + - {item.repostCount} - - + + {item.repostCount} + + + - {item.likeCount} - + + {item.likeCount} + + ( ) diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx index 8628f67c..896eab89 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 ( @@ -108,21 +118,34 @@ export const PostThreadItem = observer(function PostThreadItem({ /> {item.replyCount} - + - {item.repostCount} - - + + {item.repostCount} + + + - {item.likeCount} - + + {item.likeCount} + +