about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
authorPaul Frazee <pfrazee@gmail.com>2023-03-31 13:17:26 -0500
committerGitHub <noreply@github.com>2023-03-31 13:17:26 -0500
commita3334a01a221877d3e06e02f960fda441f3460bd (patch)
tree64cdbb1232d1a3c00750c346b6e3ae529b51d1b0 /src
parent19f3a2fa92a61ddb785fc4e42d73792c1d0e772c (diff)
downloadvoidsky-a3334a01a221877d3e06e02f960fda441f3460bd.tar.zst
Lex refactor (#362)
* Remove the hackcheck for upgrades

* Rename the PostEmbeds folder to match the codebase style

* Updates to latest lex refactor

* Update to use new bsky agent

* Update to use api package's richtext library

* Switch to upsertProfile

* Add TextEncoder/TextDecoder polyfill

* Add Intl.Segmenter polyfill

* Update composer to calculate lengths by grapheme

* Fix detox

* Fix login in e2e

* Create account e2e passing

* Implement an e2e mocking framework

* Don't use private methods on mobx models as mobx can't track them

* Add tooling for e2e-specific builds and add e2e media-picker mock

* Add some tests and fix some bugs around profile editing

* Add shell tests

* Add home screen tests

* Add thread screen tests

* Add tests for other user profile screens

* Add search screen tests

* Implement profile imagery change tools and tests

* Update to new embed behaviors

* Add post tests

* Fix to profile-screen test

* Fix session resumption

* Update web composer to new api

* 1.11.0

* Fix pagination cursor parameters

* Add quote posts to notifications

* Fix embed layouts

* Remove youtube inline player and improve tap handling on link cards

* Reset minimal shell mode on all screen loads and feed swipes (close #299)

* Update podfile.lock

* Improve post notfound UI (close #366)

* Bump atproto packages
Diffstat (limited to 'src')
-rw-r--r--src/App.native.tsx1
-rw-r--r--src/Navigation.tsx4
-rw-r--r--src/lib/api/api-polyfill.ts8
-rw-r--r--src/lib/api/api-polyfill.web.ts3
-rw-r--r--src/lib/api/build-suggested-posts.ts22
-rw-r--r--src/lib/api/feed-manip.ts8
-rw-r--r--src/lib/api/index.ts176
-rw-r--r--src/lib/media/picker.e2e.tsx116
-rw-r--r--src/lib/notifee.ts4
-rw-r--r--src/lib/routes/types.ts2
-rw-r--r--src/lib/strings/rich-text-detection.ts59
-rw-r--r--src/lib/strings/rich-text-sanitize.ts32
-rw-r--r--src/lib/strings/rich-text.ts216
-rw-r--r--src/lib/styles.ts1
-rw-r--r--src/platform/polyfills.ts17
-rw-r--r--src/platform/polyfills.web.ts8
-rw-r--r--src/routes.ts2
-rw-r--r--src/state/index.ts4
-rw-r--r--src/state/models/cache/image-sizes.ts2
-rw-r--r--src/state/models/cache/my-follows.ts21
-rw-r--r--src/state/models/discovery/foafs.ts14
-rw-r--r--src/state/models/discovery/suggested-actors.ts20
-rw-r--r--src/state/models/feed-view.ts153
-rw-r--r--src/state/models/likes-view.ts (renamed from src/state/models/votes-view.ts)30
-rw-r--r--src/state/models/log.ts8
-rw-r--r--src/state/models/me.ts2
-rw-r--r--src/state/models/notifications-view.ts76
-rw-r--r--src/state/models/post-thread-view.ts135
-rw-r--r--src/state/models/post.ts12
-rw-r--r--src/state/models/profile-view.ts116
-rw-r--r--src/state/models/profiles-view.ts2
-rw-r--r--src/state/models/reposted-by-view.ts18
-rw-r--r--src/state/models/root-store.ts41
-rw-r--r--src/state/models/session.ts35
-rw-r--r--src/state/models/suggested-posts-view.ts4
-rw-r--r--src/state/models/ui/create-account.ts4
-rw-r--r--src/state/models/ui/profile.ts12
-rw-r--r--src/state/models/ui/search.ts8
-rw-r--r--src/state/models/ui/shell.ts2
-rw-r--r--src/state/models/user-autocomplete-view.ts18
-rw-r--r--src/state/models/user-followers-view.ts19
-rw-r--r--src/state/models/user-follows-view.ts19
-rw-r--r--src/view/com/auth/create/CreateAccount.tsx8
-rw-r--r--src/view/com/auth/create/Step1.tsx9
-rw-r--r--src/view/com/auth/create/Step2.tsx4
-rw-r--r--src/view/com/auth/create/Step3.tsx1
-rw-r--r--src/view/com/auth/login/Login.tsx10
-rw-r--r--src/view/com/composer/Composer.tsx73
-rw-r--r--src/view/com/composer/char-progress/CharProgress.tsx18
-rw-r--r--src/view/com/composer/photos/OpenCameraBtn.tsx6
-rw-r--r--src/view/com/composer/photos/SelectPhotoBtn.tsx6
-rw-r--r--src/view/com/composer/text-input/TextInput.tsx53
-rw-r--r--src/view/com/composer/text-input/TextInput.web.tsx15
-rw-r--r--src/view/com/discover/SuggestedFollows.tsx11
-rw-r--r--src/view/com/modals/ChangeHandle.tsx6
-rw-r--r--src/view/com/modals/Confirm.tsx4
-rw-r--r--src/view/com/modals/DeleteAccount.tsx4
-rw-r--r--src/view/com/modals/EditProfile.tsx14
-rw-r--r--src/view/com/modals/ReportAccount.tsx25
-rw-r--r--src/view/com/modals/ReportPost.tsx23
-rw-r--r--src/view/com/modals/Repost.tsx14
-rw-r--r--src/view/com/notifications/FeedItem.tsx23
-rw-r--r--src/view/com/pager/FeedsTabBar.tsx9
-rw-r--r--src/view/com/pager/Pager.tsx4
-rw-r--r--src/view/com/pager/TabBar.tsx9
-rw-r--r--src/view/com/post-thread/PostLikedBy.tsx (renamed from src/view/com/post-thread/PostVotedBy.tsx)19
-rw-r--r--src/view/com/post-thread/PostRepostedBy.tsx1
-rw-r--r--src/view/com/post-thread/PostThread.tsx60
-rw-r--r--src/view/com/post-thread/PostThreadItem.tsx271
-rw-r--r--src/view/com/post/Post.tsx17
-rw-r--r--src/view/com/posts/Feed.tsx1
-rw-r--r--src/view/com/posts/FeedItem.tsx24
-rw-r--r--src/view/com/profile/FollowButton.tsx7
-rw-r--r--src/view/com/profile/ProfileCard.tsx17
-rw-r--r--src/view/com/profile/ProfileFollowers.tsx3
-rw-r--r--src/view/com/profile/ProfileFollows.tsx3
-rw-r--r--src/view/com/profile/ProfileHeader.tsx185
-rw-r--r--src/view/com/search/SearchResults.tsx1
-rw-r--r--src/view/com/util/Link.tsx11
-rw-r--r--src/view/com/util/PostCtrls.tsx40
-rw-r--r--src/view/com/util/PostEmbeds/YoutubeEmbed.tsx119
-rw-r--r--src/view/com/util/PostMeta.tsx10
-rw-r--r--src/view/com/util/UserAvatar.tsx7
-rw-r--r--src/view/com/util/UserBanner.tsx21
-rw-r--r--src/view/com/util/ViewHeader.tsx2
-rw-r--r--src/view/com/util/ViewSelector.tsx63
-rw-r--r--src/view/com/util/forms/Button.tsx5
-rw-r--r--src/view/com/util/forms/DropdownButton.tsx27
-rw-r--r--src/view/com/util/forms/RadioButton.tsx4
-rw-r--r--src/view/com/util/forms/RadioGroup.tsx3
-rw-r--r--src/view/com/util/images/AutoSizedImage.tsx24
-rw-r--r--src/view/com/util/post-embeds/ExternalLinkEmbed.tsx (renamed from src/view/com/util/PostEmbeds/ExternalLinkEmbed.tsx)15
-rw-r--r--src/view/com/util/post-embeds/QuoteEmbed.tsx (renamed from src/view/com/util/PostEmbeds/QuoteEmbed.tsx)30
-rw-r--r--src/view/com/util/post-embeds/YoutubeEmbed.tsx55
-rw-r--r--src/view/com/util/post-embeds/index.tsx (renamed from src/view/com/util/PostEmbeds/index.tsx)61
-rw-r--r--src/view/com/util/text/RichText.tsx106
-rw-r--r--src/view/screens/Home.tsx25
-rw-r--r--src/view/screens/NotFound.tsx14
-rw-r--r--src/view/screens/Notifications.tsx3
-rw-r--r--src/view/screens/PostLikedBy.tsx (renamed from src/view/screens/PostUpvotedBy.tsx)8
-rw-r--r--src/view/screens/PostThread.tsx2
-rw-r--r--src/view/screens/Profile.tsx3
-rw-r--r--src/view/screens/Search.tsx1
-rw-r--r--src/view/shell/BottomBar.tsx7
-rw-r--r--src/view/shell/Drawer.tsx2
-rw-r--r--src/view/shell/index.tsx37
106 files changed, 1500 insertions, 1617 deletions
diff --git a/src/App.native.tsx b/src/App.native.tsx
index ebe6a7cd6..0adbae606 100644
--- a/src/App.native.tsx
+++ b/src/App.native.tsx
@@ -29,7 +29,6 @@ const App = observer(() => {
       analytics.init(store)
       notifee.init(store)
       SplashScreen.hide()
-      store.hackCheckIfUpgradeNeeded()
       Linking.getInitialURL().then((url: string | null) => {
         if (url) {
           handleLink(url)
diff --git a/src/Navigation.tsx b/src/Navigation.tsx
index 2bfc84ea9..a1dbc4af1 100644
--- a/src/Navigation.tsx
+++ b/src/Navigation.tsx
@@ -31,7 +31,7 @@ import {ProfileScreen} from './view/screens/Profile'
 import {ProfileFollowersScreen} from './view/screens/ProfileFollowers'
 import {ProfileFollowsScreen} from './view/screens/ProfileFollows'
 import {PostThreadScreen} from './view/screens/PostThread'
-import {PostUpvotedByScreen} from './view/screens/PostUpvotedBy'
+import {PostLikedByScreen} from './view/screens/PostLikedBy'
 import {PostRepostedByScreen} from './view/screens/PostRepostedBy'
 import {DebugScreen} from './view/screens/Debug'
 import {LogScreen} from './view/screens/Log'
@@ -62,7 +62,7 @@ function commonScreens(Stack: typeof HomeTab) {
       />
       <Stack.Screen name="ProfileFollows" component={ProfileFollowsScreen} />
       <Stack.Screen name="PostThread" component={PostThreadScreen} />
-      <Stack.Screen name="PostUpvotedBy" component={PostUpvotedByScreen} />
+      <Stack.Screen name="PostLikedBy" component={PostLikedByScreen} />
       <Stack.Screen name="PostRepostedBy" component={PostRepostedByScreen} />
       <Stack.Screen name="Debug" component={DebugScreen} />
       <Stack.Screen name="Log" component={LogScreen} />
diff --git a/src/lib/api/api-polyfill.ts b/src/lib/api/api-polyfill.ts
index b7be6913a..7c38625a2 100644
--- a/src/lib/api/api-polyfill.ts
+++ b/src/lib/api/api-polyfill.ts
@@ -1,11 +1,11 @@
-import AtpAgent from '@atproto/api'
+import {BskyAgent, stringifyLex, jsonToLex} from '@atproto/api'
 import RNFS from 'react-native-fs'
 
 const GET_TIMEOUT = 15e3 // 15s
 const POST_TIMEOUT = 60e3 // 60s
 
 export function doPolyfill() {
-  AtpAgent.configure({fetch: fetchHandler})
+  BskyAgent.configure({fetch: fetchHandler})
 }
 
 interface FetchHandlerResponse {
@@ -22,7 +22,7 @@ async function fetchHandler(
 ): Promise<FetchHandlerResponse> {
   const reqMimeType = reqHeaders['Content-Type'] || reqHeaders['content-type']
   if (reqMimeType && reqMimeType.startsWith('application/json')) {
-    reqBody = JSON.stringify(reqBody)
+    reqBody = stringifyLex(reqBody)
   } else if (
     typeof reqBody === 'string' &&
     (reqBody.startsWith('/') || reqBody.startsWith('file:'))
@@ -65,7 +65,7 @@ async function fetchHandler(
   let resBody
   if (resMimeType) {
     if (resMimeType.startsWith('application/json')) {
-      resBody = await res.json()
+      resBody = jsonToLex(await res.json())
     } else if (resMimeType.startsWith('text/')) {
       resBody = await res.text()
     } else {
diff --git a/src/lib/api/api-polyfill.web.ts b/src/lib/api/api-polyfill.web.ts
index 1469cf905..1ad22b3d0 100644
--- a/src/lib/api/api-polyfill.web.ts
+++ b/src/lib/api/api-polyfill.web.ts
@@ -1,4 +1,3 @@
 export function doPolyfill() {
-  // TODO needed? native fetch may work fine -prf
-  // AtpApi.xrpc.fetch = fetchHandler
+  // no polyfill is needed on web
 }
diff --git a/src/lib/api/build-suggested-posts.ts b/src/lib/api/build-suggested-posts.ts
index defa45311..b9feefc72 100644
--- a/src/lib/api/build-suggested-posts.ts
+++ b/src/lib/api/build-suggested-posts.ts
@@ -1,9 +1,9 @@
 import {RootStoreModel} from 'state/index'
 import {
-  AppBskyFeedFeedViewPost,
+  AppBskyFeedDefs,
   AppBskyFeedGetAuthorFeed as GetAuthorFeed,
 } from '@atproto/api'
-type ReasonRepost = AppBskyFeedFeedViewPost.ReasonRepost
+type ReasonRepost = AppBskyFeedDefs.ReasonRepost
 
 async function getMultipleAuthorsPosts(
   rootStore: RootStoreModel,
@@ -12,12 +12,12 @@ async function getMultipleAuthorsPosts(
   limit: number = 10,
 ) {
   const responses = await Promise.all(
-    authors.map((author, index) =>
-      rootStore.api.app.bsky.feed
+    authors.map((actor, index) =>
+      rootStore.agent
         .getAuthorFeed({
-          author,
+          actor,
           limit,
-          before: cursor ? cursor.split(',')[index] : undefined,
+          cursor: cursor ? cursor.split(',')[index] : undefined,
         })
         .catch(_err => ({success: false, headers: {}, data: {feed: []}})),
     ),
@@ -29,14 +29,14 @@ function mergePosts(
   responses: GetAuthorFeed.Response[],
   {repostsOnly, bestOfOnly}: {repostsOnly?: boolean; bestOfOnly?: boolean},
 ) {
-  let posts: AppBskyFeedFeedViewPost.Main[] = []
+  let posts: AppBskyFeedDefs.FeedViewPost[] = []
 
   if (bestOfOnly) {
     for (const res of responses) {
       if (res.success) {
-        // filter the feed down to the post with the most upvotes
+        // filter the feed down to the post with the most likes
         res.data.feed = res.data.feed.reduce(
-          (acc: AppBskyFeedFeedViewPost.Main[], v) => {
+          (acc: AppBskyFeedDefs.FeedViewPost[], v) => {
             if (
               !acc?.[0] &&
               !v.reason &&
@@ -49,7 +49,7 @@ function mergePosts(
               acc &&
               !v.reason &&
               !v.reply &&
-              v.post.upvoteCount > acc[0]?.post.upvoteCount &&
+              (v.post.likeCount || 0) > (acc[0]?.post.likeCount || 0) &&
               isRecentEnough(v.post.indexedAt)
             ) {
               return [v]
@@ -92,7 +92,7 @@ function mergePosts(
   return posts
 }
 
-function isARepostOfSomeoneElse(post: AppBskyFeedFeedViewPost.Main): boolean {
+function isARepostOfSomeoneElse(post: AppBskyFeedDefs.FeedViewPost): boolean {
   return (
     post.reason?.$type === 'app.bsky.feed.feedViewPost#reasonRepost' &&
     post.post.author.did !== (post.reason as ReasonRepost).by.did
diff --git a/src/lib/api/feed-manip.ts b/src/lib/api/feed-manip.ts
index e9a32b7a6..6fdc9a48f 100644
--- a/src/lib/api/feed-manip.ts
+++ b/src/lib/api/feed-manip.ts
@@ -1,8 +1,8 @@
-import {AppBskyFeedFeedViewPost} from '@atproto/api'
+import {AppBskyFeedDefs} from '@atproto/api'
 import lande from 'lande'
-type FeedViewPost = AppBskyFeedFeedViewPost.Main
-import {hasProp} from '@atproto/lexicon'
+import {hasProp} from 'lib/type-guards'
 import {LANGUAGES_MAP_CODE2} from '../../locale/languages'
+type FeedViewPost = AppBskyFeedDefs.FeedViewPost
 
 export type FeedTunerFn = (
   tuner: FeedTuner,
@@ -174,7 +174,7 @@ export class FeedTuner {
       }
       const item = slices[i].rootItem
       const isRepost = Boolean(item.reason)
-      if (!isRepost && item.post.upvoteCount < 2) {
+      if (!isRepost && (item.post.likeCount || 0) < 2) {
         slices.splice(i, 1)
       }
     }
diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts
index 85eca4a61..a5aa916df 100644
--- a/src/lib/api/index.ts
+++ b/src/lib/api/index.ts
@@ -1,16 +1,16 @@
 import {
   AppBskyEmbedImages,
   AppBskyEmbedExternal,
-  ComAtprotoBlobUpload,
   AppBskyEmbedRecord,
+  AppBskyEmbedRecordWithMedia,
+  ComAtprotoRepoUploadBlob,
+  RichText,
 } from '@atproto/api'
 import {AtUri} from '../../third-party/uri'
 import {RootStoreModel} from 'state/models/root-store'
-import {extractEntities} from 'lib/strings/rich-text-detection'
 import {isNetworkError} from 'lib/strings/errors'
 import {LinkMeta} from '../link-meta/link-meta'
 import {Image} from '../media/manip'
-import {RichText} from '../strings/rich-text'
 import {isWeb} from 'platform/detection'
 
 export interface ExternalEmbedDraft {
@@ -27,7 +27,7 @@ export async function resolveName(store: RootStoreModel, didOrHandle: string) {
   if (didOrHandle.startsWith('did:')) {
     return didOrHandle
   }
-  const res = await store.api.com.atproto.handle.resolve({
+  const res = await store.agent.resolveHandle({
     handle: didOrHandle,
   })
   return res.data.did
@@ -37,15 +37,15 @@ export async function uploadBlob(
   store: RootStoreModel,
   blob: string,
   encoding: string,
-): Promise<ComAtprotoBlobUpload.Response> {
+): Promise<ComAtprotoRepoUploadBlob.Response> {
   if (isWeb) {
     // `blob` should be a data uri
-    return store.api.com.atproto.blob.upload(convertDataURIToUint8Array(blob), {
+    return store.agent.uploadBlob(convertDataURIToUint8Array(blob), {
       encoding,
     })
   } else {
     // `blob` should be a path to a file in the local FS
-    return store.api.com.atproto.blob.upload(
+    return store.agent.uploadBlob(
       blob, // this will be special-cased by the fetch monkeypatch in /src/state/lib/api.ts
       {encoding},
     )
@@ -70,22 +70,18 @@ export async function post(store: RootStoreModel, opts: PostOpts) {
     | AppBskyEmbedImages.Main
     | AppBskyEmbedExternal.Main
     | AppBskyEmbedRecord.Main
+    | AppBskyEmbedRecordWithMedia.Main
     | undefined
   let reply
-  const text = new RichText(opts.rawText, undefined, {
-    cleanNewlines: true,
-  }).text.trim()
+  const rt = new RichText(
+    {text: opts.rawText.trim()},
+    {
+      cleanNewlines: true,
+    },
+  )
 
   opts.onStateChange?.('Processing...')
-  const entities = extractEntities(text, opts.knownHandles)
-  if (entities) {
-    for (const ent of entities) {
-      if (ent.type === 'mention') {
-        const prof = await store.profiles.getProfile(ent.value)
-        ent.value = prof.data.did
-      }
-    }
-  }
+  await rt.detectFacets(store.agent)
 
   if (opts.quote) {
     embed = {
@@ -95,24 +91,37 @@ export async function post(store: RootStoreModel, opts: PostOpts) {
         cid: opts.quote.cid,
       },
     } as AppBskyEmbedRecord.Main
-  } else if (opts.images?.length) {
-    embed = {
-      $type: 'app.bsky.embed.images',
-      images: [],
-    } as AppBskyEmbedImages.Main
-    let i = 1
+  }
+
+  if (opts.images?.length) {
+    const images: AppBskyEmbedImages.Image[] = []
     for (const image of opts.images) {
-      opts.onStateChange?.(`Uploading image #${i++}...`)
+      opts.onStateChange?.(`Uploading image #${images.length + 1}...`)
       const res = await uploadBlob(store, image, 'image/jpeg')
-      embed.images.push({
-        image: {
-          cid: res.data.cid,
-          mimeType: 'image/jpeg',
-        },
+      images.push({
+        image: res.data.blob,
         alt: '', // TODO supply alt text
       })
     }
-  } else if (opts.extLink) {
+
+    if (opts.quote) {
+      embed = {
+        $type: 'app.bsky.embed.recordWithMedia',
+        record: embed,
+        media: {
+          $type: 'app.bsky.embed.images',
+          images,
+        },
+      } as AppBskyEmbedRecordWithMedia.Main
+    } else {
+      embed = {
+        $type: 'app.bsky.embed.images',
+        images,
+      } as AppBskyEmbedImages.Main
+    }
+  }
+
+  if (opts.extLink && !opts.images?.length) {
     let thumb
     if (opts.extLink.localThumb) {
       opts.onStateChange?.('Uploading link thumbnail...')
@@ -138,27 +147,41 @@ export async function post(store: RootStoreModel, opts: PostOpts) {
           opts.extLink.localThumb.path,
           encoding,
         )
-        thumb = {
-          cid: thumbUploadRes.data.cid,
-          mimeType: encoding,
-        }
+        thumb = thumbUploadRes.data.blob
       }
     }
-    embed = {
-      $type: 'app.bsky.embed.external',
-      external: {
-        uri: opts.extLink.uri,
-        title: opts.extLink.meta?.title || '',
-        description: opts.extLink.meta?.description || '',
-        thumb,
-      },
-    } as AppBskyEmbedExternal.Main
+
+    if (opts.quote) {
+      embed = {
+        $type: 'app.bsky.embed.recordWithMedia',
+        record: embed,
+        media: {
+          $type: 'app.bsky.embed.external',
+          external: {
+            uri: opts.extLink.uri,
+            title: opts.extLink.meta?.title || '',
+            description: opts.extLink.meta?.description || '',
+            thumb,
+          },
+        } as AppBskyEmbedExternal.Main,
+      } as AppBskyEmbedRecordWithMedia.Main
+    } else {
+      embed = {
+        $type: 'app.bsky.embed.external',
+        external: {
+          uri: opts.extLink.uri,
+          title: opts.extLink.meta?.title || '',
+          description: opts.extLink.meta?.description || '',
+          thumb,
+        },
+      } as AppBskyEmbedExternal.Main
+    }
   }
 
   if (opts.replyTo) {
     const replyToUrip = new AtUri(opts.replyTo)
-    const parentPost = await store.api.app.bsky.feed.post.get({
-      user: replyToUrip.host,
+    const parentPost = await store.agent.getPost({
+      repo: replyToUrip.host,
       rkey: replyToUrip.rkey,
     })
     if (parentPost) {
@@ -175,16 +198,12 @@ export async function post(store: RootStoreModel, opts: PostOpts) {
 
   try {
     opts.onStateChange?.('Posting...')
-    return await store.api.app.bsky.feed.post.create(
-      {did: store.me.did || ''},
-      {
-        text,
-        reply,
-        embed,
-        entities,
-        createdAt: new Date().toISOString(),
-      },
-    )
+    return await store.agent.post({
+      text: rt.text,
+      facets: rt.facets,
+      reply,
+      embed,
+    })
   } catch (e: any) {
     console.error(`Failed to create post: ${e.toString()}`)
     if (isNetworkError(e)) {
@@ -197,49 +216,6 @@ export async function post(store: RootStoreModel, opts: PostOpts) {
   }
 }
 
-export async function repost(store: RootStoreModel, uri: string, cid: string) {
-  return await store.api.app.bsky.feed.repost.create(
-    {did: store.me.did || ''},
-    {
-      subject: {uri, cid},
-      createdAt: new Date().toISOString(),
-    },
-  )
-}
-
-export async function unrepost(store: RootStoreModel, repostUri: string) {
-  const repostUrip = new AtUri(repostUri)
-  return await store.api.app.bsky.feed.repost.delete({
-    did: repostUrip.hostname,
-    rkey: repostUrip.rkey,
-  })
-}
-
-export async function follow(
-  store: RootStoreModel,
-  subjectDid: string,
-  subjectDeclarationCid: string,
-) {
-  return await store.api.app.bsky.graph.follow.create(
-    {did: store.me.did || ''},
-    {
-      subject: {
-        did: subjectDid,
-        declarationCid: subjectDeclarationCid,
-      },
-      createdAt: new Date().toISOString(),
-    },
-  )
-}
-
-export async function unfollow(store: RootStoreModel, followUri: string) {
-  const followUrip = new AtUri(followUri)
-  return await store.api.app.bsky.graph.follow.delete({
-    did: followUrip.hostname,
-    rkey: followUrip.rkey,
-  })
-}
-
 // helpers
 // =
 
diff --git a/src/lib/media/picker.e2e.tsx b/src/lib/media/picker.e2e.tsx
new file mode 100644
index 000000000..9f4765ac2
--- /dev/null
+++ b/src/lib/media/picker.e2e.tsx
@@ -0,0 +1,116 @@
+import {RootStoreModel} from 'state/index'
+import {PickerOpts, CameraOpts, CropperOpts, PickedMedia} from './types'
+import {
+  scaleDownDimensions,
+  Dim,
+  compressIfNeeded,
+  moveToPremanantPath,
+} from 'lib/media/manip'
+export type {PickedMedia} from './types'
+import RNFS from 'react-native-fs'
+
+let _imageCounter = 0
+async function getFile() {
+  const files = await RNFS.readDir(
+    RNFS.LibraryDirectoryPath.split('/')
+      .slice(0, -5)
+      .concat(['Media', 'DCIM', '100APPLE'])
+      .join('/'),
+  )
+  return files[_imageCounter++ % files.length]
+}
+
+export async function openPicker(
+  _store: RootStoreModel,
+  opts: PickerOpts,
+): Promise<PickedMedia[]> {
+  const mediaType = opts.mediaType || 'photo'
+  const items = await getFile()
+  const toMedia = (item: RNFS.ReadDirItem) => ({
+    mediaType,
+    path: item.path,
+    mime: 'image/jpeg',
+    size: item.size,
+    width: 4288,
+    height: 2848,
+  })
+  if (Array.isArray(items)) {
+    return items.map(toMedia)
+  }
+  return [toMedia(items)]
+}
+
+export async function openCamera(
+  _store: RootStoreModel,
+  opts: CameraOpts,
+): Promise<PickedMedia> {
+  const mediaType = opts.mediaType || 'photo'
+  const item = await getFile()
+  return {
+    mediaType,
+    path: item.path,
+    mime: 'image/jpeg',
+    size: item.size,
+    width: 4288,
+    height: 2848,
+  }
+}
+
+export async function openCropper(
+  _store: RootStoreModel,
+  opts: CropperOpts,
+): Promise<PickedMedia> {
+  const mediaType = opts.mediaType || 'photo'
+  const item = await getFile()
+  return {
+    mediaType,
+    path: item.path,
+    mime: 'image/jpeg',
+    size: item.size,
+    width: 4288,
+    height: 2848,
+  }
+}
+
+export async function pickImagesFlow(
+  store: RootStoreModel,
+  maxFiles: number,
+  maxDim: Dim,
+  maxSize: number,
+) {
+  const items = await openPicker(store, {
+    multiple: true,
+    maxFiles,
+    mediaType: 'photo',
+  })
+  const result = []
+  for (const image of items) {
+    result.push(
+      await cropAndCompressFlow(store, image.path, image, maxDim, maxSize),
+    )
+  }
+  return result
+}
+
+export async function cropAndCompressFlow(
+  store: RootStoreModel,
+  path: string,
+  imgDim: Dim,
+  maxDim: Dim,
+  maxSize: number,
+) {
+  // choose target dimensions based on the original
+  // this causes the photo cropper to start with the full image "selected"
+  const {width, height} = scaleDownDimensions(imgDim, maxDim)
+  const cropperRes = await openCropper(store, {
+    mediaType: 'photo',
+    path,
+    freeStyleCropEnabled: true,
+    width,
+    height,
+  })
+
+  const img = await compressIfNeeded(cropperRes, maxSize)
+  const permanentPath = await moveToPremanantPath(img.path)
+  return permanentPath
+}
diff --git a/src/lib/notifee.ts b/src/lib/notifee.ts
index 4baf64050..4b53ed724 100644
--- a/src/lib/notifee.ts
+++ b/src/lib/notifee.ts
@@ -45,7 +45,7 @@ export function displayNotificationFromModel(
   let author = notif.author.displayName || notif.author.handle
   let title: string
   let body: string = ''
-  if (notif.isUpvote) {
+  if (notif.isLike) {
     title = `${author} liked your post`
     body = notif.additionalPost?.thread?.postRecord?.text || ''
   } else if (notif.isRepost) {
@@ -65,7 +65,7 @@ export function displayNotificationFromModel(
   }
   let image
   if (
-    AppBskyEmbedImages.isPresented(notif.additionalPost?.thread?.post.embed) &&
+    AppBskyEmbedImages.isView(notif.additionalPost?.thread?.post.embed) &&
     notif.additionalPost?.thread?.post.embed.images[0]?.thumb
   ) {
     image = notif.additionalPost.thread.post.embed.images[0].thumb
diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts
index cc48e2dbe..59d94efa8 100644
--- a/src/lib/routes/types.ts
+++ b/src/lib/routes/types.ts
@@ -10,7 +10,7 @@ export type CommonNavigatorParams = {
   ProfileFollowers: {name: string}
   ProfileFollows: {name: string}
   PostThread: {name: string; rkey: string}
-  PostUpvotedBy: {name: string; rkey: string}
+  PostLikedBy: {name: string; rkey: string}
   PostRepostedBy: {name: string; rkey: string}
   Debug: undefined
   Log: undefined
diff --git a/src/lib/strings/rich-text-detection.ts b/src/lib/strings/rich-text-detection.ts
index 386ed48e1..51d09ec5d 100644
--- a/src/lib/strings/rich-text-detection.ts
+++ b/src/lib/strings/rich-text-detection.ts
@@ -1,64 +1,5 @@
-import {AppBskyFeedPost} from '@atproto/api'
-type Entity = AppBskyFeedPost.Entity
 import {isValidDomain} from './url-helpers'
 
-export function extractEntities(
-  text: string,
-  knownHandles?: Set<string>,
-): Entity[] | undefined {
-  let match
-  let ents: Entity[] = []
-  {
-    // mentions
-    const re = /(^|\s|\()(@)([a-zA-Z0-9.-]+)(\b)/g
-    while ((match = re.exec(text))) {
-      if (knownHandles && !knownHandles.has(match[3])) {
-        continue // not a known handle
-      } else if (!match[3].includes('.')) {
-        continue // probably not a handle
-      }
-      const start = text.indexOf(match[3], match.index) - 1
-      ents.push({
-        type: 'mention',
-        value: match[3],
-        index: {start, end: start + match[3].length + 1},
-      })
-    }
-  }
-  {
-    // links
-    const re =
-      /(^|\s|\()((https?:\/\/[\S]+)|((?<domain>[a-z][a-z0-9]*(\.[a-z0-9]+)+)[\S]*))/gim
-    while ((match = re.exec(text))) {
-      let value = match[2]
-      if (!value.startsWith('http')) {
-        const domain = match.groups?.domain
-        if (!domain || !isValidDomain(domain)) {
-          continue
-        }
-        value = `https://${value}`
-      }
-      const start = text.indexOf(match[2], match.index)
-      const index = {start, end: start + match[2].length}
-      // strip ending puncuation
-      if (/[.,;!?]$/.test(value)) {
-        value = value.slice(0, -1)
-        index.end--
-      }
-      if (/[)]$/.test(value) && !value.includes('(')) {
-        value = value.slice(0, -1)
-        index.end--
-      }
-      ents.push({
-        type: 'link',
-        value,
-        index,
-      })
-    }
-  }
-  return ents.length > 0 ? ents : undefined
-}
-
 interface DetectedLink {
   link: string
 }
diff --git a/src/lib/strings/rich-text-sanitize.ts b/src/lib/strings/rich-text-sanitize.ts
deleted file mode 100644
index 0b5895707..000000000
--- a/src/lib/strings/rich-text-sanitize.ts
+++ /dev/null
@@ -1,32 +0,0 @@
-import {RichText} from './rich-text'
-
-const EXCESS_SPACE_RE = /[\r\n]([\u00AD\u2060\u200D\u200C\u200B\s]*[\r\n]){2,}/
-const REPLACEMENT_STR = '\n\n'
-
-export function removeExcessNewlines(richText: RichText): RichText {
-  return clean(richText, EXCESS_SPACE_RE, REPLACEMENT_STR)
-}
-
-// TODO: check on whether this works correctly with multi-byte codepoints
-export function clean(
-  richText: RichText,
-  targetRegexp: RegExp,
-  replacementString: string,
-): RichText {
-  richText = richText.clone()
-
-  let match = richText.text.match(targetRegexp)
-  while (match && typeof match.index !== 'undefined') {
-    const oldText = richText.text
-    const removeStartIndex = match.index
-    const removeEndIndex = removeStartIndex + match[0].length
-    richText.delete(removeStartIndex, removeEndIndex)
-    if (richText.text === oldText) {
-      break // sanity check
-    }
-    richText.insert(removeStartIndex, replacementString)
-    match = richText.text.match(targetRegexp)
-  }
-
-  return richText
-}
diff --git a/src/lib/strings/rich-text.ts b/src/lib/strings/rich-text.ts
deleted file mode 100644
index 1df2144e0..000000000
--- a/src/lib/strings/rich-text.ts
+++ /dev/null
@@ -1,216 +0,0 @@
-/*
-= Rich Text Manipulation
-
-When we sanitize rich text, we have to update the entity indices as the
-text is modified. This can be modeled as inserts() and deletes() of the
-rich text string. The possible scenarios are outlined below, along with
-their expected behaviors.
-
-NOTE: Slices are start inclusive, end exclusive
-
-== richTextInsert()
-
-Target string:
-
-   0 1 2 3 4 5 6 7 8 910   // string indices
-   h e l l o   w o r l d   // string value
-       ^-------^           // target slice {start: 2, end: 7}
-
-Scenarios:
-
-A: ^                       // insert "test" at 0
-B:        ^                // insert "test" at 4
-C:                 ^       // insert "test" at 8
-
-A = before           -> move both by num added
-B = inner            -> move end by num added
-C = after            -> noop
-
-Results:
-
-A: 0 1 2 3 4 5 6 7 8 910   // string indices
-   t e s t h e l l o   w   // string value
-               ^-------^   // target slice {start: 6, end: 11}
-
-B: 0 1 2 3 4 5 6 7 8 910   // string indices
-   h e l l t e s t o   w   // string value
-       ^---------------^   // target slice {start: 2, end: 11}
-
-C: 0 1 2 3 4 5 6 7 8 910   // string indices
-   h e l l o   w o t e s   // string value
-       ^-------^           // target slice {start: 2, end: 7}
-
-== richTextDelete()
-
-Target string:
-
-   0 1 2 3 4 5 6 7 8 910   // string indices
-   h e l l o   w o r l d   // string value
-       ^-------^           // target slice {start: 2, end: 7}
-
-Scenarios:
-
-A: ^---------------^       // remove slice {start: 0, end: 9}
-B:               ^-----^   // remove slice {start: 7, end: 11}
-C:         ^-----------^   // remove slice {start: 4, end: 11}
-D:       ^-^               // remove slice {start: 3, end: 5}
-E:   ^-----^               // remove slice {start: 1, end: 5}
-F: ^-^                     // remove slice {start: 0, end: 2}
-
-A = entirely outer   -> delete slice
-B = entirely after   -> noop
-C = partially after  -> move end to remove-start
-D = entirely inner   -> move end by num removed
-E = partially before -> move start to remove-start index, move end by num removed
-F = entirely before  -> move both by num removed
-
-Results:
-
-A: 0 1 2 3 4 5 6 7 8 910   // string indices
-   l d                     // string value
-                           // target slice (deleted)
-
-B: 0 1 2 3 4 5 6 7 8 910   // string indices
-   h e l l o   w           // string value
-       ^-------^           // target slice {start: 2, end: 7}
-
-C: 0 1 2 3 4 5 6 7 8 910   // string indices
-   h e l l                 // string value
-       ^-^                 // target slice {start: 2, end: 4}
-
-D: 0 1 2 3 4 5 6 7 8 910   // string indices
-   h e l   w o r l d       // string value
-       ^---^               // target slice {start: 2, end: 5}
-
-E: 0 1 2 3 4 5 6 7 8 910   // string indices
-   h   w o r l d           // string value
-     ^-^                   // target slice {start: 1, end: 3}
-
-F: 0 1 2 3 4 5 6 7 8 910   // string indices
-   l l o   w o r l d       // string value
-   ^-------^               // target slice {start: 0, end: 5}
- */
-
-import cloneDeep from 'lodash.clonedeep'
-import {AppBskyFeedPost} from '@atproto/api'
-import {removeExcessNewlines} from './rich-text-sanitize'
-
-export type Entity = AppBskyFeedPost.Entity
-export interface RichTextOpts {
-  cleanNewlines?: boolean
-}
-
-export class RichText {
-  constructor(
-    public text: string,
-    public entities?: Entity[],
-    opts?: RichTextOpts,
-  ) {
-    if (opts?.cleanNewlines) {
-      removeExcessNewlines(this).copyInto(this)
-    }
-  }
-
-  clone() {
-    return new RichText(this.text, cloneDeep(this.entities))
-  }
-
-  copyInto(target: RichText) {
-    target.text = this.text
-    target.entities = cloneDeep(this.entities)
-  }
-
-  insert(insertIndex: number, insertText: string) {
-    this.text =
-      this.text.slice(0, insertIndex) +
-      insertText +
-      this.text.slice(insertIndex)
-
-    if (!this.entities?.length) {
-      return this
-    }
-
-    const numCharsAdded = insertText.length
-    for (const ent of this.entities) {
-      // see comment at top of file for labels of each scenario
-      // scenario A (before)
-      if (insertIndex <= ent.index.start) {
-        // move both by num added
-        ent.index.start += numCharsAdded
-        ent.index.end += numCharsAdded
-      }
-      // scenario B (inner)
-      else if (insertIndex >= ent.index.start && insertIndex < ent.index.end) {
-        // move end by num added
-        ent.index.end += numCharsAdded
-      }
-      // scenario C (after)
-      // noop
-    }
-    return this
-  }
-
-  delete(removeStartIndex: number, removeEndIndex: number) {
-    this.text =
-      this.text.slice(0, removeStartIndex) + this.text.slice(removeEndIndex)
-
-    if (!this.entities?.length) {
-      return this
-    }
-
-    const numCharsRemoved = removeEndIndex - removeStartIndex
-    for (const ent of this.entities) {
-      // see comment at top of file for labels of each scenario
-      // scenario A (entirely outer)
-      if (
-        removeStartIndex <= ent.index.start &&
-        removeEndIndex >= ent.index.end
-      ) {
-        // delete slice (will get removed in final pass)
-        ent.index.start = 0
-        ent.index.end = 0
-      }
-      // scenario B (entirely after)
-      else if (removeStartIndex > ent.index.end) {
-        // noop
-      }
-      // scenario C (partially after)
-      else if (
-        removeStartIndex > ent.index.start &&
-        removeStartIndex <= ent.index.end &&
-        removeEndIndex > ent.index.end
-      ) {
-        // move end to remove start
-        ent.index.end = removeStartIndex
-      }
-      // scenario D (entirely inner)
-      else if (
-        removeStartIndex >= ent.index.start &&
-        removeEndIndex <= ent.index.end
-      ) {
-        // move end by num removed
-        ent.index.end -= numCharsRemoved
-      }
-      // scenario E (partially before)
-      else if (
-        removeStartIndex < ent.index.start &&
-        removeEndIndex >= ent.index.start &&
-        removeEndIndex <= ent.index.end
-      ) {
-        // move start to remove-start index, move end by num removed
-        ent.index.start = removeStartIndex
-        ent.index.end -= numCharsRemoved
-      }
-      // scenario F (entirely before)
-      else if (removeEndIndex < ent.index.start) {
-        // move both by num removed
-        ent.index.start -= numCharsRemoved
-        ent.index.end -= numCharsRemoved
-      }
-    }
-
-    // filter out any entities that were made irrelevant
-    this.entities = this.entities.filter(ent => ent.index.start < ent.index.end)
-    return this
-  }
-}
diff --git a/src/lib/styles.ts b/src/lib/styles.ts
index aa255b21f..409c77548 100644
--- a/src/lib/styles.ts
+++ b/src/lib/styles.ts
@@ -71,6 +71,7 @@ export const s = StyleSheet.create({
   borderBottom1: {borderBottomWidth: 1},
   borderLeft1: {borderLeftWidth: 1},
   hidden: {display: 'none'},
+  dimmed: {opacity: 0.5},
 
   // font weights
   fw600: {fontWeight: '600'},
diff --git a/src/platform/polyfills.ts b/src/platform/polyfills.ts
index 3dbd13981..a64c2c33a 100644
--- a/src/platform/polyfills.ts
+++ b/src/platform/polyfills.ts
@@ -1,3 +1,5 @@
+import 'fast-text-encoding'
+import Graphemer from 'graphemer'
 export {}
 
 /**
@@ -48,3 +50,18 @@ globalThis.atob = (str: string): string => {
   }
   return result
 }
+
+const splitter = new Graphemer()
+globalThis.Intl = globalThis.Intl || {}
+
+// @ts-ignore we're polyfilling -prf
+globalThis.Intl.Segmenter =
+  // @ts-ignore we're polyfilling -prf
+  globalThis.Intl.Segmenter ||
+  class Segmenter {
+    constructor() {}
+    // NOTE
+    // this is not a precisely correct polyfill but it's sufficient for our needs
+    // -prf
+    segment = splitter.iterateGraphemes
+  }
diff --git a/src/platform/polyfills.web.ts b/src/platform/polyfills.web.ts
index 7a42f4887..e46963a6f 100644
--- a/src/platform/polyfills.web.ts
+++ b/src/platform/polyfills.web.ts
@@ -2,3 +2,11 @@
 
 // @ts-ignore whatever typescript wants to complain about here, I dont care about -prf
 window.setImmediate = (cb: () => void) => setTimeout(cb, 0)
+
+// @ts-ignore not on the TS signature due to bad support -prf
+if (!globalThis.Intl?.Segmenter) {
+  // NOTE loading as a separate script to reduce main bundle size, as this is only needed in FF -prf
+  const script = document.createElement('script')
+  script.setAttribute('src', '/static/js/intl-segmenter-polyfill.min.js')
+  document.head.appendChild(script)
+}
diff --git a/src/routes.ts b/src/routes.ts
index 6c02a7c50..167efcfb7 100644
--- a/src/routes.ts
+++ b/src/routes.ts
@@ -9,7 +9,7 @@ export const router = new Router({
   ProfileFollowers: '/profile/:name/followers',
   ProfileFollows: '/profile/:name/follows',
   PostThread: '/profile/:name/post/:rkey',
-  PostUpvotedBy: '/profile/:name/post/:rkey/upvoted-by',
+  PostLikedBy: '/profile/:name/post/:rkey/liked-by',
   PostRepostedBy: '/profile/:name/post/:rkey/reposted-by',
   Debug: '/sys/debug',
   Log: '/sys/log',
diff --git a/src/state/index.ts b/src/state/index.ts
index f0713efeb..4755c28f4 100644
--- a/src/state/index.ts
+++ b/src/state/index.ts
@@ -1,6 +1,6 @@
 import {autorun} from 'mobx'
 import {AppState, Platform} from 'react-native'
-import {AtpAgent} from '@atproto/api'
+import {BskyAgent} from '@atproto/api'
 import {RootStoreModel} from './models/root-store'
 import * as apiPolyfill from 'lib/api/api-polyfill'
 import * as storage from 'lib/storage'
@@ -19,7 +19,7 @@ export async function setupState(serviceUri = DEFAULT_SERVICE) {
 
   apiPolyfill.doPolyfill()
 
-  rootStore = new RootStoreModel(new AtpAgent({service: serviceUri}))
+  rootStore = new RootStoreModel(new BskyAgent({service: serviceUri}))
   try {
     data = (await storage.load(ROOT_STATE_STORAGE_KEY)) || {}
     rootStore.log.debug('Initial hydrate', {hasSession: !!data.session})
diff --git a/src/state/models/cache/image-sizes.ts b/src/state/models/cache/image-sizes.ts
index ff0486278..2fd6e0013 100644
--- a/src/state/models/cache/image-sizes.ts
+++ b/src/state/models/cache/image-sizes.ts
@@ -3,7 +3,7 @@ import {Dim} from 'lib/media/manip'
 
 export class ImageSizesCache {
   sizes: Map<string, Dim> = new Map()
-  private activeRequests: Map<string, Promise<Dim>> = new Map()
+  activeRequests: Map<string, Promise<Dim>> = new Map()
 
   constructor() {}
 
diff --git a/src/state/models/cache/my-follows.ts b/src/state/models/cache/my-follows.ts
index 725b7841e..14eeaae21 100644
--- a/src/state/models/cache/my-follows.ts
+++ b/src/state/models/cache/my-follows.ts
@@ -1,15 +1,12 @@
 import {makeAutoObservable, runInAction} from 'mobx'
-import {FollowRecord, AppBskyActorProfile, AppBskyActorRef} from '@atproto/api'
+import {FollowRecord, AppBskyActorDefs} from '@atproto/api'
 import {RootStoreModel} from '../root-store'
 import {bundleAsync} from 'lib/async/bundle'
 
 const CACHE_TTL = 1000 * 60 * 60 // hourly
 type FollowsListResponse = Awaited<ReturnType<FollowRecord['list']>>
 type FollowsListResponseRecord = FollowsListResponse['records'][0]
-type Profile =
-  | AppBskyActorProfile.ViewBasic
-  | AppBskyActorProfile.View
-  | AppBskyActorRef.WithInfo
+type Profile = AppBskyActorDefs.ProfileViewBasic | AppBskyActorDefs.ProfileView
 
 /**
  * This model is used to maintain a synced local cache of the user's
@@ -53,21 +50,21 @@ export class MyFollowsCache {
 
   fetch = bundleAsync(async () => {
     this.rootStore.log.debug('MyFollowsModel:fetch running full fetch')
-    let before
+    let rkeyStart
     let records: FollowsListResponseRecord[] = []
     do {
       const res: FollowsListResponse =
-        await this.rootStore.api.app.bsky.graph.follow.list({
-          user: this.rootStore.me.did,
-          before,
+        await this.rootStore.agent.app.bsky.graph.follow.list({
+          repo: this.rootStore.me.did,
+          rkeyStart,
         })
       records = records.concat(res.records)
-      before = res.cursor
-    } while (typeof before !== 'undefined')
+      rkeyStart = res.cursor
+    } while (typeof rkeyStart !== 'undefined')
     runInAction(() => {
       this.followDidToRecordMap = {}
       for (const record of records) {
-        this.followDidToRecordMap[record.value.subject.did] = record.uri
+        this.followDidToRecordMap[record.value.subject] = record.uri
       }
       this.lastSync = Date.now()
       this.myDid = this.rootStore.me.did
diff --git a/src/state/models/discovery/foafs.ts b/src/state/models/discovery/foafs.ts
index 241338a16..27cee8503 100644
--- a/src/state/models/discovery/foafs.ts
+++ b/src/state/models/discovery/foafs.ts
@@ -1,15 +1,15 @@
-import {AppBskyActorProfile, AppBskyActorRef} from '@atproto/api'
+import {AppBskyActorDefs} from '@atproto/api'
 import {makeAutoObservable, runInAction} from 'mobx'
 import sampleSize from 'lodash.samplesize'
 import {bundleAsync} from 'lib/async/bundle'
 import {RootStoreModel} from '../root-store'
 
-export type RefWithInfoAndFollowers = AppBskyActorRef.WithInfo & {
-  followers: AppBskyActorProfile.View[]
+export type RefWithInfoAndFollowers = AppBskyActorDefs.ProfileViewBasic & {
+  followers: AppBskyActorDefs.ProfileView[]
 }
 
-export type ProfileViewFollows = AppBskyActorProfile.View & {
-  follows: AppBskyActorRef.WithInfo[]
+export type ProfileViewFollows = AppBskyActorDefs.ProfileView & {
+  follows: AppBskyActorDefs.ProfileViewBasic[]
 }
 
 export class FoafsModel {
@@ -51,14 +51,14 @@ export class FoafsModel {
       this.popular.length = 0
 
       // fetch their profiles
-      const profiles = await this.rootStore.api.app.bsky.actor.getProfiles({
+      const profiles = await this.rootStore.agent.getProfiles({
         actors: this.sources,
       })
 
       // fetch their follows
       const results = await Promise.allSettled(
         this.sources.map(source =>
-          this.rootStore.api.app.bsky.graph.getFollows({user: source}),
+          this.rootStore.agent.getFollows({actor: source}),
         ),
       )
 
diff --git a/src/state/models/discovery/suggested-actors.ts b/src/state/models/discovery/suggested-actors.ts
index cf8e2dd7b..91c5efd02 100644
--- a/src/state/models/discovery/suggested-actors.ts
+++ b/src/state/models/discovery/suggested-actors.ts
@@ -1,5 +1,5 @@
 import {makeAutoObservable, runInAction} from 'mobx'
-import {AppBskyActorProfile as Profile} from '@atproto/api'
+import {AppBskyActorDefs} from '@atproto/api'
 import shuffle from 'lodash.shuffle'
 import {RootStoreModel} from '../root-store'
 import {cleanError} from 'lib/strings/errors'
@@ -8,7 +8,9 @@ import {SUGGESTED_FOLLOWS} from 'lib/constants'
 
 const PAGE_SIZE = 30
 
-export type SuggestedActor = Profile.ViewBasic | Profile.View
+export type SuggestedActor =
+  | AppBskyActorDefs.ProfileViewBasic
+  | AppBskyActorDefs.ProfileView
 
 export class SuggestedActorsModel {
   // state
@@ -20,7 +22,7 @@ export class SuggestedActorsModel {
   hasMore = true
   loadMoreCursor?: string
 
-  private hardCodedSuggestions: SuggestedActor[] | undefined
+  hardCodedSuggestions: SuggestedActor[] | undefined
 
   // data
   suggestions: SuggestedActor[] = []
@@ -82,7 +84,7 @@ export class SuggestedActorsModel {
           this.loadMoreCursor = undefined
         } else {
           // pull from the PDS' algo
-          res = await this.rootStore.api.app.bsky.actor.getSuggestions({
+          res = await this.rootStore.agent.app.bsky.actor.getSuggestions({
             limit: this.pageSize,
             cursor: this.loadMoreCursor,
           })
@@ -104,7 +106,7 @@ export class SuggestedActorsModel {
     }
   })
 
-  private async fetchHardcodedSuggestions() {
+  async fetchHardcodedSuggestions() {
     if (this.hardCodedSuggestions) {
       return
     }
@@ -118,9 +120,9 @@ export class SuggestedActorsModel {
       ]
 
       // fetch the profiles in chunks of 25 (the limit allowed by `getProfiles`)
-      let profiles: Profile.View[] = []
+      let profiles: AppBskyActorDefs.ProfileView[] = []
       do {
-        const res = await this.rootStore.api.app.bsky.actor.getProfiles({
+        const res = await this.rootStore.agent.getProfiles({
           actors: actors.splice(0, 25),
         })
         profiles = profiles.concat(res.data.profiles)
@@ -152,13 +154,13 @@ export class SuggestedActorsModel {
   // state transitions
   // =
 
-  private _xLoading(isRefreshing = false) {
+  _xLoading(isRefreshing = false) {
     this.isLoading = true
     this.isRefreshing = isRefreshing
     this.error = ''
   }
 
-  private _xIdle(err?: any) {
+  _xIdle(err?: any) {
     this.isLoading = false
     this.isRefreshing = false
     this.hasLoaded = true
diff --git a/src/state/models/feed-view.ts b/src/state/models/feed-view.ts
index 083863fe2..8b62c958f 100644
--- a/src/state/models/feed-view.ts
+++ b/src/state/models/feed-view.ts
@@ -1,32 +1,29 @@
 import {makeAutoObservable, runInAction} from 'mobx'
 import {
   AppBskyFeedGetTimeline as GetTimeline,
-  AppBskyFeedFeedViewPost,
+  AppBskyFeedDefs,
   AppBskyFeedPost,
   AppBskyFeedGetAuthorFeed as GetAuthorFeed,
+  RichText,
 } from '@atproto/api'
 import AwaitLock from 'await-lock'
 import {bundleAsync} from 'lib/async/bundle'
 import sampleSize from 'lodash.samplesize'
-type FeedViewPost = AppBskyFeedFeedViewPost.Main
-type ReasonRepost = AppBskyFeedFeedViewPost.ReasonRepost
-type PostView = AppBskyFeedPost.View
-import {AtUri} from '../../third-party/uri'
 import {RootStoreModel} from './root-store'
-import * as apilib from 'lib/api/index'
 import {cleanError} from 'lib/strings/errors'
-import {RichText} from 'lib/strings/rich-text'
 import {SUGGESTED_FOLLOWS} from 'lib/constants'
 import {
   getCombinedCursors,
   getMultipleAuthorsPosts,
   mergePosts,
 } from 'lib/api/build-suggested-posts'
-
 import {FeedTuner, FeedViewPostsSlice} from 'lib/api/feed-manip'
 
-const PAGE_SIZE = 30
+type FeedViewPost = AppBskyFeedDefs.FeedViewPost
+type ReasonRepost = AppBskyFeedDefs.ReasonRepost
+type PostView = AppBskyFeedDefs.PostView
 
+const PAGE_SIZE = 30
 let _idCounter = 0
 
 export class FeedItemModel {
@@ -51,11 +48,7 @@ export class FeedItemModel {
       const valid = AppBskyFeedPost.validateRecord(this.post.record)
       if (valid.success) {
         this.postRecord = this.post.record
-        this.richText = new RichText(
-          this.postRecord.text,
-          this.postRecord.entities,
-          {cleanNewlines: true},
-        )
+        this.richText = new RichText(this.postRecord, {cleanNewlines: true})
       } else {
         rootStore.log.warn(
           'Received an invalid app.bsky.feed.post record',
@@ -82,7 +75,7 @@ export class FeedItemModel {
   copyMetrics(v: FeedViewPost) {
     this.post.replyCount = v.post.replyCount
     this.post.repostCount = v.post.repostCount
-    this.post.upvoteCount = v.post.upvoteCount
+    this.post.likeCount = v.post.likeCount
     this.post.viewer = v.post.viewer
   }
 
@@ -92,68 +85,43 @@ export class FeedItemModel {
     }
   }
 
-  async toggleUpvote() {
-    const wasUpvoted = !!this.post.viewer.upvote
-    const wasDownvoted = !!this.post.viewer.downvote
-    const res = await this.rootStore.api.app.bsky.feed.setVote({
-      subject: {
-        uri: this.post.uri,
-        cid: this.post.cid,
-      },
-      direction: wasUpvoted ? 'none' : 'up',
-    })
-    runInAction(() => {
-      if (wasDownvoted) {
-        this.post.downvoteCount--
-      }
-      if (wasUpvoted) {
-        this.post.upvoteCount--
-      } else {
-        this.post.upvoteCount++
-      }
-      this.post.viewer.upvote = res.data.upvote
-      this.post.viewer.downvote = res.data.downvote
-    })
-  }
-
-  async toggleDownvote() {
-    const wasUpvoted = !!this.post.viewer.upvote
-    const wasDownvoted = !!this.post.viewer.downvote
-    const res = await this.rootStore.api.app.bsky.feed.setVote({
-      subject: {
-        uri: this.post.uri,
-        cid: this.post.cid,
-      },
-      direction: wasDownvoted ? 'none' : 'down',
-    })
-    runInAction(() => {
-      if (wasUpvoted) {
-        this.post.upvoteCount--
-      }
-      if (wasDownvoted) {
-        this.post.downvoteCount--
-      } else {
-        this.post.downvoteCount++
-      }
-      this.post.viewer.upvote = res.data.upvote
-      this.post.viewer.downvote = res.data.downvote
-    })
+  async toggleLike() {
+    if (this.post.viewer?.like) {
+      await this.rootStore.agent.deleteLike(this.post.viewer.like)
+      runInAction(() => {
+        this.post.likeCount = this.post.likeCount || 0
+        this.post.viewer = this.post.viewer || {}
+        this.post.likeCount--
+        this.post.viewer.like = undefined
+      })
+    } else {
+      const res = await this.rootStore.agent.like(this.post.uri, this.post.cid)
+      runInAction(() => {
+        this.post.likeCount = this.post.likeCount || 0
+        this.post.viewer = this.post.viewer || {}
+        this.post.likeCount++
+        this.post.viewer.like = res.uri
+      })
+    }
   }
 
   async toggleRepost() {
-    if (this.post.viewer.repost) {
-      await apilib.unrepost(this.rootStore, this.post.viewer.repost)
+    if (this.post.viewer?.repost) {
+      await this.rootStore.agent.deleteRepost(this.post.viewer.repost)
       runInAction(() => {
+        this.post.repostCount = this.post.repostCount || 0
+        this.post.viewer = this.post.viewer || {}
         this.post.repostCount--
         this.post.viewer.repost = undefined
       })
     } else {
-      const res = await apilib.repost(
-        this.rootStore,
+      const res = await this.rootStore.agent.repost(
         this.post.uri,
         this.post.cid,
       )
       runInAction(() => {
+        this.post.repostCount = this.post.repostCount || 0
+        this.post.viewer = this.post.viewer || {}
         this.post.repostCount++
         this.post.viewer.repost = res.uri
       })
@@ -161,10 +129,7 @@ export class FeedItemModel {
   }
 
   async delete() {
-    await this.rootStore.api.app.bsky.feed.post.delete({
-      did: this.post.author.did,
-      rkey: new AtUri(this.post.uri).rkey,
-    })
+    await this.rootStore.agent.deletePost(this.post.uri)
     this.rootStore.emitPostDeleted(this.post.uri)
   }
 }
@@ -250,7 +215,7 @@ export class FeedModel {
   tuner = new FeedTuner()
 
   // used to linearize async modifications to state
-  private lock = new AwaitLock()
+  lock = new AwaitLock()
 
   // data
   slices: FeedSliceModel[] = []
@@ -291,8 +256,8 @@ export class FeedModel {
         const params = this.params as GetAuthorFeed.QueryParams
         const item = slice.rootItem
         const isRepost =
-          item?.reasonRepost?.by?.handle === params.author ||
-          item?.reasonRepost?.by?.did === params.author
+          item?.reasonRepost?.by?.handle === params.actor ||
+          item?.reasonRepost?.by?.did === params.actor
         return (
           !item.reply || // not a reply
           isRepost || // but allow if it's a repost
@@ -338,7 +303,7 @@ export class FeedModel {
     return this.setup()
   }
 
-  private get feedTuners() {
+  get feedTuners() {
     if (this.feedType === 'goodstuff') {
       return [
         FeedTuner.dedupReposts,
@@ -406,7 +371,7 @@ export class FeedModel {
       this._xLoading()
       try {
         const res = await this._getFeed({
-          before: this.loadMoreCursor,
+          cursor: this.loadMoreCursor,
           limit: PAGE_SIZE,
         })
         await this._appendAll(res)
@@ -439,7 +404,7 @@ export class FeedModel {
       try {
         do {
           const res: GetTimeline.Response = await this._getFeed({
-            before: cursor,
+            cursor,
             limit: Math.min(numToFetch, 100),
           })
           if (res.data.feed.length === 0) {
@@ -478,14 +443,18 @@ export class FeedModel {
           new FeedSliceModel(this.rootStore, `item-${_idCounter++}`, slice),
       )
       if (autoPrepend) {
-        this.slices = nextSlicesModels.concat(
-          this.slices.filter(slice1 =>
-            nextSlicesModels.find(slice2 => slice1.uri === slice2.uri),
-          ),
-        )
-        this.setHasNewLatest(false)
+        runInAction(() => {
+          this.slices = nextSlicesModels.concat(
+            this.slices.filter(slice1 =>
+              nextSlicesModels.find(slice2 => slice1.uri === slice2.uri),
+            ),
+          )
+          this.setHasNewLatest(false)
+        })
       } else {
-        this.nextSlices = nextSlicesModels
+        runInAction(() => {
+          this.nextSlices = nextSlicesModels
+        })
         this.setHasNewLatest(true)
       }
     } else {
@@ -519,13 +488,13 @@ export class FeedModel {
   // state transitions
   // =
 
-  private _xLoading(isRefreshing = false) {
+  _xLoading(isRefreshing = false) {
     this.isLoading = true
     this.isRefreshing = isRefreshing
     this.error = ''
   }
 
-  private _xIdle(err?: any) {
+  _xIdle(err?: any) {
     this.isLoading = false
     this.isRefreshing = false
     this.hasLoaded = true
@@ -538,14 +507,12 @@ export class FeedModel {
   // helper functions
   // =
 
-  private async _replaceAll(
-    res: GetTimeline.Response | GetAuthorFeed.Response,
-  ) {
+  async _replaceAll(res: GetTimeline.Response | GetAuthorFeed.Response) {
     this.pollCursor = res.data.feed[0]?.post.uri
     return this._appendAll(res, true)
   }
 
-  private async _appendAll(
+  async _appendAll(
     res: GetTimeline.Response | GetAuthorFeed.Response,
     replace = false,
   ) {
@@ -572,7 +539,7 @@ export class FeedModel {
     })
   }
 
-  private _updateAll(res: GetTimeline.Response | GetAuthorFeed.Response) {
+  _updateAll(res: GetTimeline.Response | GetAuthorFeed.Response) {
     for (const item of res.data.feed) {
       const existingSlice = this.slices.find(slice =>
         slice.containsUri(item.post.uri),
@@ -596,7 +563,7 @@ export class FeedModel {
       const responses = await getMultipleAuthorsPosts(
         this.rootStore,
         sampleSize(SUGGESTED_FOLLOWS(String(this.rootStore.agent.service)), 20),
-        params.before,
+        params.cursor,
         20,
       )
       const combinedCursor = getCombinedCursors(responses)
@@ -611,9 +578,7 @@ export class FeedModel {
         headers: lastHeaders,
       }
     } else if (this.feedType === 'home') {
-      return this.rootStore.api.app.bsky.feed.getTimeline(
-        params as GetTimeline.QueryParams,
-      )
+      return this.rootStore.agent.getTimeline(params as GetTimeline.QueryParams)
     } else if (this.feedType === 'goodstuff') {
       const res = await getGoodStuff(
         this.rootStore.session.currentSession?.accessJwt || '',
@@ -624,7 +589,7 @@ export class FeedModel {
       )
       return res
     } else {
-      return this.rootStore.api.app.bsky.feed.getAuthorFeed(
+      return this.rootStore.agent.getAuthorFeed(
         params as GetAuthorFeed.QueryParams,
       )
     }
diff --git a/src/state/models/votes-view.ts b/src/state/models/likes-view.ts
index ad8698d21..5f9df692e 100644
--- a/src/state/models/votes-view.ts
+++ b/src/state/models/likes-view.ts
@@ -1,6 +1,6 @@
 import {makeAutoObservable, runInAction} from 'mobx'
 import {AtUri} from '../../third-party/uri'
-import {AppBskyFeedGetVotes as GetVotes} from '@atproto/api'
+import {AppBskyFeedGetLikes as GetLikes} from '@atproto/api'
 import {RootStoreModel} from './root-store'
 import {cleanError} from 'lib/strings/errors'
 import {bundleAsync} from 'lib/async/bundle'
@@ -8,24 +8,24 @@ import * as apilib from 'lib/api/index'
 
 const PAGE_SIZE = 30
 
-export type VoteItem = GetVotes.Vote
+export type LikeItem = GetLikes.Like
 
-export class VotesViewModel {
+export class LikesViewModel {
   // state
   isLoading = false
   isRefreshing = false
   hasLoaded = false
   error = ''
   resolvedUri = ''
-  params: GetVotes.QueryParams
+  params: GetLikes.QueryParams
   hasMore = true
   loadMoreCursor?: string
 
   // data
   uri: string = ''
-  votes: VoteItem[] = []
+  likes: LikeItem[] = []
 
-  constructor(public rootStore: RootStoreModel, params: GetVotes.QueryParams) {
+  constructor(public rootStore: RootStoreModel, params: GetLikes.QueryParams) {
     makeAutoObservable(
       this,
       {
@@ -68,9 +68,9 @@ export class VotesViewModel {
       const params = Object.assign({}, this.params, {
         uri: this.resolvedUri,
         limit: PAGE_SIZE,
-        before: replace ? undefined : this.loadMoreCursor,
+        cursor: replace ? undefined : this.loadMoreCursor,
       })
-      const res = await this.rootStore.api.app.bsky.feed.getVotes(params)
+      const res = await this.rootStore.agent.getLikes(params)
       if (replace) {
         this._replaceAll(res)
       } else {
@@ -85,13 +85,13 @@ export class VotesViewModel {
   // state transitions
   // =
 
-  private _xLoading(isRefreshing = false) {
+  _xLoading(isRefreshing = false) {
     this.isLoading = true
     this.isRefreshing = isRefreshing
     this.error = ''
   }
 
-  private _xIdle(err?: any) {
+  _xIdle(err?: any) {
     this.isLoading = false
     this.isRefreshing = false
     this.hasLoaded = true
@@ -104,7 +104,7 @@ export class VotesViewModel {
   // helper functions
   // =
 
-  private async _resolveUri() {
+  async _resolveUri() {
     const urip = new AtUri(this.params.uri)
     if (!urip.host.startsWith('did:')) {
       try {
@@ -118,14 +118,14 @@ export class VotesViewModel {
     })
   }
 
-  private _replaceAll(res: GetVotes.Response) {
-    this.votes = []
+  _replaceAll(res: GetLikes.Response) {
+    this.likes = []
     this._appendAll(res)
   }
 
-  private _appendAll(res: GetVotes.Response) {
+  _appendAll(res: GetLikes.Response) {
     this.loadMoreCursor = res.data.cursor
     this.hasMore = !!this.loadMoreCursor
-    this.votes = this.votes.concat(res.data.votes)
+    this.likes = this.likes.concat(res.data.likes)
   }
 }
diff --git a/src/state/models/log.ts b/src/state/models/log.ts
index ed701dc61..d80617139 100644
--- a/src/state/models/log.ts
+++ b/src/state/models/log.ts
@@ -1,5 +1,5 @@
 import {makeAutoObservable} from 'mobx'
-import {XRPCError, XRPCInvalidResponseError} from '@atproto/xrpc'
+// import {XRPCError, XRPCInvalidResponseError} from '@atproto/xrpc' TODO
 
 const MAX_ENTRIES = 300
 
@@ -32,7 +32,7 @@ export class LogModel {
     makeAutoObservable(this)
   }
 
-  private add(entry: LogEntry) {
+  add(entry: LogEntry) {
     this.entries.push(entry)
     while (this.entries.length > MAX_ENTRIES) {
       this.entries = this.entries.slice(50)
@@ -79,14 +79,14 @@ export class LogModel {
 function detailsToStr(details?: any) {
   if (details && typeof details !== 'string') {
     if (
-      details instanceof XRPCInvalidResponseError ||
+      // details instanceof XRPCInvalidResponseError || TODO
       details.constructor.name === 'XRPCInvalidResponseError'
     ) {
       return `The server gave an ill-formatted response.\nMethod: ${
         details.lexiconNsid
       }.\nError: ${details.validationError.toString()}`
     } else if (
-      details instanceof XRPCError ||
+      // details instanceof XRPCError || TODO
       details.constructor.name === 'XRPCError'
     ) {
       return `An XRPC error occurred.\nStatus: ${details.status}\nError: ${details.error}\nMessage: ${details.message}`
diff --git a/src/state/models/me.ts b/src/state/models/me.ts
index 120749155..5f670b8f9 100644
--- a/src/state/models/me.ts
+++ b/src/state/models/me.ts
@@ -85,7 +85,7 @@ export class MeModel {
     if (sess.hasSession) {
       this.did = sess.currentSession?.did || ''
       this.handle = sess.currentSession?.handle || ''
-      const profile = await this.rootStore.api.app.bsky.actor.getProfile({
+      const profile = await this.rootStore.agent.getProfile({
         actor: this.did,
       })
       runInAction(() => {
diff --git a/src/state/models/notifications-view.ts b/src/state/models/notifications-view.ts
index e88af590b..4f7a52fd9 100644
--- a/src/state/models/notifications-view.ts
+++ b/src/state/models/notifications-view.ts
@@ -1,11 +1,10 @@
 import {makeAutoObservable, runInAction} from 'mobx'
 import {
-  AppBskyNotificationList as ListNotifications,
-  AppBskyActorRef as ActorRef,
+  AppBskyNotificationListNotifications as ListNotifications,
+  AppBskyActorDefs,
   AppBskyFeedPost,
   AppBskyFeedRepost,
-  AppBskyFeedVote,
-  AppBskyGraphAssertion,
+  AppBskyFeedLike,
   AppBskyGraphFollow,
 } from '@atproto/api'
 import AwaitLock from 'await-lock'
@@ -28,8 +27,7 @@ export interface GroupedNotification extends ListNotifications.Notification {
 type SupportedRecord =
   | AppBskyFeedPost.Record
   | AppBskyFeedRepost.Record
-  | AppBskyFeedVote.Record
-  | AppBskyGraphAssertion.Record
+  | AppBskyFeedLike.Record
   | AppBskyGraphFollow.Record
 
 export class NotificationsViewItemModel {
@@ -39,11 +37,10 @@ export class NotificationsViewItemModel {
   // data
   uri: string = ''
   cid: string = ''
-  author: ActorRef.WithInfo = {
+  author: AppBskyActorDefs.ProfileViewBasic = {
     did: '',
     handle: '',
     avatar: '',
-    declaration: {cid: '', actorType: ''},
   }
   reason: string = ''
   reasonSubject?: string
@@ -86,8 +83,8 @@ export class NotificationsViewItemModel {
     }
   }
 
-  get isUpvote() {
-    return this.reason === 'vote'
+  get isLike() {
+    return this.reason === 'like'
   }
 
   get isRepost() {
@@ -102,16 +99,22 @@ export class NotificationsViewItemModel {
     return this.reason === 'reply'
   }
 
-  get isFollow() {
-    return this.reason === 'follow'
+  get isQuote() {
+    return this.reason === 'quote'
   }
 
-  get isAssertion() {
-    return this.reason === 'assertion'
+  get isFollow() {
+    return this.reason === 'follow'
   }
 
   get needsAdditionalData() {
-    if (this.isUpvote || this.isRepost || this.isReply || this.isMention) {
+    if (
+      this.isLike ||
+      this.isRepost ||
+      this.isReply ||
+      this.isQuote ||
+      this.isMention
+    ) {
       return !this.additionalPost
     }
     return false
@@ -124,7 +127,7 @@ export class NotificationsViewItemModel {
     const record = this.record
     if (
       AppBskyFeedRepost.isRecord(record) ||
-      AppBskyFeedVote.isRecord(record)
+      AppBskyFeedLike.isRecord(record)
     ) {
       return record.subject.uri
     }
@@ -135,8 +138,7 @@ export class NotificationsViewItemModel {
     for (const ns of [
       AppBskyFeedPost,
       AppBskyFeedRepost,
-      AppBskyFeedVote,
-      AppBskyGraphAssertion,
+      AppBskyFeedLike,
       AppBskyGraphFollow,
     ]) {
       if (ns.isRecord(v)) {
@@ -163,9 +165,9 @@ export class NotificationsViewItemModel {
       return
     }
     let postUri
-    if (this.isReply || this.isMention) {
+    if (this.isReply || this.isQuote || this.isMention) {
       postUri = this.uri
-    } else if (this.isUpvote || this.isRepost) {
+    } else if (this.isLike || this.isRepost) {
       postUri = this.subjectUri
     }
     if (postUri) {
@@ -194,7 +196,7 @@ export class NotificationsViewModel {
   loadMoreCursor?: string
 
   // used to linearize async modifications to state
-  private lock = new AwaitLock()
+  lock = new AwaitLock()
 
   // data
   notifications: NotificationsViewItemModel[] = []
@@ -266,7 +268,7 @@ export class NotificationsViewModel {
         const params = Object.assign({}, this.params, {
           limit: PAGE_SIZE,
         })
-        const res = await this.rootStore.api.app.bsky.notification.list(params)
+        const res = await this.rootStore.agent.listNotifications(params)
         await this._replaceAll(res)
         this._xIdle()
       } catch (e: any) {
@@ -297,9 +299,9 @@ export class NotificationsViewModel {
       try {
         const params = Object.assign({}, this.params, {
           limit: PAGE_SIZE,
-          before: this.loadMoreCursor,
+          cursor: this.loadMoreCursor,
         })
-        const res = await this.rootStore.api.app.bsky.notification.list(params)
+        const res = await this.rootStore.agent.listNotifications(params)
         await this._appendAll(res)
         this._xIdle()
       } catch (e: any) {
@@ -325,7 +327,7 @@ export class NotificationsViewModel {
     try {
       this._xLoading()
       try {
-        const res = await this.rootStore.api.app.bsky.notification.list({
+        const res = await this.rootStore.agent.listNotifications({
           limit: PAGE_SIZE,
         })
         await this._prependAll(res)
@@ -357,8 +359,8 @@ export class NotificationsViewModel {
       try {
         do {
           const res: ListNotifications.Response =
-            await this.rootStore.api.app.bsky.notification.list({
-              before: cursor,
+            await this.rootStore.agent.listNotifications({
+              cursor,
               limit: Math.min(numToFetch, 100),
             })
           if (res.data.notifications.length === 0) {
@@ -390,7 +392,7 @@ export class NotificationsViewModel {
    */
   loadUnreadCount = bundleAsync(async () => {
     const old = this.unreadCount
-    const res = await this.rootStore.api.app.bsky.notification.getCount()
+    const res = await this.rootStore.agent.countUnreadNotifications()
     runInAction(() => {
       this.unreadCount = res.data.count
     })
@@ -408,9 +410,7 @@ export class NotificationsViewModel {
       for (const notif of this.notifications) {
         notif.isRead = true
       }
-      await this.rootStore.api.app.bsky.notification.updateSeen({
-        seenAt: new Date().toISOString(),
-      })
+      await this.rootStore.agent.updateSeenNotifications()
     } catch (e: any) {
       this.rootStore.log.warn('Failed to update notifications read state', e)
     }
@@ -418,7 +418,7 @@ export class NotificationsViewModel {
 
   async getNewMostRecent(): Promise<NotificationsViewItemModel | undefined> {
     let old = this.mostRecentNotificationUri
-    const res = await this.rootStore.api.app.bsky.notification.list({
+    const res = await this.rootStore.agent.listNotifications({
       limit: 1,
     })
     if (!res.data.notifications[0] || old === res.data.notifications[0].uri) {
@@ -437,13 +437,13 @@ export class NotificationsViewModel {
   // state transitions
   // =
 
-  private _xLoading(isRefreshing = false) {
+  _xLoading(isRefreshing = false) {
     this.isLoading = true
     this.isRefreshing = isRefreshing
     this.error = ''
   }
 
-  private _xIdle(err?: any) {
+  _xIdle(err?: any) {
     this.isLoading = false
     this.isRefreshing = false
     this.hasLoaded = true
@@ -456,14 +456,14 @@ export class NotificationsViewModel {
   // helper functions
   // =
 
-  private async _replaceAll(res: ListNotifications.Response) {
+  async _replaceAll(res: ListNotifications.Response) {
     if (res.data.notifications[0]) {
       this.mostRecentNotificationUri = res.data.notifications[0].uri
     }
     return this._appendAll(res, true)
   }
 
-  private async _appendAll(res: ListNotifications.Response, replace = false) {
+  async _appendAll(res: ListNotifications.Response, replace = false) {
     this.loadMoreCursor = res.data.cursor
     this.hasMore = !!this.loadMoreCursor
     const promises = []
@@ -494,7 +494,7 @@ export class NotificationsViewModel {
     })
   }
 
-  private async _prependAll(res: ListNotifications.Response) {
+  async _prependAll(res: ListNotifications.Response) {
     const promises = []
     const itemModels: NotificationsViewItemModel[] = []
     const dedupedNotifs = res.data.notifications.filter(
@@ -525,7 +525,7 @@ export class NotificationsViewModel {
     })
   }
 
-  private _updateAll(res: ListNotifications.Response) {
+  _updateAll(res: ListNotifications.Response) {
     for (const item of res.data.notifications) {
       const existingItem = this.notifications.find(item2 => isEq(item, item2))
       if (existingItem) {
diff --git a/src/state/models/post-thread-view.ts b/src/state/models/post-thread-view.ts
index d58ee691b..c5395b9c8 100644
--- a/src/state/models/post-thread-view.ts
+++ b/src/state/models/post-thread-view.ts
@@ -2,12 +2,13 @@ import {makeAutoObservable, runInAction} from 'mobx'
 import {
   AppBskyFeedGetPostThread as GetPostThread,
   AppBskyFeedPost as FeedPost,
+  AppBskyFeedDefs,
+  RichText,
 } from '@atproto/api'
 import {AtUri} from '../../third-party/uri'
 import {RootStoreModel} from './root-store'
 import * as apilib from 'lib/api/index'
 import {cleanError} from 'lib/strings/errors'
-import {RichText} from 'lib/strings/rich-text'
 
 function* reactKeyGenerator(): Generator<string> {
   let counter = 0
@@ -26,10 +27,10 @@ export class PostThreadViewPostModel {
   _hasMore = false
 
   // data
-  post: FeedPost.View
+  post: AppBskyFeedDefs.PostView
   postRecord?: FeedPost.Record
-  parent?: PostThreadViewPostModel | GetPostThread.NotFoundPost
-  replies?: (PostThreadViewPostModel | GetPostThread.NotFoundPost)[]
+  parent?: PostThreadViewPostModel | AppBskyFeedDefs.NotFoundPost
+  replies?: (PostThreadViewPostModel | AppBskyFeedDefs.NotFoundPost)[]
   richText?: RichText
 
   get uri() {
@@ -43,7 +44,7 @@ export class PostThreadViewPostModel {
   constructor(
     public rootStore: RootStoreModel,
     reactKey: string,
-    v: GetPostThread.ThreadViewPost,
+    v: AppBskyFeedDefs.ThreadViewPost,
   ) {
     this._reactKey = reactKey
     this.post = v.post
@@ -51,11 +52,7 @@ export class PostThreadViewPostModel {
       const valid = FeedPost.validateRecord(this.post.record)
       if (valid.success) {
         this.postRecord = this.post.record
-        this.richText = new RichText(
-          this.postRecord.text,
-          this.postRecord.entities,
-          {cleanNewlines: true},
-        )
+        this.richText = new RichText(this.postRecord, {cleanNewlines: true})
       } else {
         rootStore.log.warn(
           'Received an invalid app.bsky.feed.post record',
@@ -74,14 +71,14 @@ export class PostThreadViewPostModel {
 
   assignTreeModels(
     keyGen: Generator<string>,
-    v: GetPostThread.ThreadViewPost,
+    v: AppBskyFeedDefs.ThreadViewPost,
     higlightedPostUri: string,
     includeParent = true,
     includeChildren = true,
   ) {
     // parents
     if (includeParent && v.parent) {
-      if (GetPostThread.isThreadViewPost(v.parent)) {
+      if (AppBskyFeedDefs.isThreadViewPost(v.parent)) {
         const parentModel = new PostThreadViewPostModel(
           this.rootStore,
           keyGen.next().value,
@@ -100,7 +97,7 @@ export class PostThreadViewPostModel {
           )
         }
         this.parent = parentModel
-      } else if (GetPostThread.isNotFoundPost(v.parent)) {
+      } else if (AppBskyFeedDefs.isNotFoundPost(v.parent)) {
         this.parent = v.parent
       }
     }
@@ -108,7 +105,7 @@ export class PostThreadViewPostModel {
     if (includeChildren && v.replies) {
       const replies = []
       for (const item of v.replies) {
-        if (GetPostThread.isThreadViewPost(item)) {
+        if (AppBskyFeedDefs.isThreadViewPost(item)) {
           const itemModel = new PostThreadViewPostModel(
             this.rootStore,
             keyGen.next().value,
@@ -128,7 +125,7 @@ export class PostThreadViewPostModel {
             )
           }
           replies.push(itemModel)
-        } else if (GetPostThread.isNotFoundPost(item)) {
+        } else if (AppBskyFeedDefs.isNotFoundPost(item)) {
           replies.push(item)
         }
       }
@@ -136,68 +133,43 @@ export class PostThreadViewPostModel {
     }
   }
 
-  async toggleUpvote() {
-    const wasUpvoted = !!this.post.viewer.upvote
-    const wasDownvoted = !!this.post.viewer.downvote
-    const res = await this.rootStore.api.app.bsky.feed.setVote({
-      subject: {
-        uri: this.post.uri,
-        cid: this.post.cid,
-      },
-      direction: wasUpvoted ? 'none' : 'up',
-    })
-    runInAction(() => {
-      if (wasDownvoted) {
-        this.post.downvoteCount--
-      }
-      if (wasUpvoted) {
-        this.post.upvoteCount--
-      } else {
-        this.post.upvoteCount++
-      }
-      this.post.viewer.upvote = res.data.upvote
-      this.post.viewer.downvote = res.data.downvote
-    })
-  }
-
-  async toggleDownvote() {
-    const wasUpvoted = !!this.post.viewer.upvote
-    const wasDownvoted = !!this.post.viewer.downvote
-    const res = await this.rootStore.api.app.bsky.feed.setVote({
-      subject: {
-        uri: this.post.uri,
-        cid: this.post.cid,
-      },
-      direction: wasDownvoted ? 'none' : 'down',
-    })
-    runInAction(() => {
-      if (wasUpvoted) {
-        this.post.upvoteCount--
-      }
-      if (wasDownvoted) {
-        this.post.downvoteCount--
-      } else {
-        this.post.downvoteCount++
-      }
-      this.post.viewer.upvote = res.data.upvote
-      this.post.viewer.downvote = res.data.downvote
-    })
+  async toggleLike() {
+    if (this.post.viewer?.like) {
+      await this.rootStore.agent.deleteLike(this.post.viewer.like)
+      runInAction(() => {
+        this.post.likeCount = this.post.likeCount || 0
+        this.post.viewer = this.post.viewer || {}
+        this.post.likeCount--
+        this.post.viewer.like = undefined
+      })
+    } else {
+      const res = await this.rootStore.agent.like(this.post.uri, this.post.cid)
+      runInAction(() => {
+        this.post.likeCount = this.post.likeCount || 0
+        this.post.viewer = this.post.viewer || {}
+        this.post.likeCount++
+        this.post.viewer.like = res.uri
+      })
+    }
   }
 
   async toggleRepost() {
-    if (this.post.viewer.repost) {
-      await apilib.unrepost(this.rootStore, this.post.viewer.repost)
+    if (this.post.viewer?.repost) {
+      await this.rootStore.agent.deleteRepost(this.post.viewer.repost)
       runInAction(() => {
+        this.post.repostCount = this.post.repostCount || 0
+        this.post.viewer = this.post.viewer || {}
         this.post.repostCount--
         this.post.viewer.repost = undefined
       })
     } else {
-      const res = await apilib.repost(
-        this.rootStore,
+      const res = await this.rootStore.agent.repost(
         this.post.uri,
         this.post.cid,
       )
       runInAction(() => {
+        this.post.repostCount = this.post.repostCount || 0
+        this.post.viewer = this.post.viewer || {}
         this.post.repostCount++
         this.post.viewer.repost = res.uri
       })
@@ -205,10 +177,7 @@ export class PostThreadViewPostModel {
   }
 
   async delete() {
-    await this.rootStore.api.app.bsky.feed.post.delete({
-      did: this.post.author.did,
-      rkey: new AtUri(this.post.uri).rkey,
-    })
+    await this.rootStore.agent.deletePost(this.post.uri)
     this.rootStore.emitPostDeleted(this.post.uri)
   }
 }
@@ -301,14 +270,14 @@ export class PostThreadViewModel {
   // state transitions
   // =
 
-  private _xLoading(isRefreshing = false) {
+  _xLoading(isRefreshing = false) {
     this.isLoading = true
     this.isRefreshing = isRefreshing
     this.error = ''
     this.notFound = false
   }
 
-  private _xIdle(err?: any) {
+  _xIdle(err?: any) {
     this.isLoading = false
     this.isRefreshing = false
     this.hasLoaded = true
@@ -322,7 +291,7 @@ export class PostThreadViewModel {
   // loader functions
   // =
 
-  private async _resolveUri() {
+  async _resolveUri() {
     const urip = new AtUri(this.params.uri)
     if (!urip.host.startsWith('did:')) {
       try {
@@ -336,10 +305,10 @@ export class PostThreadViewModel {
     })
   }
 
-  private async _load(isRefreshing = false) {
+  async _load(isRefreshing = false) {
     this._xLoading(isRefreshing)
     try {
-      const res = await this.rootStore.api.app.bsky.feed.getPostThread(
+      const res = await this.rootStore.agent.getPostThread(
         Object.assign({}, this.params, {uri: this.resolvedUri}),
       )
       this._replaceAll(res)
@@ -349,18 +318,18 @@ export class PostThreadViewModel {
     }
   }
 
-  private _replaceAll(res: GetPostThread.Response) {
+  _replaceAll(res: GetPostThread.Response) {
     sortThread(res.data.thread)
     const keyGen = reactKeyGenerator()
     const thread = new PostThreadViewPostModel(
       this.rootStore,
       keyGen.next().value,
-      res.data.thread as GetPostThread.ThreadViewPost,
+      res.data.thread as AppBskyFeedDefs.ThreadViewPost,
     )
     thread._isHighlightedPost = true
     thread.assignTreeModels(
       keyGen,
-      res.data.thread as GetPostThread.ThreadViewPost,
+      res.data.thread as AppBskyFeedDefs.ThreadViewPost,
       thread.uri,
     )
     this.thread = thread
@@ -368,25 +337,25 @@ export class PostThreadViewModel {
 }
 
 type MaybePost =
-  | GetPostThread.ThreadViewPost
-  | GetPostThread.NotFoundPost
+  | AppBskyFeedDefs.ThreadViewPost
+  | AppBskyFeedDefs.NotFoundPost
   | {[k: string]: unknown; $type: string}
 function sortThread(post: MaybePost) {
   if (post.notFound) {
     return
   }
-  post = post as GetPostThread.ThreadViewPost
+  post = post as AppBskyFeedDefs.ThreadViewPost
   if (post.replies) {
     post.replies.sort((a: MaybePost, b: MaybePost) => {
-      post = post as GetPostThread.ThreadViewPost
+      post = post as AppBskyFeedDefs.ThreadViewPost
       if (a.notFound) {
         return 1
       }
       if (b.notFound) {
         return -1
       }
-      a = a as GetPostThread.ThreadViewPost
-      b = b as GetPostThread.ThreadViewPost
+      a = a as AppBskyFeedDefs.ThreadViewPost
+      b = b as AppBskyFeedDefs.ThreadViewPost
       const aIsByOp = a.post.author.did === post.post.author.did
       const bIsByOp = b.post.author.did === post.post.author.did
       if (aIsByOp && bIsByOp) {
diff --git a/src/state/models/post.ts b/src/state/models/post.ts
index 749e98bb0..c7f2896ba 100644
--- a/src/state/models/post.ts
+++ b/src/state/models/post.ts
@@ -58,12 +58,12 @@ export class PostModel implements RemoveIndex<Post.Record> {
   // state transitions
   // =
 
-  private _xLoading() {
+  _xLoading() {
     this.isLoading = true
     this.error = ''
   }
 
-  private _xIdle(err?: any) {
+  _xIdle(err?: any) {
     this.isLoading = false
     this.hasLoaded = true
     this.error = cleanError(err)
@@ -75,12 +75,12 @@ export class PostModel implements RemoveIndex<Post.Record> {
   // loader functions
   // =
 
-  private async _load() {
+  async _load() {
     this._xLoading()
     try {
       const urip = new AtUri(this.uri)
-      const res = await this.rootStore.api.app.bsky.feed.post.get({
-        user: urip.host,
+      const res = await this.rootStore.agent.getPost({
+        repo: urip.host,
         rkey: urip.rkey,
       })
       // TODO
@@ -94,7 +94,7 @@ export class PostModel implements RemoveIndex<Post.Record> {
     }
   }
 
-  private _replaceAll(res: Post.Record) {
+  _replaceAll(res: Post.Record) {
     this.text = res.text
     this.entities = res.entities
     this.reply = res.reply
diff --git a/src/state/models/profile-view.ts b/src/state/models/profile-view.ts
index 9d3eeff58..eacc6a298 100644
--- a/src/state/models/profile-view.ts
+++ b/src/state/models/profile-view.ts
@@ -2,15 +2,12 @@ import {makeAutoObservable, runInAction} from 'mobx'
 import {PickedMedia} from 'lib/media/picker'
 import {
   AppBskyActorGetProfile as GetProfile,
-  AppBskySystemDeclRef,
-  AppBskyActorUpdateProfile,
+  AppBskyActorProfile,
+  RichText,
 } from '@atproto/api'
-type DeclRef = AppBskySystemDeclRef.Main
-import {extractEntities} from 'lib/strings/rich-text-detection'
 import {RootStoreModel} from './root-store'
 import * as apilib from 'lib/api/index'
 import {cleanError} from 'lib/strings/errors'
-import {RichText} from 'lib/strings/rich-text'
 
 export const ACTOR_TYPE_USER = 'app.bsky.system.actorUser'
 
@@ -35,22 +32,18 @@ export class ProfileViewModel {
   // data
   did: string = ''
   handle: string = ''
-  declaration: DeclRef = {
-    cid: '',
-    actorType: '',
-  }
   creator: string = ''
-  displayName?: string
-  description?: string
-  avatar?: string
-  banner?: string
+  displayName?: string = ''
+  description?: string = ''
+  avatar?: string = ''
+  banner?: string = ''
   followersCount: number = 0
   followsCount: number = 0
   postsCount: number = 0
   viewer = new ProfileViewViewerModel()
 
   // added data
-  descriptionRichText?: RichText
+  descriptionRichText?: RichText = new RichText({text: ''})
 
   constructor(
     public rootStore: RootStoreModel,
@@ -79,10 +72,6 @@ export class ProfileViewModel {
     return this.hasLoaded && !this.hasContent
   }
 
-  get isUser() {
-    return this.declaration.actorType === ACTOR_TYPE_USER
-  }
-
   // public api
   // =
 
@@ -111,18 +100,14 @@ export class ProfileViewModel {
     }
 
     if (followUri) {
-      await apilib.unfollow(this.rootStore, followUri)
+      await this.rootStore.agent.deleteFollow(followUri)
       runInAction(() => {
         this.followersCount--
         this.viewer.following = undefined
         this.rootStore.me.follows.removeFollow(this.did)
       })
     } else {
-      const res = await apilib.follow(
-        this.rootStore,
-        this.did,
-        this.declaration.cid,
-      )
+      const res = await this.rootStore.agent.follow(this.did)
       runInAction(() => {
         this.followersCount++
         this.viewer.following = res.uri
@@ -132,49 +117,48 @@ export class ProfileViewModel {
   }
 
   async updateProfile(
-    updates: AppBskyActorUpdateProfile.InputSchema,
+    updates: AppBskyActorProfile.Record,
     newUserAvatar: PickedMedia | undefined | null,
     newUserBanner: PickedMedia | undefined | null,
   ) {
-    if (newUserAvatar) {
-      const res = await apilib.uploadBlob(
-        this.rootStore,
-        newUserAvatar.path,
-        newUserAvatar.mime,
-      )
-      updates.avatar = {
-        cid: res.data.cid,
-        mimeType: newUserAvatar.mime,
+    await this.rootStore.agent.upsertProfile(async existing => {
+      existing = existing || {}
+      existing.displayName = updates.displayName
+      existing.description = updates.description
+      if (newUserAvatar) {
+        const res = await apilib.uploadBlob(
+          this.rootStore,
+          newUserAvatar.path,
+          newUserAvatar.mime,
+        )
+        existing.avatar = res.data.blob
+      } else if (newUserAvatar === null) {
+        existing.avatar = undefined
       }
-    } else if (newUserAvatar === null) {
-      updates.avatar = null
-    }
-    if (newUserBanner) {
-      const res = await apilib.uploadBlob(
-        this.rootStore,
-        newUserBanner.path,
-        newUserBanner.mime,
-      )
-      updates.banner = {
-        cid: res.data.cid,
-        mimeType: newUserBanner.mime,
+      if (newUserBanner) {
+        const res = await apilib.uploadBlob(
+          this.rootStore,
+          newUserBanner.path,
+          newUserBanner.mime,
+        )
+        existing.banner = res.data.blob
+      } else if (newUserBanner === null) {
+        existing.banner = undefined
       }
-    } else if (newUserBanner === null) {
-      updates.banner = null
-    }
-    await this.rootStore.api.app.bsky.actor.updateProfile(updates)
+      return existing
+    })
     await this.rootStore.me.load()
     await this.refresh()
   }
 
   async muteAccount() {
-    await this.rootStore.api.app.bsky.graph.mute({user: this.did})
+    await this.rootStore.agent.mute(this.did)
     this.viewer.muted = true
     await this.refresh()
   }
 
   async unmuteAccount() {
-    await this.rootStore.api.app.bsky.graph.unmute({user: this.did})
+    await this.rootStore.agent.unmute(this.did)
     this.viewer.muted = false
     await this.refresh()
   }
@@ -182,13 +166,13 @@ export class ProfileViewModel {
   // state transitions
   // =
 
-  private _xLoading(isRefreshing = false) {
+  _xLoading(isRefreshing = false) {
     this.isLoading = true
     this.isRefreshing = isRefreshing
     this.error = ''
   }
 
-  private _xIdle(err?: any) {
+  _xIdle(err?: any) {
     this.isLoading = false
     this.isRefreshing = false
     this.hasLoaded = true
@@ -201,40 +185,40 @@ export class ProfileViewModel {
   // loader functions
   // =
 
-  private async _load(isRefreshing = false) {
+  async _load(isRefreshing = false) {
     this._xLoading(isRefreshing)
     try {
-      const res = await this.rootStore.api.app.bsky.actor.getProfile(
-        this.params,
-      )
+      const res = await this.rootStore.agent.getProfile(this.params)
       this.rootStore.profiles.overwrite(this.params.actor, res) // cache invalidation
       this._replaceAll(res)
+      await this._createRichText()
       this._xIdle()
     } catch (e: any) {
       this._xIdle(e)
     }
   }
 
-  private _replaceAll(res: GetProfile.Response) {
+  _replaceAll(res: GetProfile.Response) {
     this.did = res.data.did
     this.handle = res.data.handle
-    Object.assign(this.declaration, res.data.declaration)
-    this.creator = res.data.creator
     this.displayName = res.data.displayName
     this.description = res.data.description
     this.avatar = res.data.avatar
     this.banner = res.data.banner
-    this.followersCount = res.data.followersCount
-    this.followsCount = res.data.followsCount
-    this.postsCount = res.data.postsCount
+    this.followersCount = res.data.followersCount || 0
+    this.followsCount = res.data.followsCount || 0
+    this.postsCount = res.data.postsCount || 0
     if (res.data.viewer) {
       Object.assign(this.viewer, res.data.viewer)
       this.rootStore.me.follows.hydrate(this.did, res.data.viewer.following)
     }
+  }
+
+  async _createRichText() {
     this.descriptionRichText = new RichText(
-      this.description || '',
-      extractEntities(this.description || ''),
+      {text: this.description || ''},
       {cleanNewlines: true},
     )
+    await this.descriptionRichText.detectFacets(this.rootStore.agent)
   }
 }
diff --git a/src/state/models/profiles-view.ts b/src/state/models/profiles-view.ts
index 4241e50e1..30e6d0442 100644
--- a/src/state/models/profiles-view.ts
+++ b/src/state/models/profiles-view.ts
@@ -31,7 +31,7 @@ export class ProfilesViewModel {
       }
     }
     try {
-      const promise = this.rootStore.api.app.bsky.actor.getProfile({
+      const promise = this.rootStore.agent.getProfile({
         actor: did,
       })
       this.cache.set(did, promise)
diff --git a/src/state/models/reposted-by-view.ts b/src/state/models/reposted-by-view.ts
index 69a728d6f..c9b089c70 100644
--- a/src/state/models/reposted-by-view.ts
+++ b/src/state/models/reposted-by-view.ts
@@ -2,7 +2,7 @@ import {makeAutoObservable, runInAction} from 'mobx'
 import {AtUri} from '../../third-party/uri'
 import {
   AppBskyFeedGetRepostedBy as GetRepostedBy,
-  AppBskyActorRef as ActorRef,
+  AppBskyActorDefs,
 } from '@atproto/api'
 import {RootStoreModel} from './root-store'
 import {bundleAsync} from 'lib/async/bundle'
@@ -11,7 +11,7 @@ import * as apilib from 'lib/api/index'
 
 const PAGE_SIZE = 30
 
-export type RepostedByItem = ActorRef.WithInfo
+export type RepostedByItem = AppBskyActorDefs.ProfileViewBasic
 
 export class RepostedByViewModel {
   // state
@@ -71,9 +71,9 @@ export class RepostedByViewModel {
       const params = Object.assign({}, this.params, {
         uri: this.resolvedUri,
         limit: PAGE_SIZE,
-        before: replace ? undefined : this.loadMoreCursor,
+        cursor: replace ? undefined : this.loadMoreCursor,
       })
-      const res = await this.rootStore.api.app.bsky.feed.getRepostedBy(params)
+      const res = await this.rootStore.agent.getRepostedBy(params)
       if (replace) {
         this._replaceAll(res)
       } else {
@@ -88,13 +88,13 @@ export class RepostedByViewModel {
   // state transitions
   // =
 
-  private _xLoading(isRefreshing = false) {
+  _xLoading(isRefreshing = false) {
     this.isLoading = true
     this.isRefreshing = isRefreshing
     this.error = ''
   }
 
-  private _xIdle(err?: any) {
+  _xIdle(err?: any) {
     this.isLoading = false
     this.isRefreshing = false
     this.hasLoaded = true
@@ -107,7 +107,7 @@ export class RepostedByViewModel {
   // helper functions
   // =
 
-  private async _resolveUri() {
+  async _resolveUri() {
     const urip = new AtUri(this.params.uri)
     if (!urip.host.startsWith('did:')) {
       try {
@@ -121,12 +121,12 @@ export class RepostedByViewModel {
     })
   }
 
-  private _replaceAll(res: GetRepostedBy.Response) {
+  _replaceAll(res: GetRepostedBy.Response) {
     this.repostedBy = []
     this._appendAll(res)
   }
 
-  private _appendAll(res: GetRepostedBy.Response) {
+  _appendAll(res: GetRepostedBy.Response) {
     this.loadMoreCursor = res.data.cursor
     this.hasMore = !!this.loadMoreCursor
     this.repostedBy = this.repostedBy.concat(res.data.repostedBy)
diff --git a/src/state/models/root-store.ts b/src/state/models/root-store.ts
index d8336d005..0c2a31d28 100644
--- a/src/state/models/root-store.ts
+++ b/src/state/models/root-store.ts
@@ -2,8 +2,8 @@
  * The root store is the base of all modeled state.
  */
 
-import {makeAutoObservable, runInAction} from 'mobx'
-import {AtpAgent} from '@atproto/api'
+import {makeAutoObservable} from 'mobx'
+import {BskyAgent} from '@atproto/api'
 import {createContext, useContext} from 'react'
 import {DeviceEventEmitter, EmitterSubscription} from 'react-native'
 import * as BgScheduler from 'lib/bg-scheduler'
@@ -29,7 +29,7 @@ export const appInfo = z.object({
 export type AppInfo = z.infer<typeof appInfo>
 
 export class RootStoreModel {
-  agent: AtpAgent
+  agent: BskyAgent
   appInfo?: AppInfo
   log = new LogModel()
   session = new SessionModel(this)
@@ -40,41 +40,16 @@ export class RootStoreModel {
   linkMetas = new LinkMetasCache(this)
   imageSizes = new ImageSizesCache()
 
-  // HACK
-  // this flag is to track the lexicon breaking refactor
-  // it should be removed once we get that done
-  // -prf
-  hackUpgradeNeeded = false
-  async hackCheckIfUpgradeNeeded() {
-    try {
-      this.log.debug('hackCheckIfUpgradeNeeded()')
-      const res = await fetch('https://bsky.social/xrpc/app.bsky.feed.getLikes')
-      await res.text()
-      runInAction(() => {
-        this.hackUpgradeNeeded = res.status !== 501
-        this.log.debug(
-          `hackCheckIfUpgradeNeeded() said ${this.hackUpgradeNeeded}`,
-        )
-      })
-    } catch (e) {
-      this.log.error('Failed to hackCheckIfUpgradeNeeded', {e})
-    }
-  }
-
-  constructor(agent: AtpAgent) {
+  constructor(agent: BskyAgent) {
     this.agent = agent
     makeAutoObservable(this, {
-      api: false,
+      agent: false,
       serialize: false,
       hydrate: false,
     })
     this.initBgFetch()
   }
 
-  get api() {
-    return this.agent.api
-  }
-
   setAppInfo(info: AppInfo) {
     this.appInfo = info
   }
@@ -131,7 +106,7 @@ export class RootStoreModel {
   /**
    * Called by the session model. Refreshes session-oriented state.
    */
-  async handleSessionChange(agent: AtpAgent) {
+  async handleSessionChange(agent: BskyAgent) {
     this.log.debug('RootStoreModel:handleSessionChange')
     this.agent = agent
     this.me.clear()
@@ -259,7 +234,7 @@ export class RootStoreModel {
   async onBgFetch(taskId: string) {
     this.log.debug(`Background fetch fired for task ${taskId}`)
     if (this.session.hasSession) {
-      const res = await this.api.app.bsky.notification.getCount()
+      const res = await this.agent.countUnreadNotifications()
       const hasNewNotifs = this.me.notifications.unreadCount !== res.data.count
       this.emitUnreadNotifications(res.data.count)
       this.log.debug(
@@ -286,7 +261,7 @@ export class RootStoreModel {
 }
 
 const throwawayInst = new RootStoreModel(
-  new AtpAgent({service: 'http://localhost'}),
+  new BskyAgent({service: 'http://localhost'}),
 ) // this will be replaced by the loader, we just need to supply a value at init
 const RootStoreContext = createContext<RootStoreModel>(throwawayInst)
 export const RootStoreProvider = RootStoreContext.Provider
diff --git a/src/state/models/session.ts b/src/state/models/session.ts
index e131b2b2c..c2e10880d 100644
--- a/src/state/models/session.ts
+++ b/src/state/models/session.ts
@@ -1,9 +1,9 @@
 import {makeAutoObservable, runInAction} from 'mobx'
 import {
-  AtpAgent,
+  BskyAgent,
   AtpSessionEvent,
   AtpSessionData,
-  ComAtprotoServerGetAccountsConfig as GetAccountsConfig,
+  ComAtprotoServerDescribeServer as DescribeServer,
 } from '@atproto/api'
 import normalizeUrl from 'normalize-url'
 import {isObj, hasProp} from 'lib/type-guards'
@@ -11,7 +11,7 @@ import {networkRetry} from 'lib/async/retry'
 import {z} from 'zod'
 import {RootStoreModel} from './root-store'
 
-export type ServiceDescription = GetAccountsConfig.OutputSchema
+export type ServiceDescription = DescribeServer.OutputSchema
 
 export const activeSession = z.object({
   service: z.string(),
@@ -40,7 +40,7 @@ export class SessionModel {
   // emergency log facility to help us track down this logout issue
   // remove when resolved
   // -prf
-  private _log(message: string, details?: Record<string, any>) {
+  _log(message: string, details?: Record<string, any>) {
     details = details || {}
     details.state = {
       data: this.data,
@@ -73,6 +73,7 @@ export class SessionModel {
       rootStore: false,
       serialize: false,
       hydrate: false,
+      hasSession: false,
     })
   }
 
@@ -154,7 +155,7 @@ export class SessionModel {
   /**
    * Sets the active session
    */
-  async setActiveSession(agent: AtpAgent, did: string) {
+  async setActiveSession(agent: BskyAgent, did: string) {
     this._log('SessionModel:setActiveSession')
     this.data = {
       service: agent.service.toString(),
@@ -166,7 +167,7 @@ export class SessionModel {
   /**
    * Upserts a session into the accounts
    */
-  private persistSession(
+  persistSession(
     service: string,
     did: string,
     event: AtpSessionEvent,
@@ -225,7 +226,7 @@ export class SessionModel {
   /**
    * Clears any session tokens from the accounts; used on logout.
    */
-  private clearSessionTokens() {
+  clearSessionTokens() {
     this._log('SessionModel:clearSessionTokens')
     this.accounts = this.accounts.map(acct => ({
       service: acct.service,
@@ -239,10 +240,8 @@ export class SessionModel {
   /**
    * Fetches additional information about an account on load.
    */
-  private async loadAccountInfo(agent: AtpAgent, did: string) {
-    const res = await agent.api.app.bsky.actor
-      .getProfile({actor: did})
-      .catch(_e => undefined)
+  async loadAccountInfo(agent: BskyAgent, did: string) {
+    const res = await agent.getProfile({actor: did}).catch(_e => undefined)
     if (res) {
       return {
         dispayName: res.data.displayName,
@@ -255,8 +254,8 @@ export class SessionModel {
    * Helper to fetch the accounts config settings from an account.
    */
   async describeService(service: string): Promise<ServiceDescription> {
-    const agent = new AtpAgent({service})
-    const res = await agent.api.com.atproto.server.getAccountsConfig({})
+    const agent = new BskyAgent({service})
+    const res = await agent.com.atproto.server.describeServer({})
     return res.data
   }
 
@@ -272,7 +271,7 @@ export class SessionModel {
       return false
     }
 
-    const agent = new AtpAgent({
+    const agent = new BskyAgent({
       service: account.service,
       persistSession: (evt: AtpSessionEvent, sess?: AtpSessionData) => {
         this.persistSession(account.service, account.did, evt, sess)
@@ -321,7 +320,7 @@ export class SessionModel {
     password: string
   }) {
     this._log('SessionModel:login')
-    const agent = new AtpAgent({service})
+    const agent = new BskyAgent({service})
     await agent.login({identifier, password})
     if (!agent.session) {
       throw new Error('Failed to establish session')
@@ -355,7 +354,7 @@ export class SessionModel {
     inviteCode?: string
   }) {
     this._log('SessionModel:createAccount')
-    const agent = new AtpAgent({service})
+    const agent = new BskyAgent({service})
     await agent.createAccount({
       handle,
       password,
@@ -389,7 +388,7 @@ export class SessionModel {
     // need to evaluate why deleting the session has caused errors at times
     // -prf
     /*if (this.hasSession) {
-      this.rootStore.api.com.atproto.session.delete().catch((e: any) => {
+      this.rootStore.agent.com.atproto.session.delete().catch((e: any) => {
         this.rootStore.log.warn(
           '(Minor issue) Failed to delete session on the server',
           e,
@@ -415,7 +414,7 @@ export class SessionModel {
     if (!sess) {
       return
     }
-    const res = await this.rootStore.api.app.bsky.actor
+    const res = await this.rootStore.agent
       .getProfile({actor: sess.did})
       .catch(_e => undefined)
     if (res?.success) {
diff --git a/src/state/models/suggested-posts-view.ts b/src/state/models/suggested-posts-view.ts
index 7a5ca81b9..46bf235ff 100644
--- a/src/state/models/suggested-posts-view.ts
+++ b/src/state/models/suggested-posts-view.ts
@@ -72,12 +72,12 @@ export class SuggestedPostsView {
   // state transitions
   // =
 
-  private _xLoading() {
+  _xLoading() {
     this.isLoading = true
     this.error = ''
   }
 
-  private _xIdle(err?: any) {
+  _xIdle(err?: any) {
     this.isLoading = false
     this.hasLoaded = true
     this.error = cleanError(err)
diff --git a/src/state/models/ui/create-account.ts b/src/state/models/ui/create-account.ts
index a212fe05e..e661cb59d 100644
--- a/src/state/models/ui/create-account.ts
+++ b/src/state/models/ui/create-account.ts
@@ -2,7 +2,7 @@ import {makeAutoObservable} from 'mobx'
 import {RootStoreModel} from '../root-store'
 import {ServiceDescription} from '../session'
 import {DEFAULT_SERVICE} from 'state/index'
-import {ComAtprotoAccountCreate} from '@atproto/api'
+import {ComAtprotoServerCreateAccount} from '@atproto/api'
 import * as EmailValidator from 'email-validator'
 import {createFullHandle} from 'lib/strings/handles'
 import {cleanError} from 'lib/strings/errors'
@@ -99,7 +99,7 @@ export class CreateAccountModel {
       })
     } catch (e: any) {
       let errMsg = e.toString()
-      if (e instanceof ComAtprotoAccountCreate.InvalidInviteCodeError) {
+      if (e instanceof ComAtprotoServerCreateAccount.InvalidInviteCodeError) {
         errMsg =
           'Invite code not accepted. Check that you input it correctly and try again.'
       }
diff --git a/src/state/models/ui/profile.ts b/src/state/models/ui/profile.ts
index 280541b74..59529aa39 100644
--- a/src/state/models/ui/profile.ts
+++ b/src/state/models/ui/profile.ts
@@ -40,7 +40,7 @@ export class ProfileUiModel {
     )
     this.profile = new ProfileViewModel(rootStore, {actor: params.user})
     this.feed = new FeedModel(rootStore, 'author', {
-      author: params.user,
+      actor: params.user,
       limit: 10,
     })
   }
@@ -64,16 +64,8 @@ export class ProfileUiModel {
     return this.profile.isRefreshing || this.currentView.isRefreshing
   }
 
-  get isUser() {
-    return this.profile.isUser
-  }
-
   get selectorItems() {
-    if (this.isUser) {
-      return USER_SELECTOR_ITEMS
-    } else {
-      return USER_SELECTOR_ITEMS
-    }
+    return USER_SELECTOR_ITEMS
   }
 
   get selectedView() {
diff --git a/src/state/models/ui/search.ts b/src/state/models/ui/search.ts
index 91e1b24bf..8436b0984 100644
--- a/src/state/models/ui/search.ts
+++ b/src/state/models/ui/search.ts
@@ -1,6 +1,6 @@
 import {makeAutoObservable, runInAction} from 'mobx'
 import {searchProfiles, searchPosts} from 'lib/api/search'
-import {AppBskyActorProfile as Profile} from '@atproto/api'
+import {AppBskyActorDefs} from '@atproto/api'
 import {RootStoreModel} from '../root-store'
 
 export class SearchUIModel {
@@ -8,7 +8,7 @@ export class SearchUIModel {
   isProfilesLoading = false
   query: string = ''
   postUris: string[] = []
-  profiles: Profile.View[] = []
+  profiles: AppBskyActorDefs.ProfileView[] = []
 
   constructor(public rootStore: RootStoreModel) {
     makeAutoObservable(this)
@@ -34,10 +34,10 @@ export class SearchUIModel {
       this.isPostsLoading = false
     })
 
-    let profiles: Profile.View[] = []
+    let profiles: AppBskyActorDefs.ProfileView[] = []
     if (profilesSearch?.length) {
       do {
-        const res = await this.rootStore.api.app.bsky.actor.getProfiles({
+        const res = await this.rootStore.agent.getProfiles({
           actors: profilesSearch.splice(0, 25).map(p => p.did),
         })
         profiles = profiles.concat(res.data.profiles)
diff --git a/src/state/models/ui/shell.ts b/src/state/models/ui/shell.ts
index fec1e2899..7f57d5b54 100644
--- a/src/state/models/ui/shell.ts
+++ b/src/state/models/ui/shell.ts
@@ -1,3 +1,4 @@
+import {AppBskyEmbedRecord} from '@atproto/api'
 import {RootStoreModel} from '../root-store'
 import {makeAutoObservable} from 'mobx'
 import {ProfileViewModel} from '../profile-view'
@@ -111,6 +112,7 @@ export interface ComposerOptsQuote {
     displayName?: string
     avatar?: string
   }
+  embeds?: AppBskyEmbedRecord.ViewRecord['embeds']
 }
 export interface ComposerOpts {
   replyTo?: ComposerOptsPostRef
diff --git a/src/state/models/user-autocomplete-view.ts b/src/state/models/user-autocomplete-view.ts
index 8e4211c27..ad89bb08b 100644
--- a/src/state/models/user-autocomplete-view.ts
+++ b/src/state/models/user-autocomplete-view.ts
@@ -1,5 +1,5 @@
 import {makeAutoObservable, runInAction} from 'mobx'
-import {AppBskyActorRef} from '@atproto/api'
+import {AppBskyActorDefs} from '@atproto/api'
 import AwaitLock from 'await-lock'
 import {RootStoreModel} from './root-store'
 
@@ -11,8 +11,8 @@ export class UserAutocompleteViewModel {
   lock = new AwaitLock()
 
   // data
-  follows: AppBskyActorRef.WithInfo[] = []
-  searchRes: AppBskyActorRef.WithInfo[] = []
+  follows: AppBskyActorDefs.ProfileViewBasic[] = []
+  searchRes: AppBskyActorDefs.ProfileViewBasic[] = []
   knownHandles: Set<string> = new Set()
 
   constructor(public rootStore: RootStoreModel) {
@@ -76,9 +76,9 @@ export class UserAutocompleteViewModel {
   // internal
   // =
 
-  private async _getFollows() {
-    const res = await this.rootStore.api.app.bsky.graph.getFollows({
-      user: this.rootStore.me.did || '',
+  async _getFollows() {
+    const res = await this.rootStore.agent.getFollows({
+      actor: this.rootStore.me.did || '',
     })
     runInAction(() => {
       this.follows = res.data.follows
@@ -88,13 +88,13 @@ export class UserAutocompleteViewModel {
     })
   }
 
-  private async _search() {
-    const res = await this.rootStore.api.app.bsky.actor.searchTypeahead({
+  async _search() {
+    const res = await this.rootStore.agent.searchActorsTypeahead({
       term: this.prefix,
       limit: 8,
     })
     runInAction(() => {
-      this.searchRes = res.data.users
+      this.searchRes = res.data.actors
       for (const u of this.searchRes) {
         this.knownHandles.add(u.handle)
       }
diff --git a/src/state/models/user-followers-view.ts b/src/state/models/user-followers-view.ts
index 7400262a4..055032eb7 100644
--- a/src/state/models/user-followers-view.ts
+++ b/src/state/models/user-followers-view.ts
@@ -1,7 +1,7 @@
 import {makeAutoObservable} from 'mobx'
 import {
   AppBskyGraphGetFollowers as GetFollowers,
-  AppBskyActorRef as ActorRef,
+  AppBskyActorDefs as ActorDefs,
 } from '@atproto/api'
 import {RootStoreModel} from './root-store'
 import {cleanError} from 'lib/strings/errors'
@@ -9,7 +9,7 @@ import {bundleAsync} from 'lib/async/bundle'
 
 const PAGE_SIZE = 30
 
-export type FollowerItem = ActorRef.WithInfo
+export type FollowerItem = ActorDefs.ProfileViewBasic
 
 export class UserFollowersViewModel {
   // state
@@ -22,10 +22,9 @@ export class UserFollowersViewModel {
   loadMoreCursor?: string
 
   // data
-  subject: ActorRef.WithInfo = {
+  subject: ActorDefs.ProfileViewBasic = {
     did: '',
     handle: '',
-    declaration: {cid: '', actorType: ''},
   }
   followers: FollowerItem[] = []
 
@@ -71,9 +70,9 @@ export class UserFollowersViewModel {
     try {
       const params = Object.assign({}, this.params, {
         limit: PAGE_SIZE,
-        before: replace ? undefined : this.loadMoreCursor,
+        cursor: replace ? undefined : this.loadMoreCursor,
       })
-      const res = await this.rootStore.api.app.bsky.graph.getFollowers(params)
+      const res = await this.rootStore.agent.getFollowers(params)
       if (replace) {
         this._replaceAll(res)
       } else {
@@ -88,13 +87,13 @@ export class UserFollowersViewModel {
   // state transitions
   // =
 
-  private _xLoading(isRefreshing = false) {
+  _xLoading(isRefreshing = false) {
     this.isLoading = true
     this.isRefreshing = isRefreshing
     this.error = ''
   }
 
-  private _xIdle(err?: any) {
+  _xIdle(err?: any) {
     this.isLoading = false
     this.isRefreshing = false
     this.hasLoaded = true
@@ -107,12 +106,12 @@ export class UserFollowersViewModel {
   // helper functions
   // =
 
-  private _replaceAll(res: GetFollowers.Response) {
+  _replaceAll(res: GetFollowers.Response) {
     this.followers = []
     this._appendAll(res)
   }
 
-  private _appendAll(res: GetFollowers.Response) {
+  _appendAll(res: GetFollowers.Response) {
     this.loadMoreCursor = res.data.cursor
     this.hasMore = !!this.loadMoreCursor
     this.followers = this.followers.concat(res.data.followers)
diff --git a/src/state/models/user-follows-view.ts b/src/state/models/user-follows-view.ts
index 7d28d7ebd..6d9d84592 100644
--- a/src/state/models/user-follows-view.ts
+++ b/src/state/models/user-follows-view.ts
@@ -1,7 +1,7 @@
 import {makeAutoObservable} from 'mobx'
 import {
   AppBskyGraphGetFollows as GetFollows,
-  AppBskyActorRef as ActorRef,
+  AppBskyActorDefs as ActorDefs,
 } from '@atproto/api'
 import {RootStoreModel} from './root-store'
 import {cleanError} from 'lib/strings/errors'
@@ -9,7 +9,7 @@ import {bundleAsync} from 'lib/async/bundle'
 
 const PAGE_SIZE = 30
 
-export type FollowItem = ActorRef.WithInfo
+export type FollowItem = ActorDefs.ProfileViewBasic
 
 export class UserFollowsViewModel {
   // state
@@ -22,10 +22,9 @@ export class UserFollowsViewModel {
   loadMoreCursor?: string
 
   // data
-  subject: ActorRef.WithInfo = {
+  subject: ActorDefs.ProfileViewBasic = {
     did: '',
     handle: '',
-    declaration: {cid: '', actorType: ''},
   }
   follows: FollowItem[] = []
 
@@ -71,9 +70,9 @@ export class UserFollowsViewModel {
     try {
       const params = Object.assign({}, this.params, {
         limit: PAGE_SIZE,
-        before: replace ? undefined : this.loadMoreCursor,
+        cursor: replace ? undefined : this.loadMoreCursor,
       })
-      const res = await this.rootStore.api.app.bsky.graph.getFollows(params)
+      const res = await this.rootStore.agent.getFollows(params)
       if (replace) {
         this._replaceAll(res)
       } else {
@@ -88,13 +87,13 @@ export class UserFollowsViewModel {
   // state transitions
   // =
 
-  private _xLoading(isRefreshing = false) {
+  _xLoading(isRefreshing = false) {
     this.isLoading = true
     this.isRefreshing = isRefreshing
     this.error = ''
   }
 
-  private _xIdle(err?: any) {
+  _xIdle(err?: any) {
     this.isLoading = false
     this.isRefreshing = false
     this.hasLoaded = true
@@ -107,12 +106,12 @@ export class UserFollowsViewModel {
   // helper functions
   // =
 
-  private _replaceAll(res: GetFollows.Response) {
+  _replaceAll(res: GetFollows.Response) {
     this.follows = []
     this._appendAll(res)
   }
 
-  private _appendAll(res: GetFollows.Response) {
+  _appendAll(res: GetFollows.Response) {
     this.loadMoreCursor = res.data.cursor
     this.hasMore = !!this.loadMoreCursor
     this.follows = this.follows.concat(res.data.follows)
diff --git a/src/view/com/auth/create/CreateAccount.tsx b/src/view/com/auth/create/CreateAccount.tsx
index 618c15cf5..6ece903d6 100644
--- a/src/view/com/auth/create/CreateAccount.tsx
+++ b/src/view/com/auth/create/CreateAccount.tsx
@@ -75,16 +75,14 @@ export const CreateAccount = observer(
             {model.step === 3 && <Step3 model={model} />}
           </View>
           <View style={[s.flexRow, s.pl20, s.pr20]}>
-            <TouchableOpacity onPress={onPressBackInner}>
+            <TouchableOpacity onPress={onPressBackInner} testID="backBtn">
               <Text type="xl" style={pal.link}>
                 Back
               </Text>
             </TouchableOpacity>
             <View style={s.flex1} />
             {model.canNext ? (
-              <TouchableOpacity
-                testID="createAccountButton"
-                onPress={onPressNext}>
+              <TouchableOpacity testID="nextBtn" onPress={onPressNext}>
                 {model.isProcessing ? (
                   <ActivityIndicator />
                 ) : (
@@ -95,7 +93,7 @@ export const CreateAccount = observer(
               </TouchableOpacity>
             ) : model.didServiceDescriptionFetchFail ? (
               <TouchableOpacity
-                testID="registerRetryButton"
+                testID="retryConnectBtn"
                 onPress={onPressRetryConnect}>
                 <Text type="xl-bold" style={[pal.link, s.pr5]}>
                   Retry
diff --git a/src/view/com/auth/create/Step1.tsx b/src/view/com/auth/create/Step1.tsx
index 0a628f9d0..ca964ede2 100644
--- a/src/view/com/auth/create/Step1.tsx
+++ b/src/view/com/auth/create/Step1.tsx
@@ -60,12 +60,14 @@ export const Step1 = observer(({model}: {model: CreateAccountModel}) => {
         This is the company that keeps you online.
       </Text>
       <Option
+        testID="blueskyServerBtn"
         isSelected={isDefaultSelected}
         label="Bluesky"
         help="&nbsp;(default)"
         onPress={onPressDefault}
       />
       <Option
+        testID="otherServerBtn"
         isSelected={!isDefaultSelected}
         label="Other"
         onPress={onPressOther}>
@@ -74,6 +76,7 @@ export const Step1 = observer(({model}: {model: CreateAccountModel}) => {
             Enter the address of your provider:
           </Text>
           <TextInput
+            testID="customServerInput"
             icon="globe"
             placeholder="Hosting provider address"
             value={model.serviceUrl}
@@ -83,12 +86,14 @@ export const Step1 = observer(({model}: {model: CreateAccountModel}) => {
           {LOGIN_INCLUDE_DEV_SERVERS && (
             <View style={[s.flexRow, s.mt10]}>
               <Button
+                testID="stagingServerBtn"
                 type="default"
                 style={s.mr5}
                 label="Staging"
                 onPress={() => onDebugChangeServiceUrl(STAGING_SERVICE)}
               />
               <Button
+                testID="localDevServerBtn"
                 type="default"
                 label="Dev Server"
                 onPress={() => onDebugChangeServiceUrl(LOCAL_DEV_SERVICE)}
@@ -112,11 +117,13 @@ function Option({
   label,
   help,
   onPress,
+  testID,
 }: React.PropsWithChildren<{
   isSelected: boolean
   label: string
   help?: string
   onPress: () => void
+  testID?: string
 }>) {
   const theme = useTheme()
   const pal = usePalette('default')
@@ -129,7 +136,7 @@ function Option({
 
   return (
     <View style={[styles.option, pal.border]}>
-      <TouchableWithoutFeedback onPress={onPress}>
+      <TouchableWithoutFeedback onPress={onPress} testID={testID}>
         <View style={styles.optionHeading}>
           <View style={[styles.circle, pal.border]}>
             {isSelected ? (
diff --git a/src/view/com/auth/create/Step2.tsx b/src/view/com/auth/create/Step2.tsx
index f115bf6ac..8df997bd3 100644
--- a/src/view/com/auth/create/Step2.tsx
+++ b/src/view/com/auth/create/Step2.tsx
@@ -59,6 +59,7 @@ export const Step2 = observer(({model}: {model: CreateAccountModel}) => {
               Email address
             </Text>
             <TextInput
+              testID="emailInput"
               icon="envelope"
               placeholder="Enter your email address"
               value={model.email}
@@ -72,6 +73,7 @@ export const Step2 = observer(({model}: {model: CreateAccountModel}) => {
               Password
             </Text>
             <TextInput
+              testID="passwordInput"
               icon="lock"
               placeholder="Choose your password"
               value={model.password}
@@ -86,7 +88,7 @@ export const Step2 = observer(({model}: {model: CreateAccountModel}) => {
               Legal check
             </Text>
             <TouchableOpacity
-              testID="registerIs13Input"
+              testID="is13Input"
               style={[styles.toggleBtn, pal.border]}
               onPress={() => model.setIs13(!model.is13)}>
               <View style={[pal.borderDark, styles.checkbox]}>
diff --git a/src/view/com/auth/create/Step3.tsx b/src/view/com/auth/create/Step3.tsx
index 652591171..13ab39a10 100644
--- a/src/view/com/auth/create/Step3.tsx
+++ b/src/view/com/auth/create/Step3.tsx
@@ -17,6 +17,7 @@ export const Step3 = observer(({model}: {model: CreateAccountModel}) => {
       <StepHeader step="3" title="Your user handle" />
       <View style={s.pb10}>
         <TextInput
+          testID="handleInput"
           icon="at"
           placeholder="eg alice"
           value={model.handle}
diff --git a/src/view/com/auth/login/Login.tsx b/src/view/com/auth/login/Login.tsx
index f99e72daa..eff1642f0 100644
--- a/src/view/com/auth/login/Login.tsx
+++ b/src/view/com/auth/login/Login.tsx
@@ -13,7 +13,7 @@ import {
   FontAwesomeIconStyle,
 } from '@fortawesome/react-native-fontawesome'
 import * as EmailValidator from 'email-validator'
-import AtpAgent from '@atproto/api'
+import {BskyAgent} from '@atproto/api'
 import {useAnalytics} from 'lib/analytics'
 import {Text} from '../../util/text/Text'
 import {UserAvatar} from '../../util/UserAvatar'
@@ -506,8 +506,8 @@ const ForgotPasswordForm = ({
     setIsProcessing(true)
 
     try {
-      const agent = new AtpAgent({service: serviceUrl})
-      await agent.api.com.atproto.account.requestPasswordReset({email})
+      const agent = new BskyAgent({service: serviceUrl})
+      await agent.com.atproto.server.requestPasswordReset({email})
       onEmailSent()
     } catch (e: any) {
       const errMsg = e.toString()
@@ -648,8 +648,8 @@ const SetNewPasswordForm = ({
     setIsProcessing(true)
 
     try {
-      const agent = new AtpAgent({service: serviceUrl})
-      await agent.api.com.atproto.account.resetPassword({
+      const agent = new BskyAgent({service: serviceUrl})
+      await agent.com.atproto.server.resetPassword({
         token: resetCode,
         password,
       })
diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx
index 572eea927..6009debdd 100644
--- a/src/view/com/composer/Composer.tsx
+++ b/src/view/com/composer/Composer.tsx
@@ -1,4 +1,4 @@
-import React, {useEffect, useRef, useState} from 'react'
+import React from 'react'
 import {observer} from 'mobx-react-lite'
 import {
   ActivityIndicator,
@@ -13,6 +13,7 @@ import {
 } from 'react-native'
 import LinearGradient from 'react-native-linear-gradient'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {RichText} from '@atproto/api'
 import {useAnalytics} from 'lib/analytics'
 import {UserAutocompleteViewModel} from 'state/models/user-autocomplete-view'
 import {ExternalEmbed} from './ExternalEmbed'
@@ -30,11 +31,11 @@ import {SelectPhotoBtn} from './photos/SelectPhotoBtn'
 import {OpenCameraBtn} from './photos/OpenCameraBtn'
 import {SelectedPhotos} from './photos/SelectedPhotos'
 import {usePalette} from 'lib/hooks/usePalette'
-import QuoteEmbed from '../util/PostEmbeds/QuoteEmbed'
+import QuoteEmbed from '../util/post-embeds/QuoteEmbed'
 import {useExternalLinkFetch} from './useExternalLinkFetch'
 import {isDesktopWeb} from 'platform/detection'
 
-const MAX_TEXT_LENGTH = 256
+const MAX_GRAPHEME_LENGTH = 300
 
 export const ComposePost = observer(function ComposePost({
   replyTo,
@@ -50,17 +51,23 @@ export const ComposePost = observer(function ComposePost({
   const {track} = useAnalytics()
   const pal = usePalette('default')
   const store = useStores()
-  const textInput = useRef<TextInputRef>(null)
-  const [isProcessing, setIsProcessing] = useState(false)
-  const [processingState, setProcessingState] = useState('')
-  const [error, setError] = useState('')
-  const [text, setText] = useState('')
-  const [quote, setQuote] = useState<ComposerOpts['quote'] | undefined>(
+  const textInput = React.useRef<TextInputRef>(null)
+  const [isProcessing, setIsProcessing] = React.useState(false)
+  const [processingState, setProcessingState] = React.useState('')
+  const [error, setError] = React.useState('')
+  const [richtext, setRichText] = React.useState(new RichText({text: ''}))
+  const graphemeLength = React.useMemo(
+    () => richtext.graphemeLength,
+    [richtext],
+  )
+  const [quote, setQuote] = React.useState<ComposerOpts['quote'] | undefined>(
     initQuote,
   )
   const {extLink, setExtLink} = useExternalLinkFetch({setQuote})
-  const [suggestedLinks, setSuggestedLinks] = useState<Set<string>>(new Set())
-  const [selectedPhotos, setSelectedPhotos] = useState<string[]>([])
+  const [suggestedLinks, setSuggestedLinks] = React.useState<Set<string>>(
+    new Set(),
+  )
+  const [selectedPhotos, setSelectedPhotos] = React.useState<string[]>([])
 
   const autocompleteView = React.useMemo<UserAutocompleteViewModel>(
     () => new UserAutocompleteViewModel(store),
@@ -78,11 +85,11 @@ export const ComposePost = observer(function ComposePost({
   }, [textInput, onClose])
 
   // initial setup
-  useEffect(() => {
+  React.useEffect(() => {
     autocompleteView.setup()
   }, [autocompleteView])
 
-  useEffect(() => {
+  React.useEffect(() => {
     // HACK
     // wait a moment before focusing the input to resolve some layout bugs with the keyboard-avoiding-view
     // -prf
@@ -132,18 +139,18 @@ export const ComposePost = observer(function ComposePost({
     if (isProcessing) {
       return
     }
-    if (text.length > MAX_TEXT_LENGTH) {
+    if (richtext.graphemeLength > MAX_GRAPHEME_LENGTH) {
       return
     }
     setError('')
-    if (text.trim().length === 0 && selectedPhotos.length === 0) {
+    if (richtext.text.trim().length === 0 && selectedPhotos.length === 0) {
       setError('Did you want to say anything?')
       return false
     }
     setIsProcessing(true)
     try {
       await apilib.post(store, {
-        rawText: text,
+        rawText: richtext.text,
         replyTo: replyTo?.uri,
         images: selectedPhotos,
         quote: quote,
@@ -172,7 +179,7 @@ export const ComposePost = observer(function ComposePost({
     Toast.show(`Your ${replyTo ? 'reply' : 'post'} has been published`)
   }, [
     isProcessing,
-    text,
+    richtext,
     setError,
     setIsProcessing,
     replyTo,
@@ -187,7 +194,7 @@ export const ComposePost = observer(function ComposePost({
     track,
   ])
 
-  const canPost = text.length <= MAX_TEXT_LENGTH
+  const canPost = graphemeLength <= MAX_GRAPHEME_LENGTH
 
   const selectTextInputPlaceholder = replyTo
     ? 'Write your reply'
@@ -215,7 +222,7 @@ export const ComposePost = observer(function ComposePost({
               </View>
             ) : canPost ? (
               <TouchableOpacity
-                testID="composerPublishButton"
+                testID="composerPublishBtn"
                 onPress={onPressPublish}>
                 <LinearGradient
                   colors={[gradients.blueLight.start, gradients.blueLight.end]}
@@ -271,42 +278,41 @@ export const ComposePost = observer(function ComposePost({
               <UserAvatar avatar={store.me.avatar} size={50} />
               <TextInput
                 ref={textInput}
-                text={text}
+                richtext={richtext}
                 placeholder={selectTextInputPlaceholder}
                 suggestedLinks={suggestedLinks}
                 autocompleteView={autocompleteView}
-                onTextChanged={setText}
+                setRichText={setRichText}
                 onPhotoPasted={onPhotoPasted}
                 onSuggestedLinksChanged={setSuggestedLinks}
                 onError={setError}
               />
             </View>
 
-            {quote ? (
-              <View style={s.mt5}>
-                <QuoteEmbed quote={quote} />
-              </View>
-            ) : undefined}
-
             <SelectedPhotos
               selectedPhotos={selectedPhotos}
               onSelectPhotos={onSelectPhotos}
             />
-            {!selectedPhotos.length && extLink && (
+            {selectedPhotos.length === 0 && extLink && (
               <ExternalEmbed
                 link={extLink}
                 onRemove={() => setExtLink(undefined)}
               />
             )}
+            {quote ? (
+              <View style={s.mt5}>
+                <QuoteEmbed quote={quote} />
+              </View>
+            ) : undefined}
           </ScrollView>
           {!extLink &&
           selectedPhotos.length === 0 &&
-          suggestedLinks.size > 0 &&
-          !quote ? (
+          suggestedLinks.size > 0 ? (
             <View style={s.mb5}>
               {Array.from(suggestedLinks).map(url => (
                 <TouchableOpacity
                   key={`suggested-${url}`}
+                  testID="addLinkCardBtn"
                   style={[pal.borderDark, styles.addExtLinkBtn]}
                   onPress={() => onPressAddLinkCard(url)}>
                   <Text style={pal.text}>
@@ -318,17 +324,17 @@ export const ComposePost = observer(function ComposePost({
           ) : null}
           <View style={[pal.border, styles.bottomBar]}>
             <SelectPhotoBtn
-              enabled={!quote && selectedPhotos.length < 4}
+              enabled={selectedPhotos.length < 4}
               selectedPhotos={selectedPhotos}
               onSelectPhotos={setSelectedPhotos}
             />
             <OpenCameraBtn
-              enabled={!quote && selectedPhotos.length < 4}
+              enabled={selectedPhotos.length < 4}
               selectedPhotos={selectedPhotos}
               onSelectPhotos={setSelectedPhotos}
             />
             <View style={s.flex1} />
-            <CharProgress count={text.length} />
+            <CharProgress count={graphemeLength} />
           </View>
         </SafeAreaView>
       </TouchableWithoutFeedback>
@@ -408,6 +414,7 @@ const styles = StyleSheet.create({
     borderRadius: 24,
     paddingHorizontal: 16,
     paddingVertical: 12,
+    marginHorizontal: 10,
     marginBottom: 4,
   },
   bottomBar: {
diff --git a/src/view/com/composer/char-progress/CharProgress.tsx b/src/view/com/composer/char-progress/CharProgress.tsx
index b17cad1ba..eaaaea5e5 100644
--- a/src/view/com/composer/char-progress/CharProgress.tsx
+++ b/src/view/com/composer/char-progress/CharProgress.tsx
@@ -8,26 +8,24 @@ import ProgressPie from 'react-native-progress/Pie'
 import {s} from 'lib/styles'
 import {usePalette} from 'lib/hooks/usePalette'
 
-const MAX_TEXT_LENGTH = 256
-const DANGER_TEXT_LENGTH = MAX_TEXT_LENGTH
+const MAX_LENGTH = 300
+const DANGER_LENGTH = MAX_LENGTH
 
 export function CharProgress({count}: {count: number}) {
   const pal = usePalette('default')
-  const textColor = count > DANGER_TEXT_LENGTH ? '#e60000' : pal.colors.text
-  const circleColor = count > DANGER_TEXT_LENGTH ? '#e60000' : pal.colors.link
+  const textColor = count > DANGER_LENGTH ? '#e60000' : pal.colors.text
+  const circleColor = count > DANGER_LENGTH ? '#e60000' : pal.colors.link
   return (
     <>
-      <Text style={[s.mr10, {color: textColor}]}>
-        {MAX_TEXT_LENGTH - count}
-      </Text>
+      <Text style={[s.mr10, {color: textColor}]}>{MAX_LENGTH - count}</Text>
       <View>
-        {count > DANGER_TEXT_LENGTH ? (
+        {count > DANGER_LENGTH ? (
           <ProgressPie
             size={30}
             borderWidth={4}
             borderColor={circleColor}
             color={circleColor}
-            progress={Math.min((count - MAX_TEXT_LENGTH) / MAX_TEXT_LENGTH, 1)}
+            progress={Math.min((count - MAX_LENGTH) / MAX_LENGTH, 1)}
           />
         ) : (
           <ProgressCircle
@@ -35,7 +33,7 @@ export function CharProgress({count}: {count: number}) {
             borderWidth={1}
             borderColor={pal.colors.border}
             color={circleColor}
-            progress={count / MAX_TEXT_LENGTH}
+            progress={count / MAX_LENGTH}
           />
         )}
       </View>
diff --git a/src/view/com/composer/photos/OpenCameraBtn.tsx b/src/view/com/composer/photos/OpenCameraBtn.tsx
index cf4a4c7d1..118728781 100644
--- a/src/view/com/composer/photos/OpenCameraBtn.tsx
+++ b/src/view/com/composer/photos/OpenCameraBtn.tsx
@@ -76,7 +76,11 @@ export function OpenCameraBtn({
       hitSlop={HITSLOP}>
       <FontAwesomeIcon
         icon="camera"
-        style={(enabled ? pal.link : pal.textLight) as FontAwesomeIconStyle}
+        style={
+          (enabled
+            ? pal.link
+            : [pal.textLight, s.dimmed]) as FontAwesomeIconStyle
+        }
         size={24}
       />
     </TouchableOpacity>
diff --git a/src/view/com/composer/photos/SelectPhotoBtn.tsx b/src/view/com/composer/photos/SelectPhotoBtn.tsx
index bdcb0534a..888118a85 100644
--- a/src/view/com/composer/photos/SelectPhotoBtn.tsx
+++ b/src/view/com/composer/photos/SelectPhotoBtn.tsx
@@ -86,7 +86,11 @@ export function SelectPhotoBtn({
       hitSlop={HITSLOP}>
       <FontAwesomeIcon
         icon={['far', 'image']}
-        style={(enabled ? pal.link : pal.textLight) as FontAwesomeIconStyle}
+        style={
+          (enabled
+            ? pal.link
+            : [pal.textLight, s.dimmed]) as FontAwesomeIconStyle
+        }
         size={24}
       />
     </TouchableOpacity>
diff --git a/src/view/com/composer/text-input/TextInput.tsx b/src/view/com/composer/text-input/TextInput.tsx
index e72b41f0a..393d168fe 100644
--- a/src/view/com/composer/text-input/TextInput.tsx
+++ b/src/view/com/composer/text-input/TextInput.tsx
@@ -9,13 +9,13 @@ import PasteInput, {
   PastedFile,
   PasteInputRef,
 } from '@mattermost/react-native-paste-input'
+import {AppBskyRichtextFacet, RichText} from '@atproto/api'
 import isEqual from 'lodash.isequal'
 import {UserAutocompleteViewModel} from 'state/models/user-autocomplete-view'
 import {Autocomplete} from './mobile/Autocomplete'
 import {Text} from 'view/com/util/text/Text'
 import {useStores} from 'state/index'
 import {cleanError} from 'lib/strings/errors'
-import {detectLinkables, extractEntities} from 'lib/strings/rich-text-detection'
 import {getImageDim} from 'lib/media/manip'
 import {cropAndCompressFlow} from 'lib/media/picker'
 import {getMentionAt, insertMentionAt} from 'lib/strings/mention-manip'
@@ -33,11 +33,11 @@ export interface TextInputRef {
 }
 
 interface TextInputProps {
-  text: string
+  richtext: RichText
   placeholder: string
   suggestedLinks: Set<string>
   autocompleteView: UserAutocompleteViewModel
-  onTextChanged: (v: string) => void
+  setRichText: (v: RichText) => void
   onPhotoPasted: (uri: string) => void
   onSuggestedLinksChanged: (uris: Set<string>) => void
   onError: (err: string) => void
@@ -51,11 +51,11 @@ interface Selection {
 export const TextInput = React.forwardRef(
   (
     {
-      text,
+      richtext,
       placeholder,
       suggestedLinks,
       autocompleteView,
-      onTextChanged,
+      setRichText,
       onPhotoPasted,
       onSuggestedLinksChanged,
       onError,
@@ -92,7 +92,9 @@ export const TextInput = React.forwardRef(
 
     const onChangeText = React.useCallback(
       (newText: string) => {
-        onTextChanged(newText)
+        const newRt = new RichText({text: newText})
+        newRt.detectFacetsWithoutResolution()
+        setRichText(newRt)
 
         const prefix = getMentionAt(
           newText,
@@ -105,20 +107,21 @@ export const TextInput = React.forwardRef(
           autocompleteView.setActive(false)
         }
 
-        const ents = extractEntities(newText)?.filter(
-          ent => ent.type === 'link',
-        )
-        const set = new Set(ents ? ents.map(e => e.value) : [])
+        const set: Set<string> = new Set()
+        if (newRt.facets) {
+          for (const facet of newRt.facets) {
+            for (const feature of facet.features) {
+              if (AppBskyRichtextFacet.isLink(feature)) {
+                set.add(feature.uri)
+              }
+            }
+          }
+        }
         if (!isEqual(set, suggestedLinks)) {
           onSuggestedLinksChanged(set)
         }
       },
-      [
-        onTextChanged,
-        autocompleteView,
-        suggestedLinks,
-        onSuggestedLinksChanged,
-      ],
+      [setRichText, autocompleteView, suggestedLinks, onSuggestedLinksChanged],
     )
 
     const onPaste = React.useCallback(
@@ -159,31 +162,35 @@ export const TextInput = React.forwardRef(
     const onSelectAutocompleteItem = React.useCallback(
       (item: string) => {
         onChangeText(
-          insertMentionAt(text, textInputSelection.current?.start || 0, item),
+          insertMentionAt(
+            richtext.text,
+            textInputSelection.current?.start || 0,
+            item,
+          ),
         )
         autocompleteView.setActive(false)
       },
-      [onChangeText, text, autocompleteView],
+      [onChangeText, richtext, autocompleteView],
     )
 
     const textDecorated = React.useMemo(() => {
       let i = 0
-      return detectLinkables(text).map(v => {
-        if (typeof v === 'string') {
+      return Array.from(richtext.segments()).map(segment => {
+        if (!segment.facet) {
           return (
             <Text key={i++} style={[pal.text, styles.textInputFormatting]}>
-              {v}
+              {segment.text}
             </Text>
           )
         } else {
           return (
             <Text key={i++} style={[pal.link, styles.textInputFormatting]}>
-              {v.link}
+              {segment.text}
             </Text>
           )
         }
       })
-    }, [text, pal.link, pal.text])
+    }, [richtext, pal.link, pal.text])
 
     return (
       <View style={styles.container}>
diff --git a/src/view/com/composer/text-input/TextInput.web.tsx b/src/view/com/composer/text-input/TextInput.web.tsx
index 4b23e891b..ad891fa5b 100644
--- a/src/view/com/composer/text-input/TextInput.web.tsx
+++ b/src/view/com/composer/text-input/TextInput.web.tsx
@@ -1,5 +1,6 @@
 import React from 'react'
 import {StyleSheet, View} from 'react-native'
+import {RichText} from '@atproto/api'
 import {useEditor, EditorContent, JSONContent} from '@tiptap/react'
 import {Document} from '@tiptap/extension-document'
 import {Link} from '@tiptap/extension-link'
@@ -17,11 +18,11 @@ export interface TextInputRef {
 }
 
 interface TextInputProps {
-  text: string
+  richtext: RichText
   placeholder: string
   suggestedLinks: Set<string>
   autocompleteView: UserAutocompleteViewModel
-  onTextChanged: (v: string) => void
+  setRichText: (v: RichText) => void
   onPhotoPasted: (uri: string) => void
   onSuggestedLinksChanged: (uris: Set<string>) => void
   onError: (err: string) => void
@@ -30,11 +31,11 @@ interface TextInputProps {
 export const TextInput = React.forwardRef(
   (
     {
-      text,
+      richtext,
       placeholder,
       suggestedLinks,
       autocompleteView,
-      onTextChanged,
+      setRichText,
       // onPhotoPasted, TODO
       onSuggestedLinksChanged,
     }: // onError, TODO
@@ -60,15 +61,15 @@ export const TextInput = React.forwardRef(
         }),
         Text,
       ],
-      content: text,
+      content: richtext.text.toString(),
       autofocus: true,
       editable: true,
       injectCSS: true,
       onUpdate({editor: editorProp}) {
         const json = editorProp.getJSON()
 
-        const newText = editorJsonToText(json).trim()
-        onTextChanged(newText)
+        const newRt = new RichText({text: editorJsonToText(json).trim()})
+        setRichText(newRt)
 
         const newSuggestedLinks = new Set(editorJsonToLinks(json))
         if (!isEqual(newSuggestedLinks, suggestedLinks)) {
diff --git a/src/view/com/discover/SuggestedFollows.tsx b/src/view/com/discover/SuggestedFollows.tsx
index 0d09038ba..e4ada5204 100644
--- a/src/view/com/discover/SuggestedFollows.tsx
+++ b/src/view/com/discover/SuggestedFollows.tsx
@@ -1,6 +1,6 @@
 import React from 'react'
 import {StyleSheet, View} from 'react-native'
-import {AppBskyActorRef, AppBskyActorProfile} from '@atproto/api'
+import {AppBskyActorDefs} from '@atproto/api'
 import {RefWithInfoAndFollowers} from 'state/models/discovery/foafs'
 import {ProfileCardWithFollowBtn} from '../profile/ProfileCard'
 import {Text} from '../util/text/Text'
@@ -12,9 +12,9 @@ export const SuggestedFollows = ({
 }: {
   title: string
   suggestions: (
-    | AppBskyActorRef.WithInfo
+    | AppBskyActorDefs.ProfileViewBasic
+    | AppBskyActorDefs.ProfileView
     | RefWithInfoAndFollowers
-    | AppBskyActorProfile.View
   )[]
 }) => {
   const pal = usePalette('default')
@@ -28,7 +28,6 @@ export const SuggestedFollows = ({
           <ProfileCardWithFollowBtn
             key={item.did}
             did={item.did}
-            declarationCid={item.declaration.cid}
             handle={item.handle}
             displayName={item.displayName}
             avatar={item.avatar}
@@ -36,12 +35,12 @@ export const SuggestedFollows = ({
             noBorder
             description={
               item.description
-                ? (item as AppBskyActorProfile.View).description
+                ? (item as AppBskyActorDefs.ProfileView).description
                 : ''
             }
             followers={
               item.followers
-                ? (item.followers as AppBskyActorProfile.View[])
+                ? (item.followers as AppBskyActorDefs.ProfileView[])
                 : undefined
             }
           />
diff --git a/src/view/com/modals/ChangeHandle.tsx b/src/view/com/modals/ChangeHandle.tsx
index f15f7ca43..37bad6957 100644
--- a/src/view/com/modals/ChangeHandle.tsx
+++ b/src/view/com/modals/ChangeHandle.tsx
@@ -105,7 +105,7 @@ export function Component({onChanged}: {onChanged: () => void}) {
       track('EditHandle:SetNewHandle')
       const newHandle = isCustom ? handle : createFullHandle(handle, userDomain)
       store.log.debug(`Updating handle to ${newHandle}`)
-      await store.api.com.atproto.handle.update({
+      await store.agent.updateHandle({
         handle: newHandle,
       })
       store.shell.closeModal()
@@ -310,7 +310,7 @@ function CustomHandleForm({
     try {
       setIsVerifying(true)
       setError('')
-      const res = await store.api.com.atproto.handle.resolve({handle})
+      const res = await store.agent.com.atproto.identity.resolveHandle({handle})
       if (res.data.did === store.me.did) {
         setCanSave(true)
       } else {
@@ -331,7 +331,7 @@ function CustomHandleForm({
     canSave,
     onPressSave,
     store.log,
-    store.api,
+    store.agent,
   ])
 
   // rendering
diff --git a/src/view/com/modals/Confirm.tsx b/src/view/com/modals/Confirm.tsx
index 60c104f99..2bfcf4118 100644
--- a/src/view/com/modals/Confirm.tsx
+++ b/src/view/com/modals/Confirm.tsx
@@ -39,7 +39,7 @@ export function Component({
     }
   }
   return (
-    <View style={[s.flex1, s.pl10, s.pr10]}>
+    <View testID="confirmModal" style={[s.flex1, s.pl10, s.pr10]}>
       <Text style={styles.title}>{title}</Text>
       {typeof message === 'string' ? (
         <Text style={styles.description}>{message}</Text>
@@ -56,7 +56,7 @@ export function Component({
           <ActivityIndicator />
         </View>
       ) : (
-        <TouchableOpacity style={s.mt10} onPress={onPress}>
+        <TouchableOpacity testID="confirmBtn" style={s.mt10} onPress={onPress}>
           <LinearGradient
             colors={[gradients.blueLight.start, gradients.blueLight.end]}
             start={{x: 0, y: 0}}
diff --git a/src/view/com/modals/DeleteAccount.tsx b/src/view/com/modals/DeleteAccount.tsx
index 23cd9eb82..353122163 100644
--- a/src/view/com/modals/DeleteAccount.tsx
+++ b/src/view/com/modals/DeleteAccount.tsx
@@ -32,7 +32,7 @@ export function Component({}: {}) {
     setError('')
     setIsProcessing(true)
     try {
-      await store.api.com.atproto.account.requestDelete()
+      await store.agent.com.atproto.server.requestAccountDelete()
       setIsEmailSent(true)
     } catch (e: any) {
       setError(cleanError(e))
@@ -43,7 +43,7 @@ export function Component({}: {}) {
     setError('')
     setIsProcessing(true)
     try {
-      await store.api.com.atproto.account.delete({
+      await store.agent.com.atproto.server.deleteAccount({
         did: store.me.did,
         password,
         token: confirmCode,
diff --git a/src/view/com/modals/EditProfile.tsx b/src/view/com/modals/EditProfile.tsx
index 6eb21d17d..0b81d7f39 100644
--- a/src/view/com/modals/EditProfile.tsx
+++ b/src/view/com/modals/EditProfile.tsx
@@ -123,7 +123,7 @@ export function Component({
   }
 
   return (
-    <View style={[s.flex1, pal.view]}>
+    <View style={[s.flex1, pal.view]} testID="editProfileModal">
       <ScrollView style={styles.inner}>
         <Text style={[styles.title, pal.text]}>Edit my profile</Text>
         <View style={styles.photos}>
@@ -147,6 +147,7 @@ export function Component({
         <View>
           <Text style={[styles.label, pal.text]}>Display Name</Text>
           <TextInput
+            testID="editProfileDisplayNameInput"
             style={[styles.textInput, pal.text]}
             placeholder="e.g. Alice Roberts"
             placeholderTextColor={colors.gray4}
@@ -157,6 +158,7 @@ export function Component({
         <View style={s.pb10}>
           <Text style={[styles.label, pal.text]}>Description</Text>
           <TextInput
+            testID="editProfileDescriptionInput"
             style={[styles.textArea, pal.text]}
             placeholder="e.g. Artist, dog-lover, and memelord."
             placeholderTextColor={colors.gray4}
@@ -171,7 +173,10 @@ export function Component({
             <ActivityIndicator />
           </View>
         ) : (
-          <TouchableOpacity style={s.mt10} onPress={onPressSave}>
+          <TouchableOpacity
+            testID="editProfileSaveBtn"
+            style={s.mt10}
+            onPress={onPressSave}>
             <LinearGradient
               colors={[gradients.blueLight.start, gradients.blueLight.end]}
               start={{x: 0, y: 0}}
@@ -181,7 +186,10 @@ export function Component({
             </LinearGradient>
           </TouchableOpacity>
         )}
-        <TouchableOpacity style={s.mt5} onPress={onPressCancel}>
+        <TouchableOpacity
+          testID="editProfileCancelBtn"
+          style={s.mt5}
+          onPress={onPressCancel}>
           <View style={[styles.btn]}>
             <Text style={[s.black, s.bold, pal.text]}>Cancel</Text>
           </View>
diff --git a/src/view/com/modals/ReportAccount.tsx b/src/view/com/modals/ReportAccount.tsx
index c9ee004b8..601bccbd1 100644
--- a/src/view/com/modals/ReportAccount.tsx
+++ b/src/view/com/modals/ReportAccount.tsx
@@ -5,7 +5,7 @@ import {
   TouchableOpacity,
   View,
 } from 'react-native'
-import {ComAtprotoReportReasonType} from '@atproto/api'
+import {ComAtprotoModerationDefs} from '@atproto/api'
 import LinearGradient from 'react-native-linear-gradient'
 import {useStores} from 'state/index'
 import {s, colors, gradients} from 'lib/styles'
@@ -39,16 +39,16 @@ export function Component({did}: {did: string}) {
     setIsProcessing(true)
     try {
       // NOTE: we should update the lexicon of reasontype to include more options -prf
-      let reasonType = ComAtprotoReportReasonType.OTHER
+      let reasonType = ComAtprotoModerationDefs.REASONOTHER
       if (issue === 'spam') {
-        reasonType = ComAtprotoReportReasonType.SPAM
+        reasonType = ComAtprotoModerationDefs.REASONSPAM
       }
       const reason = ITEMS.find(item => item.key === issue)?.label || ''
-      await store.api.com.atproto.report.create({
+      await store.agent.com.atproto.moderation.createReport({
         reasonType,
         reason,
         subject: {
-          $type: 'com.atproto.repo.repoRef',
+          $type: 'com.atproto.admin.defs#repoRef',
           did,
         },
       })
@@ -61,12 +61,18 @@ export function Component({did}: {did: string}) {
     }
   }
   return (
-    <View style={[s.flex1, s.pl10, s.pr10, pal.view]}>
+    <View
+      testID="reportAccountModal"
+      style={[s.flex1, s.pl10, s.pr10, pal.view]}>
       <Text style={[pal.text, styles.title]}>Report account</Text>
       <Text style={[pal.textLight, styles.description]}>
         What is the issue with this account?
       </Text>
-      <RadioGroup items={ITEMS} onSelect={onSelectIssue} />
+      <RadioGroup
+        testID="reportAccountRadios"
+        items={ITEMS}
+        onSelect={onSelectIssue}
+      />
       {error ? (
         <View style={s.mt10}>
           <ErrorMessage message={error} />
@@ -77,7 +83,10 @@ export function Component({did}: {did: string}) {
           <ActivityIndicator />
         </View>
       ) : issue ? (
-        <TouchableOpacity style={s.mt10} onPress={onPress}>
+        <TouchableOpacity
+          testID="sendReportBtn"
+          style={s.mt10}
+          onPress={onPress}>
           <LinearGradient
             colors={[gradients.blueLight.start, gradients.blueLight.end]}
             start={{x: 0, y: 0}}
diff --git a/src/view/com/modals/ReportPost.tsx b/src/view/com/modals/ReportPost.tsx
index 3e876c6c8..01a132af0 100644
--- a/src/view/com/modals/ReportPost.tsx
+++ b/src/view/com/modals/ReportPost.tsx
@@ -5,7 +5,7 @@ import {
   TouchableOpacity,
   View,
 } from 'react-native'
-import {ComAtprotoReportReasonType} from '@atproto/api'
+import {ComAtprotoModerationDefs} from '@atproto/api'
 import LinearGradient from 'react-native-linear-gradient'
 import {useStores} from 'state/index'
 import {s, colors, gradients} from 'lib/styles'
@@ -46,16 +46,16 @@ export function Component({
     setIsProcessing(true)
     try {
       // NOTE: we should update the lexicon of reasontype to include more options -prf
-      let reasonType = ComAtprotoReportReasonType.OTHER
+      let reasonType = ComAtprotoModerationDefs.REASONOTHER
       if (issue === 'spam') {
-        reasonType = ComAtprotoReportReasonType.SPAM
+        reasonType = ComAtprotoModerationDefs.REASONSPAM
       }
       const reason = ITEMS.find(item => item.key === issue)?.label || ''
-      await store.api.com.atproto.report.create({
+      await store.agent.createModerationReport({
         reasonType,
         reason,
         subject: {
-          $type: 'com.atproto.repo.recordRef',
+          $type: 'com.atproto.repo.strongRef',
           uri: postUri,
           cid: postCid,
         },
@@ -69,12 +69,16 @@ export function Component({
     }
   }
   return (
-    <View style={[s.flex1, s.pl10, s.pr10, pal.view]}>
+    <View testID="reportPostModal" style={[s.flex1, s.pl10, s.pr10, pal.view]}>
       <Text style={[pal.text, styles.title]}>Report post</Text>
       <Text style={[pal.textLight, styles.description]}>
         What is the issue with this post?
       </Text>
-      <RadioGroup items={ITEMS} onSelect={onSelectIssue} />
+      <RadioGroup
+        testID="reportPostRadios"
+        items={ITEMS}
+        onSelect={onSelectIssue}
+      />
       {error ? (
         <View style={s.mt10}>
           <ErrorMessage message={error} />
@@ -85,7 +89,10 @@ export function Component({
           <ActivityIndicator />
         </View>
       ) : issue ? (
-        <TouchableOpacity style={s.mt10} onPress={onPress}>
+        <TouchableOpacity
+          testID="sendReportBtn"
+          style={s.mt10}
+          onPress={onPress}>
           <LinearGradient
             colors={[gradients.blueLight.start, gradients.blueLight.end]}
             start={{x: 0, y: 0}}
diff --git a/src/view/com/modals/Repost.tsx b/src/view/com/modals/Repost.tsx
index b4669a046..d5ed66b70 100644
--- a/src/view/com/modals/Repost.tsx
+++ b/src/view/com/modals/Repost.tsx
@@ -26,22 +26,28 @@ export function Component({
   }
 
   return (
-    <View style={[s.flex1, pal.view, styles.container]}>
+    <View testID="repostModal" style={[s.flex1, pal.view, styles.container]}>
       <View style={s.pb20}>
-        <TouchableOpacity style={[styles.actionBtn]} onPress={onRepost}>
+        <TouchableOpacity
+          testID="repostBtn"
+          style={[styles.actionBtn]}
+          onPress={onRepost}>
           <RepostIcon strokeWidth={2} size={24} style={s.blue3} />
           <Text type="title-lg" style={[styles.actionBtnLabel, pal.text]}>
             {!isReposted ? 'Repost' : 'Undo repost'}
           </Text>
         </TouchableOpacity>
-        <TouchableOpacity style={[styles.actionBtn]} onPress={onQuote}>
+        <TouchableOpacity
+          testID="quoteBtn"
+          style={[styles.actionBtn]}
+          onPress={onQuote}>
           <FontAwesomeIcon icon="quote-left" size={24} style={s.blue3} />
           <Text type="title-lg" style={[styles.actionBtnLabel, pal.text]}>
             Quote Post
           </Text>
         </TouchableOpacity>
       </View>
-      <TouchableOpacity onPress={onPress}>
+      <TouchableOpacity testID="cancelBtn" onPress={onPress}>
         <LinearGradient
           colors={[gradients.blueLight.start, gradients.blueLight.end]}
           start={{x: 0, y: 0}}
diff --git a/src/view/com/notifications/FeedItem.tsx b/src/view/com/notifications/FeedItem.tsx
index 1c2299b03..7d584e8e6 100644
--- a/src/view/com/notifications/FeedItem.tsx
+++ b/src/view/com/notifications/FeedItem.tsx
@@ -47,10 +47,10 @@ export const FeedItem = observer(function FeedItem({
   const pal = usePalette('default')
   const [isAuthorsExpanded, setAuthorsExpanded] = React.useState<boolean>(false)
   const itemHref = React.useMemo(() => {
-    if (item.isUpvote || item.isRepost) {
+    if (item.isLike || item.isRepost) {
       const urip = new AtUri(item.subjectUri)
       return `/profile/${urip.host}/post/${urip.rkey}`
-    } else if (item.isFollow || item.isAssertion) {
+    } else if (item.isFollow) {
       return `/profile/${item.author.handle}`
     } else if (item.isReply) {
       const urip = new AtUri(item.uri)
@@ -59,9 +59,9 @@ export const FeedItem = observer(function FeedItem({
     return ''
   }, [item])
   const itemTitle = React.useMemo(() => {
-    if (item.isUpvote || item.isRepost) {
+    if (item.isLike || item.isRepost) {
       return 'Post'
-    } else if (item.isFollow || item.isAssertion) {
+    } else if (item.isFollow) {
       return item.author.handle
     } else if (item.isReply) {
       return 'Post'
@@ -77,7 +77,7 @@ export const FeedItem = observer(function FeedItem({
     return <View />
   }
 
-  if (item.isReply || item.isMention) {
+  if (item.isReply || item.isMention || item.isQuote) {
     if (item.additionalPost?.error) {
       // hide errors - it doesnt help the user to show them
       return <View />
@@ -103,7 +103,7 @@ export const FeedItem = observer(function FeedItem({
   let action = ''
   let icon: Props['icon'] | 'HeartIconSolid'
   let iconStyle: Props['style'] = []
-  if (item.isUpvote) {
+  if (item.isLike) {
     action = 'liked your post'
     icon = 'HeartIconSolid'
     iconStyle = [
@@ -114,9 +114,6 @@ export const FeedItem = observer(function FeedItem({
     action = 'reposted your post'
     icon = 'retweet'
     iconStyle = [s.green3 as FontAwesomeIconStyle]
-  } else if (item.isReply) {
-    action = 'replied to your post'
-    icon = ['far', 'comment']
   } else if (item.isFollow) {
     action = 'followed you'
     icon = 'user-plus'
@@ -208,7 +205,7 @@ export const FeedItem = observer(function FeedItem({
               </View>
             </View>
           </TouchableWithoutFeedback>
-          {item.isUpvote || item.isRepost ? (
+          {item.isLike || item.isRepost || item.isQuote ? (
             <AdditionalPostText additionalPost={item.additionalPost} />
           ) : (
             <></>
@@ -352,9 +349,9 @@ function AdditionalPostText({
     return <View />
   }
   const text = additionalPost.thread?.postRecord.text
-  const images = (
-    additionalPost.thread.post.embed as AppBskyEmbedImages.Presented
-  )?.images
+  const images = AppBskyEmbedImages.isView(additionalPost.thread.post.embed)
+    ? additionalPost.thread.post.embed.images
+    : undefined
   return (
     <>
       {text?.length > 0 && <Text style={pal.textLight}>{text}</Text>}
diff --git a/src/view/com/pager/FeedsTabBar.tsx b/src/view/com/pager/FeedsTabBar.tsx
index 9831218ec..76e0a6fc6 100644
--- a/src/view/com/pager/FeedsTabBar.tsx
+++ b/src/view/com/pager/FeedsTabBar.tsx
@@ -9,7 +9,9 @@ import {usePalette} from 'lib/hooks/usePalette'
 import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
 
 export const FeedsTabBar = observer(
-  (props: RenderTabBarFnProps & {onPressSelected: () => void}) => {
+  (
+    props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void},
+  ) => {
     const store = useStores()
     const pal = usePalette('default')
     const interp = useAnimatedValue(0)
@@ -32,7 +34,10 @@ export const FeedsTabBar = observer(
 
     return (
       <Animated.View style={[pal.view, styles.tabBar, transform]}>
-        <TouchableOpacity style={styles.tabBarAvi} onPress={onPressAvi}>
+        <TouchableOpacity
+          testID="viewHeaderDrawerBtn"
+          style={styles.tabBarAvi}
+          onPress={onPressAvi}>
           <UserAvatar avatar={store.me.avatar} size={30} />
         </TouchableOpacity>
         <TabBar
diff --git a/src/view/com/pager/Pager.tsx b/src/view/com/pager/Pager.tsx
index 416828a27..34747db6d 100644
--- a/src/view/com/pager/Pager.tsx
+++ b/src/view/com/pager/Pager.tsx
@@ -20,6 +20,7 @@ interface Props {
   initialPage?: number
   renderTabBar: RenderTabBarFn
   onPageSelected?: (index: number) => void
+  testID?: string
 }
 export const Pager = ({
   children,
@@ -27,6 +28,7 @@ export const Pager = ({
   initialPage = 0,
   renderTabBar,
   onPageSelected,
+  testID,
 }: React.PropsWithChildren<Props>) => {
   const [selectedPage, setSelectedPage] = React.useState(0)
   const position = useAnimatedValue(0)
@@ -49,7 +51,7 @@ export const Pager = ({
   )
 
   return (
-    <View>
+    <View testID={testID}>
       {tabBarPosition === 'top' &&
         renderTabBar({
           selectedPage,
diff --git a/src/view/com/pager/TabBar.tsx b/src/view/com/pager/TabBar.tsx
index 0b45d95f5..2070898bf 100644
--- a/src/view/com/pager/TabBar.tsx
+++ b/src/view/com/pager/TabBar.tsx
@@ -15,6 +15,7 @@ interface Layout {
 }
 
 export interface TabBarProps {
+  testID?: string
   selectedPage: number
   items: string[]
   position: Animated.Value
@@ -26,6 +27,7 @@ export interface TabBarProps {
 }
 
 export function TabBar({
+  testID,
   selectedPage,
   items,
   position,
@@ -92,12 +94,15 @@ export function TabBar({
   }
 
   return (
-    <View style={[pal.view, styles.outer]} onLayout={onLayout}>
+    <View testID={testID} style={[pal.view, styles.outer]} onLayout={onLayout}>
       <Animated.View style={[styles.indicator, indicatorStyle]} />
       {items.map((item, i) => {
         const selected = i === selectedPage
         return (
-          <TouchableWithoutFeedback key={i} onPress={() => onPressItem(i)}>
+          <TouchableWithoutFeedback
+            key={i}
+            testID={testID ? `${testID}-${item}` : undefined}
+            onPress={() => onPressItem(i)}>
             <View
               style={
                 indicatorPosition === 'top' ? styles.itemTop : styles.itemBottom
diff --git a/src/view/com/post-thread/PostVotedBy.tsx b/src/view/com/post-thread/PostLikedBy.tsx
index f86798097..9fb46702e 100644
--- a/src/view/com/post-thread/PostVotedBy.tsx
+++ b/src/view/com/post-thread/PostLikedBy.tsx
@@ -2,24 +2,18 @@ import React, {useEffect} from 'react'
 import {observer} from 'mobx-react-lite'
 import {ActivityIndicator, RefreshControl, StyleSheet, View} from 'react-native'
 import {CenteredView, FlatList} from '../util/Views'
-import {VotesViewModel, VoteItem} from 'state/models/votes-view'
+import {LikesViewModel, LikeItem} from 'state/models/likes-view'
 import {ErrorMessage} from '../util/error/ErrorMessage'
 import {ProfileCardWithFollowBtn} from '../profile/ProfileCard'
 import {useStores} from 'state/index'
 import {usePalette} from 'lib/hooks/usePalette'
 
-export const PostVotedBy = observer(function PostVotedBy({
-  uri,
-  direction,
-}: {
-  uri: string
-  direction: 'up' | 'down'
-}) {
+export const PostLikedBy = observer(function PostVotedBy({uri}: {uri: string}) {
   const pal = usePalette('default')
   const store = useStores()
   const view = React.useMemo(
-    () => new VotesViewModel(store, {uri, direction}),
-    [store, uri, direction],
+    () => new LikesViewModel(store, {uri}),
+    [store, uri],
   )
 
   useEffect(() => {
@@ -55,11 +49,10 @@ export const PostVotedBy = observer(function PostVotedBy({
 
   // loaded
   // =
-  const renderItem = ({item}: {item: VoteItem}) => (
+  const renderItem = ({item}: {item: LikeItem}) => (
     <ProfileCardWithFollowBtn
       key={item.actor.did}
       did={item.actor.did}
-      declarationCid={item.actor.declaration.cid}
       handle={item.actor.handle}
       displayName={item.actor.displayName}
       avatar={item.actor.avatar}
@@ -68,7 +61,7 @@ export const PostVotedBy = observer(function PostVotedBy({
   )
   return (
     <FlatList
-      data={view.votes}
+      data={view.likes}
       keyExtractor={item => item.actor.did}
       refreshControl={
         <RefreshControl
diff --git a/src/view/com/post-thread/PostRepostedBy.tsx b/src/view/com/post-thread/PostRepostedBy.tsx
index fda54469c..147d0271f 100644
--- a/src/view/com/post-thread/PostRepostedBy.tsx
+++ b/src/view/com/post-thread/PostRepostedBy.tsx
@@ -64,7 +64,6 @@ export const PostRepostedBy = observer(function PostRepostedBy({
     <ProfileCardWithFollowBtn
       key={item.did}
       did={item.did}
-      declarationCid={item.declaration.cid}
       handle={item.handle}
       displayName={item.displayName}
       avatar={item.avatar}
diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx
index d0452331b..569c6e392 100644
--- a/src/view/com/post-thread/PostThread.tsx
+++ b/src/view/com/post-thread/PostThread.tsx
@@ -1,17 +1,30 @@
 import React, {useRef} from 'react'
 import {observer} from 'mobx-react-lite'
-import {ActivityIndicator, RefreshControl, StyleSheet, View} from 'react-native'
+import {
+  ActivityIndicator,
+  RefreshControl,
+  StyleSheet,
+  TouchableOpacity,
+  View,
+} from 'react-native'
 import {CenteredView, FlatList} from '../util/Views'
 import {
   PostThreadViewModel,
   PostThreadViewPostModel,
 } from 'state/models/post-thread-view'
+import {
+  FontAwesomeIcon,
+  FontAwesomeIconStyle,
+} from '@fortawesome/react-native-fontawesome'
 import {PostThreadItem} from './PostThreadItem'
 import {ComposePrompt} from '../composer/Prompt'
 import {ErrorMessage} from '../util/error/ErrorMessage'
+import {Text} from '../util/text/Text'
 import {s} from 'lib/styles'
 import {isDesktopWeb} from 'platform/detection'
 import {usePalette} from 'lib/hooks/usePalette'
+import {useNavigation} from '@react-navigation/native'
+import {NavigationProp} from 'lib/routes/types'
 
 const REPLY_PROMPT = {_reactKey: '__reply__', _isHighlightedPost: false}
 const BOTTOM_BORDER = {
@@ -32,6 +45,7 @@ export const PostThread = observer(function PostThread({
   const pal = usePalette('default')
   const ref = useRef<FlatList>(null)
   const [isRefreshing, setIsRefreshing] = React.useState(false)
+  const navigation = useNavigation<NavigationProp>()
   const posts = React.useMemo(() => {
     if (view.thread) {
       return Array.from(flattenThread(view.thread)).concat([BOTTOM_BORDER])
@@ -41,6 +55,7 @@ export const PostThread = observer(function PostThread({
 
   // events
   // =
+
   const onRefresh = React.useCallback(async () => {
     setIsRefreshing(true)
     try {
@@ -50,6 +65,7 @@ export const PostThread = observer(function PostThread({
     }
     setIsRefreshing(false)
   }, [view, setIsRefreshing])
+
   const onLayout = React.useCallback(() => {
     const index = posts.findIndex(post => post._isHighlightedPost)
     if (index !== -1) {
@@ -60,6 +76,7 @@ export const PostThread = observer(function PostThread({
       })
     }
   }, [posts, ref])
+
   const onScrollToIndexFailed = React.useCallback(
     (info: {
       index: number
@@ -73,6 +90,15 @@ export const PostThread = observer(function PostThread({
     },
     [ref],
   )
+
+  const onPressBack = React.useCallback(() => {
+    if (navigation.canGoBack()) {
+      navigation.goBack()
+    } else {
+      navigation.navigate('Home')
+    }
+  }, [navigation])
+
   const renderItem = React.useCallback(
     ({item}: {item: YieldedItem}) => {
       if (item === REPLY_PROMPT) {
@@ -104,6 +130,30 @@ export const PostThread = observer(function PostThread({
   // error
   // =
   if (view.hasError) {
+    if (view.notFound) {
+      return (
+        <CenteredView>
+          <View style={[pal.view, pal.border, styles.notFoundContainer]}>
+            <Text type="title-lg" style={[pal.text, s.mb5]}>
+              Post not found
+            </Text>
+            <Text type="md" style={[pal.text, s.mb10]}>
+              The post may have been deleted.
+            </Text>
+            <TouchableOpacity onPress={onPressBack}>
+              <Text type="2xl" style={pal.link}>
+                <FontAwesomeIcon
+                  icon="angle-left"
+                  style={[pal.link as FontAwesomeIconStyle, s.mr5]}
+                  size={14}
+                />
+                Back
+              </Text>
+            </TouchableOpacity>
+          </View>
+        </CenteredView>
+      )
+    }
     return (
       <CenteredView>
         <ErrorMessage message={view.error} onPressTryAgain={onRefresh} />
@@ -159,12 +209,18 @@ function* flattenThread(
         yield* flattenThread(reply as PostThreadViewPostModel)
       }
     }
-  } else if (!isAscending && !post.parent && post.post.replyCount > 0) {
+  } else if (!isAscending && !post.parent && post.post.replyCount) {
     post._hasMore = true
   }
 }
 
 const styles = StyleSheet.create({
+  notFoundContainer: {
+    margin: 10,
+    paddingHorizontal: 18,
+    paddingVertical: 14,
+    borderRadius: 6,
+  },
   bottomBorder: {
     borderBottomWidth: 1,
   },
diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx
index 17c7943d9..cf2148060 100644
--- a/src/view/com/post-thread/PostThreadItem.tsx
+++ b/src/view/com/post-thread/PostThreadItem.tsx
@@ -19,7 +19,7 @@ import {ago} from 'lib/strings/time'
 import {pluralize} from 'lib/strings/helpers'
 import {useStores} from 'state/index'
 import {PostMeta} from '../util/PostMeta'
-import {PostEmbeds} from '../util/PostEmbeds'
+import {PostEmbeds} from '../util/post-embeds'
 import {PostCtrls} from '../util/PostCtrls'
 import {PostMutedWrapper} from '../util/PostMuted'
 import {ErrorMessage} from '../util/error/ErrorMessage'
@@ -38,7 +38,7 @@ export const PostThreadItem = observer(function PostThreadItem({
   const store = useStores()
   const [deleted, setDeleted] = React.useState(false)
   const record = item.postRecord
-  const hasEngagement = item.post.upvoteCount || item.post.repostCount
+  const hasEngagement = item.post.likeCount || item.post.repostCount
 
   const itemUri = item.post.uri
   const itemCid = item.post.cid
@@ -49,11 +49,11 @@ export const PostThreadItem = observer(function PostThreadItem({
   const itemTitle = `Post by ${item.post.author.handle}`
   const authorHref = `/profile/${item.post.author.handle}`
   const authorTitle = item.post.author.handle
-  const upvotesHref = React.useMemo(() => {
+  const likesHref = React.useMemo(() => {
     const urip = new AtUri(item.post.uri)
-    return `/profile/${item.post.author.handle}/post/${urip.rkey}/upvoted-by`
+    return `/profile/${item.post.author.handle}/post/${urip.rkey}/liked-by`
   }, [item.post.uri, item.post.author.handle])
-  const upvotesTitle = 'Likes on this post'
+  const likesTitle = 'Likes on this post'
   const repostsHref = React.useMemo(() => {
     const urip = new AtUri(item.post.uri)
     return `/profile/${item.post.author.handle}/post/${urip.rkey}/reposted-by`
@@ -80,10 +80,10 @@ export const PostThreadItem = observer(function PostThreadItem({
       .toggleRepost()
       .catch(e => store.log.error('Failed to toggle repost', e))
   }, [item, store])
-  const onPressToggleUpvote = React.useCallback(() => {
+  const onPressToggleLike = React.useCallback(() => {
     return item
-      .toggleUpvote()
-      .catch(e => store.log.error('Failed to toggle upvote', e))
+      .toggleLike()
+      .catch(e => store.log.error('Failed to toggle like', e))
   }, [item, store])
   const onCopyPostText = React.useCallback(() => {
     Clipboard.setString(record?.text || '')
@@ -125,153 +125,151 @@ export const PostThreadItem = observer(function PostThreadItem({
 
   if (item._isHighlightedPost) {
     return (
-      <>
-        <View
-          style={[
-            styles.outer,
-            styles.outerHighlighted,
-            {borderTopColor: pal.colors.border},
-            pal.view,
-          ]}>
-          <View style={styles.layout}>
-            <View style={styles.layoutAvi}>
-              <Link href={authorHref} title={authorTitle} asAnchor>
-                <UserAvatar size={52} avatar={item.post.author.avatar} />
-              </Link>
-            </View>
-            <View style={styles.layoutContent}>
-              <View style={[styles.meta, styles.metaExpandedLine1]}>
-                <View style={[s.flexRow, s.alignBaseline]}>
-                  <Link
-                    style={styles.metaItem}
-                    href={authorHref}
-                    title={authorTitle}>
-                    <Text
-                      type="xl-bold"
-                      style={[pal.text]}
-                      numberOfLines={1}
-                      lineHeight={1.2}>
-                      {item.post.author.displayName || item.post.author.handle}
-                    </Text>
-                  </Link>
-                  <Text type="md" style={[styles.metaItem, pal.textLight]}>
-                    &middot; {ago(item.post.indexedAt)}
-                  </Text>
-                </View>
-                <View style={s.flex1} />
-                <PostDropdownBtn
-                  style={styles.metaItem}
-                  itemUri={itemUri}
-                  itemCid={itemCid}
-                  itemHref={itemHref}
-                  itemTitle={itemTitle}
-                  isAuthor={item.post.author.did === store.me.did}
-                  onCopyPostText={onCopyPostText}
-                  onOpenTranslate={onOpenTranslate}
-                  onDeletePost={onDeletePost}>
-                  <FontAwesomeIcon
-                    icon="ellipsis-h"
-                    size={14}
-                    style={[s.mt2, s.mr5, pal.textLight]}
-                  />
-                </PostDropdownBtn>
-              </View>
-              <View style={styles.meta}>
+      <View
+        testID={`postThreadItem-by-${item.post.author.handle}`}
+        style={[
+          styles.outer,
+          styles.outerHighlighted,
+          {borderTopColor: pal.colors.border},
+          pal.view,
+        ]}>
+        <View style={styles.layout}>
+          <View style={styles.layoutAvi}>
+            <Link href={authorHref} title={authorTitle} asAnchor>
+              <UserAvatar size={52} avatar={item.post.author.avatar} />
+            </Link>
+          </View>
+          <View style={styles.layoutContent}>
+            <View style={[styles.meta, styles.metaExpandedLine1]}>
+              <View style={[s.flexRow, s.alignBaseline]}>
                 <Link
                   style={styles.metaItem}
                   href={authorHref}
                   title={authorTitle}>
-                  <Text type="md" style={[pal.textLight]} numberOfLines={1}>
-                    @{item.post.author.handle}
+                  <Text
+                    type="xl-bold"
+                    style={[pal.text]}
+                    numberOfLines={1}
+                    lineHeight={1.2}>
+                    {item.post.author.displayName || item.post.author.handle}
                   </Text>
                 </Link>
+                <Text type="md" style={[styles.metaItem, pal.textLight]}>
+                  &middot; {ago(item.post.indexedAt)}
+                </Text>
               </View>
-            </View>
-          </View>
-          <View style={[s.pl10, s.pr10, s.pb10]}>
-            {item.richText?.text ? (
-              <View
-                style={[
-                  styles.postTextContainer,
-                  styles.postTextLargeContainer,
-                ]}>
-                <RichText
-                  type="post-text-lg"
-                  richText={item.richText}
-                  lineHeight={1.3}
-                />
-              </View>
-            ) : undefined}
-            <PostEmbeds embed={item.post.embed} style={s.mb10} />
-            {item._isHighlightedPost && hasEngagement ? (
-              <View style={[styles.expandedInfo, pal.border]}>
-                {item.post.repostCount ? (
-                  <Link
-                    style={styles.expandedInfoItem}
-                    href={repostsHref}
-                    title={repostsTitle}>
-                    <Text type="lg" style={pal.textLight}>
-                      <Text type="xl-bold" style={pal.text}>
-                        {item.post.repostCount}
-                      </Text>{' '}
-                      {pluralize(item.post.repostCount, 'repost')}
-                    </Text>
-                  </Link>
-                ) : (
-                  <></>
-                )}
-                {item.post.upvoteCount ? (
-                  <Link
-                    style={styles.expandedInfoItem}
-                    href={upvotesHref}
-                    title={upvotesTitle}>
-                    <Text type="lg" style={pal.textLight}>
-                      <Text type="xl-bold" style={pal.text}>
-                        {item.post.upvoteCount}
-                      </Text>{' '}
-                      {pluralize(item.post.upvoteCount, 'like')}
-                    </Text>
-                  </Link>
-                ) : (
-                  <></>
-                )}
-              </View>
-            ) : (
-              <></>
-            )}
-            <View style={[s.pl10, s.pb5]}>
-              <PostCtrls
-                big
+              <View style={s.flex1} />
+              <PostDropdownBtn
+                testID="postDropdownBtn"
+                style={styles.metaItem}
                 itemUri={itemUri}
                 itemCid={itemCid}
                 itemHref={itemHref}
                 itemTitle={itemTitle}
-                author={{
-                  avatar: item.post.author.avatar!,
-                  handle: item.post.author.handle,
-                  displayName: item.post.author.displayName!,
-                }}
-                text={item.richText?.text || record.text}
-                indexedAt={item.post.indexedAt}
                 isAuthor={item.post.author.did === store.me.did}
-                isReposted={!!item.post.viewer.repost}
-                isUpvoted={!!item.post.viewer.upvote}
-                onPressReply={onPressReply}
-                onPressToggleRepost={onPressToggleRepost}
-                onPressToggleUpvote={onPressToggleUpvote}
                 onCopyPostText={onCopyPostText}
                 onOpenTranslate={onOpenTranslate}
-                onDeletePost={onDeletePost}
+                onDeletePost={onDeletePost}>
+                <FontAwesomeIcon
+                  icon="ellipsis-h"
+                  size={14}
+                  style={[s.mt2, s.mr5, pal.textLight]}
+                />
+              </PostDropdownBtn>
+            </View>
+            <View style={styles.meta}>
+              <Link
+                style={styles.metaItem}
+                href={authorHref}
+                title={authorTitle}>
+                <Text type="md" style={[pal.textLight]} numberOfLines={1}>
+                  @{item.post.author.handle}
+                </Text>
+              </Link>
+            </View>
+          </View>
+        </View>
+        <View style={[s.pl10, s.pr10, s.pb10]}>
+          {item.richText?.text ? (
+            <View
+              style={[styles.postTextContainer, styles.postTextLargeContainer]}>
+              <RichText
+                type="post-text-lg"
+                richText={item.richText}
+                lineHeight={1.3}
               />
             </View>
+          ) : undefined}
+          <PostEmbeds embed={item.post.embed} style={s.mb10} />
+          {item._isHighlightedPost && hasEngagement ? (
+            <View style={[styles.expandedInfo, pal.border]}>
+              {item.post.repostCount ? (
+                <Link
+                  style={styles.expandedInfoItem}
+                  href={repostsHref}
+                  title={repostsTitle}>
+                  <Text testID="repostCount" type="lg" style={pal.textLight}>
+                    <Text type="xl-bold" style={pal.text}>
+                      {item.post.repostCount}
+                    </Text>{' '}
+                    {pluralize(item.post.repostCount, 'repost')}
+                  </Text>
+                </Link>
+              ) : (
+                <></>
+              )}
+              {item.post.likeCount ? (
+                <Link
+                  style={styles.expandedInfoItem}
+                  href={likesHref}
+                  title={likesTitle}>
+                  <Text testID="likeCount" type="lg" style={pal.textLight}>
+                    <Text type="xl-bold" style={pal.text}>
+                      {item.post.likeCount}
+                    </Text>{' '}
+                    {pluralize(item.post.likeCount, 'like')}
+                  </Text>
+                </Link>
+              ) : (
+                <></>
+              )}
+            </View>
+          ) : (
+            <></>
+          )}
+          <View style={[s.pl10, s.pb5]}>
+            <PostCtrls
+              big
+              itemUri={itemUri}
+              itemCid={itemCid}
+              itemHref={itemHref}
+              itemTitle={itemTitle}
+              author={{
+                avatar: item.post.author.avatar!,
+                handle: item.post.author.handle,
+                displayName: item.post.author.displayName!,
+              }}
+              text={item.richText?.text || record.text}
+              indexedAt={item.post.indexedAt}
+              isAuthor={item.post.author.did === store.me.did}
+              isReposted={!!item.post.viewer?.repost}
+              isLiked={!!item.post.viewer?.like}
+              onPressReply={onPressReply}
+              onPressToggleRepost={onPressToggleRepost}
+              onPressToggleLike={onPressToggleLike}
+              onCopyPostText={onCopyPostText}
+              onOpenTranslate={onOpenTranslate}
+              onDeletePost={onDeletePost}
+            />
           </View>
         </View>
-      </>
+      </View>
     )
   } else {
     return (
       <PostMutedWrapper isMuted={item.post.author.viewer?.muted === true}>
         <Link
+          testID={`postThreadItem-by-${item.post.author.handle}`}
           style={[styles.outer, {borderTopColor: pal.colors.border}, pal.view]}
           href={itemHref}
           title={itemTitle}
@@ -305,7 +303,6 @@ export const PostThreadItem = observer(function PostThreadItem({
                 timestamp={item.post.indexedAt}
                 postHref={itemHref}
                 did={item.post.author.did}
-                declarationCid={item.post.author.declaration.cid}
               />
               {item.richText?.text ? (
                 <View style={styles.postTextContainer}>
@@ -333,12 +330,12 @@ export const PostThreadItem = observer(function PostThreadItem({
                 isAuthor={item.post.author.did === store.me.did}
                 replyCount={item.post.replyCount}
                 repostCount={item.post.repostCount}
-                upvoteCount={item.post.upvoteCount}
-                isReposted={!!item.post.viewer.repost}
-                isUpvoted={!!item.post.viewer.upvote}
+                likeCount={item.post.likeCount}
+                isReposted={!!item.post.viewer?.repost}
+                isLiked={!!item.post.viewer?.like}
                 onPressReply={onPressReply}
                 onPressToggleRepost={onPressToggleRepost}
-                onPressToggleUpvote={onPressToggleUpvote}
+                onPressToggleLike={onPressToggleLike}
                 onCopyPostText={onCopyPostText}
                 onOpenTranslate={onOpenTranslate}
                 onDeletePost={onDeletePost}
diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx
index a6c66d143..6b3dc3ac6 100644
--- a/src/view/com/post/Post.tsx
+++ b/src/view/com/post/Post.tsx
@@ -15,7 +15,7 @@ import {PostThreadViewModel} from 'state/models/post-thread-view'
 import {Link} from '../util/Link'
 import {UserInfoText} from '../util/UserInfoText'
 import {PostMeta} from '../util/PostMeta'
-import {PostEmbeds} from '../util/PostEmbeds'
+import {PostEmbeds} from '../util/post-embeds'
 import {PostCtrls} from '../util/PostCtrls'
 import {PostMutedWrapper} from '../util/PostMuted'
 import {Text} from '../util/text/Text'
@@ -118,10 +118,10 @@ export const Post = observer(function Post({
       .toggleRepost()
       .catch(e => store.log.error('Failed to toggle repost', e))
   }
-  const onPressToggleUpvote = () => {
+  const onPressToggleLike = () => {
     return item
-      .toggleUpvote()
-      .catch(e => store.log.error('Failed to toggle upvote', e))
+      .toggleLike()
+      .catch(e => store.log.error('Failed to toggle like', e))
   }
   const onCopyPostText = () => {
     Clipboard.setString(record.text)
@@ -166,7 +166,6 @@ export const Post = observer(function Post({
               timestamp={item.post.indexedAt}
               postHref={itemHref}
               did={item.post.author.did}
-              declarationCid={item.post.author.declaration.cid}
             />
             {replyAuthorDid !== '' && (
               <View style={[s.flexRow, s.mb2, s.alignCenter]}>
@@ -211,12 +210,12 @@ export const Post = observer(function Post({
               isAuthor={item.post.author.did === store.me.did}
               replyCount={item.post.replyCount}
               repostCount={item.post.repostCount}
-              upvoteCount={item.post.upvoteCount}
-              isReposted={!!item.post.viewer.repost}
-              isUpvoted={!!item.post.viewer.upvote}
+              likeCount={item.post.likeCount}
+              isReposted={!!item.post.viewer?.repost}
+              isLiked={!!item.post.viewer?.like}
               onPressReply={onPressReply}
               onPressToggleRepost={onPressToggleRepost}
-              onPressToggleUpvote={onPressToggleUpvote}
+              onPressToggleLike={onPressToggleLike}
               onCopyPostText={onCopyPostText}
               onOpenTranslate={onOpenTranslate}
               onDeletePost={onDeletePost}
diff --git a/src/view/com/posts/Feed.tsx b/src/view/com/posts/Feed.tsx
index 4154cbe75..d07afca34 100644
--- a/src/view/com/posts/Feed.tsx
+++ b/src/view/com/posts/Feed.tsx
@@ -128,6 +128,7 @@ export const Feed = observer(function Feed({
     <View testID={testID} style={style}>
       {data.length > 0 && (
         <FlatList
+          testID={testID ? `${testID}-flatlist` : undefined}
           ref={scrollElRef}
           data={data}
           keyExtractor={item => item._reactKey}
diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx
index 573b92fd3..734034a89 100644
--- a/src/view/com/posts/FeedItem.tsx
+++ b/src/view/com/posts/FeedItem.tsx
@@ -13,7 +13,7 @@ import {Text} from '../util/text/Text'
 import {UserInfoText} from '../util/UserInfoText'
 import {PostMeta} from '../util/PostMeta'
 import {PostCtrls} from '../util/PostCtrls'
-import {PostEmbeds} from '../util/PostEmbeds'
+import {PostEmbeds} from '../util/post-embeds'
 import {PostMutedWrapper} from '../util/PostMuted'
 import {RichText} from '../util/text/RichText'
 import * as Toast from '../util/Toast'
@@ -79,11 +79,11 @@ export const FeedItem = observer(function ({
       .toggleRepost()
       .catch(e => store.log.error('Failed to toggle repost', e))
   }
-  const onPressToggleUpvote = () => {
+  const onPressToggleLike = () => {
     track('FeedItem:PostLike')
     return item
-      .toggleUpvote()
-      .catch(e => store.log.error('Failed to toggle upvote', e))
+      .toggleLike()
+      .catch(e => store.log.error('Failed to toggle like', e))
   }
   const onCopyPostText = () => {
     Clipboard.setString(record?.text || '')
@@ -127,7 +127,12 @@ export const FeedItem = observer(function ({
 
   return (
     <PostMutedWrapper isMuted={isMuted}>
-      <Link style={outerStyles} href={itemHref} title={itemTitle} noFeedback>
+      <Link
+        testID={`feedItem-by-${item.post.author.handle}`}
+        style={outerStyles}
+        href={itemHref}
+        title={itemTitle}
+        noFeedback>
         {isThreadChild && (
           <View
             style={[styles.topReplyLine, {borderColor: pal.colors.replyLine}]}
@@ -189,7 +194,6 @@ export const FeedItem = observer(function ({
               timestamp={item.post.indexedAt}
               postHref={itemHref}
               did={item.post.author.did}
-              declarationCid={item.post.author.declaration.cid}
               showFollowBtn={showFollowBtn}
             />
             {!isThreadChild && replyAuthorDid !== '' && (
@@ -239,12 +243,12 @@ export const FeedItem = observer(function ({
               isAuthor={item.post.author.did === store.me.did}
               replyCount={item.post.replyCount}
               repostCount={item.post.repostCount}
-              upvoteCount={item.post.upvoteCount}
-              isReposted={!!item.post.viewer.repost}
-              isUpvoted={!!item.post.viewer.upvote}
+              likeCount={item.post.likeCount}
+              isReposted={!!item.post.viewer?.repost}
+              isLiked={!!item.post.viewer?.like}
               onPressReply={onPressReply}
               onPressToggleRepost={onPressToggleRepost}
-              onPressToggleUpvote={onPressToggleUpvote}
+              onPressToggleLike={onPressToggleLike}
               onCopyPostText={onCopyPostText}
               onOpenTranslate={onOpenTranslate}
               onDeletePost={onDeletePost}
diff --git a/src/view/com/profile/FollowButton.tsx b/src/view/com/profile/FollowButton.tsx
index 5204f5a40..f22eb9b4a 100644
--- a/src/view/com/profile/FollowButton.tsx
+++ b/src/view/com/profile/FollowButton.tsx
@@ -2,19 +2,16 @@ import React from 'react'
 import {observer} from 'mobx-react-lite'
 import {Button, ButtonType} from '../util/forms/Button'
 import {useStores} from 'state/index'
-import * as apilib from 'lib/api/index'
 import * as Toast from '../util/Toast'
 
 const FollowButton = observer(
   ({
     type = 'inverted',
     did,
-    declarationCid,
     onToggleFollow,
   }: {
     type?: ButtonType
     did: string
-    declarationCid: string
     onToggleFollow?: (v: boolean) => void
   }) => {
     const store = useStores()
@@ -23,7 +20,7 @@ const FollowButton = observer(
     const onToggleFollowInner = async () => {
       if (store.me.follows.isFollowing(did)) {
         try {
-          await apilib.unfollow(store, store.me.follows.getFollowUri(did))
+          await store.agent.deleteFollow(store.me.follows.getFollowUri(did))
           store.me.follows.removeFollow(did)
           onToggleFollow?.(false)
         } catch (e: any) {
@@ -32,7 +29,7 @@ const FollowButton = observer(
         }
       } else {
         try {
-          const res = await apilib.follow(store, did, declarationCid)
+          const res = await store.agent.follow(did)
           store.me.follows.addFollow(did, res.uri)
           onToggleFollow?.(true)
         } catch (e: any) {
diff --git a/src/view/com/profile/ProfileCard.tsx b/src/view/com/profile/ProfileCard.tsx
index 748648742..0beac8a7f 100644
--- a/src/view/com/profile/ProfileCard.tsx
+++ b/src/view/com/profile/ProfileCard.tsx
@@ -1,7 +1,7 @@
 import React from 'react'
 import {StyleSheet, View} from 'react-native'
 import {observer} from 'mobx-react-lite'
-import {AppBskyActorProfile} from '@atproto/api'
+import {AppBskyActorDefs} from '@atproto/api'
 import {Link} from '../util/Link'
 import {Text} from '../util/text/Text'
 import {UserAvatar} from '../util/UserAvatar'
@@ -11,6 +11,7 @@ import {useStores} from 'state/index'
 import FollowButton from './FollowButton'
 
 export function ProfileCard({
+  testID,
   handle,
   displayName,
   avatar,
@@ -21,6 +22,7 @@ export function ProfileCard({
   followers,
   renderButton,
 }: {
+  testID?: string
   handle: string
   displayName?: string
   avatar?: string
@@ -28,12 +30,13 @@ export function ProfileCard({
   isFollowedBy?: boolean
   noBg?: boolean
   noBorder?: boolean
-  followers?: AppBskyActorProfile.View[] | undefined
+  followers?: AppBskyActorDefs.ProfileView[] | undefined
   renderButton?: () => JSX.Element
 }) {
   const pal = usePalette('default')
   return (
     <Link
+      testID={testID}
       style={[
         styles.outer,
         pal.border,
@@ -106,7 +109,6 @@ export function ProfileCard({
 export const ProfileCardWithFollowBtn = observer(
   ({
     did,
-    declarationCid,
     handle,
     displayName,
     avatar,
@@ -117,7 +119,6 @@ export const ProfileCardWithFollowBtn = observer(
     followers,
   }: {
     did: string
-    declarationCid: string
     handle: string
     displayName?: string
     avatar?: string
@@ -125,7 +126,7 @@ export const ProfileCardWithFollowBtn = observer(
     isFollowedBy?: boolean
     noBg?: boolean
     noBorder?: boolean
-    followers?: AppBskyActorProfile.View[] | undefined
+    followers?: AppBskyActorDefs.ProfileView[] | undefined
   }) => {
     const store = useStores()
     const isMe = store.me.handle === handle
@@ -140,11 +141,7 @@ export const ProfileCardWithFollowBtn = observer(
         noBg={noBg}
         noBorder={noBorder}
         followers={followers}
-        renderButton={
-          isMe
-            ? undefined
-            : () => <FollowButton did={did} declarationCid={declarationCid} />
-        }
+        renderButton={isMe ? undefined : () => <FollowButton did={did} />}
       />
     )
   },
diff --git a/src/view/com/profile/ProfileFollowers.tsx b/src/view/com/profile/ProfileFollowers.tsx
index d1488403a..8d489ad0a 100644
--- a/src/view/com/profile/ProfileFollowers.tsx
+++ b/src/view/com/profile/ProfileFollowers.tsx
@@ -19,7 +19,7 @@ export const ProfileFollowers = observer(function ProfileFollowers({
   const pal = usePalette('default')
   const store = useStores()
   const view = React.useMemo(
-    () => new UserFollowersViewModel(store, {user: name}),
+    () => new UserFollowersViewModel(store, {actor: name}),
     [store, name],
   )
 
@@ -64,7 +64,6 @@ export const ProfileFollowers = observer(function ProfileFollowers({
     <ProfileCardWithFollowBtn
       key={item.did}
       did={item.did}
-      declarationCid={item.declaration.cid}
       handle={item.handle}
       displayName={item.displayName}
       avatar={item.avatar}
diff --git a/src/view/com/profile/ProfileFollows.tsx b/src/view/com/profile/ProfileFollows.tsx
index ddb64787a..849b33441 100644
--- a/src/view/com/profile/ProfileFollows.tsx
+++ b/src/view/com/profile/ProfileFollows.tsx
@@ -16,7 +16,7 @@ export const ProfileFollows = observer(function ProfileFollows({
   const pal = usePalette('default')
   const store = useStores()
   const view = React.useMemo(
-    () => new UserFollowsViewModel(store, {user: name}),
+    () => new UserFollowsViewModel(store, {actor: name}),
     [store, name],
   )
 
@@ -61,7 +61,6 @@ export const ProfileFollows = observer(function ProfileFollows({
     <ProfileCardWithFollowBtn
       key={item.did}
       did={item.did}
-      declarationCid={item.declaration.cid}
       handle={item.handle}
       displayName={item.displayName}
       avatar={item.avatar}
diff --git a/src/view/com/profile/ProfileHeader.tsx b/src/view/com/profile/ProfileHeader.tsx
index 06dd20989..6294c627b 100644
--- a/src/view/com/profile/ProfileHeader.tsx
+++ b/src/view/com/profile/ProfileHeader.tsx
@@ -33,7 +33,61 @@ import {isDesktopWeb} from 'platform/detection'
 
 const BACK_HITSLOP = {left: 30, top: 30, right: 30, bottom: 30}
 
-export const ProfileHeader = observer(function ProfileHeader({
+export const ProfileHeader = observer(
+  ({
+    view,
+    onRefreshAll,
+  }: {
+    view: ProfileViewModel
+    onRefreshAll: () => void
+  }) => {
+    const pal = usePalette('default')
+
+    // loading
+    // =
+    if (!view || !view.hasLoaded) {
+      return (
+        <View style={pal.view}>
+          <LoadingPlaceholder width="100%" height={120} />
+          <View
+            style={[
+              pal.view,
+              {borderColor: pal.colors.background},
+              styles.avi,
+            ]}>
+            <LoadingPlaceholder width={80} height={80} style={styles.br40} />
+          </View>
+          <View style={styles.content}>
+            <View style={[styles.buttonsLine]}>
+              <LoadingPlaceholder width={100} height={31} style={styles.br50} />
+            </View>
+            <View style={styles.displayNameLine}>
+              <Text type="title-2xl" style={[pal.text, styles.title]}>
+                {view.displayName || view.handle}
+              </Text>
+            </View>
+          </View>
+        </View>
+      )
+    }
+
+    // error
+    // =
+    if (view.hasError) {
+      return (
+        <View testID="profileHeaderHasError">
+          <Text>{view.error}</Text>
+        </View>
+      )
+    }
+
+    // loaded
+    // =
+    return <ProfileHeaderLoaded view={view} onRefreshAll={onRefreshAll} />
+  },
+)
+
+const ProfileHeaderLoaded = observer(function ProfileHeaderLoaded({
   view,
   onRefreshAll,
 }: {
@@ -44,14 +98,17 @@ export const ProfileHeader = observer(function ProfileHeader({
   const store = useStores()
   const navigation = useNavigation<NavigationProp>()
   const {track} = useAnalytics()
+
   const onPressBack = React.useCallback(() => {
     navigation.goBack()
   }, [navigation])
+
   const onPressAvi = React.useCallback(() => {
     if (view.avatar) {
       store.shell.openLightbox(new ProfileImageLightbox(view))
     }
   }, [store, view])
+
   const onPressToggleFollow = React.useCallback(() => {
     view?.toggleFollowing().then(
       () => {
@@ -64,6 +121,7 @@ export const ProfileHeader = observer(function ProfileHeader({
       err => store.log.error('Failed to toggle follow', err),
     )
   }, [view, store])
+
   const onPressEditProfile = React.useCallback(() => {
     track('ProfileHeader:EditProfileButtonClicked')
     store.shell.openModal({
@@ -72,18 +130,22 @@ export const ProfileHeader = observer(function ProfileHeader({
       onUpdate: onRefreshAll,
     })
   }, [track, store, view, onRefreshAll])
+
   const onPressFollowers = React.useCallback(() => {
     track('ProfileHeader:FollowersButtonClicked')
     navigation.push('ProfileFollowers', {name: view.handle})
   }, [track, navigation, view])
+
   const onPressFollows = React.useCallback(() => {
     track('ProfileHeader:FollowsButtonClicked')
     navigation.push('ProfileFollows', {name: view.handle})
   }, [track, navigation, view])
+
   const onPressShare = React.useCallback(() => {
     track('ProfileHeader:ShareButtonClicked')
     Share.share({url: toShareUrl(`/profile/${view.handle}`)})
   }, [track, view])
+
   const onPressMuteAccount = React.useCallback(async () => {
     track('ProfileHeader:MuteAccountButtonClicked')
     try {
@@ -94,6 +156,7 @@ export const ProfileHeader = observer(function ProfileHeader({
       Toast.show(`There was an issue! ${e.toString()}`)
     }
   }, [track, view, store])
+
   const onPressUnmuteAccount = React.useCallback(async () => {
     track('ProfileHeader:UnmuteAccountButtonClicked')
     try {
@@ -104,6 +167,7 @@ export const ProfileHeader = observer(function ProfileHeader({
       Toast.show(`There was an issue! ${e.toString()}`)
     }
   }, [track, view, store])
+
   const onPressReportAccount = React.useCallback(() => {
     track('ProfileHeader:ReportAccountButtonClicked')
     store.shell.openModal({
@@ -112,54 +176,39 @@ export const ProfileHeader = observer(function ProfileHeader({
     })
   }, [track, store, view])
 
-  // loading
-  // =
-  if (!view || !view.hasLoaded) {
-    return (
-      <View style={pal.view}>
-        <LoadingPlaceholder width="100%" height={120} />
-        <View
-          style={[pal.view, {borderColor: pal.colors.background}, styles.avi]}>
-          <LoadingPlaceholder width={80} height={80} style={styles.br40} />
-        </View>
-        <View style={styles.content}>
-          <View style={[styles.buttonsLine]}>
-            <LoadingPlaceholder width={100} height={31} style={styles.br50} />
-          </View>
-          <View style={styles.displayNameLine}>
-            <Text type="title-2xl" style={[pal.text, styles.title]}>
-              {view.displayName || view.handle}
-            </Text>
-          </View>
-        </View>
-      </View>
-    )
-  }
-
-  // error
-  // =
-  if (view.hasError) {
-    return (
-      <View testID="profileHeaderHasError">
-        <Text>{view.error}</Text>
-      </View>
-    )
-  }
-
-  // loaded
-  // =
-  const isMe = store.me.did === view.did
-  let dropdownItems: DropdownItem[] = [{label: 'Share', onPress: onPressShare}]
-  if (!isMe) {
-    dropdownItems.push({
-      label: view.viewer.muted ? 'Unmute Account' : 'Mute Account',
-      onPress: view.viewer.muted ? onPressUnmuteAccount : onPressMuteAccount,
-    })
-    dropdownItems.push({
-      label: 'Report Account',
-      onPress: onPressReportAccount,
-    })
-  }
+  const isMe = React.useMemo(
+    () => store.me.did === view.did,
+    [store.me.did, view.did],
+  )
+  const dropdownItems: DropdownItem[] = React.useMemo(() => {
+    let items: DropdownItem[] = [
+      {
+        testID: 'profileHeaderDropdownSahreBtn',
+        label: 'Share',
+        onPress: onPressShare,
+      },
+    ]
+    if (!isMe) {
+      items.push({
+        testID: 'profileHeaderDropdownMuteBtn',
+        label: view.viewer.muted ? 'Unmute Account' : 'Mute Account',
+        onPress: view.viewer.muted ? onPressUnmuteAccount : onPressMuteAccount,
+      })
+      items.push({
+        testID: 'profileHeaderDropdownReportBtn',
+        label: 'Report Account',
+        onPress: onPressReportAccount,
+      })
+    }
+    return items
+  }, [
+    isMe,
+    view.viewer.muted,
+    onPressShare,
+    onPressUnmuteAccount,
+    onPressMuteAccount,
+    onPressReportAccount,
+  ])
   return (
     <View style={pal.view}>
       <UserBanner banner={view.banner} />
@@ -178,6 +227,7 @@ export const ProfileHeader = observer(function ProfileHeader({
             <>
               {store.me.follows.isFollowing(view.did) ? (
                 <TouchableOpacity
+                  testID="unfollowBtn"
                   onPress={onPressToggleFollow}
                   style={[styles.btn, styles.mainBtn, pal.btn]}>
                   <FontAwesomeIcon
@@ -191,7 +241,7 @@ export const ProfileHeader = observer(function ProfileHeader({
                 </TouchableOpacity>
               ) : (
                 <TouchableOpacity
-                  testID="profileHeaderToggleFollowButton"
+                  testID="followBtn"
                   onPress={onPressToggleFollow}
                   style={[styles.btn, styles.primaryBtn]}>
                   <FontAwesomeIcon
@@ -207,6 +257,7 @@ export const ProfileHeader = observer(function ProfileHeader({
           )}
           {dropdownItems?.length ? (
             <DropdownButton
+              testID="profileHeaderDropdownBtn"
               type="bare"
               items={dropdownItems}
               style={[styles.btn, styles.secondaryBtn, pal.btn]}>
@@ -215,7 +266,10 @@ export const ProfileHeader = observer(function ProfileHeader({
           ) : undefined}
         </View>
         <View style={styles.displayNameLine}>
-          <Text type="title-2xl" style={[pal.text, styles.title]}>
+          <Text
+            testID="profileHeaderDisplayName"
+            type="title-2xl"
+            style={[pal.text, styles.title]}>
             {view.displayName || view.handle}
           </Text>
         </View>
@@ -241,19 +295,17 @@ export const ProfileHeader = observer(function ProfileHeader({
               {pluralize(view.followersCount, 'follower')}
             </Text>
           </TouchableOpacity>
-          {view.isUser ? (
-            <TouchableOpacity
-              testID="profileHeaderFollowsButton"
-              style={[s.flexRow, s.mr10]}
-              onPress={onPressFollows}>
-              <Text type="md" style={[s.bold, s.mr2, pal.text]}>
-                {view.followsCount}
-              </Text>
-              <Text type="md" style={[pal.textLight]}>
-                following
-              </Text>
-            </TouchableOpacity>
-          ) : undefined}
+          <TouchableOpacity
+            testID="profileHeaderFollowsButton"
+            style={[s.flexRow, s.mr10]}
+            onPress={onPressFollows}>
+            <Text type="md" style={[s.bold, s.mr2, pal.text]}>
+              {view.followsCount}
+            </Text>
+            <Text type="md" style={[pal.textLight]}>
+              following
+            </Text>
+          </TouchableOpacity>
           <View style={[s.flexRow, s.mr10]}>
             <Text type="md" style={[s.bold, s.mr2, pal.text]}>
               {view.postsCount}
@@ -265,13 +317,16 @@ export const ProfileHeader = observer(function ProfileHeader({
         </View>
         {view.descriptionRichText ? (
           <RichText
+            testID="profileHeaderDescription"
             style={[styles.description, pal.text]}
             numberOfLines={15}
             richText={view.descriptionRichText}
           />
         ) : undefined}
         {view.viewer.muted ? (
-          <View style={[styles.detailLine, pal.btn, s.p5]}>
+          <View
+            testID="profileHeaderMutedNotice"
+            style={[styles.detailLine, pal.btn, s.p5]}>
             <FontAwesomeIcon
               icon={['far', 'eye-slash']}
               style={[pal.text, s.mr5]}
diff --git a/src/view/com/search/SearchResults.tsx b/src/view/com/search/SearchResults.tsx
index 4bf46515c..b53965f44 100644
--- a/src/view/com/search/SearchResults.tsx
+++ b/src/view/com/search/SearchResults.tsx
@@ -97,7 +97,6 @@ const Profiles = observer(({model}: {model: SearchUIModel}) => {
         <ProfileCardWithFollowBtn
           key={item.did}
           did={item.did}
-          declarationCid={item.declaration.cid}
           handle={item.handle}
           displayName={item.displayName}
           avatar={item.avatar}
diff --git a/src/view/com/util/Link.tsx b/src/view/com/util/Link.tsx
index f356f0b09..703869be1 100644
--- a/src/view/com/util/Link.tsx
+++ b/src/view/com/util/Link.tsx
@@ -29,6 +29,7 @@ type Event =
   | GestureResponderEvent
 
 export const Link = observer(function Link({
+  testID,
   style,
   href,
   title,
@@ -36,6 +37,7 @@ export const Link = observer(function Link({
   noFeedback,
   asAnchor,
 }: {
+  testID?: string
   style?: StyleProp<ViewStyle>
   href?: string
   title?: string
@@ -58,6 +60,7 @@ export const Link = observer(function Link({
   if (noFeedback) {
     return (
       <TouchableWithoutFeedback
+        testID={testID}
         onPress={onPress}
         // @ts-ignore web only -prf
         href={asAnchor ? href : undefined}>
@@ -69,6 +72,7 @@ export const Link = observer(function Link({
   }
   return (
     <TouchableOpacity
+      testID={testID}
       style={style}
       onPress={onPress}
       // @ts-ignore web only -prf
@@ -79,6 +83,7 @@ export const Link = observer(function Link({
 })
 
 export const TextLink = observer(function TextLink({
+  testID,
   type = 'md',
   style,
   href,
@@ -86,6 +91,7 @@ export const TextLink = observer(function TextLink({
   numberOfLines,
   lineHeight,
 }: {
+  testID?: string
   type?: TypographyVariant
   style?: StyleProp<TextStyle>
   href: string
@@ -106,6 +112,7 @@ export const TextLink = observer(function TextLink({
 
   return (
     <Text
+      testID={testID}
       type={type}
       style={style}
       numberOfLines={numberOfLines}
@@ -120,6 +127,7 @@ export const TextLink = observer(function TextLink({
  * Only acts as a link on desktop web
  */
 export const DesktopWebTextLink = observer(function DesktopWebTextLink({
+  testID,
   type = 'md',
   style,
   href,
@@ -127,6 +135,7 @@ export const DesktopWebTextLink = observer(function DesktopWebTextLink({
   numberOfLines,
   lineHeight,
 }: {
+  testID?: string
   type?: TypographyVariant
   style?: StyleProp<TextStyle>
   href: string
@@ -137,6 +146,7 @@ export const DesktopWebTextLink = observer(function DesktopWebTextLink({
   if (isDesktopWeb) {
     return (
       <TextLink
+        testID={testID}
         type={type}
         style={style}
         href={href}
@@ -148,6 +158,7 @@ export const DesktopWebTextLink = observer(function DesktopWebTextLink({
   }
   return (
     <Text
+      testID={testID}
       type={type}
       style={style}
       numberOfLines={numberOfLines}
diff --git a/src/view/com/util/PostCtrls.tsx b/src/view/com/util/PostCtrls.tsx
index 00e35eef7..6904928f4 100644
--- a/src/view/com/util/PostCtrls.tsx
+++ b/src/view/com/util/PostCtrls.tsx
@@ -45,12 +45,12 @@ interface PostCtrlsOpts {
   style?: StyleProp<ViewStyle>
   replyCount?: number
   repostCount?: number
-  upvoteCount?: number
+  likeCount?: number
   isReposted: boolean
-  isUpvoted: boolean
+  isLiked: boolean
   onPressReply: () => void
   onPressToggleRepost: () => Promise<void>
-  onPressToggleUpvote: () => Promise<void>
+  onPressToggleLike: () => Promise<void>
   onCopyPostText: () => void
   onOpenTranslate: () => void
   onDeletePost: () => void
@@ -157,26 +157,26 @@ export function PostCtrls(opts: PostCtrlsOpts) {
     })
   }
 
-  const onPressToggleUpvoteWrapper = () => {
-    if (!opts.isUpvoted) {
+  const onPressToggleLikeWrapper = () => {
+    if (!opts.isLiked) {
       ReactNativeHapticFeedback.trigger('impactMedium')
       setLikeMod(1)
       opts
-        .onPressToggleUpvote()
+        .onPressToggleLike()
         .catch(_e => undefined)
         .then(() => setLikeMod(0))
       // DISABLED see #135
       // likeRef.current?.trigger(
       //   {start: ctrlAnimStart, style: ctrlAnimStyle},
       //   async () => {
-      //     await opts.onPressToggleUpvote().catch(_e => undefined)
+      //     await opts.onPressToggleLike().catch(_e => undefined)
       //     setLikeMod(0)
       //   },
       // )
     } else {
       setLikeMod(-1)
       opts
-        .onPressToggleUpvote()
+        .onPressToggleLike()
         .catch(_e => undefined)
         .then(() => setLikeMod(0))
     }
@@ -186,6 +186,7 @@ export function PostCtrls(opts: PostCtrlsOpts) {
     <View style={[styles.ctrls, opts.style]}>
       <View style={s.flex1}>
         <TouchableOpacity
+          testID="replyBtn"
           style={styles.ctrl}
           hitSlop={HITSLOP}
           onPress={opts.onPressReply}>
@@ -203,6 +204,7 @@ export function PostCtrls(opts: PostCtrlsOpts) {
       </View>
       <View style={s.flex1}>
         <TouchableOpacity
+          testID="repostBtn"
           hitSlop={HITSLOP}
           onPress={onPressToggleRepostWrapper}
           style={styles.ctrl}>
@@ -230,6 +232,7 @@ export function PostCtrls(opts: PostCtrlsOpts) {
           }
           {typeof opts.repostCount !== 'undefined' ? (
             <Text
+              testID="repostCount"
               style={
                 opts.isReposted || repostMod > 0
                   ? [s.bold, s.green3, s.f15, s.ml5]
@@ -242,12 +245,13 @@ export function PostCtrls(opts: PostCtrlsOpts) {
       </View>
       <View style={s.flex1}>
         <TouchableOpacity
+          testID="likeBtn"
           style={styles.ctrl}
           hitSlop={HITSLOP}
-          onPress={onPressToggleUpvoteWrapper}>
-          {opts.isUpvoted || likeMod > 0 ? (
+          onPress={onPressToggleLikeWrapper}>
+          {opts.isLiked || likeMod > 0 ? (
             <HeartIconSolid
-              style={styles.ctrlIconUpvoted as StyleProp<ViewStyle>}
+              style={styles.ctrlIconLiked as StyleProp<ViewStyle>}
               size={opts.big ? 22 : 16}
             />
           ) : (
@@ -259,9 +263,9 @@ export function PostCtrls(opts: PostCtrlsOpts) {
           )}
           {
             undefined /*DISABLED see #135 <TriggerableAnimated ref={likeRef}>
-            {opts.isUpvoted || likeMod > 0 ? (
+            {opts.isLiked || likeMod > 0 ? (
               <HeartIconSolid
-                style={styles.ctrlIconUpvoted as ViewStyle}
+                style={styles.ctrlIconLiked as ViewStyle}
                 size={opts.big ? 22 : 16}
               />
             ) : (
@@ -276,14 +280,15 @@ export function PostCtrls(opts: PostCtrlsOpts) {
             )}
             </TriggerableAnimated>*/
           }
-          {typeof opts.upvoteCount !== 'undefined' ? (
+          {typeof opts.likeCount !== 'undefined' ? (
             <Text
+              testID="likeCount"
               style={
-                opts.isUpvoted || likeMod > 0
+                opts.isLiked || likeMod > 0
                   ? [s.bold, s.red3, s.f15, s.ml5]
                   : [defaultCtrlColor, s.f15, s.ml5]
               }>
-              {opts.upvoteCount + likeMod}
+              {opts.likeCount + likeMod}
             </Text>
           ) : undefined}
         </TouchableOpacity>
@@ -291,6 +296,7 @@ export function PostCtrls(opts: PostCtrlsOpts) {
       <View style={s.flex1}>
         {opts.big ? undefined : (
           <PostDropdownBtn
+            testID="postDropdownBtn"
             style={styles.ctrl}
             itemUri={opts.itemUri}
             itemCid={opts.itemCid}
@@ -330,7 +336,7 @@ const styles = StyleSheet.create({
   ctrlIconReposted: {
     color: colors.green3,
   },
-  ctrlIconUpvoted: {
+  ctrlIconLiked: {
     color: colors.red3,
   },
   mt1: {
diff --git a/src/view/com/util/PostEmbeds/YoutubeEmbed.tsx b/src/view/com/util/PostEmbeds/YoutubeEmbed.tsx
deleted file mode 100644
index d9425fe4e..000000000
--- a/src/view/com/util/PostEmbeds/YoutubeEmbed.tsx
+++ /dev/null
@@ -1,119 +0,0 @@
-import React, {useEffect} from 'react'
-import {useState} from 'react'
-import {
-  View,
-  StyleSheet,
-  Pressable,
-  TouchableWithoutFeedback,
-  EmitterSubscription,
-} from 'react-native'
-import YoutubePlayer from 'react-native-youtube-iframe'
-import {usePalette} from 'lib/hooks/usePalette'
-import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
-import ExternalLinkEmbed from './ExternalLinkEmbed'
-import {PresentedExternal} from '@atproto/api/dist/client/types/app/bsky/embed/external'
-import {useStores} from 'state/index'
-
-const YoutubeEmbed = ({
-  link,
-  videoId,
-}: {
-  videoId: string
-  link: PresentedExternal
-}) => {
-  const store = useStores()
-  const [displayVideoPlayer, setDisplayVideoPlayer] = useState(false)
-  const [playerDimensions, setPlayerDimensions] = useState({
-    width: 0,
-    height: 0,
-  })
-  const pal = usePalette('default')
-  const handlePlayButtonPressed = () => {
-    setDisplayVideoPlayer(true)
-  }
-  const handleOnLayout = (event: {
-    nativeEvent: {layout: {width: any; height: any}}
-  }) => {
-    setPlayerDimensions({
-      width: event.nativeEvent.layout.width,
-      height: event.nativeEvent.layout.height,
-    })
-  }
-  useEffect(() => {
-    let sub: EmitterSubscription
-    if (displayVideoPlayer) {
-      sub = store.onNavigation(() => {
-        setDisplayVideoPlayer(false)
-      })
-    }
-    return () => sub && sub.remove()
-  }, [displayVideoPlayer, store])
-
-  const imageChild = (
-    <Pressable onPress={handlePlayButtonPressed} style={styles.playButton}>
-      <FontAwesomeIcon icon="play" size={24} color="white" />
-    </Pressable>
-  )
-
-  if (!displayVideoPlayer) {
-    return (
-      <View
-        style={[styles.extOuter, pal.view, pal.border]}
-        onLayout={handleOnLayout}>
-        <ExternalLinkEmbed
-          link={link}
-          onImagePress={handlePlayButtonPressed}
-          imageChild={imageChild}
-        />
-      </View>
-    )
-  }
-
-  const height = (playerDimensions.width / 16) * 9
-  const noop = () => {}
-
-  return (
-    <TouchableWithoutFeedback onPress={noop}>
-      <View>
-        {/* Removing the outter View will make tap events propagate to parents */}
-        <YoutubePlayer
-          initialPlayerParams={{
-            modestbranding: true,
-          }}
-          webViewProps={{
-            startInLoadingState: true,
-          }}
-          height={height}
-          videoId={videoId}
-          webViewStyle={styles.webView}
-        />
-      </View>
-    </TouchableWithoutFeedback>
-  )
-}
-
-const styles = StyleSheet.create({
-  extOuter: {
-    borderWidth: 1,
-    borderRadius: 8,
-    marginTop: 4,
-  },
-  playButton: {
-    position: 'absolute',
-    alignSelf: 'center',
-    alignItems: 'center',
-    top: '44%',
-    justifyContent: 'center',
-    backgroundColor: 'black',
-    padding: 10,
-    borderRadius: 50,
-    opacity: 0.8,
-  },
-  webView: {
-    alignItems: 'center',
-    alignContent: 'center',
-    justifyContent: 'center',
-  },
-})
-
-export default YoutubeEmbed
diff --git a/src/view/com/util/PostMeta.tsx b/src/view/com/util/PostMeta.tsx
index c53de5c1f..a675283b8 100644
--- a/src/view/com/util/PostMeta.tsx
+++ b/src/view/com/util/PostMeta.tsx
@@ -16,7 +16,6 @@ interface PostMetaOpts {
   postHref: string
   timestamp: string
   did?: string
-  declarationCid?: string
   showFollowBtn?: boolean
 }
 
@@ -34,13 +33,7 @@ export const PostMeta = observer(function (opts: PostMetaOpts) {
     setDidFollow(true)
   }, [setDidFollow])
 
-  if (
-    opts.showFollowBtn &&
-    !isMe &&
-    (!isFollowing || didFollow) &&
-    opts.did &&
-    opts.declarationCid
-  ) {
+  if (opts.showFollowBtn && !isMe && (!isFollowing || didFollow) && opts.did) {
     // two-liner with follow button
     return (
       <View style={styles.metaTwoLine}>
@@ -79,7 +72,6 @@ export const PostMeta = observer(function (opts: PostMetaOpts) {
           <FollowButton
             type="default"
             did={opts.did}
-            declarationCid={opts.declarationCid}
             onToggleFollow={onToggleFollow}
           />
         </View>
diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx
index 2e0632521..ff741cd34 100644
--- a/src/view/com/util/UserAvatar.tsx
+++ b/src/view/com/util/UserAvatar.tsx
@@ -23,6 +23,7 @@ import {isWeb} from 'platform/detection'
 function DefaultAvatar({size}: {size: number}) {
   return (
     <Svg
+      testID="userAvatarFallback"
       width={size}
       height={size}
       viewBox="0 0 24 24"
@@ -56,6 +57,7 @@ export function UserAvatar({
 
   const dropdownItems = [
     !isWeb && {
+      testID: 'changeAvatarCameraBtn',
       label: 'Camera',
       icon: 'camera' as IconProp,
       onPress: async () => {
@@ -73,6 +75,7 @@ export function UserAvatar({
       },
     },
     {
+      testID: 'changeAvatarLibraryBtn',
       label: 'Library',
       icon: 'image' as IconProp,
       onPress: async () => {
@@ -94,6 +97,7 @@ export function UserAvatar({
       },
     },
     {
+      testID: 'changeAvatarRemoveBtn',
       label: 'Remove',
       icon: ['far', 'trash-can'] as IconProp,
       onPress: async () => {
@@ -104,6 +108,7 @@ export function UserAvatar({
   // onSelectNewAvatar is only passed as prop on the EditProfile component
   return onSelectNewAvatar ? (
     <DropdownButton
+      testID="changeAvatarBtn"
       type="bare"
       items={dropdownItems}
       openToRight
@@ -112,6 +117,7 @@ export function UserAvatar({
       menuWidth={170}>
       {avatar ? (
         <HighPriorityImage
+          testID="userAvatarImage"
           style={{
             width: size,
             height: size,
@@ -132,6 +138,7 @@ export function UserAvatar({
     </DropdownButton>
   ) : avatar ? (
     <HighPriorityImage
+      testID="userAvatarImage"
       style={{width: size, height: size, borderRadius: Math.floor(size / 2)}}
       resizeMode="stretch"
       source={{uri: avatar}}
diff --git a/src/view/com/util/UserBanner.tsx b/src/view/com/util/UserBanner.tsx
index 8317f93ac..56d7e370a 100644
--- a/src/view/com/util/UserBanner.tsx
+++ b/src/view/com/util/UserBanner.tsx
@@ -33,6 +33,7 @@ export function UserBanner({
 
   const dropdownItems = [
     !isWeb && {
+      testID: 'changeBannerCameraBtn',
       label: 'Camera',
       icon: 'camera' as IconProp,
       onPress: async () => {
@@ -51,6 +52,7 @@ export function UserBanner({
       },
     },
     {
+      testID: 'changeBannerLibraryBtn',
       label: 'Library',
       icon: 'image' as IconProp,
       onPress: async () => {
@@ -73,6 +75,7 @@ export function UserBanner({
       },
     },
     {
+      testID: 'changeBannerRemoveBtn',
       label: 'Remove',
       icon: ['far', 'trash-can'] as IconProp,
       onPress: () => {
@@ -84,6 +87,7 @@ export function UserBanner({
   // setUserBanner is only passed as prop on the EditProfile component
   return onSelectNewBanner ? (
     <DropdownButton
+      testID="changeBannerBtn"
       type="bare"
       items={dropdownItems}
       openToRight
@@ -91,9 +95,16 @@ export function UserBanner({
       bottomOffset={-10}
       menuWidth={170}>
       {banner ? (
-        <Image style={styles.bannerImage} source={{uri: banner}} />
+        <Image
+          testID="userBannerImage"
+          style={styles.bannerImage}
+          source={{uri: banner}}
+        />
       ) : (
-        <View style={[styles.bannerImage, styles.defaultBanner]} />
+        <View
+          testID="userBannerFallback"
+          style={[styles.bannerImage, styles.defaultBanner]}
+        />
       )}
       <View style={[styles.editButtonContainer, pal.btn]}>
         <FontAwesomeIcon
@@ -106,12 +117,16 @@ export function UserBanner({
     </DropdownButton>
   ) : banner ? (
     <Image
+      testID="userBannerImage"
       style={styles.bannerImage}
       resizeMode="cover"
       source={{uri: banner}}
     />
   ) : (
-    <View style={[styles.bannerImage, styles.defaultBanner]} />
+    <View
+      testID="userBannerFallback"
+      style={[styles.bannerImage, styles.defaultBanner]}
+    />
   )
 }
 
diff --git a/src/view/com/util/ViewHeader.tsx b/src/view/com/util/ViewHeader.tsx
index a99282512..ad0a5a1d2 100644
--- a/src/view/com/util/ViewHeader.tsx
+++ b/src/view/com/util/ViewHeader.tsx
@@ -51,7 +51,7 @@ export const ViewHeader = observer(function ({
     return (
       <Container hideOnScroll={hideOnScroll || false}>
         <TouchableOpacity
-          testID="viewHeaderBackOrMenuBtn"
+          testID="viewHeaderDrawerBtn"
           onPress={canGoBack ? onPressBack : onPressMenu}
           hitSlop={BACK_HITSLOP}
           style={canGoBack ? styles.backBtn : styles.backBtnWide}>
diff --git a/src/view/com/util/ViewSelector.tsx b/src/view/com/util/ViewSelector.tsx
index e1280fd82..82351cf08 100644
--- a/src/view/com/util/ViewSelector.tsx
+++ b/src/view/com/util/ViewSelector.tsx
@@ -47,13 +47,18 @@ export function ViewSelector({
   // events
   // =
 
-  const onSwipeEnd = (dx: number) => {
-    if (dx !== 0) {
-      setSelectedIndex(clamp(selectedIndex + dx, 0, sections.length))
-    }
-  }
-  const onPressSelection = (index: number) =>
-    setSelectedIndex(clamp(index, 0, sections.length))
+  const onSwipeEnd = React.useCallback(
+    (dx: number) => {
+      if (dx !== 0) {
+        setSelectedIndex(clamp(selectedIndex + dx, 0, sections.length))
+      }
+    },
+    [setSelectedIndex, selectedIndex, sections],
+  )
+  const onPressSelection = React.useCallback(
+    (index: number) => setSelectedIndex(clamp(index, 0, sections.length)),
+    [setSelectedIndex, sections],
+  )
   useEffect(() => {
     onSelectView?.(selectedIndex)
   }, [selectedIndex, onSelectView])
@@ -61,27 +66,33 @@ export function ViewSelector({
   // rendering
   // =
 
-  const renderItemInternal = ({item}: {item: any}) => {
-    if (item === HEADER_ITEM) {
-      if (renderHeader) {
-        return renderHeader()
+  const renderItemInternal = React.useCallback(
+    ({item}: {item: any}) => {
+      if (item === HEADER_ITEM) {
+        if (renderHeader) {
+          return renderHeader()
+        }
+        return <View />
+      } else if (item === SELECTOR_ITEM) {
+        return (
+          <Selector
+            items={sections}
+            panX={panX}
+            selectedIndex={selectedIndex}
+            onSelect={onPressSelection}
+          />
+        )
+      } else {
+        return renderItem(item)
       }
-      return <View />
-    } else if (item === SELECTOR_ITEM) {
-      return (
-        <Selector
-          items={sections}
-          panX={panX}
-          selectedIndex={selectedIndex}
-          onSelect={onPressSelection}
-        />
-      )
-    } else {
-      return renderItem(item)
-    }
-  }
+    },
+    [sections, panX, selectedIndex, onPressSelection, renderHeader, renderItem],
+  )
 
-  const data = [HEADER_ITEM, SELECTOR_ITEM, ...items]
+  const data = React.useMemo(
+    () => [HEADER_ITEM, SELECTOR_ITEM, ...items],
+    [items],
+  )
   return (
     <HorzSwipe
       hasPriority
diff --git a/src/view/com/util/forms/Button.tsx b/src/view/com/util/forms/Button.tsx
index f3f4d1c79..b7c058d2d 100644
--- a/src/view/com/util/forms/Button.tsx
+++ b/src/view/com/util/forms/Button.tsx
@@ -27,11 +27,13 @@ export function Button({
   style,
   onPress,
   children,
+  testID,
 }: React.PropsWithChildren<{
   type?: ButtonType
   label?: string
   style?: StyleProp<ViewStyle>
   onPress?: () => void
+  testID?: string
 }>) {
   const theme = useTheme()
   const outerStyle = choose<ViewStyle, Record<ButtonType, ViewStyle>>(type, {
@@ -107,7 +109,8 @@ export function Button({
   return (
     <TouchableOpacity
       style={[outerStyle, styles.outer, style]}
-      onPress={onPress}>
+      onPress={onPress}
+      testID={testID}>
       {label ? (
         <Text type="button" style={[labelStyle]}>
           {label}
diff --git a/src/view/com/util/forms/DropdownButton.tsx b/src/view/com/util/forms/DropdownButton.tsx
index d6ae800c6..938c346cd 100644
--- a/src/view/com/util/forms/DropdownButton.tsx
+++ b/src/view/com/util/forms/DropdownButton.tsx
@@ -24,6 +24,7 @@ const HITSLOP = {left: 10, top: 10, right: 10, bottom: 10}
 const ESTIMATED_MENU_ITEM_HEIGHT = 52
 
 export interface DropdownItem {
+  testID?: string
   icon?: IconProp
   label: string
   onPress: () => void
@@ -33,6 +34,7 @@ type MaybeDropdownItem = DropdownItem | false | undefined
 export type DropdownButtonType = ButtonType | 'bare'
 
 export function DropdownButton({
+  testID,
   type = 'bare',
   style,
   items,
@@ -43,6 +45,7 @@ export function DropdownButton({
   rightOffset = 0,
   bottomOffset = 0,
 }: {
+  testID?: string
   type?: DropdownButtonType
   style?: StyleProp<ViewStyle>
   items: MaybeDropdownItem[]
@@ -90,22 +93,18 @@ export function DropdownButton({
   if (type === 'bare') {
     return (
       <TouchableOpacity
+        testID={testID}
         style={style}
         onPress={onPress}
         hitSlop={HITSLOP}
-        // Fix an issue where specific references cause runtime error in jest environment
-        ref={
-          typeof process !== 'undefined' && process.env.JEST_WORKER_ID != null
-            ? null
-            : ref
-        }>
+        ref={ref}>
         {children}
       </TouchableOpacity>
     )
   }
   return (
     <View ref={ref}>
-      <Button onPress={onPress} style={style} label={label}>
+      <Button testID={testID} onPress={onPress} style={style} label={label}>
         {children}
       </Button>
     </View>
@@ -113,6 +112,7 @@ export function DropdownButton({
 }
 
 export function PostDropdownBtn({
+  testID,
   style,
   children,
   itemUri,
@@ -123,6 +123,7 @@ export function PostDropdownBtn({
   onOpenTranslate,
   onDeletePost,
 }: {
+  testID?: string
   style?: StyleProp<ViewStyle>
   children?: React.ReactNode
   itemUri: string
@@ -138,6 +139,7 @@ export function PostDropdownBtn({
 
   const dropdownItems: DropdownItem[] = [
     {
+      testID: 'postDropdownTranslateBtn',
       icon: 'language',
       label: 'Translate...',
       onPress() {
@@ -145,6 +147,7 @@ export function PostDropdownBtn({
       },
     },
     {
+      testID: 'postDropdownCopyTextBtn',
       icon: ['far', 'paste'],
       label: 'Copy post text',
       onPress() {
@@ -152,6 +155,7 @@ export function PostDropdownBtn({
       },
     },
     {
+      testID: 'postDropdownShareBtn',
       icon: 'share',
       label: 'Share...',
       onPress() {
@@ -159,6 +163,7 @@ export function PostDropdownBtn({
       },
     },
     {
+      testID: 'postDropdownReportBtn',
       icon: 'circle-exclamation',
       label: 'Report post',
       onPress() {
@@ -171,6 +176,7 @@ export function PostDropdownBtn({
     },
     isAuthor
       ? {
+          testID: 'postDropdownDeleteBtn',
           icon: ['far', 'trash-can'],
           label: 'Delete post',
           onPress() {
@@ -186,7 +192,11 @@ export function PostDropdownBtn({
   ].filter(Boolean) as DropdownItem[]
 
   return (
-    <DropdownButton style={style} items={dropdownItems} menuWidth={200}>
+    <DropdownButton
+      testID={testID}
+      style={style}
+      items={dropdownItems}
+      menuWidth={200}>
       {children}
     </DropdownButton>
   )
@@ -291,6 +301,7 @@ const DropdownItems = ({
         ]}>
         {items.map((item, index) => (
           <TouchableOpacity
+            testID={item.testID}
             key={index}
             style={[styles.menuItem]}
             onPress={() => onPressItem(index)}>
diff --git a/src/view/com/util/forms/RadioButton.tsx b/src/view/com/util/forms/RadioButton.tsx
index d6b2bb119..f5696a76d 100644
--- a/src/view/com/util/forms/RadioButton.tsx
+++ b/src/view/com/util/forms/RadioButton.tsx
@@ -6,12 +6,14 @@ import {useTheme} from 'lib/ThemeContext'
 import {choose} from 'lib/functions'
 
 export function RadioButton({
+  testID,
   type = 'default-light',
   label,
   isSelected,
   style,
   onPress,
 }: {
+  testID?: string
   type?: ButtonType
   label: string
   isSelected: boolean
@@ -119,7 +121,7 @@ export function RadioButton({
     },
   })
   return (
-    <Button type={type} onPress={onPress} style={style}>
+    <Button testID={testID} type={type} onPress={onPress} style={style}>
       <View style={styles.outer}>
         <View style={[circleStyle, styles.circle]}>
           {isSelected ? (
diff --git a/src/view/com/util/forms/RadioGroup.tsx b/src/view/com/util/forms/RadioGroup.tsx
index 901b0cdd8..071540b73 100644
--- a/src/view/com/util/forms/RadioGroup.tsx
+++ b/src/view/com/util/forms/RadioGroup.tsx
@@ -10,11 +10,13 @@ export interface RadioGroupItem {
 }
 
 export function RadioGroup({
+  testID,
   type,
   items,
   initialSelection = '',
   onSelect,
 }: {
+  testID?: string
   type?: ButtonType
   items: RadioGroupItem[]
   initialSelection?: string
@@ -30,6 +32,7 @@ export function RadioGroup({
       {items.map((item, i) => (
         <RadioButton
           key={item.key}
+          testID={testID ? `${testID}-${item.key}` : undefined}
           style={i !== 0 ? s.mt2 : undefined}
           type={type}
           label={item.label}
diff --git a/src/view/com/util/images/AutoSizedImage.tsx b/src/view/com/util/images/AutoSizedImage.tsx
index 24dbe6a52..ddb09ce39 100644
--- a/src/view/com/util/images/AutoSizedImage.tsx
+++ b/src/view/com/util/images/AutoSizedImage.tsx
@@ -4,9 +4,9 @@ import {
   StyleProp,
   StyleSheet,
   TouchableOpacity,
+  View,
   ViewStyle,
 } from 'react-native'
-// import Image from 'view/com/util/images/Image'
 import {clamp} from 'lib/numbers'
 import {useStores} from 'state/index'
 import {Dim} from 'lib/media/manip'
@@ -51,16 +51,24 @@ export function AutoSizedImage({
     })
   }, [dim, setDim, setAspectRatio, store, uri])
 
+  if (onPress || onLongPress || onPressIn) {
+    return (
+      <TouchableOpacity
+        onPress={onPress}
+        onLongPress={onLongPress}
+        onPressIn={onPressIn}
+        delayPressIn={DELAY_PRESS_IN}
+        style={[styles.container, style]}>
+        <Image style={[styles.image, {aspectRatio}]} source={{uri}} />
+        {children}
+      </TouchableOpacity>
+    )
+  }
   return (
-    <TouchableOpacity
-      onPress={onPress}
-      onLongPress={onLongPress}
-      onPressIn={onPressIn}
-      delayPressIn={DELAY_PRESS_IN}
-      style={[styles.container, style]}>
+    <View style={[styles.container, style]}>
       <Image style={[styles.image, {aspectRatio}]} source={{uri}} />
       {children}
-    </TouchableOpacity>
+    </View>
   )
 }
 
diff --git a/src/view/com/util/PostEmbeds/ExternalLinkEmbed.tsx b/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx
index e8c63bdb7..a4cbb3e29 100644
--- a/src/view/com/util/PostEmbeds/ExternalLinkEmbed.tsx
+++ b/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx
@@ -3,25 +3,20 @@ import {Text} from '../text/Text'
 import {AutoSizedImage} from '../images/AutoSizedImage'
 import {StyleSheet, View} from 'react-native'
 import {usePalette} from 'lib/hooks/usePalette'
-import {PresentedExternal} from '@atproto/api/dist/client/types/app/bsky/embed/external'
+import {AppBskyEmbedExternal} from '@atproto/api'
 
-const ExternalLinkEmbed = ({
+export const ExternalLinkEmbed = ({
   link,
-  onImagePress,
   imageChild,
 }: {
-  link: PresentedExternal
-  onImagePress?: () => void
+  link: AppBskyEmbedExternal.ViewExternal
   imageChild?: React.ReactNode
 }) => {
   const pal = usePalette('default')
   return (
     <>
       {link.thumb ? (
-        <AutoSizedImage
-          uri={link.thumb}
-          style={styles.extImage}
-          onPress={onImagePress}>
+        <AutoSizedImage uri={link.thumb} style={styles.extImage}>
           {imageChild}
         </AutoSizedImage>
       ) : undefined}
@@ -65,5 +60,3 @@ const styles = StyleSheet.create({
     marginTop: 4,
   },
 })
-
-export default ExternalLinkEmbed
diff --git a/src/view/com/util/PostEmbeds/QuoteEmbed.tsx b/src/view/com/util/post-embeds/QuoteEmbed.tsx
index fee67c9bc..9dc5739a0 100644
--- a/src/view/com/util/PostEmbeds/QuoteEmbed.tsx
+++ b/src/view/com/util/post-embeds/QuoteEmbed.tsx
@@ -1,13 +1,21 @@
-import {StyleSheet} from 'react-native'
 import React from 'react'
+import {StyleProp, StyleSheet, ViewStyle} from 'react-native'
+import {AppBskyEmbedImages, AppBskyEmbedRecordWithMedia} from '@atproto/api'
 import {AtUri} from '../../../../third-party/uri'
 import {PostMeta} from '../PostMeta'
 import {Link} from '../Link'
 import {Text} from '../text/Text'
 import {usePalette} from 'lib/hooks/usePalette'
 import {ComposerOptsQuote} from 'state/models/ui/shell'
+import {PostEmbeds} from '.'
 
-const QuoteEmbed = ({quote}: {quote: ComposerOptsQuote}) => {
+export function QuoteEmbed({
+  quote,
+  style,
+}: {
+  quote: ComposerOptsQuote
+  style?: StyleProp<ViewStyle>
+}) {
   const pal = usePalette('default')
   const itemUrip = new AtUri(quote.uri)
   const itemHref = `/profile/${quote.author.handle}/post/${itemUrip.rkey}`
@@ -16,9 +24,18 @@ const QuoteEmbed = ({quote}: {quote: ComposerOptsQuote}) => {
     () => quote.text.trim().length === 0,
     [quote.text],
   )
+  const imagesEmbed = React.useMemo(
+    () =>
+      quote.embeds?.find(
+        embed =>
+          AppBskyEmbedImages.isView(embed) ||
+          AppBskyEmbedRecordWithMedia.isView(embed),
+      ),
+    [quote.embeds],
+  )
   return (
     <Link
-      style={[styles.container, pal.border]}
+      style={[styles.container, pal.border, style]}
       href={itemHref}
       title={itemTitle}>
       <PostMeta
@@ -37,6 +54,12 @@ const QuoteEmbed = ({quote}: {quote: ComposerOptsQuote}) => {
           quote.text
         )}
       </Text>
+      {AppBskyEmbedImages.isView(imagesEmbed) && (
+        <PostEmbeds embed={imagesEmbed} />
+      )}
+      {AppBskyEmbedRecordWithMedia.isView(imagesEmbed) && (
+        <PostEmbeds embed={imagesEmbed.media} />
+      )}
     </Link>
   )
 }
@@ -48,7 +71,6 @@ const styles = StyleSheet.create({
     borderRadius: 8,
     paddingVertical: 8,
     paddingHorizontal: 12,
-    marginVertical: 8,
     borderWidth: 1,
   },
   quotePost: {
diff --git a/src/view/com/util/post-embeds/YoutubeEmbed.tsx b/src/view/com/util/post-embeds/YoutubeEmbed.tsx
new file mode 100644
index 000000000..2ca0750a3
--- /dev/null
+++ b/src/view/com/util/post-embeds/YoutubeEmbed.tsx
@@ -0,0 +1,55 @@
+import React from 'react'
+import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
+import {usePalette} from 'lib/hooks/usePalette'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {ExternalLinkEmbed} from './ExternalLinkEmbed'
+import {AppBskyEmbedExternal} from '@atproto/api'
+import {Link} from '../Link'
+
+export const YoutubeEmbed = ({
+  link,
+  style,
+}: {
+  link: AppBskyEmbedExternal.ViewExternal
+  style?: StyleProp<ViewStyle>
+}) => {
+  const pal = usePalette('default')
+
+  const imageChild = (
+    <View style={styles.playButton}>
+      <FontAwesomeIcon icon="play" size={24} color="white" />
+    </View>
+  )
+
+  return (
+    <Link
+      style={[styles.extOuter, pal.view, pal.border, style]}
+      href={link.uri}
+      noFeedback>
+      <ExternalLinkEmbed link={link} imageChild={imageChild} />
+    </Link>
+  )
+}
+
+const styles = StyleSheet.create({
+  extOuter: {
+    borderWidth: 1,
+    borderRadius: 8,
+  },
+  playButton: {
+    position: 'absolute',
+    alignSelf: 'center',
+    alignItems: 'center',
+    top: '44%',
+    justifyContent: 'center',
+    backgroundColor: 'black',
+    padding: 10,
+    borderRadius: 50,
+    opacity: 0.8,
+  },
+  webView: {
+    alignItems: 'center',
+    alignContent: 'center',
+    justifyContent: 'center',
+  },
+})
diff --git a/src/view/com/util/PostEmbeds/index.tsx b/src/view/com/util/post-embeds/index.tsx
index 02a8aa90e..726bea6e7 100644
--- a/src/view/com/util/PostEmbeds/index.tsx
+++ b/src/view/com/util/post-embeds/index.tsx
@@ -10,6 +10,7 @@ import {
   AppBskyEmbedImages,
   AppBskyEmbedExternal,
   AppBskyEmbedRecord,
+  AppBskyEmbedRecordWithMedia,
   AppBskyFeedPost,
 } from '@atproto/api'
 import {Link} from '../Link'
@@ -19,15 +20,16 @@ import {ImagesLightbox} from 'state/models/ui/shell'
 import {useStores} from 'state/index'
 import {usePalette} from 'lib/hooks/usePalette'
 import {saveImageModal} from 'lib/media/manip'
-import YoutubeEmbed from './YoutubeEmbed'
-import ExternalLinkEmbed from './ExternalLinkEmbed'
+import {YoutubeEmbed} from './YoutubeEmbed'
+import {ExternalLinkEmbed} from './ExternalLinkEmbed'
 import {getYoutubeVideoId} from 'lib/strings/url-helpers'
 import QuoteEmbed from './QuoteEmbed'
 
 type Embed =
-  | AppBskyEmbedRecord.Presented
-  | AppBskyEmbedImages.Presented
-  | AppBskyEmbedExternal.Presented
+  | AppBskyEmbedRecord.View
+  | AppBskyEmbedImages.View
+  | AppBskyEmbedExternal.View
+  | AppBskyEmbedRecordWithMedia.View
   | {$type: string; [k: string]: unknown}
 
 export function PostEmbeds({
@@ -39,11 +41,35 @@ export function PostEmbeds({
 }) {
   const pal = usePalette('default')
   const store = useStores()
-  if (AppBskyEmbedRecord.isPresented(embed)) {
+
+  if (
+    AppBskyEmbedRecordWithMedia.isView(embed) &&
+    AppBskyEmbedRecord.isViewRecord(embed.record.record) &&
+    AppBskyFeedPost.isRecord(embed.record.record.value) &&
+    AppBskyFeedPost.validateRecord(embed.record.record.value).success
+  ) {
+    return (
+      <View style={[styles.stackContainer, style]}>
+        <PostEmbeds embed={embed.media} />
+        <QuoteEmbed
+          quote={{
+            author: embed.record.record.author,
+            cid: embed.record.record.cid,
+            uri: embed.record.record.uri,
+            indexedAt: embed.record.record.indexedAt,
+            text: embed.record.record.value.text,
+            embeds: embed.record.record.embeds,
+          }}
+        />
+      </View>
+    )
+  }
+
+  if (AppBskyEmbedRecord.isView(embed)) {
     if (
-      AppBskyEmbedRecord.isPresentedRecord(embed.record) &&
-      AppBskyFeedPost.isRecord(embed.record.record) &&
-      AppBskyFeedPost.validateRecord(embed.record.record).success
+      AppBskyEmbedRecord.isViewRecord(embed.record) &&
+      AppBskyFeedPost.isRecord(embed.record.value) &&
+      AppBskyFeedPost.validateRecord(embed.record.value).success
     ) {
       return (
         <QuoteEmbed
@@ -51,14 +77,17 @@ export function PostEmbeds({
             author: embed.record.author,
             cid: embed.record.cid,
             uri: embed.record.uri,
-            indexedAt: embed.record.record.createdAt, // TODO
-            text: embed.record.record.text,
+            indexedAt: embed.record.indexedAt,
+            text: embed.record.value.text,
+            embeds: embed.record.embeds,
           }}
+          style={style}
         />
       )
     }
   }
-  if (AppBskyEmbedImages.isPresented(embed)) {
+
+  if (AppBskyEmbedImages.isView(embed)) {
     if (embed.images.length > 0) {
       const uris = embed.images.map(img => img.fullsize)
       const openLightbox = (index: number) => {
@@ -129,12 +158,13 @@ export function PostEmbeds({
       }
     }
   }
-  if (AppBskyEmbedExternal.isPresented(embed)) {
+
+  if (AppBskyEmbedExternal.isView(embed)) {
     const link = embed.external
     const youtubeVideoId = getYoutubeVideoId(link.uri)
 
     if (youtubeVideoId) {
-      return <YoutubeEmbed videoId={youtubeVideoId} link={link} />
+      return <YoutubeEmbed link={link} style={style} />
     }
 
     return (
@@ -150,6 +180,9 @@ export function PostEmbeds({
 }
 
 const styles = StyleSheet.create({
+  stackContainer: {
+    gap: 6,
+  },
   imagesContainer: {
     marginTop: 4,
   },
diff --git a/src/view/com/util/text/RichText.tsx b/src/view/com/util/text/RichText.tsx
index d4cf19172..804db002a 100644
--- a/src/view/com/util/text/RichText.tsx
+++ b/src/view/com/util/text/RichText.tsx
@@ -1,20 +1,22 @@
 import React from 'react'
 import {TextStyle, StyleProp} from 'react-native'
+import {RichText as RichTextObj, AppBskyRichtextFacet} from '@atproto/api'
 import {TextLink} from '../Link'
 import {Text} from './Text'
 import {lh} from 'lib/styles'
 import {toShortUrl} from 'lib/strings/url-helpers'
-import {RichText as RichTextObj, Entity} from 'lib/strings/rich-text'
 import {useTheme, TypographyVariant} from 'lib/ThemeContext'
 import {usePalette} from 'lib/hooks/usePalette'
 
 export function RichText({
+  testID,
   type = 'md',
   richText,
   lineHeight = 1.2,
   style,
   numberOfLines,
 }: {
+  testID?: string
   type?: TypographyVariant
   richText?: RichTextObj
   lineHeight?: number
@@ -29,17 +31,24 @@ export function RichText({
     return null
   }
 
-  const {text, entities} = richText
-  if (!entities?.length) {
+  const {text, facets} = richText
+  if (!facets?.length) {
     if (/^\p{Extended_Pictographic}+$/u.test(text) && text.length <= 5) {
       style = {
         fontSize: 26,
         lineHeight: 30,
       }
-      return <Text style={[style, pal.text]}>{text}</Text>
+      return (
+        <Text testID={testID} style={[style, pal.text]}>
+          {text}
+        </Text>
+      )
     }
     return (
-      <Text type={type} style={[style, pal.text, lineHeightStyle]}>
+      <Text
+        testID={testID}
+        type={type}
+        style={[style, pal.text, lineHeightStyle]}>
         {text}
       </Text>
     )
@@ -49,40 +58,40 @@ export function RichText({
   } else if (!Array.isArray(style)) {
     style = [style]
   }
-  entities.sort(sortByIndex)
-  const segments = Array.from(toSegments(text, entities))
+
   const els = []
   let key = 0
-  for (const segment of segments) {
-    if (typeof segment === 'string') {
-      els.push(segment)
+  for (const segment of richText.segments()) {
+    const link = segment.link
+    const mention = segment.mention
+    if (mention && AppBskyRichtextFacet.validateMention(mention).success) {
+      els.push(
+        <TextLink
+          key={key}
+          type={type}
+          text={segment.text}
+          href={`/profile/${mention.did}`}
+          style={[style, lineHeightStyle, pal.link]}
+        />,
+      )
+    } else if (link && AppBskyRichtextFacet.validateLink(link).success) {
+      els.push(
+        <TextLink
+          key={key}
+          type={type}
+          text={toShortUrl(segment.text)}
+          href={link.uri}
+          style={[style, lineHeightStyle, pal.link]}
+        />,
+      )
     } else {
-      if (segment.entity.type === 'mention') {
-        els.push(
-          <TextLink
-            key={key}
-            type={type}
-            text={segment.text}
-            href={`/profile/${segment.entity.value}`}
-            style={[style, lineHeightStyle, pal.link]}
-          />,
-        )
-      } else if (segment.entity.type === 'link') {
-        els.push(
-          <TextLink
-            key={key}
-            type={type}
-            text={toShortUrl(segment.text)}
-            href={segment.entity.value}
-            style={[style, lineHeightStyle, pal.link]}
-          />,
-        )
-      }
+      els.push(segment.text)
     }
     key++
   }
   return (
     <Text
+      testID={testID}
       type={type}
       style={[style, pal.text, lineHeightStyle]}
       numberOfLines={numberOfLines}>
@@ -90,38 +99,3 @@ export function RichText({
     </Text>
   )
 }
-
-function sortByIndex(a: Entity, b: Entity) {
-  return a.index.start - b.index.start
-}
-
-function* toSegments(text: string, entities: Entity[]) {
-  let cursor = 0
-  let i = 0
-  do {
-    let currEnt = entities[i]
-    if (cursor < currEnt.index.start) {
-      yield text.slice(cursor, currEnt.index.start)
-    } else if (cursor > currEnt.index.start) {
-      i++
-      continue
-    }
-    if (currEnt.index.start < currEnt.index.end) {
-      let subtext = text.slice(currEnt.index.start, currEnt.index.end)
-      if (!subtext.trim()) {
-        // dont yield links to empty strings
-        yield subtext
-      } else {
-        yield {
-          entity: currEnt,
-          text: subtext,
-        }
-      }
-    }
-    cursor = currEnt.index.end
-    i++
-  } while (i < entities.length)
-  if (cursor < text.length) {
-    yield text.slice(cursor, text.length)
-  }
-}
diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx
index 4f2bc4c15..871aae9c7 100644
--- a/src/view/screens/Home.tsx
+++ b/src/view/screens/Home.tsx
@@ -33,6 +33,7 @@ export const HomeScreen = withAuthRequired((_opts: Props) => {
 
   useFocusEffect(
     React.useCallback(() => {
+      store.shell.setMinimalShellMode(false)
       store.shell.setIsDrawerSwipeDisabled(selectedPage > 0)
       return () => {
         store.shell.setIsDrawerSwipeDisabled(false)
@@ -42,6 +43,7 @@ export const HomeScreen = withAuthRequired((_opts: Props) => {
 
   const onPageSelected = React.useCallback(
     (index: number) => {
+      store.shell.setMinimalShellMode(false)
       setSelectedPage(index)
       store.shell.setIsDrawerSwipeDisabled(index > 0)
     },
@@ -54,7 +56,13 @@ export const HomeScreen = withAuthRequired((_opts: Props) => {
 
   const renderTabBar = React.useCallback(
     (props: RenderTabBarFnProps) => {
-      return <FeedsTabBar {...props} onPressSelected={onPressSelected} />
+      return (
+        <FeedsTabBar
+          {...props}
+          testID="homeScreenFeedTabs"
+          onPressSelected={onPressSelected}
+        />
+      )
     },
     [onPressSelected],
   )
@@ -66,27 +74,36 @@ export const HomeScreen = withAuthRequired((_opts: Props) => {
   const initialPage = store.me.follows.isEmpty ? 1 : 0
   return (
     <Pager
+      testID="homeScreen"
       onPageSelected={onPageSelected}
       renderTabBar={renderTabBar}
       tabBarPosition="top"
       initialPage={initialPage}>
       <FeedPage
         key="1"
+        testID="followingFeedPage"
         isPageFocused={selectedPage === 0}
         feed={store.me.mainFeed}
         renderEmptyState={renderFollowingEmptyState}
       />
-      <FeedPage key="2" isPageFocused={selectedPage === 1} feed={algoFeed} />
+      <FeedPage
+        key="2"
+        testID="whatshotFeedPage"
+        isPageFocused={selectedPage === 1}
+        feed={algoFeed}
+      />
     </Pager>
   )
 })
 
 const FeedPage = observer(
   ({
+    testID,
     isPageFocused,
     feed,
     renderEmptyState,
   }: {
+    testID?: string
     feed: FeedModel
     isPageFocused: boolean
     renderEmptyState?: () => JSX.Element
@@ -163,9 +180,9 @@ const FeedPage = observer(
     }, [feed, scrollToTop])
 
     return (
-      <View style={s.h100pct}>
+      <View testID={testID} style={s.h100pct}>
         <Feed
-          testID="homeFeed"
+          testID={testID ? `${testID}-feed` : undefined}
           key="default"
           feed={feed}
           scrollElRef={scrollElRef}
diff --git a/src/view/screens/NotFound.tsx b/src/view/screens/NotFound.tsx
index 6ab37f117..cb52da58b 100644
--- a/src/view/screens/NotFound.tsx
+++ b/src/view/screens/NotFound.tsx
@@ -1,16 +1,28 @@
 import React from 'react'
 import {StyleSheet, View} from 'react-native'
-import {useNavigation, StackActions} from '@react-navigation/native'
+import {
+  useNavigation,
+  StackActions,
+  useFocusEffect,
+} from '@react-navigation/native'
 import {ViewHeader} from '../com/util/ViewHeader'
 import {Text} from '../com/util/text/Text'
 import {Button} from 'view/com/util/forms/Button'
 import {NavigationProp} from 'lib/routes/types'
 import {usePalette} from 'lib/hooks/usePalette'
+import {useStores} from 'state/index'
 import {s} from 'lib/styles'
 
 export const NotFoundScreen = () => {
   const pal = usePalette('default')
   const navigation = useNavigation<NavigationProp>()
+  const store = useStores()
+
+  useFocusEffect(
+    React.useCallback(() => {
+      store.shell.setMinimalShellMode(false)
+    }, [store]),
+  )
 
   const canGoBack = navigation.canGoBack()
   const onPressHome = React.useCallback(() => {
diff --git a/src/view/screens/Notifications.tsx b/src/view/screens/Notifications.tsx
index 7da563843..e5521c7ac 100644
--- a/src/view/screens/Notifications.tsx
+++ b/src/view/screens/Notifications.tsx
@@ -72,6 +72,7 @@ export const NotificationsScreen = withAuthRequired(
     // =
     useFocusEffect(
       React.useCallback(() => {
+        store.shell.setMinimalShellMode(false)
         store.log.debug('NotificationsScreen: Updating feed')
         const softResetSub = store.onScreenSoftReset(scrollToTop)
         store.me.notifications.loadUnreadCount()
@@ -86,7 +87,7 @@ export const NotificationsScreen = withAuthRequired(
     )
 
     return (
-      <View style={s.hContentRegion}>
+      <View testID="notificationsScreen" style={s.hContentRegion}>
         <ViewHeader title="Notifications" canGoBack={false} />
         <Feed
           view={store.me.notifications}
diff --git a/src/view/screens/PostUpvotedBy.tsx b/src/view/screens/PostLikedBy.tsx
index 35b55f3c4..fb44f1f9b 100644
--- a/src/view/screens/PostUpvotedBy.tsx
+++ b/src/view/screens/PostLikedBy.tsx
@@ -4,12 +4,12 @@ import {useFocusEffect} from '@react-navigation/native'
 import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
 import {withAuthRequired} from 'view/com/auth/withAuthRequired'
 import {ViewHeader} from '../com/util/ViewHeader'
-import {PostVotedBy as PostLikedByComponent} from '../com/post-thread/PostVotedBy'
+import {PostLikedBy as PostLikedByComponent} from '../com/post-thread/PostLikedBy'
 import {useStores} from 'state/index'
 import {makeRecordUri} from 'lib/strings/url-helpers'
 
-type Props = NativeStackScreenProps<CommonNavigatorParams, 'PostUpvotedBy'>
-export const PostUpvotedByScreen = withAuthRequired(({route}: Props) => {
+type Props = NativeStackScreenProps<CommonNavigatorParams, 'PostLikedBy'>
+export const PostLikedByScreen = withAuthRequired(({route}: Props) => {
   const store = useStores()
   const {name, rkey} = route.params
   const uri = makeRecordUri(name, 'app.bsky.feed.post', rkey)
@@ -23,7 +23,7 @@ export const PostUpvotedByScreen = withAuthRequired(({route}: Props) => {
   return (
     <View>
       <ViewHeader title="Liked by" />
-      <PostLikedByComponent uri={uri} direction="up" />
+      <PostLikedByComponent uri={uri} />
     </View>
   )
 })
diff --git a/src/view/screens/PostThread.tsx b/src/view/screens/PostThread.tsx
index ad54126b6..9bfdcc95a 100644
--- a/src/view/screens/PostThread.tsx
+++ b/src/view/screens/PostThread.tsx
@@ -29,8 +29,8 @@ export const PostThreadScreen = withAuthRequired(({route}: Props) => {
 
   useFocusEffect(
     React.useCallback(() => {
-      const threadCleanup = view.registerListeners()
       store.shell.setMinimalShellMode(false)
+      const threadCleanup = view.registerListeners()
       if (!view.hasLoaded && !view.isLoading) {
         view.setup().catch(err => {
           store.log.error('Failed to fetch thread', err)
diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx
index 65f1fef26..556578e77 100644
--- a/src/view/screens/Profile.tsx
+++ b/src/view/screens/Profile.tsx
@@ -42,6 +42,7 @@ export const ProfileScreen = withAuthRequired(
     useFocusEffect(
       React.useCallback(() => {
         let aborted = false
+        store.shell.setMinimalShellMode(false)
         const feedCleanup = uiState.feed.registerListeners()
         if (hasSetup) {
           uiState.update()
@@ -57,7 +58,7 @@ export const ProfileScreen = withAuthRequired(
           aborted = true
           feedCleanup()
         }
-      }, [hasSetup, uiState]),
+      }, [hasSetup, uiState, store]),
     )
 
     // events
diff --git a/src/view/screens/Search.tsx b/src/view/screens/Search.tsx
index 641d144ae..e6947013e 100644
--- a/src/view/screens/Search.tsx
+++ b/src/view/screens/Search.tsx
@@ -152,6 +152,7 @@ export const SearchScreen = withAuthRequired(
                   {autocompleteView.searchRes.map(item => (
                     <ProfileCard
                       key={item.did}
+                      testID={`searchAutoCompleteResult-${item.handle}`}
                       handle={item.handle}
                       displayName={item.displayName}
                       avatar={item.avatar}
diff --git a/src/view/shell/BottomBar.tsx b/src/view/shell/BottomBar.tsx
index bfbd7f0a2..e46eeb991 100644
--- a/src/view/shell/BottomBar.tsx
+++ b/src/view/shell/BottomBar.tsx
@@ -112,6 +112,7 @@ export const BottomBar = observer(({navigation}: BottomTabBarProps) => {
         footerMinimalShellTransform,
       ]}>
       <Btn
+        testID="bottomBarHomeBtn"
         icon={
           isAtHome ? (
             <HomeIconSolid
@@ -130,6 +131,7 @@ export const BottomBar = observer(({navigation}: BottomTabBarProps) => {
         onPress={onPressHome}
       />
       <Btn
+        testID="bottomBarSearchBtn"
         icon={
           isAtSearch ? (
             <MagnifyingGlassIcon2Solid
@@ -148,6 +150,7 @@ export const BottomBar = observer(({navigation}: BottomTabBarProps) => {
         onPress={onPressSearch}
       />
       <Btn
+        testID="bottomBarNotificationsBtn"
         icon={
           isAtNotifications ? (
             <BellIconSolid
@@ -167,6 +170,7 @@ export const BottomBar = observer(({navigation}: BottomTabBarProps) => {
         notificationCount={store.me.notifications.unreadCount}
       />
       <Btn
+        testID="bottomBarProfileBtn"
         icon={
           <View style={styles.ctrlIconSizingWrapper}>
             <UserIcon
@@ -183,11 +187,13 @@ export const BottomBar = observer(({navigation}: BottomTabBarProps) => {
 })
 
 function Btn({
+  testID,
   icon,
   notificationCount,
   onPress,
   onLongPress,
 }: {
+  testID?: string
   icon: JSX.Element
   notificationCount?: number
   onPress?: (event: GestureResponderEvent) => void
@@ -195,6 +201,7 @@ function Btn({
 }) {
   return (
     <TouchableOpacity
+      testID={testID}
       style={styles.ctrl}
       onPress={onLongPress ? onPress : undefined}
       onPressIn={onLongPress ? undefined : onPress}
diff --git a/src/view/shell/Drawer.tsx b/src/view/shell/Drawer.tsx
index a33cf8c4e..ccf64c0e6 100644
--- a/src/view/shell/Drawer.tsx
+++ b/src/view/shell/Drawer.tsx
@@ -162,7 +162,7 @@ export const DrawerContent = observer(() => {
 
   return (
     <View
-      testID="menuView"
+      testID="drawer"
       style={[
         styles.view,
         theme.colorScheme === 'light' ? pal.view : styles.viewDarkMode,
diff --git a/src/view/shell/index.tsx b/src/view/shell/index.tsx
index eec0f8ed4..84242c283 100644
--- a/src/view/shell/index.tsx
+++ b/src/view/shell/index.tsx
@@ -7,11 +7,9 @@ import {useNavigationState} from '@react-navigation/native'
 import {useStores} from 'state/index'
 import {ModalsContainer} from 'view/com/modals/Modal'
 import {Lightbox} from 'view/com/lightbox/Lightbox'
-import {Text} from 'view/com/util/text/Text'
 import {ErrorBoundary} from 'view/com/util/ErrorBoundary'
 import {DrawerContent} from './Drawer'
 import {Composer} from './Composer'
-import {s} from 'lib/styles'
 import {useTheme} from 'lib/ThemeContext'
 import {usePalette} from 'lib/hooks/usePalette'
 import {RoutesContainer, TabsNavigator} from '../../Navigation'
@@ -72,41 +70,6 @@ const ShellInner = observer(() => {
 export const Shell: React.FC = observer(() => {
   const theme = useTheme()
   const pal = usePalette('default')
-  const store = useStores()
-
-  if (store.hackUpgradeNeeded) {
-    return (
-      <View style={styles.outerContainer}>
-        <View style={[s.flexCol, s.p20, s.h100pct]}>
-          <View style={s.flex1} />
-          <View>
-            <Text type="title-2xl" style={s.pb10}>
-              Update required
-            </Text>
-            <Text style={[s.pb20, s.bold]}>
-              Please update your app to the latest version. If no update is
-              available yet, please check the App Store in a day or so.
-            </Text>
-            <Text type="title" style={s.pb10}>
-              What's happening?
-            </Text>
-            <Text style={s.pb10}>
-              We're in the final stages of the AT Protocol's v1 development. To
-              make sure everything works as well as possible, we're making final
-              breaking changes to the APIs.
-            </Text>
-            <Text>
-              If we didn't botch this process, a new version of the app should
-              be available now.
-            </Text>
-          </View>
-          <View style={s.flex1} />
-          <View style={s.footerSpacer} />
-        </View>
-      </View>
-    )
-  }
-
   return (
     <View testID="mobileShellView" style={[styles.outerContainer, pal.view]}>
       <StatusBar