about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
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/state/models/ui/shell.ts3
-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.tsx41
-rw-r--r--src/view/com/composer/photos/Gallery.tsx244
-rw-r--r--src/view/com/modals/AltImage.tsx167
-rw-r--r--src/view/com/modals/Confirm.tsx16
-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/com/util/images/Gallery.tsx19
-rw-r--r--src/view/com/util/post-embeds/index.tsx19
-rw-r--r--src/view/screens/CustomFeed.tsx5
-rw-r--r--src/view/screens/Profile.tsx2
28 files changed, 734 insertions, 587 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/state/models/ui/shell.ts b/src/state/models/ui/shell.ts
index d6ece48aa..ba03fe1b5 100644
--- a/src/state/models/ui/shell.ts
+++ b/src/state/models/ui/shell.ts
@@ -7,6 +7,7 @@ import {Image as RNImage} from 'react-native-image-crop-picker'
 import {ImageModel} from '../media/image'
 import {ListModel} from '../content/list'
 import {GalleryModel} from '../media/gallery'
+import {StyleProp, ViewStyle} from 'react-native'
 
 export type ColorMode = 'system' | 'light' | 'dark'
 
@@ -20,6 +21,8 @@ export interface ConfirmModal {
   message: string | (() => JSX.Element)
   onPressConfirm: () => void | Promise<void>
   onPressCancel?: () => void | Promise<void>
+  confirmBtnText?: string
+  confirmBtnStyle?: StyleProp<ViewStyle>
 }
 
 export interface EditProfileModal {
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 52b90b6c7..fc324d3e5 100644
--- a/src/view/com/composer/Composer.tsx
+++ b/src/view/com/composer/Composer.tsx
@@ -92,18 +92,24 @@ export const ComposePost = observer(function ComposePost({
 
   const onPressCancel = useCallback(() => {
     if (graphemeLength > 0 || !gallery.isEmpty) {
+      if (store.shell.activeModals.some(modal => modal.name === 'confirm')) {
+        store.shell.closeModal()
+      }
       store.shell.openModal({
         name: 'confirm',
-        title: 'Cancel draft',
-        onPressConfirm: onClose,
+        title: 'Discard draft',
+        onPressConfirm: hackfixOnClose,
         onPressCancel: () => {
           store.shell.closeModal()
         },
-        message: "Are you sure you'd like to cancel this draft?",
+        message: "Are you sure you'd like to discard this draft?",
+        confirmBtnText: 'Discard',
+        confirmBtnStyle: {backgroundColor: colors.red4},
       })
+    } else {
+      hackfixOnClose()
     }
-    hackfixOnClose()
-  }, [store, hackfixOnClose, graphemeLength, gallery, onClose])
+  }, [store, hackfixOnClose, graphemeLength, gallery])
 
   // initial setup
   useEffect(() => {
@@ -114,14 +120,10 @@ export const ComposePost = observer(function ComposePost({
   const onEscape = useCallback(
     (e: KeyboardEvent) => {
       if (e.key === 'Escape') {
-        if (store.shell.activeModals.some(modal => modal.name === 'confirm')) {
-          store.shell.closeModal()
-        }
-
         onPressCancel()
       }
     },
-    [store, onPressCancel],
+    [onPressCancel],
   )
   useEffect(() => {
     if (isDesktopWeb) {
@@ -172,9 +174,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({
@@ -186,6 +185,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) {
         await store.me.mainFeed.addPostToTop(createdPost.uri)
@@ -227,13 +231,13 @@ export const ComposePost = observer(function ComposePost({
       <View style={[s.flex1, viewStyles]} aria-modal accessibilityViewIsModal>
         <View style={styles.topbar}>
           <TouchableOpacity
-            testID="composerCancelButton"
+            testID="composerDiscardButton"
             onPress={onPressCancel}
             onAccessibilityEscape={onPressCancel}
             accessibilityRole="button"
-            accessibilityLabel="Cancel"
-            accessibilityHint="Closes post composer">
-            <Text style={[pal.link, s.f18]}>Cancel</Text>
+            accessibilityLabel="Discard"
+            accessibilityHint="Closes post composer and discards post draft">
+            <Text style={[pal.link, s.f18, styles.discard]}>Discard</Text>
           </TouchableOpacity>
           <View style={s.flex1} />
           {isProcessing ? (
@@ -386,6 +390,9 @@ const styles = StyleSheet.create({
     paddingHorizontal: 20,
     height: 55,
   },
+  discard: {
+    color: colors.red3,
+  },
   postBtn: {
     borderRadius: 20,
     paddingHorizontal: 20,
diff --git a/src/view/com/composer/photos/Gallery.tsx b/src/view/com/composer/photos/Gallery.tsx
index f46c05333..c226d25cc 100644
--- a/src/view/com/composer/photos/Gallery.tsx
+++ b/src/view/com/composer/photos/Gallery.tsx
@@ -1,16 +1,16 @@
-import React, {useCallback} from 'react'
+import React from 'react'
 import {ImageStyle, Keyboard} from 'react-native'
 import {GalleryModel} from 'state/models/media/gallery'
 import {observer} from 'mobx-react-lite'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
-import {colors} from 'lib/styles'
+import {s, colors} from 'lib/styles'
 import {StyleSheet, TouchableOpacity, View} from 'react-native'
-import {ImageModel} from 'state/models/media/image'
 import {Image} from 'expo-image'
 import {Text} from 'view/com/util/text/Text'
 import {isDesktopWeb} from 'platform/detection'
 import {openAltTextModal} from 'lib/media/alt-text'
 import {useStores} from 'state/index'
+import {usePalette} from 'lib/hooks/usePalette'
 
 interface Props {
   gallery: GalleryModel
@@ -18,67 +18,39 @@ interface Props {
 
 export const Gallery = observer(function ({gallery}: Props) {
   const store = useStores()
-  const getImageStyle = useCallback(() => {
-    let side: number
+  const pal = usePalette('default')
 
-    if (gallery.size === 1) {
-      side = 250
-    } else {
-      side = (isDesktopWeb ? 560 : 350) / gallery.size
-    }
+  let side: number
 
-    return {
-      height: side,
-      width: side,
-    }
-  }, [gallery])
-
-  const imageStyle = getImageStyle()
-  const handleAddImageAltText = useCallback(
-    (image: ImageModel) => {
-      Keyboard.dismiss()
-      openAltTextModal(store, image)
-    },
-    [store],
-  )
-  const handleRemovePhoto = useCallback(
-    (image: ImageModel) => {
-      gallery.remove(image)
-    },
-    [gallery],
-  )
+  if (gallery.size === 1) {
+    side = 250
+  } else {
+    side = (isDesktopWeb ? 560 : 350) / gallery.size
+  }
 
-  const handleEditPhoto = useCallback(
-    (image: ImageModel) => {
-      gallery.edit(image)
-    },
-    [gallery],
-  )
+  const imageStyle = {
+    height: side,
+    width: side,
+  }
 
   const isOverflow = !isDesktopWeb && gallery.size > 2
 
-  const imageControlLabelStyle = {
-    borderRadius: 5,
-    paddingHorizontal: 10,
-    position: 'absolute' as const,
-    zIndex: 1,
-    ...(isOverflow
-      ? {
-          left: 4,
-          bottom: 4,
-        }
-      : isDesktopWeb && gallery.size < 3
-      ? {
-          left: 8,
-          top: 8,
-        }
-      : {
-          left: 4,
-          top: 4,
-        }),
-  }
+  const altTextControlStyle = isOverflow
+    ? {
+        left: 4,
+        bottom: 4,
+      }
+    : isDesktopWeb && gallery.size < 3
+    ? {
+        left: 8,
+        top: 8,
+      }
+    : {
+        left: 4,
+        top: 4,
+      }
 
-  const imageControlsSubgroupStyle = {
+  const imageControlsStyle = {
     display: 'flex' as const,
     flexDirection: 'row' as const,
     position: 'absolute' as const,
@@ -103,63 +75,90 @@ export const Gallery = observer(function ({gallery}: Props) {
   }
 
   return !gallery.isEmpty ? (
-    <View testID="selectedPhotosView" style={styles.gallery}>
-      {gallery.images.map(image => (
-        <View key={`selected-image-${image.path}`} style={[imageStyle]}>
-          <TouchableOpacity
-            testID="altTextButton"
-            accessibilityRole="button"
-            accessibilityLabel="Add alt text"
-            accessibilityHint=""
-            onPress={() => {
-              handleAddImageAltText(image)
-            }}
-            style={imageControlLabelStyle}>
-            <Text style={styles.imageControlTextContent}>ALT</Text>
-          </TouchableOpacity>
-          <View style={imageControlsSubgroupStyle}>
+    <>
+      <View testID="selectedPhotosView" style={styles.gallery}>
+        {gallery.images.map(image => (
+          <View key={`selected-image-${image.path}`} style={[imageStyle]}>
             <TouchableOpacity
-              testID="editPhotoButton"
+              testID="altTextButton"
               accessibilityRole="button"
-              accessibilityLabel="Edit image"
+              accessibilityLabel="Add alt text"
               accessibilityHint=""
               onPress={() => {
-                handleEditPhoto(image)
+                Keyboard.dismiss()
+                openAltTextModal(store, image)
               }}
-              style={styles.imageControl}>
-              <FontAwesomeIcon
-                icon="pen"
-                size={12}
-                style={{color: colors.white}}
-              />
+              style={[styles.altTextControl, altTextControlStyle]}>
+              <Text style={styles.altTextControlLabel}>ALT</Text>
+              {image.altText.length > 0 ? (
+                <FontAwesomeIcon
+                  icon="check"
+                  size={10}
+                  style={{color: colors.green3}}
+                />
+              ) : undefined}
             </TouchableOpacity>
+            <View style={imageControlsStyle}>
+              <TouchableOpacity
+                testID="editPhotoButton"
+                accessibilityRole="button"
+                accessibilityLabel="Edit image"
+                accessibilityHint=""
+                onPress={() => gallery.edit(image)}
+                style={styles.imageControl}>
+                <FontAwesomeIcon
+                  icon="pen"
+                  size={12}
+                  style={{color: colors.white}}
+                />
+              </TouchableOpacity>
+              <TouchableOpacity
+                testID="removePhotoButton"
+                accessibilityRole="button"
+                accessibilityLabel="Remove image"
+                accessibilityHint=""
+                onPress={() => gallery.remove(image)}
+                style={styles.imageControl}>
+                <FontAwesomeIcon
+                  icon="xmark"
+                  size={16}
+                  style={{color: colors.white}}
+                />
+              </TouchableOpacity>
+            </View>
             <TouchableOpacity
-              testID="removePhotoButton"
               accessibilityRole="button"
-              accessibilityLabel="Remove image"
+              accessibilityLabel="Add alt text"
               accessibilityHint=""
-              onPress={() => handleRemovePhoto(image)}
-              style={styles.imageControl}>
-              <FontAwesomeIcon
-                icon="xmark"
-                size={16}
-                style={{color: colors.white}}
-              />
-            </TouchableOpacity>
-          </View>
+              onPress={() => {
+                Keyboard.dismiss()
+                openAltTextModal(store, image)
+              }}
+              style={styles.altTextHiddenRegion}
+            />
 
-          <Image
-            testID="selectedPhotoImage"
-            style={[styles.image, imageStyle] as ImageStyle}
-            source={{
-              uri: image.cropped?.path ?? image.path,
-            }}
-            accessible={true}
-            accessibilityIgnoresInvertColors
-          />
+            <Image
+              testID="selectedPhotoImage"
+              style={[styles.image, imageStyle] as ImageStyle}
+              source={{
+                uri: image.cropped?.path ?? image.path,
+              }}
+              accessible={true}
+              accessibilityIgnoresInvertColors
+            />
+          </View>
+        ))}
+      </View>
+      <View style={[styles.reminder]}>
+        <View style={[styles.infoIcon, pal.viewLight]}>
+          <FontAwesomeIcon icon="info" size={12} color={pal.colors.text} />
         </View>
-      ))}
-    </View>
+        <Text type="sm" style={[pal.textLight, s.flex1]}>
+          Alt text describes images for blind and low-vision users, and helps
+          give context to everyone.
+        </Text>
+      </View>
+    </>
   ) : null
 })
 
@@ -179,19 +178,46 @@ const styles = StyleSheet.create({
     height: 24,
     borderRadius: 12,
     backgroundColor: 'rgba(0, 0, 0, 0.75)',
-    borderWidth: 0.5,
     alignItems: 'center',
     justifyContent: 'center',
   },
-  imageControlTextContent: {
+  altTextControl: {
+    position: 'absolute',
+    zIndex: 1,
     borderRadius: 6,
+    backgroundColor: 'rgba(0, 0, 0, 0.75)',
+    paddingHorizontal: 8,
+    paddingVertical: 3,
+    flexDirection: 'row',
+    alignItems: 'center',
+  },
+  altTextControlLabel: {
     color: 'white',
     fontSize: 12,
     fontWeight: 'bold',
     letterSpacing: 1,
-    backgroundColor: 'rgba(0, 0, 0, 0.75)',
-    borderWidth: 0.5,
-    paddingHorizontal: 10,
-    paddingVertical: 3,
+  },
+  altTextHiddenRegion: {
+    position: 'absolute',
+    left: 4,
+    right: 4,
+    bottom: 4,
+    top: 30,
+    zIndex: 1,
+  },
+
+  reminder: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    gap: 8,
+    borderRadius: 8,
+    paddingVertical: 14,
+  },
+  infoIcon: {
+    width: 22,
+    height: 22,
+    borderRadius: 12,
+    alignItems: 'center',
+    justifyContent: 'center',
   },
 })
diff --git a/src/view/com/modals/AltImage.tsx b/src/view/com/modals/AltImage.tsx
index 07270d557..e1145a0fe 100644
--- a/src/view/com/modals/AltImage.tsx
+++ b/src/view/com/modals/AltImage.tsx
@@ -1,5 +1,15 @@
-import React, {useCallback, useState} from 'react'
-import {StyleSheet, TextInput, TouchableOpacity, View} from 'react-native'
+import React, {useMemo, useCallback, useState} from 'react'
+import {
+  ImageStyle,
+  KeyboardAvoidingView,
+  ScrollView,
+  StyleSheet,
+  TextInput,
+  TouchableOpacity,
+  View,
+  useWindowDimensions,
+} from 'react-native'
+import {Image} from 'expo-image'
 import {usePalette} from 'lib/hooks/usePalette'
 import {gradients, s} from 'lib/styles'
 import {enforceLen} from 'lib/strings/helpers'
@@ -8,7 +18,7 @@ import {useTheme} from 'lib/ThemeContext'
 import {Text} from '../util/text/Text'
 import LinearGradient from 'react-native-linear-gradient'
 import {useStores} from 'state/index'
-import {isDesktopWeb} from 'platform/detection'
+import {isDesktopWeb, isAndroid} from 'platform/detection'
 import {ImageModel} from 'state/models/media/image'
 
 export const snapPoints = ['fullscreen']
@@ -22,6 +32,24 @@ export function Component({image}: Props) {
   const store = useStores()
   const theme = useTheme()
   const [altText, setAltText] = useState(image.altText)
+  const windim = useWindowDimensions()
+
+  const imageStyles = useMemo<ImageStyle>(() => {
+    const maxWidth = isDesktopWeb ? 450 : windim.width
+    if (image.height > image.width) {
+      return {
+        resizeMode: 'contain',
+        width: '100%',
+        aspectRatio: 1,
+        borderRadius: 8,
+      }
+    }
+    return {
+      width: '100%',
+      height: (maxWidth / image.width) * image.height,
+      borderRadius: 8,
+    }
+  }, [image, windim])
 
   const onPressSave = useCallback(() => {
     image.setAltText(altText)
@@ -33,69 +61,94 @@ export function Component({image}: Props) {
   }
 
   return (
-    <View
-      testID="altTextImageModal"
-      style={[pal.view, styles.container, s.flex1]}
-      nativeID="imageAltText">
-      <Text style={[styles.title, pal.text]}>Add alt text</Text>
-      <TextInput
-        testID="altTextImageInput"
-        style={[styles.textArea, pal.border, pal.text]}
-        keyboardAppearance={theme.colorScheme}
-        multiline
-        value={altText}
-        onChangeText={text => setAltText(enforceLen(text, MAX_ALT_TEXT))}
-        accessibilityLabel="Image alt text"
-        accessibilityHint="Sets image alt text for screenreaders"
-        accessibilityLabelledBy="imageAltText"
-      />
-      <View style={styles.buttonControls}>
-        <TouchableOpacity
-          testID="altTextImageSaveBtn"
-          onPress={onPressSave}
-          accessibilityLabel="Save alt text"
-          accessibilityHint={`Saves alt text, which reads: ${altText}`}
-          accessibilityRole="button">
-          <LinearGradient
-            colors={[gradients.blueLight.start, gradients.blueLight.end]}
-            start={{x: 0, y: 0}}
-            end={{x: 1, y: 1}}
-            style={[styles.button]}>
-            <Text type="button-lg" style={[s.white, s.bold]}>
-              Save
-            </Text>
-          </LinearGradient>
-        </TouchableOpacity>
-        <TouchableOpacity
-          testID="altTextImageCancelBtn"
-          onPress={onPressCancel}
-          accessibilityRole="button"
-          accessibilityLabel="Cancel add image alt text"
-          accessibilityHint="Exits adding alt text to image"
-          onAccessibilityEscape={onPressCancel}>
-          <View style={[styles.button]}>
-            <Text type="button-lg" style={[pal.textLight]}>
-              Cancel
-            </Text>
+    <KeyboardAvoidingView
+      behavior={isAndroid ? 'height' : 'padding'}
+      style={[pal.view, styles.container]}>
+      <ScrollView
+        testID="altTextImageModal"
+        style={styles.scrollContainer}
+        keyboardShouldPersistTaps="always"
+        nativeID="imageAltText">
+        <View style={styles.scrollInner}>
+          <View style={[pal.viewLight, styles.imageContainer]}>
+            <Image
+              testID="selectedPhotoImage"
+              style={imageStyles}
+              source={{
+                uri: image.cropped?.path ?? image.path,
+              }}
+              accessible={true}
+              accessibilityIgnoresInvertColors
+            />
+          </View>
+          <TextInput
+            testID="altTextImageInput"
+            style={[styles.textArea, pal.border, pal.text]}
+            keyboardAppearance={theme.colorScheme}
+            multiline
+            placeholder="Add alt text"
+            placeholderTextColor={pal.colors.textLight}
+            value={altText}
+            onChangeText={text => setAltText(enforceLen(text, MAX_ALT_TEXT))}
+            accessibilityLabel="Image alt text"
+            accessibilityHint=""
+            accessibilityLabelledBy="imageAltText"
+            autoFocus
+          />
+          <View style={styles.buttonControls}>
+            <TouchableOpacity
+              testID="altTextImageSaveBtn"
+              onPress={onPressSave}
+              accessibilityLabel="Save alt text"
+              accessibilityHint={`Saves alt text, which reads: ${altText}`}
+              accessibilityRole="button">
+              <LinearGradient
+                colors={[gradients.blueLight.start, gradients.blueLight.end]}
+                start={{x: 0, y: 0}}
+                end={{x: 1, y: 1}}
+                style={[styles.button]}>
+                <Text type="button-lg" style={[s.white, s.bold]}>
+                  Save
+                </Text>
+              </LinearGradient>
+            </TouchableOpacity>
+            <TouchableOpacity
+              testID="altTextImageCancelBtn"
+              onPress={onPressCancel}
+              accessibilityRole="button"
+              accessibilityLabel="Cancel add image alt text"
+              accessibilityHint=""
+              onAccessibilityEscape={onPressCancel}>
+              <View style={[styles.button]}>
+                <Text type="button-lg" style={[pal.textLight]}>
+                  Cancel
+                </Text>
+              </View>
+            </TouchableOpacity>
           </View>
-        </TouchableOpacity>
-      </View>
-    </View>
+        </View>
+      </ScrollView>
+    </KeyboardAvoidingView>
   )
 }
 
 const styles = StyleSheet.create({
   container: {
-    gap: 18,
-    paddingVertical: isDesktopWeb ? 0 : 18,
-    paddingHorizontal: isDesktopWeb ? 0 : 12,
+    flex: 1,
     height: '100%',
     width: '100%',
+    paddingVertical: isDesktopWeb ? 0 : 18,
+  },
+  scrollContainer: {
+    flex: 1,
+    height: '100%',
+    paddingHorizontal: isDesktopWeb ? 0 : 12,
+  },
+  scrollInner: {
+    gap: 12,
   },
-  title: {
-    textAlign: 'center',
-    fontWeight: 'bold',
-    fontSize: 24,
+  imageContainer: {
+    borderRadius: 8,
   },
   textArea: {
     borderWidth: 1,
diff --git a/src/view/com/modals/Confirm.tsx b/src/view/com/modals/Confirm.tsx
index 11e1a6334..f9bc0de14 100644
--- a/src/view/com/modals/Confirm.tsx
+++ b/src/view/com/modals/Confirm.tsx
@@ -12,6 +12,7 @@ import {ErrorMessage} from '../util/error/ErrorMessage'
 import {cleanError} from 'lib/strings/errors'
 import {usePalette} from 'lib/hooks/usePalette'
 import {isDesktopWeb} from 'platform/detection'
+import type {ConfirmModal} from 'state/models/ui/shell'
 
 export const snapPoints = ['50%']
 
@@ -20,12 +21,9 @@ export function Component({
   message,
   onPressConfirm,
   onPressCancel,
-}: {
-  title: string
-  message: string | (() => JSX.Element)
-  onPressConfirm: () => void | Promise<void>
-  onPressCancel?: () => void | Promise<void>
-}) {
+  confirmBtnText,
+  confirmBtnStyle,
+}: ConfirmModal) {
   const pal = usePalette('default')
   const store = useStores()
   const [isProcessing, setIsProcessing] = useState<boolean>(false)
@@ -68,11 +66,13 @@ export function Component({
         <TouchableOpacity
           testID="confirmBtn"
           onPress={onPress}
-          style={[styles.btn]}
+          style={[styles.btn, confirmBtnStyle]}
           accessibilityRole="button"
           accessibilityLabel="Confirm"
           accessibilityHint="">
-          <Text style={[s.white, s.bold, s.f18]}>Confirm</Text>
+          <Text style={[s.white, s.bold, s.f18]}>
+            {confirmBtnText ?? 'Confirm'}
+          </Text>
         </TouchableOpacity>
       )}
       {onPressCancel === undefined ? null : (
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/com/util/images/Gallery.tsx b/src/view/com/util/images/Gallery.tsx
index 723db289c..a7a64b171 100644
--- a/src/view/com/util/images/Gallery.tsx
+++ b/src/view/com/util/images/Gallery.tsx
@@ -45,23 +45,28 @@ export const GalleryItem: FC<GalleryItemProps> = ({
           accessibilityIgnoresInvertColors
         />
       </TouchableOpacity>
-      {image.alt === '' ? null : <Text style={styles.alt}>ALT</Text>}
+      {image.alt === '' ? null : (
+        <View style={styles.altContainer}>
+          <Text style={styles.alt}>ALT</Text>
+        </View>
+      )}
     </View>
   )
 }
 
 const styles = StyleSheet.create({
-  alt: {
+  altContainer: {
     backgroundColor: 'rgba(0, 0, 0, 0.75)',
     borderRadius: 6,
-    color: 'white',
-    fontSize: 12,
-    fontWeight: 'bold',
-    letterSpacing: 1,
-    paddingHorizontal: 10,
+    paddingHorizontal: 6,
     paddingVertical: 3,
     position: 'absolute',
     left: 6,
     bottom: 6,
   },
+  alt: {
+    color: 'white',
+    fontSize: 10,
+    fontWeight: 'bold',
+  },
 })
diff --git a/src/view/com/util/post-embeds/index.tsx b/src/view/com/util/post-embeds/index.tsx
index 53ef17318..7f2244b7b 100644
--- a/src/view/com/util/post-embeds/index.tsx
+++ b/src/view/com/util/post-embeds/index.tsx
@@ -126,7 +126,11 @@ export function PostEmbeds({
               onPress={() => openLightbox(0)}
               onPressIn={() => onPressIn(0)}
               style={styles.singleImage}>
-              {alt === '' ? null : <Text style={styles.alt}>ALT</Text>}
+              {alt === '' ? null : (
+                <View style={styles.altContainer}>
+                  <Text style={styles.alt}>ALT</Text>
+                </View>
+              )}
             </AutoSizedImage>
           </View>
         )
@@ -201,17 +205,18 @@ const styles = StyleSheet.create({
     borderRadius: 8,
     marginTop: 4,
   },
-  alt: {
+  altContainer: {
     backgroundColor: 'rgba(0, 0, 0, 0.75)',
     borderRadius: 6,
-    color: 'white',
-    fontSize: 12,
-    fontWeight: 'bold',
-    letterSpacing: 1,
-    paddingHorizontal: 10,
+    paddingHorizontal: 6,
     paddingVertical: 3,
     position: 'absolute',
     left: 6,
     bottom: 6,
   },
+  alt: {
+    color: 'white',
+    fontSize: 10,
+    fontWeight: 'bold',
+  },
 })
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'