about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
authorAnsh <anshnanda10@gmail.com>2023-06-27 08:11:05 -0700
committerGitHub <noreply@github.com>2023-06-27 10:11:05 -0500
commita8bbaa06c7266c73f6a71b5e9223c11c96995947 (patch)
treebae43bbbd724ceb513aa29339273418623b8d7f4 /src
parentbfaa6d73f37f251259c521befa9e9ee8ea877560 (diff)
downloadvoidsky-a8bbaa06c7266c73f6a71b5e9223c11c96995947.tar.zst
[APP-705] Metrics revamp pt2 (#896)
* export track function from analytics.tsx

* fix create account tracking

* fix tracking sign in

* add custom feed events

* fix type errors

* refactor create post event

* add profile follow & unfollow events

* refactor PostsFeedSliceModel into its own file

* refactor PostThreadItemModel into its own file

* reorganize code a lil bit

* refactor post-thread-item to use post-feed-item model under the hood

* add post events

* add post reply tracking

* track custom feed load more

* track list subscribe and unsubscribe
Diffstat (limited to 'src')
-rw-r--r--src/lib/analytics/analytics.tsx2
-rw-r--r--src/lib/analytics/types.ts38
-rw-r--r--src/state/models/content/list.ts3
-rw-r--r--src/state/models/content/post-thread-item.ts141
-rw-r--r--src/state/models/content/post-thread.ts227
-rw-r--r--src/state/models/content/profile.ts9
-rw-r--r--src/state/models/feeds/custom-feed.ts21
-rw-r--r--src/state/models/feeds/multi-feed.ts2
-rw-r--r--src/state/models/feeds/post.ts222
-rw-r--r--src/state/models/feeds/posts-slice.ts78
-rw-r--r--src/state/models/feeds/posts.ts23
-rw-r--r--src/state/models/ui/create-account.ts3
-rw-r--r--src/state/models/ui/saved-feeds.ts14
-rw-r--r--src/view/com/auth/create/CreateAccount.tsx3
-rw-r--r--src/view/com/auth/login/Login.tsx3
-rw-r--r--src/view/com/composer/Composer.tsx8
-rw-r--r--src/view/com/post-thread/PostThread.tsx6
-rw-r--r--src/view/com/post-thread/PostThreadItem.tsx2
-rw-r--r--src/view/com/post/Post.tsx6
-rw-r--r--src/view/com/posts/FeedSlice.tsx2
-rw-r--r--src/view/screens/CustomFeed.tsx5
-rw-r--r--src/view/screens/Profile.tsx2
22 files changed, 435 insertions, 385 deletions
diff --git a/src/lib/analytics/analytics.tsx b/src/lib/analytics/analytics.tsx
index d9d53e6a9..ab04bb88f 100644
--- a/src/lib/analytics/analytics.tsx
+++ b/src/lib/analytics/analytics.tsx
@@ -16,6 +16,8 @@ const segmentClient = createClient({
   trackAppLifecycleEvents: false,
 })
 
+export const track = segmentClient?.track?.bind?.(segmentClient) as TrackEvent
+
 export function useAnalytics() {
   const store = useStores()
   const methods: ClientMethods = useAnalyticsOrig()
diff --git a/src/lib/analytics/types.ts b/src/lib/analytics/types.ts
index 0638c6b77..062149d3d 100644
--- a/src/lib/analytics/types.ts
+++ b/src/lib/analytics/types.ts
@@ -11,6 +11,7 @@ interface TrackPropertiesMap {
   // LOGIN / SIGN UP events
   'Sign In': {resumedSession: boolean} // CAN BE SERVER
   'Create Account': {} // CAN BE SERVER
+  'Try Create Account': {}
   'Signin:PressedForgotPassword': {}
   'Signin:PressedSelectService': {}
   // COMPOSER / CREATE POST events
@@ -30,12 +31,28 @@ interface TrackPropertiesMap {
   // FEED events
   'Feed:onRefresh': {}
   'Feed:onEndReached': {}
+  // POST events
+  'Post:Like': {} // CAN BE SERVER
+  'Post:Unlike': {} // CAN BE SERVER
+  'Post:Repost': {} // CAN BE SERVER
+  'Post:Unrepost': {} // CAN BE SERVER
+  'Post:Delete': {} // CAN BE SERVER
+  'Post:ThreadMute': {} // CAN BE SERVER
+  'Post:ThreadUnmute': {} // CAN BE SERVER
+  'Post:Reply': {} // CAN BE SERVER
   // FEED ITEM events
   'FeedItem:PostReply': {} // CAN BE SERVER
   'FeedItem:PostRepost': {} // CAN BE SERVER
   'FeedItem:PostLike': {} // CAN BE SERVER
   'FeedItem:PostDelete': {} // CAN BE SERVER
   'FeedItem:ThreadMute': {} // CAN BE SERVER
+  // PROFILE events
+  'Profile:Follow': {
+    username: string
+  }
+  'Profile:Unfollow': {
+    username: string
+  }
   // PROFILE HEADER events
   'ProfileHeader:EditProfileButtonClicked': {}
   'ProfileHeader:FollowersButtonClicked': {}
@@ -72,7 +89,28 @@ interface TrackPropertiesMap {
   'Lists:onEndReached': {}
   'CreateMuteList:AvatarSelected': {}
   'CreateMuteList:Save': {} // CAN BE SERVER
+  'Lists:Subscribe': {} // CAN BE SERVER
+  'Lists:Unsubscribe': {} // CAN BE SERVER
   // CUSTOM FEED events
+  'CustomFeed:Save': {}
+  'CustomFeed:Unsave': {}
+  'CustomFeed:Like': {}
+  'CustomFeed:Unlike': {}
+  'CustomFeed:Share': {}
+  'CustomFeed:Pin': {
+    uri: string
+    name: string
+  }
+  'CustomFeed:Unpin': {
+    uri: string
+    name: string
+  }
+  'CustomFeed:Reorder': {
+    uri: string
+    name: string
+    index: number
+  }
+  'CustomFeed:LoadMore': {}
   'MultiFeed:onEndReached': {}
   'MultiFeed:onRefresh': {}
   // MODERATION events
diff --git a/src/state/models/content/list.ts b/src/state/models/content/list.ts
index 038e9fc30..d5c9e649e 100644
--- a/src/state/models/content/list.ts
+++ b/src/state/models/content/list.ts
@@ -11,6 +11,7 @@ import {RootStoreModel} from '../root-store'
 import * as apilib from 'lib/api/index'
 import {cleanError} from 'lib/strings/errors'
 import {bundleAsync} from 'lib/async/bundle'
+import {track} from 'lib/analytics/analytics'
 
 const PAGE_SIZE = 30
 
@@ -222,6 +223,7 @@ export class ListModel {
     await this.rootStore.agent.app.bsky.graph.muteActorList({
       list: this.list.uri,
     })
+    track('Lists:Subscribe')
     await this.refresh()
   }
 
@@ -232,6 +234,7 @@ export class ListModel {
     await this.rootStore.agent.app.bsky.graph.unmuteActorList({
       list: this.list.uri,
     })
+    track('Lists:Unsubscribe')
     await this.refresh()
   }
 
diff --git a/src/state/models/content/post-thread-item.ts b/src/state/models/content/post-thread-item.ts
new file mode 100644
index 000000000..c33415507
--- /dev/null
+++ b/src/state/models/content/post-thread-item.ts
@@ -0,0 +1,141 @@
+import {makeAutoObservable} from 'mobx'
+import {
+  AppBskyFeedPost as FeedPost,
+  AppBskyFeedDefs,
+  RichText,
+} from '@atproto/api'
+import {RootStoreModel} from '../root-store'
+import {PostLabelInfo, PostModeration} from 'lib/labeling/types'
+import {PostsFeedItemModel} from '../feeds/post'
+
+type PostView = AppBskyFeedDefs.PostView
+
+// NOTE: this model uses the same data as PostsFeedItemModel, but is used for
+// rendering a single post in a thread view, and has additional state
+// for rendering the thread view, but calls the same data methods
+// as PostsFeedItemModel
+// TODO: refactor as an extension or subclass of PostsFeedItemModel
+export class PostThreadItemModel {
+  // ui state
+  _reactKey: string = ''
+  _depth = 0
+  _isHighlightedPost = false
+  _showParentReplyLine = false
+  _showChildReplyLine = false
+  _hasMore = false
+
+  // data
+  data: PostsFeedItemModel
+  post: PostView
+  postRecord?: FeedPost.Record
+  richText?: RichText
+  parent?:
+    | PostThreadItemModel
+    | AppBskyFeedDefs.NotFoundPost
+    | AppBskyFeedDefs.BlockedPost
+  replies?: (PostThreadItemModel | AppBskyFeedDefs.NotFoundPost)[]
+
+  constructor(
+    public rootStore: RootStoreModel,
+    v: AppBskyFeedDefs.ThreadViewPost,
+  ) {
+    this._reactKey = `thread-${v.post.uri}`
+    this.data = new PostsFeedItemModel(rootStore, this._reactKey, v)
+    this.post = this.data.post
+    this.postRecord = this.data.postRecord
+    this.richText = this.data.richText
+    // replies and parent are handled via assignTreeModels
+    makeAutoObservable(this, {rootStore: false})
+  }
+
+  get uri() {
+    return this.post.uri
+  }
+  get parentUri() {
+    return this.postRecord?.reply?.parent.uri
+  }
+
+  get rootUri(): string {
+    if (this.postRecord?.reply?.root.uri) {
+      return this.postRecord.reply.root.uri
+    }
+    return this.post.uri
+  }
+  get isThreadMuted() {
+    return this.rootStore.mutedThreads.uris.has(this.rootUri)
+  }
+
+  get labelInfo(): PostLabelInfo {
+    return this.data.labelInfo
+  }
+
+  get moderation(): PostModeration {
+    return this.data.moderation
+  }
+
+  assignTreeModels(
+    v: AppBskyFeedDefs.ThreadViewPost,
+    highlightedPostUri: string,
+    includeParent = true,
+    includeChildren = true,
+  ) {
+    // parents
+    if (includeParent && v.parent) {
+      if (AppBskyFeedDefs.isThreadViewPost(v.parent)) {
+        const parentModel = new PostThreadItemModel(this.rootStore, v.parent)
+        parentModel._depth = this._depth - 1
+        parentModel._showChildReplyLine = true
+        if (v.parent.parent) {
+          parentModel._showParentReplyLine = true
+          parentModel.assignTreeModels(
+            v.parent,
+            highlightedPostUri,
+            true,
+            false,
+          )
+        }
+        this.parent = parentModel
+      } else if (AppBskyFeedDefs.isNotFoundPost(v.parent)) {
+        this.parent = v.parent
+      } else if (AppBskyFeedDefs.isBlockedPost(v.parent)) {
+        this.parent = v.parent
+      }
+    }
+    // replies
+    if (includeChildren && v.replies) {
+      const replies = []
+      for (const item of v.replies) {
+        if (AppBskyFeedDefs.isThreadViewPost(item)) {
+          const itemModel = new PostThreadItemModel(this.rootStore, item)
+          itemModel._depth = this._depth + 1
+          itemModel._showParentReplyLine =
+            itemModel.parentUri !== highlightedPostUri && replies.length === 0
+          if (item.replies?.length) {
+            itemModel._showChildReplyLine = true
+            itemModel.assignTreeModels(item, highlightedPostUri, false, true)
+          }
+          replies.push(itemModel)
+        } else if (AppBskyFeedDefs.isNotFoundPost(item)) {
+          replies.push(item)
+        }
+      }
+      this.replies = replies
+    }
+  }
+
+  async toggleLike() {
+    this.data.toggleLike()
+  }
+
+  async toggleRepost() {
+    this.data.toggleRepost()
+  }
+
+  async toggleThreadMute() {
+    this.data.toggleThreadMute()
+  }
+
+  async delete() {
+    this.data.delete()
+  }
+}
diff --git a/src/state/models/content/post-thread.ts b/src/state/models/content/post-thread.ts
index 577b76e01..0a67c783e 100644
--- a/src/state/models/content/post-thread.ts
+++ b/src/state/models/content/post-thread.ts
@@ -1,238 +1,13 @@
 import {makeAutoObservable, runInAction} from 'mobx'
 import {
   AppBskyFeedGetPostThread as GetPostThread,
-  AppBskyFeedPost as FeedPost,
   AppBskyFeedDefs,
-  RichText,
 } from '@atproto/api'
 import {AtUri} from '@atproto/api'
 import {RootStoreModel} from '../root-store'
 import * as apilib from 'lib/api/index'
 import {cleanError} from 'lib/strings/errors'
-import {updateDataOptimistically} from 'lib/async/revertible'
-import {PostLabelInfo, PostModeration} from 'lib/labeling/types'
-import {
-  getEmbedLabels,
-  getEmbedMuted,
-  getEmbedMutedByList,
-  getEmbedBlocking,
-  getEmbedBlockedBy,
-  filterAccountLabels,
-  filterProfileLabels,
-  getPostModeration,
-} from 'lib/labeling/helpers'
-
-export class PostThreadItemModel {
-  // ui state
-  _reactKey: string = ''
-  _depth = 0
-  _isHighlightedPost = false
-  _showParentReplyLine = false
-  _showChildReplyLine = false
-  _hasMore = false
-
-  // data
-  post: AppBskyFeedDefs.PostView
-  postRecord?: FeedPost.Record
-  parent?:
-    | PostThreadItemModel
-    | AppBskyFeedDefs.NotFoundPost
-    | AppBskyFeedDefs.BlockedPost
-  replies?: (PostThreadItemModel | AppBskyFeedDefs.NotFoundPost)[]
-  richText?: RichText
-
-  get uri() {
-    return this.post.uri
-  }
-
-  get parentUri() {
-    return this.postRecord?.reply?.parent.uri
-  }
-
-  get rootUri(): string {
-    if (this.postRecord?.reply?.root.uri) {
-      return this.postRecord.reply.root.uri
-    }
-    return this.uri
-  }
-
-  get isThreadMuted() {
-    return this.rootStore.mutedThreads.uris.has(this.rootUri)
-  }
-
-  get labelInfo(): PostLabelInfo {
-    return {
-      postLabels: (this.post.labels || []).concat(
-        getEmbedLabels(this.post.embed),
-      ),
-      accountLabels: filterAccountLabels(this.post.author.labels),
-      profileLabels: filterProfileLabels(this.post.author.labels),
-      isMuted:
-        this.post.author.viewer?.muted ||
-        getEmbedMuted(this.post.embed) ||
-        false,
-      mutedByList:
-        this.post.author.viewer?.mutedByList ||
-        getEmbedMutedByList(this.post.embed),
-      isBlocking:
-        !!this.post.author.viewer?.blocking ||
-        getEmbedBlocking(this.post.embed) ||
-        false,
-      isBlockedBy:
-        !!this.post.author.viewer?.blockedBy ||
-        getEmbedBlockedBy(this.post.embed) ||
-        false,
-    }
-  }
-
-  get moderation(): PostModeration {
-    return getPostModeration(this.rootStore, this.labelInfo)
-  }
-
-  constructor(
-    public rootStore: RootStoreModel,
-    v: AppBskyFeedDefs.ThreadViewPost,
-  ) {
-    this._reactKey = `thread-${v.post.uri}`
-    this.post = v.post
-    if (FeedPost.isRecord(this.post.record)) {
-      const valid = FeedPost.validateRecord(this.post.record)
-      if (valid.success) {
-        this.postRecord = this.post.record
-        this.richText = new RichText(this.postRecord, {cleanNewlines: true})
-      } else {
-        rootStore.log.warn(
-          'Received an invalid app.bsky.feed.post record',
-          valid.error,
-        )
-      }
-    } else {
-      rootStore.log.warn(
-        'app.bsky.feed.getPostThread served an unexpected record type',
-        this.post.record,
-      )
-    }
-    // replies and parent are handled via assignTreeModels
-    makeAutoObservable(this, {rootStore: false})
-  }
-
-  assignTreeModels(
-    v: AppBskyFeedDefs.ThreadViewPost,
-    highlightedPostUri: string,
-    includeParent = true,
-    includeChildren = true,
-  ) {
-    // parents
-    if (includeParent && v.parent) {
-      if (AppBskyFeedDefs.isThreadViewPost(v.parent)) {
-        const parentModel = new PostThreadItemModel(this.rootStore, v.parent)
-        parentModel._depth = this._depth - 1
-        parentModel._showChildReplyLine = true
-        if (v.parent.parent) {
-          parentModel._showParentReplyLine = true
-          parentModel.assignTreeModels(
-            v.parent,
-            highlightedPostUri,
-            true,
-            false,
-          )
-        }
-        this.parent = parentModel
-      } else if (AppBskyFeedDefs.isNotFoundPost(v.parent)) {
-        this.parent = v.parent
-      } else if (AppBskyFeedDefs.isBlockedPost(v.parent)) {
-        this.parent = v.parent
-      }
-    }
-    // replies
-    if (includeChildren && v.replies) {
-      const replies = []
-      for (const item of v.replies) {
-        if (AppBskyFeedDefs.isThreadViewPost(item)) {
-          const itemModel = new PostThreadItemModel(this.rootStore, item)
-          itemModel._depth = this._depth + 1
-          itemModel._showParentReplyLine =
-            itemModel.parentUri !== highlightedPostUri && replies.length === 0
-          if (item.replies?.length) {
-            itemModel._showChildReplyLine = true
-            itemModel.assignTreeModels(item, highlightedPostUri, false, true)
-          }
-          replies.push(itemModel)
-        } else if (AppBskyFeedDefs.isNotFoundPost(item)) {
-          replies.push(item)
-        }
-      }
-      this.replies = replies
-    }
-  }
-
-  async toggleLike() {
-    this.post.viewer = this.post.viewer || {}
-    if (this.post.viewer.like) {
-      const url = this.post.viewer.like
-      await updateDataOptimistically(
-        this.post,
-        () => {
-          this.post.likeCount = (this.post.likeCount || 0) - 1
-          this.post.viewer!.like = undefined
-        },
-        () => this.rootStore.agent.deleteLike(url),
-      )
-    } else {
-      await updateDataOptimistically(
-        this.post,
-        () => {
-          this.post.likeCount = (this.post.likeCount || 0) + 1
-          this.post.viewer!.like = 'pending'
-        },
-        () => this.rootStore.agent.like(this.post.uri, this.post.cid),
-        res => {
-          this.post.viewer!.like = res.uri
-        },
-      )
-    }
-  }
-
-  async toggleRepost() {
-    this.post.viewer = this.post.viewer || {}
-    if (this.post.viewer?.repost) {
-      const url = this.post.viewer.repost
-      await updateDataOptimistically(
-        this.post,
-        () => {
-          this.post.repostCount = (this.post.repostCount || 0) - 1
-          this.post.viewer!.repost = undefined
-        },
-        () => this.rootStore.agent.deleteRepost(url),
-      )
-    } else {
-      await updateDataOptimistically(
-        this.post,
-        () => {
-          this.post.repostCount = (this.post.repostCount || 0) + 1
-          this.post.viewer!.repost = 'pending'
-        },
-        () => this.rootStore.agent.repost(this.post.uri, this.post.cid),
-        res => {
-          this.post.viewer!.repost = res.uri
-        },
-      )
-    }
-  }
-
-  async toggleThreadMute() {
-    if (this.isThreadMuted) {
-      this.rootStore.mutedThreads.uris.delete(this.rootUri)
-    } else {
-      this.rootStore.mutedThreads.uris.add(this.rootUri)
-    }
-  }
-
-  async delete() {
-    await this.rootStore.agent.deletePost(this.post.uri)
-    this.rootStore.emitPostDeleted(this.post.uri)
-  }
-}
+import {PostThreadItemModel} from './post-thread-item'
 
 export class PostThreadModel {
   // state
diff --git a/src/state/models/content/profile.ts b/src/state/models/content/profile.ts
index 9d8378f79..34b2ea28e 100644
--- a/src/state/models/content/profile.ts
+++ b/src/state/models/content/profile.ts
@@ -18,6 +18,7 @@ import {
   filterAccountLabels,
   filterProfileLabels,
 } from 'lib/labeling/helpers'
+import {track} from 'lib/analytics/analytics'
 
 export class ProfileViewerModel {
   muted?: boolean
@@ -127,19 +128,27 @@ export class ProfileModel {
     }
 
     if (followUri) {
+      // unfollow
       await this.rootStore.agent.deleteFollow(followUri)
       runInAction(() => {
         this.followersCount--
         this.viewer.following = undefined
         this.rootStore.me.follows.removeFollow(this.did)
       })
+      track('Profile:Unfollow', {
+        username: this.handle,
+      })
     } else {
+      // follow
       const res = await this.rootStore.agent.follow(this.did)
       runInAction(() => {
         this.followersCount++
         this.viewer.following = res.uri
         this.rootStore.me.follows.addFollow(this.did, res.uri)
       })
+      track('Profile:Follow', {
+        username: this.handle,
+      })
     }
   }
 
diff --git a/src/state/models/feeds/custom-feed.ts b/src/state/models/feeds/custom-feed.ts
index 8fc1eb1ec..1303952ea 100644
--- a/src/state/models/feeds/custom-feed.ts
+++ b/src/state/models/feeds/custom-feed.ts
@@ -3,6 +3,7 @@ import {makeAutoObservable, runInAction} from 'mobx'
 import {RootStoreModel} from 'state/models/root-store'
 import {sanitizeDisplayName} from 'lib/strings/display-names'
 import {updateDataOptimistically} from 'lib/async/revertible'
+import {track} from 'lib/analytics/analytics'
 
 export class CustomFeedModel {
   // data
@@ -56,11 +57,23 @@ export class CustomFeedModel {
   // =
 
   async save() {
-    await this.rootStore.preferences.addSavedFeed(this.uri)
+    try {
+      await this.rootStore.preferences.addSavedFeed(this.uri)
+    } catch (error) {
+      this.rootStore.log.error('Failed to save feed', error)
+    } finally {
+      track('CustomFeed:Save')
+    }
   }
 
   async unsave() {
-    await this.rootStore.preferences.removeSavedFeed(this.uri)
+    try {
+      await this.rootStore.preferences.removeSavedFeed(this.uri)
+    } catch (error) {
+      this.rootStore.log.error('Failed to unsave feed', error)
+    } finally {
+      track('CustomFeed:Unsave')
+    }
   }
 
   async like() {
@@ -80,6 +93,8 @@ export class CustomFeedModel {
       )
     } catch (e: any) {
       this.rootStore.log.error('Failed to like feed', e)
+    } finally {
+      track('CustomFeed:Like')
     }
   }
 
@@ -100,6 +115,8 @@ export class CustomFeedModel {
       )
     } catch (e: any) {
       this.rootStore.log.error('Failed to unlike feed', e)
+    } finally {
+      track('CustomFeed:Unlike')
     }
   }
 
diff --git a/src/state/models/feeds/multi-feed.ts b/src/state/models/feeds/multi-feed.ts
index c2ca8d72f..1fc57a86b 100644
--- a/src/state/models/feeds/multi-feed.ts
+++ b/src/state/models/feeds/multi-feed.ts
@@ -4,7 +4,7 @@ import {bundleAsync} from 'lib/async/bundle'
 import {RootStoreModel} from '../root-store'
 import {CustomFeedModel} from './custom-feed'
 import {PostsFeedModel} from './posts'
-import {PostsFeedSliceModel} from './post'
+import {PostsFeedSliceModel} from './posts-slice'
 
 const FEED_PAGE_SIZE = 10
 const FEEDS_PAGE_SIZE = 3
diff --git a/src/state/models/feeds/post.ts b/src/state/models/feeds/post.ts
index 18a90ee82..8e3c9b03e 100644
--- a/src/state/models/feeds/post.ts
+++ b/src/state/models/feeds/post.ts
@@ -1,34 +1,35 @@
 import {makeAutoObservable} from 'mobx'
-import {AppBskyFeedDefs, AppBskyFeedPost, RichText} from '@atproto/api'
+import {
+  AppBskyFeedPost as FeedPost,
+  AppBskyFeedDefs,
+  RichText,
+} from '@atproto/api'
 import {RootStoreModel} from '../root-store'
 import {updateDataOptimistically} from 'lib/async/revertible'
 import {PostLabelInfo, PostModeration} from 'lib/labeling/types'
-import {FeedViewPostsSlice} from 'lib/api/feed-manip'
 import {
   getEmbedLabels,
   getEmbedMuted,
   getEmbedMutedByList,
   getEmbedBlocking,
   getEmbedBlockedBy,
-  getPostModeration,
   filterAccountLabels,
   filterProfileLabels,
-  mergePostModerations,
+  getPostModeration,
 } from 'lib/labeling/helpers'
+import {track} from 'lib/analytics/analytics'
 
 type FeedViewPost = AppBskyFeedDefs.FeedViewPost
 type ReasonRepost = AppBskyFeedDefs.ReasonRepost
 type PostView = AppBskyFeedDefs.PostView
 
-let _idCounter = 0
-
 export class PostsFeedItemModel {
   // ui state
   _reactKey: string = ''
 
   // data
   post: PostView
-  postRecord?: AppBskyFeedPost.Record
+  postRecord?: FeedPost.Record
   reply?: FeedViewPost['reply']
   reason?: FeedViewPost['reason']
   richText?: RichText
@@ -40,8 +41,8 @@ export class PostsFeedItemModel {
   ) {
     this._reactKey = reactKey
     this.post = v.post
-    if (AppBskyFeedPost.isRecord(this.post.record)) {
-      const valid = AppBskyFeedPost.validateRecord(this.post.record)
+    if (FeedPost.isRecord(this.post.record)) {
+      const valid = FeedPost.validateRecord(this.post.record)
       if (valid.success) {
         this.postRecord = this.post.record
         this.richText = new RichText(this.postRecord, {cleanNewlines: true})
@@ -66,6 +67,14 @@ export class PostsFeedItemModel {
     makeAutoObservable(this, {rootStore: false})
   }
 
+  get uri() {
+    return this.post.uri
+  }
+
+  get parentUri() {
+    return this.postRecord?.reply?.parent.uri
+  }
+
   get rootUri(): string {
     if (typeof this.reply?.root.uri === 'string') {
       return this.reply.root.uri
@@ -127,139 +136,94 @@ export class PostsFeedItemModel {
 
   async toggleLike() {
     this.post.viewer = this.post.viewer || {}
-    if (this.post.viewer.like) {
-      const url = this.post.viewer.like
-      await updateDataOptimistically(
-        this.post,
-        () => {
-          this.post.likeCount = (this.post.likeCount || 0) - 1
-          this.post.viewer!.like = undefined
-        },
-        () => this.rootStore.agent.deleteLike(url),
-      )
-    } else {
-      await updateDataOptimistically(
-        this.post,
-        () => {
-          this.post.likeCount = (this.post.likeCount || 0) + 1
-          this.post.viewer!.like = 'pending'
-        },
-        () => this.rootStore.agent.like(this.post.uri, this.post.cid),
-        res => {
-          this.post.viewer!.like = res.uri
-        },
-      )
+    try {
+      if (this.post.viewer.like) {
+        // unlike
+        const url = this.post.viewer.like
+        await updateDataOptimistically(
+          this.post,
+          () => {
+            this.post.likeCount = (this.post.likeCount || 0) - 1
+            this.post.viewer!.like = undefined
+          },
+          () => this.rootStore.agent.deleteLike(url),
+        )
+      } else {
+        // like
+        await updateDataOptimistically(
+          this.post,
+          () => {
+            this.post.likeCount = (this.post.likeCount || 0) + 1
+            this.post.viewer!.like = 'pending'
+          },
+          () => this.rootStore.agent.like(this.post.uri, this.post.cid),
+          res => {
+            this.post.viewer!.like = res.uri
+          },
+        )
+      }
+    } catch (error) {
+      this.rootStore.log.error('Failed to toggle like', error)
+    } finally {
+      track(this.post.viewer.like ? 'Post:Unlike' : 'Post:Like')
     }
   }
 
   async toggleRepost() {
     this.post.viewer = this.post.viewer || {}
-    if (this.post.viewer?.repost) {
-      const url = this.post.viewer.repost
-      await updateDataOptimistically(
-        this.post,
-        () => {
-          this.post.repostCount = (this.post.repostCount || 0) - 1
-          this.post.viewer!.repost = undefined
-        },
-        () => this.rootStore.agent.deleteRepost(url),
-      )
-    } else {
-      await updateDataOptimistically(
-        this.post,
-        () => {
-          this.post.repostCount = (this.post.repostCount || 0) + 1
-          this.post.viewer!.repost = 'pending'
-        },
-        () => this.rootStore.agent.repost(this.post.uri, this.post.cid),
-        res => {
-          this.post.viewer!.repost = res.uri
-        },
-      )
+    try {
+      if (this.post.viewer?.repost) {
+        const url = this.post.viewer.repost
+        await updateDataOptimistically(
+          this.post,
+          () => {
+            this.post.repostCount = (this.post.repostCount || 0) - 1
+            this.post.viewer!.repost = undefined
+          },
+          () => this.rootStore.agent.deleteRepost(url),
+        )
+      } else {
+        await updateDataOptimistically(
+          this.post,
+          () => {
+            this.post.repostCount = (this.post.repostCount || 0) + 1
+            this.post.viewer!.repost = 'pending'
+          },
+          () => this.rootStore.agent.repost(this.post.uri, this.post.cid),
+          res => {
+            this.post.viewer!.repost = res.uri
+          },
+        )
+      }
+    } catch (error) {
+      this.rootStore.log.error('Failed to toggle repost', error)
+    } finally {
+      track(this.post.viewer.repost ? 'Post:Unrepost' : 'Post:Repost')
     }
   }
 
   async toggleThreadMute() {
-    if (this.isThreadMuted) {
-      this.rootStore.mutedThreads.uris.delete(this.rootUri)
-    } else {
-      this.rootStore.mutedThreads.uris.add(this.rootUri)
+    try {
+      if (this.isThreadMuted) {
+        this.rootStore.mutedThreads.uris.delete(this.rootUri)
+      } else {
+        this.rootStore.mutedThreads.uris.add(this.rootUri)
+      }
+    } catch (error) {
+      this.rootStore.log.error('Failed to toggle thread mute', error)
+    } finally {
+      track(this.isThreadMuted ? 'Post:ThreadUnmute' : 'Post:ThreadMute')
     }
   }
 
   async delete() {
-    await this.rootStore.agent.deletePost(this.post.uri)
-    this.rootStore.emitPostDeleted(this.post.uri)
-  }
-}
-
-export class PostsFeedSliceModel {
-  // ui state
-  _reactKey: string = ''
-
-  // data
-  items: PostsFeedItemModel[] = []
-
-  constructor(
-    public rootStore: RootStoreModel,
-    reactKey: string,
-    slice: FeedViewPostsSlice,
-  ) {
-    this._reactKey = reactKey
-    for (const item of slice.items) {
-      this.items.push(
-        new PostsFeedItemModel(rootStore, `slice-${_idCounter++}`, item),
-      )
-    }
-    makeAutoObservable(this, {rootStore: false})
-  }
-
-  get uri() {
-    if (this.isReply) {
-      return this.items[1].post.uri
-    }
-    return this.items[0].post.uri
-  }
-
-  get isThread() {
-    return (
-      this.items.length > 1 &&
-      this.items.every(
-        item => item.post.author.did === this.items[0].post.author.did,
-      )
-    )
-  }
-
-  get isReply() {
-    return this.items.length > 1 && !this.isThread
-  }
-
-  get rootItem() {
-    if (this.isReply) {
-      return this.items[1]
-    }
-    return this.items[0]
-  }
-
-  get moderation() {
-    return mergePostModerations(this.items.map(item => item.moderation))
-  }
-
-  containsUri(uri: string) {
-    return !!this.items.find(item => item.post.uri === uri)
-  }
-
-  isThreadParentAt(i: number) {
-    if (this.items.length === 1) {
-      return false
-    }
-    return i < this.items.length - 1
-  }
-
-  isThreadChildAt(i: number) {
-    if (this.items.length === 1) {
-      return false
+    try {
+      await this.rootStore.agent.deletePost(this.post.uri)
+      this.rootStore.emitPostDeleted(this.post.uri)
+    } catch (error) {
+      this.rootStore.log.error('Failed to delete post', error)
+    } finally {
+      track('Post:Delete')
     }
-    return i > 0
   }
 }
diff --git a/src/state/models/feeds/posts-slice.ts b/src/state/models/feeds/posts-slice.ts
new file mode 100644
index 000000000..239bc5b6a
--- /dev/null
+++ b/src/state/models/feeds/posts-slice.ts
@@ -0,0 +1,78 @@
+import {makeAutoObservable} from 'mobx'
+import {RootStoreModel} from '../root-store'
+import {FeedViewPostsSlice} from 'lib/api/feed-manip'
+import {mergePostModerations} from 'lib/labeling/helpers'
+import {PostsFeedItemModel} from './post'
+
+let _idCounter = 0
+
+export class PostsFeedSliceModel {
+  // ui state
+  _reactKey: string = ''
+
+  // data
+  items: PostsFeedItemModel[] = []
+
+  constructor(
+    public rootStore: RootStoreModel,
+    reactKey: string,
+    slice: FeedViewPostsSlice,
+  ) {
+    this._reactKey = reactKey
+    for (const item of slice.items) {
+      this.items.push(
+        new PostsFeedItemModel(rootStore, `slice-${_idCounter++}`, item),
+      )
+    }
+    makeAutoObservable(this, {rootStore: false})
+  }
+
+  get uri() {
+    if (this.isReply) {
+      return this.items[1].post.uri
+    }
+    return this.items[0].post.uri
+  }
+
+  get isThread() {
+    return (
+      this.items.length > 1 &&
+      this.items.every(
+        item => item.post.author.did === this.items[0].post.author.did,
+      )
+    )
+  }
+
+  get isReply() {
+    return this.items.length > 1 && !this.isThread
+  }
+
+  get rootItem() {
+    if (this.isReply) {
+      return this.items[1]
+    }
+    return this.items[0]
+  }
+
+  get moderation() {
+    return mergePostModerations(this.items.map(item => item.moderation))
+  }
+
+  containsUri(uri: string) {
+    return !!this.items.find(item => item.post.uri === uri)
+  }
+
+  isThreadParentAt(i: number) {
+    if (this.items.length === 1) {
+      return false
+    }
+    return i < this.items.length - 1
+  }
+
+  isThreadChildAt(i: number) {
+    if (this.items.length === 1) {
+      return false
+    }
+    return i > 0
+  }
+}
diff --git a/src/state/models/feeds/posts.ts b/src/state/models/feeds/posts.ts
index 2c6f89c35..cd5e3c056 100644
--- a/src/state/models/feeds/posts.ts
+++ b/src/state/models/feeds/posts.ts
@@ -9,11 +9,17 @@ import {bundleAsync} from 'lib/async/bundle'
 import {RootStoreModel} from '../root-store'
 import {cleanError} from 'lib/strings/errors'
 import {FeedTuner, FeedViewPostsSlice} from 'lib/api/feed-manip'
-import {PostsFeedSliceModel} from './post'
+import {PostsFeedSliceModel} from './posts-slice'
+import {track} from 'lib/analytics/analytics'
 
 const PAGE_SIZE = 30
 let _idCounter = 0
 
+type QueryParams =
+  | GetTimeline.QueryParams
+  | GetAuthorFeed.QueryParams
+  | GetCustomFeed.QueryParams
+
 export class PostsFeedModel {
   // state
   isLoading = false
@@ -24,7 +30,7 @@ export class PostsFeedModel {
   isBlockedBy = false
   error = ''
   loadMoreError = ''
-  params: GetTimeline.QueryParams | GetAuthorFeed.QueryParams
+  params: QueryParams
   hasMore = true
   loadMoreCursor: string | undefined
   pollCursor: string | undefined
@@ -43,10 +49,7 @@ export class PostsFeedModel {
   constructor(
     public rootStore: RootStoreModel,
     public feedType: 'home' | 'author' | 'custom',
-    params:
-      | GetTimeline.QueryParams
-      | GetAuthorFeed.QueryParams
-      | GetCustomFeed.QueryParams,
+    params: QueryParams,
   ) {
     makeAutoObservable(
       this,
@@ -218,6 +221,9 @@ export class PostsFeedModel {
       }
     } finally {
       this.lock.release()
+      if (this.feedType === 'custom') {
+        track('CustomFeed:LoadMore')
+      }
     }
   })
 
@@ -416,10 +422,7 @@ export class PostsFeedModel {
   }
 
   protected async _getFeed(
-    params:
-      | GetTimeline.QueryParams
-      | GetAuthorFeed.QueryParams
-      | GetCustomFeed.QueryParams,
+    params: QueryParams,
   ): Promise<
     GetTimeline.Response | GetAuthorFeed.Response | GetCustomFeed.Response
   > {
diff --git a/src/state/models/ui/create-account.ts b/src/state/models/ui/create-account.ts
index 3f83dd6a7..78ffe8858 100644
--- a/src/state/models/ui/create-account.ts
+++ b/src/state/models/ui/create-account.ts
@@ -7,6 +7,7 @@ import * as EmailValidator from 'email-validator'
 import {createFullHandle} from 'lib/strings/handles'
 import {cleanError} from 'lib/strings/errors'
 import {getAge} from 'lib/strings/time'
+import {track} from 'lib/analytics/analytics'
 
 const DEFAULT_DATE = new Date(Date.now() - 60e3 * 60 * 24 * 365 * 20) // default to 20 years ago
 
@@ -117,6 +118,8 @@ export class CreateAccountModel {
       this.setIsProcessing(false)
       this.setError(cleanError(errMsg))
       throw e
+    } finally {
+      track('Create Account')
     }
   }
 
diff --git a/src/state/models/ui/saved-feeds.ts b/src/state/models/ui/saved-feeds.ts
index 40265f7cf..2dd72980d 100644
--- a/src/state/models/ui/saved-feeds.ts
+++ b/src/state/models/ui/saved-feeds.ts
@@ -3,6 +3,7 @@ import {RootStoreModel} from '../root-store'
 import {bundleAsync} from 'lib/async/bundle'
 import {cleanError} from 'lib/strings/errors'
 import {CustomFeedModel} from '../feeds/custom-feed'
+import {track} from 'lib/analytics/analytics'
 
 export class SavedFeedsModel {
   // state
@@ -143,8 +144,16 @@ export class SavedFeedsModel {
 
   async togglePinnedFeed(feed: CustomFeedModel) {
     if (!this.isPinned(feed)) {
+      track('CustomFeed:Pin', {
+        name: feed.data.displayName,
+        uri: feed.uri,
+      })
       return this.rootStore.preferences.addPinnedFeed(feed.uri)
     } else {
+      track('CustomFeed:Unpin', {
+        name: feed.data.displayName,
+        uri: feed.uri,
+      })
       return this.rootStore.preferences.removePinnedFeed(feed.uri)
     }
   }
@@ -185,6 +194,11 @@ export class SavedFeedsModel {
       this.rootStore.preferences.savedFeeds,
       pinned,
     )
+    track('CustomFeed:Reorder', {
+      name: item.data.displayName,
+      uri: item.uri,
+      index: pinned.indexOf(item.uri),
+    })
   }
 
   // state transitions
diff --git a/src/view/com/auth/create/CreateAccount.tsx b/src/view/com/auth/create/CreateAccount.tsx
index 97200709b..d6cb1a0a7 100644
--- a/src/view/com/auth/create/CreateAccount.tsx
+++ b/src/view/com/auth/create/CreateAccount.tsx
@@ -56,9 +56,10 @@ export const CreateAccount = observer(
       } else {
         try {
           await model.submit()
-          track('Create Account')
         } catch {
           // dont need to handle here
+        } finally {
+          track('Try Create Account')
         }
       }
     }, [model, track])
diff --git a/src/view/com/auth/login/Login.tsx b/src/view/com/auth/login/Login.tsx
index af4f01874..c76c33938 100644
--- a/src/view/com/auth/login/Login.tsx
+++ b/src/view/com/auth/login/Login.tsx
@@ -327,7 +327,6 @@ const LoginForm = ({
         identifier: fullIdent,
         password,
       })
-      track('Sign In', {resumedSession: false})
     } catch (e: any) {
       const errMsg = e.toString()
       store.log.warn('Failed to login', e)
@@ -341,6 +340,8 @@ const LoginForm = ({
       } else {
         setError(cleanError(errMsg))
       }
+    } finally {
+      track('Sign In', {resumedSession: false})
     }
   }
 
diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx
index fb6aaa231..4c73e58bf 100644
--- a/src/view/com/composer/Composer.tsx
+++ b/src/view/com/composer/Composer.tsx
@@ -169,9 +169,6 @@ export const ComposePost = observer(function ComposePost({
           knownHandles: autocompleteView.knownHandles,
           langs: store.preferences.postLanguages,
         })
-        track('Create Post', {
-          imageCount: gallery.size,
-        })
       } catch (e: any) {
         if (extLink) {
           setExtLink({
@@ -183,6 +180,11 @@ export const ComposePost = observer(function ComposePost({
         setError(cleanError(e.message))
         setIsProcessing(false)
         return
+      } finally {
+        track('Create Post', {
+          imageCount: gallery.size,
+        })
+        if (replyTo && replyTo.uri) track('Post:Reply')
       }
       if (!replyTo) {
         store.me.mainFeed.addPostToTop(createdPost.uri)
diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx
index 610b96507..51f63dbb3 100644
--- a/src/view/com/post-thread/PostThread.tsx
+++ b/src/view/com/post-thread/PostThread.tsx
@@ -9,10 +9,8 @@ import {
 } from 'react-native'
 import {AppBskyFeedDefs} from '@atproto/api'
 import {CenteredView, FlatList} from '../util/Views'
-import {
-  PostThreadModel,
-  PostThreadItemModel,
-} from 'state/models/content/post-thread'
+import {PostThreadModel} from 'state/models/content/post-thread'
+import {PostThreadItemModel} from 'state/models/content/post-thread-item'
 import {
   FontAwesomeIcon,
   FontAwesomeIconStyle,
diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx
index 647468401..002795d77 100644
--- a/src/view/com/post-thread/PostThreadItem.tsx
+++ b/src/view/com/post-thread/PostThreadItem.tsx
@@ -7,7 +7,7 @@ import {
   FontAwesomeIcon,
   FontAwesomeIconStyle,
 } from '@fortawesome/react-native-fontawesome'
-import {PostThreadItemModel} from 'state/models/content/post-thread'
+import {PostThreadItemModel} from 'state/models/content/post-thread-item'
 import {Link} from '../util/Link'
 import {RichText} from '../util/text/RichText'
 import {Text} from '../util/text/Text'
diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx
index b9d146dee..3eac7ee7b 100644
--- a/src/view/com/post/Post.tsx
+++ b/src/view/com/post/Post.tsx
@@ -13,10 +13,8 @@ import {observer} from 'mobx-react-lite'
 import Clipboard from '@react-native-clipboard/clipboard'
 import {AtUri} from '@atproto/api'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
-import {
-  PostThreadModel,
-  PostThreadItemModel,
-} from 'state/models/content/post-thread'
+import {PostThreadModel} from 'state/models/content/post-thread'
+import {PostThreadItemModel} from 'state/models/content/post-thread-item'
 import {Link} from '../util/Link'
 import {UserInfoText} from '../util/UserInfoText'
 import {PostMeta} from '../util/PostMeta'
diff --git a/src/view/com/posts/FeedSlice.tsx b/src/view/com/posts/FeedSlice.tsx
index 888466200..d75ff1385 100644
--- a/src/view/com/posts/FeedSlice.tsx
+++ b/src/view/com/posts/FeedSlice.tsx
@@ -1,6 +1,6 @@
 import React from 'react'
 import {StyleSheet, View} from 'react-native'
-import {PostsFeedSliceModel} from 'state/models/feeds/post'
+import {PostsFeedSliceModel} from 'state/models/feeds/posts-slice'
 import {AtUri} from '@atproto/api'
 import {Link} from '../util/Link'
 import {Text} from '../util/text/Text'
diff --git a/src/view/screens/CustomFeed.tsx b/src/view/screens/CustomFeed.tsx
index 4149cd49d..c0dcd7980 100644
--- a/src/view/screens/CustomFeed.tsx
+++ b/src/view/screens/CustomFeed.tsx
@@ -31,12 +31,14 @@ import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn'
 import {DropdownButton, DropdownItem} from 'view/com/util/forms/DropdownButton'
 import {useOnMainScroll} from 'lib/hooks/useOnMainScroll'
 import {EmptyState} from 'view/com/util/EmptyState'
+import {useAnalytics} from 'lib/analytics/analytics'
 
 type Props = NativeStackScreenProps<CommonNavigatorParams, 'CustomFeed'>
 export const CustomFeedScreen = withAuthRequired(
   observer(({route}: Props) => {
     const store = useStores()
     const pal = usePalette('default')
+    const {track} = useAnalytics()
     const {rkey, name} = route.params
     const uri = useMemo(
       () => makeRecordUri(name, 'app.bsky.feed.generator', rkey),
@@ -99,7 +101,8 @@ export const CustomFeedScreen = withAuthRequired(
     const onPressShare = React.useCallback(() => {
       const url = toShareUrl(`/profile/${name}/feed/${rkey}`)
       shareUrl(url)
-    }, [name, rkey])
+      track('CustomFeed:Share')
+    }, [name, rkey, track])
 
     const onScrollToTop = React.useCallback(() => {
       scrollElRef.current?.scrollToOffset({offset: 0, animated: true})
diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx
index f51bda825..390266440 100644
--- a/src/view/screens/Profile.tsx
+++ b/src/view/screens/Profile.tsx
@@ -9,7 +9,7 @@ import {CenteredView} from '../com/util/Views'
 import {ScreenHider} from 'view/com/util/moderation/ScreenHider'
 import {ProfileUiModel, Sections} from 'state/models/ui/profile'
 import {useStores} from 'state/index'
-import {PostsFeedSliceModel} from 'state/models/feeds/post'
+import {PostsFeedSliceModel} from 'state/models/feeds/posts-slice'
 import {ProfileHeader} from '../com/profile/ProfileHeader'
 import {FeedSlice} from '../com/posts/FeedSlice'
 import {ListCard} from 'view/com/lists/ListCard'