about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/state/lib/api.ts83
-rw-r--r--src/state/models/feed-view.ts63
-rw-r--r--src/state/models/post-thread-view.ts76
-rw-r--r--src/state/models/profile-view.ts12
-rw-r--r--src/view/com/feed/FeedItem.tsx49
-rw-r--r--src/view/com/post-thread/PostThread.tsx2
-rw-r--r--src/view/com/post-thread/PostThreadItem.tsx49
-rw-r--r--src/view/index.ts2
-rw-r--r--src/view/lib/styles.ts3
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},